@decentnetwork/lan 0.1.254 → 0.1.256

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.
@@ -27,6 +27,17 @@ export interface PeerManagerOptions {
27
27
  * signals over socket.io instead of failing. */
28
28
  offlineCall?: OfflineCallConfig;
29
29
  }
30
+ /**
31
+ * True when a Carrier invite send failed for a reason that will recur
32
+ * identically on every retry, so retrying or waiting for a reconnect is
33
+ * pointless and the signal must take another transport or be lost.
34
+ *
35
+ * The one that bit us: the native invite channel caps application data at
36
+ * CARRIER_MAX_INVITE_DATA_LEN (8192, from carrier.h — it is a wire constant
37
+ * shared with the iOS/Android SDK, not something we can raise unilaterally),
38
+ * while a Chrome SDP offer/answer runs 8.4-9.8 KB.
39
+ */
40
+ export declare function isPermanentInviteFailure(err: unknown): boolean;
30
41
  export declare class PeerManager extends EventEmitter {
31
42
  private peer;
32
43
  private identity;
@@ -9,6 +9,29 @@ import { PacketSession } from "./packet-session.js";
9
9
  import { FrameCodec } from "./frame.js";
10
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";
11
11
  import { Logger } from "../utils/logger.js";
12
+ /**
13
+ * True when a Carrier invite send failed for a reason that will recur
14
+ * identically on every retry, so retrying or waiting for a reconnect is
15
+ * pointless and the signal must take another transport or be lost.
16
+ *
17
+ * The one that bit us: the native invite channel caps application data at
18
+ * CARRIER_MAX_INVITE_DATA_LEN (8192, from carrier.h — it is a wire constant
19
+ * shared with the iOS/Android SDK, not something we can raise unilaterally),
20
+ * while a Chrome SDP offer/answer runs 8.4-9.8 KB.
21
+ */
22
+ export function isPermanentInviteFailure(err) {
23
+ return /invite data must be/i.test(err?.message ?? "");
24
+ }
25
+ /** Best-effort RtcSignal type ("offer"/"answer"/"candidate"/"bye") for logs. */
26
+ function signalKind(data) {
27
+ try {
28
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
29
+ return String(JSON.parse(text).type ?? "signal");
30
+ }
31
+ catch {
32
+ return "signal";
33
+ }
34
+ }
12
35
  export class PeerManager extends EventEmitter {
13
36
  peer = null;
14
37
  identity = null;
@@ -181,7 +204,21 @@ export class PeerManager extends EventEmitter {
181
204
  // A call already committed to Carrier must NOT split to socket.io on a
182
205
  // transient flap (it breaks ICE). Drop this signal; ICE recovers via the
183
206
  // other candidates / a retried offer.
207
+ //
208
+ // That reasoning only holds for a TRANSIENT failure. An oversize payload
209
+ // fails identically on every retry, so "ICE recovers via a retried offer"
210
+ // is false — the retry is the same too-big offer. Chrome's SDP runs
211
+ // 8.4-9.8 KB against the 8192-byte native invite cap (carrier.h), which
212
+ // silently killed every desktop→phone call: the offer never left, so the
213
+ // phone never rang, and on an inbound call our answer never left, so it
214
+ // rang and then never connected. Route a permanently-undeliverable signal
215
+ // to the offline bridge instead of dropping it on the floor.
184
216
  if (callId && this.offlineBridge?.isOnlineCall(callId)) {
217
+ if (this.offlineBridge.enabled && isPermanentInviteFailure(err)) {
218
+ this.logger.warn(`sendCallSignal: ${signalKind(data)} for call ${callId} cannot go over Carrier (${err.message}) — offline path to ${userid}`);
219
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
220
+ return;
221
+ }
185
222
  this.logger.info(`sendCallSignal: dropped a Carrier signal for online call ${callId} (${err.message})`);
186
223
  return;
187
224
  }
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.254";
1
+ window.__DK_UI_VERSION="0.1.256";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -2454,6 +2454,40 @@ const CALL_ICE_SERVERS = [
2454
2454
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2455
2455
  { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2456
2456
  ];
2457
+ const VIDEO_CODEC_KEEP = /^video\/(H264|VP8|rtx|red|ulpfec)$/i;
2458
+ const SDP_BUDGET = 8192;
2459
+ function trimVideoCodecs(pc) {
2460
+ var _a, _b, _c, _d, _e, _f, _g;
2461
+ const supported = (_c = (_b = (_a = window.RTCRtpSender) == null ? void 0 : _a.getCapabilities) == null ? void 0 : _b.call(_a, "video")) == null ? void 0 : _c.codecs;
2462
+ if (!supported) return;
2463
+ const keep = supported.filter((c) => VIDEO_CODEC_KEEP.test(c.mimeType));
2464
+ if (!keep.length || keep.length === supported.length) return;
2465
+ for (const t of pc.getTransceivers()) {
2466
+ const kind = ((_e = (_d = t.receiver) == null ? void 0 : _d.track) == null ? void 0 : _e.kind) || ((_g = (_f = t.sender) == null ? void 0 : _f.track) == null ? void 0 : _g.kind);
2467
+ if (kind !== "video" || !t.setCodecPreferences) continue;
2468
+ try {
2469
+ t.setCodecPreferences(keep);
2470
+ } catch (e) {
2471
+ console.warn("[call] setCodecPreferences failed", e);
2472
+ }
2473
+ }
2474
+ }
2475
+ function createLeanPeerConnection(config) {
2476
+ const pc = new RTCPeerConnection(config);
2477
+ for (const method of ["createOffer", "createAnswer"]) {
2478
+ const original = pc[method].bind(pc);
2479
+ pc[method] = async (...args) => {
2480
+ trimVideoCodecs(pc);
2481
+ const desc = await original(...args);
2482
+ const bytes = new TextEncoder().encode(desc.sdp || "").length;
2483
+ console[bytes > SDP_BUDGET ? "warn" : "log"](
2484
+ `[call] ${method} SDP ${bytes}B / ${SDP_BUDGET}B budget` + (bytes > SDP_BUDGET ? " \u2014 too large for the Carrier invite channel, falling back to the offline path" : "")
2485
+ );
2486
+ return desc;
2487
+ };
2488
+ }
2489
+ return pc;
2490
+ }
2457
2491
  function useCallController(selfId, onCallLog) {
2458
2492
  const [incoming, setIncoming] = React.useState(null);
2459
2493
  const [active, setActive] = React.useState(null);
@@ -2476,7 +2510,7 @@ function useCallController(selfId, onCallLog) {
2476
2510
  const engine = new CallEngine({
2477
2511
  selfId,
2478
2512
  signaling,
2479
- createPeerConnection: (c) => new RTCPeerConnection(c),
2513
+ createPeerConnection: (c) => createLeanPeerConnection(c),
2480
2514
  getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
2481
2515
  getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
2482
2516
  iceServers: CALL_ICE_SERVERS,
@@ -2688,6 +2722,7 @@ function CallOverlay({ T, ctl, peers }) {
2688
2722
  { key: "local", stream: ctl.localStream, label: ctl.sharing ? T && T.yourScreen || "Your screen" : T && T.you || "You", mirror: !ctl.sharing }
2689
2723
  ].filter((t) => streamHasVideo(t.stream));
2690
2724
  const feat = tiles.find((t) => t.key === featured) || tiles[0] || { key: "remote", stream: ctl.remoteStream, label: name };
2725
+ const others = tiles.filter((t) => t.key !== feat.key);
2691
2726
  return (
2692
2727
  // The thumbnail strip and the controls FLOAT over the video instead of
2693
2728
  // stacking under it. A phone camera is 9:16, so the featured tile is
@@ -2708,11 +2743,12 @@ function CallOverlay({ T, ctl, peers }) {
2708
2743
  gap: 12,
2709
2744
  background: "linear-gradient(to top, rgba(0,0,0,.65), rgba(0,0,0,0))",
2710
2745
  pointerEvents: "none"
2711
- } }, tiles.length > 0 && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap", pointerEvents: "auto" } }, tiles.map((t) => /* @__PURE__ */ React.createElement(
2746
+ } }, others.length > 0 && /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap", pointerEvents: "auto" } }, others.map((t) => /* @__PURE__ */ React.createElement(
2712
2747
  "button",
2713
2748
  {
2714
2749
  key: t.key,
2715
2750
  onClick: () => setFeatured(t.key),
2751
+ title: `Show ${t.label}`,
2716
2752
  style: {
2717
2753
  position: "relative",
2718
2754
  width: 150,
@@ -2722,7 +2758,7 @@ function CallOverlay({ T, ctl, peers }) {
2722
2758
  background: "#000",
2723
2759
  cursor: "pointer",
2724
2760
  padding: 0,
2725
- border: feat.key === t.key ? "2px solid var(--accent)" : "1px solid var(--line)"
2761
+ border: "1px solid var(--line)"
2726
2762
  }
2727
2763
  },
2728
2764
  /* @__PURE__ */ React.createElement(VideoSurface, { stream: t.stream, muted: true, style: { width: "100%", height: "100%", objectFit: "contain", transform: t.mirror ? "scaleX(-1)" : "none" } }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.254",
3
+ "version": "0.1.256",
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",