@decentnetwork/peer 0.1.137 → 0.1.139

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,39 @@
1
+ /**
2
+ * Inline files over the Carrier (bulk)message channel — the only file path the
3
+ * native Beagle apps can see online (toxcore's file transfer 80-82 is invisible
4
+ * to the Carrier C SDK).
5
+ *
6
+ * There are TWO envelopes in the wild:
7
+ *
8
+ * - iOS / Beagle group chats: a FileModel JSON envelope, base64 payload —
9
+ * {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}
10
+ * (handled as text in peer.ts, since it is valid UTF-8).
11
+ *
12
+ * - Android 1:1 chats (CarrierFileHelper.packFilePayload): a BINARY envelope
13
+ * with a big-endian length prefix, no base64 —
14
+ *
15
+ * [uint32 BE metaLen][meta JSON][raw file bytes]
16
+ * meta = {"type":"file","filename":…,"contentType":…,"size":N}
17
+ *
18
+ * The body is real binary, so it never survives a UTF-8 decode: it has to be
19
+ * recognised on the raw bytes, or the file is shredded into U+FFFD mojibake.
20
+ */
21
+ /** Metadata Android puts in front of the bytes. */
22
+ export type AndroidFileEnvelope = {
23
+ name: string;
24
+ contentType: string;
25
+ data: Uint8Array;
26
+ /** `size` as declared in the header — may disagree with data.length on a
27
+ * truncated payload, which the caller can report. */
28
+ declaredSize?: number;
29
+ };
30
+ /**
31
+ * Decode Android's binary file envelope. Returns undefined for anything that
32
+ * isn't one (ordinary chat text included) — never throws.
33
+ */
34
+ export declare function decodeAndroidFileEnvelope(bytes: Uint8Array): AndroidFileEnvelope | undefined;
35
+ /** Build the same envelope Android builds, byte for byte (key order included). */
36
+ export declare function encodeAndroidFileEnvelope(name: string, data: Uint8Array, contentType?: string): Uint8Array;
37
+ /** Classify an inline file the way the Beagle apps do — by MIME type when the
38
+ * sender bothered to set a real one, otherwise by extension. */
39
+ export declare function inlineFileTypeFor(name: string, contentType?: string): "image" | "audio" | "text" | "unknown";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Inline files over the Carrier (bulk)message channel — the only file path the
3
+ * native Beagle apps can see online (toxcore's file transfer 80-82 is invisible
4
+ * to the Carrier C SDK).
5
+ *
6
+ * There are TWO envelopes in the wild:
7
+ *
8
+ * - iOS / Beagle group chats: a FileModel JSON envelope, base64 payload —
9
+ * {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}
10
+ * (handled as text in peer.ts, since it is valid UTF-8).
11
+ *
12
+ * - Android 1:1 chats (CarrierFileHelper.packFilePayload): a BINARY envelope
13
+ * with a big-endian length prefix, no base64 —
14
+ *
15
+ * [uint32 BE metaLen][meta JSON][raw file bytes]
16
+ * meta = {"type":"file","filename":…,"contentType":…,"size":N}
17
+ *
18
+ * The body is real binary, so it never survives a UTF-8 decode: it has to be
19
+ * recognised on the raw bytes, or the file is shredded into U+FFFD mojibake.
20
+ */
21
+ /** Android's own unpackPayload() bounds, mirrored so we accept exactly what it
22
+ * sends and reject anything else cheaply. */
23
+ const MAX_META_LEN = 4096;
24
+ /**
25
+ * Decode Android's binary file envelope. Returns undefined for anything that
26
+ * isn't one (ordinary chat text included) — never throws.
27
+ */
28
+ export function decodeAndroidFileEnvelope(bytes) {
29
+ // A 4-byte big-endian length below 4096 always starts with two zero bytes,
30
+ // which no chat text ever does — cheap reject before touching the JSON.
31
+ if (bytes.length < 6 || bytes[0] !== 0 || bytes[1] !== 0)
32
+ return undefined;
33
+ const metaLen = ((bytes[2] << 8) | bytes[3]) >>> 0;
34
+ if (metaLen < 2 || metaLen > MAX_META_LEN || 4 + metaLen > bytes.length)
35
+ return undefined;
36
+ try {
37
+ const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(4, 4 + metaLen));
38
+ const meta = JSON.parse(json);
39
+ if (meta.type !== "file" || typeof meta.filename !== "string" || meta.filename.length === 0)
40
+ return undefined;
41
+ return {
42
+ name: meta.filename,
43
+ contentType: typeof meta.contentType === "string" ? meta.contentType : "application/octet-stream",
44
+ data: bytes.slice(4 + metaLen),
45
+ declaredSize: typeof meta.size === "number" ? meta.size : undefined
46
+ };
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ /** Build the same envelope Android builds, byte for byte (key order included). */
53
+ export function encodeAndroidFileEnvelope(name, data, contentType = "application/octet-stream") {
54
+ const meta = new TextEncoder().encode(JSON.stringify({ type: "file", filename: name, contentType, size: data.length }));
55
+ const out = new Uint8Array(4 + meta.length + data.length);
56
+ new DataView(out.buffer).setUint32(0, meta.length, false);
57
+ out.set(meta, 4);
58
+ out.set(data, 4 + meta.length);
59
+ return out;
60
+ }
61
+ /** Classify an inline file the way the Beagle apps do — by MIME type when the
62
+ * sender bothered to set a real one, otherwise by extension. */
63
+ export function inlineFileTypeFor(name, contentType) {
64
+ const mime = (contentType ?? "").toLowerCase();
65
+ if (mime.startsWith("image/"))
66
+ return "image";
67
+ if (mime.startsWith("audio/"))
68
+ return "audio";
69
+ if (mime.startsWith("text/"))
70
+ return "text";
71
+ const dot = name.lastIndexOf(".");
72
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
73
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".heif", ".bmp"].includes(ext))
74
+ return "image";
75
+ if ([".m4a", ".mp3", ".aac", ".wav", ".ogg"].includes(ext))
76
+ return "audio";
77
+ if ([".txt", ".md", ".log"].includes(ext))
78
+ return "text";
79
+ return "unknown";
80
+ }
package/dist/peer.js CHANGED
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import nacl from "tweetnacl";
8
8
  import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
