@decentnetwork/peer 0.1.139 → 0.1.141

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.
@@ -0,0 +1,62 @@
1
+ /** "\u{1E}DNFT1" — the leading bytes of every frame. */
2
+ export declare const DNFT1_MAGIC: Uint8Array<ArrayBuffer>;
3
+ /** magic(6) + packetId(1) + payloadLen(4). */
4
+ export declare const DNFT1_HEADER_LENGTH = 11;
5
+ /** Wrap one file-transfer packet payload in a DNFT1 frame. */
6
+ export declare function encodeDnft1Frame(packetId: number, payload: Uint8Array): Uint8Array;
7
+ /**
8
+ * Parse a DNFT1 frame out of a received message body.
9
+ *
10
+ * Returns null for anything that is not a file-transfer frame (ordinary chat
11
+ * data — the common case, so this must stay cheap), and for a frame truncated
12
+ * below its declared payload length: dropped rather than half-parsed, since a
13
+ * short read would hand the engine a chunk with missing bytes. Bytes past
14
+ * payloadLen are transport junk and are ignored.
15
+ */
16
+ export declare function parseDnft1Frame(raw: Uint8Array): {
17
+ packetId: number;
18
+ payload: Uint8Array;
19
+ } | null;
20
+ /**
21
+ * Picks the transport for each friend's file packets: raw messenger packets
22
+ * (a JS peer, which is faster — no envelope, no message-channel framing) or
23
+ * DNFT1 frames (a native peer, the only thing that reaches its app layer).
24
+ *
25
+ * There is no reliable capability handshake to key this off — appVersion is
26
+ * absent for older builds and lies across forks — so the mode is LEARNED from
27
+ * what actually works, which needs no negotiation and self-corrects:
28
+ *
29
+ * - The engine already retransmits an unanswered OFFER until the receiver
30
+ * responds. Every UNANSWERED_OFFERS_BEFORE_SWITCH of those with no inbound
31
+ * file packet flips the transport. Alternating (rather than a one-way
32
+ * downgrade) matters: an old JS peer that cannot parse DNFT1 must not be
33
+ * stranded there by one slow response, and a native peer must not be
34
+ * stranded on raw. Each transport keeps getting tried until one answers.
35
+ * - The first inbound file packet PINS the mode to the transport it arrived
36
+ * on and stops the alternation. That is ground truth about what the peer
37
+ * speaks, so later transfers to that friend start on the right one.
38
+ *
39
+ * At the engine's offer cadence (~450ms) a switch costs about 1.4s, well
40
+ * inside the 60s give-up window, so the wrong initial guess is invisible.
41
+ */
42
+ export declare class Dnft1TransportPolicy {
43
+ #private;
44
+ /** Consecutive unanswered offers before trying the other transport. */
45
+ static readonly UNANSWERED_OFFERS_BEFORE_SWITCH = 3;
46
+ /** True when this friend's file packets should be sent as DNFT1 frames. */
47
+ useDnft1(friendId: string): boolean;
48
+ /** True once a real inbound file packet has settled the transport. */
49
+ isPinned(friendId: string): boolean;
50
+ /**
51
+ * Called for every outbound OFFER (packet 80). Returns true when this offer
52
+ * flipped the transport — the caller should send THIS offer on the new one.
53
+ */
54
+ noteOfferSent(friendId: string): boolean;
55
+ /**
56
+ * Called for every inbound file packet, with the transport it arrived on.
57
+ * Pins the mode: the peer just proved which one it speaks.
58
+ */
59
+ noteInbound(friendId: string, viaDnft1: boolean): void;
60
+ /** Drop all state for a friend (removed, or session torn down). */
61
+ forget(friendId: string): void;
62
+ }
@@ -0,0 +1,146 @@
1
+ // DNFT1 — carrying file-transfer packets 80–83 inside ordinary friend messages.
2
+ //
3
+ // Why this exists: the JS peer speaks toxcore messenger packets 80–83 natively,
4
+ // but a native app never sees them. The precompiled Carrier C SDK consumes
5
+ // 80/81/82 internally (they feed its own file state machine, which registers no
6
+ // app callbacks) and discards 83 (FEC — it does not know the id). A P0 probe on
7
+ // iOS confirmed it: raw packets never reach the mobile app layer, so JS↔iOS
8
+ // file transfer could not work at all.
9
+ //
10
+ // The fix keeps the SDK untouched: both ends carry the SAME packet payloads
11
+ // inside ordinary friend messages, wrapped in a frame no chat text can produce.
12
+ // iOS implemented it first (FileTransferWire.swift in the Beagle app); this is
13
+ // the byte-exact JS counterpart — read that file before changing anything here.
14
+ //
15
+ // [0x1E]["DNFT1"][packetId u8][payloadLen u32 BE][payload]
16
+ //
17
+ // - 0x1E (record separator) leads, mirroring the "\u{1E}DNPACK1:" text-ack
18
+ // envelope convention — a one-byte reject for ordinary chat text.
19
+ // - payloadLen is explicit because the carrier/express path can append junk
20
+ // past the real payload. Trailing junk after a binary chunk would corrupt the
21
+ // file, so the frame carries its own length and anything past it is ignored.
22
+ //
23
+ // The payloads inside are UNCHANGED — the same bytes filetransfer.ts already
24
+ // builds. This module only adds and removes the envelope, so the engine, the
25
+ // FEC layer and the conformance fixtures stay transport-agnostic.
26
+ import { PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_FEC, } from "./filetransfer.js";
27
+ /** "\u{1E}DNFT1" — the leading bytes of every frame. */
28
+ export const DNFT1_MAGIC = Uint8Array.from([0x1e, 0x44, 0x4e, 0x46, 0x54, 0x31]);
29
+ /** magic(6) + packetId(1) + payloadLen(4). */
30
+ export const DNFT1_HEADER_LENGTH = 11;
31
+ /** Wrap one file-transfer packet payload in a DNFT1 frame. */
32
+ export function encodeDnft1Frame(packetId, payload) {
33
+ const out = new Uint8Array(DNFT1_HEADER_LENGTH + payload.length);
34
+ out.set(DNFT1_MAGIC, 0);
35
+ out[6] = packetId & 0xff;
36
+ const n = payload.length;
37
+ out[7] = (n >>> 24) & 0xff;
38
+ out[8] = (n >>> 16) & 0xff;
39
+ out[9] = (n >>> 8) & 0xff;
40
+ out[10] = n & 0xff;
41
+ out.set(payload, DNFT1_HEADER_LENGTH);
42
+ return out;
43
+ }
44
+ /**
45
+ * Parse a DNFT1 frame out of a received message body.
46
+ *
47
+ * Returns null for anything that is not a file-transfer frame (ordinary chat
48
+ * data — the common case, so this must stay cheap), and for a frame truncated
49
+ * below its declared payload length: dropped rather than half-parsed, since a
50
+ * short read would hand the engine a chunk with missing bytes. Bytes past
51
+ * payloadLen are transport junk and are ignored.
52
+ */
53
+ export function parseDnft1Frame(raw) {
54
+ if (raw.length < DNFT1_HEADER_LENGTH)
55
+ return null;
56
+ for (let i = 0; i < DNFT1_MAGIC.length; i++) {
57
+ if (raw[i] !== DNFT1_MAGIC[i])
58
+ return null;
59
+ }
60
+ const packetId = raw[6];
61
+ if (packetId < PACKET_ID_FILE_SENDREQUEST || packetId > PACKET_ID_FILE_FEC)
62
+ return null;
63
+ // >>> 0 keeps the length unsigned: a hostile 0xFFFFFFFF must compare as
64
+ // 4294967295 (and fail the bounds check), not as -1 (which would pass it).
65
+ const length = ((raw[7] << 24) | (raw[8] << 16) | (raw[9] << 8) | raw[10]) >>> 0;
66
+ if (raw.length - DNFT1_HEADER_LENGTH < length)
67
+ return null;
68
+ return {
69
+ packetId,
70
+ payload: raw.subarray(DNFT1_HEADER_LENGTH, DNFT1_HEADER_LENGTH + length),
71
+ };
72
+ }
73
+ /**
74
+ * Picks the transport for each friend's file packets: raw messenger packets
75
+ * (a JS peer, which is faster — no envelope, no message-channel framing) or
76
+ * DNFT1 frames (a native peer, the only thing that reaches its app layer).
77
+ *
78
+ * There is no reliable capability handshake to key this off — appVersion is
79
+ * absent for older builds and lies across forks — so the mode is LEARNED from
80
+ * what actually works, which needs no negotiation and self-corrects:
81
+ *
82
+ * - The engine already retransmits an unanswered OFFER until the receiver
83
+ * responds. Every UNANSWERED_OFFERS_BEFORE_SWITCH of those with no inbound
84
+ * file packet flips the transport. Alternating (rather than a one-way
85
+ * downgrade) matters: an old JS peer that cannot parse DNFT1 must not be
86
+ * stranded there by one slow response, and a native peer must not be
87
+ * stranded on raw. Each transport keeps getting tried until one answers.
88
+ * - The first inbound file packet PINS the mode to the transport it arrived
89
+ * on and stops the alternation. That is ground truth about what the peer
90
+ * speaks, so later transfers to that friend start on the right one.
91
+ *
92
+ * At the engine's offer cadence (~450ms) a switch costs about 1.4s, well
93
+ * inside the 60s give-up window, so the wrong initial guess is invisible.
94
+ */
95
+ export class Dnft1TransportPolicy {
96
+ /** Consecutive unanswered offers before trying the other transport. */
97
+ static UNANSWERED_OFFERS_BEFORE_SWITCH = 3;
98
+ #dnft1 = new Set();
99
+ #pinned = new Set();
100
+ #unanswered = new Map();
101
+ /** True when this friend's file packets should be sent as DNFT1 frames. */
102
+ useDnft1(friendId) {
103
+ return this.#dnft1.has(friendId);
104
+ }
105
+ /** True once a real inbound file packet has settled the transport. */
106
+ isPinned(friendId) {
107
+ return this.#pinned.has(friendId);
108
+ }
109
+ /**
110
+ * Called for every outbound OFFER (packet 80). Returns true when this offer
111
+ * flipped the transport — the caller should send THIS offer on the new one.
112
+ */
113
+ noteOfferSent(friendId) {
114
+ if (this.#pinned.has(friendId))
115
+ return false;
116
+ const n = (this.#unanswered.get(friendId) ?? 0) + 1;
117
+ if (n < Dnft1TransportPolicy.UNANSWERED_OFFERS_BEFORE_SWITCH) {
118
+ this.#unanswered.set(friendId, n);
119
+ return false;
120
+ }
121
+ this.#unanswered.set(friendId, 0);
122
+ if (this.#dnft1.has(friendId))
123
+ this.#dnft1.delete(friendId);
124
+ else
125
+ this.#dnft1.add(friendId);
126
+ return true;
127
+ }
128
+ /**
129
+ * Called for every inbound file packet, with the transport it arrived on.
130
+ * Pins the mode: the peer just proved which one it speaks.
131
+ */
132
+ noteInbound(friendId, viaDnft1) {
133
+ this.#unanswered.delete(friendId);
134
+ this.#pinned.add(friendId);
135
+ if (viaDnft1)
136
+ this.#dnft1.add(friendId);
137
+ else
138
+ this.#dnft1.delete(friendId);
139
+ }
140
+ /** Drop all state for a friend (removed, or session torn down). */
141
+ forget(friendId) {
142
+ this.#dnft1.delete(friendId);
143
+ this.#pinned.delete(friendId);
144
+ this.#unanswered.delete(friendId);
145
+ }
146
+ }
package/dist/index.d.ts CHANGED
@@ -14,4 +14,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
14
14
  export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
15
15
  export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
16
16
  export type { CarrierAddressParts } from "./compat/address.js";
17
- export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
17
+ export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
package/dist/peer.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type BootstrapResult } from "./compat/bootstrap.js";
2
2
  import type { FriendRecord } from "./store/friends.js";
3
3
  import { type CarrierTurnServerInfo, type RtcIceServer } from "./ice-servers.js";
4
- import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
4
+ import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
5
5
  export declare class Peer {
6
6
  #private;
7
7
  private constructor();
@@ -140,7 +140,16 @@ export declare class Peer {
140
140
  name: string;
141
141
  data: Uint8Array;
142
142
  fileType?: "image" | "audio" | "text" | "unknown";
143
- }): Promise<void>;
143
+ deliveryTimeoutMs?: number;
144
+ }): Promise<InlineSendResult>;
145
+ /**
146
+ * True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
147
+ * DHT/relay key differs from its identity key, where a JS peer announces its
148
+ * identity key as its DHT key. Natives cannot see the toxcore file-transfer
149
+ * protocol (sendFile) — the inline envelope is their only file path — and
150
+ * they never speak the JS text-ACK scheme.
151
+ */
152
+ isNativeFriend(pubkey: string): boolean;
144
153
  /** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
145
154
  * This is the channel the iOS/Android WebRTC SDK uses for call signaling —
146
155
  * each RtcSignal arrives as an "invite" with ext="carrier" and `data` the
package/dist/peer.js CHANGED
@@ -13,6 +13,7 @@ import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONI
13
13
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
14
14
  import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
15
15
  import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA, PACKET_ID_FILE_FEC } from "./compat/filetransfer.js";
16
+ import { Dnft1TransportPolicy, encodeDnft1Frame, parseDnft1Frame } from "./compat/dnft1.js";
16
17
  import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
17
18
  import { LegacyExpressClient } from "./compat/express.js";
18
19
  import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
@@ -295,12 +296,22 @@ export class Peer {
295
296
  * old peer returns ACCEPT/ACK over its reliable TCP relay. */
