@decentnetwork/peer 0.1.140 → 0.1.142
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compat/dnft1.d.ts +62 -0
- package/dist/compat/dnft1.js +146 -0
- package/dist/peer.d.ts +2 -0
- package/dist/peer.js +106 -3
- package/dist/store/friends.d.ts +4 -0
- package/dist/types/peer.d.ts +17 -0
- package/package.json +1 -1
|
@@ -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/peer.d.ts
CHANGED
|
@@ -335,11 +335,13 @@ export declare class Peer {
|
|
|
335
335
|
setUserInfo(info: {
|
|
336
336
|
name?: string;
|
|
337
337
|
description?: string;
|
|
338
|
+
punkId?: number | null;
|
|
338
339
|
}): void;
|
|
339
340
|
/** Our current display name + status-message description. */
|
|
340
341
|
userInfo(): {
|
|
341
342
|
name: string;
|
|
342
343
|
description: string;
|
|
344
|
+
punkId?: number;
|
|
343
345
|
};
|
|
344
346
|
/**
|
|
345
347
|
* Tell a friend our public UDP endpoint over the (already-working,
|
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";
|
|
@@ -285,6 +286,19 @@ const RECV_REORDER_WINDOW = 8192;
|
|
|
285
286
|
// Minimum gap between PACKET_ID_REQUEST packets we send for our own receive gaps
|
|
286
287
|
// (toxcore paces requests ~ once per RTT; this floor avoids flooding on a burst).
|
|
287
288
|
const RECV_REQUEST_MIN_INTERVAL_MS = 200;
|
|
289
|
+
/** Read a CryptoPunks id out of the userinfo `gender` field.
|
|
290
|
+
*
|
|
291
|
+
* Beagle writes the bare nft id there. Anything else — an empty string, or a
|
|
292
|
+
* real gender from a non-Beagle Carrier client — is not a punk and must not
|
|
293
|
+
* be rendered as one. */
|
|
294
|
+
function parsePunkField(value) {
|
|
295
|
+
if (!value)
|
|
296
|
+
return undefined;
|
|
297
|
+
const n = Number(value.trim());
|
|
298
|
+
if (!Number.isInteger(n) || n < 0 || n > 9999)
|
|
299
|
+
return undefined;
|
|
300
|
+
return n;
|
|
301
|
+
}
|
|
288
302
|
export class Peer {
|
|
289
303
|
#opts;
|
|
290
304
|
#events = new EventEmitter();
|
|
@@ -295,12 +309,22 @@ export class Peer {
|
|
|
295
309
|
* old peer returns ACCEPT/ACK over its reliable TCP relay. */
|
|
296
310
|
#fileRelayNegotiationUntil = new Map();
|
|
297
311
|
// Toxcore-standard file transfer (wire-compatible with native Carrier/toxcore).
|
|
312
|
+
/** Which transport carries each friend's file packets — raw messenger
|
|
313
|
+
* packets (JS peers) or DNFT1 frames inside friend messages (native peers,
|
|
314
|
+
* whose Carrier SDK swallows 80-82 and drops 83). See compat/dnft1.ts. */
|
|
315
|
+
#dnft1 = new Dnft1TransportPolicy();
|
|
298
316
|
#fileTransfer = new FileTransferManager((friendId, packetId, payload) => {
|
|
299
317
|
if (packetId === PACKET_ID_FILE_SENDREQUEST) {
|
|
300
318
|
const session = this.#friendSessions.get(friendId);
|
|
301
319
|
const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
|
|
302
320
|
if (!lan)
|
|
303
321
|
this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 6_000);
|
|
322
|
+
// Every unanswered offer is evidence the peer cannot hear this
|
|
323
|
+
// transport; the policy alternates until one of them answers.
|
|
324
|
+
this.#dnft1.noteOfferSent(friendId);
|
|
325
|
+
}
|
|
326
|
+
if (this.#dnft1.useDnft1(friendId)) {
|
|
327
|
+
return this.#sendDnft1Frame(friendId, packetId, payload);
|
|
304
328
|
}
|
|
305
329
|
return this.#sendMessengerPacket(friendId, packetId, payload);
|
|
306
330
|
}, (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
|
|
@@ -1180,6 +1204,7 @@ export class Peer {
|
|
|
1180
1204
|
const existed = this.#friends.delete(friendId);
|
|
1181
1205
|
this.#friendSessions.delete(friendId);
|
|
1182
1206
|
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
1207
|
+
this.#dnft1.forget(friendId);
|
|
1183
1208
|
this.#pendingFriendRequests.delete(friendId);
|
|
1184
1209
|
this.#cookieRetryCount.delete(friendId);
|
|
1185
1210
|
this.#lastCookieSentKey.delete(friendId);
|
|
@@ -1751,6 +1776,37 @@ export class Peer {
|
|
|
1751
1776
|
onCustomPacket(cb) {
|
|
1752
1777
|
this.#events.on("customPacket", cb);
|
|
1753
1778
|
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Send one file-transfer packet as a DNFT1 frame inside a friend message —
|
|
1781
|
+
* the only route that reaches a native app's layer (its Carrier SDK
|
|
1782
|
+
* swallows packets 80-82 and drops 83). Payload bytes are unchanged; see
|
|
1783
|
+
* compat/dnft1.ts for the envelope and the iOS counterpart.
|
|
1784
|
+
*
|
|
1785
|
+
* Deliberately live-session only, and never express: express is
|
|
1786
|
+
* store-and-forward for chat, and 100 MB of file chunks has no business in
|
|
1787
|
+
* a mailbox. Control loss is already covered by the engine's offer/ack
|
|
1788
|
+
* retransmits, so a failed send here is a dropped packet the engine will
|
|
1789
|
+
* redo — not a lost transfer.
|
|
1790
|
+
*/
|
|
1791
|
+
async #sendDnft1Frame(friendId, packetId, payload) {
|
|
1792
|
+
const frame = encodeDnft1Frame(packetId, payload);
|
|
1793
|
+
// A full DATA frame (11 + 5 + 1367 = 1383 B) exceeds the 1024-byte
|
|
1794
|
+
// friendmsg unit, so it rides BULKMSG fragments — exactly what the C SDK
|
|
1795
|
+
// does when iOS hands the same frame to sendFriendMessage.
|
|
1796
|
+
if (frame.length <= CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
1797
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeFriendMessagePacket(frame));
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
const tid = (BigInt(Date.now()) << 20n) ^ BigInt(Math.floor(Math.random() * 0xfffff));
|
|
1801
|
+
for (let off = 0; off < frame.length; off += CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
1802
|
+
const end = Math.min(off + CARRIER_MAX_APP_MESSAGE_LEN, frame.length);
|
|
1803
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeBulkMsgPacket({
|
|
1804
|
+
totalsz: off === 0 ? frame.length : 0,
|
|
1805
|
+
tid,
|
|
1806
|
+
data: frame.subarray(off, end)
|
|
1807
|
+
}));
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1754
1810
|
// ───────────────────── file transfer (toxcore-standard, native-ready) ─────
|
|
1755
1811
|
/**
|
|
1756
1812
|
* Offer a file to a friend (by userid). Returns the fileId (hex) or null if
|
|
@@ -3093,6 +3149,14 @@ export class Peer {
|
|
|
3093
3149
|
// the very same packFilePayload as the online path).
|
|
3094
3150
|
if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
|
|
3095
3151
|
return;
|
|
3152
|
+
// Neither end routes DNFT1 through express on purpose — a 100 MB stream
|
|
3153
|
+
// has no business in a store-and-forward mailbox, and the engine's
|
|
3154
|
+
// retransmits already cover control loss. But if a frame does surface
|
|
3155
|
+
// here, swallow it rather than rendering the binary as chat mojibake.
|
|
3156
|
+
if (parseDnft1Frame(decoded.data)) {
|
|
3157
|
+
this.#debugLog(`ignoring DNFT1 frame from ${fromUserId} on the express path`);
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3096
3160
|
if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
|
|
3097
3161
|
return;
|
|
3098
3162
|
void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
|
|
@@ -4121,6 +4185,11 @@ export class Peer {
|
|
|
4121
4185
|
if (kind === PACKET_ID_FILE_CONTROL)
|
|
4122
4186
|
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
4123
4187
|
// Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
|
|
4188
|
+
if (kind >= PACKET_ID_FILE_SENDREQUEST && kind <= PACKET_ID_FILE_FEC) {
|
|
4189
|
+
// A raw file packet arrived, so this peer speaks raw — pin it and stop
|
|
4190
|
+
// alternating toward DNFT1.
|
|
4191
|
+
this.#dnft1.noteInbound(friendId, false);
|
|
4192
|
+
}
|
|
4124
4193
|
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
4125
4194
|
return;
|
|
4126
4195
|
if (kind === PACKET_ID_ONLINE) {
|
|
@@ -4194,12 +4263,17 @@ export class Peer {
|
|
|
4194
4263
|
// back to plain UTF-8 for compatibility.
|
|
4195
4264
|
let userInfoName;
|
|
4196
4265
|
let userInfoDescr;
|
|
4266
|
+
let userInfoPunk;
|
|
4197
4267
|
let clientMeta;
|
|
4198
4268
|
try {
|
|
4199
4269
|
const decoded = decodeCarrierPacket(inner);
|
|
4200
4270
|
if (decoded.type === PACKET_TYPE_USERINFO) {
|
|
4201
4271
|
userInfoName = decoded.name;
|
|
4202
4272
|
userInfoDescr = decoded.descr;
|
|
4273
|
+
// The friend's avatar. `gender` is where Beagle puts the punk id —
|
|
4274
|
+
// it was decoded here and thrown away, which is the whole reason a
|
|
4275
|
+
// friend's picture could only ever come from beagles.eth.
|
|
4276
|
+
userInfoPunk = parsePunkField(decoded.gender);
|
|
4203
4277
|
// AgentNet appended fields — present only for updated peers.
|
|
4204
4278
|
if (decoded.protoVersion || decoded.platform || decoded.appVersion) {
|
|
4205
4279
|
clientMeta = {
|
|
@@ -4218,19 +4292,23 @@ export class Peer {
|
|
|
4218
4292
|
const friend = this.#friends.get(friendId);
|
|
4219
4293
|
const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
|
|
4220
4294
|
const newDescr = userInfoDescr ?? friend?.description;
|
|
4295
|
+
// Keep what we had when this packet carried no punk: a peer that
|
|
4296
|
+
// stops sending one has not taken their avatar off.
|
|
4297
|
+
const newPunk = userInfoPunk ?? friend?.punkId;
|
|
4221
4298
|
const metaChanged = friend != null && clientMeta != null &&
|
|
4222
4299
|
(friend.protoVersion !== clientMeta.protoVersion ||
|
|
4223
4300
|
friend.platform !== clientMeta.platform ||
|
|
4224
4301
|
friend.osVersion !== clientMeta.osVersion ||
|
|
4225
4302
|
friend.appVersion !== clientMeta.appVersion);
|
|
4226
|
-
if (friend && (friend.name !== newName || friend.description !== newDescr || metaChanged)) {
|
|
4227
|
-
this.#friends.set(friendId, { ...friend, name: newName, description: newDescr, ...(clientMeta ?? {}) });
|
|
4303
|
+
if (friend && (friend.name !== newName || friend.description !== newDescr || friend.punkId !== newPunk || metaChanged)) {
|
|
4304
|
+
this.#friends.set(friendId, { ...friend, name: newName, description: newDescr, punkId: newPunk, ...(clientMeta ?? {}) });
|
|
4228
4305
|
this.#persistFriends();
|
|
4229
4306
|
this.#events.emit("friendInfo", {
|
|
4230
4307
|
pubkey: friendId,
|
|
4231
4308
|
userid: friend.userid ?? friendId,
|
|
4232
4309
|
name: newName,
|
|
4233
4310
|
description: newDescr,
|
|
4311
|
+
punkId: newPunk,
|
|
4234
4312
|
...(clientMeta ?? {})
|
|
4235
4313
|
});
|
|
4236
4314
|
}
|
|
@@ -4319,6 +4397,18 @@ export class Peer {
|
|
|
4319
4397
|
else {
|
|
4320
4398
|
raw = inner;
|
|
4321
4399
|
}
|
|
4400
|
+
// DNFT1: a file-transfer packet carried inside a friend message (the
|
|
4401
|
+
// native-interop path — see compat/dnft1.ts). Like the Android binary
|
|
4402
|
+
// envelope below, this MUST run on the undecoded bytes: raw chunk bytes
|
|
4403
|
+
// do not survive a UTF-8 decode.
|
|
4404
|
+
const ft = parseDnft1Frame(raw);
|
|
4405
|
+
if (ft) {
|
|
4406
|
+
this.#dnft1.noteInbound(friendId, true);
|
|
4407
|
+
if (ft.packetId === PACKET_ID_FILE_CONTROL)
|
|
4408
|
+
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
4409
|
+
this.#fileTransfer.handlePacket(friendId, ft.packetId, ft.payload);
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4322
4412
|
if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
|
|
4323
4413
|
return;
|
|
4324
4414
|
text = decodeUtf8Best(raw);
|
|
@@ -4890,6 +4980,11 @@ export class Peer {
|
|
|
4890
4980
|
opts.nickname = info.name;
|
|
4891
4981
|
if (info.description !== undefined)
|
|
4892
4982
|
opts.statusMessage = info.description;
|
|
4983
|
+
// null clears it explicitly; undefined means "not changing it". Everything
|
|
4984
|
+
// below re-sends the profile to every established friend, which is what
|
|
4985
|
+
// makes an avatar change visible without a reconnect.
|
|
4986
|
+
if (info.punkId !== undefined)
|
|
4987
|
+
opts.punkId = info.punkId ?? undefined;
|
|
4893
4988
|
this.#profileSentTo.clear();
|
|
4894
4989
|
for (const timer of this.#profileRetryTimers.values())
|
|
4895
4990
|
clearTimeout(timer);
|
|
@@ -4906,6 +5001,9 @@ export class Peer {
|
|
|
4906
5001
|
return {
|
|
4907
5002
|
name: opts.nickname ?? PEER_NICKNAME,
|
|
4908
5003
|
description: opts.statusMessage ?? PEER_STATUS_MESSAGE,
|
|
5004
|
+
// Reported so "what am I actually advertising" is answerable without
|
|
5005
|
+
// reading a packet capture — the UI surfaces this as `me.advertised`.
|
|
5006
|
+
punkId: opts.punkId,
|
|
4909
5007
|
};
|
|
4910
5008
|
}
|
|
4911
5009
|
#scheduleProfileRetry(friendId) {
|
|
@@ -4962,7 +5060,12 @@ export class Peer {
|
|
|
4962
5060
|
protoVersion: AGENTNET_PROTO_VERSION,
|
|
4963
5061
|
platform: this.#opts.platform ?? process.platform,
|
|
4964
5062
|
osVersion: this.#opts.osVersion ?? `node-${process.versions?.node ?? ""}`,
|
|
4965
|
-
appVersion: this.#opts.appVersion ?? `peer-${PEER_PKG_VERSION}
|
|
5063
|
+
appVersion: this.#opts.appVersion ?? `peer-${PEER_PKG_VERSION}`,
|
|
5064
|
+
// The avatar, in the field Beagle uses for it (see punkId on
|
|
5065
|
+
// FriendInfoEvent). OMITTED when we have none, never sent as "":
|
|
5066
|
+
// an empty string would clear an avatar the friend already
|
|
5067
|
+
// resolved. Without this, iOS shows us no picture at all.
|
|
5068
|
+
...(this.#opts.punkId != null ? { gender: String(this.#opts.punkId) } : {})
|
|
4966
5069
|
});
|
|
4967
5070
|
await this.#sendMessengerPacket(friendId, PACKET_ID_STATUSMESSAGE, userInfo);
|
|
4968
5071
|
this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}", proto=${AGENTNET_PROTO_VERSION}, platform=${this.#opts.platform ?? process.platform})`);
|
package/dist/store/friends.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export type FriendRecord = {
|
|
|
5
5
|
nospam?: number;
|
|
6
6
|
name?: string;
|
|
7
7
|
description?: string;
|
|
8
|
+
/** The friend's CryptoPunks id, from the userinfo `gender` field. Persisted
|
|
9
|
+
* so their avatar survives a restart instead of waiting for the next
|
|
10
|
+
* profile packet. See FriendInfoEvent.punkId. */
|
|
11
|
+
punkId?: number;
|
|
8
12
|
status: "requested" | "offline" | "online";
|
|
9
13
|
remoteHost?: string;
|
|
10
14
|
remotePort?: number;
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -88,6 +88,11 @@ export type PeerOptions = {
|
|
|
88
88
|
* version.
|
|
89
89
|
*/
|
|
90
90
|
appVersion?: string;
|
|
91
|
+
/** Our own CryptoPunks id, advertised to friends in the userinfo `gender`
|
|
92
|
+
* field — the only way an avatar travels over Carrier itself. Leave unset
|
|
93
|
+
* and the field is omitted entirely (never sent as ""), so a friend keeps
|
|
94
|
+
* whatever picture they already have for us. */
|
|
95
|
+
punkId?: number;
|
|
91
96
|
compatibilityMode?: CompatibilityMode;
|
|
92
97
|
debugLabel?: string;
|
|
93
98
|
};
|
|
@@ -110,6 +115,18 @@ export type FriendInfoEvent = {
|
|
|
110
115
|
userid?: string;
|
|
111
116
|
name?: string;
|
|
112
117
|
description?: string;
|
|
118
|
+
/** The friend's CryptoPunks id — their avatar.
|
|
119
|
+
*
|
|
120
|
+
* Carried in the userinfo packet's `gender` field, which is not a mistake
|
|
121
|
+
* and not ours to rename. The protocol has no avatar field: `has_avatar`
|
|
122
|
+
* is a bool in all four implementations and the image exchange was never
|
|
123
|
+
* built. Beagle borrowed `gender` because a punk carries a gender anyway.
|
|
124
|
+
* Defined in WalletForBeagleV2/docs/profile.md — set with
|
|
125
|
+
* userinfo.setGender(nftid), read back off friendInfo.gender.
|
|
126
|
+
*
|
|
127
|
+
* Undefined when the friend sent no punk, or sent something that is not a
|
|
128
|
+
* punk id (a real gender from a non-Beagle Carrier client). */
|
|
129
|
+
punkId?: number;
|
|
113
130
|
/** Peer's advertised client metadata (from the userinfo profile). Present
|
|
114
131
|
* only once the friend has sent a profile with these fields — a legacy
|
|
115
132
|
* peer (native C SDK before the extension, or an old JS build) leaves them
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.142",
|
|
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",
|