@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.
- package/dist/compat/inline-file.d.ts +39 -0
- package/dist/compat/inline-file.js +80 -0
- package/dist/peer.js +58 -8
- package/package.json +3 -2
|
@@ -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 ?? (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4241
|
+
raw = carrier.data;
|
|
4217
4242
|
}
|
|
4218
4243
|
else {
|
|
4219
|
-
|
|
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.
|
|
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",
|