296
297
  #fileRelayNegotiationUntil = new Map();
297
298
  // Toxcore-standard file transfer (wire-compatible with native Carrier/toxcore).
299
+ /** Which transport carries each friend's file packets — raw messenger
300
+ * packets (JS peers) or DNFT1 frames inside friend messages (native peers,
301
+ * whose Carrier SDK swallows 80-82 and drops 83). See compat/dnft1.ts. */
302
+ #dnft1 = new Dnft1TransportPolicy();
298
303
  #fileTransfer = new FileTransferManager((friendId, packetId, payload) => {
299
304
  if (packetId === PACKET_ID_FILE_SENDREQUEST) {
300
305
  const session = this.#friendSessions.get(friendId);
301
306
  const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
302
307
  if (!lan)
303
308
  this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 6_000);
309
+ // Every unanswered offer is evidence the peer cannot hear this
310
+ // transport; the policy alternates until one of them answers.
311
+ this.#dnft1.noteOfferSent(friendId);
312
+ }
313
+ if (this.#dnft1.useDnft1(friendId)) {
314
+ return this.#sendDnft1Frame(friendId, packetId, payload);
304
315
  }
305
316
  return this.#sendMessengerPacket(friendId, packetId, payload);
306
317
  }, (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
@@ -1180,6 +1191,7 @@ export class Peer {
1180
1191
  const existed = this.#friends.delete(friendId);
1181
1192
  this.#friendSessions.delete(friendId);
1182
1193
  this.#fileRelayNegotiationUntil.delete(friendId);
1194
+ this.#dnft1.forget(friendId);
1183
1195
  this.#pendingFriendRequests.delete(friendId);
1184
1196
  this.#cookieRetryCount.delete(friendId);
1185
1197
  this.#lastCookieSentKey.delete(friendId);
@@ -1240,6 +1252,9 @@ export class Peer {
1240
1252
  }
1241
1253
  await this.#sendTextPlain(pubkey, text);
1242
1254
  }
1255
+ /** What #sendTextPlain actually did with the message — callers that promise
1256
+ * delivery semantics (inline files) need to know the path and, for the live
1257
+ * path, where the send window stood after the last fragment. */
1243
1258
  async #sendTextPlain(pubkey, text) {
1244
1259
  const friend = this.#friends.get(pubkey);
1245
1260
  if (!friend) {
@@ -1307,7 +1322,10 @@ export class Peer {
1307
1322
  for (const p of livePackets) {
1308
1323
  await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, p);
1309
1324
  }
1310
- return;
1325
+ // Every fragment got a packet number BELOW the counter's position now —
1326
+ // when the peer's implicit acks advance the window past it, the whole
1327
+ // message is confirmed on their device.
1328
+ return { via: "online", endPacketNumber: this.#friendSessions.get(pubkey)?.sendPacketNumber };
1311
1329
  }
1312
1330
  catch (error) {
1313
1331
  const emsg = error.message;
@@ -1343,7 +1361,7 @@ export class Peer {
1343
1361
  // no session is simply a no-op.
1344
1362
  if (text.length === 0) {
1345
1363
  this.#debugLog(`sendText: empty connection-kick to ${pubkey} with no live session — dropping (not flooding express)`);
1346
- return;
1364
+ return { via: "online" };
1347
1365
  }
1348
1366
  // Offline / fallback path via Carrier express HTTP store-and-forward.
1349
1367
  // Skipped entirely in control-plane-only mode (decentlan's data plane):
@@ -1353,7 +1371,7 @@ export class Peer {
1353
1371
  if (this.#express?.hasNodes() && !this.#opts.expressControlPlaneOnly) {
1354
1372
  await this.#express.sendOfflineText(pubkey, packet);
1355
1373
  this.#debugLog(`sendText: queued via express for ${pubkey}`);
1356
- return;
1374
+ return { via: "offline" };
1357
1375
  }
1358
1376
  throw new Error("friend is offline and no express node is configured");
1359
1377
  }
@@ -1568,7 +1586,77 @@ export class Peer {
1568
1586
  if (envelope.length > CARRIER_MAX_APP_BULKMSG_LEN) {
1569
1587
  throw new Error(`inline file too large for the message channel (${envelope.length} bytes encoded, max ${CARRIER_MAX_APP_BULKMSG_LEN})`);
1570
1588
  }
1571
- await this.sendText(pubkey, envelope);
1589
+ // The confirmation window scales with the payload: a floor for handshake
1590
+ // latency plus time to move the envelope at a conservative 50 KB/s, capped
1591
+ // so a dead session can't pin the caller for ages.
1592
+ const deliveryTimeoutMs = opts.deliveryTimeoutMs ??
1593
+ Math.min(180_000, 20_000 + Math.ceil(envelope.length / 50));
1594
+ if (this.#shouldRequireTextAck(pubkey)) {
1595
+ // JS peer: the app-level text-ACK is the strongest confirmation there is
1596
+ // — the receiving app parsed the envelope. The retry interval must be
1597
+ // longer than one full send of the payload, or a slow path gets the
1598
+ // whole multi-MB envelope re-blasted mid-flight.
1599
+ await this.sendTextUntilAck(pubkey, envelope, {
1600
+ timeoutMs: deliveryTimeoutMs,
1601
+ retryIntervalMs: Math.max(TEXT_ACK_RETRY_MS, Math.ceil(envelope.length / 100))
1602
+ });
1603
+ return { delivery: "acked" };
1604
+ }
1605
+ // Native peer (iOS/Android/C): it will never speak our text-ACK envelope,
1606
+ // but its toxcore DOES acknowledge every reliable packet — the very signal
1607
+ // its own SDK surfaces to the app as "Delivered". Wait for the send window
1608
+ // to drain past our fragments.
1609
+ const outcome = await this.#sendTextPlain(pubkey, envelope);
1610
+ if (outcome.via === "offline")
1611
+ return { delivery: "offline" };
1612
+ if (outcome.endPacketNumber === undefined)
1613
+ return { delivery: "accepted" };
1614
+ const acked = await this.#awaitTransportAck(pubkey, outcome.endPacketNumber, deliveryTimeoutMs);
1615
+ this.#debugLog(`inline file "${opts.name}" to ${pubkey}: transport ${acked ? "ACKED" : `NOT confirmed within ${deliveryTimeoutMs}ms`}`);
1616
+ return { delivery: acked ? "acked" : "accepted" };
1617
+ }
1618
+ /**
1619
+ * True once the friend's implicit acks (PACKET_ID_REQUEST walks) have moved
1620
+ * our reliable send window past `endPacketNumber` — i.e. every packet we had
1621
+ * sent by that point is confirmed received on their device. False when the
1622
+ * window doesn't drain in time or the session dies/rekeys under us (a new
1623
+ * session restarts the numbering, so the old position proves nothing).
1624
+ */
1625
+ async #awaitTransportAck(pubkey, endPacketNumber, timeoutMs) {
1626
+ const watched = this.#friendSessions.get(pubkey);
1627
+ if (!watched)
1628
+ return false;
1629
+ const deadline = Date.now() + timeoutMs;
1630
+ for (;;) {
1631
+ const session = this.#friendSessions.get(pubkey);
1632
+ if (session !== watched || !session.established)
1633
+ return false;
1634
+ const start = session.sendBufferStartNum;
1635
+ if (start !== undefined) {
1636
+ // Wraparound-safe: the window is ≤8192 packets, so a "distance" over
1637
+ // 2^31 means start has passed endPacketNumber.
1638
+ const pending = (endPacketNumber - start) >>> 0;
1639
+ if (pending === 0 || pending > 0x80000000)
1640
+ return true;
1641
+ }
1642
+ else if (!session.sendArray || session.sendArray.size === 0) {
1643
+ return true; // nothing left unacknowledged at all
1644
+ }
1645
+ if (Date.now() >= deadline)
1646
+ return false;
1647
+ await sleep(250);
1648
+ }
1649
+ }
1650
+ /**
1651
+ * True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
1652
+ * DHT/relay key differs from its identity key, where a JS peer announces its
1653
+ * identity key as its DHT key. Natives cannot see the toxcore file-transfer
1654
+ * protocol (sendFile) — the inline envelope is their only file path — and
1655
+ * they never speak the JS text-ACK scheme.
1656
+ */
1657
+ isNativeFriend(pubkey) {
1658
+ const friend = this.#friends.get(pubkey);
1659
+ return !!(friend?.dhtPubkey && friend.dhtPubkey !== friend.pubkey);
1572
1660
  }
1573
1661
  /** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
1574
1662
  * This is the channel the iOS/Android WebRTC SDK uses for call signaling —
@@ -1675,6 +1763,37 @@ export class Peer {
1675
1763
  onCustomPacket(cb) {
1676
1764
  this.#events.on("customPacket", cb);
1677
1765
  }
1766
+ /**
1767
+ * Send one file-transfer packet as a DNFT1 frame inside a friend message —
1768
+ * the only route that reaches a native app's layer (its Carrier SDK
1769
+ * swallows packets 80-82 and drops 83). Payload bytes are unchanged; see
1770
+ * compat/dnft1.ts for the envelope and the iOS counterpart.
1771
+ *
1772
+ * Deliberately live-session only, and never express: express is
1773
+ * store-and-forward for chat, and 100 MB of file chunks has no business in
1774
+ * a mailbox. Control loss is already covered by the engine's offer/ack
1775
+ * retransmits, so a failed send here is a dropped packet the engine will
1776
+ * redo — not a lost transfer.
1777
+ */
1778
+ async #sendDnft1Frame(friendId, packetId, payload) {
1779
+ const frame = encodeDnft1Frame(packetId, payload);
1780
+ // A full DATA frame (11 + 5 + 1367 = 1383 B) exceeds the 1024-byte
1781
+ // friendmsg unit, so it rides BULKMSG fragments — exactly what the C SDK
1782
+ // does when iOS hands the same frame to sendFriendMessage.
1783
+ if (frame.length <= CARRIER_MAX_APP_MESSAGE_LEN) {
1784
+ await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeFriendMessagePacket(frame));
1785
+ return;
1786
+ }
1787
+ const tid = (BigInt(Date.now()) << 20n) ^ BigInt(Math.floor(Math.random() * 0xfffff));
1788
+ for (let off = 0; off < frame.length; off += CARRIER_MAX_APP_MESSAGE_LEN) {
1789
+ const end = Math.min(off + CARRIER_MAX_APP_MESSAGE_LEN, frame.length);
1790
+ await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeBulkMsgPacket({
1791
+ totalsz: off === 0 ? frame.length : 0,
1792
+ tid,
1793
+ data: frame.subarray(off, end)
1794
+ }));
1795
+ }
1796
+ }
1678
1797
  // ───────────────────── file transfer (toxcore-standard, native-ready) ─────
