@ghostlytools/cli 1.2.3 → 1.3.0
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/README.md +18 -7
- package/dist/assets/api-BiaX6l6f.js +3 -0
- package/dist/assets/api-CrxVxtY1.js +4071 -0
- package/dist/assets/api-CrxVxtY1.js.map +1 -0
- package/dist/assets/apiKit-CajiDxdK.js +196 -0
- package/dist/assets/apiKit-CajiDxdK.js.map +1 -0
- package/dist/assets/apps-CbfiCSIw.js +787 -0
- package/dist/assets/apps-CbfiCSIw.js.map +1 -0
- package/dist/assets/apps-t23gkc28.js +2 -0
- package/dist/assets/{arkade-B-ALutmU.js → arkade-D4BVJFEQ.js} +2 -2
- package/dist/assets/{arkade-B-ALutmU.js.map → arkade-D4BVJFEQ.js.map} +1 -1
- package/dist/assets/backup-BAMi2lAq.js +731 -0
- package/dist/assets/backup-BAMi2lAq.js.map +1 -0
- package/dist/assets/backup-Cg52WeQi.js +2 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/buttons-CWl2yNJu.js +2 -0
- package/dist/assets/buttons-CX1pKB0j.js +497 -0
- package/dist/assets/buttons-CX1pKB0j.js.map +1 -0
- package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-Dn7H8qE1.js} +14 -8
- package/dist/assets/endpoint-Dn7H8qE1.js.map +1 -0
- package/dist/assets/envelope-BvU0fLEt.js +90 -0
- package/dist/assets/envelope-BvU0fLEt.js.map +1 -0
- package/dist/assets/errors-my6VJR_0.js +77 -0
- package/dist/assets/errors-my6VJR_0.js.map +1 -0
- package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
- package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
- package/dist/assets/fileBytes-CgbibpbV.js +2 -0
- package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
- package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
- package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
- package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
- package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
- package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
- package/dist/assets/groupFiles-BNTSDaoq.js +7685 -0
- package/dist/assets/groupFiles-BNTSDaoq.js.map +1 -0
- package/dist/assets/groupFiles-DBbjOLuD.js +2088 -0
- package/dist/assets/groupFiles-DBbjOLuD.js.map +1 -0
- package/dist/assets/host-Cmd205Dc.js +4648 -0
- package/dist/assets/host-Cmd205Dc.js.map +1 -0
- package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
- package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-hKC9DpIH.js → hyperdhtRelay-CEmBpT0Q.js} +26 -10
- package/dist/assets/hyperdhtRelay-CEmBpT0Q.js.map +1 -0
- package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
- package/dist/assets/idb-B5L8wuOm.js.map +1 -0
- package/dist/assets/irohWeb--YyEA-hX.js +545 -0
- package/dist/assets/irohWeb--YyEA-hX.js.map +1 -0
- package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
- package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
- package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
- package/dist/assets/main-BzNN6bkx.js +4302 -0
- package/dist/assets/main-BzNN6bkx.js.map +1 -0
- package/dist/assets/{mainline-DriRu_hb.js → mainline-D5D5C-jT.js} +14 -6
- package/dist/assets/mainline-D5D5C-jT.js.map +1 -0
- package/dist/assets/mints-BrlY773j.js +74 -0
- package/dist/assets/mints-BrlY773j.js.map +1 -0
- package/dist/assets/mints-CqTdA4Gk.js +2 -0
- package/dist/assets/networkReason-DxZWHw6v.js +154 -0
- package/dist/assets/networkReason-DxZWHw6v.js.map +1 -0
- package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
- package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
- package/dist/assets/profiles-B6F4oa9K.js +139 -0
- package/dist/assets/profiles-B6F4oa9K.js.map +1 -0
- package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
- package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
- package/dist/assets/{server-DHMXMwNn.js → server-CBCEYKMN.js} +1290 -277
- package/dist/assets/server-CBCEYKMN.js.map +1 -0
- package/dist/assets/{src-_FzJkeAJ.js → src-DhfGgAUu.js} +1068 -2266
- package/dist/assets/src-DhfGgAUu.js.map +1 -0
- package/dist/assets/turnRefs-ChEfgbSX.js +323 -0
- package/dist/assets/turnRefs-ChEfgbSX.js.map +1 -0
- package/dist/assets/{usdt-D0lO2pRR.js → usdt-C1Xao2xH.js} +3 -3
- package/dist/assets/{usdt-D0lO2pRR.js.map → usdt-C1Xao2xH.js.map} +1 -1
- package/dist/ghostly.mjs +4 -21992
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
- package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
- package/dist/assets/hyperdhtRelay-hKC9DpIH.js.map +0 -1
- package/dist/assets/idb-CTF6LF5B.js.map +0 -1
- package/dist/assets/mainline-DriRu_hb.js.map +0 -1
- package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
- package/dist/assets/server-DHMXMwNn.js.map +0 -1
- package/dist/assets/src-_FzJkeAJ.js.map +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { At as fromBase64, B as readStatusCard, C as downsamplePeaks, Ct as hasVisible, D as parseVideoMeta, Dt as trimEndOf, Et as stripHidden, Ft as toBase64Url, H as statusCardBytes, I as cardEditNumber, It as toZ32, Lt as utf8Decode, Mt as fromZ32, N as STATUS_CARD_LIMITS, Nt as randomBytes, O as BUTTON_ID, Ot as bytesEqual, Pt as toBase64, Rt as utf8Encode, St as wrapDataChannel, T as parseVoiceMeta, Tt as sanitizeNick, X as APP_PREFIXES, Y as APP_NAME, _t as LIMITS, bt as encodeControl, c as readGroupFileMeta, ct as readAppSignature, dt as verifyAppRevocation, et as appRefPublisher, f as imageSize, ft as verifyAppSignature, g as formatFileSize, gt as CHUNK_KIND, h as FileTransfers, ht as readCanonicalJson, it as isAppRef, jt as fromBase64Url, kt as concatBytes$1, lt as signAppObject, mt as canonicalJsonBytes, nt as isAppHash, ot as readAppRevocationShape, p as parseImageMeta, rt as isAppKey, t as GROUP_FILE_LIMITS, v as sanitizeFileName, vt as decodeChunk, wt as sanitizeDisplayText, xt as sendBody, y as sanitizeMime, yt as decodeControl } from "./groupFiles-DBbjOLuD.js";
|
|
1
2
|
import { xsalsa20poly1305 } from "@noble/ciphers/salsa.js";
|
|
2
3
|
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
|
3
4
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
@@ -12,165 +13,6 @@ import { p256, p384, p521 } from "@noble/curves/nist.js";
|
|
|
12
13
|
import { gcm } from "@noble/ciphers/aes.js";
|
|
13
14
|
import { ripemd160 } from "@noble/hashes/legacy.js";
|
|
14
15
|
import { bytesToNumberBE } from "@noble/curves/utils.js";
|
|
15
|
-
//#region ../core/src/bytes.ts
|
|
16
|
-
var textEncoder$1 = new TextEncoder();
|
|
17
|
-
var textDecoder$1 = new TextDecoder("utf-8", { fatal: true });
|
|
18
|
-
function utf8Encode(str) {
|
|
19
|
-
return textEncoder$1.encode(str);
|
|
20
|
-
}
|
|
21
|
-
function utf8Decode(bytes) {
|
|
22
|
-
return textDecoder$1.decode(bytes);
|
|
23
|
-
}
|
|
24
|
-
function concatBytes$1(...parts) {
|
|
25
|
-
let length = 0;
|
|
26
|
-
for (const p of parts) length += p.length;
|
|
27
|
-
const out = new Uint8Array(length);
|
|
28
|
-
let offset = 0;
|
|
29
|
-
for (const p of parts) {
|
|
30
|
-
out.set(p, offset);
|
|
31
|
-
offset += p.length;
|
|
32
|
-
}
|
|
33
|
-
return out;
|
|
34
|
-
}
|
|
35
|
-
function bytesEqual(a, b) {
|
|
36
|
-
if (a.length !== b.length) return false;
|
|
37
|
-
let diff = 0;
|
|
38
|
-
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
39
|
-
return diff === 0;
|
|
40
|
-
}
|
|
41
|
-
/** `Uint8Array.prototype.toBase64` (Node 25+, Chrome 140+, Firefox 133+, Safari 18.2+): ~200x the JS below. */
|
|
42
|
-
var nativeToBase64 = Uint8Array.prototype.toBase64;
|
|
43
|
-
var tableOf = (chars) => Uint8Array.from(chars, (c) => c.charCodeAt(0));
|
|
44
|
-
var BASE64_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
|
|
45
|
-
var BASE64URL_TABLE = tableOf("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");
|
|
46
|
-
/**
|
|
47
|
-
* Base64 where the runtime has no native encoder (Node 22 and 24, older WebViews): the characters go into bytes and
|
|
48
|
-
* become one string at the end. Every file chunk is encoded (files/3 `pf-data`), so this is on a transfer's path.
|
|
49
|
-
*/
|
|
50
|
-
function encodeBase64(bytes, table, pad) {
|
|
51
|
-
const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4);
|
|
52
|
-
let o = 0, i = 0;
|
|
53
|
-
for (; i + 2 < bytes.length; i += 3) {
|
|
54
|
-
const n = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2];
|
|
55
|
-
out[o++] = table[n >>> 18];
|
|
56
|
-
out[o++] = table[n >>> 12 & 63];
|
|
57
|
-
out[o++] = table[n >>> 6 & 63];
|
|
58
|
-
out[o++] = table[n & 63];
|
|
59
|
-
}
|
|
60
|
-
const rest = bytes.length - i;
|
|
61
|
-
if (rest) {
|
|
62
|
-
const n = bytes[i] << 16 | (rest === 2 ? bytes[i + 1] << 8 : 0);
|
|
63
|
-
out[o++] = table[n >>> 18];
|
|
64
|
-
out[o++] = table[n >>> 12 & 63];
|
|
65
|
-
if (rest === 2) out[o++] = table[n >>> 6 & 63];
|
|
66
|
-
else if (pad) out[o++] = 61;
|
|
67
|
-
if (pad) out[o++] = 61;
|
|
68
|
-
}
|
|
69
|
-
return textDecoder$1.decode(out.subarray(0, o));
|
|
70
|
-
}
|
|
71
|
-
function toBase64(bytes) {
|
|
72
|
-
return nativeToBase64 ? nativeToBase64.call(bytes) : encodeBase64(bytes, BASE64_TABLE, true);
|
|
73
|
-
}
|
|
74
|
-
function fromBase64(str) {
|
|
75
|
-
const binary = atob(str);
|
|
76
|
-
const bytes = new Uint8Array(binary.length);
|
|
77
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
78
|
-
return bytes;
|
|
79
|
-
}
|
|
80
|
-
function toBase64Url(bytes) {
|
|
81
|
-
return nativeToBase64 ? nativeToBase64.call(bytes, {
|
|
82
|
-
alphabet: "base64url",
|
|
83
|
-
omitPadding: true
|
|
84
|
-
}) : encodeBase64(bytes, BASE64URL_TABLE, false);
|
|
85
|
-
}
|
|
86
|
-
function fromBase64Url(str) {
|
|
87
|
-
let b = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
88
|
-
while (b.length % 4) b += "=";
|
|
89
|
-
return fromBase64(b);
|
|
90
|
-
}
|
|
91
|
-
var Z32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769";
|
|
92
|
-
function toZ32(bytes) {
|
|
93
|
-
let out = "";
|
|
94
|
-
let bits = 0;
|
|
95
|
-
let value = 0;
|
|
96
|
-
for (const byte of bytes) {
|
|
97
|
-
value = value << 8 | byte;
|
|
98
|
-
bits += 8;
|
|
99
|
-
while (bits >= 5) {
|
|
100
|
-
out += Z32_ALPHABET[value >>> bits - 5 & 31];
|
|
101
|
-
bits -= 5;
|
|
102
|
-
}
|
|
103
|
-
value &= (1 << bits) - 1;
|
|
104
|
-
}
|
|
105
|
-
if (bits > 0) out += Z32_ALPHABET[value << 5 - bits & 31];
|
|
106
|
-
return out;
|
|
107
|
-
}
|
|
108
|
-
function fromZ32(str) {
|
|
109
|
-
const out = new Uint8Array(Math.floor(str.length * 5 / 8));
|
|
110
|
-
let bits = 0;
|
|
111
|
-
let value = 0;
|
|
112
|
-
let index = 0;
|
|
113
|
-
for (const char of str) {
|
|
114
|
-
const v = Z32_ALPHABET.indexOf(char);
|
|
115
|
-
if (v < 0) throw new Error(`Invalid z-base-32 character: ${char}`);
|
|
116
|
-
value = value << 5 | v;
|
|
117
|
-
bits += 5;
|
|
118
|
-
if (bits >= 8) {
|
|
119
|
-
out[index++] = value >>> bits - 8 & 255;
|
|
120
|
-
bits -= 8;
|
|
121
|
-
}
|
|
122
|
-
value &= (1 << bits) - 1;
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
}
|
|
126
|
-
function randomBytes(length) {
|
|
127
|
-
const bytes = new Uint8Array(length);
|
|
128
|
-
crypto.getRandomValues(bytes);
|
|
129
|
-
return bytes;
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Invisible and direction-changing characters. Unlike a file name, display
|
|
133
|
-
* text may legitimately hold emoji and Indic or Arabic joiners, so the two
|
|
134
|
-
* joiners stay and the rest of Cf goes: bidi overrides and isolates reorder
|
|
135
|
-
* what follows them, and the others hide text or let two different names
|
|
136
|
-
* render identically.
|
|
137
|
-
*/
|
|
138
|
-
var HIDDEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
139
|
-
/** Zero-width non-joiner and zero-width joiner. */
|
|
140
|
-
var KEEP = /[\u200c\u200d]/;
|
|
141
|
-
/** `text` without its invisible and direction-changing characters, at any length: for display text with a cut of its own. */
|
|
142
|
-
function stripHidden(text) {
|
|
143
|
-
return text.replace(HIDDEN, (char) => KEEP.test(char) ? char : "");
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* What draws nothing when it stands alone and `HIDDEN` leaves in, since text needs it beside something else: the
|
|
147
|
-
* joiners, the fillers, the variation selectors and the other default-ignorable code points, combining marks, white
|
|
148
|
-
* space and the blank Braille pattern.
|
|
149
|
-
*/
|
|
150
|
-
var UNSEEN = /^[\p{Default_Ignorable_Code_Point}\p{M}\p{White_Space}\u2800]*$/u;
|
|
151
|
-
/** Whether `text` holds a character that shows by itself: a name of joiners, fillers or marks alone has none. */
|
|
152
|
-
function hasVisible(text) {
|
|
153
|
-
return !UNSEEN.test(text);
|
|
154
|
-
}
|
|
155
|
-
/** Undefined when nothing visible is left, so callers can fall back. */
|
|
156
|
-
function sanitizeDisplayText(text, max) {
|
|
157
|
-
const clean = [...stripHidden(text)].slice(0, max).join("").trim();
|
|
158
|
-
return hasVisible(clean) ? clean : void 0;
|
|
159
|
-
}
|
|
160
|
-
/** A nickname as it arrived from a peer, ready to show. */
|
|
161
|
-
function sanitizeNick(nick) {
|
|
162
|
-
return typeof nick === "string" ? sanitizeDisplayText(nick, 64) : void 0;
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* `text` without the characters of `chars` it ends with, in one pass from the end. A `/[…]+$/` replace would retry
|
|
166
|
-
* from every character of a run that does not reach the end.
|
|
167
|
-
*/
|
|
168
|
-
function trimEndOf(text, chars) {
|
|
169
|
-
let end = text.length;
|
|
170
|
-
while (end > 0 && chars.includes(text[end - 1])) end--;
|
|
171
|
-
return end === text.length ? text : text.slice(0, end);
|
|
172
|
-
}
|
|
173
|
-
//#endregion
|
|
174
16
|
//#region ../core/src/avatar.ts
|
|
175
17
|
/**
|
|
176
18
|
* A profile picture as it travels between paired peers: a small square JPEG in a data URL. The picture
|
|
@@ -181,7 +23,7 @@ function trimEndOf(text, chars) {
|
|
|
181
23
|
var MAX_AVATAR_LENGTH = 48e3;
|
|
182
24
|
var PREFIX$1 = "data:image/jpeg;base64,";
|
|
183
25
|
/** Width and height from a JPEG's frame header, or undefined when there is none before the image data. */
|
|
184
|
-
function jpegSize
|
|
26
|
+
function jpegSize(bytes) {
|
|
185
27
|
if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return;
|
|
186
28
|
let i = 2;
|
|
187
29
|
while (i + 4 <= bytes.length) {
|
|
@@ -216,7 +58,7 @@ function sanitizeAvatar(value) {
|
|
|
216
58
|
}
|
|
217
59
|
const bytes = new Uint8Array(binary.length);
|
|
218
60
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
219
|
-
const size = jpegSize
|
|
61
|
+
const size = jpegSize(bytes);
|
|
220
62
|
if (!size || size.width < 1 || size.height < 1 || size.width > 512 || size.height > 512) return;
|
|
221
63
|
return value;
|
|
222
64
|
}
|
|
@@ -234,7 +76,8 @@ function generateEncryptionKey() {
|
|
|
234
76
|
function encrypt(plaintext, key) {
|
|
235
77
|
if (key.length !== KEY_LENGTH) throw new Error("Invalid key length: expected 32 bytes");
|
|
236
78
|
const nonce = randomBytes(NONCE_LENGTH$3);
|
|
237
|
-
|
|
79
|
+
const box = xsalsa20poly1305(key, nonce).encrypt(utf8Encode(plaintext));
|
|
80
|
+
return toBase64(concatBytes$1(nonce, box));
|
|
238
81
|
}
|
|
239
82
|
/** The length of what `encrypt` returns for a plaintext of this many UTF-8 bytes: base64 of nonce, box and 16-byte tag. */
|
|
240
83
|
function sealedLength(plaintextBytes) {
|
|
@@ -515,27 +358,31 @@ function signRelayPayload(identity, dnsPacket, seq) {
|
|
|
515
358
|
new DataView(timestamp.buffer).setBigUint64(0, seq);
|
|
516
359
|
return concatBytes$1(signature, timestamp, dnsPacket);
|
|
517
360
|
}
|
|
518
|
-
/**
|
|
519
|
-
function
|
|
361
|
+
/** A relay payload's parts, its size checked: the signature, the sequence number and the DNS packet (not decoded). */
|
|
362
|
+
function splitRelayPayload(payload) {
|
|
520
363
|
if (payload.length < 84) throw new Error("Relay payload too short");
|
|
521
364
|
if (payload.length > 1072) throw new PacketTooLargeError(payload.length);
|
|
365
|
+
return {
|
|
366
|
+
signature: payload.subarray(0, SIGNATURE_LENGTH),
|
|
367
|
+
seq: new DataView(payload.buffer, payload.byteOffset + SIGNATURE_LENGTH, TIMESTAMP_LENGTH).getBigUint64(0),
|
|
368
|
+
dnsPacket: payload.subarray(72)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** Checks a relay payload's size and signature. Returns its sequence number and DNS packet, not decoded. */
|
|
372
|
+
function openRelayPayload(pubKeyZ32, payload) {
|
|
373
|
+
const { signature, seq, dnsPacket } = splitRelayPayload(payload);
|
|
522
374
|
const publicKey = publicKeyFromZ32(pubKeyZ32);
|
|
523
|
-
const signature = payload.subarray(0, SIGNATURE_LENGTH);
|
|
524
|
-
const seq = new DataView(payload.buffer, payload.byteOffset + SIGNATURE_LENGTH, TIMESTAMP_LENGTH).getBigUint64(0);
|
|
525
|
-
const dnsPacket = payload.subarray(72);
|
|
526
375
|
if (!verify(signature, signable(seq, dnsPacket), publicKey)) throw new Error("Invalid signature");
|
|
527
376
|
return {
|
|
528
377
|
seq,
|
|
529
378
|
dnsPacket
|
|
530
379
|
};
|
|
531
380
|
}
|
|
532
|
-
|
|
533
|
-
function parseRelayPayload(pubKeyZ32, payload) {
|
|
534
|
-
const { seq: timestampMicros, dnsPacket } = openRelayPayload(pubKeyZ32, payload);
|
|
381
|
+
function decodeRelayPayload(pubKeyZ32, seq, dnsPacket) {
|
|
535
382
|
const suffix = `.${pubKeyZ32}`;
|
|
536
383
|
return {
|
|
537
384
|
pubKeyZ32,
|
|
538
|
-
timestampMicros,
|
|
385
|
+
timestampMicros: seq,
|
|
539
386
|
records: decodeTxtPacket(dnsPacket).map((r) => ({
|
|
540
387
|
label: r.name.endsWith(suffix) ? r.name.slice(0, -suffix.length) : r.name,
|
|
541
388
|
value: r.value,
|
|
@@ -543,6 +390,20 @@ function parseRelayPayload(pubKeyZ32, payload) {
|
|
|
543
390
|
}))
|
|
544
391
|
};
|
|
545
392
|
}
|
|
393
|
+
/** Verifies and decodes a relay payload. Throws if the signature does not match. */
|
|
394
|
+
function parseRelayPayload(pubKeyZ32, payload) {
|
|
395
|
+
const { seq, dnsPacket } = openRelayPayload(pubKeyZ32, payload);
|
|
396
|
+
return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Decodes a payload whose signature this process made or already checked (one it is putting), without checking it
|
|
400
|
+
* again: an Ed25519 verify costs about 1 ms, and a CLI daemon at its relay budget checked its own packets ~500 times a
|
|
401
|
+
* minute. Never for bytes read from elsewhere: `parseRelayPayload` is.
|
|
402
|
+
*/
|
|
403
|
+
function readOwnRelayPayload(pubKeyZ32, payload) {
|
|
404
|
+
const { seq, dnsPacket } = splitRelayPayload(payload);
|
|
405
|
+
return decodeRelayPayload(pubKeyZ32, seq, dnsPacket);
|
|
406
|
+
}
|
|
546
407
|
/** How far ahead of this clock a packet's time may be and still count as now: clocks drift, not by this much. */
|
|
547
408
|
var PKARR_FUTURE_SKEW_MS = 6e5;
|
|
548
409
|
/**
|
|
@@ -584,6 +445,7 @@ function withRequestOptions(transport, extra) {
|
|
|
584
445
|
if (transport.subscribe) wrapped.subscribe = (listener) => transport.subscribe(listener);
|
|
585
446
|
if (transport.networkChanged) wrapped.networkChanged = () => transport.networkChanged();
|
|
586
447
|
if (transport.readAnsweredAt) wrapped.readAnsweredAt = (pubKeyZ32) => transport.readAnsweredAt(pubKeyZ32);
|
|
448
|
+
if (transport.readHeldBackFor) wrapped.readHeldBackFor = (pubKeyZ32) => transport.readHeldBackFor(pubKeyZ32);
|
|
587
449
|
if (transport.onServerTime) wrapped.onServerTime = (listener) => transport.onServerTime(listener);
|
|
588
450
|
if (transport.configure) wrapped.configure = (options) => transport.configure(options);
|
|
589
451
|
if (transport.turnRead) wrapped.turnRead = (pubKeyZ32, options) => transport.turnRead(pubKeyZ32, options);
|
|
@@ -798,6 +660,11 @@ var isGroupSignal = (who) => who.group && !who.background && (who.signal || isWa
|
|
|
798
660
|
var watchLane = (who) => `${+who.urgent}${+who.background}`;
|
|
799
661
|
/** A group's read that is not a background one: an edge's, a private group's knock poll (`GROUP_RATION_WINDOW_MS`). */
|
|
800
662
|
var isGroupRead = (who) => who.group && !who.write && !who.background;
|
|
663
|
+
/**
|
|
664
|
+
* A read that takes its turn per key with the others (`READ_TURN_MS`): a background one, and a 1:1 chat's at any pace.
|
|
665
|
+
* Not a community door's bell.
|
|
666
|
+
*/
|
|
667
|
+
var isTurnRead = (who) => !who.write && !who.door && (who.background || !who.group);
|
|
801
668
|
/** A write that goes first once the budget frees a request (see `WRITE_FIRST_MS`): a chat's or a group's, never a background one. */
|
|
802
669
|
var firstWriter = (who) => !who.write || who.background ? null : who.group ? "group" : "chat";
|
|
803
670
|
/**
|
|
@@ -826,6 +693,8 @@ function currentRelays(relays) {
|
|
|
826
693
|
* only thing that peer is waiting for) would go out only once the polling slowed down, half a minute later.
|
|
827
694
|
*/
|
|
828
695
|
var WRITE_FIRST_MS = 5e3;
|
|
696
|
+
/** The most of a relay's minute the chats' reserve takes, however many chats need it: the rest stays for everything else. */
|
|
697
|
+
var CHAT_RESERVE_MOST_SHARE = 5 / 6;
|
|
829
698
|
/**
|
|
830
699
|
* A 1:1 chat's signaling (a new offer or answer, and its reads for the answer to its offer, `signal`) may go over a
|
|
831
700
|
* relay's minute by this fraction of it: 6 on a relay of 30, 12 on one of 60, 1 on relay.pkarr.org's 5 (it allows 10),
|
|
@@ -871,6 +740,29 @@ var WATCH_SHARE = 1 / 3;
|
|
|
871
740
|
* waits, the others wait too (`WATCH_SHARE`, `GROUP_RATION_WINDOW_MS`). One that stops asking loses it.
|
|
872
741
|
*/
|
|
873
742
|
var WATCH_TURN_MS = 5e3;
|
|
743
|
+
/**
|
|
744
|
+
* Background reads, and a 1:1 chat's at any pace, take turns per key the same way: while a key read longer ago asked
|
|
745
|
+
* within this long and was not read since, a read of another key waits (a chat's, at a pace that is not the background
|
|
746
|
+
* one, only for another chat's key). A saved chat that is not live reads its contact every 30 s in the
|
|
747
|
+
* background, and one the budget refused read again only at its next poll. A request frees 60 s after the one that took
|
|
748
|
+
* it, which is the poll after next of the chat that took it: the same chats took every request that freed. A daemon with
|
|
749
|
+
* 30 chats and 5 groups, back after a restart while its groups signaled, read some contacts once in 10 min, and those
|
|
750
|
+
* chats stayed down although both sides were online and offering (CLI bug hunt, 2026-10-12). A link's background read
|
|
751
|
+
* the budget refused reads again when it frees a request (`LinkSession`), so the key whose turn it is is there to take it.
|
|
752
|
+
* A chat not live after a restart looks fast while its offer is out, and those reads took no turn: the budget answered
|
|
753
|
+
* them from the packet read before, every 2 to 8 s for 10 minutes, while the chats that were live already, the groups'
|
|
754
|
+
* edges and the writes took every request that freed. A quarter of a daemon's 30 chats read their contact once in 10
|
|
755
|
+
* minutes, their contacts online and offering, and showed them offline at 6 minutes (CLI bug hunt, 2026-10-13).
|
|
756
|
+
*/
|
|
757
|
+
var READ_TURN_MS = 5e3;
|
|
758
|
+
/**
|
|
759
|
+
* A 1:1 chat's read the budget held back this long without a break is starved: it goes before the writes that wait
|
|
760
|
+
* (`WRITE_FIRST_MS`) and past the background share, within the minute, the key held back longest first. A daemon back
|
|
761
|
+
* after a restart spends its whole minute on writes (each chat's and edge's offer, its packets after the attempt), which
|
|
762
|
+
* go first, and requests free in bursts as the minute's first requests age out: every burst went to the writes that
|
|
763
|
+
* waited, and the reads of the chats that were not in an attempt never had their turn (CLI restart lab, 2026-10-13).
|
|
764
|
+
*/
|
|
765
|
+
var READ_STARVED_MS = 6e4;
|
|
874
766
|
/** How long a read that let an older one go first waits before it asks again. */
|
|
875
767
|
var TURN_WAIT_MS = 1e3;
|
|
876
768
|
/**
|
|
@@ -990,6 +882,8 @@ var RelayTransport = class {
|
|
|
990
882
|
* it is under the watch share (`WATCH_SHARE`) and while the groups' reads are rationed (`GROUP_RATION_WINDOW_MS`).
|
|
991
883
|
*/
|
|
992
884
|
readTurns = /* @__PURE__ */ new Map();
|
|
885
|
+
/** Per key read in turn (`isTurnRead`), when a read of it last went out and when it last asked (`READ_TURN_MS`). */
|
|
886
|
+
keyTurns = /* @__PURE__ */ new Map();
|
|
993
887
|
/** Groups' reads (not background ones) on each relay in the last `GROUP_RATION_WINDOW_MS`. */
|
|
994
888
|
spentGroupReads = /* @__PURE__ */ new Map();
|
|
995
889
|
/** When a group's read last found each relay's minute full: its reads there are rationed for `GROUP_RATION_MS`. */
|
|
@@ -1009,6 +903,11 @@ var RelayTransport = class {
|
|
|
1009
903
|
/** Per relay and key, the newest packet that relay held back for its budget while another relay took it (`catchUpLater`). */
|
|
1010
904
|
catchUp = /* @__PURE__ */ new Map();
|
|
1011
905
|
catchUpTimer = null;
|
|
906
|
+
/**
|
|
907
|
+
* Per key whose reads the budget held back since its last read went out: when a request frees for it
|
|
908
|
+
* (`readHeldBackFor`), how many times in a row, and since when (`READ_STARVED_MS`).
|
|
909
|
+
*/
|
|
910
|
+
heldBack = /* @__PURE__ */ new Map();
|
|
1012
911
|
/** When the relays last answered a read of each key, for `FRESH_READ_MS`. */
|
|
1013
912
|
readAt = /* @__PURE__ */ new Map();
|
|
1014
913
|
freshReadMs;
|
|
@@ -1131,14 +1030,22 @@ var RelayTransport = class {
|
|
|
1131
1030
|
}
|
|
1132
1031
|
/**
|
|
1133
1032
|
* Signs the records, dated by `nextTimestamp`, and hands the payload to `put` (the relays; the CLI adds the DHT).
|
|
1033
|
+
* When every relay's budget (or rate limit) holds this asker back, it throws that wait before signing anything
|
|
1034
|
+
* (`publishWait`): a daemon at its budget signed about 500 packets a minute that never went out. `elsewhere`: `put`
|
|
1035
|
+
* also takes the packet where no budget holds it back (the CLI's DHT), so it is signed whatever the relays say.
|
|
1134
1036
|
* `put` calls `conflict` for a relay that answered 409: it holds a packet under this key dated at or after this one,
|
|
1135
1037
|
* which this client has not read (an inviter's warm packet under the key it gave its contact, from a clock ahead of
|
|
1136
1038
|
* this one). That packet is read from that relay, and the records go again dated just past it, once. Without this
|
|
1137
1039
|
* the packet was refused until this device's clock passed the other one's, however long that took, and on a relay
|
|
1138
1040
|
* that did take it, readers still preferred the later-dated empty packet of another relay.
|
|
1139
1041
|
*/
|
|
1140
|
-
async publishDated(identity, records, put, options = {}, again = false) {
|
|
1141
|
-
const key = identity.pubKeyZ32
|
|
1042
|
+
async publishDated(identity, records, put, options = {}, again = false, elsewhere = false) {
|
|
1043
|
+
const key = identity.pubKeyZ32;
|
|
1044
|
+
if (!again && !elsewhere) {
|
|
1045
|
+
const held = this.publishWait(key, asker(options, true));
|
|
1046
|
+
if (held) throw new DiscoveryBudgetError(held.wait, `Publish held back on every relay: ${held.reasons.join("; ")}`);
|
|
1047
|
+
}
|
|
1048
|
+
const timestamp = this.nextTimestamp(key);
|
|
1142
1049
|
let redated;
|
|
1143
1050
|
const conflict = (relay) => {
|
|
1144
1051
|
if (again || redated) return;
|
|
@@ -1150,7 +1057,7 @@ var RelayTransport = class {
|
|
|
1150
1057
|
relay
|
|
1151
1058
|
});
|
|
1152
1059
|
if (this.lastTimestamp.get(key) !== timestamp) return true;
|
|
1153
|
-
await this.publishDated(identity, records, put, options, true);
|
|
1060
|
+
await this.publishDated(identity, records, put, options, true, elsewhere);
|
|
1154
1061
|
return true;
|
|
1155
1062
|
})();
|
|
1156
1063
|
redated.catch(() => {});
|
|
@@ -1162,6 +1069,46 @@ var RelayTransport = class {
|
|
|
1162
1069
|
if (!pending || !await pending) throw error;
|
|
1163
1070
|
}
|
|
1164
1071
|
}
|
|
1072
|
+
/**
|
|
1073
|
+
* How long until some relay takes a publish of this asker, and why each holds it back; null when one would now. Walks the relays as `put` does,
|
|
1074
|
+
* without asking any: a relay the budget holds back gets its refused `take` (the asker's write then waits first in
|
|
1075
|
+
* line, as when `put` is refused), one rate limiting or throttling this address is a wait, one left alone for failing
|
|
1076
|
+
* is none. Null too when a relay fails another way (its network cooling down): the publish then goes, and fails, as
|
|
1077
|
+
* before, and when no relay is held back at all.
|
|
1078
|
+
*/
|
|
1079
|
+
publishWait(pubKeyZ32, who) {
|
|
1080
|
+
const probe = this.breaker.allDownProbe(this.relays);
|
|
1081
|
+
let wait = Infinity;
|
|
1082
|
+
const held = [], reasons = [];
|
|
1083
|
+
for (const relay of this.relays) {
|
|
1084
|
+
if (relay !== probe && this.networkCoolingDown(relay, "PUT")) return null;
|
|
1085
|
+
const limited = this.rateLimitedFor(relay);
|
|
1086
|
+
if (limited > 0) {
|
|
1087
|
+
wait = Math.min(wait, limited);
|
|
1088
|
+
reasons.push(`${relay}: Discovery relay is cooling down after a 429; retry shortly`);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const blocked = relay === probe ? 0 : this.breaker.blockedFor(relay);
|
|
1092
|
+
if (blocked > 0) {
|
|
1093
|
+
if (this.breaker.blockedKind(relay) === "throttled") {
|
|
1094
|
+
wait = Math.min(wait, blocked);
|
|
1095
|
+
reasons.push(`${relay}: Discovery relay is throttling this address; retry shortly`);
|
|
1096
|
+
} else reasons.push(`${relay} is left alone after failing`);
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
if (this.heldFor(relay, who, Date.now(), pubKeyZ32) <= 0) return null;
|
|
1100
|
+
held.push(relay);
|
|
1101
|
+
const left = this.heldFor(relay, who);
|
|
1102
|
+
wait = Math.min(wait, left);
|
|
1103
|
+
reasons.push(`${relay}: discovery budget reached; retry in ${Math.ceil(left / 1e3)} s`);
|
|
1104
|
+
}
|
|
1105
|
+
if (wait === Infinity) return null;
|
|
1106
|
+
for (const relay of held) this.take(relay, who, pubKeyZ32);
|
|
1107
|
+
return {
|
|
1108
|
+
wait,
|
|
1109
|
+
reasons
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1165
1112
|
/** One relay's packet for a key, read now: its time, kept for `nextTimestamp`. Null when it has none or does not answer. */
|
|
1166
1113
|
async heldAt(relay, pubKeyZ32, who) {
|
|
1167
1114
|
if (!this.take(relay, who, pubKeyZ32)) return null;
|
|
@@ -1181,10 +1128,22 @@ var RelayTransport = class {
|
|
|
1181
1128
|
sawTimestamp(pubKeyZ32, timestamp) {
|
|
1182
1129
|
if (timestamp > (this.seenTimestamp.get(pubKeyZ32) ?? 0n)) this.seenTimestamp.set(pubKeyZ32, timestamp);
|
|
1183
1130
|
}
|
|
1184
|
-
/**
|
|
1131
|
+
/**
|
|
1132
|
+
* Puts a payload `publishDated` just signed, dated `timestamp`, on every relay. Its signature is not checked: it was
|
|
1133
|
+
* made here (`publishPayload` checks one signed elsewhere, to read its sequence number). `conflict`: as `publishDated`
|
|
1134
|
+
* gives it.
|
|
1135
|
+
*/
|
|
1136
|
+
async putSigned(pubKeyZ32, payload, timestamp, options = {}, conflict) {
|
|
1137
|
+
if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
|
|
1138
|
+
await this.putEverywhere(pubKeyZ32, payload, timestamp, options, conflict);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Puts a payload signed elsewhere (a did:dht document), byte for byte, on every relay; one signed here says its date
|
|
1142
|
+
* (`signedAt`) and is not checked again. `conflict`: as `publishDated` gives it.
|
|
1143
|
+
*/
|
|
1185
1144
|
async publishPayload(pubKeyZ32, payload, options = {}, conflict) {
|
|
1186
1145
|
if (this.relays.length === 0) throw new Error("No Pkarr relays configured");
|
|
1187
|
-
const
|
|
1146
|
+
const seq = options.signedAt ?? openRelayPayload(pubKeyZ32, payload).seq;
|
|
1188
1147
|
await this.putEverywhere(pubKeyZ32, payload, seq, options, conflict);
|
|
1189
1148
|
}
|
|
1190
1149
|
/**
|
|
@@ -1225,7 +1184,7 @@ var RelayTransport = class {
|
|
|
1225
1184
|
accepted = true;
|
|
1226
1185
|
noWriteWaits();
|
|
1227
1186
|
try {
|
|
1228
|
-
this.written.set(pubKeyZ32,
|
|
1187
|
+
this.written.set(pubKeyZ32, readOwnRelayPayload(pubKeyZ32, payload));
|
|
1229
1188
|
} catch {}
|
|
1230
1189
|
resolve();
|
|
1231
1190
|
}, () => {});
|
|
@@ -1398,8 +1357,14 @@ var RelayTransport = class {
|
|
|
1398
1357
|
}
|
|
1399
1358
|
const resting = this.relays.every((r) => this.isCoolingDown(r, "GET") || this.breaker.blockedFor(r) > 0 || this.heldFor(r, who, Date.now(), pubKeyZ32) > 0);
|
|
1400
1359
|
const known = down ? this.newest.get(pubKeyZ32) : newerPacket(this.newest.get(pubKeyZ32), this.written.get(pubKeyZ32));
|
|
1401
|
-
if (resting && known)
|
|
1402
|
-
|
|
1360
|
+
if (resting && known) {
|
|
1361
|
+
if (!down && budgetWait < Infinity) this.heldBackUntil(pubKeyZ32, Date.now() + budgetWait);
|
|
1362
|
+
return known;
|
|
1363
|
+
}
|
|
1364
|
+
if (!down && budgetWait < Infinity) {
|
|
1365
|
+
this.heldBackUntil(pubKeyZ32, Date.now() + budgetWait);
|
|
1366
|
+
throw new DiscoveryBudgetError(budgetWait);
|
|
1367
|
+
}
|
|
1403
1368
|
throw new Error("No Pkarr relay reachable");
|
|
1404
1369
|
}
|
|
1405
1370
|
/** The order a key's read asks the relays in: in turn per key (see `resolve`), relays found slow lately last. */
|
|
@@ -1466,9 +1431,25 @@ var RelayTransport = class {
|
|
|
1466
1431
|
readAnsweredAt(pubKeyZ32) {
|
|
1467
1432
|
return this.readAt.get(pubKeyZ32);
|
|
1468
1433
|
}
|
|
1434
|
+
readHeldBackFor(pubKeyZ32) {
|
|
1435
|
+
const held = this.heldBack.get(pubKeyZ32);
|
|
1436
|
+
const left = held === void 0 ? 0 : held.until - Date.now();
|
|
1437
|
+
return left > 0 ? left : void 0;
|
|
1438
|
+
}
|
|
1439
|
+
heldBackUntil(pubKeyZ32, until) {
|
|
1440
|
+
const held = this.heldBack.get(pubKeyZ32), times = (held?.times ?? 0) + 1;
|
|
1441
|
+
this.heldBack.delete(pubKeyZ32);
|
|
1442
|
+
this.heldBack.set(pubKeyZ32, {
|
|
1443
|
+
until,
|
|
1444
|
+
times,
|
|
1445
|
+
since: held?.since ?? Date.now()
|
|
1446
|
+
});
|
|
1447
|
+
if (this.heldBack.size > READ_TURNS_KEPT) this.heldBack.delete(this.heldBack.keys().next().value);
|
|
1448
|
+
}
|
|
1469
1449
|
/** The relays answered a read of this key now; answers older than `FRESH_READ_MS` are dropped as the list grows. */
|
|
1470
1450
|
answeredRead(pubKeyZ32) {
|
|
1471
1451
|
const now = Date.now();
|
|
1452
|
+
this.heldBack.delete(pubKeyZ32);
|
|
1472
1453
|
this.readAt.delete(pubKeyZ32);
|
|
1473
1454
|
this.readAt.set(pubKeyZ32, now);
|
|
1474
1455
|
if (this.readAt.size > 64) for (const [key, at] of this.readAt) {
|
|
@@ -1584,6 +1565,7 @@ var RelayTransport = class {
|
|
|
1584
1565
|
if (isChat(who)) this.chatNeed(key).urgentAt = now;
|
|
1585
1566
|
}
|
|
1586
1567
|
if (isGroupRead(who)) this.readAsked(key, who, now);
|
|
1568
|
+
if (isTurnRead(who)) this.turnAsked(key, who, now);
|
|
1587
1569
|
if (this.heldFor(relay, who, now, key) > 0) {
|
|
1588
1570
|
if (isGroupRead(who) && (this.spent.get(relay)?.length ?? 0) >= this.limitOf(relay)) this.groupsFullAt.set(relay, now);
|
|
1589
1571
|
if (isChat(who)) this.chatNeed(key).refused.set(relay, now);
|
|
@@ -1607,6 +1589,12 @@ var RelayTransport = class {
|
|
|
1607
1589
|
lane: watchLane(who)
|
|
1608
1590
|
});
|
|
1609
1591
|
}
|
|
1592
|
+
if (isTurnRead(who)) this.keyTurns.set(key, {
|
|
1593
|
+
servedAt: now,
|
|
1594
|
+
askedAt: now,
|
|
1595
|
+
waiting: false,
|
|
1596
|
+
who
|
|
1597
|
+
});
|
|
1610
1598
|
return true;
|
|
1611
1599
|
}
|
|
1612
1600
|
/**
|
|
@@ -1618,7 +1606,7 @@ var RelayTransport = class {
|
|
|
1618
1606
|
* signaling goes over the limit by its allowance (`SIGNALING_ALLOWANCE_SHARE`), and so do a few of its reads of a
|
|
1619
1607
|
* contact that just went away (`DEPARTED_READS`).
|
|
1620
1608
|
*/
|
|
1621
|
-
heldFor(relay, who, now = Date.now(), key) {
|
|
1609
|
+
heldFor(relay, who, now = Date.now(), key, turns = true) {
|
|
1622
1610
|
const recent = (this.spent.get(relay) ?? []).filter((at) => now - at < 6e4);
|
|
1623
1611
|
const recentBackground = (this.spentBackground.get(relay) ?? []).filter((at) => now - at < 6e4);
|
|
1624
1612
|
this.spent.set(relay, recent);
|
|
@@ -1628,10 +1616,11 @@ var RelayTransport = class {
|
|
|
1628
1616
|
const departed = isDepartedRead(who) && key !== void 0 && recent.length >= limit && this.departedReads(key, now) < 6;
|
|
1629
1617
|
const share = isChatSignal(who) || departed ? SIGNALING_ALLOWANCE_SHARE : isGroupSignal(who) ? GROUP_SIGNALING_ALLOWANCE_SHARE : 0;
|
|
1630
1618
|
let wait = over(recent, limit + (share ? Math.max(0, Math.floor(limit * share) - (who.write ? 0 : 1)) : 0));
|
|
1619
|
+
const starved = key !== void 0 && this.starved(key, who, now);
|
|
1631
1620
|
const groupOver = (this.spentGroupOver.get(relay) ?? []).filter((at) => now - at < GROUP_OVER_PACE_MS);
|
|
1632
1621
|
this.spentGroupOver.set(relay, groupOver);
|
|
1633
1622
|
if (isGroupSignal(who) && !who.write && recent.length >= limit && groupOver.length > 0) wait = Math.max(wait, groupOver[groupOver.length - 1] + GROUP_OVER_PACE_MS - now);
|
|
1634
|
-
if (who.background) {
|
|
1623
|
+
if (who.background && !starved) {
|
|
1635
1624
|
wait = Math.max(wait, over(recentBackground, this.backgroundPerMinute));
|
|
1636
1625
|
const signaling = this.urgentAt + SIGNALING_WINDOW_MS - now;
|
|
1637
1626
|
if (signaling > 0 && !who.door) wait = Math.max(wait, Math.min(signaling, over(recentBackground, this.backgroundWhileSignaling)));
|
|
@@ -1657,17 +1646,18 @@ var RelayTransport = class {
|
|
|
1657
1646
|
if (rationed.length >= most) wait = Math.max(wait, rationed[rationed.length - most] + GROUP_RATION_WINDOW_MS - now);
|
|
1658
1647
|
if (this.readsAhead(key, now) > 0) wait = Math.max(wait, TURN_WAIT_MS);
|
|
1659
1648
|
}
|
|
1660
|
-
if (who.group) {
|
|
1649
|
+
if (who.group || who.background) {
|
|
1661
1650
|
const reserved = this.chatReservedFor(relay, now);
|
|
1662
|
-
if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.
|
|
1651
|
+
if (reserved > 0) wait = Math.max(wait, Math.min(reserved, over(recent, limit - this.chatReserveOf(relay, now))));
|
|
1663
1652
|
}
|
|
1664
1653
|
const writer = firstWriter(who);
|
|
1665
1654
|
const waiting = (lane) => {
|
|
1666
1655
|
const at = this.writeWaiting.get(`${lane} ${relay}`);
|
|
1667
1656
|
return at !== void 0 && now - at < 5e3 ? at + WRITE_FIRST_MS - now : 0;
|
|
1668
1657
|
};
|
|
1669
|
-
if (writer !== "chat" && !departed) wait = Math.max(wait, waiting("chat"));
|
|
1670
|
-
if (writer === null && who.group && !pastLimit || who.background) wait = Math.max(wait, waiting("group"));
|
|
1658
|
+
if (writer !== "chat" && !departed && !starved) wait = Math.max(wait, waiting("chat"));
|
|
1659
|
+
if ((writer === null && who.group && !pastLimit || who.background) && !starved) wait = Math.max(wait, waiting("group"));
|
|
1660
|
+
if (turns && wait <= 0 && isTurnRead(who) && this.turnAhead(relay, key, who, now)) wait = TURN_WAIT_MS;
|
|
1671
1661
|
return Math.max(wait, 0);
|
|
1672
1662
|
}
|
|
1673
1663
|
/** A group's read of `key` asks: it waits for its turn from now on, unless it is taken (`readsAhead`). */
|
|
@@ -1697,6 +1687,39 @@ var RelayTransport = class {
|
|
|
1697
1687
|
}
|
|
1698
1688
|
return ahead;
|
|
1699
1689
|
}
|
|
1690
|
+
/** A read of `key` that takes turns asks: it waits for its turn from now on, unless it is taken (`turnAhead`). */
|
|
1691
|
+
turnAsked(key, who, now) {
|
|
1692
|
+
for (const [other, turn] of this.keyTurns) if (now - Math.max(turn.askedAt, turn.servedAt) >= 6e4) this.keyTurns.delete(other);
|
|
1693
|
+
this.keyTurns.set(key, {
|
|
1694
|
+
servedAt: this.keyTurns.get(key)?.servedAt ?? -Infinity,
|
|
1695
|
+
askedAt: now,
|
|
1696
|
+
waiting: true,
|
|
1697
|
+
who
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Whether a key read in turn longer ago than `key`, still asking, would take this relay's next request: one read in the
|
|
1702
|
+
* background for a background read, and a chat's that is starved (`READ_STARVED_MS`) for any. One asks again after
|
|
1703
|
+
* the budget held it back (a link's poll does, a one-off look does not), and keeps asking within `READ_TURN_MS` of its
|
|
1704
|
+
* last ask, or of when it was told the budget frees a request for it (`readHeldBackFor`).
|
|
1705
|
+
* One the budget holds back on its own account (a group's read, for the chats' reserve) keeps no turn: a group key
|
|
1706
|
+
* waiting for the chats held every chat's read back (CLI lab, 2026-10-13).
|
|
1707
|
+
*/
|
|
1708
|
+
turnAhead(relay, key, who, now) {
|
|
1709
|
+
const mine = key === void 0 ? -Infinity : this.keyTurns.get(key)?.servedAt ?? -Infinity;
|
|
1710
|
+
for (const [other, turn] of this.keyTurns) {
|
|
1711
|
+
const held = this.heldBack.get(other);
|
|
1712
|
+
if (other === key || !turn.waiting || turn.servedAt >= mine || !held || held.times < 2) continue;
|
|
1713
|
+
if (!(who.background && turn.who.background) && !this.starved(other, turn.who, now)) continue;
|
|
1714
|
+
if (now - Math.max(turn.askedAt, held.until) < READ_TURN_MS && this.heldFor(relay, turn.who, now, other, false) === 0) return true;
|
|
1715
|
+
}
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
/** A 1:1 chat's read of `key` the budget has held back for `READ_STARVED_MS` without a break. */
|
|
1719
|
+
starved(key, who, now) {
|
|
1720
|
+
const since = this.heldBack.get(key)?.since;
|
|
1721
|
+
return !who.group && !who.write && !who.door && since !== void 0 && now - since >= READ_STARVED_MS;
|
|
1722
|
+
}
|
|
1700
1723
|
/** This contact's departed reads past the limit in the last minute (`DEPARTED_READS`). */
|
|
1701
1724
|
departedReads(key, now) {
|
|
1702
1725
|
const reads = (this.departedOver.get(key) ?? []).filter((at) => now - at < 6e4);
|
|
@@ -1735,6 +1758,12 @@ var RelayTransport = class {
|
|
|
1735
1758
|
if (share === void 0) return this.perMinute;
|
|
1736
1759
|
return this.perMinuteGiven ? Math.min(this.perMinute, share) : share;
|
|
1737
1760
|
}
|
|
1761
|
+
/** What the chats that need the relays keep on this relay (`CHAT_RESERVE_PER_CHAT`), never less than `reserveOf`. */
|
|
1762
|
+
chatReserveOf(relay, now) {
|
|
1763
|
+
let needing = 0;
|
|
1764
|
+
for (const need of this.chatNeeds.values()) if (now - Math.max(need.urgentAt, need.refused.get(relay) ?? -Infinity) < 6e4) needing++;
|
|
1765
|
+
return Math.max(this.reserveOf(relay), Math.min(needing * 3, Math.floor(this.limitOf(relay) * CHAT_RESERVE_MOST_SHARE)));
|
|
1766
|
+
}
|
|
1738
1767
|
/** What groups leave a chat on this relay: `CHAT_RESERVE`, or a third of a smaller budget. */
|
|
1739
1768
|
reserveOf(relay) {
|
|
1740
1769
|
return Math.min(10, Math.floor(this.limitOf(relay) / 3));
|
|
@@ -3467,9 +3496,9 @@ var HANDOFF_CANCEL_REASONS = [
|
|
|
3467
3496
|
"stalled"
|
|
3468
3497
|
];
|
|
3469
3498
|
var KEY$3 = /^[A-Za-z0-9_-]{43}$/;
|
|
3470
|
-
var DIGEST$
|
|
3499
|
+
var DIGEST$1 = KEY$3;
|
|
3471
3500
|
var SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
|
3472
|
-
var ID$
|
|
3501
|
+
var ID$4 = /^[A-Za-z0-9_-]{22}$/;
|
|
3473
3502
|
var B64$4 = /^[A-Za-z0-9_-]*$/;
|
|
3474
3503
|
var HEX = /^[0-9a-f]{64}$/;
|
|
3475
3504
|
var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
|
|
@@ -3483,9 +3512,9 @@ var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
|
|
|
3483
3512
|
/** The part a file is: `file/<its SHA-256, base64url>`. */
|
|
3484
3513
|
var filePartName = (sha256B64) => `file/${sha256B64}`;
|
|
3485
3514
|
/** The digest a file part names, or null for any other part. */
|
|
3486
|
-
var filePartDigest = (name) => name.startsWith("file/") && DIGEST$
|
|
3515
|
+
var filePartDigest = (name) => name.startsWith("file/") && DIGEST$1.test(name.slice(5)) ? name.slice(5) : null;
|
|
3487
3516
|
function isPart(value) {
|
|
3488
|
-
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$
|
|
3517
|
+
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$1) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
|
|
3489
3518
|
}
|
|
3490
3519
|
/** Names compared as UTF-8 byte strings, as `H` sorts them. */
|
|
3491
3520
|
function compareNames(a, b) {
|
|
@@ -3607,7 +3636,7 @@ function readHandoffHello(frame) {
|
|
|
3607
3636
|
if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
|
|
3608
3637
|
if (typeof frame.metered !== "boolean") return null;
|
|
3609
3638
|
if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
|
|
3610
|
-
if (frame.id !== void 0 && !isText$2(frame.id, ID$
|
|
3639
|
+
if (frame.id !== void 0 && !isText$2(frame.id, ID$4)) return null;
|
|
3611
3640
|
const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
|
|
3612
3641
|
return {
|
|
3613
3642
|
v: 1,
|
|
@@ -3633,7 +3662,7 @@ var handoffOfferFrame = (turn, id, bytes) => ({
|
|
|
3633
3662
|
});
|
|
3634
3663
|
function readHandoffTurnFrame(frame) {
|
|
3635
3664
|
if (frame.t !== "handoff-request" && frame.t !== "handoff-offer") return null;
|
|
3636
|
-
if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$
|
|
3665
|
+
if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$4)) return null;
|
|
3637
3666
|
if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
|
|
3638
3667
|
turn: frame.turn,
|
|
3639
3668
|
id: frame.id,
|
|
@@ -3676,7 +3705,7 @@ function readHandoffPake(frame) {
|
|
|
3676
3705
|
};
|
|
3677
3706
|
}
|
|
3678
3707
|
function readHandoffHave(frame) {
|
|
3679
|
-
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$
|
|
3708
|
+
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$1))) return null;
|
|
3680
3709
|
const p = {};
|
|
3681
3710
|
if (frame.p !== void 0) {
|
|
3682
3711
|
if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
|
|
@@ -3732,7 +3761,7 @@ function readHandoffAck(frame) {
|
|
|
3732
3761
|
}
|
|
3733
3762
|
/** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
|
|
3734
3763
|
function readHandoffVerified(frame, turnAddress, turn, takerKey) {
|
|
3735
|
-
if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$
|
|
3764
|
+
if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$1) || !isText$2(frame.s, SIGNATURE)) return null;
|
|
3736
3765
|
const h = fromBase64Url(frame.h);
|
|
3737
3766
|
try {
|
|
3738
3767
|
return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
|
|
@@ -3800,7 +3829,7 @@ var handoffAttemptSucceeded = () => ({
|
|
|
3800
3829
|
/** Seconds until a locked-out device may try again (for `handoff-busy`). */
|
|
3801
3830
|
var handoffRetryAfter = (attempts, now) => attempts?.until !== void 0 && attempts.until > now ? Math.ceil((attempts.until - now) / 1e3) : 0;
|
|
3802
3831
|
var HTTP_SERVICE_PROTO = "ghostly-http/1";
|
|
3803
|
-
var MAX_NAME_LENGTH
|
|
3832
|
+
var MAX_NAME_LENGTH = 48;
|
|
3804
3833
|
var MAX_META_ENTRIES = 8;
|
|
3805
3834
|
var MAX_META_LENGTH = 64;
|
|
3806
3835
|
var SERVICE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
@@ -3881,7 +3910,7 @@ function servicesFromWire(wire) {
|
|
|
3881
3910
|
id: e.i,
|
|
3882
3911
|
type: e.t
|
|
3883
3912
|
};
|
|
3884
|
-
const name = cleanString(e.n, MAX_NAME_LENGTH
|
|
3913
|
+
const name = cleanString(e.n, MAX_NAME_LENGTH);
|
|
3885
3914
|
const proto = cleanString(e.p, MAX_META_LENGTH);
|
|
3886
3915
|
if (name) ad.name = name;
|
|
3887
3916
|
if (proto) ad.proto = proto;
|
|
@@ -4727,304 +4756,6 @@ function buildDataSdp(signal) {
|
|
|
4727
4756
|
return lines.join("\r\n") + "\r\n";
|
|
4728
4757
|
}
|
|
4729
4758
|
//#endregion
|
|
4730
|
-
//#region ../core/src/frames.ts
|
|
4731
|
-
/**
|
|
4732
|
-
* Framing on the `ghostly/1` DataChannel (ordered, reliable).
|
|
4733
|
-
*
|
|
4734
|
-
* - Text messages are control frames: compact JSON with a `t` discriminator,
|
|
4735
|
-
* the same shape Ghostly already uses for `_msgs` and `_call`.
|
|
4736
|
-
* - Binary messages are body chunks that belong to a stream opened by a
|
|
4737
|
-
* control frame: `<kind u8><stream id u32 BE><flags u8><payload>`.
|
|
4738
|
-
*
|
|
4739
|
-
* Everything here handles data from the remote peer and must never trust it.
|
|
4740
|
-
*/
|
|
4741
|
-
var LIMITS = {
|
|
4742
|
-
/** Largest control frame accepted, headers included. */
|
|
4743
|
-
maxControlFrameBytes: 61440,
|
|
4744
|
-
/** Longest payment endpoint value (a Cashu token, an invoice) a payment frame may carry; a longer one is dropped. */
|
|
4745
|
-
maxPaymentEndpointChars: 32768,
|
|
4746
|
-
/** Largest DataChannel message sent. 16 KiB is safe across WebRTC stacks. */
|
|
4747
|
-
maxChunkMessageBytes: 16384,
|
|
4748
|
-
maxRequestBodyBytes: 8388608,
|
|
4749
|
-
maxResponseBodyBytes: 67108864,
|
|
4750
|
-
maxHeaderCount: 128,
|
|
4751
|
-
maxHeaderValueBytes: 16384,
|
|
4752
|
-
maxPathBytes: 8192,
|
|
4753
|
-
/** Requests a host serves at once for one peer. */
|
|
4754
|
-
maxConcurrentRequestsPerPeer: 32,
|
|
4755
|
-
/** Requests a client keeps in flight; the rest wait in a queue. */
|
|
4756
|
-
maxClientInFlight: 16,
|
|
4757
|
-
maxChatMessageBytes: 16384,
|
|
4758
|
-
maxFileBytes: 104857600,
|
|
4759
|
-
/** Files a peer may be sending us at once. */
|
|
4760
|
-
maxIncomingFilesPerPeer: 3,
|
|
4761
|
-
/** Received bytes kept for one peer, in flight included. Past it new files are refused. */
|
|
4762
|
-
maxStoredIncomingBytesPerPeer: 524288e3,
|
|
4763
|
-
requestTimeoutMs: 6e4,
|
|
4764
|
-
bodyIdleTimeoutMs: 3e4,
|
|
4765
|
-
/** Stop writing to the channel above this many buffered bytes. */
|
|
4766
|
-
sendHighWaterMark: 1048576,
|
|
4767
|
-
sendLowWaterMark: 262144
|
|
4768
|
-
};
|
|
4769
|
-
var CHUNK_KIND = {
|
|
4770
|
-
requestBody: 1,
|
|
4771
|
-
responseBody: 2,
|
|
4772
|
-
fileBody: 3
|
|
4773
|
-
};
|
|
4774
|
-
var CHUNK_HEADER_BYTES = 6;
|
|
4775
|
-
var MAX_CHUNK_PAYLOAD_BYTES = LIMITS.maxChunkMessageBytes - CHUNK_HEADER_BYTES;
|
|
4776
|
-
function encodeControl(frame) {
|
|
4777
|
-
const json = JSON.stringify(frame);
|
|
4778
|
-
if (utf8Encode(json).length > LIMITS.maxControlFrameBytes) throw new Error("Control frame too large");
|
|
4779
|
-
return json;
|
|
4780
|
-
}
|
|
4781
|
-
function isHeaderList(value) {
|
|
4782
|
-
if (!Array.isArray(value) || value.length > LIMITS.maxHeaderCount) return false;
|
|
4783
|
-
return value.every((h) => Array.isArray(h) && h.length === 2 && typeof h[0] === "string" && typeof h[1] === "string" && h[0].length <= 256 && h[1].length <= LIMITS.maxHeaderValueBytes);
|
|
4784
|
-
}
|
|
4785
|
-
function isStreamId(value) {
|
|
4786
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4294967295;
|
|
4787
|
-
}
|
|
4788
|
-
function isPayId(value) {
|
|
4789
|
-
return typeof value === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(value);
|
|
4790
|
-
}
|
|
4791
|
-
/** Decimal text, as in Paykit's Payment Amount: no floats on the wire. */
|
|
4792
|
-
function isAmount(value) {
|
|
4793
|
-
return typeof value === "string" && /^(0|[1-9]\d{0,15})(\.\d{1,8})?$/.test(value);
|
|
4794
|
-
}
|
|
4795
|
-
function isUnit(value) {
|
|
4796
|
-
return typeof value === "string" && /^[a-z0-9]{1,12}$/.test(value);
|
|
4797
|
-
}
|
|
4798
|
-
function isEndpoint(value) {
|
|
4799
|
-
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && value[0].length <= 64 && /^[a-z0-9][a-z0-9-]*(?:\/[1-9][0-9]*)?$/.test(value[0]) && typeof value[1] === "string" && value[1].length <= LIMITS.maxPaymentEndpointChars;
|
|
4800
|
-
}
|
|
4801
|
-
/** Returns null for anything malformed; callers drop the frame. */
|
|
4802
|
-
function decodeControl(text) {
|
|
4803
|
-
if (text.length > LIMITS.maxControlFrameBytes || utf8Encode(text).length > LIMITS.maxControlFrameBytes) return null;
|
|
4804
|
-
let raw;
|
|
4805
|
-
try {
|
|
4806
|
-
raw = JSON.parse(text);
|
|
4807
|
-
} catch {
|
|
4808
|
-
return null;
|
|
4809
|
-
}
|
|
4810
|
-
if (!raw || typeof raw !== "object") return null;
|
|
4811
|
-
const f = raw;
|
|
4812
|
-
switch (f.t) {
|
|
4813
|
-
case "hello":
|
|
4814
|
-
if (typeof f.v !== "number") return null;
|
|
4815
|
-
return {
|
|
4816
|
-
t: "hello",
|
|
4817
|
-
v: f.v,
|
|
4818
|
-
svc: f.svc,
|
|
4819
|
-
nick: sanitizeNick(f.nick)
|
|
4820
|
-
};
|
|
4821
|
-
case "m":
|
|
4822
|
-
if (typeof f.ts !== "number" || typeof f.m !== "string") return null;
|
|
4823
|
-
if (utf8Encode(f.m).length > LIMITS.maxChatMessageBytes) return null;
|
|
4824
|
-
return {
|
|
4825
|
-
t: "m",
|
|
4826
|
-
ts: f.ts,
|
|
4827
|
-
m: f.m
|
|
4828
|
-
};
|
|
4829
|
-
case "call":
|
|
4830
|
-
if (typeof f.s !== "string" || utf8Encode(f.s).length > LIMITS.maxChatMessageBytes) return null;
|
|
4831
|
-
return {
|
|
4832
|
-
t: "call",
|
|
4833
|
-
s: f.s
|
|
4834
|
-
};
|
|
4835
|
-
case "svc": return {
|
|
4836
|
-
t: "svc",
|
|
4837
|
-
svc: f.svc
|
|
4838
|
-
};
|
|
4839
|
-
case "req":
|
|
4840
|
-
if (!isStreamId(f.id) || typeof f.s !== "string" || typeof f.m !== "string") return null;
|
|
4841
|
-
if (typeof f.p !== "string" || f.p.length > LIMITS.maxPathBytes || !isHeaderList(f.h)) return null;
|
|
4842
|
-
return {
|
|
4843
|
-
t: "req",
|
|
4844
|
-
id: f.id,
|
|
4845
|
-
s: f.s,
|
|
4846
|
-
m: f.m,
|
|
4847
|
-
p: f.p,
|
|
4848
|
-
h: f.h,
|
|
4849
|
-
b: f.b === true
|
|
4850
|
-
};
|
|
4851
|
-
case "res":
|
|
4852
|
-
if (!isStreamId(f.id) || typeof f.st !== "number" || !Number.isInteger(f.st)) return null;
|
|
4853
|
-
if (f.st < 100 || f.st > 599 || !isHeaderList(f.h)) return null;
|
|
4854
|
-
return {
|
|
4855
|
-
t: "res",
|
|
4856
|
-
id: f.id,
|
|
4857
|
-
st: f.st,
|
|
4858
|
-
h: f.h,
|
|
4859
|
-
b: f.b === true
|
|
4860
|
-
};
|
|
4861
|
-
case "file":
|
|
4862
|
-
if (!isStreamId(f.id) || typeof f.f !== "string" || typeof f.ts !== "number") return null;
|
|
4863
|
-
if (typeof f.n !== "string" || typeof f.m !== "string") return null;
|
|
4864
|
-
if (typeof f.s !== "number" || !Number.isSafeInteger(f.s) || f.s < 0) return null;
|
|
4865
|
-
return {
|
|
4866
|
-
t: "file",
|
|
4867
|
-
id: f.id,
|
|
4868
|
-
f: f.f,
|
|
4869
|
-
ts: f.ts,
|
|
4870
|
-
n: f.n,
|
|
4871
|
-
s: f.s,
|
|
4872
|
-
m: f.m
|
|
4873
|
-
};
|
|
4874
|
-
case "pay-req":
|
|
4875
|
-
case "pay": {
|
|
4876
|
-
if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
|
|
4877
|
-
const memo = typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0;
|
|
4878
|
-
if (f.t === "pay-req") {
|
|
4879
|
-
if (!Array.isArray(f.e) || f.e.length === 0 || f.e.length > 8 || !f.e.every(isEndpoint)) return null;
|
|
4880
|
-
return {
|
|
4881
|
-
t: "pay-req",
|
|
4882
|
-
id: f.id,
|
|
4883
|
-
ts: f.ts,
|
|
4884
|
-
v: f.v,
|
|
4885
|
-
u: f.u,
|
|
4886
|
-
memo,
|
|
4887
|
-
e: f.e,
|
|
4888
|
-
a: isPayId(f.a) ? f.a : void 0,
|
|
4889
|
-
n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
|
|
4890
|
-
};
|
|
4891
|
-
}
|
|
4892
|
-
if (!isEndpoint(f.e) || f.rid !== void 0 && !isPayId(f.rid)) return null;
|
|
4893
|
-
return {
|
|
4894
|
-
t: "pay",
|
|
4895
|
-
id: f.id,
|
|
4896
|
-
ts: f.ts,
|
|
4897
|
-
rid: f.rid,
|
|
4898
|
-
v: f.v,
|
|
4899
|
-
u: f.u,
|
|
4900
|
-
memo,
|
|
4901
|
-
e: f.e
|
|
4902
|
-
};
|
|
4903
|
-
}
|
|
4904
|
-
case "pay-ask":
|
|
4905
|
-
if (!isPayId(f.id) || typeof f.ts !== "number" || !isAmount(f.v) || !isUnit(f.u)) return null;
|
|
4906
|
-
if (f.m !== "arkade" && f.m !== "usdt" && f.m !== "bark" && f.m !== "bitcoin" && f.m !== "fedimint" && f.m !== "spark") return null;
|
|
4907
|
-
return {
|
|
4908
|
-
t: "pay-ask",
|
|
4909
|
-
id: f.id,
|
|
4910
|
-
ts: f.ts,
|
|
4911
|
-
v: f.v,
|
|
4912
|
-
u: f.u,
|
|
4913
|
-
m: f.m,
|
|
4914
|
-
memo: typeof f.memo === "string" ? f.memo.slice(0, 140) : void 0,
|
|
4915
|
-
n: f.n === "mainnet" || f.n === "testnet" ? f.n : void 0
|
|
4916
|
-
};
|
|
4917
|
-
case "pay-res":
|
|
4918
|
-
if (!isPayId(f.id) || typeof f.ok !== "boolean") return null;
|
|
4919
|
-
if (f.v !== void 0 && !isAmount(f.v)) return null;
|
|
4920
|
-
return {
|
|
4921
|
-
t: "pay-res",
|
|
4922
|
-
id: f.id,
|
|
4923
|
-
ok: f.ok,
|
|
4924
|
-
v: f.v,
|
|
4925
|
-
err: typeof f.err === "string" ? f.err.slice(0, 200) : void 0,
|
|
4926
|
-
...f.c === true && !f.ok ? { c: true } : {}
|
|
4927
|
-
};
|
|
4928
|
-
case "rst":
|
|
4929
|
-
if (!isStreamId(f.id) || f.d !== "q" && f.d !== "s" && f.d !== "f") return null;
|
|
4930
|
-
return {
|
|
4931
|
-
t: "rst",
|
|
4932
|
-
id: f.id,
|
|
4933
|
-
d: f.d,
|
|
4934
|
-
e: typeof f.e === "string" ? f.e.slice(0, 256) : ""
|
|
4935
|
-
};
|
|
4936
|
-
case "ping":
|
|
4937
|
-
case "pong":
|
|
4938
|
-
if (typeof f.ts !== "number") return null;
|
|
4939
|
-
return {
|
|
4940
|
-
t: f.t,
|
|
4941
|
-
ts: f.ts
|
|
4942
|
-
};
|
|
4943
|
-
default: return null;
|
|
4944
|
-
}
|
|
4945
|
-
}
|
|
4946
|
-
function encodeChunk(chunk) {
|
|
4947
|
-
if (chunk.payload.length > MAX_CHUNK_PAYLOAD_BYTES) throw new Error("Chunk payload too large");
|
|
4948
|
-
const out = new Uint8Array(CHUNK_HEADER_BYTES + chunk.payload.length);
|
|
4949
|
-
const view = new DataView(out.buffer);
|
|
4950
|
-
view.setUint8(0, chunk.kind);
|
|
4951
|
-
view.setUint32(1, chunk.id);
|
|
4952
|
-
view.setUint8(5, chunk.end ? 1 : 0);
|
|
4953
|
-
out.set(chunk.payload, CHUNK_HEADER_BYTES);
|
|
4954
|
-
return out;
|
|
4955
|
-
}
|
|
4956
|
-
function decodeChunk(data) {
|
|
4957
|
-
if (data.length < CHUNK_HEADER_BYTES || data.length > LIMITS.maxChunkMessageBytes) return null;
|
|
4958
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
4959
|
-
const kind = view.getUint8(0);
|
|
4960
|
-
if (kind !== CHUNK_KIND.requestBody && kind !== CHUNK_KIND.responseBody && kind !== CHUNK_KIND.fileBody) return null;
|
|
4961
|
-
return {
|
|
4962
|
-
kind,
|
|
4963
|
-
id: view.getUint32(1),
|
|
4964
|
-
end: (view.getUint8(5) & 1) !== 0,
|
|
4965
|
-
payload: data.subarray(CHUNK_HEADER_BYTES)
|
|
4966
|
-
};
|
|
4967
|
-
}
|
|
4968
|
-
function wrapDataChannel(dc) {
|
|
4969
|
-
dc.binaryType = "arraybuffer";
|
|
4970
|
-
dc.bufferedAmountLowThreshold = LIMITS.sendLowWaterMark;
|
|
4971
|
-
const channel = {
|
|
4972
|
-
send(data) {
|
|
4973
|
-
if (dc.readyState !== "open") throw new Error("Data link is not open");
|
|
4974
|
-
if (typeof data === "string") dc.send(data);
|
|
4975
|
-
else dc.send(data);
|
|
4976
|
-
},
|
|
4977
|
-
get bufferedAmount() {
|
|
4978
|
-
return dc.bufferedAmount;
|
|
4979
|
-
},
|
|
4980
|
-
drained() {
|
|
4981
|
-
if (dc.bufferedAmount <= LIMITS.sendLowWaterMark || dc.readyState !== "open") return Promise.resolve();
|
|
4982
|
-
return new Promise((resolve) => {
|
|
4983
|
-
const done = () => {
|
|
4984
|
-
dc.removeEventListener("bufferedamountlow", done);
|
|
4985
|
-
dc.removeEventListener("close", done);
|
|
4986
|
-
resolve();
|
|
4987
|
-
};
|
|
4988
|
-
dc.addEventListener("bufferedamountlow", done);
|
|
4989
|
-
dc.addEventListener("close", done);
|
|
4990
|
-
});
|
|
4991
|
-
},
|
|
4992
|
-
close() {
|
|
4993
|
-
dc.close();
|
|
4994
|
-
},
|
|
4995
|
-
onMessage: null,
|
|
4996
|
-
onClose: null
|
|
4997
|
-
};
|
|
4998
|
-
dc.addEventListener("message", (event) => {
|
|
4999
|
-
const data = event.data;
|
|
5000
|
-
if (typeof data === "string") channel.onMessage?.(data);
|
|
5001
|
-
else if (data instanceof ArrayBuffer) channel.onMessage?.(new Uint8Array(data));
|
|
5002
|
-
});
|
|
5003
|
-
dc.addEventListener("close", () => channel.onClose?.());
|
|
5004
|
-
return channel;
|
|
5005
|
-
}
|
|
5006
|
-
/** Writes a body as chunks, honouring backpressure. Always ends with an END chunk. */
|
|
5007
|
-
async function sendBody(channel, kind, id, body, isCancelled = () => false) {
|
|
5008
|
-
for await (const part of body) for (let offset = 0; offset < part.length; offset += MAX_CHUNK_PAYLOAD_BYTES) {
|
|
5009
|
-
if (isCancelled()) return;
|
|
5010
|
-
if (channel.bufferedAmount > LIMITS.sendHighWaterMark) await channel.drained();
|
|
5011
|
-
const payload = part.subarray(offset, offset + MAX_CHUNK_PAYLOAD_BYTES);
|
|
5012
|
-
channel.send(encodeChunk({
|
|
5013
|
-
kind,
|
|
5014
|
-
id,
|
|
5015
|
-
end: false,
|
|
5016
|
-
payload
|
|
5017
|
-
}));
|
|
5018
|
-
}
|
|
5019
|
-
if (isCancelled()) return;
|
|
5020
|
-
channel.send(encodeChunk({
|
|
5021
|
-
kind,
|
|
5022
|
-
id,
|
|
5023
|
-
end: true,
|
|
5024
|
-
payload: /* @__PURE__ */ new Uint8Array(0)
|
|
5025
|
-
}));
|
|
5026
|
-
}
|
|
5027
|
-
//#endregion
|
|
5028
4759
|
//#region ../core/src/http.ts
|
|
5029
4760
|
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
5030
4761
|
"localhost",
|
|
@@ -5601,6 +5332,19 @@ var STALLED_LOOK_MS = 15e3;
|
|
|
5601
5332
|
* #1441, on a loaded host), and past it the race's DHT dial has brought the chat back anyway.
|
|
5602
5333
|
*/
|
|
5603
5334
|
var DEPARTED_READ_MS = EXPECT_PEER_MS;
|
|
5335
|
+
/** A chat whose contact was never seen (an invite just sent) keeps looking at the active pace this long. */
|
|
5336
|
+
var AWAITING_PEER_MS = 6e5;
|
|
5337
|
+
/**
|
|
5338
|
+
* An invite this side made that nobody used, once no longer awaited (`AWAITING_PEER_MS`), is read less often the older
|
|
5339
|
+
* it is: a fifth of its age between reads, up to this. Read at the background pace instead, 15 unused invites (their
|
|
5340
|
+
* keys and their mailboxes) asked the relays for 60 reads a minute for as long as the app ran: the relays' minute went
|
|
5341
|
+
* to invites nobody may ever open, and a join on one was noticed 90 s later (bug hunt 1013-1642, 2026-10-13).
|
|
5342
|
+
*/
|
|
5343
|
+
var UNUSED_INVITE_WAIT_MAX_MS = 18e4;
|
|
5344
|
+
/** The wait between reads of an unused invite of age `age` (`UNUSED_INVITE_WAIT_MAX_MS`), never shorter than the pace's `interval`. */
|
|
5345
|
+
function unusedInviteWait(age, interval) {
|
|
5346
|
+
return Math.max(interval, Math.min(UNUSED_INVITE_WAIT_MAX_MS, Math.round(age / 5)));
|
|
5347
|
+
}
|
|
5604
5348
|
var PUBLISH_RETRY_MS = 4e3;
|
|
5605
5349
|
/**
|
|
5606
5350
|
* A packet the relays' request budget held back goes again when the budget frees a request, and at least this often
|
|
@@ -5656,6 +5400,8 @@ var LinkSession = class {
|
|
|
5656
5400
|
startedAt = Date.now();
|
|
5657
5401
|
/** From when a peer never seen is awaited (`AWAITING_PEER_MS`): never later than this start, whatever the clock said when the chat was made. */
|
|
5658
5402
|
awaitingSince;
|
|
5403
|
+
/** This side's invite, nobody used yet (`LinkSessionOptions.invite`). */
|
|
5404
|
+
invite;
|
|
5659
5405
|
intervals;
|
|
5660
5406
|
fastPollUntil = 0;
|
|
5661
5407
|
/** An offer to a saved contact is out: from then on its fast window looks less often (`OFFER_FAST_MS`); 0 for none. */
|
|
@@ -5711,6 +5457,7 @@ var LinkSession = class {
|
|
|
5711
5457
|
this.events = options.events ?? {};
|
|
5712
5458
|
this.firstPublish = options.firstPublish ?? "at-start";
|
|
5713
5459
|
this.awaitingSince = Math.min(options.awaitingSince ?? this.startedAt, this.startedAt);
|
|
5460
|
+
this.invite = options.invite ?? (() => false);
|
|
5714
5461
|
}
|
|
5715
5462
|
get peerPresence() {
|
|
5716
5463
|
return this.presence;
|
|
@@ -5884,8 +5631,8 @@ var LinkSession = class {
|
|
|
5884
5631
|
async setRtcSignal(signal, reportFailure = false) {
|
|
5885
5632
|
if (this.rtcSignal === signal) return;
|
|
5886
5633
|
this.rtcSignal = signal;
|
|
5887
|
-
if (reportFailure) await this.publish();
|
|
5888
|
-
else await this.publish().catch(() => {});
|
|
5634
|
+
if (reportFailure) await this.publish(signal === null);
|
|
5635
|
+
else await this.publish(signal === null).catch(() => {});
|
|
5889
5636
|
if (signal) this.pollNow();
|
|
5890
5637
|
}
|
|
5891
5638
|
/**
|
|
@@ -5958,6 +5705,7 @@ var LinkSession = class {
|
|
|
5958
5705
|
const stepping = this.fastStepsAfter ? Date.now() - this.fastStepsAfter : -1;
|
|
5959
5706
|
if (pace === "fast" && stepping >= 0) return Math.min(4 * interval, Math.max(2 * interval, stepping));
|
|
5960
5707
|
if (pace === "fast" || pace === "connected" || since < 0) return interval;
|
|
5708
|
+
if (pace === "background" && this.presence.lastPacketAt === 0 && this.invite()) return unusedInviteWait(Date.now() - this.awaitingSince, interval);
|
|
5961
5709
|
return Math.min(interval, Math.max(2 * this.intervals.fast, since));
|
|
5962
5710
|
}
|
|
5963
5711
|
/**
|
|
@@ -5969,13 +5717,17 @@ var LinkSession = class {
|
|
|
5969
5717
|
watching() {
|
|
5970
5718
|
return Date.now() < this.watchUntil && this.rtcSignal === null && !(this.presence.online && this.presence.lastPacketAt > this.watchFrom);
|
|
5971
5719
|
}
|
|
5720
|
+
/** A contact never seen, whose invite was sent lately (`AWAITING_PEER_MS`): its join is due. */
|
|
5721
|
+
awaitingPeer() {
|
|
5722
|
+
return this.presence.lastPacketAt === 0 && Date.now() - this.awaitingSince < 6e5;
|
|
5723
|
+
}
|
|
5972
5724
|
/** How urgently this link looks right now. */
|
|
5973
5725
|
pace() {
|
|
5974
5726
|
if (Date.now() < this.stalledUntil) return "fast";
|
|
5975
5727
|
if (this.connected) return "connected";
|
|
5976
5728
|
if (Date.now() < this.fastPollUntil) return "fast";
|
|
5977
5729
|
if (Date.now() < this.watchUntil) return "active";
|
|
5978
|
-
if (!this.active && this.
|
|
5730
|
+
if (!this.active && this.awaitingPeer()) return "active";
|
|
5979
5731
|
if (!this.active) return "background";
|
|
5980
5732
|
return Date.now() - this.lastActivity > 6e4 ? "idle" : "active";
|
|
5981
5733
|
}
|
|
@@ -6032,7 +5784,9 @@ var LinkSession = class {
|
|
|
6032
5784
|
}, this.encKey);
|
|
6033
5785
|
const started = Date.now();
|
|
6034
5786
|
try {
|
|
6035
|
-
const
|
|
5787
|
+
const awaited = this.connected || this.presence.online || this.sentBuffer.length > 0 || this.callSignal !== null || this.awaitingPeer();
|
|
5788
|
+
const takesBack = !rtcSignal && this.rtcSignalOut !== null;
|
|
5789
|
+
const options = rtcSignal && rtcSignal !== this.rtcSignalOut ? { signal: true } : this.watching() ? { watch: true } : upkeep && (this.connected || takesBack) || !rtcSignal && !awaited ? { background: true } : void 0;
|
|
6036
5790
|
await this.transport.publish(this.identity, built.records, options);
|
|
6037
5791
|
} catch (error) {
|
|
6038
5792
|
const ms = Date.now() - started, waiting = isDiscoveryBudgetError(error);
|
|
@@ -6096,13 +5850,15 @@ var LinkSession = class {
|
|
|
6096
5850
|
nextInMs: 0
|
|
6097
5851
|
});
|
|
6098
5852
|
const started = Date.now();
|
|
5853
|
+
let heldBackMs;
|
|
6099
5854
|
try {
|
|
6100
5855
|
const pace = this.pace();
|
|
6101
5856
|
const signal = pace === "fast" && this.fastStepsAfter > 0 && this.rtcSignalOut !== null;
|
|
6102
5857
|
const watch = !signal && this.watching();
|
|
6103
5858
|
const departed = !signal && Date.now() < this.departedUntil;
|
|
5859
|
+
const background = pace === "background" || pace === "connected";
|
|
6104
5860
|
const packet = await this.transport.resolve(this.peerPubKeyZ32, {
|
|
6105
|
-
background
|
|
5861
|
+
background,
|
|
6106
5862
|
urgent: pace === "fast",
|
|
6107
5863
|
...signal && { signal },
|
|
6108
5864
|
...watch && { watch },
|
|
@@ -6117,6 +5873,7 @@ var LinkSession = class {
|
|
|
6117
5873
|
const read = answered !== void 0 && answered >= started;
|
|
6118
5874
|
if (read) this.lastReadAt = started;
|
|
6119
5875
|
this.readMissed = !!this.transport.readAnsweredAt && !read;
|
|
5876
|
+
if (this.readMissed) heldBackMs = this.transport.readHeldBackFor?.(this.peerPubKeyZ32);
|
|
6120
5877
|
let receivedNew = false;
|
|
6121
5878
|
const batch = packet ? parseLinkRecords(packet, this.encKey) : null;
|
|
6122
5879
|
if (batch && isEmptyLinkPacket(batch)) {
|
|
@@ -6205,7 +5962,8 @@ var LinkSession = class {
|
|
|
6205
5962
|
ms: Date.now() - started,
|
|
6206
5963
|
error: String(error)
|
|
6207
5964
|
});
|
|
6208
|
-
if (
|
|
5965
|
+
if (isDiscoveryBudgetError(error)) heldBackMs = error.retryInMs;
|
|
5966
|
+
else {
|
|
6209
5967
|
this.discoveryResult("read", error);
|
|
6210
5968
|
this.events.onStatus?.("error");
|
|
6211
5969
|
}
|
|
@@ -6223,7 +5981,7 @@ var LinkSession = class {
|
|
|
6223
5981
|
this.poll();
|
|
6224
5982
|
} else if (this.running && !this.pollTimer) {
|
|
6225
5983
|
const interval = this.nextInterval();
|
|
6226
|
-
const wait = Math.max(MIN_POLL_GAP_MS, interval - (Date.now() - started));
|
|
5984
|
+
const wait = heldBackMs !== void 0 ? Math.min(interval, Math.max(BUDGET_RETRY_MIN_MS, heldBackMs)) : Math.max(MIN_POLL_GAP_MS, interval - (Date.now() - started));
|
|
6227
5985
|
this.events.onPoll?.({
|
|
6228
5986
|
polling: false,
|
|
6229
5987
|
nextInMs: wait
|
|
@@ -6333,7 +6091,9 @@ var ICE_GATHERING_TIMEOUT_MS = 5e3;
|
|
|
6333
6091
|
* settle); where they do not, every offer of a first pairing waited the full 5 s, twice when the inviter offered too
|
|
6334
6092
|
* (One's e2e runs: 13 offers of 89 at 5 s, 9 more at 1.3-3 s, 2026-10-05). An offer without one still connects to most
|
|
6335
6093
|
* contacts (their checks learn this side's address from its own); behind a restricted NAT on the contact's side it may
|
|
6336
|
-
* not, and the next offer of the chat then waits as long as it takes (`fullGather`).
|
|
6094
|
+
* not, and the next offer of the chat then waits as long as it takes (`fullGather`). An answer is bounded the same way:
|
|
6095
|
+
* it waited the full 5 s, and two CLI daemons whose STUN server swallowed every request went live 10 s after the offer
|
|
6096
|
+
* rather than 2 s (calls bug hunt, 2026-10-12).
|
|
6337
6097
|
*/
|
|
6338
6098
|
var OFFER_HOST_GATHER_MS = 2e3;
|
|
6339
6099
|
/**
|
|
@@ -6343,6 +6103,14 @@ var OFFER_HOST_GATHER_MS = 2e3;
|
|
|
6343
6103
|
var GATHER_STALL_MS = 3e3;
|
|
6344
6104
|
/** How long a connection may stay `disconnected` before it is given up. */
|
|
6345
6105
|
var DISCONNECT_GRACE_MS = 12e3;
|
|
6106
|
+
/**
|
|
6107
|
+
* The same race a moment later: setRemoteDescription resolves, and the connection closes right after it (8 ms on a
|
|
6108
|
+
* loaded core). A connection that ends within this long of its answer going in was refused too: no network fails one
|
|
6109
|
+
* that soon (ICE gives up after tens of seconds), so it says nothing about direct connections (`DirectEvidence`).
|
|
6110
|
+
* Taken for a network that lets none through, an edge between two CLIs left WebRTC for the run, and a member whose
|
|
6111
|
+
* app has WebRTC alone (one from before native group links) was never reached again (groupCompat, 2026-10-12).
|
|
6112
|
+
*/
|
|
6113
|
+
var ANSWER_REFUSED_AFTER_MS = 1e3;
|
|
6346
6114
|
/** An offer this close to the end of the offerer's attempt is not answered again: the new answer would come too late. */
|
|
6347
6115
|
var REANSWER_MARGIN_MS = 15e3;
|
|
6348
6116
|
/**
|
|
@@ -6374,12 +6142,13 @@ var DataLink = class {
|
|
|
6374
6142
|
pc = null;
|
|
6375
6143
|
myOfferTs = 0;
|
|
6376
6144
|
/**
|
|
6377
|
-
* An offer of this side's that went without a public candidate came to nothing (
|
|
6378
|
-
* connected): the next one waits for its STUN servers as long as it takes
|
|
6145
|
+
* An offer or answer of this side's that went without a public candidate came to nothing (both descriptions were
|
|
6146
|
+
* exchanged, and no path connected): the next one waits for its STUN servers as long as it takes
|
|
6147
|
+
* (`ICE_GATHERING_TIMEOUT_MS`).
|
|
6379
6148
|
*/
|
|
6380
6149
|
fullGather = false;
|
|
6381
|
-
/** The offer out has no public candidate (as it went: the connection's description gains any that come later). */
|
|
6382
|
-
|
|
6150
|
+
/** The offer or answer out has no public candidate (as it went: the connection's description gains any that come later). */
|
|
6151
|
+
sentLacksPublic = false;
|
|
6383
6152
|
lastSignalTs = 0;
|
|
6384
6153
|
/** The peer's description as this connection was given it: Chrome shows it as `remoteDescription` only once applied. */
|
|
6385
6154
|
remoteSdp = null;
|
|
@@ -6425,7 +6194,7 @@ var DataLink = class {
|
|
|
6425
6194
|
this.options.setFastPoll(true, true);
|
|
6426
6195
|
}
|
|
6427
6196
|
this.myOfferTs = Date.now();
|
|
6428
|
-
this.
|
|
6197
|
+
this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
|
|
6429
6198
|
const signal = {
|
|
6430
6199
|
t: "o",
|
|
6431
6200
|
ts: this.myOfferTs,
|
|
@@ -6531,9 +6300,12 @@ var DataLink = class {
|
|
|
6531
6300
|
this.reset();
|
|
6532
6301
|
if (evidence) this.options.onDirect?.(evidence);
|
|
6533
6302
|
}
|
|
6534
|
-
/**
|
|
6303
|
+
/**
|
|
6304
|
+
* This side's offer went with no public candidate and was answered, or its answer went with none, and it came to
|
|
6305
|
+
* nothing: the next offer or answer gathers for as long as it takes.
|
|
6306
|
+
*/
|
|
6535
6307
|
noteNoPublic(ended) {
|
|
6536
|
-
if (ended && this.myOfferTs && this.
|
|
6308
|
+
if (ended && (this.myOfferTs || this.answered) && this.sentLacksPublic && this.state === "connecting") this.fullGather = true;
|
|
6537
6309
|
}
|
|
6538
6310
|
/**
|
|
6539
6311
|
* What this attempt says about direct connections if it ends now without opening: nothing unless both descriptions
|
|
@@ -6548,17 +6320,22 @@ var DataLink = class {
|
|
|
6548
6320
|
/**
|
|
6549
6321
|
* The attempt on this connection failed (it timed out, ICE failed or closed, its channel closed). An answer the
|
|
6550
6322
|
* offerer may not have read yet is made again for the same offer while it still stands (`REANSWERS`); anything
|
|
6551
|
-
* else goes back to idle.
|
|
6323
|
+
* else goes back to idle. `byConnection`: the connection said so itself, rather than a timer of this link's.
|
|
6552
6324
|
*/
|
|
6553
|
-
failed() {
|
|
6325
|
+
failed(byConnection = false) {
|
|
6554
6326
|
if (this.state === "offering" && this.remoteSdp) {
|
|
6555
6327
|
this.answerRefused("failed");
|
|
6556
6328
|
return;
|
|
6557
6329
|
}
|
|
6330
|
+
if (byConnection && this.state === "connecting" && this.myOfferTs && Date.now() - this.connectingSince < ANSWER_REFUSED_AFTER_MS) {
|
|
6331
|
+
this.answerRefused("failed");
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6558
6334
|
const answered = this.state === "connecting" ? this.answered : null;
|
|
6559
6335
|
const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
|
|
6560
|
-
const
|
|
6561
|
-
this.
|
|
6336
|
+
const givenUp = !!answered && this.options.offerStanding?.(answered.offer.ts) === false;
|
|
6337
|
+
const evidence = givenUp ? null : this.directEvidence();
|
|
6338
|
+
this.noteNoPublic(!givenUp);
|
|
6562
6339
|
if (answered && answered.again < 2 && left > REANSWER_MARGIN_MS && this.options.offerStanding?.(answered.offer.ts)) {
|
|
6563
6340
|
answered.again++;
|
|
6564
6341
|
traceLink(this.options.myPubKeyZ32, "reanswer", {
|
|
@@ -6599,6 +6376,7 @@ var DataLink = class {
|
|
|
6599
6376
|
await pc.setLocalDescription(await pc.createAnswer());
|
|
6600
6377
|
});
|
|
6601
6378
|
if (!pc) return;
|
|
6379
|
+
this.sentLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
|
|
6602
6380
|
const signal = {
|
|
6603
6381
|
t: "a",
|
|
6604
6382
|
ts: Date.now(),
|
|
@@ -6628,7 +6406,7 @@ var DataLink = class {
|
|
|
6628
6406
|
await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
|
|
6629
6407
|
stallMs: GATHER_STALL_MS,
|
|
6630
6408
|
settleMs: 150,
|
|
6631
|
-
|
|
6409
|
+
...!this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
|
|
6632
6410
|
});
|
|
6633
6411
|
if (this.pc !== pc) return null;
|
|
6634
6412
|
const ms = Date.now() - gathering;
|
|
@@ -6675,7 +6453,7 @@ var DataLink = class {
|
|
|
6675
6453
|
tracePair(this.options.myPubKeyZ32, pc);
|
|
6676
6454
|
});
|
|
6677
6455
|
dc.addEventListener("close", () => {
|
|
6678
|
-
if (this.pc === pc) this.failed();
|
|
6456
|
+
if (this.pc === pc) this.failed(true);
|
|
6679
6457
|
});
|
|
6680
6458
|
pc.addEventListener("connectionstatechange", () => {
|
|
6681
6459
|
if (this.pc !== pc) return;
|
|
@@ -6684,7 +6462,7 @@ var DataLink = class {
|
|
|
6684
6462
|
if (pc.connectionState === "connected") this.options.onDisconnected?.(false);
|
|
6685
6463
|
}
|
|
6686
6464
|
this.disconnectTimer = null;
|
|
6687
|
-
if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed();
|
|
6465
|
+
if (pc.connectionState === "failed" || pc.connectionState === "closed") this.failed(true);
|
|
6688
6466
|
else if (pc.connectionState === "disconnected") {
|
|
6689
6467
|
if (this.state === "open") this.options.onDisconnected?.(true);
|
|
6690
6468
|
this.disconnectTimer = setTimeout(() => {
|
|
@@ -7040,1038 +6818,48 @@ function rememberAlone(alone, key, present) {
|
|
|
7040
6818
|
}
|
|
7041
6819
|
var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
|
|
7042
6820
|
//#endregion
|
|
7043
|
-
//#region ../core/src/
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
if (
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
|
|
7070
|
-
return JSON.stringify(value);
|
|
7071
|
-
}
|
|
7072
|
-
if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
|
|
7073
|
-
if (typeof value === "object") {
|
|
7074
|
-
const record = value;
|
|
7075
|
-
return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
|
|
7076
|
-
if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
|
|
7077
|
-
return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
|
|
7078
|
-
}).join(",")}}`;
|
|
7079
|
-
}
|
|
7080
|
-
throw new Error(`Not a JSON value: ${typeof value}`);
|
|
7081
|
-
}
|
|
7082
|
-
/** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
|
|
7083
|
-
function canonicalJson(value) {
|
|
7084
|
-
return write(value, 0);
|
|
7085
|
-
}
|
|
7086
|
-
/** The canonical UTF-8 bytes of a JSON value. */
|
|
7087
|
-
function canonicalJsonBytes(value) {
|
|
7088
|
-
return utf8Encode(canonicalJson(value));
|
|
6821
|
+
//#region ../core/src/replies.ts
|
|
6822
|
+
var REPLY_LIMITS = {
|
|
6823
|
+
/** Code points of the line of the original, an ellipsis included when it was cut. */
|
|
6824
|
+
snippet: 120,
|
|
6825
|
+
/** Characters of the original's id. */
|
|
6826
|
+
id: 128,
|
|
6827
|
+
/** Characters of a line as it arrives, before it is cleaned: anything longer is not a line of a message. */
|
|
6828
|
+
raw: 2048
|
|
6829
|
+
};
|
|
6830
|
+
/** A paired chat's message id, a payment id or a file's wire id; a group message's `sender:epoch:seq`. */
|
|
6831
|
+
var REPLY_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
6832
|
+
/** The two authors a paired chat has, from the reply author's side. */
|
|
6833
|
+
var PAIRED_REPLY_AUTHORS = ["sender", "recipient"];
|
|
6834
|
+
/** A picture sent inline has no line worth quoting. */
|
|
6835
|
+
var INLINE_PICTURE = /^data:image\//i;
|
|
6836
|
+
/**
|
|
6837
|
+
* A line of a message to quote: plain text on one line (line breaks and runs of spaces become one space), without
|
|
6838
|
+
* control, invisible or direction characters (WISP 400, as for names), at most `REPLY_LIMITS.snippet` code points,
|
|
6839
|
+
* the last of them an ellipsis when it was cut.
|
|
6840
|
+
*/
|
|
6841
|
+
function replySnippet(text) {
|
|
6842
|
+
if (INLINE_PICTURE.test(text.trimStart())) return "🖼️ Picture";
|
|
6843
|
+
const flat = text.slice(0, REPLY_LIMITS.raw * 2).replace(/\s+/gu, " ");
|
|
6844
|
+
const clean = sanitizeDisplayText(flat, REPLY_LIMITS.snippet + 1) ?? "";
|
|
6845
|
+
const points = [...clean];
|
|
6846
|
+
return points.length > REPLY_LIMITS.snippet ? `${points.slice(0, REPLY_LIMITS.snippet - 1).join("").trimEnd()}…` : clean;
|
|
7089
6847
|
}
|
|
7090
6848
|
/**
|
|
7091
|
-
*
|
|
7092
|
-
*
|
|
6849
|
+
* A reply as a receiver takes it: an object with an id of this chat's shape, a line (cleaned and cut again here,
|
|
6850
|
+
* whatever the sender did) and an author `author` accepts. Anything else is no reply: the message stays, without it.
|
|
7093
6851
|
*/
|
|
7094
|
-
function
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
ok: false,
|
|
7101
|
-
reason: "not-json",
|
|
7102
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
7103
|
-
};
|
|
7104
|
-
}
|
|
7105
|
-
let again;
|
|
7106
|
-
try {
|
|
7107
|
-
again = canonicalJsonBytes(value);
|
|
7108
|
-
} catch (error) {
|
|
7109
|
-
return {
|
|
7110
|
-
ok: false,
|
|
7111
|
-
reason: "not-canonical",
|
|
7112
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
7113
|
-
};
|
|
7114
|
-
}
|
|
7115
|
-
if (!bytesEqual(again, bytes)) return {
|
|
7116
|
-
ok: false,
|
|
7117
|
-
reason: "not-canonical"
|
|
7118
|
-
};
|
|
6852
|
+
function readReply(raw, author) {
|
|
6853
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
6854
|
+
const { i, s, f, b } = raw;
|
|
6855
|
+
if (typeof i !== "string" || !REPLY_ID.test(i)) return void 0;
|
|
6856
|
+
if (typeof s !== "string" || s.length > REPLY_LIMITS.raw) return void 0;
|
|
6857
|
+
if (typeof f !== "string" || !author(f)) return void 0;
|
|
7119
6858
|
return {
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
}
|
|
7124
|
-
//#endregion
|
|
7125
|
-
//#region ../core/src/appStatements.ts
|
|
7126
|
-
/** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
|
|
7127
|
-
var APP_PREFIXES = {
|
|
7128
|
-
app: "ghostly-app/1",
|
|
7129
|
-
store: "ghostly-store/1",
|
|
7130
|
-
revoke: "ghostly-revoke/1"
|
|
7131
|
-
};
|
|
7132
|
-
var APP_STATEMENT_LIMITS = {
|
|
7133
|
-
/** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
|
|
7134
|
-
signatureBytes: 1024,
|
|
7135
|
-
/** Digests named by one revocation. */
|
|
7136
|
-
revokeDigests: 64,
|
|
7137
|
-
/** A revocation's reason, in characters. */
|
|
7138
|
-
reason: 200,
|
|
7139
|
-
/** Revocations in one `ghostly-revoke.json`. */
|
|
7140
|
-
revocations: 4096
|
|
7141
|
-
};
|
|
7142
|
-
/** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
|
|
7143
|
-
function isAppKey(value) {
|
|
7144
|
-
if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
|
|
7145
|
-
const bytes = fromZ32(value);
|
|
7146
|
-
return bytes.length === 32 && toZ32(bytes) === value;
|
|
7147
|
-
}
|
|
7148
|
-
/** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
|
|
7149
|
-
function isAppHash(value) {
|
|
7150
|
-
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
7151
|
-
return toBase64Url(fromBase64Url(value)) === value;
|
|
7152
|
-
}
|
|
7153
|
-
/** An app reference, `<publisher key>/<name>`. */
|
|
7154
|
-
var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
7155
|
-
function isAppRef(value) {
|
|
7156
|
-
if (typeof value !== "string") return false;
|
|
7157
|
-
return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
|
|
7158
|
-
}
|
|
7159
|
-
function appRef(publisher, name) {
|
|
7160
|
-
return `${publisher}/${name}`;
|
|
7161
|
-
}
|
|
7162
|
-
/** The publisher key of a reference. */
|
|
7163
|
-
function appRefPublisher(ref) {
|
|
7164
|
-
return ref.slice(0, 52);
|
|
7165
|
-
}
|
|
7166
|
-
/**
|
|
7167
|
-
* The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
|
|
7168
|
-
*/
|
|
7169
|
-
function appFingerprint(key) {
|
|
7170
|
-
return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
|
|
7171
|
-
}
|
|
7172
|
-
/** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
|
|
7173
|
-
function appSignedBytes(prefix, canonical) {
|
|
7174
|
-
const p = utf8Encode(prefix);
|
|
7175
|
-
const out = new Uint8Array(p.length + 1 + 32);
|
|
7176
|
-
out.set(p, 0);
|
|
7177
|
-
out.set(sha256(canonical), p.length + 1);
|
|
7178
|
-
return out;
|
|
7179
|
-
}
|
|
7180
|
-
/** Signs an object under a prefix: its canonical bytes and the signature statement. */
|
|
7181
|
-
async function signAppObject(prefix, value, signer) {
|
|
7182
|
-
const bytes = canonicalJsonBytes(value);
|
|
7183
|
-
const sig = await signer.sign(appSignedBytes(prefix, bytes));
|
|
7184
|
-
return {
|
|
7185
|
-
bytes,
|
|
7186
|
-
signature: {
|
|
7187
|
-
alg: "ed25519",
|
|
7188
|
-
key: toZ32(signer.publicKey),
|
|
7189
|
-
sig: toBase64Url(sig)
|
|
7190
|
-
}
|
|
7191
|
-
};
|
|
7192
|
-
}
|
|
7193
|
-
/**
|
|
7194
|
-
* Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
|
|
7195
|
-
* so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
|
|
7196
|
-
*/
|
|
7197
|
-
function strictVerify(signature, message, publicKey) {
|
|
7198
|
-
try {
|
|
7199
|
-
return ed25519.verify(signature, message, publicKey, { zip215: false });
|
|
7200
|
-
} catch {
|
|
7201
|
-
return false;
|
|
7202
|
-
}
|
|
7203
|
-
}
|
|
7204
|
-
var exactKeys$1 = (value, keys, optional = []) => {
|
|
7205
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
7206
|
-
const have = Object.keys(value);
|
|
7207
|
-
return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
|
|
7208
|
-
};
|
|
7209
|
-
/** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
|
|
7210
|
-
function isAppSignature(value) {
|
|
7211
|
-
if (!exactKeys$1(value, [
|
|
7212
|
-
"alg",
|
|
7213
|
-
"key",
|
|
7214
|
-
"sig"
|
|
7215
|
-
])) return false;
|
|
7216
|
-
if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
|
|
7217
|
-
return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
|
|
7218
|
-
}
|
|
7219
|
-
/** Reads a signature statement from its bytes, which must be canonical. */
|
|
7220
|
-
function readAppSignature(bytes) {
|
|
7221
|
-
if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
|
|
7222
|
-
ok: false,
|
|
7223
|
-
reason: "bad-signature-statement",
|
|
7224
|
-
detail: "Too large"
|
|
7225
|
-
};
|
|
7226
|
-
const read = readCanonicalJson(bytes);
|
|
7227
|
-
if (!read.ok) return {
|
|
7228
|
-
ok: false,
|
|
7229
|
-
reason: "bad-signature-statement",
|
|
7230
|
-
detail: read.reason
|
|
7231
|
-
};
|
|
7232
|
-
if (!isAppSignature(read.value)) return {
|
|
7233
|
-
ok: false,
|
|
7234
|
-
reason: "bad-signature-statement",
|
|
7235
|
-
detail: "Not {alg, key, sig}"
|
|
7236
|
-
};
|
|
7237
|
-
return {
|
|
7238
|
-
ok: true,
|
|
7239
|
-
signature: read.value
|
|
7240
|
-
};
|
|
7241
|
-
}
|
|
7242
|
-
/**
|
|
7243
|
-
* Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
|
|
7244
|
-
* statement names another key, `bad-signature` when it does not verify.
|
|
7245
|
-
*/
|
|
7246
|
-
function verifyAppSignature(prefix, canonical, signature, key) {
|
|
7247
|
-
if (!isAppSignature(signature)) return {
|
|
7248
|
-
ok: false,
|
|
7249
|
-
reason: "bad-signature-statement"
|
|
7250
|
-
};
|
|
7251
|
-
if (signature.key !== key) return {
|
|
7252
|
-
ok: false,
|
|
7253
|
-
reason: "signature-key"
|
|
7254
|
-
};
|
|
7255
|
-
return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
|
|
7256
|
-
ok: false,
|
|
7257
|
-
reason: "bad-signature"
|
|
7258
|
-
};
|
|
7259
|
-
}
|
|
7260
|
-
var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
|
|
7261
|
-
var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
7262
|
-
/** Checks a revocation statement's shape; the reason it is refused, or null. */
|
|
7263
|
-
function checkAppRevokeStatement(value) {
|
|
7264
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
|
|
7265
|
-
const v = value;
|
|
7266
|
-
const byDigests = "digests" in v;
|
|
7267
|
-
if (!exactKeys$1(v, [
|
|
7268
|
-
"ghostlyRevoke",
|
|
7269
|
-
"app",
|
|
7270
|
-
byDigests ? "digests" : "upTo"
|
|
7271
|
-
], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
|
|
7272
|
-
if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
|
|
7273
|
-
if (!isAppRef(v.app)) return "app is not a reference";
|
|
7274
|
-
if (byDigests) {
|
|
7275
|
-
const d = v.digests;
|
|
7276
|
-
if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
|
|
7277
|
-
if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
|
|
7278
|
-
} else if (!isSequence(v.upTo)) return "upTo is not a sequence";
|
|
7279
|
-
if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
|
|
7280
|
-
return null;
|
|
7281
|
-
}
|
|
7282
|
-
/** Signs a revocation with the publisher's key. */
|
|
7283
|
-
async function signAppRevocation(statement, signer) {
|
|
7284
|
-
const problem = checkAppRevokeStatement(statement);
|
|
7285
|
-
if (problem) throw new Error(`Not a revocation: ${problem}`);
|
|
7286
|
-
if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
|
|
7287
|
-
const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
|
|
7288
|
-
return {
|
|
7289
|
-
statement,
|
|
7290
|
-
signature
|
|
7291
|
-
};
|
|
7292
|
-
}
|
|
7293
|
-
/** What a checked revocation is kept by: the SHA-256 of its canonical bytes (statement and signature), base64url. */
|
|
7294
|
-
function appRevocationId(revocation) {
|
|
7295
|
-
return toBase64Url(sha256(canonicalJsonBytes(revocation)));
|
|
7296
|
-
}
|
|
7297
|
-
/**
|
|
7298
|
-
* Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
|
|
7299
|
-
* the signature under `ghostly-revoke/1` by the publisher its `app` names. One that `checked` holds verified before, so
|
|
7300
|
-
* its signature is not checked again; one that verifies now is added to it.
|
|
7301
|
-
*/
|
|
7302
|
-
function readAppRevocation(value, checked) {
|
|
7303
|
-
const read = readAppRevocationShape(value);
|
|
7304
|
-
if (!read.ok) return read;
|
|
7305
|
-
const verified = verifyAppRevocation(read.revocation, checked);
|
|
7306
|
-
return verified.ok ? read : verified;
|
|
7307
|
-
}
|
|
7308
|
-
/** The first half of `readAppRevocation`: the shape of a signed revocation, with no signature checked. */
|
|
7309
|
-
function readAppRevocationShape(value) {
|
|
7310
|
-
if (!exactKeys$1(value, ["statement", "signature"])) return {
|
|
7311
|
-
ok: false,
|
|
7312
|
-
reason: "bad-revocation",
|
|
7313
|
-
detail: "Keys are not statement and signature"
|
|
7314
|
-
};
|
|
7315
|
-
const problem = checkAppRevokeStatement(value.statement);
|
|
7316
|
-
if (problem) return {
|
|
7317
|
-
ok: false,
|
|
7318
|
-
reason: "bad-revocation",
|
|
7319
|
-
detail: problem
|
|
7320
|
-
};
|
|
7321
|
-
if (!isAppSignature(value.signature)) return {
|
|
7322
|
-
ok: false,
|
|
7323
|
-
reason: "bad-signature-statement"
|
|
7324
|
-
};
|
|
7325
|
-
return {
|
|
7326
|
-
ok: true,
|
|
7327
|
-
revocation: {
|
|
7328
|
-
statement: value.statement,
|
|
7329
|
-
signature: value.signature
|
|
7330
|
-
}
|
|
7331
|
-
};
|
|
7332
|
-
}
|
|
7333
|
-
/** The second half: the signature of a revocation of the right shape, unless `checked` holds it. */
|
|
7334
|
-
function verifyAppRevocation(revocation, checked) {
|
|
7335
|
-
const id = checked && appRevocationId(revocation);
|
|
7336
|
-
if (id !== void 0 && checked.has(id)) return { ok: true };
|
|
7337
|
-
const verified = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(revocation.statement), revocation.signature, appRefPublisher(revocation.statement.app));
|
|
7338
|
-
if (verified.ok && id !== void 0) checked.add(id);
|
|
7339
|
-
return verified;
|
|
7340
|
-
}
|
|
7341
|
-
/**
|
|
7342
|
-
* Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
|
|
7343
|
-
* file, as a store index's `revoked` does.
|
|
7344
|
-
*/
|
|
7345
|
-
function readAppRevocations(bytes) {
|
|
7346
|
-
const read = readCanonicalJson(bytes);
|
|
7347
|
-
if (!read.ok) return read;
|
|
7348
|
-
if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
|
|
7349
|
-
ok: false,
|
|
7350
|
-
reason: "bad-revocation",
|
|
7351
|
-
detail: "Not a list of at most 4096 revocations"
|
|
7352
|
-
};
|
|
7353
|
-
const revocations = [];
|
|
7354
|
-
for (const entry of read.value) {
|
|
7355
|
-
const one = readAppRevocation(entry);
|
|
7356
|
-
if (!one.ok) return one;
|
|
7357
|
-
revocations.push(one.revocation);
|
|
7358
|
-
}
|
|
7359
|
-
return {
|
|
7360
|
-
ok: true,
|
|
7361
|
-
revocations
|
|
7362
|
-
};
|
|
7363
|
-
}
|
|
7364
|
-
//#endregion
|
|
7365
|
-
//#region ../core/src/messageTime.ts
|
|
7366
|
-
/**
|
|
7367
|
-
* How far ahead of this device's clock a number a peer made from its clock may be (a pin's number, a status card's
|
|
7368
|
-
* times): clocks drift, but a time past this is the peer's choice, not its clock.
|
|
7369
|
-
*/
|
|
7370
|
-
var MESSAGE_CLOCK_SKEW_MS = 3e5;
|
|
7371
|
-
/**
|
|
7372
|
-
* The time a line of a group's history is kept under (a membership change, a rename): what its commit says, but at
|
|
7373
|
-
* most `MESSAGE_CLOCK_SKEW_MS` past this clock, and now when it is not a positive number. Messages do not use it:
|
|
7374
|
-
* they are placed where they arrive (below).
|
|
7375
|
-
*/
|
|
7376
|
-
function receivedTimestamp(ts, now = Date.now()) {
|
|
7377
|
-
if (!Number.isFinite(ts) || ts <= 0) return now;
|
|
7378
|
-
return Math.min(ts, now + MESSAGE_CLOCK_SKEW_MS);
|
|
7379
|
-
}
|
|
7380
|
-
/** The latest moment a date can hold. */
|
|
7381
|
-
var MAX_DATE_MS = 864e13;
|
|
7382
|
-
/** The time a sender says, as it is kept: a positive time a date can hold, or nothing. */
|
|
7383
|
-
function claimedTime(ts) {
|
|
7384
|
-
return typeof ts === "number" && Number.isFinite(ts) && ts > 0 && ts <= MAX_DATE_MS ? ts : void 0;
|
|
7385
|
-
}
|
|
7386
|
-
/**
|
|
7387
|
-
* The place of a row stored now: this clock, and past the last place given in its chat, so rows that come in the
|
|
7388
|
-
* same millisecond (a hold pickup, a group's catch-up) keep the order they came in.
|
|
7389
|
-
*/
|
|
7390
|
-
function arrivalKey(now, last = 0) {
|
|
7391
|
-
return Math.max(now, last + 1);
|
|
7392
|
-
}
|
|
7393
|
-
/** The time a message shows: what its sender said, never later than when it came. A row of mine shows its own time. */
|
|
7394
|
-
function shownTime(message) {
|
|
7395
|
-
return message.sentAt === void 0 ? message.timestamp : Math.min(message.sentAt, message.timestamp);
|
|
7396
|
-
}
|
|
7397
|
-
/**
|
|
7398
|
-
* When something a peer dated happened (an edit), for this device: what the peer says, now at the latest, and now
|
|
7399
|
-
* when it says nothing a date can hold.
|
|
7400
|
-
*/
|
|
7401
|
-
function heardTime(ts, now = Date.now()) {
|
|
7402
|
-
return Math.min(claimedTime(ts) ?? now, now);
|
|
7403
|
-
}
|
|
7404
|
-
//#endregion
|
|
7405
|
-
//#region ../core/src/statusCards.ts
|
|
7406
|
-
var STATUS_CARD_LIMITS = {
|
|
7407
|
-
/** A card's JSON, as sent (UTF-8 bytes): with the fallback text it stays well inside every message bound. */
|
|
7408
|
-
bytes: 8192,
|
|
7409
|
-
/** The sender's id for the card: stable across its updates. */
|
|
7410
|
-
id: 64,
|
|
7411
|
-
title: 120,
|
|
7412
|
-
/** The current step, a list item, a run's summary. */
|
|
7413
|
-
line: 200,
|
|
7414
|
-
branch: 120,
|
|
7415
|
-
schedule: 80,
|
|
7416
|
-
cron: 64,
|
|
7417
|
-
linkLabel: 40,
|
|
7418
|
-
url: 512,
|
|
7419
|
-
items: 20,
|
|
7420
|
-
links: 4,
|
|
7421
|
-
runs: 10,
|
|
7422
|
-
/** Any count on a card (a PR's number, lines, files; steps). */
|
|
7423
|
-
count: 1e9,
|
|
7424
|
-
/** How far ahead a routine's next run may be. */
|
|
7425
|
-
nextRunMs: 316224e5,
|
|
7426
|
-
/** Edits a message with a card takes (a text takes `MAX_EDITS_PER_MESSAGE`): a bot updates a long task often. */
|
|
7427
|
-
edits: 5e3,
|
|
7428
|
-
/** Labels on a task, and a label's length in characters. */
|
|
7429
|
-
tags: 3,
|
|
7430
|
-
tag: 24,
|
|
7431
|
-
/** Buttons on a message (WISP 406 · Message Buttons). */
|
|
7432
|
-
buttons: 6,
|
|
7433
|
-
/** A button's id: what a press names. */
|
|
7434
|
-
buttonId: 32,
|
|
7435
|
-
/** A button's label, in characters. */
|
|
7436
|
-
buttonLabel: 40,
|
|
7437
|
-
/** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
|
|
7438
|
-
appTitle: 40,
|
|
7439
|
-
appVersion: 32,
|
|
7440
|
-
/** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
|
|
7441
|
-
usageLabel: 24,
|
|
7442
|
-
/** A usage window's name ("5 h", "week"), in characters. */
|
|
7443
|
-
usageWindow: 16,
|
|
7444
|
-
/** Windows beside a usage card's own, shown in its details. */
|
|
7445
|
-
usageWindows: 3
|
|
7446
|
-
};
|
|
7447
|
-
/** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
|
|
7448
|
-
var BUTTON_STYLES = [
|
|
7449
|
-
"primary",
|
|
7450
|
-
"neutral",
|
|
7451
|
-
"danger"
|
|
7452
|
-
];
|
|
7453
|
-
var TASK_STATUSES = [
|
|
7454
|
-
"queued",
|
|
7455
|
-
"running",
|
|
7456
|
-
"blocked",
|
|
7457
|
-
"done",
|
|
7458
|
-
"failed",
|
|
7459
|
-
"cancelled"
|
|
7460
|
-
];
|
|
7461
|
-
/** A task that is still going: shown first, and counted on the chat's Tasks button. */
|
|
7462
|
-
var ACTIVE_TASK_STATUSES = [
|
|
7463
|
-
"queued",
|
|
7464
|
-
"running",
|
|
7465
|
-
"blocked"
|
|
7466
|
-
];
|
|
7467
|
-
var ITEM_STATES = [
|
|
7468
|
-
"pending",
|
|
7469
|
-
"running",
|
|
7470
|
-
"done",
|
|
7471
|
-
"failed",
|
|
7472
|
-
"skipped"
|
|
7473
|
-
];
|
|
7474
|
-
var ROUTINE_STATES = ["active", "paused"];
|
|
7475
|
-
var RUN_RESULTS = [
|
|
7476
|
-
"ok",
|
|
7477
|
-
"failed",
|
|
7478
|
-
"skipped"
|
|
7479
|
-
];
|
|
7480
|
-
/**
|
|
7481
|
-
* Where a task's pull request stands: `draft` (not ready for review), `open` (ready for review), `merged`, `closed`
|
|
7482
|
-
* (closed without a merge). A reader that does not know the field shows the pull request as before.
|
|
7483
|
-
*/
|
|
7484
|
-
var PR_STATES = [
|
|
7485
|
-
"draft",
|
|
7486
|
-
"open",
|
|
7487
|
-
"merged",
|
|
7488
|
-
"closed"
|
|
7489
|
-
];
|
|
7490
|
-
/** How a pull request's checks stand. */
|
|
7491
|
-
var PR_CHECKS = [
|
|
7492
|
-
"passing",
|
|
7493
|
-
"failing",
|
|
7494
|
-
"pending"
|
|
7495
|
-
];
|
|
7496
|
-
var ID$4 = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,63}$/;
|
|
7497
|
-
/** A button's id: 1 to 32 of `A-Z a-z 0-9 _ . : -`, not starting with `-`. */
|
|
7498
|
-
var BUTTON_ID = /^[A-Za-z0-9_.:][A-Za-z0-9_.:-]{0,31}$/;
|
|
7499
|
-
var CRON = /^[0-9A-Za-z*,/?#\- ]{1,64}$/;
|
|
7500
|
-
/**
|
|
7501
|
-
* Whether a typed answer is a button's label or id (WISP 406 · Message Buttons): ignoring case and spaces at the ends.
|
|
7502
|
-
* Not the device's locale's case: the presser's and the author's apps must agree ("I" is not "ı" anywhere).
|
|
7503
|
-
*/
|
|
7504
|
-
var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
7505
|
-
/**
|
|
7506
|
-
* Two buttons a typed answer could not tell apart, or none: a label that reads as an earlier label (`label`), or a
|
|
7507
|
-
* label that reads as another button's id (`id`), both as `sameButtonText` compares them. The labels as a reader keeps
|
|
7508
|
-
* them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
|
|
7509
|
-
*/
|
|
7510
|
-
function buttonLabelClash(buttons) {
|
|
7511
|
-
const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
|
|
7512
|
-
const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
|
|
7513
|
-
for (const [at, label] of labels.entries()) {
|
|
7514
|
-
if (!label) continue;
|
|
7515
|
-
const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
|
|
7516
|
-
if (twin !== -1) return {
|
|
7517
|
-
at,
|
|
7518
|
-
with: twin,
|
|
7519
|
-
kind: "label"
|
|
7520
|
-
};
|
|
7521
|
-
const named = ids.findIndex((id, i) => i !== at && id !== void 0 && sameButtonText(id, label));
|
|
7522
|
-
if (named !== -1) return {
|
|
7523
|
-
at,
|
|
7524
|
-
with: named,
|
|
7525
|
-
kind: "id"
|
|
7526
|
-
};
|
|
7527
|
-
}
|
|
7528
|
-
}
|
|
7529
|
-
/** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
|
|
7530
|
-
function appCardId(ref) {
|
|
7531
|
-
return `${ref.slice(53)}.${ref.slice(0, 16)}`;
|
|
7532
|
-
}
|
|
7533
|
-
/** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
|
|
7534
|
-
var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
7535
|
-
var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7536
|
-
/** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
|
|
7537
|
-
function cardLine(value, max) {
|
|
7538
|
-
if (typeof value !== "string") return void 0;
|
|
7539
|
-
return sanitizeDisplayText(value.replace(/\s+/gu, " "), max);
|
|
7540
|
-
}
|
|
7541
|
-
/**
|
|
7542
|
-
* An https link a card may show: no credentials, a host, no spaces or control characters, at most
|
|
7543
|
-
* `STATUS_CARD_LIMITS.url` characters. Returned as the parser writes it (a non-ASCII host in punycode).
|
|
7544
|
-
*/
|
|
7545
|
-
function cardUrl(value) {
|
|
7546
|
-
if (typeof value !== "string" || !value || value.length > STATUS_CARD_LIMITS.url || /[\s\p{Cc}\p{Cf}]/u.test(value)) return void 0;
|
|
7547
|
-
let url;
|
|
7548
|
-
try {
|
|
7549
|
-
url = new URL(value);
|
|
7550
|
-
} catch {
|
|
7551
|
-
return;
|
|
7552
|
-
}
|
|
7553
|
-
if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return void 0;
|
|
7554
|
-
return url.href.length <= STATUS_CARD_LIMITS.url ? url.href : void 0;
|
|
7555
|
-
}
|
|
7556
|
-
var count = (value, min = 0) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= STATUS_CARD_LIMITS.count ? value : void 0;
|
|
7557
|
-
/** A time on a card as a reader keeps it: a positive whole number, never later than `latest`. */
|
|
7558
|
-
var time = (value, latest) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? Math.min(value, latest) : void 0;
|
|
7559
|
-
var oneOf = (values, value) => typeof value === "string" && values.includes(value) ? value : void 0;
|
|
7560
|
-
function readLinks(raw) {
|
|
7561
|
-
if (!Array.isArray(raw)) return [];
|
|
7562
|
-
const links = [];
|
|
7563
|
-
for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
|
|
7564
|
-
if (!isObject$4(entry)) continue;
|
|
7565
|
-
const url = cardUrl(entry.url);
|
|
7566
|
-
if (!url) continue;
|
|
7567
|
-
const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
|
|
7568
|
-
links.push({
|
|
7569
|
-
url,
|
|
7570
|
-
...label && { label }
|
|
7571
|
-
});
|
|
7572
|
-
}
|
|
7573
|
-
return links;
|
|
7574
|
-
}
|
|
7575
|
-
/** A task's labels as a reader keeps them: the first `STATUS_CARD_LIMITS.tags` that hold, each one clean line, none twice. */
|
|
7576
|
-
function readTags(raw) {
|
|
7577
|
-
if (!Array.isArray(raw)) return [];
|
|
7578
|
-
const tags = [];
|
|
7579
|
-
for (const entry of raw) {
|
|
7580
|
-
const tag = cardLine(entry, STATUS_CARD_LIMITS.tag);
|
|
7581
|
-
if (tag && !tags.includes(tag)) tags.push(tag);
|
|
7582
|
-
if (tags.length === STATUS_CARD_LIMITS.tags) break;
|
|
7583
|
-
}
|
|
7584
|
-
return tags;
|
|
7585
|
-
}
|
|
7586
|
-
function readRun(raw, now) {
|
|
7587
|
-
if (!isObject$4(raw)) return void 0;
|
|
7588
|
-
const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
|
|
7589
|
-
if (!at || !result) return void 0;
|
|
7590
|
-
const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
|
|
7591
|
-
return {
|
|
7592
|
-
at,
|
|
7593
|
-
result,
|
|
7594
|
-
...summary && { summary }
|
|
7595
|
-
};
|
|
7596
|
-
}
|
|
7597
|
-
/** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
|
|
7598
|
-
var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
|
|
7599
|
-
/** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
|
|
7600
|
-
var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
|
|
7601
|
-
/** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
|
|
7602
|
-
function readUsage(raw, id, now) {
|
|
7603
|
-
const left = percent(raw.left);
|
|
7604
|
-
let used = count(raw.used), limit = count(raw.limit, 1);
|
|
7605
|
-
if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
|
|
7606
|
-
if (left === void 0 && limit === void 0) return void 0;
|
|
7607
|
-
const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
|
|
7608
|
-
const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
|
|
7609
|
-
const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
|
|
7610
|
-
const windows = [];
|
|
7611
|
-
if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
|
|
7612
|
-
if (!isObject$4(entry)) continue;
|
|
7613
|
-
const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
|
|
7614
|
-
if (!name || share === void 0) continue;
|
|
7615
|
-
const at = ahead(entry.resetsAt, now);
|
|
7616
|
-
windows.push({
|
|
7617
|
-
window: name,
|
|
7618
|
-
left: share,
|
|
7619
|
-
...at && { resetsAt: at }
|
|
7620
|
-
});
|
|
7621
|
-
}
|
|
7622
|
-
return {
|
|
7623
|
-
kind: "usage",
|
|
7624
|
-
id,
|
|
7625
|
-
...left !== void 0 && { left },
|
|
7626
|
-
...used !== void 0 && limit !== void 0 && {
|
|
7627
|
-
used,
|
|
7628
|
-
limit
|
|
7629
|
-
},
|
|
7630
|
-
...label && { label },
|
|
7631
|
-
...account && { account },
|
|
7632
|
-
...window && { window },
|
|
7633
|
-
...resetsAt && { resetsAt },
|
|
7634
|
-
...updatedAt && { updatedAt },
|
|
7635
|
-
...windows.length && { windows }
|
|
7636
|
-
};
|
|
7637
|
-
}
|
|
7638
|
-
/** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
|
|
7639
|
-
function usageLeft(card) {
|
|
7640
|
-
if (card.left !== void 0) return card.left;
|
|
7641
|
-
return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
|
|
7642
|
-
}
|
|
7643
|
-
/** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
|
|
7644
|
-
function statusCardBytes(card) {
|
|
7645
|
-
try {
|
|
7646
|
-
return utf8Encode(JSON.stringify(card) ?? "").length;
|
|
7647
|
-
} catch {
|
|
7648
|
-
return Infinity;
|
|
7649
|
-
}
|
|
7650
|
-
}
|
|
7651
|
-
/**
|
|
7652
|
-
* A card as a reader takes it from the wire (`sc`), or undefined when it cannot stand: not an object, past
|
|
7653
|
-
* `STATUS_CARD_LIMITS.bytes`, a kind this app does not know, no valid id, title or name, status or state. Fields that do
|
|
7654
|
-
* not hold are left out and lists are cut to their bound; times are kept no later than the reader's clock allows (a
|
|
7655
|
-
* routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
|
|
7656
|
-
*/
|
|
7657
|
-
function readStatusCard(raw, now = Date.now()) {
|
|
7658
|
-
if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
|
|
7659
|
-
const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
|
|
7660
|
-
if (!id) return void 0;
|
|
7661
|
-
if (raw.kind === "app") {
|
|
7662
|
-
if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
|
|
7663
|
-
const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
|
|
7664
|
-
if (!title) return void 0;
|
|
7665
|
-
const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
|
|
7666
|
-
return {
|
|
7667
|
-
kind: "app",
|
|
7668
|
-
id,
|
|
7669
|
-
ref: raw.ref,
|
|
7670
|
-
digest: raw.digest,
|
|
7671
|
-
sequence: raw.sequence,
|
|
7672
|
-
title,
|
|
7673
|
-
...version && { version },
|
|
7674
|
-
...url && { url },
|
|
7675
|
-
...raw.opened === true && { opened: true }
|
|
7676
|
-
};
|
|
7677
|
-
}
|
|
7678
|
-
const latest = now + MESSAGE_CLOCK_SKEW_MS;
|
|
7679
|
-
if (raw.kind === "usage") return readUsage(raw, id, now);
|
|
7680
|
-
const links = readLinks(raw.links);
|
|
7681
|
-
if (raw.kind === "task") {
|
|
7682
|
-
const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
|
|
7683
|
-
if (!title || !status) return void 0;
|
|
7684
|
-
const progress = typeof raw.progress === "number" && Number.isFinite(raw.progress) ? Math.round(Math.min(100, Math.max(0, raw.progress))) : void 0;
|
|
7685
|
-
let done = count(raw.done), total = count(raw.total, 1);
|
|
7686
|
-
if (done === void 0 || total === void 0 || done > total) done = total = void 0;
|
|
7687
|
-
const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
|
|
7688
|
-
let pr;
|
|
7689
|
-
if (isObject$4(raw.pr)) {
|
|
7690
|
-
const url = cardUrl(raw.pr.url);
|
|
7691
|
-
if (url) {
|
|
7692
|
-
const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
|
|
7693
|
-
const state = oneOf(PR_STATES, raw.pr.state), checks = oneOf(PR_CHECKS, raw.pr.checks);
|
|
7694
|
-
pr = {
|
|
7695
|
-
url,
|
|
7696
|
-
...number !== void 0 && { number },
|
|
7697
|
-
...additions !== void 0 && { additions },
|
|
7698
|
-
...deletions !== void 0 && { deletions },
|
|
7699
|
-
...files !== void 0 && { files },
|
|
7700
|
-
...state && { state },
|
|
7701
|
-
...checks && { checks }
|
|
7702
|
-
};
|
|
7703
|
-
}
|
|
7704
|
-
}
|
|
7705
|
-
const items = [];
|
|
7706
|
-
if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
|
|
7707
|
-
if (!isObject$4(entry)) continue;
|
|
7708
|
-
const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
|
|
7709
|
-
if (text) items.push({
|
|
7710
|
-
text,
|
|
7711
|
-
state: oneOf(ITEM_STATES, entry.state) ?? "pending"
|
|
7712
|
-
});
|
|
7713
|
-
}
|
|
7714
|
-
const startedAt = time(raw.startedAt, latest), updatedAt = time(raw.updatedAt, latest);
|
|
7715
|
-
const tags = readTags(raw.tags);
|
|
7716
|
-
const parent = typeof raw.parent === "string" && ID$4.test(raw.parent) && raw.parent !== id ? raw.parent : void 0;
|
|
7717
|
-
return {
|
|
7718
|
-
kind: "task",
|
|
7719
|
-
id,
|
|
7720
|
-
title,
|
|
7721
|
-
status,
|
|
7722
|
-
...progress !== void 0 && { progress },
|
|
7723
|
-
...done !== void 0 && total !== void 0 && {
|
|
7724
|
-
done,
|
|
7725
|
-
total
|
|
7726
|
-
},
|
|
7727
|
-
...step && { step },
|
|
7728
|
-
...pr && { pr },
|
|
7729
|
-
...branch && { branch },
|
|
7730
|
-
...startedAt && { startedAt },
|
|
7731
|
-
...updatedAt && { updatedAt },
|
|
7732
|
-
...items.length && { items },
|
|
7733
|
-
...links.length && { links },
|
|
7734
|
-
...tags.length && { tags },
|
|
7735
|
-
...parent && { parent }
|
|
7736
|
-
};
|
|
7737
|
-
}
|
|
7738
|
-
if (raw.kind === "routine") {
|
|
7739
|
-
const name = cardLine(raw.name, STATUS_CARD_LIMITS.title), schedule = cardLine(raw.schedule, STATUS_CARD_LIMITS.schedule), state = oneOf(ROUTINE_STATES, raw.state);
|
|
7740
|
-
if (!name || !schedule || !state) return void 0;
|
|
7741
|
-
const cron = typeof raw.cron === "string" && CRON.test(raw.cron.trim()) ? raw.cron.trim() : void 0;
|
|
7742
|
-
const lastRun = readRun(raw.lastRun, now);
|
|
7743
|
-
const nextRunAt = typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs ? raw.nextRunAt : void 0;
|
|
7744
|
-
const runs = [];
|
|
7745
|
-
if (Array.isArray(raw.runs)) for (const entry of raw.runs.slice(0, STATUS_CARD_LIMITS.runs)) {
|
|
7746
|
-
const run = readRun(entry, now);
|
|
7747
|
-
if (run) runs.push(run);
|
|
7748
|
-
}
|
|
7749
|
-
return {
|
|
7750
|
-
kind: "routine",
|
|
7751
|
-
id,
|
|
7752
|
-
name,
|
|
7753
|
-
schedule,
|
|
7754
|
-
state,
|
|
7755
|
-
...cron && { cron },
|
|
7756
|
-
...lastRun && { lastRun },
|
|
7757
|
-
...nextRunAt && { nextRunAt },
|
|
7758
|
-
...runs.length && { runs },
|
|
7759
|
-
...links.length && { links }
|
|
7760
|
-
};
|
|
7761
|
-
}
|
|
7762
|
-
if (raw.kind === "buttons") {
|
|
7763
|
-
if (!Array.isArray(raw.buttons)) return void 0;
|
|
7764
|
-
const buttons = [], seen = /* @__PURE__ */ new Set();
|
|
7765
|
-
for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
|
|
7766
|
-
if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
|
|
7767
|
-
const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
|
|
7768
|
-
if (!label) continue;
|
|
7769
|
-
seen.add(entry.id);
|
|
7770
|
-
const style = oneOf(BUTTON_STYLES, entry.style);
|
|
7771
|
-
buttons.push({
|
|
7772
|
-
id: entry.id,
|
|
7773
|
-
label,
|
|
7774
|
-
...style && style !== "neutral" && { style },
|
|
7775
|
-
...entry.once === true && { once: true }
|
|
7776
|
-
});
|
|
7777
|
-
}
|
|
7778
|
-
if (!buttons.length) return void 0;
|
|
7779
|
-
const chosen = typeof raw.chosen === "string" && seen.has(raw.chosen) ? raw.chosen : void 0;
|
|
7780
|
-
return {
|
|
7781
|
-
kind: "buttons",
|
|
7782
|
-
id,
|
|
7783
|
-
buttons,
|
|
7784
|
-
...chosen && { chosen },
|
|
7785
|
-
...raw.closed === true && { closed: true }
|
|
7786
|
-
};
|
|
7787
|
-
}
|
|
7788
|
-
}
|
|
7789
|
-
/**
|
|
7790
|
-
* A card as a sender may send it, or why not: the reader's rule, but anything it would leave out or cut is refused
|
|
7791
|
-
* here instead (an unknown status, a link that is not https, a list past its bound, a line too long), so a bot learns
|
|
7792
|
-
* at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
|
|
7793
|
-
*/
|
|
7794
|
-
function checkStatusCard(raw, now = Date.now()) {
|
|
7795
|
-
if (!isObject$4(raw)) return { error: "A card is an object" };
|
|
7796
|
-
const bytes = statusCardBytes(raw);
|
|
7797
|
-
if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
|
|
7798
|
-
if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons" && raw.kind !== "app" && raw.kind !== "usage") return { error: "kind is task, routine, buttons, app or usage" };
|
|
7799
|
-
if (typeof raw.id !== "string" || !ID$4.test(raw.id)) return { error: `id is 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -` };
|
|
7800
|
-
const line = (field, value, max, required = false) => {
|
|
7801
|
-
if (value === void 0 && !required) return null;
|
|
7802
|
-
if (typeof value !== "string" || !cardLine(value, Infinity)) return `${field} is text`;
|
|
7803
|
-
if ([...cardLine(value, Infinity)].length > max) return `${field} is at most ${max} characters`;
|
|
7804
|
-
return null;
|
|
7805
|
-
};
|
|
7806
|
-
const url = (field, value) => cardUrl(value) ? null : `${field} must be an https link of at most ${STATUS_CARD_LIMITS.url} characters`;
|
|
7807
|
-
const whole = (field, value, min = 0) => value === void 0 || count(value, min) !== void 0 ? null : `${field} is a whole number from ${min}`;
|
|
7808
|
-
const errors = [];
|
|
7809
|
-
if (raw.links !== void 0) {
|
|
7810
|
-
if (!Array.isArray(raw.links) || raw.links.length > STATUS_CARD_LIMITS.links) errors.push(`links is a list of at most ${STATUS_CARD_LIMITS.links}`);
|
|
7811
|
-
else for (const [i, link] of raw.links.entries()) {
|
|
7812
|
-
if (!isObject$4(link)) {
|
|
7813
|
-
errors.push(`links[${i}] is an object`);
|
|
7814
|
-
continue;
|
|
7815
|
-
}
|
|
7816
|
-
errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
|
|
7817
|
-
}
|
|
7818
|
-
}
|
|
7819
|
-
const share = (field, value) => value === void 0 || typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? null : `${field} is a number from 0 to 100`;
|
|
7820
|
-
const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
|
|
7821
|
-
if (raw.kind === "usage") {
|
|
7822
|
-
if (raw.links !== void 0) errors.push("a usage card takes no links");
|
|
7823
|
-
if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
|
|
7824
|
-
errors.push(share("left", raw.left));
|
|
7825
|
-
if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
|
|
7826
|
-
else if (raw.used !== void 0) {
|
|
7827
|
-
errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
|
|
7828
|
-
if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
|
|
7829
|
-
}
|
|
7830
|
-
errors.push(line("label", raw.label, STATUS_CARD_LIMITS.usageLabel), line("account", raw.account, STATUS_CARD_LIMITS.usageLabel), line("window", raw.window, STATUS_CARD_LIMITS.usageWindow), reset("resetsAt", raw.resetsAt));
|
|
7831
|
-
if (raw.updatedAt !== void 0 && !(typeof raw.updatedAt === "number" && Number.isSafeInteger(raw.updatedAt) && raw.updatedAt > 0 && raw.updatedAt <= now + 3e5)) errors.push("updatedAt is a time in milliseconds, not in the future");
|
|
7832
|
-
if (raw.windows !== void 0) {
|
|
7833
|
-
if (!Array.isArray(raw.windows) || raw.windows.length > STATUS_CARD_LIMITS.usageWindows) errors.push(`windows is a list of at most ${STATUS_CARD_LIMITS.usageWindows}`);
|
|
7834
|
-
else for (const [i, entry] of raw.windows.entries()) {
|
|
7835
|
-
if (!isObject$4(entry)) {
|
|
7836
|
-
errors.push(`windows[${i}] is an object`);
|
|
7837
|
-
continue;
|
|
7838
|
-
}
|
|
7839
|
-
errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
|
|
7840
|
-
errors.push(entry.left === void 0 ? `windows[${i}].left is a number from 0 to 100` : share(`windows[${i}].left`, entry.left), reset(`windows[${i}].resetsAt`, entry.resetsAt));
|
|
7841
|
-
}
|
|
7842
|
-
}
|
|
7843
|
-
} else if (raw.kind === "app") {
|
|
7844
|
-
if (raw.links !== void 0) errors.push("an app card takes no links");
|
|
7845
|
-
if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
|
|
7846
|
-
else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
|
|
7847
|
-
if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
|
|
7848
|
-
if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
|
|
7849
|
-
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
|
|
7850
|
-
if (raw.url !== void 0) errors.push(url("url", raw.url));
|
|
7851
|
-
if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
|
|
7852
|
-
} else if (raw.kind === "buttons") {
|
|
7853
|
-
if (raw.links !== void 0) errors.push("buttons take no links");
|
|
7854
|
-
if (!Array.isArray(raw.buttons) || !raw.buttons.length || raw.buttons.length > STATUS_CARD_LIMITS.buttons) errors.push(`buttons is a list of 1 to ${STATUS_CARD_LIMITS.buttons}`);
|
|
7855
|
-
else {
|
|
7856
|
-
const ids = /* @__PURE__ */ new Set();
|
|
7857
|
-
for (const [i, button] of raw.buttons.entries()) {
|
|
7858
|
-
if (!isObject$4(button)) {
|
|
7859
|
-
errors.push(`buttons[${i}] is an object`);
|
|
7860
|
-
continue;
|
|
7861
|
-
}
|
|
7862
|
-
if (typeof button.id !== "string" || !BUTTON_ID.test(button.id)) errors.push(`buttons[${i}].id is 1 to ${STATUS_CARD_LIMITS.buttonId} of A-Z a-z 0-9 _ . : - and does not start with -`);
|
|
7863
|
-
else if (ids.has(button.id)) errors.push(`buttons[${i}].id "${button.id}" is used twice`);
|
|
7864
|
-
else ids.add(button.id);
|
|
7865
|
-
errors.push(line(`buttons[${i}].label`, button.label, STATUS_CARD_LIMITS.buttonLabel, true));
|
|
7866
|
-
if (button.style !== void 0 && !oneOf(BUTTON_STYLES, button.style)) errors.push(`buttons[${i}].style is one of ${BUTTON_STYLES.join(", ")}`);
|
|
7867
|
-
if (button.once !== void 0 && button.once !== true) errors.push(`buttons[${i}].once is true or left out`);
|
|
7868
|
-
}
|
|
7869
|
-
const clash = buttonLabelClash(raw.buttons);
|
|
7870
|
-
if (clash) errors.push(`buttons[${clash.at}].label ${JSON.stringify(cardLine(raw.buttons[clash.at].label, Infinity))} ${clash.kind === "label" ? `repeats buttons[${clash.with}].label` : `is buttons[${clash.with}].id`} (ignoring case and spaces at the ends)`);
|
|
7871
|
-
if (raw.chosen !== void 0 && !(typeof raw.chosen === "string" && ids.has(raw.chosen))) errors.push("chosen names one of the buttons");
|
|
7872
|
-
}
|
|
7873
|
-
if (raw.closed !== void 0 && raw.closed !== true) errors.push("closed is true or left out");
|
|
7874
|
-
} else if (raw.kind === "task") {
|
|
7875
|
-
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.title, true));
|
|
7876
|
-
if (!oneOf(TASK_STATUSES, raw.status)) errors.push(`status is one of ${TASK_STATUSES.join(", ")}`);
|
|
7877
|
-
if (raw.progress !== void 0 && !(typeof raw.progress === "number" && Number.isFinite(raw.progress) && raw.progress >= 0 && raw.progress <= 100)) errors.push("progress is a number from 0 to 100");
|
|
7878
|
-
if (raw.done === void 0 !== (raw.total === void 0)) errors.push("done and total go together");
|
|
7879
|
-
else if (raw.done !== void 0) {
|
|
7880
|
-
errors.push(whole("done", raw.done), whole("total", raw.total, 1));
|
|
7881
|
-
if (typeof raw.done === "number" && typeof raw.total === "number" && raw.done > raw.total) errors.push("done is at most total");
|
|
7882
|
-
}
|
|
7883
|
-
errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
|
|
7884
|
-
if (raw.pr !== void 0) {
|
|
7885
|
-
if (!isObject$4(raw.pr)) errors.push("pr is an object");
|
|
7886
|
-
else {
|
|
7887
|
-
errors.push(url("pr.url", raw.pr.url), whole("pr.number", raw.pr.number, 1), whole("pr.additions", raw.pr.additions), whole("pr.deletions", raw.pr.deletions), whole("pr.files", raw.pr.files));
|
|
7888
|
-
if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
|
|
7889
|
-
if (raw.pr.checks !== void 0 && !oneOf(PR_CHECKS, raw.pr.checks)) errors.push(`pr.checks is one of ${PR_CHECKS.join(", ")}`);
|
|
7890
|
-
}
|
|
7891
|
-
}
|
|
7892
|
-
if (raw.tags !== void 0) {
|
|
7893
|
-
if (!Array.isArray(raw.tags) || raw.tags.length > STATUS_CARD_LIMITS.tags) errors.push(`tags is a list of at most ${STATUS_CARD_LIMITS.tags}`);
|
|
7894
|
-
else {
|
|
7895
|
-
const seen = /* @__PURE__ */ new Set();
|
|
7896
|
-
for (const [i, tag] of raw.tags.entries()) {
|
|
7897
|
-
const problem = line(`tags[${i}]`, tag, STATUS_CARD_LIMITS.tag, true);
|
|
7898
|
-
errors.push(problem);
|
|
7899
|
-
if (problem) continue;
|
|
7900
|
-
const clean = cardLine(tag, Infinity);
|
|
7901
|
-
if (seen.has(clean)) errors.push(`tags[${i}] ${JSON.stringify(clean)} is there twice`);
|
|
7902
|
-
seen.add(clean);
|
|
7903
|
-
}
|
|
7904
|
-
}
|
|
7905
|
-
}
|
|
7906
|
-
if (raw.parent !== void 0) {
|
|
7907
|
-
if (typeof raw.parent !== "string" || !ID$4.test(raw.parent)) errors.push(`parent is another task's id: 1 to ${STATUS_CARD_LIMITS.id} of A-Z a-z 0-9 _ . : - and does not start with -`);
|
|
7908
|
-
else if (raw.parent === raw.id) errors.push("parent is another task's id, not its own");
|
|
7909
|
-
}
|
|
7910
|
-
for (const field of ["startedAt", "updatedAt"]) {
|
|
7911
|
-
const value = raw[field];
|
|
7912
|
-
if (value !== void 0 && !(typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + 3e5)) errors.push(`${field} is a time in milliseconds, not in the future`);
|
|
7913
|
-
}
|
|
7914
|
-
if (raw.items !== void 0) {
|
|
7915
|
-
if (!Array.isArray(raw.items) || raw.items.length > STATUS_CARD_LIMITS.items) errors.push(`items is a list of at most ${STATUS_CARD_LIMITS.items}`);
|
|
7916
|
-
else for (const [i, item] of raw.items.entries()) {
|
|
7917
|
-
if (!isObject$4(item)) {
|
|
7918
|
-
errors.push(`items[${i}] is an object`);
|
|
7919
|
-
continue;
|
|
7920
|
-
}
|
|
7921
|
-
errors.push(line(`items[${i}].text`, item.text, STATUS_CARD_LIMITS.line, true));
|
|
7922
|
-
if (item.state !== void 0 && !oneOf(ITEM_STATES, item.state)) errors.push(`items[${i}].state is one of ${ITEM_STATES.join(", ")}`);
|
|
7923
|
-
}
|
|
7924
|
-
}
|
|
7925
|
-
} else {
|
|
7926
|
-
errors.push(line("name", raw.name, STATUS_CARD_LIMITS.title, true), line("schedule", raw.schedule, STATUS_CARD_LIMITS.schedule, true));
|
|
7927
|
-
if (!oneOf(ROUTINE_STATES, raw.state)) errors.push(`state is one of ${ROUTINE_STATES.join(", ")}`);
|
|
7928
|
-
if (raw.cron !== void 0 && !(typeof raw.cron === "string" && CRON.test(raw.cron.trim()))) errors.push(`cron is a cron line of at most ${STATUS_CARD_LIMITS.cron} characters`);
|
|
7929
|
-
if (raw.nextRunAt !== void 0 && !(typeof raw.nextRunAt === "number" && Number.isSafeInteger(raw.nextRunAt) && raw.nextRunAt > 0 && raw.nextRunAt <= now + STATUS_CARD_LIMITS.nextRunMs)) errors.push("nextRunAt is a time in milliseconds, within a year");
|
|
7930
|
-
const run = (field, value) => {
|
|
7931
|
-
if (!isObject$4(value)) return errors.push(`${field} is an object`);
|
|
7932
|
-
if (!(typeof value.at === "number" && Number.isSafeInteger(value.at) && value.at > 0 && value.at <= now + 3e5)) errors.push(`${field}.at is a time in milliseconds, not in the future`);
|
|
7933
|
-
if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
|
|
7934
|
-
errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
|
|
7935
|
-
};
|
|
7936
|
-
if (raw.lastRun !== void 0) run("lastRun", raw.lastRun);
|
|
7937
|
-
if (raw.runs !== void 0) {
|
|
7938
|
-
if (!Array.isArray(raw.runs) || raw.runs.length > STATUS_CARD_LIMITS.runs) errors.push(`runs is a list of at most ${STATUS_CARD_LIMITS.runs}`);
|
|
7939
|
-
else raw.runs.forEach((entry, i) => run(`runs[${i}]`, entry));
|
|
7940
|
-
}
|
|
7941
|
-
}
|
|
7942
|
-
const error = errors.find((e) => typeof e === "string");
|
|
7943
|
-
if (error) return { error };
|
|
7944
|
-
const card = readStatusCard(raw, now);
|
|
7945
|
-
return card ? { card } : { error: "The card does not hold" };
|
|
7946
|
-
}
|
|
7947
|
-
/** The progress a task shows, 0 to 100: its own, else its steps done of total; undefined when it says neither. */
|
|
7948
|
-
function taskProgress(card) {
|
|
7949
|
-
if (card.progress !== void 0) return card.progress;
|
|
7950
|
-
return card.done !== void 0 && card.total ? Math.round(card.done / card.total * 100) : void 0;
|
|
7951
|
-
}
|
|
7952
|
-
var TASK_MARK = {
|
|
7953
|
-
queued: "⏳",
|
|
7954
|
-
running: "🔄",
|
|
7955
|
-
blocked: "⛔",
|
|
7956
|
-
done: "✅",
|
|
7957
|
-
failed: "❌",
|
|
7958
|
-
cancelled: "🚫"
|
|
7959
|
-
};
|
|
7960
|
-
var STATUS_WORD = {
|
|
7961
|
-
queued: "Queued",
|
|
7962
|
-
running: "Running",
|
|
7963
|
-
blocked: "Blocked",
|
|
7964
|
-
done: "Done",
|
|
7965
|
-
failed: "Failed",
|
|
7966
|
-
cancelled: "Cancelled"
|
|
7967
|
-
};
|
|
7968
|
-
var RESULT_WORD = {
|
|
7969
|
-
ok: "ok",
|
|
7970
|
-
failed: "failed",
|
|
7971
|
-
skipped: "skipped"
|
|
7972
|
-
};
|
|
7973
|
-
/** A time as the fallback text says it: in UTC, to the minute ("2026-09-30 01:00 UTC"). */
|
|
7974
|
-
function cardTimeUtc(ms) {
|
|
7975
|
-
return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
7976
|
-
}
|
|
7977
|
-
/**
|
|
7978
|
-
* The text a card's message carries: what an app without cards shows, the DHT floor carries, the chat list previews
|
|
7979
|
-
* and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
|
|
7980
|
-
*/
|
|
7981
|
-
function statusCardText(card) {
|
|
7982
|
-
if (card.kind === "app") {
|
|
7983
|
-
const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
|
|
7984
|
-
return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
|
|
7985
|
-
}
|
|
7986
|
-
if (card.kind === "usage") {
|
|
7987
|
-
const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
|
|
7988
|
-
return [
|
|
7989
|
-
`📊 ${card.label ?? "Usage"}`,
|
|
7990
|
-
...card.account ? [card.account] : [],
|
|
7991
|
-
left,
|
|
7992
|
-
...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
|
|
7993
|
-
...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
|
|
7994
|
-
...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
|
|
7995
|
-
].join(" · ");
|
|
7996
|
-
}
|
|
7997
|
-
if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
|
|
7998
|
-
if (card.kind === "task") {
|
|
7999
|
-
const progress = taskProgress(card);
|
|
8000
|
-
const state = [
|
|
8001
|
-
STATUS_WORD[card.status],
|
|
8002
|
-
...progress !== void 0 ? [`${progress}%`] : [],
|
|
8003
|
-
...card.done !== void 0 && card.total ? [`${card.done} of ${card.total} steps`] : []
|
|
8004
|
-
].join(" · ");
|
|
8005
|
-
const standing = card.pr && [...card.pr.state ? [card.pr.state] : [], ...card.pr.checks ? [`checks ${card.pr.checks}`] : []].join(", ");
|
|
8006
|
-
const pr = card.pr && [
|
|
8007
|
-
`PR${card.pr.number !== void 0 ? ` #${card.pr.number}` : ""}`,
|
|
8008
|
-
...card.pr.additions !== void 0 || card.pr.deletions !== void 0 ? [`+${card.pr.additions ?? 0} -${card.pr.deletions ?? 0}`] : [],
|
|
8009
|
-
...standing ? [`(${standing})`] : []
|
|
8010
|
-
].join(" ") + `: ${card.pr.url}`;
|
|
8011
|
-
const now = card.step && ACTIVE_TASK_STATUSES.includes(card.status) ? [`Now: ${card.step}`] : [];
|
|
8012
|
-
return [
|
|
8013
|
-
`${TASK_MARK[card.status]} ${card.title}`,
|
|
8014
|
-
state,
|
|
8015
|
-
...now,
|
|
8016
|
-
...pr ? [pr] : [],
|
|
8017
|
-
...card.tags?.length ? [`Tags: ${card.tags.join(", ")}`] : [],
|
|
8018
|
-
...card.parent ? [`Part of: ${card.parent}`] : []
|
|
8019
|
-
].join("\n");
|
|
8020
|
-
}
|
|
8021
|
-
const last = card.lastRun && `Last run: ${RESULT_WORD[card.lastRun.result]}, ${cardTimeUtc(card.lastRun.at)}${card.lastRun.summary ? ` · ${card.lastRun.summary}` : ""}`;
|
|
8022
|
-
return [
|
|
8023
|
-
`🔁 ${card.name}`,
|
|
8024
|
-
`${card.schedule} · ${card.state === "active" ? "active" : "paused"}`,
|
|
8025
|
-
...last ? [last] : [],
|
|
8026
|
-
...card.nextRunAt && card.state === "active" ? [`Next run: ${cardTimeUtc(card.nextRunAt)}`] : []
|
|
8027
|
-
].join("\n");
|
|
8028
|
-
}
|
|
8029
|
-
/** Whether a message's edit number holds: a text's bound, or a card message's when the edit carries a card. */
|
|
8030
|
-
function cardEditNumber(e) {
|
|
8031
|
-
return Number.isSafeInteger(e) && e >= 1 && e <= STATUS_CARD_LIMITS.edits;
|
|
8032
|
-
}
|
|
8033
|
-
//#endregion
|
|
8034
|
-
//#region ../core/src/replies.ts
|
|
8035
|
-
var REPLY_LIMITS = {
|
|
8036
|
-
/** Code points of the line of the original, an ellipsis included when it was cut. */
|
|
8037
|
-
snippet: 120,
|
|
8038
|
-
/** Characters of the original's id. */
|
|
8039
|
-
id: 128,
|
|
8040
|
-
/** Characters of a line as it arrives, before it is cleaned: anything longer is not a line of a message. */
|
|
8041
|
-
raw: 2048
|
|
8042
|
-
};
|
|
8043
|
-
/** A paired chat's message id, a payment id or a file's wire id; a group message's `sender:epoch:seq`. */
|
|
8044
|
-
var REPLY_ID = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
8045
|
-
/** The two authors a paired chat has, from the reply author's side. */
|
|
8046
|
-
var PAIRED_REPLY_AUTHORS = ["sender", "recipient"];
|
|
8047
|
-
/** A picture sent inline has no line worth quoting. */
|
|
8048
|
-
var INLINE_PICTURE = /^data:image\//i;
|
|
8049
|
-
/**
|
|
8050
|
-
* A line of a message to quote: plain text on one line (line breaks and runs of spaces become one space), without
|
|
8051
|
-
* control, invisible or direction characters (WISP 400, as for names), at most `REPLY_LIMITS.snippet` code points,
|
|
8052
|
-
* the last of them an ellipsis when it was cut.
|
|
8053
|
-
*/
|
|
8054
|
-
function replySnippet(text) {
|
|
8055
|
-
if (INLINE_PICTURE.test(text.trimStart())) return "🖼️ Picture";
|
|
8056
|
-
const clean = sanitizeDisplayText(text.slice(0, REPLY_LIMITS.raw * 2).replace(/\s+/gu, " "), REPLY_LIMITS.snippet + 1) ?? "";
|
|
8057
|
-
const points = [...clean];
|
|
8058
|
-
return points.length > REPLY_LIMITS.snippet ? `${points.slice(0, REPLY_LIMITS.snippet - 1).join("").trimEnd()}…` : clean;
|
|
8059
|
-
}
|
|
8060
|
-
/**
|
|
8061
|
-
* A reply as a receiver takes it: an object with an id of this chat's shape, a line (cleaned and cut again here,
|
|
8062
|
-
* whatever the sender did) and an author `author` accepts. Anything else is no reply: the message stays, without it.
|
|
8063
|
-
*/
|
|
8064
|
-
function readReply(raw, author) {
|
|
8065
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
8066
|
-
const { i, s, f, b } = raw;
|
|
8067
|
-
if (typeof i !== "string" || !REPLY_ID.test(i)) return void 0;
|
|
8068
|
-
if (typeof s !== "string" || s.length > REPLY_LIMITS.raw) return void 0;
|
|
8069
|
-
if (typeof f !== "string" || !author(f)) return void 0;
|
|
8070
|
-
return {
|
|
8071
|
-
i,
|
|
8072
|
-
s: replySnippet(s),
|
|
8073
|
-
f,
|
|
8074
|
-
...typeof b === "string" && BUTTON_ID.test(b) && { b }
|
|
6859
|
+
i,
|
|
6860
|
+
s: replySnippet(s),
|
|
6861
|
+
f,
|
|
6862
|
+
...typeof b === "string" && BUTTON_ID.test(b) && { b }
|
|
8075
6863
|
};
|
|
8076
6864
|
}
|
|
8077
6865
|
/** Who may have written the original in a paired chat. */
|
|
@@ -8429,7 +7217,7 @@ function previewImageSize(value) {
|
|
|
8429
7217
|
if (binary.length > LINK_PREVIEW_LIMITS.imageBytes) return;
|
|
8430
7218
|
const bytes = new Uint8Array(binary.length);
|
|
8431
7219
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
8432
|
-
return jpegSize
|
|
7220
|
+
return jpegSize(bytes);
|
|
8433
7221
|
}
|
|
8434
7222
|
/** The thumbnail if it is one Ghostly shows: a JPEG data URL within the limits whose header declares a sane size. */
|
|
8435
7223
|
function sanitizePreviewImage(value) {
|
|
@@ -8590,500 +7378,6 @@ var RateWindow = class {
|
|
|
8590
7378
|
}
|
|
8591
7379
|
};
|
|
8592
7380
|
//#endregion
|
|
8593
|
-
//#region ../core/src/video.ts
|
|
8594
|
-
var VIDEO_LIMITS = {
|
|
8595
|
-
/** The poster's bytes: with the rest of an offer, a frame stays far below the 60 KiB a session carries. */
|
|
8596
|
-
maxPosterBytes: 12288,
|
|
8597
|
-
/** The poster's longest side, in pixels, as the sender draws it. */
|
|
8598
|
-
posterEdge: 320,
|
|
8599
|
-
/** Ten hours: longer is a mistake, not a video. */
|
|
8600
|
-
maxDurationMs: 36e6,
|
|
8601
|
-
/** Pixels on either side (8K and a bit). */
|
|
8602
|
-
maxEdge: 16384
|
|
8603
|
-
};
|
|
8604
|
-
/**
|
|
8605
|
-
* Video a player may be handed as it is. None of these runs anything in the page's origin, so received bytes
|
|
8606
|
-
* of these types are served with their type (see `safeBlobType`): WebKit will not play what is typed as bytes.
|
|
8607
|
-
*/
|
|
8608
|
-
var PLAYABLE_VIDEO = /^video\/(mp4|webm|quicktime|ogg|x-m4v)$/;
|
|
8609
|
-
/** Whether a file is a video, by its type: shown in a video bubble whether or not it came with a description. */
|
|
8610
|
-
function isVideoMime(mime) {
|
|
8611
|
-
return mime.split(";")[0].trim().toLowerCase().startsWith("video/");
|
|
8612
|
-
}
|
|
8613
|
-
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
8614
|
-
var JPEG_START = [
|
|
8615
|
-
255,
|
|
8616
|
-
216,
|
|
8617
|
-
255
|
|
8618
|
-
];
|
|
8619
|
-
/**
|
|
8620
|
-
* A peer's `video` description, or undefined when it is not one. It never refuses the file. A poster that is
|
|
8621
|
-
* too large or not a JPEG is dropped on its own; the length and size are kept.
|
|
8622
|
-
*/
|
|
8623
|
-
function parseVideoMeta(value, mime) {
|
|
8624
|
-
if (mime !== void 0 && !isVideoMime(mime)) return void 0;
|
|
8625
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
8626
|
-
const { duration, width, height, poster } = value;
|
|
8627
|
-
if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VIDEO_LIMITS.maxDurationMs) return void 0;
|
|
8628
|
-
const edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= VIDEO_LIMITS.maxEdge;
|
|
8629
|
-
if (!edge(width) || !edge(height)) return void 0;
|
|
8630
|
-
const meta = {
|
|
8631
|
-
duration,
|
|
8632
|
-
width,
|
|
8633
|
-
height
|
|
8634
|
-
};
|
|
8635
|
-
if (typeof poster === "string" && poster.length <= Math.ceil(VIDEO_LIMITS.maxPosterBytes * 4 / 3) && BASE64URL.test(poster)) try {
|
|
8636
|
-
const bytes = fromBase64Url(poster);
|
|
8637
|
-
if (bytes.length <= VIDEO_LIMITS.maxPosterBytes && JPEG_START.every((b, i) => bytes[i] === b)) meta.poster = poster;
|
|
8638
|
-
} catch {}
|
|
8639
|
-
return meta;
|
|
8640
|
-
}
|
|
8641
|
-
/** `0:07`, `12:45`, `1:02:03`: as a player shows a video's length. */
|
|
8642
|
-
function formatVideoDuration(ms) {
|
|
8643
|
-
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
|
|
8644
|
-
const hours = Math.floor(total / 3600), minutes = Math.floor(total % 3600 / 60), seconds = String(total % 60).padStart(2, "0");
|
|
8645
|
-
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
|
|
8646
|
-
}
|
|
8647
|
-
//#endregion
|
|
8648
|
-
//#region ../core/src/voice.ts
|
|
8649
|
-
var VOICE_LIMITS = {
|
|
8650
|
-
/** Fifteen minutes: at the bitrate recorded (32 kbit/s) about 3.6 MB, well inside a held item's 8 MiB. */
|
|
8651
|
-
maxDurationMs: 9e5,
|
|
8652
|
-
/** Bars drawn for a recording. */
|
|
8653
|
-
bars: 64,
|
|
8654
|
-
/** Bars accepted from a peer. */
|
|
8655
|
-
maxBars: 128,
|
|
8656
|
-
/** Asked of the recorder; opus speech is clear well below it. */
|
|
8657
|
-
audioBitsPerSecond: 32e3
|
|
8658
|
-
};
|
|
8659
|
-
/**
|
|
8660
|
-
* Audio a player may be handed as it is. None of these runs anything in the page's origin,
|
|
8661
|
-
* so received bytes of these types are served with their type (see `safeBlobType`).
|
|
8662
|
-
*/
|
|
8663
|
-
var PLAYABLE_AUDIO = /^audio\/(webm|ogg|opus|mp4|mpeg|mp3|aac|x-m4a|wav|x-wav|wave|flac|x-flac)$/;
|
|
8664
|
-
/** The part of a type before its parameters, in lower case: `audio/webm;codecs=opus` → `audio/webm`. */
|
|
8665
|
-
function baseMime(mime) {
|
|
8666
|
-
return mime.split(";")[0].trim().toLowerCase();
|
|
8667
|
-
}
|
|
8668
|
-
/**
|
|
8669
|
-
* A peer's `voice` description, or undefined when it is not one. It never refuses the file:
|
|
8670
|
-
* a bad description only means the file is shown as a file.
|
|
8671
|
-
*/
|
|
8672
|
-
function parseVoiceMeta(value, mime) {
|
|
8673
|
-
if (mime !== void 0 && !PLAYABLE_AUDIO.test(baseMime(mime))) return void 0;
|
|
8674
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
8675
|
-
const { duration, peaks } = value;
|
|
8676
|
-
if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 || duration > VOICE_LIMITS.maxDurationMs + 5e3) return void 0;
|
|
8677
|
-
if (!Array.isArray(peaks) || peaks.length === 0 || peaks.length > VOICE_LIMITS.maxBars) return void 0;
|
|
8678
|
-
if (!peaks.every((peak) => typeof peak === "number" && Number.isInteger(peak) && peak >= 0 && peak <= 255)) return void 0;
|
|
8679
|
-
return {
|
|
8680
|
-
duration,
|
|
8681
|
-
peaks: [...peaks]
|
|
8682
|
-
};
|
|
8683
|
-
}
|
|
8684
|
-
/**
|
|
8685
|
-
* How the level is metered while recording (apps/ui/src/lib/voiceRecorder.ts): every `intervalMs`, the RMS of the last
|
|
8686
|
-
* `window` samples an AnalyserNode holds (at a 48 kHz context, about 21 ms of sound).
|
|
8687
|
-
*/
|
|
8688
|
-
var VOICE_METER = {
|
|
8689
|
-
intervalMs: 50,
|
|
8690
|
-
window: 1024,
|
|
8691
|
-
windowRate: 48e3
|
|
8692
|
-
};
|
|
8693
|
-
/** One reading of the meter, 0-1: the RMS of the samples, lifted because speech sits low on a linear scale. */
|
|
8694
|
-
function voiceLevel(samples) {
|
|
8695
|
-
if (samples.length === 0) return 0;
|
|
8696
|
-
let sum = 0;
|
|
8697
|
-
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
|
|
8698
|
-
return Math.min(1, Math.sqrt(sum / samples.length) * 4);
|
|
8699
|
-
}
|
|
8700
|
-
/**
|
|
8701
|
-
* The readings the recorder would have taken of this sound (mono, -1 to 1), for a recording made elsewhere: one
|
|
8702
|
-
* every `intervalMs` of it, each over the window that ends there, as long in time as the analyser's.
|
|
8703
|
-
*/
|
|
8704
|
-
function voiceLevelsOf(mono, sampleRate) {
|
|
8705
|
-
if (!(sampleRate > 0) || mono.length === 0) return [];
|
|
8706
|
-
const window = Math.max(1, Math.round(VOICE_METER.window * sampleRate / VOICE_METER.windowRate));
|
|
8707
|
-
const levels = [];
|
|
8708
|
-
for (let step = 1;; step++) {
|
|
8709
|
-
const end = Math.round(step * VOICE_METER.intervalMs * sampleRate / 1e3);
|
|
8710
|
-
if (end > mono.length) break;
|
|
8711
|
-
levels.push(voiceLevel(mono.subarray(Math.max(0, end - window), end)));
|
|
8712
|
-
}
|
|
8713
|
-
return levels;
|
|
8714
|
-
}
|
|
8715
|
-
/** The waveform sent with a recording made elsewhere, drawn as the recorder draws its own. */
|
|
8716
|
-
function voicePeaksOf(mono, sampleRate, bars = VOICE_LIMITS.bars) {
|
|
8717
|
-
return downsamplePeaks(voiceLevelsOf(mono, sampleRate), bars);
|
|
8718
|
-
}
|
|
8719
|
-
/** Below this a recording is silence, and is drawn flat rather than stretched to fill the bars. */
|
|
8720
|
-
var QUIET = .02;
|
|
8721
|
-
/**
|
|
8722
|
-
* Bars from the loudness measured while recording (RMS, 0-1, oldest first): the loudest
|
|
8723
|
-
* sample of each stretch, scaled so the loudest bar is full height.
|
|
8724
|
-
*/
|
|
8725
|
-
function downsamplePeaks(levels, bars = VOICE_LIMITS.bars) {
|
|
8726
|
-
const count = Math.max(1, Math.min(VOICE_LIMITS.maxBars, Math.floor(bars)));
|
|
8727
|
-
const clean = Array.from(levels, (level) => Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0);
|
|
8728
|
-
if (clean.length === 0) return new Array(count).fill(0);
|
|
8729
|
-
const loudest = Math.max(QUIET, ...clean);
|
|
8730
|
-
const out = [];
|
|
8731
|
-
for (let bar = 0; bar < count; bar++) {
|
|
8732
|
-
const from = Math.floor(bar * clean.length / count);
|
|
8733
|
-
const to = Math.max(from + 1, Math.floor((bar + 1) * clean.length / count));
|
|
8734
|
-
let peak = 0;
|
|
8735
|
-
for (let i = from; i < to && i < clean.length; i++) peak = Math.max(peak, clean[i]);
|
|
8736
|
-
out.push(Math.round(peak / loudest * 255));
|
|
8737
|
-
}
|
|
8738
|
-
return out;
|
|
8739
|
-
}
|
|
8740
|
-
/** `0:07`, `12:45`: minutes and seconds, as a player shows them. */
|
|
8741
|
-
function formatVoiceDuration(ms) {
|
|
8742
|
-
const total = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1e3));
|
|
8743
|
-
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
|
8744
|
-
}
|
|
8745
|
-
/** The line a file message shows in the chat list and in notifications. */
|
|
8746
|
-
function fileMessageText(file) {
|
|
8747
|
-
if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)})`;
|
|
8748
|
-
if (file.video) return `🎬 Video (${formatVideoDuration(file.video.duration)})`;
|
|
8749
|
-
return `📎 ${file.name}`;
|
|
8750
|
-
}
|
|
8751
|
-
//#endregion
|
|
8752
|
-
//#region ../core/src/files.ts
|
|
8753
|
-
var FILE_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
8754
|
-
var MIME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/i;
|
|
8755
|
-
var MAX_NAME_LENGTH = 200;
|
|
8756
|
-
/**
|
|
8757
|
-
* Invisible and direction-changing characters: bidi overrides turn
|
|
8758
|
-
* "invoice\u202Efdp.exe" into what reads as "invoiceexe.pdf", zero-width ones hide
|
|
8759
|
-
* text. Cf covers both; line and paragraph separators break the layout.
|
|
8760
|
-
*/
|
|
8761
|
-
var INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
8762
|
-
/**
|
|
8763
|
-
* Names Windows keeps for devices, whatever the case, with any extension, and with spaces before the dot:
|
|
8764
|
-
* "CON", "nul.txt", "COM1 .log" open a device there, not a file.
|
|
8765
|
-
*/
|
|
8766
|
-
var WINDOWS_DEVICE = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])\s*(\.|$)/i;
|
|
8767
|
-
/**
|
|
8768
|
-
* At most `MAX_NAME_LENGTH` characters. A longer name loses the end of its stem, not its extension (up to 15
|
|
8769
|
-
* characters after the last dot): "…report.pdf" stays a PDF to the system it is saved on.
|
|
8770
|
-
*/
|
|
8771
|
-
function shorten(name) {
|
|
8772
|
-
const chars = [...name];
|
|
8773
|
-
if (chars.length <= MAX_NAME_LENGTH) return name;
|
|
8774
|
-
const extension = [.../\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? ""];
|
|
8775
|
-
return chars.slice(0, MAX_NAME_LENGTH - extension.length).join("").trimEnd() + extension.join("");
|
|
8776
|
-
}
|
|
8777
|
-
/** A file name is display text and a download suggestion, never a path. */
|
|
8778
|
-
function sanitizeFileName(name) {
|
|
8779
|
-
const clean = shorten(name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "").trim());
|
|
8780
|
-
if (WINDOWS_DEVICE.test(clean)) return shorten("_" + clean);
|
|
8781
|
-
return clean || "file";
|
|
8782
|
-
}
|
|
8783
|
-
function sanitizeMime(mime) {
|
|
8784
|
-
return MIME.test(mime) ? mime.toLowerCase() : "application/octet-stream";
|
|
8785
|
-
}
|
|
8786
|
-
/** A size for people: B, KB, MB, GB, TB (powers of 1024), one decimal from KB up. */
|
|
8787
|
-
function formatFileSize(bytes) {
|
|
8788
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
8789
|
-
const units = [
|
|
8790
|
-
"KB",
|
|
8791
|
-
"MB",
|
|
8792
|
-
"GB",
|
|
8793
|
-
"TB"
|
|
8794
|
-
];
|
|
8795
|
-
let value = bytes / 1024, unit = 0;
|
|
8796
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
8797
|
-
value /= 1024;
|
|
8798
|
-
unit++;
|
|
8799
|
-
}
|
|
8800
|
-
return `${value.toFixed(1)} ${units[unit]}`;
|
|
8801
|
-
}
|
|
8802
|
-
/** Raster images a UI may show inline. SVG is left out on purpose: it can carry scripts. */
|
|
8803
|
-
var PREVIEWABLE_IMAGE = /^image\/(png|jpe?g|gif|webp)$/;
|
|
8804
|
-
/**
|
|
8805
|
-
* The type received bytes are served with. The peer picks the announced type,
|
|
8806
|
-
* and a blob: URL typed text/html or image/svg+xml would run in the app's
|
|
8807
|
-
* origin, so anything but a previewable image or playable audio or video is opaque bytes.
|
|
8808
|
-
* Audio and video keep their type because WebKit will not play what is typed as bytes.
|
|
8809
|
-
*/
|
|
8810
|
-
function safeBlobType(mime) {
|
|
8811
|
-
const clean = sanitizeMime(mime);
|
|
8812
|
-
return PREVIEWABLE_IMAGE.test(clean) || PLAYABLE_AUDIO.test(clean) || PLAYABLE_VIDEO.test(clean) ? clean : "application/octet-stream";
|
|
8813
|
-
}
|
|
8814
|
-
var FileTransfers = class {
|
|
8815
|
-
channel;
|
|
8816
|
-
events;
|
|
8817
|
-
nextStreamId = 1;
|
|
8818
|
-
incoming = /* @__PURE__ */ new Map();
|
|
8819
|
-
outgoing = /* @__PURE__ */ new Map();
|
|
8820
|
-
constructor(channel, events) {
|
|
8821
|
-
this.channel = channel;
|
|
8822
|
-
this.events = events;
|
|
8823
|
-
}
|
|
8824
|
-
/** Streams a file to the peer. Resolves once every byte is handed to the channel. */
|
|
8825
|
-
async send(file, source) {
|
|
8826
|
-
if (file.size > LIMITS.maxFileBytes) throw new Error("File is too large");
|
|
8827
|
-
const id = this.nextStreamId++;
|
|
8828
|
-
const state = {
|
|
8829
|
-
fileId: file.id,
|
|
8830
|
-
cancelled: false
|
|
8831
|
-
};
|
|
8832
|
-
this.outgoing.set(id, state);
|
|
8833
|
-
try {
|
|
8834
|
-
this.channel.send(encodeControl({
|
|
8835
|
-
t: "file",
|
|
8836
|
-
id,
|
|
8837
|
-
f: file.id,
|
|
8838
|
-
ts: file.timestamp,
|
|
8839
|
-
n: sanitizeFileName(file.name),
|
|
8840
|
-
s: file.size,
|
|
8841
|
-
m: sanitizeMime(file.mime)
|
|
8842
|
-
}));
|
|
8843
|
-
let sent = 0;
|
|
8844
|
-
const counted = async function* () {
|
|
8845
|
-
for await (const part of source) {
|
|
8846
|
-
sent += part.length;
|
|
8847
|
-
yield part;
|
|
8848
|
-
this.events.onProgress?.(file.id, sent, "out");
|
|
8849
|
-
}
|
|
8850
|
-
}.call(this);
|
|
8851
|
-
await sendBody(this.channel, CHUNK_KIND.fileBody, id, counted, () => state.cancelled);
|
|
8852
|
-
if (state.cancelled) throw new Error("The peer refused the file");
|
|
8853
|
-
if (sent !== file.size) throw new Error("File changed while it was being sent");
|
|
8854
|
-
this.events.onComplete?.(file.id, "out");
|
|
8855
|
-
} catch (error) {
|
|
8856
|
-
if (!state.cancelled) try {
|
|
8857
|
-
this.channel.send(encodeControl({
|
|
8858
|
-
t: "rst",
|
|
8859
|
-
id,
|
|
8860
|
-
d: "f",
|
|
8861
|
-
e: "aborted"
|
|
8862
|
-
}));
|
|
8863
|
-
} catch {}
|
|
8864
|
-
this.events.onFailed?.(file.id, error instanceof Error ? error.message : String(error), "out");
|
|
8865
|
-
throw error;
|
|
8866
|
-
} finally {
|
|
8867
|
-
this.outgoing.delete(id);
|
|
8868
|
-
}
|
|
8869
|
-
}
|
|
8870
|
-
handleFile(frame) {
|
|
8871
|
-
if (this.incoming.has(frame.id)) return;
|
|
8872
|
-
const refuse = (reason) => {
|
|
8873
|
-
try {
|
|
8874
|
-
this.channel.send(encodeControl({
|
|
8875
|
-
t: "rst",
|
|
8876
|
-
id: frame.id,
|
|
8877
|
-
d: "f",
|
|
8878
|
-
e: reason
|
|
8879
|
-
}));
|
|
8880
|
-
} catch {}
|
|
8881
|
-
};
|
|
8882
|
-
if (!FILE_ID.test(frame.f)) return refuse("invalid file id");
|
|
8883
|
-
if (frame.s > LIMITS.maxFileBytes) return refuse("file too large");
|
|
8884
|
-
if (this.incoming.size >= LIMITS.maxIncomingFilesPerPeer) return refuse("too many transfers");
|
|
8885
|
-
const file = {
|
|
8886
|
-
id: frame.f,
|
|
8887
|
-
name: sanitizeFileName(frame.n),
|
|
8888
|
-
size: frame.s,
|
|
8889
|
-
mime: sanitizeMime(frame.m),
|
|
8890
|
-
timestamp: frame.ts
|
|
8891
|
-
};
|
|
8892
|
-
const sink = this.events.onIncoming(file);
|
|
8893
|
-
if (!sink || typeof sink === "string") return refuse(sink || "refused");
|
|
8894
|
-
const entry = {
|
|
8895
|
-
file,
|
|
8896
|
-
sink,
|
|
8897
|
-
received: 0,
|
|
8898
|
-
queue: Promise.resolve()
|
|
8899
|
-
};
|
|
8900
|
-
this.incoming.set(frame.id, entry);
|
|
8901
|
-
this.armIdleTimer(frame.id, entry);
|
|
8902
|
-
}
|
|
8903
|
-
handleChunk(chunk) {
|
|
8904
|
-
const entry = this.incoming.get(chunk.id);
|
|
8905
|
-
if (!entry) return;
|
|
8906
|
-
entry.received += chunk.payload.length;
|
|
8907
|
-
if (entry.received > entry.file.size) return this.failIncoming(chunk.id, "more data than announced", true);
|
|
8908
|
-
if (chunk.payload.length > 0) {
|
|
8909
|
-
const payload = chunk.payload.slice();
|
|
8910
|
-
entry.queue = entry.queue.then(() => entry.sink.write(payload));
|
|
8911
|
-
this.events.onProgress?.(entry.file.id, entry.received, "in");
|
|
8912
|
-
}
|
|
8913
|
-
if (!chunk.end) return this.armIdleTimer(chunk.id, entry);
|
|
8914
|
-
if (entry.received !== entry.file.size) return this.failIncoming(chunk.id, "incomplete file", false);
|
|
8915
|
-
clearTimeout(entry.idleTimer);
|
|
8916
|
-
this.incoming.delete(chunk.id);
|
|
8917
|
-
entry.queue.then(() => entry.sink.close()).then(() => this.events.onComplete?.(entry.file.id, "in"), () => {
|
|
8918
|
-
entry.sink.abort();
|
|
8919
|
-
this.events.onFailed?.(entry.file.id, "could not store the file", "in");
|
|
8920
|
-
});
|
|
8921
|
-
}
|
|
8922
|
-
handleReset(frame) {
|
|
8923
|
-
if (this.incoming.has(frame.id)) this.failIncoming(frame.id, frame.e || "cancelled by the sender", false);
|
|
8924
|
-
const sending = this.outgoing.get(frame.id);
|
|
8925
|
-
if (sending) sending.cancelled = true;
|
|
8926
|
-
}
|
|
8927
|
-
/** The link went away: nothing in flight can finish. */
|
|
8928
|
-
closeAll() {
|
|
8929
|
-
for (const id of [...this.incoming.keys()]) this.failIncoming(id, "connection lost", false);
|
|
8930
|
-
for (const sending of this.outgoing.values()) sending.cancelled = true;
|
|
8931
|
-
}
|
|
8932
|
-
armIdleTimer(id, entry) {
|
|
8933
|
-
clearTimeout(entry.idleTimer);
|
|
8934
|
-
entry.idleTimer = setTimeout(() => this.failIncoming(id, "transfer stalled", true), LIMITS.bodyIdleTimeoutMs);
|
|
8935
|
-
}
|
|
8936
|
-
failIncoming(id, reason, notifyPeer) {
|
|
8937
|
-
const entry = this.incoming.get(id);
|
|
8938
|
-
if (!entry) return;
|
|
8939
|
-
clearTimeout(entry.idleTimer);
|
|
8940
|
-
this.incoming.delete(id);
|
|
8941
|
-
entry.sink.abort();
|
|
8942
|
-
if (notifyPeer) try {
|
|
8943
|
-
this.channel.send(encodeControl({
|
|
8944
|
-
t: "rst",
|
|
8945
|
-
id,
|
|
8946
|
-
d: "f",
|
|
8947
|
-
e: reason
|
|
8948
|
-
}));
|
|
8949
|
-
} catch {}
|
|
8950
|
-
this.events.onFailed?.(entry.file.id, reason, "in");
|
|
8951
|
-
}
|
|
8952
|
-
};
|
|
8953
|
-
//#endregion
|
|
8954
|
-
//#region ../core/src/image.ts
|
|
8955
|
-
var IMAGE_LIMITS = {
|
|
8956
|
-
/** Pixels on either side: the most a JPEG can say, well past what anyone sends as a picture. */
|
|
8957
|
-
maxEdge: 65535,
|
|
8958
|
-
/** How much of a file is read for its size: past a JPEG's EXIF, thumbnails and colour profile. */
|
|
8959
|
-
headBytes: 262144
|
|
8960
|
-
};
|
|
8961
|
-
/** Whether a file is a picture, by its type. */
|
|
8962
|
-
function isImageMime(mime) {
|
|
8963
|
-
return mime.split(";")[0].trim().toLowerCase().startsWith("image/");
|
|
8964
|
-
}
|
|
8965
|
-
var edge = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0 && n <= IMAGE_LIMITS.maxEdge;
|
|
8966
|
-
/** A peer's `image` description, or undefined when it is not one. It never refuses the file. */
|
|
8967
|
-
function parseImageMeta(value, mime) {
|
|
8968
|
-
if (mime !== void 0 && !isImageMime(mime)) return void 0;
|
|
8969
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
8970
|
-
const { width, height } = value;
|
|
8971
|
-
return edge(width) && edge(height) ? {
|
|
8972
|
-
width,
|
|
8973
|
-
height
|
|
8974
|
-
} : void 0;
|
|
8975
|
-
}
|
|
8976
|
-
var u16be = (b, at) => b[at] << 8 | b[at + 1];
|
|
8977
|
-
var u16le = (b, at) => b[at] | b[at + 1] << 8;
|
|
8978
|
-
var u24le = (b, at) => b[at] | b[at + 1] << 8 | b[at + 2] << 16;
|
|
8979
|
-
var u32be = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
|
|
8980
|
-
var ascii = (b, at, text) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
|
|
8981
|
-
var sized = (width, height) => edge(width) && edge(height) ? {
|
|
8982
|
-
width,
|
|
8983
|
-
height
|
|
8984
|
-
} : void 0;
|
|
8985
|
-
/** SOFn markers: every frame type but DHT (C4), JPG (C8) and DAC (CC). */
|
|
8986
|
-
var JPEG_FRAME = /* @__PURE__ */ new Set([
|
|
8987
|
-
192,
|
|
8988
|
-
193,
|
|
8989
|
-
194,
|
|
8990
|
-
195,
|
|
8991
|
-
197,
|
|
8992
|
-
198,
|
|
8993
|
-
199,
|
|
8994
|
-
201,
|
|
8995
|
-
202,
|
|
8996
|
-
203,
|
|
8997
|
-
205,
|
|
8998
|
-
206,
|
|
8999
|
-
207
|
|
9000
|
-
]);
|
|
9001
|
-
/** The EXIF orientation in an APP1 segment's TIFF block (1 when it says none). */
|
|
9002
|
-
function exifOrientation(b, tiff, end) {
|
|
9003
|
-
if (tiff + 8 > end) return 1;
|
|
9004
|
-
const little = ascii(b, tiff, "II");
|
|
9005
|
-
if (!little && !ascii(b, tiff, "MM")) return 1;
|
|
9006
|
-
const u16 = (at) => little ? u16le(b, at) : u16be(b, at);
|
|
9007
|
-
const u32 = (at) => little ? u16le(b, at) + u16le(b, at + 2) * 65536 : u32be(b, at);
|
|
9008
|
-
if (u16(tiff + 2) !== 42) return 1;
|
|
9009
|
-
const ifd = tiff + u32(tiff + 4);
|
|
9010
|
-
if (ifd + 2 > end) return 1;
|
|
9011
|
-
const count = u16(ifd);
|
|
9012
|
-
for (let i = 0; i < count; i++) {
|
|
9013
|
-
const entry = ifd + 2 + i * 12;
|
|
9014
|
-
if (entry + 12 > end) return 1;
|
|
9015
|
-
if (u16(entry) === 274) {
|
|
9016
|
-
const value = u16(entry + 8);
|
|
9017
|
-
return value >= 1 && value <= 8 ? value : 1;
|
|
9018
|
-
}
|
|
9019
|
-
}
|
|
9020
|
-
return 1;
|
|
9021
|
-
}
|
|
9022
|
-
function jpegSize(b) {
|
|
9023
|
-
let at = 2, orientation = 1;
|
|
9024
|
-
while (at + 4 <= b.length) {
|
|
9025
|
-
if (b[at] !== 255) return void 0;
|
|
9026
|
-
const marker = b[at + 1];
|
|
9027
|
-
if (marker === 255) {
|
|
9028
|
-
at++;
|
|
9029
|
-
continue;
|
|
9030
|
-
}
|
|
9031
|
-
if (marker === 1 || marker >= 208 && marker <= 215) {
|
|
9032
|
-
at += 2;
|
|
9033
|
-
continue;
|
|
9034
|
-
}
|
|
9035
|
-
if (marker === 217 || marker === 218) return void 0;
|
|
9036
|
-
const length = u16be(b, at + 2);
|
|
9037
|
-
if (length < 2) return void 0;
|
|
9038
|
-
const end = Math.min(b.length, at + 2 + length);
|
|
9039
|
-
if (marker === 225 && ascii(b, at + 4, "Exif\0\0")) orientation = exifOrientation(b, at + 10, end);
|
|
9040
|
-
if (JPEG_FRAME.has(marker)) {
|
|
9041
|
-
if (at + 9 > b.length) return void 0;
|
|
9042
|
-
const height = u16be(b, at + 5), width = u16be(b, at + 7);
|
|
9043
|
-
return orientation >= 5 ? sized(height, width) : sized(width, height);
|
|
9044
|
-
}
|
|
9045
|
-
at += 2 + length;
|
|
9046
|
-
}
|
|
9047
|
-
}
|
|
9048
|
-
function webpSize(b) {
|
|
9049
|
-
if (b.length < 30) return void 0;
|
|
9050
|
-
if (ascii(b, 12, "VP8 ")) {
|
|
9051
|
-
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42) return void 0;
|
|
9052
|
-
return sized(u16le(b, 26) & 16383, u16le(b, 28) & 16383);
|
|
9053
|
-
}
|
|
9054
|
-
if (ascii(b, 12, "VP8L")) {
|
|
9055
|
-
if (b[20] !== 47) return void 0;
|
|
9056
|
-
const bits = b[21] | b[22] << 8 | b[23] << 16 | b[24] << 24;
|
|
9057
|
-
return sized((bits & 16383) + 1, (bits >>> 14 & 16383) + 1);
|
|
9058
|
-
}
|
|
9059
|
-
if (ascii(b, 12, "VP8X")) return sized(u24le(b, 24) + 1, u24le(b, 27) + 1);
|
|
9060
|
-
}
|
|
9061
|
-
/**
|
|
9062
|
-
* A picture's size as it is shown, read from its first bytes (PNG, JPEG, GIF, WebP), or undefined when they do not
|
|
9063
|
-
* say. Only the header is read: a JPEG whose frame header sits past `IMAGE_LIMITS.headBytes` goes without.
|
|
9064
|
-
*/
|
|
9065
|
-
function imageSize(head) {
|
|
9066
|
-
const b = head;
|
|
9067
|
-
try {
|
|
9068
|
-
if (b.length >= 24 && b[0] === 137 && ascii(b, 1, "PNG\r\n\n") && ascii(b, 12, "IHDR")) return sized(u32be(b, 16), u32be(b, 20));
|
|
9069
|
-
if (b.length >= 10 && (ascii(b, 0, "GIF87a") || ascii(b, 0, "GIF89a"))) return sized(u16le(b, 6), u16le(b, 8));
|
|
9070
|
-
if (b.length >= 4 && b[0] === 255 && b[1] === 216 && b[2] === 255) return jpegSize(b);
|
|
9071
|
-
if (b.length >= 16 && ascii(b, 0, "RIFF") && ascii(b, 8, "WEBP")) return webpSize(b);
|
|
9072
|
-
} catch {}
|
|
9073
|
-
}
|
|
9074
|
-
/**
|
|
9075
|
-
* The description a picture is sent with, read from the start of the file, or undefined when it is not a picture
|
|
9076
|
-
* or its first bytes do not say (it then goes without one).
|
|
9077
|
-
*/
|
|
9078
|
-
async function readImageMeta(source, mime) {
|
|
9079
|
-
if (!isImageMime(mime)) return void 0;
|
|
9080
|
-
try {
|
|
9081
|
-
return imageSize(new Uint8Array(await source.slice(0, IMAGE_LIMITS.headBytes).arrayBuffer()));
|
|
9082
|
-
} catch {
|
|
9083
|
-
return;
|
|
9084
|
-
}
|
|
9085
|
-
}
|
|
9086
|
-
//#endregion
|
|
9087
7381
|
//#region ../core/src/dhtFiles.ts
|
|
9088
7382
|
/**
|
|
9089
7383
|
* A file's bubble on the DHT floor (WISP 403 § Files): while the contact is away, a file's offer (its name, size, type,
|
|
@@ -9177,6 +7471,13 @@ var DHT_EXPIRY_SKEW_MS = PKARR_FUTURE_SKEW_MS;
|
|
|
9177
7471
|
*/
|
|
9178
7472
|
var DHT_ISSUED_BACK_MS = 12e4;
|
|
9179
7473
|
var MAX_ATTEMPTS = 8;
|
|
7474
|
+
/**
|
|
7475
|
+
* Envelope sequences are set aside this many at a time (`DhtDeliveryState.sequence` is the highest set aside): a
|
|
7476
|
+
* keep-alive, every four minutes per chat, and one the relays' budget turns away, every 30 s on a profile idling at
|
|
7477
|
+
* it, then cost no disk write of their own. A reader only needs the sequence to grow, so those a restart skips cost
|
|
7478
|
+
* nothing; one the budget turned away is not used again, as it may have gone out on the DHT (the CLI puts there too).
|
|
7479
|
+
*/
|
|
7480
|
+
var SEQUENCE_BLOCK = 64;
|
|
9180
7481
|
/** The wait before publication attempt `attempts + 1` of a text, or of a receipt the contact still asks for. */
|
|
9181
7482
|
var backoff = (attempts) => Math.min(6e4, 4e3 * 2 ** attempts);
|
|
9182
7483
|
/** The contact's mailbox is read this often while either side is DHT-only, and otherwise… */
|
|
@@ -9230,6 +7531,10 @@ var emptyDhtDeliveryState = () => ({
|
|
|
9230
7531
|
var DhtDelivery = class DhtDelivery {
|
|
9231
7532
|
options;
|
|
9232
7533
|
state;
|
|
7534
|
+
/** The state as last saved: a save that would write the same row again is skipped. */
|
|
7535
|
+
saved;
|
|
7536
|
+
/** The sequence of the last envelope signed here; the state's is the highest set aside (`SEQUENCE_BLOCK`). */
|
|
7537
|
+
sequence;
|
|
9233
7538
|
mode;
|
|
9234
7539
|
running = false;
|
|
9235
7540
|
ticking = false;
|
|
@@ -9240,6 +7545,7 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9240
7545
|
fastUntil = 0;
|
|
9241
7546
|
live = false;
|
|
9242
7547
|
active = false;
|
|
7548
|
+
startedAt = Date.now();
|
|
9243
7549
|
/** The next read was asked for (a refresh, a fresh packet of the contact): it is not a background one. */
|
|
9244
7550
|
urgent = false;
|
|
9245
7551
|
/** Reads left that are signaling: the contact is leaving DHT only, and the live link waits on them (`expect`). */
|
|
@@ -9284,6 +7590,8 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9284
7590
|
const { peerRejected: _peerRejected, ...state } = structuredClone(options.state ?? emptyDhtDeliveryState());
|
|
9285
7591
|
this.state = state;
|
|
9286
7592
|
this.mode = options.mode;
|
|
7593
|
+
this.sequence = state.sequence;
|
|
7594
|
+
if (options.state) this.saved = JSON.stringify(state);
|
|
9287
7595
|
this.unsealedOut = state.sequence > 0 && !options.credentials.peerKey;
|
|
9288
7596
|
this.from = identityFromSeedB64(options.params.seedB64).pubKeyZ32;
|
|
9289
7597
|
this.to = options.params.peerPubKeyZ32;
|
|
@@ -9372,7 +7680,11 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9372
7680
|
return operation;
|
|
9373
7681
|
}
|
|
9374
7682
|
async persist(next) {
|
|
9375
|
-
|
|
7683
|
+
const json = JSON.stringify(next);
|
|
7684
|
+
if (json !== this.saved) {
|
|
7685
|
+
await this.options.save(structuredClone(next));
|
|
7686
|
+
this.saved = json;
|
|
7687
|
+
}
|
|
9376
7688
|
this.state = next;
|
|
9377
7689
|
}
|
|
9378
7690
|
sealedKey(peer) {
|
|
@@ -9547,7 +7859,7 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9547
7859
|
}
|
|
9548
7860
|
packetError(message, expires, reply, edit) {
|
|
9549
7861
|
try {
|
|
9550
|
-
this.records(this.body(this.
|
|
7862
|
+
this.records(this.body(this.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], edit), this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
|
|
9551
7863
|
return null;
|
|
9552
7864
|
} catch (error) {
|
|
9553
7865
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -9555,7 +7867,7 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9555
7867
|
}
|
|
9556
7868
|
/** Whether a text with its hop count fits the packet and what a reader reads (`MAX_ENVELOPE_PLAINTEXT`). */
|
|
9557
7869
|
roomForHops(message, expires, reply, forwarded) {
|
|
9558
|
-
const body = this.body(this.
|
|
7870
|
+
const body = this.body(this.sequence + 1, Date.now(), expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", reply, [], void 0, forwarded);
|
|
9559
7871
|
try {
|
|
9560
7872
|
this.records(body, this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
|
|
9561
7873
|
} catch {
|
|
@@ -9634,7 +7946,7 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9634
7946
|
], expires = now + DHT_MESSAGE_TTL;
|
|
9635
7947
|
const hops = readForwarded(forwarded);
|
|
9636
7948
|
const fits = (element, withReply, withHops) => {
|
|
9637
|
-
const body = this.body(this.
|
|
7949
|
+
const body = this.body(this.sequence + 1, now, expires, message, this.state.receipt?.id ?? "abcdefghijklmnopqrstuv", withReply ? reply : void 0, [], void 0, withHops ? hops : void 0, element);
|
|
9638
7950
|
try {
|
|
9639
7951
|
this.records(body, this.options.credentials.peerKey ?? this.participation.pubKeyZ32);
|
|
9640
7952
|
} catch {
|
|
@@ -9823,13 +8135,14 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9823
8135
|
if (keepAlive && this.controlDue > now) return;
|
|
9824
8136
|
const expires = pending?.expires ?? now - 12e4 + CONTROL_TTL;
|
|
9825
8137
|
const issued = Math.min(now, Math.max(now - DHT_ISSUED_BACK_MS, expires - (pending ? DHT_MESSAGE_TTL : CONTROL_TTL), (pending?.message[1] ?? 0) - 3e4));
|
|
9826
|
-
const { body, records, reactions } = this.fitted(this.
|
|
8138
|
+
const { body, records, reactions } = this.fitted(this.sequence + 1, issued, expires, pending?.message ?? null, receipt?.id ?? null, pending?.reply, pending?.edit, pending?.forwarded, pending?.file);
|
|
9827
8139
|
const reactionsWereDue = this.reactionsDue;
|
|
9828
8140
|
this.reactionsDue = !reactions && !!this.options.reactions?.().length;
|
|
9829
8141
|
const identity = !reseal && this.state.peerPinned && this.state.peerPinned !== "can" && this.pinned()?.identity || this.identity;
|
|
8142
|
+
const sequence = body[1];
|
|
9830
8143
|
await this.persist({
|
|
9831
8144
|
...this.state,
|
|
9832
|
-
sequence:
|
|
8145
|
+
sequence: sequence > this.state.sequence ? sequence + SEQUENCE_BLOCK - 1 : this.state.sequence,
|
|
9833
8146
|
pending: pending ? {
|
|
9834
8147
|
...pending,
|
|
9835
8148
|
attempts: pending.attempts + 1,
|
|
@@ -9841,6 +8154,7 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9841
8154
|
next: now + backoff(receipt.attempts)
|
|
9842
8155
|
} : this.state.receipt
|
|
9843
8156
|
});
|
|
8157
|
+
this.sequence = sequence;
|
|
9844
8158
|
const before = {
|
|
9845
8159
|
lastPublish: this.lastPublish,
|
|
9846
8160
|
controlDue: this.controlDue
|
|
@@ -10175,11 +8489,20 @@ var DhtDelivery = class DhtDelivery {
|
|
|
10175
8489
|
get pollMs() {
|
|
10176
8490
|
if (this.options.pollMs) return this.options.pollMs;
|
|
10177
8491
|
const now = Date.now();
|
|
8492
|
+
const unused = this.unusedInviteAge(now);
|
|
8493
|
+
if (unused !== void 0 && !this.active && now >= this.fastUntil) return unusedInviteWait(unused, this.mode === "dht" ? DHT_POLL_MS : STREAM_POLL_MS);
|
|
10178
8494
|
if (this.mode === "dht" || this.state.peerMode === "dht" && now < this.leavingUntil || now < this.fastUntil) return DHT_POLL_MS;
|
|
10179
8495
|
if (this.live) return LIVE_POLL_MS;
|
|
10180
8496
|
if (this.state.pending && this.state.pending.expires > now) return DHT_POLL_MS;
|
|
10181
8497
|
return this.active ? ACTIVE_DHT_POLL_MS : STREAM_POLL_MS;
|
|
10182
8498
|
}
|
|
8499
|
+
/** How long ago this side's invite was made, while nobody used it and it is no longer awaited (`AWAITING_PEER_MS`). */
|
|
8500
|
+
unusedInviteAge(now) {
|
|
8501
|
+
const madeAt = this.options.inviteMadeAt;
|
|
8502
|
+
if (madeAt === void 0 || this.options.credentials.peerKey || this.state.peerSequence) return void 0;
|
|
8503
|
+
const age = now - Math.min(madeAt, this.startedAt);
|
|
8504
|
+
return age >= 6e5 ? age : void 0;
|
|
8505
|
+
}
|
|
10183
8506
|
schedule() {
|
|
10184
8507
|
if (!this.running || this.ticking) return;
|
|
10185
8508
|
if (this.timer) clearTimeout(this.timer);
|
|
@@ -13713,6 +12036,11 @@ var SERVICES_CAPABILITY = "services/1";
|
|
|
13713
12036
|
* confirmed byte, checked by digest (`pf-offer` … frames, `chatFiles.ts`). Without it, files/2 and 100 MiB.
|
|
13714
12037
|
*/
|
|
13715
12038
|
var FILES_CAPABILITY = "files/3";
|
|
12039
|
+
/**
|
|
12040
|
+
* A file's bytes as binary messages (WISP 501 § files/4): beside `files/3`, on a session whose channel carries binary
|
|
12041
|
+
* messages, a chunk travels as it is (`encodeFileData`, `chatFiles.ts`) and not as base64url text in a `pf-data` frame.
|
|
12042
|
+
*/
|
|
12043
|
+
var FILES_BINARY_CAPABILITY = "files/4";
|
|
13716
12044
|
/** The typing indicator: `paired-typing` frames on this session, 1:1 chats only (WISP 401 § Typing, `pairedTyping.ts`). */
|
|
13717
12045
|
var TYPING_CAPABILITY = "typing/1";
|
|
13718
12046
|
/** Reactions to messages: `paired-reaction` frames on this session, 1:1 chats only (WISP 401 § Reactions, `reactions.ts`). */
|
|
@@ -13752,6 +12080,7 @@ var KNOWN_SESSION_CAPABILITIES = [
|
|
|
13752
12080
|
CALLS_CAPABILITY,
|
|
13753
12081
|
SERVICES_CAPABILITY,
|
|
13754
12082
|
FILES_CAPABILITY,
|
|
12083
|
+
FILES_BINARY_CAPABILITY,
|
|
13755
12084
|
TYPING_CAPABILITY,
|
|
13756
12085
|
REACTIONS_CAPABILITY,
|
|
13757
12086
|
EDIT_CAPABILITY,
|
|
@@ -13848,6 +12177,8 @@ var SessionCapabilities = class {
|
|
|
13848
12177
|
* be matched across a person's chats; it travels only inside the encrypted session.
|
|
13849
12178
|
*/
|
|
13850
12179
|
var APP_FRAME = "paired-app";
|
|
12180
|
+
/** The largest `d`, as compact UTF-8 JSON. The session's 60 KiB cap on any frame still holds above it. */
|
|
12181
|
+
var APP_DATA_MAX_BYTES = 32768;
|
|
13851
12182
|
var DOMAIN = "ghostly-apps/1";
|
|
13852
12183
|
var APP_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
13853
12184
|
/** Semantic versioning 2.0.0, as a manifest's `version` is. */
|
|
@@ -14160,7 +12491,8 @@ function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
|
|
|
14160
12491
|
exp: Math.floor(now / 1e3) + TOKEN_SECONDS,
|
|
14161
12492
|
sub: subject
|
|
14162
12493
|
})}`;
|
|
14163
|
-
|
|
12494
|
+
const signature = p256.sign(utf8Encode(unsigned), fromBase64Url(keys.privateKey));
|
|
12495
|
+
return `vapid t=${unsigned}.${toBase64Url(signature)}, k=${keys.publicKey}`;
|
|
14164
12496
|
}
|
|
14165
12497
|
/**
|
|
14166
12498
|
* `plaintext` encrypted for a subscription (RFC 8291 with RFC 8188 `aes128gcm`, one record). `sender` fixes
|
|
@@ -14478,7 +12810,8 @@ var LINK_OR_MARKUP = /[a-z][a-z0-9+.-]*:\/\/|\bwww\.|\b(?:mailto|javascript|data
|
|
|
14478
12810
|
*/
|
|
14479
12811
|
function sanitizeTypingStatus(value) {
|
|
14480
12812
|
if (typeof value !== "string") return void 0;
|
|
14481
|
-
const
|
|
12813
|
+
const oneLine = value.slice(0, 160).replace(/\s+/g, " ");
|
|
12814
|
+
const clean = sanitizeDisplayText(oneLine, 40);
|
|
14482
12815
|
if (!clean || LINK_OR_MARKUP.test(clean)) return void 0;
|
|
14483
12816
|
return clean;
|
|
14484
12817
|
}
|
|
@@ -14687,6 +13020,11 @@ var FILE_LIMITS = {
|
|
|
14687
13020
|
chunkBytes: 16384,
|
|
14688
13021
|
/** Bytes a sender has sent and not seen confirmed: throughput is this per round trip. */
|
|
14689
13022
|
windowBytes: 1048576,
|
|
13023
|
+
/**
|
|
13024
|
+
* Bytes all of a chat's files have sent and not seen confirmed, together. Three files' windows at once were 3 MiB, and
|
|
13025
|
+
* as base64 text past what a session's receive queue holds (`SESSION_RECEIVE_PENDING`, 3.75 MiB).
|
|
13026
|
+
*/
|
|
13027
|
+
chatWindowBytes: 2097152,
|
|
14690
13028
|
/** A receiver asks its person before taking a file larger than this. */
|
|
14691
13029
|
askAboveBytes: 26214400,
|
|
14692
13030
|
/** Files taken without asking, per contact, while stored. Past it, every file is asked about. */
|
|
@@ -14722,7 +13060,7 @@ var FILE_LIMITS = {
|
|
|
14722
13060
|
resendBytes: 268435456
|
|
14723
13061
|
};
|
|
14724
13062
|
var ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
14725
|
-
var DIGEST
|
|
13063
|
+
var DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
14726
13064
|
var DATA = /^[A-Za-z0-9_-]+$/;
|
|
14727
13065
|
var FILE_FRAMES = /* @__PURE__ */ new Set([
|
|
14728
13066
|
"pf-offer",
|
|
@@ -14755,6 +13093,37 @@ function transferStalled(record, movedAt, restored, now) {
|
|
|
14755
13093
|
}
|
|
14756
13094
|
var key = (direction, id) => `${direction}:${id}`;
|
|
14757
13095
|
var isInt$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
13096
|
+
var FILE_DATA_OFFSET_BYTES = 8;
|
|
13097
|
+
/** The binary message for the bytes of file `id` at `offset`. Throws on what no receiver would take. */
|
|
13098
|
+
function encodeFileData(id, offset, data) {
|
|
13099
|
+
if (!ID.test(id) || !isInt$1(offset) || !data.length || data.length > FILE_LIMITS.chunkBytes) throw new Error("Invalid file data");
|
|
13100
|
+
const head = 2 + id.length;
|
|
13101
|
+
const out = new Uint8Array(head + FILE_DATA_OFFSET_BYTES + data.length);
|
|
13102
|
+
out[0] = 4;
|
|
13103
|
+
out[1] = id.length;
|
|
13104
|
+
for (let i = 0; i < id.length; i++) out[2 + i] = id.charCodeAt(i);
|
|
13105
|
+
const view = new DataView(out.buffer);
|
|
13106
|
+
view.setUint32(head, Math.floor(offset / 2 ** 32));
|
|
13107
|
+
view.setUint32(head + 4, offset % 2 ** 32);
|
|
13108
|
+
out.set(data, head + FILE_DATA_OFFSET_BYTES);
|
|
13109
|
+
return out;
|
|
13110
|
+
}
|
|
13111
|
+
/** What a binary message from the contact says, or null when it is not a well-formed file data message. `data` is a view of `message`. */
|
|
13112
|
+
function decodeFileData(message) {
|
|
13113
|
+
if (message.length < 2 || message[0] !== 4) return null;
|
|
13114
|
+
const head = 2 + message[1], body = head + FILE_DATA_OFFSET_BYTES;
|
|
13115
|
+
if (message.length <= body || message.length - body > FILE_LIMITS.chunkBytes) return null;
|
|
13116
|
+
const id = String.fromCharCode(...message.subarray(2, head));
|
|
13117
|
+
if (!ID.test(id)) return null;
|
|
13118
|
+
const view = new DataView(message.buffer, message.byteOffset, message.byteLength);
|
|
13119
|
+
const high = view.getUint32(head);
|
|
13120
|
+
if (high > 2097151) return null;
|
|
13121
|
+
return {
|
|
13122
|
+
id,
|
|
13123
|
+
offset: high * 2 ** 32 + view.getUint32(head + 4),
|
|
13124
|
+
data: message.subarray(body)
|
|
13125
|
+
};
|
|
13126
|
+
}
|
|
14758
13127
|
/** What a refusal means to the sender's person. */
|
|
14759
13128
|
function refusalText(why, room) {
|
|
14760
13129
|
switch (why) {
|
|
@@ -14791,6 +13160,10 @@ function refusalText(why, room) {
|
|
|
14791
13160
|
var ChatFiles = class {
|
|
14792
13161
|
host;
|
|
14793
13162
|
entries = /* @__PURE__ */ new Map();
|
|
13163
|
+
/** Files sending that may have bytes not confirmed yet: what `inFlight` adds up (the rest are left out as it goes). */
|
|
13164
|
+
moving = /* @__PURE__ */ new Set();
|
|
13165
|
+
/** Files that stopped sending for the chat's window alone: they go on once another's bytes are confirmed. */
|
|
13166
|
+
held = /* @__PURE__ */ new Set();
|
|
14794
13167
|
attached = false;
|
|
14795
13168
|
peerRoomValue = null;
|
|
14796
13169
|
constructor(host) {
|
|
@@ -14850,6 +13223,15 @@ var ChatFiles = class {
|
|
|
14850
13223
|
send(frame) {
|
|
14851
13224
|
return this.attached && this.host.send(frame);
|
|
14852
13225
|
}
|
|
13226
|
+
sendData(id, offset, data) {
|
|
13227
|
+
if (!this.host.sendData) return this.send({
|
|
13228
|
+
t: "pf-data",
|
|
13229
|
+
id,
|
|
13230
|
+
offset,
|
|
13231
|
+
data: toBase64Url(data)
|
|
13232
|
+
});
|
|
13233
|
+
return this.attached && this.host.sendData(id, offset, data);
|
|
13234
|
+
}
|
|
14853
13235
|
/** A session that agreed files/3 is open: say what fits here, then offer again what is not finished. */
|
|
14854
13236
|
attach() {
|
|
14855
13237
|
this.attached = true;
|
|
@@ -14868,6 +13250,8 @@ var ChatFiles = class {
|
|
|
14868
13250
|
if (!this.attached) return;
|
|
14869
13251
|
this.attached = false;
|
|
14870
13252
|
this.peerRoomValue = null;
|
|
13253
|
+
this.moving.clear();
|
|
13254
|
+
this.held.clear();
|
|
14871
13255
|
for (const entry of this.entries.values()) {
|
|
14872
13256
|
if (entry.out) {
|
|
14873
13257
|
entry.out.generation++;
|
|
@@ -14991,10 +13375,34 @@ var ChatFiles = class {
|
|
|
14991
13375
|
const at = offset - out.block.start;
|
|
14992
13376
|
return out.block.bytes.subarray(at, at + length);
|
|
14993
13377
|
}
|
|
14994
|
-
|
|
13378
|
+
/** Whether this file's own window lets it send more. */
|
|
13379
|
+
fileMayPump(entry) {
|
|
14995
13380
|
const { record } = entry, out = entry.out;
|
|
14996
13381
|
return this.attached && record.state === "active" && out.next < record.file.size && out.next - record.confirmed < FILE_LIMITS.windowBytes;
|
|
14997
13382
|
}
|
|
13383
|
+
shouldPump(entry) {
|
|
13384
|
+
return this.fileMayPump(entry) && this.inFlight() < FILE_LIMITS.chatWindowBytes;
|
|
13385
|
+
}
|
|
13386
|
+
/** Bytes this chat's files sent and have not seen confirmed. */
|
|
13387
|
+
inFlight() {
|
|
13388
|
+
let bytes = 0;
|
|
13389
|
+
for (const entry of this.moving) {
|
|
13390
|
+
const out = entry.out, { record } = entry;
|
|
13391
|
+
if (!out || record.state !== "active" || out.next <= record.confirmed) {
|
|
13392
|
+
this.moving.delete(entry);
|
|
13393
|
+
continue;
|
|
13394
|
+
}
|
|
13395
|
+
bytes += out.next - record.confirmed;
|
|
13396
|
+
}
|
|
13397
|
+
return bytes;
|
|
13398
|
+
}
|
|
13399
|
+
/** Bytes were confirmed, or a file stopped sending: the files the chat's window held go on, before the one that freed it. */
|
|
13400
|
+
wakeHeld() {
|
|
13401
|
+
for (const entry of [...this.held]) {
|
|
13402
|
+
this.held.delete(entry);
|
|
13403
|
+
this.pump(entry);
|
|
13404
|
+
}
|
|
13405
|
+
}
|
|
14998
13406
|
/**
|
|
14999
13407
|
* Whether the next `length` bytes go. Within the file's size they always do; past it the receiver is having parts
|
|
15000
13408
|
* sent again, which nothing it says bounds, so this side does.
|
|
@@ -15025,17 +13433,13 @@ var ChatFiles = class {
|
|
|
15025
13433
|
this.end(entry, "failed", "Your contact asked for the same part of this file too many times");
|
|
15026
13434
|
break;
|
|
15027
13435
|
}
|
|
15028
|
-
if (!this.
|
|
15029
|
-
t: "pf-data",
|
|
15030
|
-
id: entry.record.id,
|
|
15031
|
-
offset,
|
|
15032
|
-
data: toBase64Url(chunk)
|
|
15033
|
-
})) {
|
|
13436
|
+
if (!this.sendData(entry.record.id, offset, chunk)) {
|
|
15034
13437
|
refused = true;
|
|
15035
13438
|
break;
|
|
15036
13439
|
}
|
|
15037
13440
|
out.next = offset + chunk.length;
|
|
15038
13441
|
out.sent += chunk.length;
|
|
13442
|
+
this.moving.add(entry);
|
|
15039
13443
|
}
|
|
15040
13444
|
} catch (error) {
|
|
15041
13445
|
if (generation === out.generation) {
|
|
@@ -15050,7 +13454,7 @@ var ChatFiles = class {
|
|
|
15050
13454
|
if (generation === out.generation && this.shouldPump(entry)) {
|
|
15051
13455
|
if (refused) out.wake = setTimeout(() => void this.pump(entry), FILE_LIMITS.sendRetryMs);
|
|
15052
13456
|
else this.pump(entry);
|
|
15053
|
-
}
|
|
13457
|
+
} else if (generation === out.generation && this.fileMayPump(entry)) this.held.add(entry);
|
|
15054
13458
|
}
|
|
15055
13459
|
}
|
|
15056
13460
|
async sendSum(entry) {
|
|
@@ -15082,7 +13486,7 @@ var ChatFiles = class {
|
|
|
15082
13486
|
return [...this.entries.values()].filter((e) => e.record.direction === "in" && e.record.state === "asking").length;
|
|
15083
13487
|
}
|
|
15084
13488
|
activeIncoming(except) {
|
|
15085
|
-
return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying")).length;
|
|
13489
|
+
return [...this.entries.values()].filter((e) => e !== except && e.record.direction === "in" && (e.record.state === "active" || e.record.state === "verifying" || e.in?.starting)).length;
|
|
15086
13490
|
}
|
|
15087
13491
|
/**
|
|
15088
13492
|
* Removes what this side stored of an incoming file that ends here. Through its target, opened now if need be: after
|
|
@@ -15109,9 +13513,15 @@ var ChatFiles = class {
|
|
|
15109
13513
|
}
|
|
15110
13514
|
return incoming.target;
|
|
15111
13515
|
}
|
|
15112
|
-
/**
|
|
13516
|
+
/**
|
|
13517
|
+
* An accepted file goes when a session is open and fewer than three others arrive at once. One whose target is still
|
|
13518
|
+
* being opened counts among the three: counted only once active, every file offered or queued meanwhile went too, and
|
|
13519
|
+
* the windows of ten files and more at once overflowed the session's receive queue.
|
|
13520
|
+
*/
|
|
15113
13521
|
startIncoming(entry) {
|
|
15114
13522
|
if (!this.attached || transferEnded(entry.record) || entry.record.state === "asking" || entry.record.state === "paused" && entry.record.pausedBy === "me") return;
|
|
13523
|
+
const incoming = entry.in;
|
|
13524
|
+
if (incoming.starting) return;
|
|
15115
13525
|
if (this.activeIncoming(entry) >= FILE_LIMITS.maxActiveIncoming) {
|
|
15116
13526
|
if (entry.record.state !== "queued") {
|
|
15117
13527
|
entry.record.state = "queued";
|
|
@@ -15124,9 +13534,13 @@ var ChatFiles = class {
|
|
|
15124
13534
|
});
|
|
15125
13535
|
return;
|
|
15126
13536
|
}
|
|
13537
|
+
incoming.starting = true;
|
|
15127
13538
|
this.target(entry).then(() => {
|
|
15128
|
-
|
|
15129
|
-
if (entry.record.state === "paused" && entry.record.pausedBy === "me")
|
|
13539
|
+
incoming.starting = false;
|
|
13540
|
+
if (transferEnded(entry.record) || !this.attached || entry.record.state === "paused" && entry.record.pausedBy === "me") {
|
|
13541
|
+
this.startNext();
|
|
13542
|
+
return;
|
|
13543
|
+
}
|
|
15130
13544
|
entry.record.state = "active";
|
|
15131
13545
|
entry.record.pausedBy = void 0;
|
|
15132
13546
|
entry.in.gapAt = void 0;
|
|
@@ -15139,12 +13553,14 @@ var ChatFiles = class {
|
|
|
15139
13553
|
entry.in.heardAt = this.now();
|
|
15140
13554
|
this.armNudge(entry);
|
|
15141
13555
|
}, (error) => {
|
|
13556
|
+
incoming.starting = false;
|
|
15142
13557
|
this.end(entry, "failed", `Could not store the file: ${error instanceof Error ? error.message : String(error)}`);
|
|
15143
13558
|
this.send({
|
|
15144
13559
|
t: "pf-refuse",
|
|
15145
13560
|
id: entry.record.id,
|
|
15146
13561
|
why: "no-room"
|
|
15147
13562
|
});
|
|
13563
|
+
this.startNext();
|
|
15148
13564
|
});
|
|
15149
13565
|
}
|
|
15150
13566
|
/** The next file waiting its turn, once one ends. */
|
|
@@ -15383,7 +13799,9 @@ var ChatFiles = class {
|
|
|
15383
13799
|
if (record.state !== "active" || !incoming.target) return;
|
|
15384
13800
|
incoming.heardAt = this.now();
|
|
15385
13801
|
const { offset, data } = frame;
|
|
15386
|
-
|
|
13802
|
+
const binary = data instanceof Uint8Array;
|
|
13803
|
+
if (!isInt$1(offset) || !(binary || typeof data === "string") || !data.length) return;
|
|
13804
|
+
if (!binary && (data.length > Math.ceil(FILE_LIMITS.chunkBytes * 4 / 3) || !DATA.test(data))) return;
|
|
15387
13805
|
if (offset < incoming.written) {
|
|
15388
13806
|
if (incoming.stored === incoming.written) this.send({
|
|
15389
13807
|
t: "pf-got",
|
|
@@ -15403,7 +13821,7 @@ var ChatFiles = class {
|
|
|
15403
13821
|
}
|
|
15404
13822
|
return;
|
|
15405
13823
|
}
|
|
15406
|
-
const bytes = fromBase64Url(data);
|
|
13824
|
+
const bytes = binary ? data : fromBase64Url(data);
|
|
15407
13825
|
if (!bytes.length || bytes.length > FILE_LIMITS.chunkBytes || offset + bytes.length > record.file.size) return;
|
|
15408
13826
|
incoming.written += bytes.length;
|
|
15409
13827
|
const target = incoming.target;
|
|
@@ -15495,7 +13913,7 @@ var ChatFiles = class {
|
|
|
15495
13913
|
return;
|
|
15496
13914
|
}
|
|
15497
13915
|
if (record.state !== "active" || incoming.verifying || !incoming.target) return;
|
|
15498
|
-
if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST
|
|
13916
|
+
if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST.test(frame.digest)) return;
|
|
15499
13917
|
const digest = frame.digest;
|
|
15500
13918
|
await incoming.writing;
|
|
15501
13919
|
if (transferEnded(record) || record.state !== "active") return;
|
|
@@ -15588,6 +14006,7 @@ var ChatFiles = class {
|
|
|
15588
14006
|
record.state = "active";
|
|
15589
14007
|
record.pausedBy = record.waitingFor = void 0;
|
|
15590
14008
|
this.changed(outgoing);
|
|
14009
|
+
this.wakeHeld();
|
|
15591
14010
|
if (record.confirmed === record.file.size) this.sendSum(outgoing);
|
|
15592
14011
|
else {
|
|
15593
14012
|
this.armStall(outgoing);
|
|
@@ -15607,11 +14026,13 @@ var ChatFiles = class {
|
|
|
15607
14026
|
record.waitingFor = frame.why === "busy" ? "busy" : "consent";
|
|
15608
14027
|
}
|
|
15609
14028
|
this.changed(outgoing);
|
|
14029
|
+
this.wakeHeld();
|
|
15610
14030
|
return;
|
|
15611
14031
|
case "pf-got":
|
|
15612
14032
|
if (!isInt$1(frame.offset) || frame.offset <= record.confirmed || frame.offset > out.next) return;
|
|
15613
14033
|
record.confirmed = frame.offset;
|
|
15614
14034
|
this.changed(outgoing, true);
|
|
14035
|
+
this.wakeHeld();
|
|
15615
14036
|
if (record.confirmed === record.file.size) this.sendSum(outgoing);
|
|
15616
14037
|
else {
|
|
15617
14038
|
this.armStall(outgoing);
|
|
@@ -15619,7 +14040,7 @@ var ChatFiles = class {
|
|
|
15619
14040
|
}
|
|
15620
14041
|
return;
|
|
15621
14042
|
case "pf-done":
|
|
15622
|
-
if (record.state !== "verifying") return;
|
|
14043
|
+
if (record.state !== "verifying" && record.confirmed !== record.file.size) return;
|
|
15623
14044
|
record.confirmed = record.file.size;
|
|
15624
14045
|
this.end(outgoing, "done");
|
|
15625
14046
|
return;
|
|
@@ -15641,6 +14062,8 @@ var ChatFiles = class {
|
|
|
15641
14062
|
clearTimeout(entry.out.wake);
|
|
15642
14063
|
entry.out.block = void 0;
|
|
15643
14064
|
entry.out.source = void 0;
|
|
14065
|
+
this.moving.delete(entry);
|
|
14066
|
+
this.held.delete(entry);
|
|
15644
14067
|
}
|
|
15645
14068
|
if (entry.in) {
|
|
15646
14069
|
const incoming = entry.in;
|
|
@@ -15650,6 +14073,7 @@ var ChatFiles = class {
|
|
|
15650
14073
|
});
|
|
15651
14074
|
}
|
|
15652
14075
|
this.changed(entry);
|
|
14076
|
+
if (entry.out) this.wakeHeld();
|
|
15653
14077
|
}
|
|
15654
14078
|
entry(direction, id) {
|
|
15655
14079
|
const entry = this.entries.get(key(direction, id));
|
|
@@ -15698,6 +14122,7 @@ var ChatFiles = class {
|
|
|
15698
14122
|
this.startNext();
|
|
15699
14123
|
}
|
|
15700
14124
|
this.changed(entry);
|
|
14125
|
+
if (entry.out) this.wakeHeld();
|
|
15701
14126
|
}
|
|
15702
14127
|
resume(direction, id) {
|
|
15703
14128
|
const entry = this.entry(direction, id), { record } = entry;
|
|
@@ -16316,6 +14741,8 @@ var CarriedTransport = class {
|
|
|
16316
14741
|
/** My last packet while the edge is down: what a member that just became reachable is handed. */
|
|
16317
14742
|
mine = null;
|
|
16318
14743
|
lastTimestamp = 0n;
|
|
14744
|
+
/** The date each packet `stamp` signed carries: the relays' transport takes it rather than check the signature again. */
|
|
14745
|
+
dated = /* @__PURE__ */ new WeakMap();
|
|
16319
14746
|
/** My newest packet, wherever it went: what the edge hands the member over itself once it is ready (`linkReady`). */
|
|
16320
14747
|
newest = null;
|
|
16321
14748
|
/** The newest packet of the member's that came through a carrier, and the timestamp of the one a read last returned. */
|
|
@@ -16395,7 +14822,9 @@ var CarriedTransport = class {
|
|
|
16395
14822
|
stamp(identity, records) {
|
|
16396
14823
|
const now = BigInt(Date.now()) * 1000n;
|
|
16397
14824
|
this.lastTimestamp = now > this.lastTimestamp ? now : this.lastTimestamp + 1n;
|
|
16398
|
-
|
|
14825
|
+
const payload = createRelayPayload(identity, records, this.lastTimestamp);
|
|
14826
|
+
this.dated.set(payload, this.lastTimestamp);
|
|
14827
|
+
return payload;
|
|
16399
14828
|
}
|
|
16400
14829
|
/**
|
|
16401
14830
|
* The relays get the very packet members carried or the edge itself handed over, byte for byte, where the transport
|
|
@@ -16405,7 +14834,11 @@ var CarriedTransport = class {
|
|
|
16405
14834
|
* its restart and then wait 40 s for (7 of 16 goodbyes on the admin in the member-back runs, 1 of 16 on dev, 2026-10-03).
|
|
16406
14835
|
*/
|
|
16407
14836
|
toRelays(identity, records, payload, options) {
|
|
16408
|
-
|
|
14837
|
+
const signedAt = this.dated.get(payload);
|
|
14838
|
+
return this.inner.publishPayload ? this.inner.publishPayload(identity.pubKeyZ32, payload, signedAt === void 0 ? options : {
|
|
14839
|
+
...options,
|
|
14840
|
+
signedAt
|
|
14841
|
+
}) : this.inner.publish(identity, records, options);
|
|
16409
14842
|
}
|
|
16410
14843
|
/**
|
|
16411
14844
|
* The packet the relays just took, handed to the member over the edge itself while it is up. An edge up reads nothing
|
|
@@ -16519,142 +14952,6 @@ var CarriedTransport = class {
|
|
|
16519
14952
|
}
|
|
16520
14953
|
};
|
|
16521
14954
|
//#endregion
|
|
16522
|
-
//#region ../core/src/groupFiles.ts
|
|
16523
|
-
/**
|
|
16524
|
-
* Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
|
|
16525
|
-
* description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
|
|
16526
|
-
* The limits below are the profile's first numbers, kept in one place so they are easy to change.
|
|
16527
|
-
*/
|
|
16528
|
-
var GROUP_FILE_LIMITS = {
|
|
16529
|
-
/** The largest file a group takes. */
|
|
16530
|
-
maxBytes: 104857600,
|
|
16531
|
-
/** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
|
|
16532
|
-
autoBytes: 8388608,
|
|
16533
|
-
/** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
|
|
16534
|
-
autoBytesPerGroup: 268435456,
|
|
16535
|
-
/** Bytes of group files a device serves to others in a day, when serving is on. */
|
|
16536
|
-
serveBytesPerDay: 1073741824,
|
|
16537
|
-
/** Transfers a holder serves at once, all groups together. */
|
|
16538
|
-
serveAtOnce: 3,
|
|
16539
|
-
/** File announcements a member sends to one group in a minute. */
|
|
16540
|
-
announcePerMinute: 8,
|
|
16541
|
-
/** File announcements of one member of a group fetched by themselves in `announceWindowMs`; past it, kept for a Download. */
|
|
16542
|
-
announceTaken: 30,
|
|
16543
|
-
announceWindowMs: 6e5,
|
|
16544
|
-
/** Holders remembered per file. */
|
|
16545
|
-
holders: 8,
|
|
16546
|
-
/**
|
|
16547
|
-
* Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
|
|
16548
|
-
* catch-up begins), remembered with their holders for when it does.
|
|
16549
|
-
*/
|
|
16550
|
-
heardAhead: 256,
|
|
16551
|
-
/** Message ids in one `group-have`. */
|
|
16552
|
-
haveIds: 32,
|
|
16553
|
-
/** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
|
|
16554
|
-
haveEveryMs: 1e4,
|
|
16555
|
-
communityHaveEveryMs: 6e4,
|
|
16556
|
-
/**
|
|
16557
|
-
* How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
|
|
16558
|
-
* left for the next holder too.
|
|
16559
|
-
*/
|
|
16560
|
-
wantWaitMs: 3e4,
|
|
16561
|
-
/** A file session closes this long after its last transfer ends. */
|
|
16562
|
-
sessionIdleMs: 6e4,
|
|
16563
|
-
/** File sessions opening at once, and open at once, all groups together. */
|
|
16564
|
-
sessionsOpening: 2,
|
|
16565
|
-
sessionsOpen: 4,
|
|
16566
|
-
/** The longest file name and type kept from a description. */
|
|
16567
|
-
nameChars: 255,
|
|
16568
|
-
mimeChars: 255
|
|
16569
|
-
};
|
|
16570
|
-
/**
|
|
16571
|
-
* The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
|
|
16572
|
-
* `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
|
|
16573
|
-
* `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
|
|
16574
|
-
*/
|
|
16575
|
-
var GROUP_WANT_FRAME = "group-want";
|
|
16576
|
-
var GROUP_WANT_NO_FRAME = "group-want-no";
|
|
16577
|
-
var GROUP_HAVE_FRAME = "group-have";
|
|
16578
|
-
var REFUSALS = [
|
|
16579
|
-
"gone",
|
|
16580
|
-
"busy",
|
|
16581
|
-
"refused"
|
|
16582
|
-
];
|
|
16583
|
-
/** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
|
|
16584
|
-
var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
|
|
16585
|
-
var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
|
|
16586
|
-
function frameOf(value, t, groupId) {
|
|
16587
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
16588
|
-
const f = value;
|
|
16589
|
-
return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
|
|
16590
|
-
}
|
|
16591
|
-
/** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
|
|
16592
|
-
function readGroupWant(value, groupId) {
|
|
16593
|
-
const f = frameOf(value, GROUP_WANT_FRAME, groupId);
|
|
16594
|
-
return f && isGroupMessageId(f.id) ? { id: f.id } : null;
|
|
16595
|
-
}
|
|
16596
|
-
/** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
|
|
16597
|
-
function readGroupWantNo(value, groupId) {
|
|
16598
|
-
const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
|
|
16599
|
-
if (!f || !isGroupMessageId(f.id)) return null;
|
|
16600
|
-
return {
|
|
16601
|
-
id: f.id,
|
|
16602
|
-
why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
|
|
16603
|
-
};
|
|
16604
|
-
}
|
|
16605
|
-
/**
|
|
16606
|
-
* The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
|
|
16607
|
-
* At most `haveIds` message ids; a frame with more, or anything else, is null.
|
|
16608
|
-
*/
|
|
16609
|
-
function readGroupHave(value, groupId) {
|
|
16610
|
-
const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
|
|
16611
|
-
if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
|
|
16612
|
-
return [...new Set(f.ids)];
|
|
16613
|
-
}
|
|
16614
|
-
var DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
16615
|
-
/**
|
|
16616
|
-
* A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
|
|
16617
|
-
* size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
|
|
16618
|
-
* on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
|
|
16619
|
-
*/
|
|
16620
|
-
function readGroupFileMeta(value) {
|
|
16621
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
16622
|
-
const v = value;
|
|
16623
|
-
if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
|
|
16624
|
-
if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
|
|
16625
|
-
if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
|
|
16626
|
-
if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
|
|
16627
|
-
const mime = sanitizeMime(v.mime);
|
|
16628
|
-
const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
|
|
16629
|
-
const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
|
|
16630
|
-
const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
|
|
16631
|
-
return {
|
|
16632
|
-
name: sanitizeFileName(v.name),
|
|
16633
|
-
mime,
|
|
16634
|
-
size: v.size,
|
|
16635
|
-
d: v.d,
|
|
16636
|
-
...voice ? { voice } : {},
|
|
16637
|
-
...video ? { video } : {},
|
|
16638
|
-
...image ? { image } : {}
|
|
16639
|
-
};
|
|
16640
|
-
}
|
|
16641
|
-
/**
|
|
16642
|
-
* The text an announcement carries when its author wrote no caption: what an app without group files shows instead
|
|
16643
|
-
* of the file. English, as wire text is; an app that knows the file shows the file, not this line.
|
|
16644
|
-
*/
|
|
16645
|
-
function groupFileFallback(file) {
|
|
16646
|
-
if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
|
|
16647
|
-
return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
|
|
16648
|
-
}
|
|
16649
|
-
/**
|
|
16650
|
-
* Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
|
|
16651
|
-
* other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
|
|
16652
|
-
*/
|
|
16653
|
-
function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
|
|
16654
|
-
if (file.voice) return true;
|
|
16655
|
-
return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
|
|
16656
|
-
}
|
|
16657
|
-
//#endregion
|
|
16658
14955
|
//#region ../core/src/pairingProgress.ts
|
|
16659
14956
|
var FIRST_STAGE = {
|
|
16660
14957
|
inviter: "publishing",
|
|
@@ -16846,6 +15143,7 @@ var ENGINE_ERRORS = {
|
|
|
16846
15143
|
notEnoughSats: "Not enough sats in your wallet",
|
|
16847
15144
|
noSharedMint: "You share no mint with this contact",
|
|
16848
15145
|
ecashAlreadySpent: "This ecash was already spent somewhere else",
|
|
15146
|
+
ecashBeingReceived: "This ecash is already being received",
|
|
16849
15147
|
ecashOtherUnit: "This ecash is in {unit}: only sat ecash can be redeemed here",
|
|
16850
15148
|
reviewedEcashSpent: "The ecash for this payment was already spent somewhere else. Nothing was sent, and your balance now shows what the mint still holds.",
|
|
16851
15149
|
reviewedSatsGone: "The sats this payment was reviewed with went to another payment. Nothing was sent: review it again.",
|
|
@@ -16954,8 +15252,14 @@ var PACKET_TRANSPORTS_FAST_ATTEMPTS = 3;
|
|
|
16954
15252
|
var DHT_PIN_GRACE_MS = 1e4;
|
|
16955
15253
|
/** Back after a restart (`resume`), dials wait this long at most for the native endpoint that carried the chat to start. */
|
|
16956
15254
|
var RESUME_WAIT_MS = 1e4;
|
|
16957
|
-
/**
|
|
16958
|
-
|
|
15255
|
+
/**
|
|
15256
|
+
* A dial of the contact's that meets such a session waits this long for that session to answer a ping. It answers when
|
|
15257
|
+
* the dials crossed (the contact holds this side's dial too); it does not when the contact's app ended since and is
|
|
15258
|
+
* back: its dial then takes over. Refused outright, that dial left the contact waiting for the dead session's end, 16.6
|
|
15259
|
+
* to 18.9 s (CLI daemons over HyperDHT, the contact killed 1 to 3 s after this side's knock, 5 of 5). A round trip is
|
|
15260
|
+
* 7 ms on loopback and under 0.5 s over a relayed Iroh.
|
|
15261
|
+
*/
|
|
15262
|
+
var CROSSED_PROBE_MS = 1500;
|
|
16959
15263
|
/** A WebRTC attempt under way, not yet open: what a member's packet saying it has no WebRTC ends (`peerPacketTransports`). */
|
|
16960
15264
|
var WEBRTC_ATTEMPT = /* @__PURE__ */ new Set([
|
|
16961
15265
|
"offering",
|
|
@@ -17077,6 +15381,18 @@ var HANDLED_WAIT_MS = 1e4;
|
|
|
17077
15381
|
*/
|
|
17078
15382
|
var WAKE_LOST_MS = 1e4;
|
|
17079
15383
|
/**
|
|
15384
|
+
* What files/3 may hold on a session's channel (WISP 501, Backpressure): a quarter of its send budget (native and iroh
|
|
15385
|
+
* channels throw past 120 KiB), or the high water mark of a WebRTC channel, which has none.
|
|
15386
|
+
*/
|
|
15387
|
+
function filesCap(channel) {
|
|
15388
|
+
return channel.sendBudget ? channel.sendBudget / 4 : LIMITS.sendHighWaterMark;
|
|
15389
|
+
}
|
|
15390
|
+
/**
|
|
15391
|
+
* How long after files last wrote on a channel, or last waited for room on it, an app's data keeps its own quarter above
|
|
15392
|
+
* their line (WISP 1200 § In a chat): a file waiting for `pf-got` holds its share meanwhile.
|
|
15393
|
+
*/
|
|
15394
|
+
var FILES_IN_USE_MS = 2e3;
|
|
15395
|
+
/**
|
|
17080
15396
|
* A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
|
|
17081
15397
|
* with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
|
|
17082
15398
|
* and always goes, and so does a forwarded message's hop count (`fw`, WISP 401 § Forwards) and a status card (`sc`,
|
|
@@ -17296,10 +15612,15 @@ var GhostLink = class {
|
|
|
17296
15612
|
* was the app's in the background, not a restart (`resumeAfterAbsence`).
|
|
17297
15613
|
*/
|
|
17298
15614
|
offered;
|
|
15615
|
+
/**
|
|
15616
|
+
* The WebRTC offer this side's dial made last, fallback allowed or not: one made before the contact's record was read
|
|
15617
|
+
* races once it is (`raceStandingOffer`).
|
|
15618
|
+
*/
|
|
15619
|
+
offerMade;
|
|
17299
15620
|
/** The attempt (its epoch) whose direct transports were knocked on as its offer went out (`knockEarly`), and whether that is under way. */
|
|
17300
15621
|
knockedEarly = -1;
|
|
17301
15622
|
knockingEarly = false;
|
|
17302
|
-
/** When
|
|
15623
|
+
/** When this side's offer's answer was applied (`RACE_ANSWERED_MS`); 0 while the data link is not connecting on it. */
|
|
17303
15624
|
answeredAt = 0;
|
|
17304
15625
|
/** This side's offer is out unanswered, or answered and not connected yet: what is ranked after WebRTC may race it. */
|
|
17305
15626
|
get offerOut() {
|
|
@@ -17419,6 +15740,7 @@ var GhostLink = class {
|
|
|
17419
15740
|
});
|
|
17420
15741
|
options.events?.onPairingProgress?.(progress);
|
|
17421
15742
|
}) : null;
|
|
15743
|
+
const invite = options.pairingProgress?.role === "inviter" && !!options.pairing && !options.pairing.credentials.peerKey;
|
|
17422
15744
|
this.dht = options.params.profile && options.pairing && options.dht ? new DhtDelivery({
|
|
17423
15745
|
params: options.params,
|
|
17424
15746
|
mode: this.deliveryMode,
|
|
@@ -17428,6 +15750,7 @@ var GhostLink = class {
|
|
|
17428
15750
|
save: options.dht.save,
|
|
17429
15751
|
pollMs: options.dht.pollMs,
|
|
17430
15752
|
firstControlAfterMs: options.dht.firstControlAfterMs,
|
|
15753
|
+
...invite && { inviteMadeAt: options.madeAt ?? Date.now() },
|
|
17431
15754
|
capsRev: options.dht.capsRev,
|
|
17432
15755
|
peerCapsRev: options.dht.peerCapsRev,
|
|
17433
15756
|
peerAcceptsText: options.dht.peerAcceptsText,
|
|
@@ -17443,6 +15766,7 @@ var GhostLink = class {
|
|
|
17443
15766
|
},
|
|
17444
15767
|
pin: async (key) => {
|
|
17445
15768
|
await options.pairing.pinPeer(key, true);
|
|
15769
|
+
if (invite) this.session.lookNow();
|
|
17446
15770
|
if (this.isDataLinkOpen || !this.tracker || this.tracker.done) return;
|
|
17447
15771
|
if (this.deliveryMode === "dht") {
|
|
17448
15772
|
this.tracker.onDht("chosen");
|
|
@@ -17513,6 +15837,7 @@ var GhostLink = class {
|
|
|
17513
15837
|
lastSeenTimestamp: options.lastSeenTimestamp,
|
|
17514
15838
|
pollIntervals: options.pollIntervals,
|
|
17515
15839
|
awaitingSince: options.madeAt,
|
|
15840
|
+
...invite && { invite: () => !options.pairing.credentials.peerKey },
|
|
17516
15841
|
getServices: options.getServices,
|
|
17517
15842
|
firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" && options.rtcAvailable !== false || this.resuming ? "after-first-poll" : "at-start"),
|
|
17518
15843
|
events: {
|
|
@@ -17632,6 +15957,7 @@ var GhostLink = class {
|
|
|
17632
15957
|
channel.close();
|
|
17633
15958
|
return;
|
|
17634
15959
|
}
|
|
15960
|
+
if (this.rtcStartedUnpinned) this.openedUnpinned.add(channel);
|
|
17635
15961
|
const plan = this.switcher.pending;
|
|
17636
15962
|
if (plan?.choices.includes("webrtc/1") && this.paired?.state.status === "ready") this.attachCandidate(channel, void 0, plan).catch(() => {});
|
|
17637
15963
|
else if (this.activeBinding) channel.close();
|
|
@@ -17647,6 +15973,7 @@ var GhostLink = class {
|
|
|
17647
15973
|
const was = this.lastDataLinkState;
|
|
17648
15974
|
traceLink(this.myPubKeyZ32, "datalink", { state });
|
|
17649
15975
|
this.trackDataLink(state);
|
|
15976
|
+
if (state === "offering" || state === "answering") this.rtcStartedUnpinned = !options.pairing?.credentials.peerKey && !options.pairing?.credentials.expectedPeerKey;
|
|
17650
15977
|
if (this.activeBinding) return;
|
|
17651
15978
|
events.onDataLinkState?.(state);
|
|
17652
15979
|
if (state === "open") {
|
|
@@ -17666,9 +15993,9 @@ var GhostLink = class {
|
|
|
17666
15993
|
}
|
|
17667
15994
|
}
|
|
17668
15995
|
this.answeredAt = 0;
|
|
17669
|
-
if (state === "connecting" && was === "offering" && this.
|
|
15996
|
+
if (state === "connecting" && was === "offering" && this.offerMade?.epoch === this.connectionEpoch) {
|
|
17670
15997
|
this.answeredAt = Date.now();
|
|
17671
|
-
this.scheduleRace(this.offered.epoch, this.offered.at);
|
|
15998
|
+
if (this.offered?.epoch === this.connectionEpoch) this.scheduleRace(this.offered.epoch, this.offered.at);
|
|
17672
15999
|
}
|
|
17673
16000
|
if (state !== "idle") return;
|
|
17674
16001
|
this.dialUnseen = was === "offering" && this.offerUnsent;
|
|
@@ -18023,11 +16350,11 @@ var GhostLink = class {
|
|
|
18023
16350
|
}
|
|
18024
16351
|
start() {
|
|
18025
16352
|
if (this.saidTransports) this.publishPacketTransports();
|
|
18026
|
-
if (this.deliveryMode !== "dht") this.session.start();
|
|
18027
16353
|
if (this.resuming && this.deliveryMode !== "dht") {
|
|
18028
16354
|
this.session.expectPeer();
|
|
18029
16355
|
this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
|
|
18030
16356
|
}
|
|
16357
|
+
if (this.deliveryMode !== "dht") this.session.start();
|
|
18031
16358
|
if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner") {
|
|
18032
16359
|
this.dht?.expect();
|
|
18033
16360
|
this.dialEarly();
|
|
@@ -18149,6 +16476,7 @@ var GhostLink = class {
|
|
|
18149
16476
|
disconnect() {
|
|
18150
16477
|
this.closeRetired();
|
|
18151
16478
|
this.dropCrossed();
|
|
16479
|
+
this.dropCrossedProbe();
|
|
18152
16480
|
this.dropBlockedDial();
|
|
18153
16481
|
this.switcher.stop();
|
|
18154
16482
|
this.cancelCandidate();
|
|
@@ -18259,6 +16587,7 @@ var GhostLink = class {
|
|
|
18259
16587
|
}
|
|
18260
16588
|
if (held && heldOn && reidentified.has(heldOn) && this.restartedRedial(held, heldOn)) return;
|
|
18261
16589
|
if (this.redescribedDial()) return;
|
|
16590
|
+
if (!this.dialing) this.raceStandingOffer();
|
|
18262
16591
|
if (this.channel || this.dialing) return;
|
|
18263
16592
|
if (news || !again) {
|
|
18264
16593
|
this.autoConnectFailures = 0;
|
|
@@ -18836,6 +17165,10 @@ var GhostLink = class {
|
|
|
18836
17165
|
...resume && { resume },
|
|
18837
17166
|
...woke && { woke }
|
|
18838
17167
|
} : void 0;
|
|
17168
|
+
this.offerMade = {
|
|
17169
|
+
epoch,
|
|
17170
|
+
at: offeredAt
|
|
17171
|
+
};
|
|
18839
17172
|
const gate = this.dialGate;
|
|
18840
17173
|
this.dialGate = void 0;
|
|
18841
17174
|
const replaced = new Promise((resolve) => {
|
|
@@ -18847,10 +17180,12 @@ var GhostLink = class {
|
|
|
18847
17180
|
traceLink(this.myPubKeyZ32, "dial-held", {});
|
|
18848
17181
|
this.afterRtc = void 0;
|
|
18849
17182
|
this.offered = void 0;
|
|
17183
|
+
this.offerMade = void 0;
|
|
18850
17184
|
return;
|
|
18851
17185
|
}
|
|
18852
17186
|
if (this.dataLink.state !== "idle" || epoch !== this.connectionEpoch) {
|
|
18853
17187
|
this.scheduleRace(epoch, offeredAt);
|
|
17188
|
+
this.raceStandingOffer();
|
|
18854
17189
|
return;
|
|
18855
17190
|
}
|
|
18856
17191
|
this.rtcUnstarted();
|
|
@@ -19016,6 +17351,31 @@ var GhostLink = class {
|
|
|
19016
17351
|
}, Math.max(0, Math.min(...at) - now));
|
|
19017
17352
|
}
|
|
19018
17353
|
/**
|
|
17354
|
+
* This side's offer went out before the contact's record was read (a joiner's early one, `dialEarly`): nothing was
|
|
17355
|
+
* known of what the contact runs, so it was made with no fallback and nothing raced it. Once the record says, and both
|
|
17356
|
+
* sides allow it, the offer races what both run as one made then would: answered and not connected `RACE_ANSWERED_MS`
|
|
17357
|
+
* later, the relayed ones too. Behind a VPN that drops UDP, a first pairing went live over the relayed Iroh only once
|
|
17358
|
+
* the attempt timed out and the next one raced, 32 to 66 s in (zero's VPN lab, 2026-10-13).
|
|
17359
|
+
*/
|
|
17360
|
+
raceStandingOffer() {
|
|
17361
|
+
const made = this.offerMade, epoch = this.connectionEpoch;
|
|
17362
|
+
if (!made || made.epoch !== epoch || this.offered?.epoch === epoch || !this.offerOut || this.channel || this.stopped) return;
|
|
17363
|
+
if (!this.fallback || !this.peerFallback || !this.peerTransports) return;
|
|
17364
|
+
const relayed = relayedTransports(this.localDescriptors(), this.peerDescriptors);
|
|
17365
|
+
const rest = rankTransports(this.transportOffer(), this.peerTransports, relayed).filter((t) => t !== "webrtc/1");
|
|
17366
|
+
if (!rest.length) return;
|
|
17367
|
+
this.offered = {
|
|
17368
|
+
epoch,
|
|
17369
|
+
at: made.at
|
|
17370
|
+
};
|
|
17371
|
+
this.afterRtc = {
|
|
17372
|
+
epoch,
|
|
17373
|
+
rest
|
|
17374
|
+
};
|
|
17375
|
+
traceLink(this.myPubKeyZ32, "race-standing", { transports: rest });
|
|
17376
|
+
this.scheduleRace(epoch, made.at);
|
|
17377
|
+
}
|
|
17378
|
+
/**
|
|
19019
17379
|
* Back after an absence, this side answers an offer the contact made while it was away (it dials whatever its key):
|
|
19020
17380
|
* the contact looks for that answer at the pace of an offer out a while (every 8 s on the relays), and its attempt may
|
|
19021
17381
|
* end before it does. The direct transports both run are knocked on meanwhile, and the relayed ones where both apps
|
|
@@ -19123,6 +17483,18 @@ var GhostLink = class {
|
|
|
19123
17483
|
if (!this.options.params.profile || this.options.packetTransports) return true;
|
|
19124
17484
|
return this.transportOffer().some((t) => t === "webrtc/1" ? !this.peerTransports || this.peerTransports.includes(t) : this.canDial(t) && !!this.peerTransports?.includes(t));
|
|
19125
17485
|
}
|
|
17486
|
+
/**
|
|
17487
|
+
* Neither side can dial the other until something changes, past a first pairing: what the contact runs is known, and
|
|
17488
|
+
* no transport both run has WebRTC on both sides or an endpoint up on both (this side's, and the one its record
|
|
17489
|
+
* describes). A chat that gave its listener up to another (`yieldEndpoint`), or whose contact did, is so. It dials
|
|
17490
|
+
* again when that changes (a listener taken back, `registerEndpoint`; the record describing one, `learnPeerTransports`),
|
|
17491
|
+
* and reads at its own pace meanwhile: looks every 20 s for a dial with nothing to try, and the contact's fast looks
|
|
17492
|
+
* for an offer that could not come, read its key 2.5x to 4x as often as the background pace, for good (bug hunt,
|
|
17493
|
+
* 2026-10-12). A first pairing keeps both: the records are what it is reading for.
|
|
17494
|
+
*/
|
|
17495
|
+
nothingToDialEitherWay() {
|
|
17496
|
+
return !(!!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht") && !!this.peerTransports && !this.anythingToDial();
|
|
17497
|
+
}
|
|
19126
17498
|
clearRace() {
|
|
19127
17499
|
if (this.raceTimer) clearTimeout(this.raceTimer);
|
|
19128
17500
|
this.raceTimer = null;
|
|
@@ -19148,6 +17520,7 @@ var GhostLink = class {
|
|
|
19148
17520
|
const left = next.rest.filter((t) => t !== "webrtc/1" && !due.includes(t));
|
|
19149
17521
|
if (!due.length) {
|
|
19150
17522
|
this.scheduleRace(epoch, offeredAt);
|
|
17523
|
+
this.raceStandingOffer();
|
|
19151
17524
|
return;
|
|
19152
17525
|
}
|
|
19153
17526
|
this.afterRtc = left.length ? {
|
|
@@ -19172,6 +17545,7 @@ var GhostLink = class {
|
|
|
19172
17545
|
if (live || epoch !== this.connectionEpoch || this.channel || this.stopped) return;
|
|
19173
17546
|
if (this.dataLink.state !== "idle") {
|
|
19174
17547
|
this.scheduleRace(epoch, offeredAt);
|
|
17548
|
+
this.raceStandingOffer();
|
|
19175
17549
|
return;
|
|
19176
17550
|
}
|
|
19177
17551
|
const rest = this.afterRtc;
|
|
@@ -19420,7 +17794,7 @@ var GhostLink = class {
|
|
|
19420
17794
|
return;
|
|
19421
17795
|
}
|
|
19422
17796
|
} else if (role !== "joiner" && this.myPubKeyZ32 > this.options.params.peerPubKeyZ32 && !this.resuming && !this.dialsPastRtc()) {
|
|
19423
|
-
if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor) {
|
|
17797
|
+
if (Date.now() - presenceSeenAt(presence) < 3e4 && presence.lastPacketAt !== this.offerAwaitedFor && !this.nothingToDialEitherWay()) {
|
|
19424
17798
|
this.offerAwaitedFor = presence.lastPacketAt;
|
|
19425
17799
|
this.session.expectPeer();
|
|
19426
17800
|
}
|
|
@@ -19498,6 +17872,7 @@ var GhostLink = class {
|
|
|
19498
17872
|
this.retryLook = null;
|
|
19499
17873
|
const from = this.lastAutoConnectAt;
|
|
19500
17874
|
if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
|
|
17875
|
+
if (this.nothingToDialEitherWay()) return;
|
|
19501
17876
|
this.retryLook = setTimeout(() => {
|
|
19502
17877
|
this.retryLook = null;
|
|
19503
17878
|
if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
@@ -19757,6 +18132,7 @@ var GhostLink = class {
|
|
|
19757
18132
|
if (this.options.callsSupport) offered.push(CALLS_CAPABILITY);
|
|
19758
18133
|
if (this.options.servicesSupport) offered.push(SERVICES_CAPABILITY);
|
|
19759
18134
|
if (this.options.largeFilesSupport) offered.push(FILES_CAPABILITY);
|
|
18135
|
+
if (this.options.largeFilesSupport && this.options.binaryFilesSupport && this.channel?.binary) offered.push(FILES_BINARY_CAPABILITY);
|
|
19760
18136
|
if (this.options.typingSupport) offered.push(TYPING_CAPABILITY);
|
|
19761
18137
|
if (this.options.reactionsSupport) offered.push(REACTIONS_CAPABILITY);
|
|
19762
18138
|
if (this.options.editSupport) offered.push(EDIT_CAPABILITY);
|
|
@@ -19793,6 +18169,10 @@ var GhostLink = class {
|
|
|
19793
18169
|
get supportsLargeFiles() {
|
|
19794
18170
|
return this.isDataLinkOpen && this.sessionCapabilities.agreed("files/3");
|
|
19795
18171
|
}
|
|
18172
|
+
/** Both sides offer `files/4` beside `files/3` on the open session: a file's bytes go as binary messages. */
|
|
18173
|
+
get supportsBinaryFiles() {
|
|
18174
|
+
return this.supportsLargeFiles && this.sessionCapabilities.agreed("files/4");
|
|
18175
|
+
}
|
|
19796
18176
|
/** Both sides offer `typing/1` on the open session: typing can be said and shown. */
|
|
19797
18177
|
get supportsTyping() {
|
|
19798
18178
|
return this.isDataLinkOpen && this.sessionCapabilities.agreed("typing/1");
|
|
@@ -19956,13 +18336,18 @@ var GhostLink = class {
|
|
|
19956
18336
|
}
|
|
19957
18337
|
/**
|
|
19958
18338
|
* One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
|
|
19959
|
-
* kept to send later.
|
|
18339
|
+
* kept to send later. While the channel already holds a quarter of what it may queue (its send budget, or the high
|
|
18340
|
+
* water mark of a WebRTC channel), the frame is refused with `too-fast`, so the chat's messages and pings never wait
|
|
18341
|
+
* behind an app's data on a link slower than it sends (WISP 1200 § In a chat). While a file goes, that quarter sits
|
|
18342
|
+
* above what files may hold (`filesWritable`): files keep the channel at their own line for the whole transfer.
|
|
19960
18343
|
*/
|
|
19961
18344
|
sendAppData(app, data) {
|
|
19962
18345
|
if (!this.apps.open.has(app)) return "not-open";
|
|
19963
18346
|
if (appDataBytes(data) > 32768) return "too-large";
|
|
19964
18347
|
if (!this.channel || !this.isDataLinkOpen) return "offline";
|
|
19965
18348
|
if (!this.supportsApps) return "peer-closed";
|
|
18349
|
+
const files = this.filesUse?.channel === this.channel && (this.filesUse.waiting > 0 || Date.now() - this.filesUse.at < FILES_IN_USE_MS) ? filesCap(this.channel) : 0;
|
|
18350
|
+
if (this.channel.bufferedAmount >= files + (this.channel.sendBudget ?? LIMITS.sendHighWaterMark) / 4) return "too-fast";
|
|
19966
18351
|
const refused = this.apps.canSend(app, data);
|
|
19967
18352
|
if (refused) return refused;
|
|
19968
18353
|
try {
|
|
@@ -20013,19 +18398,57 @@ var GhostLink = class {
|
|
|
20013
18398
|
}
|
|
20014
18399
|
}
|
|
20015
18400
|
/**
|
|
18401
|
+
* A file's bytes to the contact, on the open session: one binary message while both sides agreed `files/4` on it,
|
|
18402
|
+
* the `pf-data` frame with them as base64url text otherwise (WISP 501 § files/4). The form is chosen for each chunk,
|
|
18403
|
+
* so a transfer follows the session it is on. False when files/3 cannot flow now, or the channel has no room.
|
|
18404
|
+
*/
|
|
18405
|
+
sendFileData(id, offset, data) {
|
|
18406
|
+
if (!this.supportsLargeFiles || !this.channel) return false;
|
|
18407
|
+
try {
|
|
18408
|
+
this.channel.send(this.supportsBinaryFiles ? encodeFileData(id, offset, data) : JSON.stringify({
|
|
18409
|
+
t: "pf-data",
|
|
18410
|
+
id,
|
|
18411
|
+
offset,
|
|
18412
|
+
data: toBase64Url(data)
|
|
18413
|
+
}));
|
|
18414
|
+
this.filesUsing(this.channel).at = Date.now();
|
|
18415
|
+
return true;
|
|
18416
|
+
} catch {
|
|
18417
|
+
return false;
|
|
18418
|
+
}
|
|
18419
|
+
}
|
|
18420
|
+
/** Files on a channel: when they last wrote on it, and how many wait for room. An app's data gets room above theirs. */
|
|
18421
|
+
filesUse = null;
|
|
18422
|
+
filesUsing(channel) {
|
|
18423
|
+
if (this.filesUse?.channel !== channel) this.filesUse = {
|
|
18424
|
+
channel,
|
|
18425
|
+
at: 0,
|
|
18426
|
+
waiting: 0
|
|
18427
|
+
};
|
|
18428
|
+
return this.filesUse;
|
|
18429
|
+
}
|
|
18430
|
+
/**
|
|
20016
18431
|
* Resolves once the open session can take another files/3 data frame; undefined when it can now. A channel with a send budget (native and
|
|
20017
18432
|
* iroh ones throw past 120 KiB) gives files a quarter of it, so a ping, a message or any other frame still fits
|
|
20018
|
-
* while a file goes; others keep under the high water mark, as files/2 does.
|
|
18433
|
+
* while a file goes; others keep under the high water mark, as files/2 does. An app's data gets a quarter above that
|
|
18434
|
+
* line meanwhile (`sendAppData`).
|
|
20019
18435
|
*/
|
|
20020
18436
|
filesWritable() {
|
|
20021
18437
|
const channel = this.channel;
|
|
20022
18438
|
if (!channel) return;
|
|
20023
|
-
const cap = channel
|
|
18439
|
+
const cap = filesCap(channel);
|
|
20024
18440
|
if (channel.bufferedAmount < cap) return;
|
|
18441
|
+
const use = this.filesUsing(channel);
|
|
18442
|
+
use.waiting++;
|
|
20025
18443
|
return (async () => {
|
|
20026
|
-
|
|
20027
|
-
|
|
20028
|
-
|
|
18444
|
+
try {
|
|
18445
|
+
while (this.channel === channel && channel.bufferedAmount >= cap) {
|
|
18446
|
+
await channel.drained().catch(() => {});
|
|
18447
|
+
if (this.channel === channel && channel.bufferedAmount >= cap) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
18448
|
+
}
|
|
18449
|
+
} finally {
|
|
18450
|
+
use.waiting--;
|
|
18451
|
+
use.at = Date.now();
|
|
20029
18452
|
}
|
|
20030
18453
|
})();
|
|
20031
18454
|
}
|
|
@@ -20247,8 +18670,8 @@ var GhostLink = class {
|
|
|
20247
18670
|
* Both sides dialled at once (two apps back after a restart, each knocking): the lower key's dial carries the chat, on
|
|
20248
18671
|
* both sides; the higher key's side takes it over as a replacement (`attachReplacement`). Here, on the lower key's
|
|
20249
18672
|
* side, the contact's connection waits: dropped once this side's own dial connects, taken if that dial fails, or has
|
|
20250
|
-
* not connected within `CROSSED_WAIT_MS` (
|
|
20251
|
-
*
|
|
18673
|
+
* not connected within `CROSSED_WAIT_MS` (it stalled, or goes to an endpoint the contact no longer runs, for as long
|
|
18674
|
+
* as it takes to give up).
|
|
20252
18675
|
*/
|
|
20253
18676
|
crossed = null;
|
|
20254
18677
|
parkCrossed(channel, binding) {
|
|
@@ -20265,7 +18688,7 @@ var GhostLink = class {
|
|
|
20265
18688
|
this.dialing = false;
|
|
20266
18689
|
this.attempt = null;
|
|
20267
18690
|
this.attach(channel, binding);
|
|
20268
|
-
},
|
|
18691
|
+
}, 500);
|
|
20269
18692
|
channel.onClose = () => {
|
|
20270
18693
|
if (this.crossed?.channel === channel) {
|
|
20271
18694
|
clearTimeout(timer);
|
|
@@ -20323,13 +18746,20 @@ var GhostLink = class {
|
|
|
20323
18746
|
}
|
|
20324
18747
|
const held = this.channel;
|
|
20325
18748
|
if (held && this.activeBinding && !this.dialedIn.has(held) && Date.now() - this.channelSince < 1e4 && this.myPubKeyZ32 < this.options.params.peerPubKeyZ32) {
|
|
20326
|
-
|
|
20327
|
-
|
|
20328
|
-
|
|
20329
|
-
|
|
20330
|
-
|
|
18749
|
+
if (this.paired?.state.status !== "ready") {
|
|
18750
|
+
traceLink(this.myPubKeyZ32, "dial-crossed", {
|
|
18751
|
+
transport: binding.transport,
|
|
18752
|
+
held: true
|
|
18753
|
+
});
|
|
18754
|
+
channel.close();
|
|
18755
|
+
return;
|
|
18756
|
+
}
|
|
18757
|
+
this.probeCrossed(held, channel, binding);
|
|
20331
18758
|
return;
|
|
20332
18759
|
}
|
|
18760
|
+
this.replaceWith(channel, binding);
|
|
18761
|
+
}
|
|
18762
|
+
replaceWith(channel, binding) {
|
|
20333
18763
|
traceLink(this.myPubKeyZ32, "replacing", {
|
|
20334
18764
|
transport: binding.transport,
|
|
20335
18765
|
held: this.paired?.state.transport
|
|
@@ -20340,6 +18770,62 @@ var GhostLink = class {
|
|
|
20340
18770
|
});
|
|
20341
18771
|
}
|
|
20342
18772
|
/**
|
|
18773
|
+
* The contact dialled in while this side's own fresh dial holds the chat (`CROSSED_FRESH_MS`): the dials crossed, or
|
|
18774
|
+
* the contact's app ended since that dial and is back. The held session is pinged; heard from within
|
|
18775
|
+
* `CROSSED_PROBE_MS`, the contact holds this side's dial too and its own closes. Not heard from, the contact's dial
|
|
18776
|
+
* takes over.
|
|
18777
|
+
*/
|
|
18778
|
+
crossedProbe = null;
|
|
18779
|
+
/** Frames heard on the open session, counted from its start: whether the contact answered `probeCrossed`. */
|
|
18780
|
+
framesHeard = 0;
|
|
18781
|
+
probeCrossed(held, channel, binding) {
|
|
18782
|
+
this.dropCrossedProbe();
|
|
18783
|
+
traceLink(this.myPubKeyZ32, "dial-crossed", {
|
|
18784
|
+
transport: binding.transport,
|
|
18785
|
+
held: true,
|
|
18786
|
+
probe: true
|
|
18787
|
+
});
|
|
18788
|
+
const heard = this.framesHeard;
|
|
18789
|
+
try {
|
|
18790
|
+
this.sendPing(held, false);
|
|
18791
|
+
} catch {}
|
|
18792
|
+
const timer = setTimeout(() => {
|
|
18793
|
+
if (this.crossedProbe?.channel !== channel) return;
|
|
18794
|
+
this.crossedProbe = null;
|
|
18795
|
+
if (this.stopped || this.leaving || this.candidate) {
|
|
18796
|
+
channel.close();
|
|
18797
|
+
return;
|
|
18798
|
+
}
|
|
18799
|
+
if (!this.channel) {
|
|
18800
|
+
this.attach(channel, binding);
|
|
18801
|
+
return;
|
|
18802
|
+
}
|
|
18803
|
+
if (this.channel === held && this.framesHeard !== heard) {
|
|
18804
|
+
channel.close();
|
|
18805
|
+
return;
|
|
18806
|
+
}
|
|
18807
|
+
traceLink(this.myPubKeyZ32, "crossed-held-silent", { transport: binding.transport });
|
|
18808
|
+
this.replaceWith(channel, binding);
|
|
18809
|
+
}, CROSSED_PROBE_MS);
|
|
18810
|
+
channel.onClose = () => {
|
|
18811
|
+
if (this.crossedProbe?.channel === channel) {
|
|
18812
|
+
clearTimeout(timer);
|
|
18813
|
+
this.crossedProbe = null;
|
|
18814
|
+
}
|
|
18815
|
+
};
|
|
18816
|
+
this.crossedProbe = {
|
|
18817
|
+
channel,
|
|
18818
|
+
timer
|
|
18819
|
+
};
|
|
18820
|
+
}
|
|
18821
|
+
dropCrossedProbe() {
|
|
18822
|
+
const probe = this.crossedProbe;
|
|
18823
|
+
if (!probe) return;
|
|
18824
|
+
this.crossedProbe = null;
|
|
18825
|
+
clearTimeout(probe.timer);
|
|
18826
|
+
probe.channel.close();
|
|
18827
|
+
}
|
|
18828
|
+
/**
|
|
20343
18829
|
* Connections that came in on this side's native endpoints. Their address was in the capability record, which before
|
|
20344
18830
|
* the pin anyone holding a copy of the invite could read: a key other than the pinned one on such a connection proves
|
|
20345
18831
|
* no more than on the DHT, so it closes that connection and nothing else.
|
|
@@ -20347,6 +18833,8 @@ var GhostLink = class {
|
|
|
20347
18833
|
dialedIn = /* @__PURE__ */ new WeakSet();
|
|
20348
18834
|
/** Connections whose session started with no key pinned nor named by the invite. */
|
|
20349
18835
|
openedUnpinned = /* @__PURE__ */ new WeakSet();
|
|
18836
|
+
/** The WebRTC offer under way was made or answered with no key pinned nor named by the invite (its channel opens later). */
|
|
18837
|
+
rtcStartedUnpinned = false;
|
|
20350
18838
|
/**
|
|
20351
18839
|
* `migration`: the channel is a candidate that takes over from the session open now once it authenticated. With a
|
|
20352
18840
|
* `plan`, a live switch both sides agreed; without one, a replacement (`attachReplacement`): the contact dialled
|
|
@@ -20532,7 +19020,18 @@ var GhostLink = class {
|
|
|
20532
19020
|
if (this.channel !== channel) return;
|
|
20533
19021
|
this.unansweredPings = 0;
|
|
20534
19022
|
this.heardFromPeer();
|
|
20535
|
-
|
|
19023
|
+
this.framesHeard++;
|
|
19024
|
+
if (typeof data !== "string") {
|
|
19025
|
+
const chunk = this.supportsBinaryFiles ? decodeFileData(data) : null;
|
|
19026
|
+
if (!chunk) return;
|
|
19027
|
+
this.activityAt = Date.now();
|
|
19028
|
+
await this.options.events?.onFilesFrame?.({
|
|
19029
|
+
t: "pf-data",
|
|
19030
|
+
...chunk
|
|
19031
|
+
});
|
|
19032
|
+
return;
|
|
19033
|
+
}
|
|
19034
|
+
if (data.length > 61440) return;
|
|
20536
19035
|
let frame;
|
|
20537
19036
|
try {
|
|
20538
19037
|
frame = JSON.parse(data);
|
|
@@ -25251,6 +23750,12 @@ var byeSigned = (f) => utf8Encode(JSON.stringify([
|
|
|
25251
23750
|
f.e,
|
|
25252
23751
|
f.ts
|
|
25253
23752
|
]));
|
|
23753
|
+
var leftSigned = (commit) => utf8Encode(JSON.stringify([
|
|
23754
|
+
"ghostly-group/1 left",
|
|
23755
|
+
commit.g,
|
|
23756
|
+
commit.e,
|
|
23757
|
+
commitHash(commit)
|
|
23758
|
+
]));
|
|
25254
23759
|
var editSigned = (f) => utf8Encode(JSON.stringify([
|
|
25255
23760
|
"ghostly-group/1 edit",
|
|
25256
23761
|
f.g,
|
|
@@ -25283,6 +23788,7 @@ function readFirstPlace(o, epoch) {
|
|
|
25283
23788
|
/** One of my own frames, among mine: `e:n`. */
|
|
25284
23789
|
var ownKey = (f) => `${f.e}:${f.n}`;
|
|
25285
23790
|
/** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
|
|
23791
|
+
var frameKey$1 = (f) => `${f.s}:${f.e}:${f.n}`;
|
|
25286
23792
|
/** What a kept frame counts for against `GROUP_LIMITS.relayBytes`. */
|
|
25287
23793
|
var frameBytes = (f) => f.c.length + (f.m?.c.length ?? 0) + (f.r?.c.length ?? 0) + (f.sc?.c.length ?? 0) + (f.fl?.c.length ?? 0) + 400;
|
|
25288
23794
|
/** Only the fields an edit frame has, as `clean` does for a message. */
|
|
@@ -25403,8 +23909,15 @@ var GroupSession = class {
|
|
|
25403
23909
|
pendingMeta;
|
|
25404
23910
|
/** While `handle` runs: the frames it took that a hub passes on (`handle`'s result). */
|
|
25405
23911
|
passOn = null;
|
|
23912
|
+
/** The frames kept to hand on that the app holds with its messages, oldest first; undefined when the state holds them all. */
|
|
23913
|
+
onRows;
|
|
25406
23914
|
/** Set while a save of what received messages changed is due (`persistSoon`). */
|
|
25407
23915
|
saveTimer;
|
|
23916
|
+
/**
|
|
23917
|
+
* The frames of members who left that each member was handed (`frameKey`), so an app that does not take them (from
|
|
23918
|
+
* before revision 2026-10-13, and never marks them seen) is not handed them again at every sync. In memory only.
|
|
23919
|
+
*/
|
|
23920
|
+
leaversHanded = /* @__PURE__ */ new Map();
|
|
25408
23921
|
/** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
|
|
25409
23922
|
syncsAnswered = /* @__PURE__ */ new Map();
|
|
25410
23923
|
/** Catch-up answers still going out, a slice at a time (`handOut`). */
|
|
@@ -25417,10 +23930,21 @@ var GroupSession = class {
|
|
|
25417
23930
|
provisional;
|
|
25418
23931
|
/** When my chain last moved (a commit made or applied), by `hooks.clock`: frames are said again once it has settled. */
|
|
25419
23932
|
chainMovedAt;
|
|
25420
|
-
|
|
23933
|
+
/**
|
|
23934
|
+
* `onRows`: the frames the app holds with the messages it stored (`GroupIncomingMessage.frame`), oldest first. Given
|
|
23935
|
+
* (empty at first), the app is where frames are kept: the saved state carries none of them, and a message received
|
|
23936
|
+
* no longer writes them all again. Absent: they are kept in the state, as before.
|
|
23937
|
+
*/
|
|
23938
|
+
constructor(state, hooks, onRows) {
|
|
25421
23939
|
this.hooks = hooks;
|
|
25422
23940
|
this.state = state;
|
|
25423
23941
|
this.identity = identityFromSeedB64(state.seedB64);
|
|
23942
|
+
if (onRows) {
|
|
23943
|
+
const held = new Set(onRows.map(frameKey$1));
|
|
23944
|
+
if (state.relay?.length) state.relay = state.relay.filter((f) => !held.has(frameKey$1(f)));
|
|
23945
|
+
this.onRows = state.status === "active" ? onRows.filter((f) => this.handsOn(f.s, f.e)) : [];
|
|
23946
|
+
this.trimRelay();
|
|
23947
|
+
}
|
|
25424
23948
|
this.answers = new CatchUpAnswers({
|
|
25425
23949
|
send: (to, frame) => hooks.send(to, frame),
|
|
25426
23950
|
...hooks.handled && { handled: (to) => hooks.handled(to) },
|
|
@@ -25731,11 +24255,43 @@ var GroupSession = class {
|
|
|
25731
24255
|
}];
|
|
25732
24256
|
});
|
|
25733
24257
|
}
|
|
25734
|
-
|
|
24258
|
+
/**
|
|
24259
|
+
* A welcome for a member already in the roster that never got the first one (its chat closed while it was let in):
|
|
24260
|
+
* nothing is committed. It carries the secrets of the epochs it was in, from its admission on, sealed to its key.
|
|
24261
|
+
*/
|
|
24262
|
+
rewelcome(memberKey) {
|
|
24263
|
+
return this.serialize(async () => {
|
|
24264
|
+
this.requireAdmin();
|
|
24265
|
+
if (!rosterHas(this.roster, memberKey) || memberKey === this.myKey) throw new Error("Not a member");
|
|
24266
|
+
const chain = this.state.chain;
|
|
24267
|
+
let admitted = chain.length - 1;
|
|
24268
|
+
while (admitted >= 0 && !(chain[admitted].k === "add" && chain[admitted].s === memberKey)) admitted--;
|
|
24269
|
+
if (admitted < 0) throw new Error("Not a member");
|
|
24270
|
+
const secrets = chain.slice(admitted).filter((c) => this.state.secrets[c.e] && rosterHas(c.m, memberKey)).map((c) => ({
|
|
24271
|
+
e: c.e,
|
|
24272
|
+
s: sealSecret(memberKey, this.secret(c.e), secretAad$1(this.id, c.e, memberKey))
|
|
24273
|
+
}));
|
|
24274
|
+
const pieces = chainPieces(chain);
|
|
24275
|
+
const tail = pieces.pop();
|
|
24276
|
+
return [...pieces.map((commits) => ({
|
|
24277
|
+
t: "group-chain",
|
|
24278
|
+
g: this.id,
|
|
24279
|
+
commits
|
|
24280
|
+
})), {
|
|
24281
|
+
t: "group-welcome",
|
|
24282
|
+
g: this.id,
|
|
24283
|
+
name: this.name,
|
|
24284
|
+
commits: tail,
|
|
24285
|
+
secrets
|
|
24286
|
+
}];
|
|
24287
|
+
});
|
|
24288
|
+
}
|
|
24289
|
+
/** `left`: the member asked to leave (on the admin's chat), and the commit says so (`GroupCommitFrame.left`). */
|
|
24290
|
+
remove(memberKey, now = Date.now(), { left = false } = {}) {
|
|
25735
24291
|
return this.serialize(async () => {
|
|
25736
24292
|
this.requireAdmin();
|
|
25737
24293
|
if (!rosterHas(this.roster, memberKey) || memberKey === this.myKey) throw new Error("Not a member");
|
|
25738
|
-
await this.commit("remove", memberKey, now);
|
|
24294
|
+
await this.commit("remove", memberKey, now, left);
|
|
25739
24295
|
});
|
|
25740
24296
|
}
|
|
25741
24297
|
transferAdmin(memberKey, now = Date.now()) {
|
|
@@ -25752,7 +24308,7 @@ var GroupSession = class {
|
|
|
25752
24308
|
await this.commit("rotate", void 0, now);
|
|
25753
24309
|
});
|
|
25754
24310
|
}
|
|
25755
|
-
async commit(kind, subject, now) {
|
|
24311
|
+
async commit(kind, subject, now, left = false) {
|
|
25756
24312
|
if (this.hooks.adminWork?.() === false) throw new Error(GROUP_ADMIN_OFF_ERROR);
|
|
25757
24313
|
if (this.hooks.adminTurn && !await this.hooks.adminTurn()) throw engineError("groupTurnUnconfirmed");
|
|
25758
24314
|
if (this.state.chain.length >= 1024) throw new Error("This group has reached its membership history limit. Create a new group.");
|
|
@@ -25776,6 +24332,8 @@ var GroupSession = class {
|
|
|
25776
24332
|
...draft,
|
|
25777
24333
|
c: confirmationTag(epochKeys(secret, this.id, epoch).confirm, commitUntaggedHash(draft))
|
|
25778
24334
|
}, this.identity.seed);
|
|
24335
|
+
const mark = left && kind === "remove" ? toBase64Url(sign(leftSigned(commit), this.identity.seed)) : void 0;
|
|
24336
|
+
if (mark) (this.state.leftMarks ??= {})[epoch] = mark;
|
|
25779
24337
|
this.state.chain.push(commit);
|
|
25780
24338
|
this.chainMovedAt = this.clock();
|
|
25781
24339
|
this.state.secrets[epoch] = toBase64Url(secret);
|
|
@@ -25791,15 +24349,20 @@ var GroupSession = class {
|
|
|
25791
24349
|
t: "group-commit",
|
|
25792
24350
|
g: this.id,
|
|
25793
24351
|
commit,
|
|
25794
|
-
...member ? { secret: sealSecret(key, secret, secretAad$1(this.id, epoch, key)) } : {}
|
|
24352
|
+
...member ? { secret: sealSecret(key, secret, secretAad$1(this.id, epoch, key)) } : {},
|
|
24353
|
+
...mark ? { left: mark } : {}
|
|
25795
24354
|
});
|
|
25796
24355
|
}
|
|
25797
24356
|
this.hooks.changed();
|
|
25798
24357
|
return { secret };
|
|
25799
24358
|
}
|
|
25800
|
-
/**
|
|
24359
|
+
/**
|
|
24360
|
+
* Someone removed is not handed on any more, whatever they sent while in the roster; one who left still is, for the
|
|
24361
|
+
* epochs before its leave (WISP 902 § Catch-up).
|
|
24362
|
+
*/
|
|
25801
24363
|
pruneRelay() {
|
|
25802
|
-
if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) =>
|
|
24364
|
+
if (this.state.relay?.length) this.state.relay = this.state.relay.filter((f) => this.handsOn(f.s, f.e));
|
|
24365
|
+
if (this.onRows?.length) this.onRows = this.onRows.filter((f) => this.handsOn(f.s, f.e));
|
|
25803
24366
|
if (this.state.relayEdits?.length) this.state.relayEdits = this.state.relayEdits.filter((f) => rosterHas(this.roster, f.s));
|
|
25804
24367
|
}
|
|
25805
24368
|
/** Another member's edit, kept to hand on: the latest per message; the oldest go once the log is full. False: one as new was kept. */
|
|
@@ -25815,10 +24378,21 @@ var GroupSession = class {
|
|
|
25815
24378
|
}
|
|
25816
24379
|
/** A frame of another member, kept to hand on; the oldest go once the log is full. */
|
|
25817
24380
|
keep(frame) {
|
|
25818
|
-
|
|
25819
|
-
|
|
25820
|
-
|
|
25821
|
-
|
|
24381
|
+
if (this.onRows) {
|
|
24382
|
+
const key = frameKey$1(frame);
|
|
24383
|
+
if (this.onRows.some((f) => frameKey$1(f) === key)) return;
|
|
24384
|
+
this.onRows.push(frame);
|
|
24385
|
+
} else (this.state.relay ??= []).push(frame);
|
|
24386
|
+
this.trimRelay();
|
|
24387
|
+
}
|
|
24388
|
+
/** The frames kept to hand on, oldest first: the state's, then the ones the app holds with its messages. */
|
|
24389
|
+
kept() {
|
|
24390
|
+
return this.onRows ? [...this.state.relay ?? [], ...this.onRows] : this.state.relay ?? [];
|
|
24391
|
+
}
|
|
24392
|
+
trimRelay() {
|
|
24393
|
+
const old = this.state.relay ?? [], rows = this.onRows ?? [];
|
|
24394
|
+
let bytes = old.reduce((sum, f) => sum + frameBytes(f), 0) + rows.reduce((sum, f) => sum + frameBytes(f), 0);
|
|
24395
|
+
while (old.length + rows.length > GROUP_LIMITS.relay || bytes > GROUP_LIMITS.relayBytes) bytes -= frameBytes((old.length ? old : rows).shift());
|
|
25822
24396
|
}
|
|
25823
24397
|
pruneSecrets() {
|
|
25824
24398
|
const epochs = Object.keys(this.state.secrets).map(Number).sort((a, b) => a - b);
|
|
@@ -25854,6 +24428,7 @@ var GroupSession = class {
|
|
|
25854
24428
|
this.state.secrets = {};
|
|
25855
24429
|
this.state.sent = [];
|
|
25856
24430
|
this.state.relay = [];
|
|
24431
|
+
if (this.onRows) this.onRows = [];
|
|
25857
24432
|
this.state.relayEdits = [];
|
|
25858
24433
|
this.state.alone = [];
|
|
25859
24434
|
this.waiting = [];
|
|
@@ -26191,7 +24766,7 @@ var GroupSession = class {
|
|
|
26191
24766
|
case "group-bye":
|
|
26192
24767
|
await this.receiveBye(from, raw);
|
|
26193
24768
|
break;
|
|
26194
|
-
case "group-leave": if (this.isAdmin && this.hooks.adminWork?.() !== false && rosterHas(this.roster, from) && from !== this.myKey) await this.commitUnlessTurnUnconfirmed("remove", from);
|
|
24769
|
+
case "group-leave": if (this.isAdmin && this.hooks.adminWork?.() !== false && rosterHas(this.roster, from) && from !== this.myKey) await this.commitUnlessTurnUnconfirmed("remove", from, true);
|
|
26195
24770
|
}
|
|
26196
24771
|
return this.state.status === "active" ? this.passOn : this.state.status === "removed" ? this.passOn.filter((f) => f.t === "group-commit") : [];
|
|
26197
24772
|
} finally {
|
|
@@ -26306,7 +24881,7 @@ var GroupSession = class {
|
|
|
26306
24881
|
return;
|
|
26307
24882
|
}
|
|
26308
24883
|
if (bye.e < this.admittedAt(bye.k)) return;
|
|
26309
|
-
if (this.isAdmin && this.hooks.adminWork?.() !== false && await this.commitUnlessTurnUnconfirmed("remove", bye.k)) return;
|
|
24884
|
+
if (this.isAdmin && this.hooks.adminWork?.() !== false && await this.commitUnlessTurnUnconfirmed("remove", bye.k, true)) return;
|
|
26310
24885
|
if (from !== bye.k && this.byesPassed.get(bye.k)?.sig === bye.sig) return;
|
|
26311
24886
|
this.byesPassed.set(bye.k, bye);
|
|
26312
24887
|
this.took(bye);
|
|
@@ -26331,9 +24906,9 @@ var GroupSession = class {
|
|
|
26331
24906
|
* An automatic commit (a member's leave): made, or not made when no fresh turn read says this device is the active
|
|
26332
24907
|
* one (`adminTurn`). Then false, and the caller goes on as a member would. Any other refusal is thrown as before.
|
|
26333
24908
|
*/
|
|
26334
|
-
async commitUnlessTurnUnconfirmed(kind, subject) {
|
|
24909
|
+
async commitUnlessTurnUnconfirmed(kind, subject, left = false) {
|
|
26335
24910
|
try {
|
|
26336
|
-
await this.commit(kind, subject, Date.now());
|
|
24911
|
+
await this.commit(kind, subject, Date.now(), left);
|
|
26337
24912
|
return true;
|
|
26338
24913
|
} catch (error) {
|
|
26339
24914
|
if (error instanceof EngineError && error.engineCode === "groupTurnUnconfirmed") return false;
|
|
@@ -26342,7 +24917,8 @@ var GroupSession = class {
|
|
|
26342
24917
|
}
|
|
26343
24918
|
/**
|
|
26344
24919
|
* A message, from its author's edge or handed on by another member. Handed on, it is taken only while both are in
|
|
26345
|
-
* the roster (someone removed is neither heard from nor handed on, #300),
|
|
24920
|
+
* the roster (someone removed is neither heard from nor handed on, #300), or from a member in it when its author left
|
|
24921
|
+
* after it (`handsOn`), and its boxes only with the author's `xs`.
|
|
26346
24922
|
*/
|
|
26347
24923
|
async receiveMessage(from, raw) {
|
|
26348
24924
|
if (!isMessageFrame$1(raw)) return;
|
|
@@ -26351,7 +24927,7 @@ var GroupSession = class {
|
|
|
26351
24927
|
return;
|
|
26352
24928
|
}
|
|
26353
24929
|
const relayed = raw.s !== from;
|
|
26354
|
-
if (relayed && (!rosterHas(this.roster, from) || !
|
|
24930
|
+
if (relayed && (!rosterHas(this.roster, from) || !this.handsOn(raw.s, raw.e))) return;
|
|
26355
24931
|
if (!verify(fromBase64Url(raw.sig), messageSigned$1(raw), publicKeyFromZ32(raw.s))) return;
|
|
26356
24932
|
if (raw.e > this.epoch) {
|
|
26357
24933
|
this.park(from, raw);
|
|
@@ -26389,10 +24965,11 @@ var GroupSession = class {
|
|
|
26389
24965
|
n: first.n
|
|
26390
24966
|
};
|
|
26391
24967
|
if (firstFrame && rosterHas(this.state.chain[first.e]?.m ?? [], this.myKey) && this.isDuplicate(firstFrame)) {
|
|
24968
|
+
await this.hooks.kept?.(groupMessageId(raw.s, first.e, first.n), clean(raw));
|
|
26392
24969
|
this.markSeen(raw);
|
|
26393
24970
|
this.keep(clean(raw));
|
|
26394
24971
|
this.persistSoon();
|
|
26395
|
-
if (
|
|
24972
|
+
if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
|
|
26396
24973
|
return;
|
|
26397
24974
|
}
|
|
26398
24975
|
const id = first && !this.provisional.has(own) ? groupMessageId(raw.s, first.e, first.n) : own;
|
|
@@ -26429,7 +25006,7 @@ var GroupSession = class {
|
|
|
26429
25006
|
if (firstFrame && rosterHas(this.state.chain[firstFrame.e]?.m ?? [], this.myKey)) this.markSeen(firstFrame);
|
|
26430
25007
|
this.keep(clean(raw));
|
|
26431
25008
|
this.persistSoon();
|
|
26432
|
-
if (
|
|
25009
|
+
if (this.handsOn(raw.s, raw.e)) this.took(clean(raw));
|
|
26433
25010
|
}
|
|
26434
25011
|
/**
|
|
26435
25012
|
* An edit of a member's own message, from its author's edge or handed on by another member: believed only while its
|
|
@@ -26463,7 +25040,7 @@ var GroupSession = class {
|
|
|
26463
25040
|
const k = validMentions(body.m, body.text, rosterAdmin(commit.m) === raw.s && this.admin === raw.s);
|
|
26464
25041
|
const sc = body.sc === void 0 ? void 0 : readStatusCard(body.sc);
|
|
26465
25042
|
if (!validEditNumber(raw.v) && !sc) return;
|
|
26466
|
-
const copy =
|
|
25043
|
+
const copy = this.kept().find((f) => f.s === raw.s && f.e === raw.e && f.n === raw.n);
|
|
26467
25044
|
const first = copy && readFirstPlace(copy.o, copy.e);
|
|
26468
25045
|
await this.hooks.edit?.({
|
|
26469
25046
|
id: first ? groupMessageId(raw.s, first.e, first.n) : groupMessageId(raw.s, raw.e, raw.n),
|
|
@@ -26595,6 +25172,7 @@ var GroupSession = class {
|
|
|
26595
25172
|
const result = verifyCommit(raw, this.state.chain[e - 1] ?? null, this.id);
|
|
26596
25173
|
if ("error" in result || result.commit.by !== known.by) return;
|
|
26597
25174
|
if (commitHash(result.commit) !== commitHash(known)) await this.fork(`Member ${from.slice(0, 8)} holds a different membership history for epoch ${e}`);
|
|
25175
|
+
else if (this.takeLeftMark(known, frame.left)) await this.persist();
|
|
26598
25176
|
return;
|
|
26599
25177
|
}
|
|
26600
25178
|
if (e > this.epoch + 1) {
|
|
@@ -26608,11 +25186,13 @@ var GroupSession = class {
|
|
|
26608
25186
|
if (result.error.startsWith("Commit does not follow") && verifyCommitSignature(raw)?.by === this.admin) await this.fork(`Member ${from.slice(0, 8)} holds a different membership history for epoch ${e}`);
|
|
26609
25187
|
return;
|
|
26610
25188
|
}
|
|
25189
|
+
const mark = this.takeLeftMark(result.commit, frame.left) ? frame.left : void 0;
|
|
26611
25190
|
await this.apply(result.commit, frame.secret);
|
|
26612
25191
|
this.took({
|
|
26613
25192
|
t: "group-commit",
|
|
26614
25193
|
g: this.id,
|
|
26615
|
-
commit: result.commit
|
|
25194
|
+
commit: result.commit,
|
|
25195
|
+
...mark ? { left: mark } : {}
|
|
26616
25196
|
});
|
|
26617
25197
|
if (this.state.status === "active" && !this.state.secrets[this.epoch]) this.ask(from);
|
|
26618
25198
|
const next = this.pendingCommits.get(this.epoch + 1);
|
|
@@ -26621,6 +25201,27 @@ var GroupSession = class {
|
|
|
26621
25201
|
await this.receiveCommit(from, next);
|
|
26622
25202
|
}
|
|
26623
25203
|
}
|
|
25204
|
+
/**
|
|
25205
|
+
* Whether a frame of `author` under `epoch` is taken from another member, kept and handed on: while its author is in
|
|
25206
|
+
* the roster, or when it left (its removal marked `left`, and it is not back in) and the frame is from before that.
|
|
25207
|
+
*/
|
|
25208
|
+
handsOn(author, epoch) {
|
|
25209
|
+
if (rosterHas(this.roster, author)) return true;
|
|
25210
|
+
const chain = this.state.chain;
|
|
25211
|
+
for (let e = chain.length - 1; e > 0; e--) if (chain[e].k === "remove" && chain[e].s === author) return !!this.state.leftMarks?.[e] && epoch < e;
|
|
25212
|
+
return false;
|
|
25213
|
+
}
|
|
25214
|
+
/** Keeps the admin's mark on a commit that took out a member who left (`GroupCommitFrame.left`) when it holds; true when new. */
|
|
25215
|
+
takeLeftMark(commit, mark) {
|
|
25216
|
+
if (commit.k !== "remove" || typeof mark !== "string" || mark.length !== 86 || !B64$1.test(mark) || this.state.leftMarks?.[commit.e]) return false;
|
|
25217
|
+
try {
|
|
25218
|
+
if (!verify(fromBase64Url(mark), leftSigned(commit), publicKeyFromZ32(commit.by))) return false;
|
|
25219
|
+
} catch {
|
|
25220
|
+
return false;
|
|
25221
|
+
}
|
|
25222
|
+
(this.state.leftMarks ??= {})[commit.e] = mark;
|
|
25223
|
+
return true;
|
|
25224
|
+
}
|
|
26624
25225
|
async apply(commit, sealed) {
|
|
26625
25226
|
this.state.chain.push(commit);
|
|
26626
25227
|
this.chainMovedAt = this.clock();
|
|
@@ -26683,11 +25284,13 @@ var GroupSession = class {
|
|
|
26683
25284
|
const answer = [];
|
|
26684
25285
|
for (let e = frame.e + 1; e <= this.epoch; e++) {
|
|
26685
25286
|
const commit = this.state.chain[e], secret = this.secret(e);
|
|
25287
|
+
const left = this.state.leftMarks?.[e];
|
|
26686
25288
|
answer.push({
|
|
26687
25289
|
t: "group-commit",
|
|
26688
25290
|
g: this.id,
|
|
26689
25291
|
commit,
|
|
26690
|
-
...secret && rosterHas(commit.m, from) ? { secret: sealSecret(from, secret, secretAad$1(this.id, e, from)) } : {}
|
|
25292
|
+
...secret && rosterHas(commit.m, from) ? { secret: sealSecret(from, secret, secretAad$1(this.id, e, from)) } : {},
|
|
25293
|
+
...left ? { left } : {}
|
|
26691
25294
|
});
|
|
26692
25295
|
}
|
|
26693
25296
|
const theirs = new Set(Array.isArray(frame.secrets) ? frame.secrets.filter((n) => Number.isSafeInteger(n)) : []);
|
|
@@ -26702,7 +25305,15 @@ var GroupSession = class {
|
|
|
26702
25305
|
});
|
|
26703
25306
|
const lacks = missingIn(frame, from);
|
|
26704
25307
|
const asked = new Set(Array.isArray(frame.ask) ? frame.ask.filter((k) => typeof k === "string" && k !== from && k !== this.myKey && rosterHas(this.roster, k)) : []);
|
|
26705
|
-
const
|
|
25308
|
+
const handed = this.leaversHanded.get(from) ?? /* @__PURE__ */ new Set();
|
|
25309
|
+
const kept = !asked.size && !this.state.leftMarks ? [] : this.kept().filter((f) => {
|
|
25310
|
+
if (!this.state.chain[f.e] || !rosterHas(this.state.chain[f.e].m, from) || !lacks(f)) return false;
|
|
25311
|
+
if (asked.has(f.s)) return true;
|
|
25312
|
+
if (rosterHas(this.roster, f.s) || handed.has(frameKey$1(f)) || !this.handsOn(f.s, f.e)) return false;
|
|
25313
|
+
handed.add(frameKey$1(f));
|
|
25314
|
+
return true;
|
|
25315
|
+
});
|
|
25316
|
+
if (handed.size) this.leaversHanded.set(from, handed);
|
|
26706
25317
|
answer.push(...asWritten(this.ownLacked(from, frame), kept));
|
|
26707
25318
|
for (const author of asked) {
|
|
26708
25319
|
const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
|
|
@@ -27009,9 +25620,10 @@ function knockIdentity(link) {
|
|
|
27009
25620
|
function knockRecords(link, knocks) {
|
|
27010
25621
|
const nonce = randomBytes(NONCE_LENGTH);
|
|
27011
25622
|
const body = utf8Encode(JSON.stringify(knocks.slice(-6).map((k) => [k.key, k.ts])));
|
|
25623
|
+
const box = xchacha20poly1305(knockMaterial(link, "knock key"), nonce, utf8Encode(link.g)).encrypt(body);
|
|
27012
25624
|
return [{
|
|
27013
25625
|
label: KNOCK_LABEL,
|
|
27014
|
-
value: toBase64Url(concatBytes$1(nonce,
|
|
25626
|
+
value: toBase64Url(concatBytes$1(nonce, box))
|
|
27015
25627
|
}];
|
|
27016
25628
|
}
|
|
27017
25629
|
/** Knocks that open with the link's key and are well formed; anything else is nobody. */
|
|
@@ -27098,6 +25710,8 @@ var COMMUNITY_LIMITS = {
|
|
|
27098
25710
|
/** Commits in one welcome or chain piece. */
|
|
27099
25711
|
chainPiece: 100,
|
|
27100
25712
|
pendingLeaves: 64,
|
|
25713
|
+
/** Leaves whose mark (`group-left`) is kept, and the epochs one names. */
|
|
25714
|
+
leftMarks: 64,
|
|
27101
25715
|
/**
|
|
27102
25716
|
* Syncs of one member answered in `syncWindowMs`: each answer may carry the chain's tail, secrets and the whole
|
|
27103
25717
|
* store. A member catching up asks every five seconds at most, and every member syncs every thirty; one asking more
|
|
@@ -27214,6 +25828,14 @@ var leaveStatement = (g, s) => utf8Encode(JSON.stringify([
|
|
|
27214
25828
|
g,
|
|
27215
25829
|
s
|
|
27216
25830
|
]));
|
|
25831
|
+
/** What the member who committed a leave (`h`) had heard of the leaver then: `e:h16` → its highest number there. */
|
|
25832
|
+
var leftStatement = (g, h, hi) => utf8Encode(JSON.stringify([
|
|
25833
|
+
"ghostly-group/2 left",
|
|
25834
|
+
g,
|
|
25835
|
+
h,
|
|
25836
|
+
Object.entries(hi).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
25837
|
+
]));
|
|
25838
|
+
var LEFT_EPOCH = /^\d{1,15}:[a-f0-9]{16}$/;
|
|
27217
25839
|
/** The secret of an epoch that adds, re-roles or re-links: from the previous one and the commit. */
|
|
27218
25840
|
function derivedSecret(previous, untaggedHash) {
|
|
27219
25841
|
return hkdf(sha256, previous, utf8Encode(untaggedHash), utf8Encode("ghostly-group/2 derived"), 32);
|
|
@@ -27677,6 +26299,60 @@ var CommunitySession = class {
|
|
|
27677
26299
|
return -1;
|
|
27678
26300
|
}
|
|
27679
26301
|
/**
|
|
26302
|
+
* Whether a frame of someone the chain took out is still heard. A member removed is heard no more, even for an epoch
|
|
26303
|
+
* it was in. What one who left wrote while in the group is taken and handed on, its goodbye included, up to what the
|
|
26304
|
+
* member who committed the leave had heard of it then (its `group-left` mark), never past it: the leaver still holds
|
|
26305
|
+
* those epochs' secrets, and anything it signs later is not heard (WISP 903 § Leaving and removal). No mark here
|
|
26306
|
+
* (one not come yet, or a committer's app before it) and nothing of it is heard.
|
|
26307
|
+
*/
|
|
26308
|
+
heardAfterOut(f) {
|
|
26309
|
+
const i = this.outIndex(f.s);
|
|
26310
|
+
if (i < 0) return true;
|
|
26311
|
+
const commit = this.state.chain[i];
|
|
26312
|
+
if (commit.k !== "leave") return false;
|
|
26313
|
+
const high = this.state.left?.[communityCommitHash(commit)]?.hi[seenKey(f.e, f.h)];
|
|
26314
|
+
return high !== void 0 && f.n <= high;
|
|
26315
|
+
}
|
|
26316
|
+
/** The mark of a leave on my branch, as a frame to hand on (see `heardAfterOut`). */
|
|
26317
|
+
leftFrame(key) {
|
|
26318
|
+
const i = this.outIndex(key);
|
|
26319
|
+
if (i < 0 || this.state.chain[i].k !== "leave") return void 0;
|
|
26320
|
+
const h = communityCommitHash(this.state.chain[i]), mark = this.state.left?.[h];
|
|
26321
|
+
return mark && {
|
|
26322
|
+
t: "group-left",
|
|
26323
|
+
v: 2,
|
|
26324
|
+
g: this.id,
|
|
26325
|
+
h,
|
|
26326
|
+
hi: mark.hi,
|
|
26327
|
+
sig: mark.sig
|
|
26328
|
+
};
|
|
26329
|
+
}
|
|
26330
|
+
keepLeft(h, mark) {
|
|
26331
|
+
const left = { ...this.state.left ?? {} };
|
|
26332
|
+
delete left[h];
|
|
26333
|
+
left[h] = mark;
|
|
26334
|
+
const keys = Object.keys(left);
|
|
26335
|
+
for (const old of keys.slice(0, Math.max(0, keys.length - COMMUNITY_LIMITS.leftMarks))) delete left[old];
|
|
26336
|
+
this.state.left = left;
|
|
26337
|
+
}
|
|
26338
|
+
/** A leave's mark, from the member who committed it: kept when it is that member's, for a leave I hold. */
|
|
26339
|
+
receiveLeft(frame) {
|
|
26340
|
+
const { h, hi, sig } = frame;
|
|
26341
|
+
if (typeof h !== "string" || !HASH.test(h) || typeof sig !== "string" || !SIG.test(sig) || !isObject$2(hi)) return false;
|
|
26342
|
+
const entries = Object.entries(hi);
|
|
26343
|
+
if (entries.length > COMMUNITY_LIMITS.leftMarks || entries.some(([k, n]) => !LEFT_EPOCH.test(k) || !Number.isSafeInteger(n) || n < 0)) return false;
|
|
26344
|
+
const commit = this.known.get(h);
|
|
26345
|
+
if (!commit || commit.k !== "leave" || this.state.left?.[h]?.sig === sig) return false;
|
|
26346
|
+
const marks = hi;
|
|
26347
|
+
if (!verify(fromBase64Url(sig), leftStatement(this.id, h, marks), publicKeyFromZ32(commit.by))) return false;
|
|
26348
|
+
this.keepLeft(h, {
|
|
26349
|
+
hi: marks,
|
|
26350
|
+
sig
|
|
26351
|
+
});
|
|
26352
|
+
this.persistSoon();
|
|
26353
|
+
return true;
|
|
26354
|
+
}
|
|
26355
|
+
/**
|
|
27680
26356
|
* What someone the chain took out is told when it comes back (WISP 903 § Leaving and removal): the commits from where
|
|
27681
26357
|
* it says it is (`sync`: its tip, or its locator) up to the one that took it out, and nothing after. Later commits
|
|
27682
26358
|
* are not its business (a `link` commit names the new entry key); no secret, no stored frame, no seed goes with them.
|
|
@@ -28276,10 +26952,11 @@ var CommunitySession = class {
|
|
|
28276
26952
|
continue;
|
|
28277
26953
|
}
|
|
28278
26954
|
try {
|
|
28279
|
-
await this.commit("leave", {
|
|
26955
|
+
const { hash } = await this.commit("leave", {
|
|
28280
26956
|
s: request.s,
|
|
28281
26957
|
ls: request.ls
|
|
28282
26958
|
}, now);
|
|
26959
|
+
this.markLeft(hash, request.s);
|
|
28283
26960
|
done++;
|
|
28284
26961
|
} catch {}
|
|
28285
26962
|
this.state.pendingLeaves = this.state.pendingLeaves.filter((r) => r !== request);
|
|
@@ -28288,6 +26965,25 @@ var CommunitySession = class {
|
|
|
28288
26965
|
return done;
|
|
28289
26966
|
});
|
|
28290
26967
|
}
|
|
26968
|
+
/** Signs and says how far I had heard a member whose leave I just committed (`group-left`). */
|
|
26969
|
+
markLeft(h, leaver) {
|
|
26970
|
+
const hi = {};
|
|
26971
|
+
const heard = Object.entries(this.state.seen[leaver] ?? {}).filter(([k]) => LEFT_EPOCH.test(k)).slice(-COMMUNITY_LIMITS.leftMarks);
|
|
26972
|
+
for (const [k, entry] of heard) hi[k] = entry.high;
|
|
26973
|
+
const sig = toBase64Url(sign(leftStatement(this.id, h, hi), this.identity.seed));
|
|
26974
|
+
this.keepLeft(h, {
|
|
26975
|
+
hi,
|
|
26976
|
+
sig
|
|
26977
|
+
});
|
|
26978
|
+
this.hooks.broadcast({
|
|
26979
|
+
t: "group-left",
|
|
26980
|
+
v: 2,
|
|
26981
|
+
g: this.id,
|
|
26982
|
+
h,
|
|
26983
|
+
hi,
|
|
26984
|
+
sig
|
|
26985
|
+
});
|
|
26986
|
+
}
|
|
28291
26987
|
out(status, reason) {
|
|
28292
26988
|
this.state.status = status;
|
|
28293
26989
|
this.state.statusReason = reason;
|
|
@@ -28611,6 +27307,7 @@ var CommunitySession = class {
|
|
|
28611
27307
|
return false;
|
|
28612
27308
|
case "group-meta": return this.receiveMeta(from, raw);
|
|
28613
27309
|
case "group-leave": return this.receiveLeave(frame);
|
|
27310
|
+
case "group-left": return this.receiveLeft(frame);
|
|
28614
27311
|
case "group-entry": {
|
|
28615
27312
|
if (frame.to !== this.myKey || !isSealed(frame.s) || frame.x !== this.currentEntryKey()) return false;
|
|
28616
27313
|
const seed = openSecret(this.identity.seed, this.myKey, frame.s, entryAad(this.id, this.myKey));
|
|
@@ -28639,7 +27336,7 @@ var CommunitySession = class {
|
|
|
28639
27336
|
return false;
|
|
28640
27337
|
}
|
|
28641
27338
|
if (!verify(fromBase64Url(raw.sig), messageSigned(raw), publicKeyFromZ32(raw.s))) return false;
|
|
28642
|
-
if (this.
|
|
27339
|
+
if (!this.heardAfterOut(raw)) return false;
|
|
28643
27340
|
const found = this.commitByShort(raw.e, raw.h);
|
|
28644
27341
|
if (!found) {
|
|
28645
27342
|
this.park(from, raw);
|
|
@@ -28879,16 +27576,22 @@ var CommunitySession = class {
|
|
|
28879
27576
|
const have = frame.have && typeof frame.have === "object" ? frame.have : {};
|
|
28880
27577
|
const handed = /* @__PURE__ */ new Set();
|
|
28881
27578
|
const unheard = new Set(this.state.unheard ?? []);
|
|
27579
|
+
const marked = /* @__PURE__ */ new Set();
|
|
28882
27580
|
for (const stored of this.state.store) {
|
|
28883
27581
|
if (stored.s === from) continue;
|
|
28884
27582
|
const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
|
|
28885
27583
|
if (Number.isSafeInteger(high) && high >= stored.n) continue;
|
|
28886
|
-
if (this.
|
|
27584
|
+
if (!this.heardAfterOut(stored)) continue;
|
|
28887
27585
|
const found = this.commitByShort(stored.e, stored.h);
|
|
28888
27586
|
if (!found) continue;
|
|
28889
27587
|
const reads = rosterHas(this.rosterAt(found.hash) ?? [], from), mine = stored.s === this.myKey && unheard.has(frameKey(stored));
|
|
28890
27588
|
if (!reads && !(mine && rosterHas(this.roster, from))) continue;
|
|
28891
27589
|
if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed, answer);
|
|
27590
|
+
if (!rosterHas(this.roster, stored.s) && !marked.has(stored.s)) {
|
|
27591
|
+
marked.add(stored.s);
|
|
27592
|
+
const left = this.leftFrame(stored.s);
|
|
27593
|
+
if (left) answer.push(left);
|
|
27594
|
+
}
|
|
28892
27595
|
answer.push(stored);
|
|
28893
27596
|
if (mine && reads) unheard.delete(frameKey(stored));
|
|
28894
27597
|
}
|
|
@@ -29550,8 +28253,63 @@ function appDigest(manifestBytes) {
|
|
|
29550
28253
|
return toBase64Url(sha256(manifestBytes));
|
|
29551
28254
|
}
|
|
29552
28255
|
var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
|
|
29553
|
-
/**
|
|
29554
|
-
|
|
28256
|
+
/**
|
|
28257
|
+
* Lets the thread go for one turn: what waits (input, drawing, timers, port messages, IndexedDB answers) runs, then the
|
|
28258
|
+
* caller goes on. A message to itself, queued behind the page's other tasks. Never `scheduler.yield()`: in Chromium its
|
|
28259
|
+
* continuation outranks every normal task, so a paced read of 4096 revocations held the page's timers, its apps' broker
|
|
28260
|
+
* ports and IndexedDB for the whole 1.8 s. Never `setTimeout(0)` but as the last resort: from the fifth nested timer on
|
|
28261
|
+
* the browser waits at least 4 ms (HTML's clamp), which made that read take 60% longer than the work, and a hidden tab
|
|
28262
|
+
* runs timers once a second.
|
|
28263
|
+
*/
|
|
28264
|
+
function yieldThread() {
|
|
28265
|
+
if (typeof MessageChannel === "function") return new Promise((resolve) => {
|
|
28266
|
+
const channel = new MessageChannel();
|
|
28267
|
+
channel.port1.onmessage = () => {
|
|
28268
|
+
channel.port1.close();
|
|
28269
|
+
resolve();
|
|
28270
|
+
};
|
|
28271
|
+
channel.port2.postMessage(null);
|
|
28272
|
+
});
|
|
28273
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
28274
|
+
}
|
|
28275
|
+
/**
|
|
28276
|
+
* A pause for a loop of short steps: `await pause()` after each step lets the thread go once `sliceMs` of work has
|
|
28277
|
+
* run since the last time, and answers at once otherwise.
|
|
28278
|
+
*/
|
|
28279
|
+
function pacer(sliceMs) {
|
|
28280
|
+
let since = performance.now();
|
|
28281
|
+
return () => {
|
|
28282
|
+
if (performance.now() - since < sliceMs) return void 0;
|
|
28283
|
+
return yieldThread().then(() => {
|
|
28284
|
+
since = performance.now();
|
|
28285
|
+
});
|
|
28286
|
+
};
|
|
28287
|
+
}
|
|
28288
|
+
/** `sha256Paced` hashes up to this much at once: under a millisecond of the thread. */
|
|
28289
|
+
var HASH_AT_ONCE = 65536;
|
|
28290
|
+
/** Without `crypto.subtle`, `sha256Paced` hashes this much (a few ms), then lets the thread go. */
|
|
28291
|
+
var HASH_SLICE = 262144;
|
|
28292
|
+
/** `readAppBundleAsync` hashes files for this long at most before it lets the thread go, however small each file. */
|
|
28293
|
+
var HASH_SLICE_MS = 5;
|
|
28294
|
+
/**
|
|
28295
|
+
* SHA-256 of bytes that may be a whole bundle, for a client whose engine shares its page's thread: `sha256` in
|
|
28296
|
+
* JavaScript takes about 12 ms per MiB on a desktop CPU, so 16 MiB in one run is a page that draws nothing and takes no
|
|
28297
|
+
* input for 200 ms. With `crypto.subtle` the bytes are hashed off the thread; without it (a page not served over
|
|
28298
|
+
* HTTPS), a slice at a time with a pause between slices. The bytes must not change until it answers.
|
|
28299
|
+
*/
|
|
28300
|
+
async function sha256Paced(bytes) {
|
|
28301
|
+
if (bytes.length <= HASH_AT_ONCE) return sha256(bytes);
|
|
28302
|
+
const subtle = globalThis.crypto?.subtle;
|
|
28303
|
+
const away = subtle ? await subtle.digest("SHA-256", bytes).catch(() => null) : null;
|
|
28304
|
+
if (away) return new Uint8Array(away);
|
|
28305
|
+
const hash = sha256.create();
|
|
28306
|
+
for (let at = 0; at < bytes.length; at += HASH_SLICE) {
|
|
28307
|
+
if (at) await yieldThread();
|
|
28308
|
+
hash.update(bytes.subarray(at, at + HASH_SLICE));
|
|
28309
|
+
}
|
|
28310
|
+
return hash.digest();
|
|
28311
|
+
}
|
|
28312
|
+
function readAppBundleHead(bytes) {
|
|
29555
28313
|
if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
|
|
29556
28314
|
ok: false,
|
|
29557
28315
|
reason: "too-large"
|
|
@@ -29610,17 +28368,18 @@ function readAppBundle(bytes) {
|
|
|
29610
28368
|
};
|
|
29611
28369
|
const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
|
|
29612
28370
|
if (!signed.ok) return signed;
|
|
29613
|
-
|
|
29614
|
-
|
|
29615
|
-
|
|
29616
|
-
|
|
29617
|
-
|
|
29618
|
-
|
|
29619
|
-
|
|
29620
|
-
|
|
29621
|
-
|
|
29622
|
-
|
|
29623
|
-
|
|
28371
|
+
return {
|
|
28372
|
+
ok: true,
|
|
28373
|
+
head: {
|
|
28374
|
+
manifest,
|
|
28375
|
+
manifestBytes,
|
|
28376
|
+
signature: statement.signature,
|
|
28377
|
+
filesAt: at
|
|
28378
|
+
}
|
|
28379
|
+
};
|
|
28380
|
+
}
|
|
28381
|
+
/** The last check, once every file's hash held: the icon. */
|
|
28382
|
+
function closeAppBundle({ manifest, manifestBytes, signature }, files) {
|
|
29624
28383
|
const icon = files.get(APP_ICON_PATH);
|
|
29625
28384
|
if (icon) {
|
|
29626
28385
|
const size = imageSize(icon);
|
|
@@ -29641,11 +28400,54 @@ function readAppBundle(bytes) {
|
|
|
29641
28400
|
manifest,
|
|
29642
28401
|
manifestBytes,
|
|
29643
28402
|
digest: appDigest(manifestBytes),
|
|
29644
|
-
signature
|
|
28403
|
+
signature,
|
|
29645
28404
|
files
|
|
29646
28405
|
}
|
|
29647
28406
|
};
|
|
29648
28407
|
}
|
|
28408
|
+
/** Reads and checks a bundle (the order of the checks is part of the format: see above). */
|
|
28409
|
+
function readAppBundle(bytes) {
|
|
28410
|
+
const read = readAppBundleHead(bytes);
|
|
28411
|
+
if (!read.ok) return read;
|
|
28412
|
+
const files = /* @__PURE__ */ new Map();
|
|
28413
|
+
let at = read.head.filesAt;
|
|
28414
|
+
for (const f of read.head.manifest.files) {
|
|
28415
|
+
const content = bytes.subarray(at, at + f.size);
|
|
28416
|
+
at += f.size;
|
|
28417
|
+
if (toBase64Url(sha256(content)) !== f.sha256) return {
|
|
28418
|
+
ok: false,
|
|
28419
|
+
reason: "file-hash",
|
|
28420
|
+
detail: f.path
|
|
28421
|
+
};
|
|
28422
|
+
files.set(f.path, content);
|
|
28423
|
+
}
|
|
28424
|
+
return closeAppBundle(read.head, files);
|
|
28425
|
+
}
|
|
28426
|
+
/**
|
|
28427
|
+
* `readAppBundle` for a client whose engine shares its page's thread: the same checks in the same order, with the same
|
|
28428
|
+
* refusals, and each file hashed by `sha256Paced`, one after the other, with the thread let go between files every
|
|
28429
|
+
* `HASH_SLICE_MS` (64 files of 64 KiB, each hashed at once, held it for 50 ms). The bytes must not change until it
|
|
28430
|
+
* answers.
|
|
28431
|
+
*/
|
|
28432
|
+
async function readAppBundleAsync(bytes) {
|
|
28433
|
+
const read = readAppBundleHead(bytes);
|
|
28434
|
+
if (!read.ok) return read;
|
|
28435
|
+
const files = /* @__PURE__ */ new Map();
|
|
28436
|
+
const pause = pacer(HASH_SLICE_MS);
|
|
28437
|
+
let at = read.head.filesAt;
|
|
28438
|
+
for (const f of read.head.manifest.files) {
|
|
28439
|
+
const content = bytes.subarray(at, at + f.size);
|
|
28440
|
+
at += f.size;
|
|
28441
|
+
if (toBase64Url(await sha256Paced(content)) !== f.sha256) return {
|
|
28442
|
+
ok: false,
|
|
28443
|
+
reason: "file-hash",
|
|
28444
|
+
detail: f.path
|
|
28445
|
+
};
|
|
28446
|
+
files.set(f.path, content);
|
|
28447
|
+
await pause();
|
|
28448
|
+
}
|
|
28449
|
+
return closeAppBundle(read.head, files);
|
|
28450
|
+
}
|
|
29649
28451
|
/** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
|
|
29650
28452
|
function assembleAppBundle(manifestBytes, statementBytes, files) {
|
|
29651
28453
|
const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
|
|
@@ -30007,6 +28809,6 @@ function appStoreDecision(held, read) {
|
|
|
30007
28809
|
return read.digest === held.digest ? "same" : "equivocation";
|
|
30008
28810
|
}
|
|
30009
28811
|
//#endregion
|
|
30010
|
-
export { parseCommunityEdit as $, receivedTimestamp as $a, identityFromSeed as $c, DHT_FILE_TOO_LARGE as $i, readGroupHave as $n, HANDOFF_PAKE as $o, vapidKeysMatch as $r, verifyEnrollProof as $s, signDidDhtPacket as $t, encodeCommunityLink as A, reactionEmoji as Aa, signTurnPacket as Ac, DHT_TEXT_CAPABILITY as Ai, resolveLightningDestination as An, webLocalFetch as Ao, FILE_LIMITS as Ar, readHandoffTurnFrame as As, DidError as At, GroupWakeLimiter as B, PR_CHECKS as Ba, normalizeRelayUrl as Bc, identityRevocationValue as Bi, INVITE_TAKEN as Bn, extractParamsFromSdp as Bo, mayPin as Br, ENROLL_DONE as Bs, didWebHost as Bt, openGroupTyping as C, readForwarded as Ca, classifyTurnRead as Cc, normalizeDomain as Ci, normalizePubkyKey as Cn, DHT_POLL_INTERVALS as Co, pickHubs as Cr, newHandoffId as Cs, parseAtprotoDidDocument as Ct, GROUP_READ_NOTE_COMMUNITY as D, ReactionWindow as Da, firstDeviceSetSecret as Dc, txtValue as Di, homeserverWebEndpoint as Dn, GhostlyHttpError as Do, readLobby as Dr, readHandoffHave as Ds, DID_DOCUMENT_MAX_BYTES as Dt, CommunitySession as E, REACTION_LIMITS as Ea, TURN_MAX as Ec, recordsName as Ei, pubkyProofPath as En, presenceSeenAt as Eo, readBeaconHead as Er, readHandoffDone as Es, verifyAtprotoRecordProof as Et, knockIdentity as F, pairedReplyAuthor as Fa, turnStatedVerifier as Fc, IDENTITY_REVOCATION_LABEL as Fi, findEndpoint as Fn, RTC_CONFIG as Fo, TypingSender as Fr, readSetUpdate as Fs, didSignedMessages as Ft, GROUP_REACTED_FRAME as G, cardLine as Ga, withRequestOptions as Gc, lastSharedWithMe as Gi, GROUP_FILE_LIMITS as Gn, LEGACY_SERVICES as Go, WAKE_CALL_INTERVAL_MS as Gr, enrollCancelReason as Gs, signingKeys as Gt, groupWakes as H, RUN_RESULTS as Ha, traceLink as Hc, newIdentityBinding as Hi, pairedMessageFrame as Hn, signalHasVideo as Ho, pinIsNewer as Hr, ENROLL_HELLO as Hs, parseDid as Ht, knockRecords as I, readReply as Ia, turnVerifierMark as Ic, IdentityCheckUnavailable as Ii, isFederationId as In, answersOffer as Io, sanitizeTypingStatus as Ir, setAckFrame as Is, didWebDocumentUrl as It, frameBytes as J, sameButtonText as Ja, createRelayPayload as Jc, validExternalKey as Ji, GROUP_WANT_NO_FRAME as Jn, HANDOFF_CANCEL as Jo, newWakeToken as Jr, enrollGrantFrame as Js, didDhtDocument as Jt, GROUP_READ_NOTE as K, cardUrl as Ka, PacketTooLargeError as Kc, PeerProofs as Ki, GROUP_HAVE_FRAME as Kn, serviceIdFromName as Ko, WakeLimiter as Kr, enrollDigits as Ks, staticDidDocument as Kt, mergeKnocks as L, replySnippet as La, DEFAULT_RELAYS as Lc, IdentityExchange as Li, parseCashuRequestPayload as Ln, buildSdpFromSignal as Lo, typingActivity as Lr, setUpdateFrame as Ls, didWebFile as Lt, decodeGroupEntryLink as M, readReaction as Ma, turnInstance as Mc, dialDescriptors as Mi, ENDPOINT as Mn, decodeControl as Mo, transferStalled as Mr, sealHandoffPiece as Ms, checkDidSignature as Mt, encodeGroupEntryLink as N, validReactionNumber as Na, turnKeys as Nc, IDENTITY_MAX_EVIDENCE as Ni, cashuRequestPayload as Nn, encodeControl as No, TYPING_KINDS as Nr, SET_ACK as Ns, didGhostlyServiceEntry as Nt, communityHasFrame as O, nextReactionNumber as Oa, nextTurnPosition as Oc, wellKnownUrl as Oi, pubkyHomeserverOf as On, formatLocalTarget as Oo, shouldBeHub as Or, readHandoffHello as Os, DID_FILE_MAX_BYTES as Ot, entryParams as P, wireReaction as Pa, turnName as Pc, IDENTITY_MAX_VALIDITY as Pi, fedimintRequestPayload as Pn, CALL_SIGNAL_MAX_AGE_MS as Po, TYPING_REFRESH_MS as Pr, SET_UPDATE as Ps, didJwsAlg as Pt, carryMentions as Q, heardTime as Qa, createIdentity as Qc, irohRelayUrl as Qi, readGroupFileMeta as Qn, HANDOFF_OFFER as Qo, checkPushEndpoint as Qr, readEnrollHello as Qs, resolveDidDht as Qt, readKnocks as R, BUTTON_ID as Ra, RelayTransport as Rc, boundVerifiedIdentity as Ri, parseFedimintRequestPayload as Rn, callRtcConfig as Ro, GROUP_PIN_FRAME as Rr, signTombstone as Rs, didWebFileNames as Rt, groupTypingEpoch as S, forwardedAgain as Sa, TURN_SETTLE_MS as Sc, nip05RootKey as Si, newPubkyProofFolder as Sn, DirectPathWatch as So, newerHead as Sr, handoffVersions as Ss, normalizeAtprotoHandle as St, COMMUNITY_LIMITS as T, GROUP_REACTION_FRAME as Ta, TURN_LAST_SEQUENCE as Tc, recordsFromWellKnown as Ti, pubkyProofFile as Tn, RELAY_POLL_INTERVALS as To, readBeacon as Tr, readHandoffCancel as Ts, AtprotoProofError as Tt, parseGroupWakeFrame as U, STATUS_CARD_LIMITS as Ua, budgetRetryMs as Uc, receivedIdentityStatus as Ui, engineError as Un, emptyLinkRecords as Uo, pinNumberHolds as Ur, ENROLL_PROOF as Us, parseDidDocument as Ut, groupWakeFrame as V, PR_STATES as Va, setLinkTraceSink as Vc, identityStatement as Vi, LIVENESS_PING_MS as Vn, parseCallSignal as Vo, nextPinNumber as Vr, ENROLL_GRANT as Vs, normalizeDid as Vt, GROUP_LIMITS as W, buttonLabelClash as Wa, isDiscoveryBudgetError as Wc, revokesIdentity as Wi, engineText as Wn, HTTP_SERVICE_PROTO as Wo, readPin as Wr, enrollCancelFrame as Ws, parseDidSignaturePaste as Wt, GROUP_EDIT_FRAME as X, arrivalKey as Xa, openRelayPayload as Xc, automaticTransport as Xi, groupFileFallback as Xn, HANDOFF_HAVE as Xo, relayRequest as Xr, enrollProofFrame as Xs, encodeDidDhtDeactivated as Xt, COMMUNITY_EDIT_FRAME as Y, statusCardText as Ya, newerPacket as Yc, TRANSPORTS as Yi, fetchesByItself as Yn, HANDOFF_DONE as Yo, parseWakeFrame as Yr, enrollHelloFrame as Ys, didDhtFromPublicKey as Yt, GROUP_EDIT_TEXT_BYTES as Z, claimedTime as Za, parseRelayPayload as Zc, irohDescriptor as Zi, isGroupMessageId as Zn, HANDOFF_HELLO as Zo, wakeRequest as Zr, readEnrollGrant as Zs, encodeDidDhtPacket as Zt, checkAppManifest as _, EDIT_BUFFER_MS as _a, newDeviceSetSecret as _c, dnsProofName as _i, sparkInvoiceDetails as _n, MEMBER_KEY as _o, freshHubs as _r, handoffOfferFrame as _s, atprotoDidMethod as _t, planAppUpdate as a, readImageMeta as aa, decodeInviteCode as ac, STATUS_CARD_CAPABILITY as ai, bytesEqual as al, SEPOLIA_TEST_USDT_FAUCET as an, checkAppRevokeStatement as ao, SIGNAL_HOLD_MS as ar, handoffAttemptAllowed as as, parseSshPublicKey as at, GROUP_TYPING_FRAME as b, dhtEditId as ba, TURN_PUT_WINDOW_MS as bc, encodeDnsQuery as bi, isPubkyKey as bn, verifyCommitSignature as bo, mergeBeacon as br, handoffRetryAfter as bs, checkAtprotoProofRecord as bt, readAppStore as c, sanitizeFileName as ca, inviteOwnership as cc, HOLD_DATED_BACK_MS as ci, toBase64Url as cl, WALLET_NETWORKS as cn, readAppRevocation as co, readGroupSignal as cr, handoffBusyFrame as cs, sshSignCommand as ct, APP_BUNDLE_LIMITS as d, VOICE_LIMITS as da, HANDOFF_CAPABILITY as dc, HoldRefusedError as di, utf8Encode as dl, formatPaymentAmount as dn, signAppRevocation as do, meshHubs as dr, handoffContextProof as ds, BitcoinAddressError as dt, DHT_MESSAGE_TTL as ea, createDeviceInvite as ec, chatAppId as ei, identityFromSeedB64 as el, ETHEREUM_USDT as en, shownTime as eo, readGroupWant as er, HANDOFF_PIECE_BYTES as es, mentionsMember as et, APP_BUNDLE_MAGIC as f, baseMime as fa, deviceEchoFrame as fc, isHoldUrl as fi, validatePaymentTarget as fn, canonicalJson as fo, pickMeshHubs as fr, handoffDataFrame as fs, decodeAnyBitcoinAddress as ft, assembleAppBundle as g, parseVideoMeta as ga, devicePingFrame as gc, decodeDnsResponse as gi, sparkAddressKind as gn, MAX_GROUP_CHAIN as go, doorHubs as gr, handoffManifestFrames as gs, ATPROTO_PROOF_SCOPE as gt, appViewOf as h, voicePeaksOf as ha, deviceLinkPairing as hc, DOMAIN_FILE_MAX_BYTES as hi, SPARK_PROVIDER as hn, GROUP_MEMBER_CAP as ho, beaconRecords as hr, handoffHelloFrame as hs, ATPROTO_PROOF_COLLECTION as ht, checkAppBeforeRun as i, parseImageMeta as ia, createChatInvite as ic, EDIT_CAPABILITY as ii, sanitizeAvatar as il, SEPOLIA_TEST_USDT as in, appRef as io, SIGNAL_FRESH_MS as ir, filePartName as is, SshSigError as it, KNOCK_TTL_MS as j, reactionIsNewer as ja, signTurnRelease as jc, capsDescriptors as ji, decodeBolt11 as jn, LIMITS as jo, transferEnded as jr, readHandoffVerified as js, checkDidJws as jt, decodeCommunityLink as k, queueReaction as ka, readTurnPacket as kc, CapsExchange as ki, requestLnurlInvoice as kn, parseLocalTarget as ko, ChatFiles as kr, readHandoffPake as ks, DID_JWS_MAX_LENGTH as kt, readAppStoreSteps as l, sanitizeMime as la, DEVICES_CAPABILITY as lc, HOLD_LIMITS as li, toZ32 as ll, assertTokenUnits as ln, readAppRevocations as lo, MESH_HUBS as lr, handoffCancelFrame as ls, verifySshSignature as lt, appDigest as m, parseVoiceMeta as ma, deviceKeyZ32 as mc, readManifest as mi, SPARK_NETWORKS as mn, readCanonicalJson as mo, beaconKeys as mr, handoffEphemeral as ms, ATPROTO_FULL_SCOPE as mt, appStoreDecision as n, DHT_FILE_CAPABILITY as na, readDeviceInvite as nc, APPS_CAPABILITY as ni, verify as nl, ONCHAIN_PROVIDER as nn, APP_STATEMENT_LIMITS as no, CarriedTransport as nr, HANDOFF_TIMINGS as ns, SSHSIG_MAX_ARMOR as nt, APP_STORE_LIMITS as o, formatFileSize as oa, encodeInviteCode as oc, UPGRADE_CAPABILITY as oi, fromBase64Url as ol, TEST_USDT_FAUCET_AMOUNT as on, isAppKey as oo, SIGNAL_REPLY_MS as or, handoffAttemptFailed as os, parseSshSignature as ot, APP_ICON_PATH as p, fileMessageText as pa, deviceEchoNonce as pc, newMailbox as pi, walletNetworkOf as pn, canonicalJsonBytes as po, COMMUNITY_TOPOLOGY as pr, handoffDigest as ps, ATPROTO_DECLARED_SCOPE as pt, GroupSession as q, checkStatusCard as qa, RELAY_PAYLOAD_MAX_BYTES as qc, emptyProofLedger as qi, GROUP_WANT_FRAME as qn, HANDOFF_ACK as qo, deviceWakeRequest as qr, enrollDoneFrame as qs, strictBase64Url as qt, appUpdateDecision as r, dhtFileId as ra, OwnInviteError as rc, BUTTONS_CAPABILITY as ri, MAX_AVATAR_LENGTH as rl, PaymentPreflightError as rn, appFingerprint as ro, GROUP_SIGNAL_FRAME as rr, HANDOFF_VERIFIED as rs, SSHSIG_NAMESPACE as rt, checkAppStoreIndex as s, safeBlobType as sa, inviteLink as sc, HOLD_CAPABILITY as si, randomBytes as sl, USDT_PUBLIC_RPC as sn, isAppRef as so, groupSignalFrame as sr, handoffAttemptSucceeded as ss, sameSshKey as st, appRevocationFor as t, peekDhtMailbox as ta, deviceInviteJoinerParams as tc, isAppVersion as ti, publicKeyFromZ32 as tl, EVM_TEST_CHAINS as tn, APP_PREFIXES as to, readGroupWantNo as tr, HANDOFF_REQUEST as ts, groupName as tt, signAppStore as u, PLAYABLE_AUDIO as ua, ENROLL_CAPABILITY as uc, HoldKeys as ui, utf8Decode as ul, assertWholeSats as un, signAppObject as uo, mayBeHub as ur, handoffContext as us, verifyBitcoinMessage as ut, isAppUrl as v, EDIT_RATE_WINDOW_MS as va, seedSigner as vc, domainTxtRecord as vi, isBitcoinAddress as vn, commitHash as vo, lobbyKeys as vr, handoffPakeFrame as vs, atprotoProofRecord as vt, sealGroupTyping as w, COMMUNITY_REACTION_FRAME as wa, turnPutSummary as wc, recordsFromTxt as wi, pubkyProofCapability as wn, EXPECT_PEER_MS as wo, rankHubs as wr, readHandoffAck as ws, shortAtprotoDid as wt, GroupTypingBoard as x, parseLinkPreview as xa, TURN_RAISE_READ_TIMEOUT_MS as xc, isPublicAddress as xi, isPubkyProofFolder as xn, edgeParams as xo, mergeLobby as xr, handoffStreamKey as xs, isAtprotoDid as xt, readAppBundle as y, RateWindow as ya, webCryptoSigner as yc, domainWellKnownFile as yi, PUBKY_PROOF_MAX_BYTES as yn, rosterHas as yo, lobbyRecords as yr, handoffReleaseFrame as ys, atprotoProofRkey as yt, GROUP_WAKE_FRAME as z, ITEM_STATES as za, currentRelays as zc, emptyIdentityLedger as zi, GhostLink as zn, callSignalHeardAt as zo, PIN_LIMITS as zr, verifySetUpdate as zs, didWebFileUrl as zt };
|
|
28812
|
+
export { COMMUNITY_EDIT_FRAME as $, HANDOFF_HELLO as $a, EDIT_RATE_WINDOW_MS as $i, SIGNAL_REPLY_MS as $n, readEnrollGrant as $o, STATUS_CARD_CAPABILITY as $r, parseRelayPayload as $s, didDhtFromPublicKey as $t, CommunitySession as A, RELAY_POLL_INTERVALS as Aa, IdentityCheckUnavailable as Ai, pubkyProofPath as An, readHandoffHello as Ao, typingActivity as Ar, nextTurnPosition as As, verifyAtprotoRecordProof as At, knockRecords as B, callRtcConfig as Ba, PeerProofs as Bi, isFederationId as Bn, signTombstone as Bo, deviceWakeRequest as Br, RelayTransport as Bs, didWebDocumentUrl as Bt, sha256Paced as C, rosterHas as Ca, CapsExchange as Ci, PUBKY_PROOF_MAX_BYTES as Cn, handoffStreamKey as Co, FILE_LIMITS as Cr, TURN_RAISE_READ_TIMEOUT_MS as Cs, atprotoProofRkey as Ct, openGroupTyping as D, AWAITING_PEER_MS as Da, IDENTITY_MAX_EVIDENCE as Di, normalizePubkyKey as Dn, readHandoffCancel as Do, TYPING_REFRESH_MS as Dr, TURN_LAST_SEQUENCE as Ds, parseAtprotoDidDocument as Dt, groupTypingEpoch as E, DirectPathWatch as Ea, dialDescriptors as Ei, newPubkyProofFolder as En, readHandoffAck as Eo, TYPING_KINDS as Er, turnPutSummary as Es, normalizeAtprotoHandle as Et, KNOCK_TTL_MS as F, webLocalFetch as Fa, identityStatement as Fi, decodeBolt11 as Fn, SET_ACK as Fo, pinIsNewer as Fr, turnKeys as Fs, checkDidJws as Ft, groupWakeFrame as G, emptyLinkRecords as Ga, irohDescriptor as Gi, LIVENESS_PING_MS as Gn, ENROLL_PROOF as Go, checkPushEndpoint as Gr, budgetRetryMs as Gs, normalizeDid as Gt, readKnocks as H, extractParamsFromSdp as Ha, validExternalKey as Hi, parseFedimintRequestPayload as Hn, ENROLL_DONE as Ho, parseWakeFrame as Hr, normalizeRelayUrl as Hs, didWebFileNames as Ht, decodeGroupEntryLink as I, CALL_SIGNAL_MAX_AGE_MS as Ia, newIdentityBinding as Ii, ENDPOINT as In, SET_UPDATE as Io, pinNumberHolds as Ir, turnName as Is, checkDidSignature as It, GROUP_LIMITS as J, serviceIdFromName as Ja, DHT_MESSAGE_TTL as Ji, engineText as Jn, enrollDigits as Jo, chatAppId as Jr, PacketTooLargeError as Js, parseDidSignaturePaste as Jt, groupWakes as K, HTTP_SERVICE_PROTO as Ka, irohRelayUrl as Ki, pairedMessageFrame as Kn, enrollCancelFrame as Ko, vapidKeysMatch as Kr, isDiscoveryBudgetError as Ks, parseDid as Kt, encodeGroupEntryLink as L, RTC_CONFIG as La, receivedIdentityStatus as Li, cashuRequestPayload as Ln, readSetUpdate as Lo, readPin as Lr, turnStatedVerifier as Ls, didGhostlyServiceEntry as Lt, communityHasFrame as M, GhostlyHttpError as Ma, boundVerifiedIdentity as Mi, pubkyHomeserverOf as Mn, readHandoffTurnFrame as Mo, PIN_LIMITS as Mr, signTurnPacket as Ms, DID_FILE_MAX_BYTES as Mt, decodeCommunityLink as N, formatLocalTarget as Na, emptyIdentityLedger as Ni, requestLnurlInvoice as Nn, readHandoffVerified as No, mayPin as Nr, signTurnRelease as Ns, DID_JWS_MAX_LENGTH as Nt, sealGroupTyping as O, DHT_POLL_INTERVALS as Oa, IDENTITY_MAX_VALIDITY as Oi, pubkyProofCapability as On, readHandoffDone as Oo, TypingSender as Or, TURN_MAX as Os, shortAtprotoDid as Ot, encodeCommunityLink as P, parseLocalTarget as Pa, identityRevocationValue as Pi, resolveLightningDestination as Pn, sealHandoffPiece as Po, nextPinNumber as Pr, turnInstance as Ps, DidError as Pt, frameBytes as Q, HANDOFF_HAVE as Qa, EDIT_BUFFER_MS as Qi, SIGNAL_HOLD_MS as Qn, enrollProofFrame as Qo, EDIT_CAPABILITY as Qr, openRelayPayload as Qs, didDhtDocument as Qt, entryParams as R, answersOffer as Ra, revokesIdentity as Ri, fedimintRequestPayload as Rn, setAckFrame as Ro, WAKE_CALL_INTERVAL_MS as Rr, turnVerifierMark as Rs, didJwsAlg as Rt, readAppBundleAsync as S, commitHash as Sa, wellKnownUrl as Si, isBitcoinAddress as Sn, handoffRetryAfter as So, ChatFiles as Sr, TURN_PUT_WINDOW_MS as Ss, atprotoProofRecord as St, GroupTypingBoard as T, edgeParams as Ta, capsDescriptors as Ti, isPubkyProofFolder as Tn, newHandoffId as To, transferStalled as Tr, classifyTurnRead as Ts, isAtprotoDid as Tt, GROUP_WAKE_FRAME as U, parseCallSignal as Ua, TRANSPORTS as Ui, GhostLink as Un, ENROLL_GRANT as Uo, relayRequest as Ur, setLinkTraceSink as Us, didWebFileUrl as Ut, mergeKnocks as V, callSignalHeardAt as Va, emptyProofLedger as Vi, parseCashuRequestPayload as Vn, verifySetUpdate as Vo, newWakeToken as Vr, currentRelays as Vs, didWebFile as Vt, GroupWakeLimiter as W, signalHasVideo as Wa, automaticTransport as Wi, INVITE_TAKEN as Wn, ENROLL_HELLO as Wo, wakeRequest as Wr, traceLink as Ws, didWebHost as Wt, GROUP_READ_NOTE as X, HANDOFF_CANCEL as Xa, DHT_FILE_CAPABILITY as Xi, GROUP_SIGNAL_FRAME as Xn, enrollGrantFrame as Xo, APPS_CAPABILITY as Xr, createRelayPayload as Xs, staticDidDocument as Xt, GROUP_REACTED_FRAME as Y, HANDOFF_ACK as Ya, peekDhtMailbox as Yi, CarriedTransport as Yn, enrollDoneFrame as Yo, isAppVersion as Yr, RELAY_PAYLOAD_MAX_BYTES as Ys, signingKeys as Yt, GroupSession as Z, HANDOFF_DONE as Za, dhtFileId as Zi, SIGNAL_FRESH_MS as Zn, enrollHelloFrame as Zo, BUTTONS_CAPABILITY as Zr, newerPacket as Zs, strictBase64Url as Zt, assembleAppBundle as _, readReply as _a, normalizeDomain as _i, walletNetworkOf as _n, handoffHelloFrame as _o, rankHubs as _r, deviceLinkPairing as _s, ATPROTO_DECLARED_SCOPE as _t, planAppUpdate as a, COMMUNITY_REACTION_FRAME as aa, MAX_AVATAR_LENGTH as ac, HoldRefusedError as ai, EVM_TEST_CHAINS as an, HANDOFF_VERIFIED as ao, pickMeshHubs as ar, OwnInviteError as as, groupName as at, pacer as b, MAX_GROUP_CHAIN as ba, recordsName as bi, sparkAddressKind as bn, handoffPakeFrame as bo, readLobby as br, seedSigner as bs, ATPROTO_PROOF_SCOPE as bt, readAppStore as c, ReactionWindow as ca, readManifest as ci, SEPOLIA_TEST_USDT as cn, handoffAttemptFailed as co, beaconRecords as cr, encodeInviteCode as cs, SshSigError as ct, APP_BUNDLE_LIMITS as d, reactionEmoji as da, dnsProofName as di, USDT_PUBLIC_RPC as dn, handoffCancelFrame as do, lobbyKeys as dr, DEVICES_CAPABILITY as ds, sameSshKey as dt, RateWindow as ea, createIdentity as ec, UPGRADE_CAPABILITY as ei, encodeDidDhtDeactivated as en, HANDOFF_OFFER as eo, groupSignalFrame as er, readEnrollHello as es, GROUP_EDIT_FRAME as et, APP_BUNDLE_MAGIC as f, reactionIsNewer as fa, domainTxtRecord as fi, WALLET_NETWORKS as fn, handoffContext as fo, lobbyRecords as fr, ENROLL_CAPABILITY as fs, sshSignCommand as ft, appViewOf as g, pairedReplyAuthor as ga, nip05RootKey as gi, validatePaymentTarget as gn, handoffEphemeral as go, pickHubs as gr, deviceKeyZ32 as gs, decodeAnyBitcoinAddress as gt, appDigest as h, wireReaction as ha, isPublicAddress as hi, formatPaymentAmount as hn, handoffDigest as ho, newerHead as hr, deviceEchoNonce as hs, BitcoinAddressError as ht, checkAppBeforeRun as i, readForwarded as ia, verify as ic, HoldKeys as ii, ETHEREUM_USDT as in, HANDOFF_TIMINGS as io, meshHubs as ir, readDeviceInvite as is, mentionsMember as it, GROUP_READ_NOTE_COMMUNITY as j, presenceSeenAt as ja, IdentityExchange as ji, homeserverWebEndpoint as jn, readHandoffPake as jo, GROUP_PIN_FRAME as jr, readTurnPacket as js, DID_DOCUMENT_MAX_BYTES as jt, COMMUNITY_LIMITS as k, EXPECT_PEER_MS as ka, IDENTITY_REVOCATION_LABEL as ki, pubkyProofFile as kn, readHandoffHave as ko, sanitizeTypingStatus as kr, firstDeviceSetSecret as ks, AtprotoProofError as kt, readAppStoreSteps as l, nextReactionNumber as la, DOMAIN_FILE_MAX_BYTES as li, SEPOLIA_TEST_USDT_FAUCET as ln, handoffAttemptSucceeded as lo, doorHubs as lr, inviteLink as ls, parseSshPublicKey as lt, APP_PERMISSIONS as m, validReactionNumber as ma, encodeDnsQuery as mi, assertWholeSats as mn, handoffDataFrame as mo, mergeLobby as mr, deviceEchoFrame as ms, verifyBitcoinMessage as mt, appStoreDecision as n, parseLinkPreview as na, identityFromSeedB64 as nc, HOLD_DATED_BACK_MS as ni, resolveDidDht as nn, HANDOFF_PIECE_BYTES as no, MESH_HUBS as nr, createDeviceInvite as ns, carryMentions as nt, APP_STORE_LIMITS as o, GROUP_REACTION_FRAME as oa, sanitizeAvatar as oc, isHoldUrl as oi, ONCHAIN_PROVIDER as on, filePartName as oo, COMMUNITY_TOPOLOGY as or, createChatInvite as os, SSHSIG_MAX_ARMOR as ot, APP_ICON_PATH as p, readReaction as pa, domainWellKnownFile as pi, assertTokenUnits as pn, handoffContextProof as po, mergeBeacon as pr, HANDOFF_CAPABILITY as ps, verifySshSignature as pt, parseGroupWakeFrame as q, LEGACY_SERVICES as qa, DHT_FILE_TOO_LARGE as qi, engineError as qn, enrollCancelReason as qo, APP_DATA_MAX_BYTES as qr, withRequestOptions as qs, parseDidDocument as qt, appUpdateDecision as r, forwardedAgain as ra, publicKeyFromZ32 as rc, HOLD_LIMITS as ri, signDidDhtPacket as rn, HANDOFF_REQUEST as ro, mayBeHub as rr, deviceInviteJoinerParams as rs, parseCommunityEdit as rt, checkAppStoreIndex as s, REACTION_LIMITS as sa, newMailbox as si, PaymentPreflightError as sn, handoffAttemptAllowed as so, beaconKeys as sr, decodeInviteCode as ss, SSHSIG_NAMESPACE as st, appRevocationFor as t, dhtEditId as ta, identityFromSeed as tc, HOLD_CAPABILITY as ti, encodeDidDhtPacket as tn, HANDOFF_PAKE as to, readGroupSignal as tr, verifyEnrollProof as ts, GROUP_EDIT_TEXT_BYTES as tt, signAppStore as u, queueReaction as ua, decodeDnsResponse as ui, TEST_USDT_FAUCET_AMOUNT as un, handoffBusyFrame as uo, freshHubs as ur, inviteOwnership as us, parseSshSignature as ut, checkAppManifest as v, replySnippet as va, recordsFromTxt as vi, SPARK_NETWORKS as vn, handoffManifestFrames as vo, readBeacon as vr, devicePingFrame as vs, ATPROTO_FULL_SCOPE as vt, GROUP_TYPING_FRAME as w, verifyCommitSignature as wa, DHT_TEXT_CAPABILITY as wi, isPubkyKey as wn, handoffVersions as wo, transferEnded as wr, TURN_SETTLE_MS as ws, checkAtprotoProofRecord as wt, readAppBundle as x, MEMBER_KEY as xa, txtValue as xi, sparkInvoiceDetails as xn, handoffReleaseFrame as xo, shouldBeHub as xr, webCryptoSigner as xs, atprotoDidMethod as xt, isAppUrl as y, GROUP_MEMBER_CAP as ya, recordsFromWellKnown as yi, SPARK_PROVIDER as yn, handoffOfferFrame as yo, readBeaconHead as yr, newDeviceSetSecret as ys, ATPROTO_PROOF_COLLECTION as yt, knockIdentity as z, buildSdpFromSignal as za, lastSharedWithMe as zi, findEndpoint as zn, setUpdateFrame as zo, WakeLimiter as zr, DEFAULT_RELAYS as zs, didSignedMessages as zt };
|
|
30011
28813
|
|
|
30012
|
-
//# sourceMappingURL=src-
|
|
28814
|
+
//# sourceMappingURL=src-DhfGgAUu.js.map
|