@decentnetwork/lan 0.1.158 → 0.1.160

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.
Binary file
Binary file
Binary file
Binary file
@@ -66,6 +66,14 @@ export declare class PeerManager extends EventEmitter {
66
66
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
67
67
  * receive online. Use for native friends; JS friends keep sendFile(). */
68
68
  sendInlineFile(userid: string, data: Uint8Array, name: string): Promise<void>;
69
+ /**
70
+ * Send a WebRTC call-signaling payload to a friend over the Carrier
71
+ * friend-invite channel (the "carrier" extension), the exact transport the
72
+ * iOS/Android Beagle apps listen on for calls. `data` is an RtcSignal JSON
73
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime — requires a live
74
+ * session, no offline fallback.
75
+ */
76
+ sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
69
77
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
70
78
  * relay/DHT key differs from its identity key. JS peers announce their
71
79
  * identity key as their DHT key, so the two match. */
@@ -129,6 +129,18 @@ export class PeerManager extends EventEmitter {
129
129
  throw new Error("Peer not created. Call create() first.");
130
130
  await this.peer.sendInlineFile(userid, { name, data });
131
131
  }
132
+ /**
133
+ * Send a WebRTC call-signaling payload to a friend over the Carrier
134
+ * friend-invite channel (the "carrier" extension), the exact transport the
135
+ * iOS/Android Beagle apps listen on for calls. `data` is an RtcSignal JSON
136
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime — requires a live
137
+ * session, no offline fallback.
138
+ */
139
+ async sendCallSignal(userid, data) {
140
+ if (!this.peer)
141
+ throw new Error("Peer not created. Call create() first.");
142
+ await this.peer.sendInvite(userid, data, { ext: "carrier" });
143
+ }
132
144
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
133
145
  * relay/DHT key differs from its identity key. JS peers announce their
134
146
  * identity key as their DHT key, so the two match. */
@@ -534,6 +546,15 @@ export class PeerManager extends EventEmitter {
534
546
  // Inline files (iOS/C Carrier style: FileModel JSON over bulkmsg).
535
547
  // The SDK reassembles + decodes; forward to the daemon to save + log.
536
548
  this.peer.onInlineFile((f) => this.emit("inline-file", f));
549
+ // WebRTC call signaling (RtcSignal JSON) over the Carrier "carrier"
550
+ // friend-invite extension — the channel iOS/Android Beagle use for calls.
551
+ // Forward the raw payload to the daemon, which relays it to the browser UI
552
+ // (peer-webrtc CallEngine) over the low-latency call-signal poll.
553
+ this.peer.onInvite((evt) => {
554
+ if (evt.ext !== undefined && evt.ext !== "carrier")
555
+ return;
556
+ this.emit("call-signal", { pubkey: evt.pubkey, data: evt.data });
557
+ });
537
558
  // Toxcore-standard file transfer (native-compatible). Forward offers,
538
559
  // progress, and completions to the daemon, which auto-accepts and saves.
539
560
  this.peer.onFile((o) => this.emit("file-offer", o));
@@ -82,6 +82,15 @@ export interface IpcHandlers {
82
82
  fileCancel: (peer: string, ids: string[]) => Promise<Record<string, unknown>>;
83
83
  /** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
84
84
  chatMarkRead: (userid: string, ts?: number) => Promise<void>;
85
+ /** Send a WebRTC call-signaling payload (RtcSignal JSON) to a friend over the
86
+ * Carrier "carrier" friend-invite extension — the channel iOS/Android Beagle
87
+ * listen on for calls. Backs the desktop UI's audio/video calls. */
88
+ callSignal: (userid: string, data: string) => Promise<void>;
89
+ /** Long-poll for inbound call-signaling payloads. Resolves with any queued
90
+ * signals immediately, otherwise holds the connection until one arrives or a
91
+ * ~20s timeout elapses (then resolves empty). The UI re-polls to get
92
+ * near-instant offer/answer/candidate delivery without a WebSocket. */
93
+ callPoll: () => Promise<Record<string, unknown>>;
85
94
  /** Re-read proxy allowlist from config and apply it to the running
86
95
  * proxy WITHOUT restarting the daemon. Lets `agentnet proxy
87
96
  * allow-host` take effect instantly instead of forcing a daemon
@@ -104,7 +113,7 @@ export interface IpcHandlers {
104
113
  selfRestart: () => Promise<Record<string, unknown>>;
105
114
  }
106
115
  export interface IpcRequest {
107
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "chat-mark-read" | "subscribe" | "sign" | "proxy-reload" | "proxy-access" | "self-restart";
116
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
108
117
  address?: string;
109
118
  hello?: string;
110
119
  userid?: string;
@@ -118,6 +127,8 @@ export interface IpcRequest {
118
127
  ts?: number;
119
128
  enabled?: boolean;
120
129
  ids?: string[];
130
+ /** RtcSignal JSON payload for the "call-signal" op. */
131
+ data?: string;
121
132
  }
122
133
  export interface IpcResponseOk {
123
134
  ok: true;
@@ -241,6 +241,16 @@ export class IpcServer {
241
241
  throw new Error("text is required");
242
242
  return await this.handlers.sign(req.text);
243
243
  }
244
+ case "call-signal": {
245
+ if (!req.userid)
246
+ throw new Error("userid is required");
247
+ if (typeof req.data !== "string")
248
+ throw new Error("data is required");
249
+ await this.handlers.callSignal(req.userid, req.data);
250
+ return;
251
+ }
252
+ case "call-poll":
253
+ return await this.handlers.callPoll();
244
254
  case "proxy-reload":
245
255
  return await this.handlers.proxyReload();
246
256
  case "proxy-access":
@@ -44,6 +44,12 @@ export declare class DaemonServer {
44
44
  /** Outgoing file transfers in flight: fileId → the chat message tracking it,
45
45
  * so progress/complete/cancel events can patch its status + sent bytes. */
46
46
  private readonly activeSends;
47
+ /** Inbound WebRTC call-signaling payloads ({userid, data}) waiting to be
48
+ * drained by the UI's /api/call-poll long-poll. Bounded so a UI that never
49
+ * polls can't grow it unboundedly. */
50
+ private readonly callSignalQueue;
51
+ /** Resolvers for in-flight call-poll long-polls, woken when a signal lands. */
52
+ private callSignalWaiters;
47
53
  /** Directory holding queued (offline) outgoing file bytes: <configDir>/outbox.
48
54
  * One file per queued message, named by its msgId. Set in start(). */
49
55
  private outboxDir;
@@ -89,6 +89,12 @@ export class DaemonServer {
89
89
  /** Outgoing file transfers in flight: fileId → the chat message tracking it,
90
90
  * so progress/complete/cancel events can patch its status + sent bytes. */
91
91
  activeSends = new Map();
92
+ /** Inbound WebRTC call-signaling payloads ({userid, data}) waiting to be
93
+ * drained by the UI's /api/call-poll long-poll. Bounded so a UI that never
94
+ * polls can't grow it unboundedly. */
95
+ callSignalQueue = [];
96
+ /** Resolvers for in-flight call-poll long-polls, woken when a signal lands. */
97
+ callSignalWaiters = [];
92
98
  /** Directory holding queued (offline) outgoing file bytes: <configDir>/outbox.
93
99
  * One file per queued message, named by its msgId. Set in start(). */
94
100
  outboxDir = "";
@@ -529,6 +535,32 @@ export class DaemonServer {
529
535
  chatMarkRead: async (userid, ts) => {
530
536
  this.friendMeta?.markRead(userid, ts);
531
537
  },
538
+ callSignal: async (userid, data) => {
539
+ // Relay a WebRTC RtcSignal from the browser UI out to the friend over
540
+ // the Carrier "carrier" invite extension. Realtime — requires a live
541
+ // session (throws if the peer is unreachable, so the UI can surface it).
542
+ await this.peerManager.sendCallSignal(userid, data);
543
+ },
544
+ callPoll: async () => {
545
+ // Drain immediately if signals are queued; otherwise hold up to ~20s
546
+ // for one to arrive (near-instant delivery without a WebSocket). The
547
+ // IPC client's timeout is 30s, so a 20s hold is safe.
548
+ if (this.callSignalQueue.length === 0) {
549
+ await new Promise((resolve) => {
550
+ const timer = setTimeout(() => {
551
+ this.callSignalWaiters = this.callSignalWaiters.filter((w) => w !== wake);
552
+ resolve();
553
+ }, 20_000);
554
+ const wake = () => {
555
+ clearTimeout(timer);
556
+ resolve();
557
+ };
558
+ this.callSignalWaiters.push(wake);
559
+ });
560
+ }
561
+ const signals = this.callSignalQueue.splice(0, this.callSignalQueue.length);
562
+ return { signals };
563
+ },
532
564
  subscribe: (emit) => {
533
565
  const onEvent = (event) => emit(event);
534
566
  this.ipcEvents.on("event", onEvent);
@@ -931,6 +963,23 @@ export class DaemonServer {
931
963
  this.ipcEvents.emit("event", { type: "chat", userid: f.pubkey, dir: "in" });
932
964
  })().catch((e) => this.logger.warn(`Failed to save inline file: ${e.message}`));
933
965
  });
966
+ // WebRTC call signaling (RtcSignal JSON) inbound over the Carrier
967
+ // "carrier" invite extension. Queue it for the UI's /api/call-poll and
968
+ // wake any pending long-poll for near-instant delivery. The payload is
969
+ // JSON text (native C senders NUL-terminate it — strip that here).
970
+ this.peerManager.on("call-signal", (evt) => {
971
+ const text = Buffer.from(evt.data).toString("utf-8").replace(/\0+$/u, "").trim();
972
+ if (!text)
973
+ return;
974
+ // Cap the backlog so a UI that stopped polling can't grow it forever.
975
+ if (this.callSignalQueue.length > 256)
976
+ this.callSignalQueue.shift();
977
+ this.callSignalQueue.push({ userid: evt.pubkey, data: text });
978
+ const waiters = this.callSignalWaiters;
979
+ this.callSignalWaiters = [];
980
+ for (const wake of waiters)
981
+ wake();
982
+ });
934
983
  this.peerManager.on("friend-request", (req) => {
935
984
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
936
985
  const who = `${req.name || "(unnamed)"} ${userid}`;
@@ -1061,7 +1061,7 @@ export function startMultiExitRouter(opts) {
1061
1061
  // Only enabled with hlsAccel + a configDir for the CA. We MITM a host only
1062
1062
  // when it's routed to a region (China) AND its name looks like a video CDN —
1063
1063
  // never generic HTTPS (banking, mail, APIs), which stays an opaque tunnel.
1064
- const VIDEO_HOST_RE = /(cctv|cntv|live|vod|hls|video|stream|liveplay|bdydns|myalicdn|volcfcdn|myqcloud|gslb|byteimg|volcfcdn|bilivideo|hdslb|ivideo|tvyun)/;
1064
+ const VIDEO_HOST_RE = /(cctv|cntv|live|vod|hls|video|stream|liveplay|bdydns|wscdns|kcdnvip|ks-cdn|ksyuncdn|myalicdn|volcfcdn|myqcloud|gslb|byteimg|volcfcdn|bilivideo|hdslb|ivideo|tvyun|piccpnd)/;
1065
1065
  let mitm = null;
1066
1066
  if (opts.hlsAccel && opts.configDir) {
1067
1067
  mitm = new MitmGateway({
@@ -1241,7 +1241,7 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, selMode, selected, onT
1241
1241
  ), mine && m.status === "queued" ? /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: 2 }, title: "waiting for peer \u2014 will send when they're online" }, /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--faint)" }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, T.queued || "queued")) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
1242
1242
  );
1243
1243
  }
1244
- function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
1244
+ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall }) {
1245
1245
  const scrollRef = React.useRef(null);
1246
1246
  const fileRef = React.useRef(null);
1247
1247
  const thread = threadProp && threadProp.length ? threadProp : [{ day: "Today" }, { from: "them", time: "\u2014", text: lang === "zh" ? "\u6682\u65E0\u6D88\u606F\u8BB0\u5F55\uFF0C\u53D1\u4E2A\u6D88\u606F\u6253\u4E2A\u62DB\u547C\u5427\u3002" : "No messages yet. Say hi." }];
@@ -1331,7 +1331,7 @@ function Conversation({ T, peer, lang, thread: threadProp, onSend, onSendFile, o
1331
1331
  onDrop
1332
1332
  },
1333
1333
  dragOver && /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", inset: 0, zIndex: 30, background: "rgba(91,140,255,0.10)", border: "2px dashed var(--accent)", borderRadius: 12, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", fontFamily: "var(--mono)", fontSize: 14, color: "var(--accent)" } }, lang === "zh" ? "\u677E\u5F00\u53D1\u9001\u6587\u4EF6" : "Drop to send file"),
1334
- /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1334
+ /* @__PURE__ */ React.createElement("div", { style: { height: 60, flexShrink: 0, borderBottom: "1px solid var(--line)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px", background: "var(--panel)" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer, size: 34, radius: 8 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: peer.alias ? "var(--ui)" : "var(--mono)", fontSize: 15, fontWeight: 700, color: "var(--text)" } }, peer.alias || shortKey(peer.userId, 10, 6)), peer.agent && /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, "agent"), /* @__PURE__ */ React.createElement(RouteTag, { peer })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Mono, { size: 11.5, dim: true, copy: peer.userId, title: peer.userId }, shortKey(peer.userId, 10, 6)), /* @__PURE__ */ React.createElement("span", { style: { color: "var(--line)" } }, "\xB7"), /* @__PURE__ */ React.createElement("button", { onClick: () => onOpenNet(peer), style: { background: "none", border: "none", cursor: "pointer", padding: 0, fontFamily: "var(--mono)", fontSize: 11.5, color: "var(--accent)", display: "inline-flex", alignItems: "center", gap: 4 } }, /* @__PURE__ */ React.createElement(Icon, { name: "network", size: 12, stroke: 2 }), " ", peer.ip))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "phone", title: T && T.audioCall || "Audio call", onClick: () => onCall(peer.userId, false) }), onCall && /* @__PURE__ */ React.createElement(Btn, { icon: "video", title: T && T.videoCall || "Video call", onClick: () => onCall(peer.userId, true) }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(Btn, { icon: "more", onClick: () => setMenu((v) => !v) }), menu && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { onClick: () => setMenu(false), style: { position: "fixed", inset: 0, zIndex: 40 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 0, top: 36, zIndex: 50, width: 180, background: "var(--panel-2)", border: "1px solid var(--line)", borderRadius: 9, padding: 6, boxShadow: "0 14px 40px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement(MenuItem, { icon: "hash", label: T.alias, onClick: () => {
1335
1335
  setMenu(false);
1336
1336
  onAlias(peer);
1337
1337
  } }), /* @__PURE__ */ React.createElement(MenuItem, { icon: "check", label: T.selectDelete || "Select & delete", onClick: () => {
@@ -1455,9 +1455,9 @@ function MenuItem({ icon, label, onClick, danger }) {
1455
1455
  function ChatEmpty({ T }) {
1456
1456
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--faint)", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "message", size: 40, stroke: 1.4, color: "var(--line)" }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13 } }, T.pickPeer));
1457
1457
  }
1458
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }) {
1458
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall }) {
1459
1459
  const peer = peers.find((p) => p.id === activeId);
1460
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onSendFile, onAlias, onRemove, onOpenNet }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1460
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, thread, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
1461
1461
  }
