@decentnetwork/lan 0.1.166 → 0.1.176

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
@@ -0,0 +1,69 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { type RtcSignal } from "@decentnetwork/peer-webrtc";
3
+ /**
4
+ * Configuration for the offline-call fallback. Defaults target the public
5
+ * Beagle gateways; the appKey/appName must match what the target device
6
+ * registered with (see the Beagle app's Service.swift).
7
+ */
8
+ export interface OfflineCallConfig {
9
+ /** Master switch. When false the bridge is inert. */
10
+ enabled: boolean;
11
+ /** Beagle push appKey (matches the callee's registration). */
12
+ appKey: string;
13
+ /** Beagle push appName — the callee app's CFBundleName ("Beagle"/"webrtc"). */
14
+ appName: string;
15
+ /** Socket.IO signaling gateway (primary). */
16
+ socketUrl: string;
17
+ /** Ordered socket.IO gateways to try — connects to the first that comes up,
18
+ * switches to the next on failure. Defaults to [socketUrl]. Both call peers
19
+ * must agree on a server, so keep this list in sync with the Beagle apps. */
20
+ socketUrls?: string[];
21
+ /** Push-API endpoints (POST /push-api/push-message), tried in order. */
22
+ pushEndpoints?: string[];
23
+ }
24
+ export declare const DEFAULT_OFFLINE_CONFIG: Omit<OfflineCallConfig, "enabled">;
25
+ /**
26
+ * Offline-call bridge — the socket.io + push path Beagle uses to reach a peer
27
+ * whose app is backgrounded/offline (so a live Carrier session doesn't exist).
28
+ *
29
+ * The daemon uses this as a FALLBACK: `sendCallSignal` tries Carrier first and
30
+ * drops to this only when there's no live session. Signaling then rides the
31
+ * socket.io room (keyed by the RtcSignal `callId`); the initial offer also fires
32
+ * a push to wake the callee. Inbound socket.io signals are re-emitted as
33
+ * `"signal"` events so the daemon forwards them to the browser exactly like
34
+ * Carrier invites.
35
+ *
36
+ * Runs entirely in the daemon (Node): socket.io-client for signaling, fetch for
37
+ * push. socket.io-client is an OPTIONAL dependency — if it isn't installed the
38
+ * bridge stays disabled and logs a hint rather than crashing.
39
+ */
40
+ export declare class OfflineCallBridge extends EventEmitter {
41
+ #private;
42
+ constructor(opts: {
43
+ config: OfflineCallConfig;
44
+ selfUserId: string;
45
+ selfName: string;
46
+ log?: (msg: string) => void;
47
+ });
48
+ get enabled(): boolean;
49
+ /**
50
+ * Send a call signal to `userid` over the offline path. Decodes the signal to
51
+ * key the room by `callId`; for the initial offer it also pushes to wake the
52
+ * callee. Throws if the bridge can't be brought up (caller keeps the original
53
+ * Carrier error).
54
+ */
55
+ /** Extract the callId from a raw signal payload (undefined if not decodable). */
56
+ callIdOf(data: Uint8Array | string): string | undefined;
57
+ /** True once this call has fallen back to the offline path. */
58
+ isOfflineCall(callId: string): boolean;
59
+ /** Mark a call as committed to the Carrier (online) path. */
60
+ markOnlineCall(callId: string): void;
61
+ /** True once this call has committed to the Carrier path. */
62
+ isOnlineCall(callId: string): boolean;
63
+ /** Drop a finished call's transport state (best-effort cleanup). */
64
+ forgetCall(callId: string): void;
65
+ sendSignal(userid: string, data: Uint8Array | string, hasVideo: boolean): Promise<void>;
66
+ /** Tear down the socket connection (e.g. on daemon shutdown). */
67
+ close(): void;
68
+ }
69
+ export type { RtcSignal };
@@ -0,0 +1,192 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { SocketIoSignaling, BeaglePushClient, decodeSignal, encodeSignalBytes } from "@decentnetwork/peer-webrtc";
3
+ export const DEFAULT_OFFLINE_CONFIG = {
4
+ // From the Beagle app (Service.swift). Overridable via config.
5
+ appKey: "08726e938c624c4da9dcd9a85f2bbcd8",
6
+ appName: "Beagle",
7
+ socketUrl: "https://io.beagle.chat",
8
+ socketUrls: ["https://io.beagle.chat"],
9
+ pushEndpoints: [
10
+ "https://pushapi.beagle.chat/push-api/push-message",
11
+ "https://www.callpass.cn/push-api/push-message",
12
+ "https://tokyo.fi.chat:3004/push-api/push-message"
13
+ ]
14
+ };
15
+ /**
16
+ * Offline-call bridge — the socket.io + push path Beagle uses to reach a peer
17
+ * whose app is backgrounded/offline (so a live Carrier session doesn't exist).
18
+ *
19
+ * The daemon uses this as a FALLBACK: `sendCallSignal` tries Carrier first and
20
+ * drops to this only when there's no live session. Signaling then rides the
21
+ * socket.io room (keyed by the RtcSignal `callId`); the initial offer also fires
22
+ * a push to wake the callee. Inbound socket.io signals are re-emitted as
23
+ * `"signal"` events so the daemon forwards them to the browser exactly like
24
+ * Carrier invites.
25
+ *
26
+ * Runs entirely in the daemon (Node): socket.io-client for signaling, fetch for
27
+ * push. socket.io-client is an OPTIONAL dependency — if it isn't installed the
28
+ * bridge stays disabled and logs a hint rather than crashing.
29
+ */
30
+ export class OfflineCallBridge extends EventEmitter {
31
+ #cfg;
32
+ #selfUserId;
33
+ #selfName;
34
+ #log;
35
+ #push;
36
+ #signaling;
37
+ #socket;
38
+ #connecting;
39
+ /** callIds that have fallen to the offline path — subsequent signals for them
40
+ * skip the Carrier retry entirely (no per-signal 1.5s stall). */
41
+ #offlineCalls = new Set();
42
+ /** callIds committed to the Carrier (online) path — a later transient session
43
+ * flap must NOT divert their signals to socket.io (splitting transports
44
+ * mid-call breaks ICE: the peer gets candidates over two channels). */
45
+ #onlineCalls = new Set();
46
+ constructor(opts) {
47
+ super();
48
+ this.#cfg = opts.config;
49
+ this.#selfUserId = opts.selfUserId;
50
+ this.#selfName = opts.selfName;
51
+ this.#log = opts.log ?? (() => { });
52
+ }
53
+ get enabled() {
54
+ return this.#cfg.enabled;
55
+ }
56
+ /**
57
+ * Send a call signal to `userid` over the offline path. Decodes the signal to
58
+ * key the room by `callId`; for the initial offer it also pushes to wake the
59
+ * callee. Throws if the bridge can't be brought up (caller keeps the original
60
+ * Carrier error).
61
+ */
62
+ /** Extract the callId from a raw signal payload (undefined if not decodable). */
63
+ callIdOf(data) {
64
+ try {
65
+ return decodeSignal(data).callId || undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /** True once this call has fallen back to the offline path. */
72
+ isOfflineCall(callId) {
73
+ return this.#offlineCalls.has(callId);
74
+ }
75
+ /** Mark a call as committed to the Carrier (online) path. */
76
+ markOnlineCall(callId) {
77
+ this.#onlineCalls.add(callId);
78
+ }
79
+ /** True once this call has committed to the Carrier path. */
80
+ isOnlineCall(callId) {
81
+ return this.#onlineCalls.has(callId);
82
+ }
83
+ /** Drop a finished call's transport state (best-effort cleanup). */
84
+ forgetCall(callId) {
85
+ this.#offlineCalls.delete(callId);
86
+ this.#onlineCalls.delete(callId);
87
+ }
88
+ async sendSignal(userid, data, hasVideo) {
89
+ if (!this.#cfg.enabled)
90
+ throw new Error("offline calls disabled");
91
+ const signal = decodeSignal(data);
92
+ if (!signal.callId)
93
+ throw new Error("offline call signal has no callId");
94
+ this.#offlineCalls.add(signal.callId);
95
+ await this.#ensureConnected();
96
+ // Ring the callee for the first outbound signal of a call (the offer).
97
+ if (signal.type === "offer" && this.#push) {
98
+ const woke = await this.#push
99
+ .ring(userid, {
100
+ callerUserId: this.#selfUserId,
101
+ callId: signal.callId,
102
+ callerName: this.#selfName,
103
+ hasVideo,
104
+ channel: "socketio"
105
+ })
106
+ .catch(() => false);
107
+ this.#log(`offline call: push ${woke ? "sent" : "FAILED"} to ${userid} (call ${signal.callId})`);
108
+ }
109
+ this.#signaling.joinCall(signal.callId);
110
+ const kind = signal.type === "candidate"
111
+ ? `candidate(${signal.candidates?.length ?? 0})`
112
+ : signal.type === "event" ? `event:${signal.event}` : signal.type;
113
+ this.#log(`offline call: SEND ${kind} to ${userid} (call ${signal.callId})`);
114
+ this.#signaling.send(userid, signal);
115
+ }
116
+ /** Bring up the socket.io connection + signaling/push clients once. */
117
+ async #ensureConnected() {
118
+ if (this.#signaling)
119
+ return;
120
+ if (this.#connecting)
121
+ return this.#connecting;
122
+ this.#connecting = this.#connect();
123
+ try {
124
+ await this.#connecting;
125
+ }
126
+ finally {
127
+ this.#connecting = undefined;
128
+ }
129
+ }
130
+ async #connect() {
131
+ let io;
132
+ try {
133
+ // Optional dependency — imported lazily so the daemon runs without it.
134
+ const mod = (await import("socket.io-client"));
135
+ io = mod.io;
136
+ }
137
+ catch {
138
+ throw new Error("offline calls need socket.io-client — run `npm install socket.io-client` in decentlan");
139
+ }
140
+ // Try each gateway in order; connect to the first that comes up.
141
+ const urls = this.#cfg.socketUrls?.length ? this.#cfg.socketUrls : [this.#cfg.socketUrl];
142
+ let connectedUrl;
143
+ for (const url of urls) {
144
+ const sock = io(url, { transports: ["websocket"], forceNew: true, reconnection: false });
145
+ const ok = await new Promise((resolve) => {
146
+ const t = setTimeout(() => resolve(false), 7000);
147
+ sock.on("connect", () => { clearTimeout(t); resolve(true); });
148
+ sock.on("connect_error", () => { clearTimeout(t); resolve(false); });
149
+ });
150
+ if (ok) {
151
+ this.#socket = sock;
152
+ connectedUrl = url;
153
+ break;
154
+ }
155
+ this.#log(`offline call: socket gateway ${url} unreachable — trying next`);
156
+ try {
157
+ sock.disconnect?.();
158
+ }
159
+ catch { /* best-effort */ }
160
+ }
161
+ if (!this.#socket)
162
+ throw new Error(`offline calls: no reachable socket gateway (${urls.join(", ")})`);
163
+ this.#signaling = new SocketIoSignaling(this.#socket, { userId: this.#selfUserId });
164
+ this.#signaling.onSignal((peerId, signal) => {
165
+ // Re-emit inbound socket.io signals just like a Carrier invite so the
166
+ // daemon's existing call-signal queue forwards them to the browser.
167
+ const kind = signal.type === "candidate"
168
+ ? `candidate(${signal.candidates?.length ?? 0})`
169
+ : signal.type === "event" ? `event:${signal.event}` : signal.type;
170
+ this.#log(`offline call: RECV ${kind} from ${peerId} (call ${signal.callId ?? "?"})`);
171
+ this.emit("signal", { userid: peerId, data: encodeSignalBytes(signal) });
172
+ });
173
+ this.#push = new BeaglePushClient({
174
+ appKey: this.#cfg.appKey,
175
+ appName: this.#cfg.appName,
176
+ endpoints: this.#cfg.pushEndpoints
177
+ });
178
+ this.#log(`offline call bridge connected to ${connectedUrl}`);
179
+ }
180
+ /** Tear down the socket connection (e.g. on daemon shutdown). */
181
+ close() {
182
+ try {
183
+ this.#socket?.disconnect?.();
184
+ }
185
+ catch {
186
+ /* best-effort */
187
+ }
188
+ this.#socket = undefined;
189
+ this.#signaling = undefined;
190
+ this.#push = undefined;
191
+ }
192
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
+ import { type OfflineCallConfig } from "./offline-call.js";
7
8
  import { PacketSession } from "./packet-session.js";
8
9
  import type { PeerIdentity, RemotePeer } from "./types.js";
9
10
  import type { BootstrapNode } from "../types.js";
@@ -21,6 +22,10 @@ export interface PeerManagerOptions {
21
22
  /** Directory for persisting partially-received files so a dropped/restarted
22
23
  * transfer resumes instead of restarting (断点续传). */
23
24
  fileResumeDir?: string;
25
+ /** Offline-call fallback (socket.io + push). When enabled, a call to a peer
26
+ * with no live Carrier session rings it via the Beagle push gateway and
27
+ * signals over socket.io instead of failing. */
28
+ offlineCall?: OfflineCallConfig;
24
29
  }
25
30
  export declare class PeerManager extends EventEmitter {
26
31
  private peer;
@@ -28,6 +33,9 @@ export declare class PeerManager extends EventEmitter {
28
33
  private sessions;
29
34
  private sessionCounter;
30
35
  private logger;
36
+ private offlineBridge;
37
+ private offlineConfig;
38
+ private nickname;
31
39
  constructor();
32
40
  create(opts: PeerManagerOptions): Promise<void>;
33
41
  start(): Promise<void>;
@@ -70,10 +78,12 @@ export declare class PeerManager extends EventEmitter {
70
78
  * Send a WebRTC call-signaling payload to a friend over the Carrier
71
79
  * friend-invite channel (the "carrier" extension), the exact transport the
72
80
  * 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.
81
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime over Carrier when a
82
+ * live session exists; otherwise falls back to the offline path (socket.io +
83
+ * push) when configured, so a call to a backgrounded/offline phone still
84
+ * rings instead of failing.
75
85
  */
76
- sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
86
+ sendCallSignal(userid: string, data: Uint8Array | string, hasVideo?: boolean): Promise<void>;
77
87
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
78
88
  * relay/DHT key differs from its identity key. JS peers announce their
79
89
  * identity key as their DHT key, so the two match. */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
+ import { OfflineCallBridge } from "./offline-call.js";
7
8
  import { PacketSession } from "./packet-session.js";
8
9
  import { FrameCodec } from "./frame.js";
9
10
  import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, } from "./types.js";
@@ -14,12 +15,17 @@ export class PeerManager extends EventEmitter {
14
15
  sessions = new Map();
15
16
  sessionCounter = 1;
16
17
  logger;
18
+ offlineBridge = null;
19
+ offlineConfig = null;
20
+ nickname = "";
17
21
  constructor() {
18
22
  super();
19
23
  this.logger = new Logger({ prefix: "PeerManager" });
20
24
  }
21
25
  async create(opts) {
22
26
  this.logger.info("Creating Peer instance");
27
+ this.offlineConfig = opts.offlineCall ?? null;
28
+ this.nickname = opts.nickname ?? "";
23
29
  this.peer = await Peer.create({
24
30
  keyFile: opts.keyFile,
25
31
  compatibilityMode: "legacy",
@@ -50,6 +56,21 @@ export class PeerManager extends EventEmitter {
50
56
  userid: this.peer.userid(),
51
57
  };
52
58
  this.logger.info(`Peer started: ${this.identity.address}`);
59
+ // Bring up the offline-call fallback (socket.io + push) if configured.
60
+ // Signals it receives are re-emitted as "call-signal" — identical to a
61
+ // Carrier invite — so the daemon's existing browser bridge handles them.
62
+ if (this.offlineConfig?.enabled) {
63
+ this.offlineBridge = new OfflineCallBridge({
64
+ config: this.offlineConfig,
65
+ selfUserId: this.identity.userid,
66
+ selfName: this.nickname || this.identity.userid,
67
+ log: (m) => this.logger.info(m),
68
+ });
69
+ this.offlineBridge.on("signal", (evt) => {
70
+ this.emit("call-signal", { pubkey: evt.userid, data: evt.data });
71
+ });
72
+ this.logger.info("Offline-call fallback enabled (socket.io + push)");
73
+ }
53
74
  }
54
75
  async joinNetwork() {
55
76
  if (!this.peer) {
@@ -133,13 +154,45 @@ export class PeerManager extends EventEmitter {
133
154
  * Send a WebRTC call-signaling payload to a friend over the Carrier
134
155
  * friend-invite channel (the "carrier" extension), the exact transport the
135
156
  * 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.
157
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime over Carrier when a
158
+ * live session exists; otherwise falls back to the offline path (socket.io +
159
+ * push) when configured, so a call to a backgrounded/offline phone still
160
+ * rings instead of failing.
138
161
  */
139
- async sendCallSignal(userid, data) {
162
+ async sendCallSignal(userid, data, hasVideo = true) {
140
163
  if (!this.peer)
141
164
  throw new Error("Peer not created. Call create() first.");
142
- await this.peer.sendInvite(userid, data, { ext: "carrier" });
165
+ // Once a call has fallen to the offline path, keep every later signal for it
166
+ // on that path — don't re-attempt Carrier per signal (that stalled the UI
167
+ // ~1.5s each and reordered signals across transports).
168
+ const callId = this.offlineBridge?.enabled ? this.offlineBridge.callIdOf(data) : undefined;
169
+ if (callId && this.offlineBridge.isOfflineCall(callId)) {
170
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
171
+ return;
172
+ }
173
+ try {
174
+ // Fail fast (1.5s, not 5s) so an offline peer drops to push quickly.
175
+ await this.peer.sendInvite(userid, data, { ext: "carrier", establishTimeoutMs: 1500 });
176
+ // Committed to Carrier — keep this call there even if it briefly flaps.
177
+ if (callId)
178
+ this.offlineBridge?.markOnlineCall(callId);
179
+ }
180
+ catch (err) {
181
+ // A call already committed to Carrier must NOT split to socket.io on a
182
+ // transient flap (it breaks ICE). Drop this signal; ICE recovers via the
183
+ // other candidates / a retried offer.
184
+ if (callId && this.offlineBridge?.isOnlineCall(callId)) {
185
+ this.logger.info(`sendCallSignal: dropped a Carrier signal for online call ${callId} (${err.message})`);
186
+ return;
187
+ }
188
+ // No live session and not yet committed to Carrier — use the offline path.
189
+ if (this.offlineBridge?.enabled) {
190
+ this.logger.info(`sendCallSignal: Carrier failed (${err.message}) — offline path to ${userid}`);
191
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
192
+ return;
193
+ }
194
+ throw err;
195
+ }
143
196
  }
144
197
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
145
198
  * relay/DHT key differs from its identity key. JS peers announce their
@@ -5,6 +5,7 @@ import { resolve } from "path";
5
5
  import { EventEmitter } from "events";
6
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
7
7
  import { PeerManager } from "../carrier/peer-manager.js";
8
+ import { DEFAULT_OFFLINE_CONFIG } from "../carrier/offline-call.js";
8
9
  import { TunDevice } from "../tun/tun-device.js";
9
10
  import { RouteManager } from "../tun/route-manager.js";
10
11
  import { Ipam } from "../ipam/ipam.js";
@@ -292,6 +293,18 @@ export class DaemonServer {
292
293
  // Persist partially-received files so a dropped/restarted transfer
293
294
  // resumes instead of restarting from 0 (断点续传).
294
295
  fileResumeDir: resolve(this.configDir, "downloads", ".partial"),
296
+ // Offline-call fallback (socket.io + push): ring an offline/backgrounded
297
+ // phone via the Beagle push gateway when there's no live Carrier
298
+ // session. Enabled by default with the public Beagle gateways; override
299
+ // appKey/appName/socketUrl under `call:` in config.yaml.
300
+ offlineCall: {
301
+ enabled: this.config.call?.offlineEnabled ?? true,
302
+ appKey: this.config.call?.appKey ?? DEFAULT_OFFLINE_CONFIG.appKey,
303
+ appName: this.config.call?.appName ?? DEFAULT_OFFLINE_CONFIG.appName,
304
+ socketUrl: this.config.call?.socketUrl ?? DEFAULT_OFFLINE_CONFIG.socketUrl,
305
+ socketUrls: this.config.call?.socketUrls ?? DEFAULT_OFFLINE_CONFIG.socketUrls,
306
+ pushEndpoints: this.config.call?.pushEndpoints ?? DEFAULT_OFFLINE_CONFIG.pushEndpoints,
307
+ },
295
308
  });
296
309
  await this.peerManager.start();
297
310
  this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
@@ -467,10 +480,12 @@ export class DaemonServer {
467
480
  // Beagle apps themselves use. Detected via the DHT-key signature.
468
481
  if (this.peerManager?.isNativeFriend(userid)) {
469
482
  // The FileModel JSON envelope is base64 (inflates ~1.34x) + a little
470
- // JSON overhead, and the whole thing must fit the 5MB bulkmsg cap
471
- // (CARRIER_MAX_APP_BULKMSG_LEN). 3.5MB raw ~4.7MB envelope, safely
472
- // under 5MB. (Was a conservative 2MB.)
473
- const INLINE_MAX = 3.5 * 1024 * 1024;
483
+ // JSON overhead, and the whole thing must fit the 16MB bulkmsg cap
484
+ // (CARRIER_MAX_APP_BULKMSG_LEN, raised from 5MB alongside the receive
485
+ // reorder buffer in peer >= 0.1.87). 11MB raw ~14.7MB envelope,
486
+ // safely under 16MB. Needs BOTH ends on the raised cap. (Very large /
487
+ // streaming transfers should use toxcore file transfer, not inline.)
488
+ const INLINE_MAX = 11 * 1024 * 1024;
474
489
  if (data.length > INLINE_MAX) {
475
490
  throw new Error(`This friend is a native (iOS/Android) client — files are sent inline and limited to ` +
476
491
  `${(INLINE_MAX / 1024 / 1024).toFixed(1)} MB (this is ${(data.length / 1024 / 1024).toFixed(1)} MB).`);
@@ -563,6 +578,15 @@ export class DaemonServer {
563
578
  });
564
579
  }
565
580
  const signals = this.callSignalQueue.splice(0, this.callSignalQueue.length);
581
+ if (signals.length) {
582
+ const kinds = signals.map((s) => { try {
583
+ return JSON.parse(s.data).type;
584
+ }
585
+ catch {
586
+ return "?";
587
+ } }).join(",");
588
+ this.logger.info(`call-poll -> browser: ${signals.length} signal(s) [${kinds}]`);
589
+ }
566
590
  return { signals };
567
591
  },
568
592
  subscribe: (emit) => {
package/dist/types.d.ts CHANGED
@@ -193,6 +193,23 @@ export interface DecentAgentNetConfig {
193
193
  friends?: FriendsConfig;
194
194
  dora?: DoraConfig;
195
195
  forwarding?: ForwardingConfig;
196
+ call?: CallConfig;
197
+ }
198
+ export interface CallConfig {
199
+ /** Enable the offline-call fallback (socket.io + push). Default true. When a
200
+ * call target has no live Carrier session, ring it via the Beagle push
201
+ * gateway and signal over socket.io instead of failing. */
202
+ offlineEnabled?: boolean;
203
+ /** Beagle push appKey — must match what the callee's app registered with. */
204
+ appKey?: string;
205
+ /** Beagle push appName (the callee app's CFBundleName, e.g. "Beagle"). */
206
+ appName?: string;
207
+ /** Socket.IO signaling gateway URL (primary). */
208
+ socketUrl?: string;
209
+ /** Ordered socket.IO gateways to try (failover). Defaults to [socketUrl]. */
210
+ socketUrls?: string[];
211
+ /** Push-API endpoints (POST /push-api/push-message), tried in order. */
212
+ pushEndpoints?: string[];
196
213
  }
197
214
  export interface ForwardingConfig {
198
215
  /** Pace + fair-queue egress IP data (default true). Prevents one bursty flow
@@ -20,6 +20,7 @@ const ICON_PATHS = {
20
20
  qr: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M14 14h3v3M21 14v.01M14 21h.01M17 21h.01M21 17v4"/>',
21
21
  // ---- app tiles ----
22
22
  video: '<path d="m22 8.5-5.4 3.5 5.4 3.5V8.5Z"/><rect x="2" y="6" width="14.5" height="12" rx="3"/>',
23
+ monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>',
23
24
  sparkles: '<path d="M12 3.5 13.6 9 19 10.5 13.6 12 12 17.5 10.4 12 5 10.5 10.4 9 12 3.5Z"/><path d="M19 3.5v3.2M20.6 5.1h-3.2"/><path d="M5 16v2.4M6.2 17.2H3.8"/>',
24
25
  mic: '<rect x="9" y="2.5" width="6" height="11.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0"/><path d="M12 17.5v3.5"/>',
25
26
  flask: '<path d="M9.5 3v6.2L4.8 17.4A2 2 0 0 0 6.5 20.5h11a2 2 0 0 0 1.7-3.1L14.5 9.2V3"/><path d="M8.5 3h7"/><path d="M7.3 15h9.4"/>',
@@ -1596,6 +1597,7 @@ function useCallController(selfId) {
1596
1597
  const [active, setActive] = React.useState(null);
1597
1598
  const [localStream, setLocalStream] = React.useState(null);
1598
1599
  const [remoteStream, setRemoteStream] = React.useState(null);
1600
+ const [sharing, setSharing] = React.useState(false);
1599
1601
  const engineRef = React.useRef(null);
1600
1602
  React.useEffect(() => {
1601
1603
  if (!selfId || engineRef.current) return;
@@ -1610,12 +1612,19 @@ function useCallController(selfId) {
1610
1612
  signaling,
1611
1613
  createPeerConnection: (c) => new RTCPeerConnection(c),
1612
1614
  getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
1615
+ getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
1613
1616
  iceServers: CALL_ICE_SERVERS,
1614
1617
  logger: (m) => console.log(m)
1615
1618
  });
1616
1619
  engine.on("incomingCall", (info) => setIncoming(info));
1617
1620
  engine.on("stateChanged", (info, state) => setActive((a) => a && a.callId === info.callId ? { ...a, state } : a));
1618
- engine.on("localStream", (id, s) => setLocalStream(s));
1621
+ engine.on("localStream", (id, s) => {
1622
+ setLocalStream(s);
1623
+ try {
1624
+ setSharing(engine.isSharingScreen(id));
1625
+ } catch (e) {
1626
+ }
1627
+ });
1619
1628
  engine.on("remoteStream", (id, s) => setRemoteStream(s));
1620
1629
  engine.on("ended", (id) => {
1621
1630
  setActive((a) => a && a.callId === id ? null : a);
@@ -1633,18 +1642,17 @@ function useCallController(selfId) {
1633
1642
  engineRef.current = null;
1634
1643
  };
1635
1644
  }, [selfId]);
1636
- const start = React.useCallback(async (peerId, video) => {
1645
+ const start = React.useCallback((peerId, video) => {
1637
1646
  const eng = engineRef.current;
1638
1647
  if (!eng) {
1639
1648
  alert("Calls unavailable (WebRTC not supported here)");
1640
1649
  return;
1641
1650
  }
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) {
1651
+ setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
1652
+ eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
1653
+ setActive((a) => a && a.peerId === peerId ? null : a);
1646
1654
  alert("Could not start call: " + (e && e.message || e));
1647
- }
1655
+ });
1648
1656
  }, []);
1649
1657
  const accept = React.useCallback(async () => {
1650
1658
  const eng = engineRef.current;
@@ -1666,7 +1674,7 @@ function useCallController(selfId) {
1666
1674
  const hangup = React.useCallback(() => {
1667
1675
  const eng = engineRef.current;
1668
1676
  setActive((a) => {
1669
- if (eng && a) eng.hangup(a.callId);
1677
+ if (eng && a && a.callId) eng.hangup(a.callId);
1670
1678
  return null;
1671
1679
  });
1672
1680
  setLocalStream(null);
@@ -1674,13 +1682,23 @@ function useCallController(selfId) {
1674
1682
  }, []);
1675
1683
  const setMuted = React.useCallback((m) => {
1676
1684
  const eng = engineRef.current, a = active;
1677
- if (eng && a) eng.setLocalTrackEnabled(a.callId, "audio", !m);
1685
+ if (eng && a && a.callId) eng.setLocalTrackEnabled(a.callId, "audio", !m);
1686
+ }, [active]);
1687
+ const toggleShare = React.useCallback(async () => {
1688
+ const eng = engineRef.current, a = active;
1689
+ if (!eng || !a || !a.callId) return;
1690
+ try {
1691
+ if (eng.isSharingScreen(a.callId)) await eng.stopScreenShare(a.callId);
1692
+ else await eng.shareScreen(a.callId);
1693
+ } catch (e) {
1694
+ if (e && e.name !== "NotAllowedError") alert("Screen share failed: " + (e && e.message || e));
1695
+ }
1678
1696
  }, [active]);
1679
1697
  const setVideoOn = React.useCallback((v) => {
1680
1698
  const eng = engineRef.current, a = active;
1681
- if (eng && a) eng.setLocalTrackEnabled(a.callId, "video", v);
1699
+ if (eng && a && a.callId) eng.setLocalTrackEnabled(a.callId, "video", v);
1682
1700
  }, [active]);
1683
- return { incoming, active, localStream, remoteStream, start, accept, reject, hangup, setMuted, setVideoOn };
1701
+ return { incoming, active, localStream, remoteStream, sharing, start, accept, reject, hangup, setMuted, setVideoOn, toggleShare };
1684
1702
  }
1685
1703
  function VideoSurface({ stream, muted, style }) {
1686
1704
  const ref = React.useCallback((el) => {
@@ -1790,7 +1808,8 @@ function CallOverlay({ T, ctl, peers }) {
1790
1808
  setMuted(n);
1791
1809
  ctl.setMuted(n);
1792
1810
  };
1793
- 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 }));
1811
+ const canShare = connected && typeof navigator !== "undefined" && navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia;
1812
+ 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 }), canShare && /* @__PURE__ */ React.createElement(CallBtn, { icon: "monitor", label: ctl.sharing ? T && T.stopShare || "stop share" : T && T.share || "share", active: ctl.sharing, onClick: ctl.toggleShare }), /* @__PURE__ */ React.createElement(CallBtn, { icon: "phone", label: T && T.endCall || "end", danger: true, wide: true, onClick: ctl.hangup }));
1794
1813
  return /* @__PURE__ */ React.createElement("div", { style: { position: "fixed", inset: 0, zIndex: 70, background: "var(--bg)", display: "flex", flexDirection: "column" } }, ctl.remoteStream && /* @__PURE__ */ React.createElement(AudioSink, { stream: ctl.remoteStream }), /* @__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, muted: true, 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 } }, /* @__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)));
1795
1814
  }
1796
1815
  Object.assign(window, { useCallController, CallOverlay, IncomingCallModal });
@@ -28,10 +28,12 @@ var PeerWebRTC = (() => {
28
28
  // node_modules/@decentnetwork/peer-webrtc/dist/index.js
29
29
  var dist_exports = {};
30
30
  __export(dist_exports, {
31
+ BeaglePushClient: () => BeaglePushClient,
31
32
  BroadcastChannelSignaling: () => BroadcastChannelSignaling,
32
33
  CallEngine: () => CallEngine,
33
34
  CarrierSignaling: () => CarrierSignaling,
34
35
  Emitter: () => Emitter,
36
+ SocketIoSignaling: () => SocketIoSignaling,
35
37
  decodeSignal: () => decodeSignal,
36
38
  decodeSignalBytesGuard: () => decodeSignalBytesGuard,
37
39
  encodeSignal: () => encodeSignal,
@@ -151,7 +153,7 @@ var PeerWebRTC = (() => {
151
153
  var DEFAULT_ICE_SERVERS = [
152
154
  { urls: "stun:stun.l.google.com:19302" }
153
155
  ];
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, normId_fn, _toInfo, newCallId_fn, log_fn;
156
+ var _opts, _iceServers, _sessions, _CallEngine_instances, setOutgoingVideoTrack_fn, renegotiate_fn, handleSignal_fn, onOffer_fn, answerRenegotiation_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, normId_fn, _toInfo, newCallId_fn, log_fn;
155
157
  var CallEngine = class extends Emitter {
156
158
  constructor(opts) {
157
159
  var _a;
@@ -197,17 +199,26 @@ var PeerWebRTC = (() => {
197
199
  * "remoteStream"/"ended" as the call progresses.
198
200
  */
199
201
  async call(peerId, kinds = {}) {
200
- var _a, _b, _c;
202
+ var _a, _b, _c, _d, _e;
201
203
  const audio = (_a = kinds.audio) != null ? _a : true;
202
204
  const video = (_b = kinds.video) != null ? _b : false;
203
205
  const data = (_c = kinds.data) != null ? _c : false;
204
206
  const callId = __privateMethod(this, _CallEngine_instances, newCallId_fn).call(this);
205
207
  const session = __privateMethod(this, _CallEngine_instances, createSession_fn).call(this, callId, peerId, "outgoing", { audio, video, data });
206
208
  await __privateMethod(this, _CallEngine_instances, attachLocalMedia_fn).call(this, session);
207
- const offer = await session.pc.createOffer({
208
- offerToReceiveAudio: audio,
209
- offerToReceiveVideo: video
210
- });
209
+ if (audio && !((_d = session.localStream) == null ? void 0 : _d.getAudioTracks().length)) {
210
+ try {
211
+ session.pc.addTransceiver("audio", { direction: "recvonly" });
212
+ } catch {
213
+ }
214
+ }
215
+ if (video && !((_e = session.localStream) == null ? void 0 : _e.getVideoTracks().length)) {
216
+ try {
217
+ session.pc.addTransceiver("video", { direction: "recvonly" });
218
+ } catch {
219
+ }
220
+ }
221
+ const offer = await session.pc.createOffer();
211
222
  await session.pc.setLocalDescription(offer);
212
223
  const options = [];
213
224
  if (audio)
@@ -275,6 +286,50 @@ var PeerWebRTC = (() => {
275
286
  track.enabled = enabled;
276
287
  }
277
288
  }
289
+ /** Start sharing the screen on an active call. Captures the display, sends it
290
+ * as the outgoing video track (replacing the camera if any), and renegotiates
291
+ * so the peer receives it. Works even with no camera. Ending the OS "stop
292
+ * sharing" prompt reverts automatically. */
293
+ async shareScreen(callId) {
294
+ const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, callId));
295
+ if (!session)
296
+ throw new Error(`shareScreen: no call ${callId}`);
297
+ if (!__privateGet(this, _opts).getDisplayMedia)
298
+ throw new Error("screen share not supported here");
299
+ const screen = await __privateGet(this, _opts).getDisplayMedia({ video: true, audio: false });
300
+ const track = screen.getVideoTracks()[0];
301
+ if (!track)
302
+ return;
303
+ if (!session.screenStream)
304
+ session.cameraStream = session.localStream;
305
+ session.screenStream = screen;
306
+ await __privateMethod(this, _CallEngine_instances, setOutgoingVideoTrack_fn).call(this, session, track, screen);
307
+ track.addEventListener("ended", () => {
308
+ void this.stopScreenShare(callId);
309
+ });
310
+ await __privateMethod(this, _CallEngine_instances, renegotiate_fn).call(this, session);
311
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `shareScreen started for ${callId}`);
312
+ }
313
+ /** Stop screen sharing; restore the camera track if there was one. */
314
+ async stopScreenShare(callId) {
315
+ var _a;
316
+ const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, callId));
317
+ if (!(session == null ? void 0 : session.screenStream))
318
+ return;
319
+ for (const t of session.screenStream.getTracks())
320
+ t.stop();
321
+ session.screenStream = void 0;
322
+ const camTrack = (_a = session.cameraStream) == null ? void 0 : _a.getVideoTracks()[0];
323
+ await __privateMethod(this, _CallEngine_instances, setOutgoingVideoTrack_fn).call(this, session, camTrack != null ? camTrack : null, session.cameraStream);
324
+ session.cameraStream = void 0;
325
+ await __privateMethod(this, _CallEngine_instances, renegotiate_fn).call(this, session);
326
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `shareScreen stopped for ${callId}`);
327
+ }
328
+ /** True when the call is currently sharing the screen. */
329
+ isSharingScreen(callId) {
330
+ var _a;
331
+ return !!((_a = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, callId))) == null ? void 0 : _a.screenStream);
332
+ }
278
333
  /** Tear down every call (e.g. on app shutdown). */
279
334
  dispose() {
280
335
  for (const session of [...__privateGet(this, _sessions).values()]) {
@@ -287,6 +342,44 @@ var PeerWebRTC = (() => {
287
342
  _iceServers = new WeakMap();
288
343
  _sessions = new WeakMap();
289
344
  _CallEngine_instances = new WeakSet();
345
+ setOutgoingVideoTrack_fn = async function(session, track, stream) {
346
+ var _a;
347
+ const transceivers = session.pc.getTransceivers();
348
+ const tr = (_a = transceivers.find((t) => {
349
+ var _a2;
350
+ return ((_a2 = t.sender.track) == null ? void 0 : _a2.kind) === "video";
351
+ })) != null ? _a : transceivers.find((t) => {
352
+ var _a2;
353
+ return ((_a2 = t.receiver.track) == null ? void 0 : _a2.kind) === "video";
354
+ });
355
+ if (tr) {
356
+ await tr.sender.replaceTrack(track);
357
+ if (track) {
358
+ if (tr.direction === "recvonly")
359
+ tr.direction = "sendrecv";
360
+ else if (tr.direction === "inactive")
361
+ tr.direction = "sendonly";
362
+ }
363
+ } else if (track && stream) {
364
+ session.pc.addTrack(track, stream);
365
+ }
366
+ session.localStream = stream;
367
+ this.emit("localStream", session.callId, stream != null ? stream : new MediaStream());
368
+ };
369
+ renegotiate_fn = async function(session) {
370
+ try {
371
+ const offer = await session.pc.createOffer();
372
+ await session.pc.setLocalDescription(offer);
373
+ const options = [];
374
+ if (session.audio)
375
+ options.push("audio");
376
+ if (session.video || session.screenStream)
377
+ options.push("video");
378
+ await __privateMethod(this, _CallEngine_instances, send_fn).call(this, session.peerId, { type: "offer", sdp: offer.sdp, options, callId: session.callId });
379
+ } catch (err) {
380
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `renegotiate failed: ${err.message}`);
381
+ }
382
+ };
290
383
  // ---- internals -------------------------------------------------------
291
384
  handleSignal_fn = function(peerId, signal) {
292
385
  switch (signal.type) {
@@ -317,7 +410,10 @@ var PeerWebRTC = (() => {
317
410
  return;
318
411
  const existing = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
319
412
  if (existing) {
320
- existing.pendingOffer = signal.sdp;
413
+ if (existing.hasReceivedSdp)
414
+ void __privateMethod(this, _CallEngine_instances, answerRenegotiation_fn).call(this, existing, signal.sdp);
415
+ else
416
+ existing.pendingOffer = signal.sdp;
321
417
  return;
322
418
  }
323
419
  const audio = signal.options ? signal.options.includes("audio") : true;
@@ -328,15 +424,27 @@ var PeerWebRTC = (() => {
328
424
  this.emit("incomingCall", __privateGet(this, _toInfo).call(this, session));
329
425
  __privateMethod(this, _CallEngine_instances, log_fn).call(this, `incoming call from ${peerId} callId=${signal.callId} audio=${audio} video=${video}`);
330
426
  };
427
+ answerRenegotiation_fn = async function(session, sdp) {
428
+ try {
429
+ await session.pc.setRemoteDescription({ type: "offer", sdp });
430
+ const answer = await session.pc.createAnswer();
431
+ await session.pc.setLocalDescription(answer);
432
+ await __privateMethod(this, _CallEngine_instances, send_fn).call(this, session.peerId, { type: "answer", sdp: answer.sdp, callId: session.callId });
433
+ } catch (err) {
434
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `renegotiation answer failed: ${err.message}`);
435
+ }
436
+ };
331
437
  onAnswer_fn = function(peerId, signal) {
332
438
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
333
- if (!session || session.peerId !== peerId || !signal.sdp)
439
+ if (!session || !signal.sdp)
334
440
  return;
441
+ session.peerId = peerId;
335
442
  void (async () => {
336
443
  try {
337
444
  await session.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
338
445
  session.hasReceivedSdp = true;
339
- __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "connecting");
446
+ if (session.state !== "connected")
447
+ __privateMethod(this, _CallEngine_instances, setState_fn).call(this, session, "connecting");
340
448
  await __privateMethod(this, _CallEngine_instances, drainRemoteCandidates_fn).call(this, session);
341
449
  await __privateMethod(this, _CallEngine_instances, flushLocalCandidates_fn).call(this, session);
342
450
  } catch (err) {
@@ -347,7 +455,7 @@ var PeerWebRTC = (() => {
347
455
  onCandidate_fn = function(peerId, signal) {
348
456
  var _a;
349
457
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
350
- if (!session || session.peerId !== peerId || !signal.candidates)
458
+ if (!session || !signal.candidates)
351
459
  return;
352
460
  for (const c of signal.candidates) {
353
461
  const init = {
@@ -365,7 +473,7 @@ var PeerWebRTC = (() => {
365
473
  onBye_fn = function(peerId, signal) {
366
474
  var _a;
367
475
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
368
- if (!session || session.peerId !== peerId)
476
+ if (!session)
369
477
  return;
370
478
  const reason = (_a = signal.reason) != null ? _a : "normal";
371
479
  const state = reason === "busy" ? "busy" : reason === "declined" ? "declined" : "ended";
@@ -375,7 +483,7 @@ var PeerWebRTC = (() => {
375
483
  };
376
484
  onAuxiliary_fn = function(peerId, signal) {
377
485
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
378
- if (!session || session.peerId !== peerId)
486
+ if (!session)
379
487
  return;
380
488
  if (signal.type === "event") {
381
489
  if (signal.event === "ringing")
@@ -427,12 +535,8 @@ var PeerWebRTC = (() => {
427
535
  }
428
536
  };
429
537
  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
- }
538
+ const stream = __privateMethod(this, _CallEngine_instances, ensureRemoteStream_fn).call(this, session, ev.track);
539
+ this.emit("remoteStream", session.callId, stream);
436
540
  };
437
541
  pc.oniceconnectionstatechange = () => {
438
542
  switch (pc.iceConnectionState) {
@@ -459,7 +563,8 @@ var PeerWebRTC = (() => {
459
563
  ensureRemoteStream_fn = function(session, track) {
460
564
  if (!session.remoteStream)
461
565
  session.remoteStream = new MediaStream();
462
- session.remoteStream.addTrack(track);
566
+ if (!session.remoteStream.getTracks().includes(track))
567
+ session.remoteStream.addTrack(track);
463
568
  return session.remoteStream;
464
569
  };
465
570
  attachLocalMedia_fn = async function(session) {
@@ -467,7 +572,24 @@ var PeerWebRTC = (() => {
467
572
  return;
468
573
  if (!__privateGet(this, _opts).getLocalMedia || !session.audio && !session.video)
469
574
  return;
470
- const stream = await __privateGet(this, _opts).getLocalMedia({ audio: session.audio, video: session.video });
575
+ let stream;
576
+ try {
577
+ stream = await __privateGet(this, _opts).getLocalMedia({ audio: session.audio, video: session.video });
578
+ } catch (err) {
579
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `getLocalMedia(audio:${session.audio},video:${session.video}) failed: ${err.message}`);
580
+ if (session.video && session.audio) {
581
+ try {
582
+ stream = await __privateGet(this, _opts).getLocalMedia({ audio: true, video: false });
583
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `local camera unavailable \u2014 sending audio only, still receiving video, call ${session.callId}`);
584
+ } catch (err2) {
585
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `audio-only fallback failed too: ${err2.message}`);
586
+ }
587
+ }
588
+ }
589
+ if (!stream) {
590
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `no local media for ${session.callId}; connecting receive-only`);
591
+ return;
592
+ }
471
593
  session.localStream = stream;
472
594
  for (const track of stream.getTracks()) {
473
595
  session.pc.addTrack(track, stream);
@@ -631,5 +753,237 @@ var PeerWebRTC = (() => {
631
753
  };
632
754
  _peer = new WeakMap();
633
755
  _ext = new WeakMap();
756
+
757
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/socketio.js
758
+ var _socket, _userId, _handler2, _rooms, _pending, _lastOffer, _wired, _SocketIoSignaling_instances, roomKey_fn, ensureRoom_fn, emitMessage_fn, wire_fn;
759
+ var SocketIoSignaling = class {
760
+ constructor(socket, opts) {
761
+ __privateAdd(this, _SocketIoSignaling_instances);
762
+ __privateAdd(this, _socket);
763
+ __privateAdd(this, _userId);
764
+ __privateAdd(this, _handler2);
765
+ /** Rooms we've asked to join (callId -> confirmed self-membership). */
766
+ __privateAdd(this, _rooms, /* @__PURE__ */ new Map());
767
+ /** Signals queued per room until the server confirms our membership. */
768
+ __privateAdd(this, _pending, /* @__PURE__ */ new Map());
769
+ /** Last offer per callId, RE-SENT when the peer joins the room — the callee
770
+ * (woken by a push) joins seconds after we sent the offer, and socket.io does
771
+ * NOT replay, so without this the peer never sees the offer and never
772
+ * answers (offline call stuck at "connecting"). */
773
+ __privateAdd(this, _lastOffer, /* @__PURE__ */ new Map());
774
+ /** The remote peer id per room, learned from inbound messages so a queued
775
+ * peer-greeting can be addressed. */
776
+ __privateAdd(this, _wired, false);
777
+ __privateSet(this, _socket, socket);
778
+ __privateSet(this, _userId, opts.userId);
779
+ }
780
+ onSignal(handler) {
781
+ __privateSet(this, _handler2, handler);
782
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
783
+ }
784
+ /**
785
+ * Deliver a signal to `peerId`. Joins the call's room (keyed by
786
+ * `signal.callId`) on first use and queues until the server confirms
787
+ * membership, then emits — mirroring the iOS provider, which queues until
788
+ * `connectToRoom`.
789
+ */
790
+ send(peerId, signal) {
791
+ var _a;
792
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
793
+ const callId = signal.callId;
794
+ if (!callId) {
795
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, signal);
796
+ return;
797
+ }
798
+ if (signal.type === "offer")
799
+ __privateGet(this, _lastOffer).set(callId, signal);
800
+ const room = __privateMethod(this, _SocketIoSignaling_instances, ensureRoom_fn).call(this, callId);
801
+ if (room.joined) {
802
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, signal);
803
+ } else {
804
+ const q = (_a = __privateGet(this, _pending).get(callId)) != null ? _a : [];
805
+ q.push(signal);
806
+ __privateGet(this, _pending).set(callId, q);
807
+ }
808
+ }
809
+ /**
810
+ * Explicitly join the room for an INCOMING call (e.g. after a push
811
+ * notification delivered the `callId`), so inbound signals for it are
812
+ * received. Safe to call more than once.
813
+ */
814
+ joinCall(callId) {
815
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
816
+ __privateMethod(this, _SocketIoSignaling_instances, ensureRoom_fn).call(this, callId);
817
+ }
818
+ /** Leave a call's room (best-effort) and forget its queued state. */
819
+ leaveCall(callId) {
820
+ __privateGet(this, _rooms).delete(callId);
821
+ __privateGet(this, _pending).delete(callId);
822
+ __privateGet(this, _lastOffer).delete(callId);
823
+ __privateGet(this, _socket).emit("leave-channel", { channel: __privateMethod(this, _SocketIoSignaling_instances, roomKey_fn).call(this, callId), sender: __privateGet(this, _userId) });
824
+ }
825
+ };
826
+ _socket = new WeakMap();
827
+ _userId = new WeakMap();
828
+ _handler2 = new WeakMap();
829
+ _rooms = new WeakMap();
830
+ _pending = new WeakMap();
831
+ _lastOffer = new WeakMap();
832
+ _wired = new WeakMap();
833
+ _SocketIoSignaling_instances = new WeakSet();
834
+ /** The socket.io ROOM key. The native Beagle apps join the room with the
835
+ * call's `UUID.uuidString`, which Swift renders UPPERCASE — so a lowercase
836
+ * `crypto.randomUUID()` from JS would land us in a DIFFERENT room and we'd
837
+ * never receive the peer's answer/candidates. Canonicalize to UPPERCASE to
838
+ * share the native peer's room. (The RtcSignal payload callId stays as-is;
839
+ * the CallEngine matches it case-insensitively.) */
840
+ roomKey_fn = function(callId) {
841
+ return callId.toUpperCase();
842
+ };
843
+ ensureRoom_fn = function(callId) {
844
+ let room = __privateGet(this, _rooms).get(callId);
845
+ if (!room) {
846
+ room = { joined: false };
847
+ __privateGet(this, _rooms).set(callId, room);
848
+ __privateGet(this, _socket).emit("new-channel", { channel: __privateMethod(this, _SocketIoSignaling_instances, roomKey_fn).call(this, callId), sender: __privateGet(this, _userId) });
849
+ }
850
+ return room;
851
+ };
852
+ emitMessage_fn = function(signal) {
853
+ __privateGet(this, _socket).emit("message", { data: encodeSignal(signal), sender: __privateGet(this, _userId) });
854
+ };
855
+ wire_fn = function() {
856
+ if (__privateGet(this, _wired))
857
+ return;
858
+ __privateSet(this, _wired, true);
859
+ __privateGet(this, _socket).on("message", (...args) => {
860
+ var _a;
861
+ const payload = firstObject(args);
862
+ if (!(payload == null ? void 0 : payload.data) || !payload.sender)
863
+ return;
864
+ if (payload.sender === __privateGet(this, _userId))
865
+ return;
866
+ const signal = decodeSignalBytesGuard(payload.data);
867
+ if (signal)
868
+ (_a = __privateGet(this, _handler2)) == null ? void 0 : _a.call(this, payload.sender, signal);
869
+ });
870
+ __privateGet(this, _socket).on("connectToRoom", (...args) => {
871
+ const payload = firstObject(args);
872
+ const sender = payload == null ? void 0 : payload.sender;
873
+ if (!sender)
874
+ return;
875
+ if (sender === __privateGet(this, _userId)) {
876
+ for (const [callId, room] of __privateGet(this, _rooms)) {
877
+ room.joined = true;
878
+ const q = __privateGet(this, _pending).get(callId);
879
+ if (q && q.length) {
880
+ for (const sig of q)
881
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, sig);
882
+ __privateGet(this, _pending).delete(callId);
883
+ }
884
+ }
885
+ } else {
886
+ for (const callId of __privateGet(this, _rooms).keys()) {
887
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, { type: "event", callId, event: "online" });
888
+ const offer = __privateGet(this, _lastOffer).get(callId);
889
+ if (offer)
890
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, offer);
891
+ }
892
+ }
893
+ });
894
+ __privateGet(this, _socket).on("disconnectToRoom", (...args) => {
895
+ const payload = firstObject(args);
896
+ const sender = payload == null ? void 0 : payload.sender;
897
+ if (sender && sender === __privateGet(this, _userId)) {
898
+ for (const room of __privateGet(this, _rooms).values())
899
+ room.joined = false;
900
+ }
901
+ });
902
+ };
903
+ function firstObject(args) {
904
+ const first = args[0];
905
+ return first && typeof first === "object" ? first : void 0;
906
+ }
907
+
908
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/push.js
909
+ var DEFAULT_ENDPOINTS = [
910
+ "https://pushapi.beagle.chat/push-api/push-message",
911
+ "https://www.callpass.cn/push-api/push-message",
912
+ "https://tokyo.fi.chat:3004/push-api/push-message"
913
+ ];
914
+ var _appKey, _appName, _endpoints, _fetch;
915
+ var BeaglePushClient = class {
916
+ constructor(opts) {
917
+ __privateAdd(this, _appKey);
918
+ __privateAdd(this, _appName);
919
+ __privateAdd(this, _endpoints);
920
+ __privateAdd(this, _fetch);
921
+ var _a, _b;
922
+ __privateSet(this, _appKey, opts.appKey);
923
+ __privateSet(this, _appName, opts.appName);
924
+ __privateSet(this, _endpoints, ((_a = opts.endpoints) == null ? void 0 : _a.length) ? opts.endpoints : DEFAULT_ENDPOINTS);
925
+ const f = (_b = opts.fetch) != null ? _b : globalThis.fetch;
926
+ if (!f)
927
+ throw new Error("BeaglePushClient: no fetch available \u2014 pass opts.fetch");
928
+ __privateSet(this, _fetch, f);
929
+ }
930
+ /**
931
+ * Ring an offline peer. `calleeUserId` is the destination (their Carrier user
932
+ * id); the payload describes the call. Tries each endpoint until one accepts.
933
+ * Resolves `true` on success, `false` if every endpoint failed.
934
+ */
935
+ async sendCallPush(calleeUserId, payload) {
936
+ var _a;
937
+ const params = new URLSearchParams({
938
+ account: calleeUserId,
939
+ payload: JSON.stringify(payload),
940
+ appKey: __privateGet(this, _appKey),
941
+ appName: __privateGet(this, _appName)
942
+ }).toString();
943
+ for (const endpoint of __privateGet(this, _endpoints)) {
944
+ try {
945
+ const url = endpoint + (endpoint.includes("?") ? "&" : "?") + params;
946
+ const res = await __privateGet(this, _fetch).call(this, url, { method: "POST" });
947
+ if (!res.ok)
948
+ continue;
949
+ const json = await res.json().catch(() => ({}));
950
+ const code = json.code;
951
+ const ok = code === void 0 || code === 0 || code === "0" || ((_a = json.data) == null ? void 0 : _a.success) === true;
952
+ if (ok)
953
+ return true;
954
+ } catch {
955
+ }
956
+ }
957
+ return false;
958
+ }
959
+ /** Convenience: ring `calleeUserId` for a call over `channel`. */
960
+ ring(calleeUserId, args) {
961
+ var _a;
962
+ return this.sendCallPush(calleeUserId, {
963
+ userId: args.callerUserId,
964
+ hasVideo: args.hasVideo ? "true" : "false",
965
+ callId: args.callId,
966
+ callName: args.callerName,
967
+ action: "call",
968
+ channel: (_a = args.channel) != null ? _a : "socketio"
969
+ });
970
+ }
971
+ /** Convenience: cancel a ring (e.g. caller hung up before answer). */
972
+ cancel(calleeUserId, args) {
973
+ var _a;
974
+ return this.sendCallPush(calleeUserId, {
975
+ userId: args.callerUserId,
976
+ hasVideo: args.hasVideo ? "true" : "false",
977
+ callId: args.callId,
978
+ callName: args.callerName,
979
+ action: "decline",
980
+ channel: (_a = args.channel) != null ? _a : "socketio"
981
+ });
982
+ }
983
+ };
984
+ _appKey = new WeakMap();
985
+ _appName = new WeakMap();
986
+ _endpoints = new WeakMap();
987
+ _fetch = new WeakMap();
634
988
  return __toCommonJS(dist_exports);
635
989
  })();
package/dist/ui/server.js CHANGED
@@ -198,11 +198,13 @@ export function startFriendUi(opts) {
198
198
  const file = join(DESKTOP_DIR, rel);
199
199
  // Guard against path traversal: resolved file must stay under DESKTOP_DIR.
200
200
  if (existsSync(file) && file.startsWith(DESKTOP_DIR)) {
201
- // app.js changes on every UI rebuild never cache it. Vendored React
202
- // UMD is stable, so let it cache normally.
201
+ // app.js AND vendor/peer-webrtc.js change on every rebuild / SDK bump
202
+ // never cache them, or the browser keeps running stale call/UI code
203
+ // (this silently defeated every peer-webrtc fix). Only the truly stable
204
+ // vendored libs (React UMD) may cache.
203
205
  const headers = { "content-type": "application/javascript; charset=utf-8" };
204
- if (rel === "app.js")
205
- headers["cache-control"] = "no-store";
206
+ const volatile = rel === "app.js" || rel.includes("peer-webrtc");
207
+ headers["cache-control"] = volatile ? "no-store" : "public, max-age=31536000";
206
208
  res.writeHead(200, headers);
207
209
  res.end(readFileSync(file));
208
210
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.166",
3
+ "version": "0.1.176",
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,12 +79,13 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.13",
82
- "@decentnetwork/peer": "^0.1.86",
83
- "@decentnetwork/peer-webrtc": "^0.1.1",
82
+ "@decentnetwork/peer": "^0.1.91",
83
+ "@decentnetwork/peer-webrtc": "^0.2.8",
84
84
  "ink": "^5.2.1",
85
85
  "js-yaml": "^4.1.0",
86
86
  "node-forge": "^1.4.0",
87
87
  "react": "^18.3.1",
88
+ "socket.io-client": "^4.7.5",
88
89
  "yargs": "^17.7.2"
89
90
  },
90
91
  "devDependencies": {