@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.
- package/dist/compat/inline-file.d.ts +39 -0
- package/dist/compat/inline-file.js +80 -0
- package/dist/peer.js +40 -7
- 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({
|
|
@@ -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
|
-
|
|
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
|
-
|
|
4241
|
+
raw = carrier.data;
|
|
4234
4242
|
}
|
|
4235
4243
|
else {
|
|
4236
|
-
|
|
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.
|
|
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",
|