1462
1462
  Object.assign(window, { ChatTab });
1463
1463
  function StatTile({ label, value, sub, tone }) {
@@ -1540,6 +1540,209 @@ function ProfileTab({ T, me, onEdit }) {
1540
1540
  } }));
1541
1541
  }
1542
1542
  Object.assign(window, { ProfileTab });
1543
+ function makeDaemonSignaling() {
1544
+ const PW = window.PeerWebRTC;
1545
+ let handler = null;
1546
+ let stopped = false;
1547
+ async function pollLoop() {
1548
+ while (!stopped) {
1549
+ let signals = [];
1550
+ try {
1551
+ const r = await fetch("/api/call-poll", { headers: { "cache-control": "no-cache" } });
1552
+ const d = await r.json();
1553
+ signals = d && d.signals || [];
1554
+ } catch (e) {
1555
+ await new Promise((res) => setTimeout(res, 1e3));
1556
+ continue;
1557
+ }
1558
+ for (const s of signals) {
1559
+ try {
1560
+ const signal = PW.decodeSignal(s.data);
1561
+ handler && handler(s.userid, signal);
1562
+ } catch (e) {
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+ return {
1568
+ async send(peerId, signal) {
1569
+ const body = JSON.stringify({ userid: peerId, data: PW.encodeSignal(signal) });
1570
+ const r = await fetch("/api/call-signal", {
1571
+ method: "POST",
1572
+ headers: { "content-type": "application/json" },
1573
+ body
1574
+ });
1575
+ const d = await r.json().catch(() => ({ ok: r.ok }));
1576
+ if (!d.ok) throw new Error(d.error || "call-signal send failed");
1577
+ },
1578
+ onSignal(cb) {
1579
+ handler = cb;
1580
+ if (stopped === false && !this._started) {
1581
+ this._started = true;
1582
+ pollLoop();
1583
+ }
1584
+ },
1585
+ stop() {
1586
+ stopped = true;
1587
+ }
1588
+ };
1589
+ }
1590
+ const CALL_ICE_SERVERS = [
1591
+ { urls: "stun:stun.l.google.com:19302" },
1592
+ { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" }
1593
+ ];
1594
+ function useCallController(selfId) {
1595
+ const [incoming, setIncoming] = React.useState(null);
1596
+ const [active, setActive] = React.useState(null);
1597
+ const [localStream, setLocalStream] = React.useState(null);
1598
+ const [remoteStream, setRemoteStream] = React.useState(null);
1599
+ const engineRef = React.useRef(null);
1600
+ React.useEffect(() => {
1601
+ if (!selfId || engineRef.current) return;
1602
+ if (!window.PeerWebRTC || !window.RTCPeerConnection) {
1603
+ console.warn("[call] WebRTC or peer-webrtc unavailable \u2014 calls disabled");
1604
+ return;
1605
+ }
1606
+ const { CallEngine } = window.PeerWebRTC;
1607
+ const signaling = makeDaemonSignaling();
1608
+ const engine = new CallEngine({
1609
+ selfId,
1610
+ signaling,
1611
+ createPeerConnection: (c) => new RTCPeerConnection(c),
1612
+ getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
1613
+ iceServers: CALL_ICE_SERVERS,
1614
+ logger: (m) => console.log(m)
1615
+ });
1616
+ engine.on("incomingCall", (info) => setIncoming(info));
1617
+ engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
1618
+ engine.on("localStream", (id, s) => setLocalStream(s));
1619
+ engine.on("remoteStream", (id, s) => setRemoteStream(s));
1620
+ engine.on("ended", (id) => {
1621
+ setActive((a) => a && a.callId === id ? null : a);
1622
+ setIncoming((i) => i && i.callId === id ? null : i);
1623
+ setLocalStream(null);
1624
+ setRemoteStream(null);
1625
+ });
1626
+ engineRef.current = engine;
1627
+ return () => {
1628
+ try {
1629
+ signaling.stop();
1630
+ engine.dispose();
1631
+ } catch (e) {
1632
+ }
1633
+ engineRef.current = null;
1634
+ };
1635
+ }, [selfId]);
1636
+ const start = React.useCallback(async (peerId, video) => {
1637
+ const eng = engineRef.current;
1638
+ if (!eng) {
1639
+ alert("Calls unavailable (WebRTC not supported here)");
1640
+ return;
1641
+ }
1642
+ try {
1643
+ const callId = await eng.call(peerId, { audio: true, video: !!video });
1644
+ setActive({ callId, peerId, video: !!video, direction: "outgoing", state: "ringing" });
1645
+ } catch (e) {
1646
+ alert("Could not start call: " + (e && e.message || e));
1647
+ }
1648
+ }, []);
1649
+ const accept = React.useCallback(async () => {
1650
+ const eng = engineRef.current;
1651
+ setIncoming((inc) => {
1652
+ if (eng && inc) {
1653
+ setActive({ callId: inc.callId, peerId: inc.peerId, video: inc.video, direction: "incoming", state: "connecting" });
1654
+ eng.accept(inc.callId).catch((e) => alert("Accept failed: " + (e && e.message || e)));
1655
+ }
1656
+ return null;
1657
+ });
1658
+ }, []);
1659
+ const reject = React.useCallback(() => {
1660
+ const eng = engineRef.current;
1661
+ setIncoming((inc) => {
1662
+ if (eng && inc) eng.reject(inc.callId);
1663
+ return null;
1664
+ });
1665
+ }, []);
1666
+ const hangup = React.useCallback(() => {
1667
+ const eng = engineRef.current;
1668
+ setActive((a) => {
1669
+ if (eng && a) eng.hangup(a.callId);
1670
+ return null;
1671
+ });
1672
+ setLocalStream(null);
1673
+ setRemoteStream(null);
1674
+ }, []);
1675
+ const setMuted = React.useCallback((m) => {
1676
+ const eng = engineRef.current, a = active;
1677
+ if (eng && a) eng.setLocalTrackEnabled(a.callId, "audio", !m);
1678
+ }, [active]);
1679
+ const setVideoOn = React.useCallback((v) => {
1680
+ const eng = engineRef.current, a = active;
1681
+ if (eng && a) eng.setLocalTrackEnabled(a.callId, "video", v);
1682
+ }, [active]);
1683
+ return { incoming, active, localStream, remoteStream, start, accept, reject, hangup, setMuted, setVideoOn };
1684
+ }
1685
+ function VideoSurface({ stream, muted, style }) {
1686
+ const ref = React.useCallback((el) => {
1687
+ if (el && el.srcObject !== (stream || null)) el.srcObject = stream || null;
1688
+ }, [stream]);
1689
+ return /* @__PURE__ */ React.createElement("video", { ref, autoPlay: true, playsInline: true, muted: !!muted, style });
1690
+ }
1691
+ function IncomingCallModal({ T, ctl, peers }) {
1692
+ const inc = ctl.incoming;
1693
+ if (!inc) return null;
1694
+ const peer = (peers || []).find((p) => p.id === inc.peerId) || { id: inc.peerId };
1695
+ const name = peer.alias || peer.name || shortKey(inc.peerId, 10, 6);
1696
+ return /* @__PURE__ */ React.createElement("div", { style: { position: "fixed", inset: 0, zIndex: 80, background: "color-mix(in oklab, var(--bg), transparent 15%)", display: "flex", alignItems: "center", justifyContent: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 320, padding: 26, borderRadius: 18, background: "var(--panel)", border: "1px solid var(--line)", textAlign: "center", boxShadow: "0 20px 60px rgba(0,0,0,0.4)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", marginBottom: 14 } }, /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", width: 96, height: 96, borderRadius: 999, border: "2px solid var(--accent)", animation: "dkpulse 2s ease-out infinite" } }), /* @__PURE__ */ React.createElement(DkIdenticon, { seed: inc.peerId, size: 72, radius: 18 })), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 17, fontWeight: 700, color: "var(--text)" } }, name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--faint)", marginTop: 4, display: "flex", gap: 6, alignItems: "center", justifyContent: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: inc.video ? "video" : "phone", size: 14, stroke: 2 }), T && (inc.video ? T.incomingVideo : T.incomingAudio) || "Incoming " + (inc.video ? "video" : "audio") + " call"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 14, justifyContent: "center", marginTop: 22 } }, /* @__PURE__ */ React.createElement(CallBtn, { icon: "phone", label: T && T.decline || "Decline", danger: true, onClick: ctl.reject }), /* @__PURE__ */ React.createElement(CallBtn, { icon: inc.video ? "video" : "phone", label: T && T.accept || "Accept", active: true, onClick: ctl.accept }))));
1697
+ }
1698
+ function CallBtn({ icon, label, onClick, active, danger, wide }) {
1699
+ const bg = danger ? "var(--danger)" : active ? "var(--accent)" : "var(--chip)";
1700
+ const fg = danger || active ? "#fff" : "var(--text)";
1701
+ return /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement("button", { onClick, style: {
1702
+ width: wide ? 76 : 56,
1703
+ height: 56,
1704
+ borderRadius: wide ? 18 : 999,
1705
+ cursor: "pointer",
1706
+ background: bg,
1707
+ color: fg,
1708
+ border: "1px solid " + (active || danger ? "transparent" : "var(--line)"),
1709
+ display: "flex",
1710
+ alignItems: "center",
1711
+ justifyContent: "center"
1712
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: icon, size: 22, stroke: 2 })), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, label));
1713
+ }
1714
+ function CallOverlay({ T, ctl, peers }) {
1715
+ const call = ctl.active;
1716
+ const [secs, setSecs] = React.useState(0);
1717
+ const [muted, setMuted] = React.useState(false);
1718
+ const connected = call && call.state === "connected";
1719
+ React.useEffect(() => {
1720
+ if (!connected) return;
1721
+ const id = setInterval(() => setSecs((s) => s + 1), 1e3);
1722
+ return () => clearInterval(id);
1723
+ }, [connected]);
1724
+ React.useEffect(() => {
1725
+ if (!call) {
1726
+ setSecs(0);
1727
+ setMuted(false);
1728
+ }
1729
+ }, [call]);
1730
+ if (!call) return null;
1731
+ const peer = (peers || []).find((p) => p.id === call.peerId) || { id: call.peerId };
1732
+ const name = peer.alias || peer.name || shortKey(call.peerId, 10, 6);
1733
+ const video = call.video;
1734
+ const mm = String(Math.floor(secs / 60)).padStart(2, "0");
1735
+ const ss = String(secs % 60).padStart(2, "0");
1736
+ const stateLabel = connected ? mm + ":" + ss : call.state === "ringing" ? T && T.ringing || "ringing\u2026" : call.state === "connecting" ? T && T.connecting || "connecting\u2026" : call.state;
1737
+ const toggleMute = () => {
1738
+ const n = !muted;
1739
+ setMuted(n);
1740
+ ctl.setMuted(n);
1741
+ };
1742
+ const controls = /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 18, alignItems: "flex-start" } }, /* @__PURE__ */ React.createElement(CallBtn, { icon: muted ? "micFill" : "mic", label: muted ? T && T.unmute || "unmute" : T && T.mute || "mute", active: muted, onClick: toggleMute }), /* @__PURE__ */ React.createElement(CallBtn, { icon: "phone", label: T && T.endCall || "end", danger: true, wide: true, onClick: ctl.hangup }));
1743
+ return /* @__PURE__ */ React.createElement("div", { style: { position: "fixed", inset: 0, zIndex: 70, background: "var(--bg)", display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 52, flexShrink: 0, display: "flex", alignItems: "center", gap: 10, padding: "0 18px", borderBottom: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement(Icon, { name: video ? "video" : "phone", size: 16, color: "var(--accent)", stroke: 2 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700, color: "var(--text)" } }, name), /* @__PURE__ */ React.createElement(Tag, { tone: connected ? "ok" : "warn" }, stateLabel), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), connected && /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, color: "var(--text)", letterSpacing: 1 } }, mm, ":", ss)), video ? /* @__PURE__ */ React.createElement("div", { style: { flex: 1, position: "relative", padding: 18 } }, /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", inset: 18, borderRadius: 14, overflow: "hidden", background: "#000", border: "1px solid var(--line)" } }, ctl.remoteStream ? /* @__PURE__ */ React.createElement(VideoSurface, { stream: ctl.remoteStream, style: { width: "100%", height: "100%", objectFit: "cover" } }) : /* @__PURE__ */ React.createElement("div", { style: { width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" } }, /* @__PURE__ */ React.createElement(DkIdenticon, { seed: call.peerId, size: 92, radius: 22 }))), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", right: 30, bottom: 30, width: 220, height: 148, borderRadius: 12, overflow: "hidden", background: "#000", border: "1px solid var(--line)" } }, ctl.localStream ? /* @__PURE__ */ React.createElement(VideoSurface, { stream: ctl.localStream, muted: true, style: { width: "100%", height: "100%", objectFit: "cover", transform: "scaleX(-1)" } }) : /* @__PURE__ */ React.createElement("div", { style: { width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" } }, /* @__PURE__ */ React.createElement(Icon, { name: "userRound", size: 30, color: "var(--faint)", stroke: 1.4 }))), /* @__PURE__ */ React.createElement("div", { style: { position: "absolute", left: 0, right: 0, bottom: 30, display: "flex", justifyContent: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { padding: "16px 26px", borderRadius: 20, background: "color-mix(in oklab, var(--panel), transparent 8%)", border: "1px solid var(--line)" } }, controls))) : /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 22 } }, ctl.remoteStream && /* @__PURE__ */ React.createElement(VideoSurface, { stream: ctl.remoteStream, style: { display: "none" } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative", display: "flex", alignItems: "center", justifyContent: "center" } }, !connected && /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", width: 150, height: 150, borderRadius: 999, border: "2px solid var(--accent)", animation: "dkpulse 2s ease-out infinite" } }), /* @__PURE__ */ React.createElement(DkIdenticon, { seed: call.peerId, size: 108, radius: 26 })), /* @__PURE__ */ React.createElement("div", { style: { textAlign: "center" } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, color: "var(--text)" } }, name), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 8, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--faint)" } }, stateLabel)), /* @__PURE__ */ React.createElement("div", { style: { marginTop: 18 } }, controls)));
1744
+ }
1745
+ Object.assign(window, { useCallController, CallOverlay, IncomingCallModal });
1543
1746
  const DK_DEFAULTS = (
1544
1747
  /*EDITMODE-BEGIN*/
1545
1748
  {
@@ -1781,6 +1984,8 @@ function DkApp() {
1781
1984
  const T = STR[t.lang] || STR.en;
1782
1985
  const vars = dkTheme(t.theme, t.accent);
1783
1986
  const rowPad = t.density === "comfortable" ? "11px 12px" : "7px 10px";
1987
+ const callCtl = useCallController(me.userId);
1988
+ const onCall = (peerId, video) => callCtl.start(peerId, !!video);
1784
1989
  React.useEffect(() => {
1785
1990
  if (!activeId && peers.length) setActiveId(peers[0].id);
1786
1991
  }, [peers, activeId]);
@@ -1840,7 +2045,7 @@ function DkApp() {
1840
2045
  { id: "network", icon: "network", label: T.network },
1841
2046
  { id: "profile", icon: "userRound", label: T.profile }
1842
2047
  ];
1843
- return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
2048
+ return /* @__PURE__ */ React.createElement("div", { style: { ...vars, "--row-pad": rowPad, position: "fixed", inset: 0, display: "flex", background: "var(--bg)", color: "var(--text)", fontFamily: "var(--ui)" } }, /* @__PURE__ */ React.createElement("div", { style: { width: 68, flexShrink: 0, borderRight: "1px solid var(--line)", background: "var(--rail)", display: "flex", flexDirection: "column", alignItems: "center", padding: "14px 0", gap: 8 } }, /* @__PURE__ */ React.createElement("div", { style: { width: 38, height: 38, borderRadius: 10, background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", marginBottom: 8 } }, /* @__PURE__ */ React.createElement(Icon, { name: "terminal", size: 20, color: "#fff", stroke: 2.2 })), nav.map((n) => /* @__PURE__ */ React.createElement(RailBtn, { key: n.id, icon: n.icon, label: n.label, active: tab === n.id, soon: n.soon, onClick: () => setTab(n.id) })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 36, radius: 9 }))), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column" } }, /* @__PURE__ */ React.createElement("div", { style: { height: 46, flexShrink: 0, borderBottom: "1px solid var(--line)", background: "var(--panel)", display: "flex", alignItems: "center", gap: 12, padding: "0 16px" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 14, fontWeight: 700, letterSpacing: -0.3, color: "var(--text)" } }, "beagle"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12, color: "var(--faint)" } }, "\xB7 ", nav.find((n) => n.id === tab).label.toLowerCase()), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Tag, { tone: "accent" }, me.channel, " \xB7 lan ", me.lanVer), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 7, padding: "0 4px" } }, /* @__PURE__ */ React.createElement(StatusDot, { online: me.online }), /* @__PURE__ */ React.createElement(Mono, { size: 12.5, copy: me.ip }, me.ip)), /* @__PURE__ */ React.createElement("span", { style: { width: 1, height: 22, background: "var(--line)" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkAvatar, { peer: { ...me, id: me.userId, agent: false }, size: 26, radius: 7 }), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: "var(--text)" } }, me.name))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, peers, requests, activeId, thread: data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onAlias, onRemove, onOpenNet, onCall }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit })), /* @__PURE__ */ React.createElement(TweaksPanel, null, /* @__PURE__ */ React.createElement(TweakSection, { label: t.lang === "zh" ? "\u5916\u89C2" : "Appearance" }), /* @__PURE__ */ React.createElement(
1844
2049
  TweakRadio,
1845
2050
  {
1846
2051
  label: t.lang === "zh" ? "\u4E3B\u9898" : "Theme",
@@ -1883,6 +2088,6 @@ function DkApp() {
1883
2088
  color: "#fff",
1884
2089
  fontSize: 12,
1885
2090
  fontWeight: 600
1886
- } }, n.label)))));
2091
+ } }, n.label)))), /* @__PURE__ */ React.createElement(IncomingCallModal, { T, ctl: callCtl, peers }), /* @__PURE__ */ React.createElement(CallOverlay, { T, ctl: callCtl, peers }));
1887
2092
  }
