@decentnetwork/peer 0.1.138 → 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({
@@ -3013,7 +3012,11 @@ export class Peer {
3013
3012
  }
3014
3013
  }
3015
3014
  // Offline images/audio arrive as the same FileModel JSON envelope
3016
- // (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;
3017
3020
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
3018
3021
  return;
3019
3022
  void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
@@ -4222,19 +4225,27 @@ export class Peer {
4222
4225
  this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
4223
4226
  return;
4224
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;
4225
4233
  if (carrier?.type === PACKET_TYPE_BULKMSG) {
4226
4234
  const complete = this.#assembleBulkMsg(friendId, carrier, packetNumber);
4227
4235
  if (!complete)
4228
4236
  return; // more fragments pending
4229
- text = decodeUtf8Best(complete);
4237
+ raw = complete;
4230
4238
  this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
4231
4239
  }
4232
4240
  else if (carrier?.type === PACKET_TYPE_MESSAGE) {
4233
- text = new TextDecoder().decode(carrier.data);
4241
+ raw = carrier.data;
4234
4242
  }
4235
4243
  else {
4236
- text = decodeUtf8Best(inner);
4244
+ raw = inner;
4237
4245
  }
4246
+ if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
4247
+ return;
4248
+ text = decodeUtf8Best(raw);
4238
4249
  // Carrier C peers terminate strings with a NUL — strip it so chat
4239
4250
  // text doesn't carry an invisible trailing byte.
4240
4251
  text = text?.replace(/\0+$/u, "");
@@ -4480,6 +4491,28 @@ export class Peer {
4480
4491
  return false;
4481
4492
  }
4482
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
+ }
4483
4516
  /** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
4484
4517
  * (a) implicitly ACK every walked packet number that isn't requested,
4485
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.138",
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",