@decentnetwork/peer 0.1.138 → 0.1.140
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/index.d.ts +1 -1
- package/dist/peer.d.ts +11 -2
- package/dist/peer.js +120 -11
- package/dist/types/peer.d.ts +17 -0
- 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/index.d.ts
CHANGED
|
@@ -14,4 +14,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
|
|
|
14
14
|
export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
|
|
15
15
|
export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
16
16
|
export type { CarrierAddressParts } from "./compat/address.js";
|
|
17
|
-
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
17
|
+
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
package/dist/peer.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type BootstrapResult } from "./compat/bootstrap.js";
|
|
2
2
|
import type { FriendRecord } from "./store/friends.js";
|
|
3
3
|
import { type CarrierTurnServerInfo, type RtcIceServer } from "./ice-servers.js";
|
|
4
|
-
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
4
|
+
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
5
5
|
export declare class Peer {
|
|
6
6
|
#private;
|
|
7
7
|
private constructor();
|
|
@@ -140,7 +140,16 @@ export declare class Peer {
|
|
|
140
140
|
name: string;
|
|
141
141
|
data: Uint8Array;
|
|
142
142
|
fileType?: "image" | "audio" | "text" | "unknown";
|
|
143
|
-
|
|
143
|
+
deliveryTimeoutMs?: number;
|
|
144
|
+
}): Promise<InlineSendResult>;
|
|
145
|
+
/**
|
|
146
|
+
* True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
|
|
147
|
+
* DHT/relay key differs from its identity key, where a JS peer announces its
|
|
148
|
+
* identity key as its DHT key. Natives cannot see the toxcore file-transfer
|
|
149
|
+
* protocol (sendFile) — the inline envelope is their only file path — and
|
|
150
|
+
* they never speak the JS text-ACK scheme.
|
|
151
|
+
*/
|
|
152
|
+
isNativeFriend(pubkey: string): boolean;
|
|
144
153
|
/** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
|
|
145
154
|
* This is the channel the iOS/Android WebRTC SDK uses for call signaling —
|
|
146
155
|
* each RtcSignal arrives as an "invite" with ext="carrier" and `data` the
|
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";
|
|
@@ -1239,6 +1240,9 @@ export class Peer {
|
|
|
1239
1240
|
}
|
|
1240
1241
|
await this.#sendTextPlain(pubkey, text);
|
|
1241
1242
|
}
|
|
1243
|
+
/** What #sendTextPlain actually did with the message — callers that promise
|
|
1244
|
+
* delivery semantics (inline files) need to know the path and, for the live
|
|
1245
|
+
* path, where the send window stood after the last fragment. */
|
|
1242
1246
|
async #sendTextPlain(pubkey, text) {
|
|
1243
1247
|
const friend = this.#friends.get(pubkey);
|
|
1244
1248
|
if (!friend) {
|
|
@@ -1306,7 +1310,10 @@ export class Peer {
|
|
|
1306
1310
|
for (const p of livePackets) {
|
|
1307
1311
|
await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, p);
|
|
1308
1312
|
}
|
|
1309
|
-
|
|
1313
|
+
// Every fragment got a packet number BELOW the counter's position now —
|
|
1314
|
+
// when the peer's implicit acks advance the window past it, the whole
|
|
1315
|
+
// message is confirmed on their device.
|
|
1316
|
+
return { via: "online", endPacketNumber: this.#friendSessions.get(pubkey)?.sendPacketNumber };
|
|
1310
1317
|
}
|
|
1311
1318
|
catch (error) {
|
|
1312
1319
|
const emsg = error.message;
|
|
@@ -1342,7 +1349,7 @@ export class Peer {
|
|
|
1342
1349
|
// no session is simply a no-op.
|
|
1343
1350
|
if (text.length === 0) {
|
|
1344
1351
|
this.#debugLog(`sendText: empty connection-kick to ${pubkey} with no live session — dropping (not flooding express)`);
|
|
1345
|
-
return;
|
|
1352
|
+
return { via: "online" };
|
|
1346
1353
|
}
|
|
1347
1354
|
// Offline / fallback path via Carrier express HTTP store-and-forward.
|
|
1348
1355
|
// Skipped entirely in control-plane-only mode (decentlan's data plane):
|
|
@@ -1352,7 +1359,7 @@ export class Peer {
|
|
|
1352
1359
|
if (this.#express?.hasNodes() && !this.#opts.expressControlPlaneOnly) {
|
|
1353
1360
|
await this.#express.sendOfflineText(pubkey, packet);
|
|
1354
1361
|
this.#debugLog(`sendText: queued via express for ${pubkey}`);
|
|
1355
|
-
return;
|
|
1362
|
+
return { via: "offline" };
|
|
1356
1363
|
}
|
|
1357
1364
|
throw new Error("friend is offline and no express node is configured");
|
|
1358
1365
|
}
|
|
@@ -1555,9 +1562,7 @@ export class Peer {
|
|
|
1555
1562
|
const dot = opts.name.lastIndexOf(".");
|
|
1556
1563
|
const ext = dot > 0 ? opts.name.slice(dot).toLowerCase() : "";
|
|
1557
1564
|
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");
|
|
1565
|
+
const type = opts.fileType ?? inlineFileTypeFor(opts.name);
|
|
1561
1566
|
// Key order matters for byte-identical envelopes with iOS (JSONEncoder
|
|
1562
1567
|
// .sortedKeys): data, fileExtension, fileName, type — alphabetical.
|
|
1563
1568
|
const envelope = JSON.stringify({
|
|
@@ -1569,7 +1574,77 @@ export class Peer {
|
|
|
1569
1574
|
if (envelope.length > CARRIER_MAX_APP_BULKMSG_LEN) {
|
|
1570
1575
|
throw new Error(`inline file too large for the message channel (${envelope.length} bytes encoded, max ${CARRIER_MAX_APP_BULKMSG_LEN})`);
|
|
1571
1576
|
}
|
|
1572
|
-
|
|
1577
|
+
// The confirmation window scales with the payload: a floor for handshake
|
|
1578
|
+
// latency plus time to move the envelope at a conservative 50 KB/s, capped
|
|
1579
|
+
// so a dead session can't pin the caller for ages.
|
|
1580
|
+
const deliveryTimeoutMs = opts.deliveryTimeoutMs ??
|
|
1581
|
+
Math.min(180_000, 20_000 + Math.ceil(envelope.length / 50));
|
|
1582
|
+
if (this.#shouldRequireTextAck(pubkey)) {
|
|
1583
|
+
// JS peer: the app-level text-ACK is the strongest confirmation there is
|
|
1584
|
+
// — the receiving app parsed the envelope. The retry interval must be
|
|
1585
|
+
// longer than one full send of the payload, or a slow path gets the
|
|
1586
|
+
// whole multi-MB envelope re-blasted mid-flight.
|
|
1587
|
+
await this.sendTextUntilAck(pubkey, envelope, {
|
|
1588
|
+
timeoutMs: deliveryTimeoutMs,
|
|
1589
|
+
retryIntervalMs: Math.max(TEXT_ACK_RETRY_MS, Math.ceil(envelope.length / 100))
|
|
1590
|
+
});
|
|
1591
|
+
return { delivery: "acked" };
|
|
1592
|
+
}
|
|
1593
|
+
// Native peer (iOS/Android/C): it will never speak our text-ACK envelope,
|
|
1594
|
+
// but its toxcore DOES acknowledge every reliable packet — the very signal
|
|
1595
|
+
// its own SDK surfaces to the app as "Delivered". Wait for the send window
|
|
1596
|
+
// to drain past our fragments.
|
|
1597
|
+
const outcome = await this.#sendTextPlain(pubkey, envelope);
|
|
1598
|
+
if (outcome.via === "offline")
|
|
1599
|
+
return { delivery: "offline" };
|
|
1600
|
+
if (outcome.endPacketNumber === undefined)
|
|
1601
|
+
return { delivery: "accepted" };
|
|
1602
|
+
const acked = await this.#awaitTransportAck(pubkey, outcome.endPacketNumber, deliveryTimeoutMs);
|
|
1603
|
+
this.#debugLog(`inline file "${opts.name}" to ${pubkey}: transport ${acked ? "ACKED" : `NOT confirmed within ${deliveryTimeoutMs}ms`}`);
|
|
1604
|
+
return { delivery: acked ? "acked" : "accepted" };
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* True once the friend's implicit acks (PACKET_ID_REQUEST walks) have moved
|
|
1608
|
+
* our reliable send window past `endPacketNumber` — i.e. every packet we had
|
|
1609
|
+
* sent by that point is confirmed received on their device. False when the
|
|
1610
|
+
* window doesn't drain in time or the session dies/rekeys under us (a new
|
|
1611
|
+
* session restarts the numbering, so the old position proves nothing).
|
|
1612
|
+
*/
|
|
1613
|
+
async #awaitTransportAck(pubkey, endPacketNumber, timeoutMs) {
|
|
1614
|
+
const watched = this.#friendSessions.get(pubkey);
|
|
1615
|
+
if (!watched)
|
|
1616
|
+
return false;
|
|
1617
|
+
const deadline = Date.now() + timeoutMs;
|
|
1618
|
+
for (;;) {
|
|
1619
|
+
const session = this.#friendSessions.get(pubkey);
|
|
1620
|
+
if (session !== watched || !session.established)
|
|
1621
|
+
return false;
|
|
1622
|
+
const start = session.sendBufferStartNum;
|
|
1623
|
+
if (start !== undefined) {
|
|
1624
|
+
// Wraparound-safe: the window is ≤8192 packets, so a "distance" over
|
|
1625
|
+
// 2^31 means start has passed endPacketNumber.
|
|
1626
|
+
const pending = (endPacketNumber - start) >>> 0;
|
|
1627
|
+
if (pending === 0 || pending > 0x80000000)
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
else if (!session.sendArray || session.sendArray.size === 0) {
|
|
1631
|
+
return true; // nothing left unacknowledged at all
|
|
1632
|
+
}
|
|
1633
|
+
if (Date.now() >= deadline)
|
|
1634
|
+
return false;
|
|
1635
|
+
await sleep(250);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
|
|
1640
|
+
* DHT/relay key differs from its identity key, where a JS peer announces its
|
|
1641
|
+
* identity key as its DHT key. Natives cannot see the toxcore file-transfer
|
|
1642
|
+
* protocol (sendFile) — the inline envelope is their only file path — and
|
|
1643
|
+
* they never speak the JS text-ACK scheme.
|
|
1644
|
+
*/
|
|
1645
|
+
isNativeFriend(pubkey) {
|
|
1646
|
+
const friend = this.#friends.get(pubkey);
|
|
1647
|
+
return !!(friend?.dhtPubkey && friend.dhtPubkey !== friend.pubkey);
|
|
1573
1648
|
}
|
|
1574
1649
|
/** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
|
|
1575
1650
|
* This is the channel the iOS/Android WebRTC SDK uses for call signaling —
|
|
@@ -3013,7 +3088,11 @@ export class Peer {
|
|
|
3013
3088
|
}
|
|
3014
3089
|
}
|
|
3015
3090
|
// Offline images/audio arrive as the same FileModel JSON envelope
|
|
3016
|
-
// (iOS posts the whole thing as one express MESSAGE packet)
|
|
3091
|
+
// (iOS posts the whole thing as one express MESSAGE packet), or as
|
|
3092
|
+
// Android's binary [len][meta][bytes] envelope (ExpressNodeClient uses
|
|
3093
|
+
// the very same packFilePayload as the online path).
|
|
3094
|
+
if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
|
|
3095
|
+
return;
|
|
3017
3096
|
if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
|
|
3018
3097
|
return;
|
|
3019
3098
|
void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
|
|
@@ -4222,19 +4301,27 @@ export class Peer {
|
|
|
4222
4301
|
this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
|
|
4223
4302
|
return;
|
|
4224
4303
|
}
|
|
4304
|
+
// The message body as it arrived, still undecoded. Android Beagle sends
|
|
4305
|
+
// files as a BINARY envelope (see #tryEmitBinaryInlineFile), and raw file
|
|
4306
|
+
// bytes do not survive a UTF-8 decode — so the sniff has to run here, on
|
|
4307
|
+
// the bytes, before anything turns them into a string.
|
|
4308
|
+
let raw;
|
|
4225
4309
|
if (carrier?.type === PACKET_TYPE_BULKMSG) {
|
|
4226
4310
|
const complete = this.#assembleBulkMsg(friendId, carrier, packetNumber);
|
|
4227
4311
|
if (!complete)
|
|
4228
4312
|
return; // more fragments pending
|
|
4229
|
-
|
|
4313
|
+
raw = complete;
|
|
4230
4314
|
this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
|
|
4231
4315
|
}
|
|
4232
4316
|
else if (carrier?.type === PACKET_TYPE_MESSAGE) {
|
|
4233
|
-
|
|
4317
|
+
raw = carrier.data;
|
|
4234
4318
|
}
|
|
4235
4319
|
else {
|
|
4236
|
-
|
|
4320
|
+
raw = inner;
|
|
4237
4321
|
}
|
|
4322
|
+
if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
|
|
4323
|
+
return;
|
|
4324
|
+
text = decodeUtf8Best(raw);
|
|
4238
4325
|
// Carrier C peers terminate strings with a NUL — strip it so chat
|
|
4239
4326
|
// text doesn't carry an invisible trailing byte.
|
|
4240
4327
|
text = text?.replace(/\0+$/u, "");
|
|
@@ -4480,6 +4567,28 @@ export class Peer {
|
|
|
4480
4567
|
return false;
|
|
4481
4568
|
}
|
|
4482
4569
|
}
|
|
4570
|
+
/** Android Beagle sends files as a BINARY envelope over the (bulk)message
|
|
4571
|
+
* channel — [uint32 BE metaLen][meta JSON][raw bytes], no base64. Raw file
|
|
4572
|
+
* bytes do not survive a UTF-8 decode, so this sniff runs on the UNDECODED
|
|
4573
|
+
* payload; before it existed such a message landed in the chat as a wall of
|
|
4574
|
+
* U+FFFD mojibake with the file lost. Returns true when handled. */
|
|
4575
|
+
#tryEmitBinaryInlineFile(friendId, bytes, via) {
|
|
4576
|
+
const envelope = decodeAndroidFileEnvelope(bytes);
|
|
4577
|
+
if (!envelope)
|
|
4578
|
+
return false;
|
|
4579
|
+
if (envelope.declaredSize !== undefined && envelope.declaredSize !== envelope.data.length) {
|
|
4580
|
+
this.#debugLog(`inline file from ${friendId}: declared ${envelope.declaredSize}B, got ${envelope.data.length}B`);
|
|
4581
|
+
}
|
|
4582
|
+
this.#events.emit("inlineFile", {
|
|
4583
|
+
pubkey: friendId,
|
|
4584
|
+
name: envelope.name,
|
|
4585
|
+
fileType: inlineFileTypeFor(envelope.name, envelope.contentType),
|
|
4586
|
+
data: envelope.data,
|
|
4587
|
+
via
|
|
4588
|
+
});
|
|
4589
|
+
this.#debugLog(`inline file (Android envelope) from ${friendId}: "${envelope.name}" (${envelope.data.length} bytes, ${via})`);
|
|
4590
|
+
return true;
|
|
4591
|
+
}
|
|
4483
4592
|
/** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
|
|
4484
4593
|
* (a) implicitly ACK every walked packet number that isn't requested,
|
|
4485
4594
|
* (b) retransmit the requested ones from the send buffer. */
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -135,6 +135,23 @@ export type TextMessage = {
|
|
|
135
135
|
* can see when online delivery is failing and only offline messages land. */
|
|
136
136
|
via?: "online" | "offline";
|
|
137
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Outcome of an inline-file send — the caller's ONLY honest basis for a "sent"
|
|
140
|
+
* checkmark. "Bytes left this machine" is not delivery.
|
|
141
|
+
*/
|
|
142
|
+
export type InlineSendResult = {
|
|
143
|
+
/**
|
|
144
|
+
* - "acked": the peer CONFIRMED receipt — an app-level ACK from a JS peer,
|
|
145
|
+
* or the toxcore transport ACK from a native one (the same signal Android
|
|
146
|
+
* surfaces as ReceiptState.Delivered). Safe to mark "sent".
|
|
147
|
+
* - "accepted": the transport took the bytes but no confirmation arrived in
|
|
148
|
+
* the window. NOT delivered as far as anyone can prove — mark it failed or
|
|
149
|
+
* queued, never "sent".
|
|
150
|
+
* - "offline": stored on the express relay for the peer's next pull. Mark
|
|
151
|
+
* it queued.
|
|
152
|
+
*/
|
|
153
|
+
delivery: "acked" | "accepted" | "offline";
|
|
154
|
+
};
|
|
138
155
|
export type SendTextUntilAckOptions = {
|
|
139
156
|
/** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
|
|
140
157
|
deliveryId?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
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",
|