1888
2093
  ReactDOM.createRoot(document.getElementById("root")).render(/* @__PURE__ */ React.createElement(DkApp, null));
@@ -32,6 +32,8 @@
32
32
  <script src="vendor/react-dom.production.min.js"></script>
33
33
  <!-- QR encoder (Kazuhiko Arase, MIT) — exposes window.qrcode for the QR-code button. -->
34
34
  <script src="vendor/qrcode.js"></script>
35
+ <!-- peer-webrtc call engine (bundled IIFE) — exposes window.PeerWebRTC. -->
36
+ <script src="vendor/peer-webrtc.js"></script>
35
37
  <!-- The whole desktop app, esbuild-transpiled from src/ui/desktop/*.jsx. -->
36
38
  <script src="app.js"></script>
37
39
  </body>
@@ -0,0 +1,626 @@
1
+ var PeerWebRTC = (() => {
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __typeError = (msg) => {
7
+ throw TypeError(msg);
8
+ };
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
22
+ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
23
+ var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
24
+ var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
25
+ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
26
+ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
27
+
28
+ // node_modules/@decentnetwork/peer-webrtc/dist/index.js
29
+ var dist_exports = {};
30
+ __export(dist_exports, {
31
+ BroadcastChannelSignaling: () => BroadcastChannelSignaling,
32
+ CallEngine: () => CallEngine,
33
+ CarrierSignaling: () => CarrierSignaling,
34
+ Emitter: () => Emitter,
35
+ decodeSignal: () => decodeSignal,
36
+ decodeSignalBytesGuard: () => decodeSignalBytesGuard,
37
+ encodeSignal: () => encodeSignal,
38
+ encodeSignalBytes: () => encodeSignalBytes,
39
+ looksLikeSignal: () => looksLikeSignal
40
+ });
41
+
42
+ // node_modules/@decentnetwork/peer-webrtc/dist/emitter.js
43
+ var _handlers;
44
+ var Emitter = class {
45
+ constructor() {
46
+ __privateAdd(this, _handlers, {});
47
+ }
48
+ on(event, handler) {
49
+ var _a, _b;
50
+ ((_b = (_a = __privateGet(this, _handlers))[event]) != null ? _b : _a[event] = /* @__PURE__ */ new Set()).add(handler);
51
+ return () => this.off(event, handler);
52
+ }
53
+ off(event, handler) {
54
+ var _a;
55
+ (_a = __privateGet(this, _handlers)[event]) == null ? void 0 : _a.delete(handler);
56
+ }
57
+ once(event, handler) {
58
+ const wrapped = (...args) => {
59
+ this.off(event, wrapped);
60
+ handler(...args);
61
+ };
62
+ return this.on(event, wrapped);
63
+ }
64
+ emit(event, ...args) {
65
+ const set = __privateGet(this, _handlers)[event];
66
+ if (!set)
67
+ return;
68
+ for (const handler of [...set]) {
69
+ handler(...args);
70
+ }
71
+ }
72
+ removeAll() {
73
+ __privateSet(this, _handlers, {});
74
+ }
75
+ };
76
+ _handlers = new WeakMap();
77
+
78
+ // node_modules/@decentnetwork/peer-webrtc/dist/signal.js
79
+ var SDP_TYPES = /* @__PURE__ */ new Set([
80
+ "offer",
81
+ "answer",
82
+ "candidate",
83
+ "remove-candidates",
84
+ "prAnswer",
85
+ "bye",
86
+ "action",
87
+ "event"
88
+ ]);
89
+ function encodeSignal(signal) {
90
+ const out = { type: signal.type };
91
+ if (signal.sdp !== void 0)
92
+ out.sdp = signal.sdp;
93
+ if (signal.candidates !== void 0) {
94
+ out.candidates = signal.candidates.map((c) => {
95
+ var _a;
96
+ const o = { sdp: c.sdp, sdpMLineIndex: c.sdpMLineIndex };
97
+ o.sdpMid = (_a = c.sdpMid) != null ? _a : null;
98
+ return o;
99
+ });
100
+ }
101
+ if (signal.reason !== void 0)
102
+ out.reason = signal.reason;
103
+ if (signal.options !== void 0)
104
+ out.options = signal.options;
105
+ if (signal.action !== void 0)
106
+ out.action = signal.action;
107
+ out.callId = signal.callId;
108
+ if (signal.event !== void 0)
109
+ out.event = signal.event;
110
+ return JSON.stringify(out);
111
+ }
112
+ function encodeSignalBytes(signal) {
113
+ return new TextEncoder().encode(encodeSignal(signal));
114
+ }
115
+ function decodeSignal(input) {
116
+ const text = (typeof input === "string" ? input : new TextDecoder().decode(input)).replace(/\0+$/u, "").trim();
117
+ const obj = JSON.parse(text);
118
+ if (typeof obj.type !== "string" || !SDP_TYPES.has(obj.type)) {
119
+ throw new Error(`RtcSignal: invalid or missing "type" (${String(obj.type)})`);
120
+ }
121
+ if (typeof obj.callId !== "string" || obj.callId.length === 0) {
122
+ throw new Error('RtcSignal: missing "callId"');
123
+ }
124
+ const signal = { type: obj.type, callId: obj.callId };
125
+ if (typeof obj.sdp === "string")
126
+ signal.sdp = obj.sdp;
127
+ if (Array.isArray(obj.candidates)) {
128
+ signal.candidates = obj.candidates.filter((c) => c && typeof c.sdp === "string").map((c) => ({
129
+ sdp: c.sdp,
130
+ sdpMLineIndex: typeof c.sdpMLineIndex === "number" ? c.sdpMLineIndex : 0,
131
+ sdpMid: typeof c.sdpMid === "string" ? c.sdpMid : null
132
+ }));
133
+ }
134
+ if (typeof obj.reason === "string")
135
+ signal.reason = obj.reason;
136
+ if (Array.isArray(obj.options)) {
137
+ signal.options = obj.options.filter((o) => o === "audio" || o === "video" || o === "data");
138
+ }
139
+ if (obj.action === "accept" || obj.action === "reject")
140
+ signal.action = obj.action;
141
+ if (typeof obj.event === "string")
142
+ signal.event = obj.event;
143
+ return signal;
144
+ }
145
+ function looksLikeSignal(input) {
146
+ const text = (typeof input === "string" ? input : new TextDecoder().decode(input)).replace(/\0+$/u, "").trimStart();
147
+ return text.startsWith("{") && text.includes('"callId"') && text.includes('"type"');
148
+ }
149
+
150
+ // node_modules/@decentnetwork/peer-webrtc/dist/call-engine.js
151
+ var DEFAULT_ICE_SERVERS = [
152
+ { urls: "stun:stun.l.google.com:19302" }
153
+ ];
154
+ var _opts, _iceServers, _sessions, _CallEngine_instances, handleSignal_fn, onOffer_fn, onAnswer_fn, onCandidate_fn, onBye_fn, onAuxiliary_fn, createSession_fn, wirePeerConnection_fn, ensureRemoteStream_fn, attachLocalMedia_fn, flushLocalCandidates_fn, drainRemoteCandidates_fn, send_fn, sendByeSafe_fn, cleanup_fn, setState_fn, _toInfo, newCallId_fn, log_fn;
155
+ var CallEngine = class extends Emitter {
156
+ constructor(opts) {
157
+ var _a;
158
+ super();
159
+ __privateAdd(this, _CallEngine_instances);
160
+ __privateAdd(this, _opts);
161
+ __privateAdd(this, _iceServers);
162
+ __privateAdd(this, _sessions, /* @__PURE__ */ new Map());
163
+ __privateAdd(this, _toInfo, (session) => ({
164
+ callId: session.callId,
165
+ peerId: session.peerId,
166
+ direction: session.direction,
167
+ audio: session.audio,
168
+ video: session.video,
169
+ data: session.data,
170
+ state: session.state
171
+ }));
172
+ __privateSet(this, _opts, opts);
173
+ __privateSet(this, _iceServers, (_a = opts.iceServers) != null ? _a : DEFAULT_ICE_SERVERS);
174
+ opts.signaling.onSignal((peerId, signal) => {
175
+ try {
176
+ __privateMethod(this, _CallEngine_instances, handleSignal_fn).call(this, peerId, signal);
177
+ } catch (err) {
178
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `handleSignal error: ${err.message}`);
179
+ }
180
+ });
181
+ }
182
+ /** Calls currently tracked by the engine. */
183
+ get calls() {
184
+ return [...__privateGet(this, _sessions).values()].map(__privateGet(this, _toInfo));
185
+ }
186
+ /** True while any call is active (useful for busy-signalling). */
187
+ get isBusy() {
188
+ for (const s of __privateGet(this, _sessions).values()) {
189
+ if (!s.closed && s.state !== "ended" && s.state !== "failed")
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+ /**
195
+ * Place an outgoing call. Acquires local media, sends an offer, and returns
196
+ * the new callId. Emits "localStream" once media is up, "stateChanged" and
197
+ * "remoteStream"/"ended" as the call progresses.
198
+ */
199
+ async call(peerId, kinds = {}) {
200
+ var _a, _b, _c;
201
+ const audio = (_a = kinds.audio) != null ? _a : true;
202
+ const video = (_b = kinds.video) != null ? _b : false;
203
+ const data = (_c = kinds.data) != null ? _c : false;
204
+ const callId = __privateMethod(this, _CallEngine_instances, newCallId_fn).call(this);
205
+ const session = __privateMethod(this, _CallEngine_instances, createSession_fn).call(this, callId, peerId, "outgoing", { audio, video, data });
206
+ await __privateMethod(this, _CallEngine_instances, attachLocalMedia_fn).call(this, session);
207
+ const offer = await session.pc.createOffer({
208
+ offerToReceiveAudio: audio,
209
+ offerToReceiveVideo: video
210
+ });
211
+ await session.pc.setLocalDescription(offer);
212
+ const options = [];
213
+ if (audio)
214
+ options.push("audio");
215
+ if (video)
216
+ options.push("video");
217
+ if (data)
218
+ options.push("data");
219
+ await __privateMethod(this, _CallEngine_instances, send_fn).call(this, peerId, {
220
+ type: "offer",
221
+ sdp: offer.sdp,
222
+ options,
223
+ callId,
224
+ event: void 0
225
+ });
226
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "ringing");
227
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `call \u2192 ${peerId} callId=${callId} audio=${audio} video=${video}`);
228
+ return callId;
229
+ }
230
+ /**
231
+ * Accept an incoming call (one that surfaced via "incomingCall"). Acquires
232
+ * local media, applies the stored offer, and sends the answer.
233
+ */
234
+ async accept(callId) {
235
+ const session = __privateGet(this, _sessions).get(callId);
236
+ if (!session || session.direction !== "incoming" || !session.pendingOffer) {
237
+ throw new Error(`accept: no pending incoming call ${callId}`);
238
+ }
239
+ await __privateMethod(this, _CallEngine_instances, attachLocalMedia_fn).call(this, session);
240
+ await session.pc.setRemoteDescription({ type: "offer", sdp: session.pendingOffer });
241
+ session.hasReceivedSdp = true;
242
+ session.pendingOffer = void 0;
243
+ await __privateMethod(this, _CallEngine_instances, drainRemoteCandidates_fn).call(this, session);
244
+ const answer = await session.pc.createAnswer();
245
+ await session.pc.setLocalDescription(answer);
246
+ await __privateMethod(this, _CallEngine_instances, send_fn).call(this, session.peerId, { type: "answer", sdp: answer.sdp, callId });
247
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "connecting");
248
+ await __privateMethod(this, _CallEngine_instances, flushLocalCandidates_fn).call(this, session);
249
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `accept callId=${callId}`);
250
+ }
251
+ /** Reject an incoming call (sends bye/declined). */
252
+ async reject(callId) {
253
+ const session = __privateGet(this, _sessions).get(callId);
254
+ if (!session)
255
+ return;
256
+ await __privateMethod(this, _CallEngine_instances, sendByeSafe_fn).call(this, session, "declined");
257
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "declined");
258
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, "declined");
259
+ }
260
+ /** Hang up an active call (sends bye/normal). */
261
+ async hangup(callId, reason = "normal") {
262
+ const session = __privateGet(this, _sessions).get(callId);
263
+ if (!session)
264
+ return;
265
+ await __privateMethod(this, _CallEngine_instances, sendByeSafe_fn).call(this, session, reason);
266
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, reason);
267
+ }
268
+ /** Enable/disable a local track kind on an active call (mute / camera off). */
269
+ setLocalTrackEnabled(callId, kind, enabled) {
270
+ const session = __privateGet(this, _sessions).get(callId);
271
+ if (!(session == null ? void 0 : session.localStream))
272
+ return;
273
+ for (const track of session.localStream.getTracks()) {
274
+ if (track.kind === kind)
275
+ track.enabled = enabled;
276
+ }
277
+ }
278
+ /** Tear down every call (e.g. on app shutdown). */
279
+ dispose() {
280
+ for (const session of [...__privateGet(this, _sessions).values()]) {
281
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, "close");
282
+ }
283
+ this.removeAll();
284
+ }
285
+ };
286
+ _opts = new WeakMap();
287
+ _iceServers = new WeakMap();
288
+ _sessions = new WeakMap();
289
+ _CallEngine_instances = new WeakSet();
290
+ // ---- internals -------------------------------------------------------
291
+ handleSignal_fn = function(peerId, signal) {
292
+ switch (signal.type) {
293
+ case "offer":
294
+ __privateMethod(this, _CallEngine_instances, onOffer_fn).call(this, peerId, signal);
295
+ return;
296
+ case "answer":
297
+ __privateMethod(this, _CallEngine_instances, onAnswer_fn).call(this, peerId, signal);
298
+ return;
299
+ case "candidate":
300
+ __privateMethod(this, _CallEngine_instances, onCandidate_fn).call(this, peerId, signal);
301
+ return;
302
+ case "remove-candidates":
303
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `remove-candidates from ${peerId} (ignored)`);
304
+ return;
305
+ case "bye":
306
+ __privateMethod(this, _CallEngine_instances, onBye_fn).call(this, peerId, signal);
307
+ return;
308
+ case "action":
309
+ case "event":
310
+ case "prAnswer":
311
+ __privateMethod(this, _CallEngine_instances, onAuxiliary_fn).call(this, peerId, signal);
312
+ return;
313
+ }
314
+ };
315
+ onOffer_fn = function(peerId, signal) {
316
+ if (!signal.sdp)
317
+ return;
318
+ const existing = __privateGet(this, _sessions).get(signal.callId);
319
+ if (existing) {
320
+ existing.pendingOffer = signal.sdp;
321
+ return;
322
+ }
323
+ const audio = signal.options ? signal.options.includes("audio") : true;
324
+ const video = signal.options ? signal.options.includes("video") : false;
325
+ const data = signal.options ? signal.options.includes("data") : false;
326
+ const session = __privateMethod(this, _CallEngine_instances, createSession_fn).call(this, signal.callId, peerId, "incoming", { audio, video, data });
327
+ session.pendingOffer = signal.sdp;
328
+ this.emit("incomingCall", __privateGet(this, _toInfo).call(this, session));
329
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `incoming call from ${peerId} callId=${signal.callId} audio=${audio} video=${video}`);
330
+ };
331
+ onAnswer_fn = function(peerId, signal) {
332
+ const session = __privateGet(this, _sessions).get(signal.callId);
333
+ if (!session || session.peerId !== peerId || !signal.sdp)
334
+ return;
335
+ void (async () => {
336
+ try {
337
+ await session.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
338
+ session.hasReceivedSdp = true;
339
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "connecting");
340
+ await __privateMethod(this, _CallEngine_instances, drainRemoteCandidates_fn).call(this, session);
341
+ await __privateMethod(this, _CallEngine_instances, flushLocalCandidates_fn).call(this, session);
342
+ } catch (err) {
343
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `setRemoteDescription(answer) failed: ${err.message}`);
344
+ }
345
+ })();
346
+ };
347
+ onCandidate_fn = function(peerId, signal) {
348
+ var _a;
349
+ const session = __privateGet(this, _sessions).get(signal.callId);
350
+ if (!session || session.peerId !== peerId || !signal.candidates)
351
+ return;
352
+ for (const c of signal.candidates) {
353
+ const init = {
354
+ candidate: c.sdp,
355
+ sdpMLineIndex: c.sdpMLineIndex,
356
+ sdpMid: (_a = c.sdpMid) != null ? _a : void 0
357
+ };
358
+ if (session.pc.remoteDescription) {
359
+ session.pc.addIceCandidate(init).catch((err) => __privateMethod(this, _CallEngine_instances, log_fn).call(this, `addIceCandidate failed: ${err.message}`));
360
+ } else {
361
+ session.remoteCandidateQueue.push(init);
362
+ }
363
+ }
364
+ };
365
+ onBye_fn = function(peerId, signal) {
366
+ var _a;
367
+ const session = __privateGet(this, _sessions).get(signal.callId);
368
+ if (!session || session.peerId !== peerId)
369
+ return;
370
+ const reason = (_a = signal.reason) != null ? _a : "normal";
371
+ const state = reason === "busy" ? "busy" : reason === "declined" ? "declined" : "ended";
372
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, state);
373
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, reason);
374
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `bye from ${peerId} callId=${signal.callId} reason=${reason}`);
375
+ };
376
+ onAuxiliary_fn = function(peerId, signal) {
377
+ const session = __privateGet(this, _sessions).get(signal.callId);
378
+ if (!session || session.peerId !== peerId)
379
+ return;
380
+ if (signal.type === "event") {
381
+ if (signal.event === "ringing")
382
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "remoteRinging");
383
+ else if (signal.event === "online")
384
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "remoteOnline");
385
+ } else if (signal.type === "action") {
386
+ if (signal.action === "reject") {
387
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "declined");
388
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, "declined");
389
+ }
390
+ }
391
+ };
392
+ createSession_fn = function(callId, peerId, direction, kinds) {
393
+ const pc = __privateGet(this, _opts).createPeerConnection({ iceServers: __privateGet(this, _iceServers) });
394
+ const session = {
395
+ callId,
396
+ peerId,
397
+ direction,
398
+ pc,
399
+ audio: kinds.audio,
400
+ video: kinds.video,
401
+ data: kinds.data,
402
+ state: "connecting",
403
+ hasReceivedSdp: false,
404
+ localCandidateQueue: [],
405
+ remoteCandidateQueue: [],
406
+ closed: false
407
+ };
408
+ __privateGet(this, _sessions).set(callId, session);
409
+ __privateMethod(this, _CallEngine_instances, wirePeerConnection_fn).call(this, session);
410
+ return session;
411
+ };
412
+ wirePeerConnection_fn = function(session) {
413
+ const pc = session.pc;
414
+ pc.onicecandidate = (ev) => {
415
+ var _a, _b;
416
+ if (!ev.candidate || !ev.candidate.candidate)
417
+ return;
418
+ const sig = {
419
+ sdp: ev.candidate.candidate,
420
+ sdpMLineIndex: (_a = ev.candidate.sdpMLineIndex) != null ? _a : 0,
421
+ sdpMid: (_b = ev.candidate.sdpMid) != null ? _b : null
422
+ };
423
+ if (session.hasReceivedSdp) {
424
+ void __privateMethod(this, _CallEngine_instances, send_fn).call(this, session.peerId, { type: "candidate", candidates: [sig], callId: session.callId });
425
+ } else {
426
+ session.localCandidateQueue.push(sig);
427
+ }
428
+ };
429
+ pc.ontrack = (ev) => {
430
+ var _a;
431
+ const stream = (_a = ev.streams[0]) != null ? _a : __privateMethod(this, _CallEngine_instances, ensureRemoteStream_fn).call(this, session, ev.track);
432
+ if (session.remoteStream !== stream) {
433
+ session.remoteStream = stream;
434
+ this.emit("remoteStream", session.callId, stream);
435
+ }
436
+ };
437
+ pc.oniceconnectionstatechange = () => {
438
+ switch (pc.iceConnectionState) {
439
+ case "connected":
440
+ case "completed":
441
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "connected");
442
+ break;
443
+ case "disconnected":
444
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "disconnected");
445
+ break;
446
+ case "failed":
447
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "failed");
448
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, "close", "failed");
449
+ break;
450
+ case "closed":
451
+ if (!session.closed)
452
+ __privateMethod(this, _CallEngine_instances, cleanup_fn).call(this, session, "close");
453
+ break;
454
+ default:
455
+ break;
456
+ }
457
+ };
458
+ };
459
+ ensureRemoteStream_fn = function(session, track) {
460
+ if (!session.remoteStream)
461
+ session.remoteStream = new MediaStream();
462
+ session.remoteStream.addTrack(track);
463
+ return session.remoteStream;
464
+ };
465
+ attachLocalMedia_fn = async function(session) {
466
+ if (session.localStream)
467
+ return;
468
+ if (!__privateGet(this, _opts).getLocalMedia || !session.audio && !session.video)
469
+ return;
470
+ const stream = await __privateGet(this, _opts).getLocalMedia({ audio: session.audio, video: session.video });
471
+ session.localStream = stream;
472
+ for (const track of stream.getTracks()) {
473
+ session.pc.addTrack(track, stream);
474
+ }
475
+ this.emit("localStream", session.callId, stream);
476
+ };
477
+ flushLocalCandidates_fn = async function(session) {
478
+ if (!session.hasReceivedSdp || session.localCandidateQueue.length === 0)
479
+ return;
480
+ const queued = session.localCandidateQueue.splice(0);
481
+ for (const sig of queued) {
482
+ await __privateMethod(this, _CallEngine_instances, send_fn).call(this, session.peerId, { type: "candidate", candidates: [sig], callId: session.callId });
483
+ }
484
+ };
485
+ drainRemoteCandidates_fn = async function(session) {
486
+ if (!session.pc.remoteDescription)
487
+ return;
488
+ const queued = session.remoteCandidateQueue.splice(0);
489
+ for (const init of queued) {
490
+ await session.pc.addIceCandidate(init).catch((err) => __privateMethod(this, _CallEngine_instances, log_fn).call(this, `addIceCandidate (drain) failed: ${err.message}`));
491
+ }
492
+ };
493
+ send_fn = async function(peerId, signal) {
494
+ try {
495
+ await __privateGet(this, _opts).signaling.send(peerId, signal);
496
+ } catch (err) {
497
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `signal send failed (${signal.type} \u2192 ${peerId}): ${err.message}`);
498
+ throw err;
499
+ }
500
+ };
501
+ sendByeSafe_fn = async function(session, reason) {
502
+ try {
503
+ await __privateGet(this, _opts).signaling.send(session.peerId, { type: "bye", reason, callId: session.callId });
504
+ } catch (err) {
505
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `bye send failed: ${err.message}`);
506
+ }
507
+ };
508
+ cleanup_fn = function(session, reason, endedReason) {
509
+ var _a, _b;
510
+ if (session.closed)
511
+ return;
512
+ session.closed = true;
513
+ try {
514
+ session.pc.onicecandidate = null;
515
+ session.pc.ontrack = null;
516
+ session.pc.oniceconnectionstatechange = null;
517
+ session.pc.close();
518
+ } catch {
519
+ }
520
+ for (const track of (_b = (_a = session.localStream) == null ? void 0 : _a.getTracks()) != null ? _b : []) {
521
+ try {
522
+ track.stop();
523
+ } catch {
524
+ }
525
+ }
526
+ __privateGet(this, _sessions).delete(session.callId);
527
+ if (session.state !== "ended" && session.state !== "failed") {
528
+ session.state = endedReason === "failed" ? "failed" : "ended";
529
+ }
530
+ this.emit("ended", session.callId, endedReason != null ? endedReason : reason);
531
+ };
532
+ setState_fn = function(session, state) {
533
+ if (session.state === state)
534
+ return;
535
+ session.state = state;
536
+ this.emit("stateChanged", __privateGet(this, _toInfo).call(this, session), state);
537
+ };
538
+ _toInfo = new WeakMap();
539
+ newCallId_fn = function() {
540
+ if (__privateGet(this, _opts).generateCallId)
541
+ return __privateGet(this, _opts).generateCallId();
542
+ const c = globalThis.crypto;
543
+ if (c && typeof c.randomUUID === "function")
544
+ return c.randomUUID();
545
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
546
+ const r = Math.floor(Math.random() * 16);
547
+ const v = ch === "x" ? r : r & 3 | 8;
548
+ return v.toString(16);
549
+ });
550
+ };
551
+ log_fn = function(msg) {
552
+ var _a, _b;
553
+ (_b = (_a = __privateGet(this, _opts)).logger) == null ? void 0 : _b.call(_a, `[peer-webrtc] ${msg}`);
554
+ };
555
+
556
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/broadcast.js
557
+ var _selfId, _channel, _handler;
558
+ var BroadcastChannelSignaling = class {
559
+ constructor(selfId, channelName = "peer-webrtc-demo") {
560
+ __privateAdd(this, _selfId);
561
+ __privateAdd(this, _channel);
562
+ __privateAdd(this, _handler);
563
+ __privateSet(this, _selfId, selfId);
564
+ __privateSet(this, _channel, new BroadcastChannel(channelName));
565
+ __privateGet(this, _channel).onmessage = (ev) => {
566
+ var _a;
567
+ const msg = ev.data;
568
+ if (!msg || msg.to !== __privateGet(this, _selfId) || msg.from === __privateGet(this, _selfId))
569
+ return;
570
+ (_a = __privateGet(this, _handler)) == null ? void 0 : _a.call(this, msg.from, msg.signal);
571
+ };
572
+ }
573
+ send(peerId, signal) {
574
+ const env = { from: __privateGet(this, _selfId), to: peerId, signal };
575
+ __privateGet(this, _channel).postMessage(env);
576
+ }
577
+ onSignal(handler) {
578
+ __privateSet(this, _handler, handler);
579
+ }
580
+ close() {
581
+ __privateGet(this, _channel).close();
582
+ }
583
+ };
584
+ _selfId = new WeakMap();
585
+ _channel = new WeakMap();
586
+ _handler = new WeakMap();
587
+
588
+ // node_modules/@decentnetwork/peer-webrtc/dist/guard.js
589
+ function decodeSignalBytesGuard(input) {
590
+ try {
591
+ return decodeSignal(input);
592
+ } catch {
593
+ return void 0;
594
+ }
595
+ }
596
+
597
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/carrier.js
598
+ var DEFAULT_EXT = "carrier";
599
+ var _peer, _ext;
600
+ var CarrierSignaling = class {
601
+ constructor(peer, opts = {}) {
602
+ __privateAdd(this, _peer);
603
+ __privateAdd(this, _ext);
604
+ var _a;
605
+ __privateSet(this, _peer, peer);
606
+ __privateSet(this, _ext, (_a = opts.ext) != null ? _a : DEFAULT_EXT);
607
+ }
608
+ async send(peerId, signal) {
609
+ await __privateGet(this, _peer).sendInvite(peerId, encodeSignalBytes(signal), { ext: __privateGet(this, _ext) });
610
+ }
611
+ onSignal(handler) {
612
+ __privateGet(this, _peer).onInvite((evt) => {
613
+ if (evt.ext !== void 0 && evt.ext !== __privateGet(this, _ext))
614
+ return;
615
+ if (!looksLikeSignal(evt.data))
616
+ return;
617
+ const signal = decodeSignalBytesGuard(evt.data);
618
+ if (signal)
619
+ handler(evt.pubkey, signal);
620
+ });
621
+ }
622
+ };
623
+ _peer = new WeakMap();
624
+ _ext = new WeakMap();
625
+ return __toCommonJS(dist_exports);
626
+ })();
package/dist/ui/server.js CHANGED
@@ -602,6 +602,28 @@ export function startFriendUi(opts) {
602
602
  sendJson(res, r.ok ? 200 : 400, r);
603
603
  return;
604
604
  }