9
9
  import { LegacyBootstrapClient } from "./compat/bootstrap.js";
10
+ import { decodeAndroidFileEnvelope, inlineFileTypeFor } from "./compat/inline-file.js";
10
11
  import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, PACKET_TYPE_INVITE_REQUEST, PACKET_TYPE_INVITE_RESPONSE, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket, encodeInviteReqPacket, encodeRetransmitRequest } from "./compat/packet.js";
11
12
  import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, createOnionRequest0Tcp, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
12
13
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
@@ -1555,9 +1556,7 @@ export class Peer {
1555
1556
  const dot = opts.name.lastIndexOf(".");
1556
1557
  const ext = dot > 0 ? opts.name.slice(dot).toLowerCase() : "";
1557
1558
  const base = dot > 0 ? opts.name.slice(0, dot) : opts.name;
1558
- const type = opts.fileType ?? ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"].includes(ext) ? "image" :
1559
- [".m4a", ".mp3", ".aac", ".wav", ".ogg"].includes(ext) ? "audio" :
1560
- [".txt", ".md", ".log"].includes(ext) ? "text" : "unknown");
1559
+ const type = opts.fileType ?? inlineFileTypeFor(opts.name);
1561
1560
  // Key order matters for byte-identical envelopes with iOS (JSONEncoder
1562
1561
  // .sortedKeys): data, fileExtension, fileName, type — alphabetical.