1679
1798
  /**
1680
1799
  * Offer a file to a friend (by userid). Returns the fileId (hex) or null if
@@ -3017,6 +3136,14 @@ export class Peer {
3017
3136
  // the very same packFilePayload as the online path).
3018
3137
  if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
3019
3138
  return;
3139
+ // Neither end routes DNFT1 through express on purpose — a 100 MB stream
3140
+ // has no business in a store-and-forward mailbox, and the engine's
3141
+ // retransmits already cover control loss. But if a frame does surface
3142
+ // here, swallow it rather than rendering the binary as chat mojibake.
3143
+ if (parseDnft1Frame(decoded.data)) {
3144
+ this.#debugLog(`ignoring DNFT1 frame from ${fromUserId} on the express path`);
3145
+ return;
3146
+ }
3020
3147
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
3021
3148
  return;
3022
3149
  void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
@@ -4045,6 +4172,11 @@ export class Peer {
4045
4172
  if (kind === PACKET_ID_FILE_CONTROL)
4046
4173
  this.#fileRelayNegotiationUntil.delete(friendId);
4047
4174
  // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
4175
+ if (kind >= PACKET_ID_FILE_SENDREQUEST && kind <= PACKET_ID_FILE_FEC) {
4176
+ // A raw file packet arrived, so this peer speaks raw — pin it and stop
4177
+ // alternating toward DNFT1.
4178
+ this.#dnft1.noteInbound(friendId, false);
4179
+ }
4048
4180
  if (this.#fileTransfer.handlePacket(friendId, kind, inner))
4049
4181
  return;
4050
4182
  if (kind === PACKET_ID_ONLINE) {
@@ -4243,6 +4375,18 @@ export class Peer {
4243
4375
  else {
4244
4376
  raw = inner;
4245
4377
  }
4378
+ // DNFT1: a file-transfer packet carried inside a friend message (the
4379
+ // native-interop path — see compat/dnft1.ts). Like the Android binary
4380
+ // envelope below, this MUST run on the undecoded bytes: raw chunk bytes
4381
+ // do not survive a UTF-8 decode.
4382
+ const ft = parseDnft1Frame(raw);
4383
+ if (ft) {
4384
+ this.#dnft1.noteInbound(friendId, true);
4385
+ if (ft.packetId === PACKET_ID_FILE_CONTROL)
4386
+ this.#fileRelayNegotiationUntil.delete(friendId);
4387
+ this.#fileTransfer.handlePacket(friendId, ft.packetId, ft.payload);
4388
+ return;
4389
+ }
4246
4390
  if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
4247
4391
  return;
4248
4392
  text = decodeUtf8Best(raw);
@@ -135,6 +135,23 @@ export type TextMessage = {
135
135
  * can see when online delivery is failing and only offline messages land. */
136
136
  via?: "online" | "offline";
137
137
  };
138
+ /**
139
+ * Outcome of an inline-file send — the caller's ONLY honest basis for a "sent"
140
+ * checkmark. "Bytes left this machine" is not delivery.
141
+ */
142
+ export type InlineSendResult = {
143
+ /**
144
+ * - "acked": the peer CONFIRMED receipt — an app-level ACK from a JS peer,
145
+ * or the toxcore transport ACK from a native one (the same signal Android
146
+ * surfaces as ReceiptState.Delivered). Safe to mark "sent".
147
+ * - "accepted": the transport took the bytes but no confirmation arrived in
148
+ * the window. NOT delivered as far as anyone can prove — mark it failed or
149
+ * queued, never "sent".
150
+ * - "offline": stored on the express relay for the peer's next pull. Mark
151
+ * it queued.
152
+ */
153
+ delivery: "acked" | "accepted" | "offline";
154
+ };
138
155
  export type SendTextUntilAckOptions = {
139
156
  /** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
140
157
  deliveryId?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.139",
3
+ "version": "0.1.141",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",