605
+ // WebRTC call signaling (peer-webrtc). The browser CallEngine sends
606
+ // RtcSignals here and long-polls call-poll for inbound ones; the daemon
607
+ // relays both over the Carrier "carrier" invite extension.
608
+ if (req.method === "POST" && url === "/api/call-signal") {
609
+ const { userid, data } = await readBody(req);
610
+ const r = await opts.call({ op: "call-signal", userid, data });
611
+ sendJson(res, r.ok ? 200 : 400, r);
612
+ return;
613
+ }
614
+ if (req.method === "GET" && url === "/api/call-poll") {
615
+ // The daemon holds this up to ~20s (long-poll). opts.call's own IPC
616
+ // timeout is 30s, so it returns before that; on any error, resolve
617
+ // empty so the browser simply re-polls.
618
+ try {
619
+ const r = await opts.call({ op: "call-poll" });
620
+ sendJson(res, 200, r.ok ? r.data ?? { signals: [] } : { signals: [] });
621
+ }
622
+ catch {
623
+ sendJson(res, 200, { signals: [] });
624
+ }
625
+ return;
626
+ }
605
627
  if (req.method === "POST" && url === "/api/friend-remove") {
606
628
  const { userid } = await readBody(req);
607
629
  const r = await opts.call({ op: "friend-remove", userid });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.158",
3
+ "version": "0.1.160",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -79,7 +79,8 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.13",
82
- "@decentnetwork/peer": "^0.1.83",
82
+ "@decentnetwork/peer": "^0.1.84",
83
+ "@decentnetwork/peer-webrtc": "^0.1.0",
83
84
  "ink": "^5.2.1",
84
85
  "js-yaml": "^4.1.0",
85
86
  "node-forge": "^1.4.0",