1563
1562
  const envelope = JSON.stringify({
@@ -1712,7 +1711,24 @@ export class Peer {
1712
1711
  this.#events.on("friendInfo", cb);
1713
1712
  }
1714
1713
  friends() {
1715
- return [...this.#friends.values()];
1714
+ // Fill in `address` when the record lacks one (friends who requested US,
1715
+ // or were adopted from a session, never carried it). The address is
1716
+ // pubkey ‖ nospam(4B LE) ‖ checksum(2B) — locally derivable, no lookup.
1717
+ // It is the ONLY string another person can send a friend request to, so
1718
+ // a client that can't show it can't let its user recommend a contact
1719
+ // (INBOX 2026-08-05). Use the friend's recorded nospam when we have it
1720
+ // (their request told us); otherwise 0, which is what every current
1721
+ // deployment uses.
1722
+ return [...this.#friends.values()].map((f) => {
1723
+ if (f.address)
1724
+ return f;
1725
+ try {
1726
+ return { ...f, address: carrierAddressFromPublicKey(base58ToBytes(f.pubkey), f.nospam ?? 0) };
1727
+ }
1728
+ catch {
1729
+ return f; // malformed pubkey — better an absent address than a wrong one
1730
+ }
1731
+ });
1716
1732
  }
1717
1733
  /**
1718
1734
  * Read-only snapshot of the live net_crypto session state for a
@@ -2996,7 +3012,11 @@ export class Peer {
2996
3012
  }
2997
3013
  }
2998
3014
  // Offline images/audio arrive as the same FileModel JSON envelope
2999
- // (iOS posts the whole thing as one express MESSAGE packet).
3015
+ // (iOS posts the whole thing as one express MESSAGE packet), or as
3016
+ // Android's binary [len][meta][bytes] envelope (ExpressNodeClient uses
3017
+ // the very same packFilePayload as the online path).
3018
+ if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
3019
+ return;
3000
3020
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
3001
3021
  return;
3002
3022
  void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
@@ -4205,19 +4225,27 @@ export class Peer {
4205
4225
  this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
4206
4226
  return;
4207
4227
  }
4228
+ // The message body as it arrived, still undecoded. Android Beagle sends
4229
+ // files as a BINARY envelope (see #tryEmitBinaryInlineFile), and raw file
4230
+ // bytes do not survive a UTF-8 decode — so the sniff has to run here, on
4231
+ // the bytes, before anything turns them into a string.
4232
+ let raw;
4208
4233
  if (carrier?.type === PACKET_TYPE_BULKMSG) {
4209
4234
  const complete = this.#assembleBulkMsg(friendId, carrier, packetNumber);
4210
4235
  if (!complete)
4211
4236
  return; // more fragments pending
4212
- text = decodeUtf8Best(complete);
4237
+ raw = complete;
4213
4238
  this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
4214
4239
  }
4215
4240
  else if (carrier?.type === PACKET_TYPE_MESSAGE) {
4216
- text = new TextDecoder().decode(carrier.data);
4241
+ raw = carrier.data;
4217
4242
  }
4218
4243
  else {
4219
- text = decodeUtf8Best(inner);
4244
+ raw = inner;
4220
4245
  }
4246
+ if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
4247
+ return;
4248
+ text = decodeUtf8Best(raw);
4221
4249
  // Carrier C peers terminate strings with a NUL — strip it so chat
4222
4250
  // text doesn't carry an invisible trailing byte.
4223
4251
  text = text?.replace(/\0+$/u, "");
@@ -4463,6 +4491,28 @@ export class Peer {
4463
4491
  return false;
4464
4492
  }
4465
4493
  }
4494
+ /** Android Beagle sends files as a BINARY envelope over the (bulk)message
4495
+ * channel — [uint32 BE metaLen][meta JSON][raw bytes], no base64. Raw file
4496
+ * bytes do not survive a UTF-8 decode, so this sniff runs on the UNDECODED
4497
+ * payload; before it existed such a message landed in the chat as a wall of
4498
+ * U+FFFD mojibake with the file lost. Returns true when handled. */
4499
+ #tryEmitBinaryInlineFile(friendId, bytes, via) {
4500
+ const envelope = decodeAndroidFileEnvelope(bytes);
4501
+ if (!envelope)
4502
+ return false;
4503
+ if (envelope.declaredSize !== undefined && envelope.declaredSize !== envelope.data.length) {
4504
+ this.#debugLog(`inline file from ${friendId}: declared ${envelope.declaredSize}B, got ${envelope.data.length}B`);
4505
+ }
4506
+ this.#events.emit("inlineFile", {
4507
+ pubkey: friendId,
4508
+ name: envelope.name,
4509
+ fileType: inlineFileTypeFor(envelope.name, envelope.contentType),
4510
+ data: envelope.data,
4511
+ via
4512
+ });
4513
+ this.#debugLog(`inline file (Android envelope) from ${friendId}: "${envelope.name}" (${envelope.data.length} bytes, ${via})`);
4514
+ return true;
4515
+ }
4466
4516
  /** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
4467
4517
  * (a) implicitly ACK every walked packet number that isn't requested,
4468
4518
  * (b) retransmit the requested ones from the send buffer. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.137",
3
+ "version": "0.1.139",
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",
@@ -74,7 +74,8 @@
74
74
  "test:tcp-onion": "pnpm run build && node scripts/tcp-onion-selftest.mjs",
75
75
  "smoke:join": "node scripts/smoke-join.mjs",
76
76
  "typecheck": "tsc -p tsconfig.json --noEmit",
77
- "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs"
77
+ "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs",
78
+ "test:inline-file": "pnpm run build && node scripts/inline-file-selftest.mjs"
78
79
  },
79
80
  "dependencies": {
80
81
  "flatbuffers": "^25.9.23",