@ghostlytools/cli 1.1.4 → 1.1.6
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 +8 -2
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/{endpoint-CaSbSFDw.js → endpoint-D_3bYafG.js} +76 -3
- package/dist/assets/endpoint-D_3bYafG.js.map +1 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
- package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
- package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
- package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
- package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-BoxPHp7v.js → hyperdhtRelay-CnEkFl8R.js} +3 -3
- package/dist/assets/{hyperdhtRelay-BoxPHp7v.js.map → hyperdhtRelay-CnEkFl8R.js.map} +1 -1
- package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
- package/dist/assets/idb-BIdus0WX.js.map +1 -0
- package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
- package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
- package/dist/assets/{mainline-dkA94eh2.js → mainline-DQxDSWXm.js} +2 -2
- package/dist/assets/{mainline-dkA94eh2.js.map → mainline-DQxDSWXm.js.map} +1 -1
- package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
- package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
- package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
- package/dist/assets/{server-CXFTDnFI.js → server-HMzdsyn_.js} +1720 -284
- package/dist/assets/server-HMzdsyn_.js.map +1 -0
- package/dist/assets/{src-K3NijMq-.js → src-DtxVo2Rm.js} +2299 -164
- package/dist/assets/src-DtxVo2Rm.js.map +1 -0
- package/dist/ghostly.mjs +1944 -64
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/endpoint-CaSbSFDw.js.map +0 -1
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-CXFTDnFI.js.map +0 -1
- package/dist/assets/src-K3NijMq-.js.map +0 -1
|
@@ -897,6 +897,8 @@ function normalizeRelayUrl(input) {
|
|
|
897
897
|
return null;
|
|
898
898
|
}
|
|
899
899
|
}
|
|
900
|
+
/** A relay the breaker leaves alone for failing (`RelayBreaker`): nothing was asked of it now. */
|
|
901
|
+
var RelayLeftAloneError = class extends Error {};
|
|
900
902
|
var RelayTransport = class {
|
|
901
903
|
relays;
|
|
902
904
|
timeoutMs;
|
|
@@ -1185,8 +1187,11 @@ var RelayTransport = class {
|
|
|
1185
1187
|
return;
|
|
1186
1188
|
}
|
|
1187
1189
|
const held = results.map((r) => r.status === "rejected" && isDiscoveryBudgetError(r.reason) ? r.reason : null);
|
|
1190
|
+
const leftAlone = (r) => r.status === "rejected" && r.reason instanceof RelayLeftAloneError;
|
|
1191
|
+
const waits = held.filter((e) => e !== null);
|
|
1188
1192
|
const reasons = results.map((r) => r.status === "rejected" ? String(r.reason) : "").join("; ");
|
|
1189
|
-
if (
|
|
1193
|
+
if (waits.length === results.length) reject(new DiscoveryBudgetError(Math.min(...waits.map((e) => e.retryInMs)), `Publish held back on every relay: ${reasons}`));
|
|
1194
|
+
else if (waits.length && results.every((r, i) => held[i] !== null || leftAlone(r))) reject(new DiscoveryBudgetError(Math.min(...waits.map((e) => e.retryInMs)), `Publish held back on every relay not left alone: ${reasons}`));
|
|
1190
1195
|
else reject(/* @__PURE__ */ new Error(`Publish failed on every relay: ${reasons}`));
|
|
1191
1196
|
});
|
|
1192
1197
|
});
|
|
@@ -1484,7 +1489,7 @@ var RelayTransport = class {
|
|
|
1484
1489
|
const blocked = probe ? 0 : this.breaker.blockedFor(relay);
|
|
1485
1490
|
if (blocked > 0) {
|
|
1486
1491
|
if (this.breaker.blockedKind(relay) === "throttled") throw new DiscoveryBudgetError(blocked, "Discovery relay is throttling this address; retry shortly");
|
|
1487
|
-
throw new
|
|
1492
|
+
throw new RelayLeftAloneError(`${relay} is left alone after failing; asked again in ${Math.max(1, Math.ceil(this.breaker.askedAgainIn(relay, this.relays) / 1e3))} s`);
|
|
1488
1493
|
}
|
|
1489
1494
|
if (!this.take(relay, who, pubKeyZ32)) throw new DiscoveryBudgetError(this.heldFor(relay, who));
|
|
1490
1495
|
if (probe) this.breaker.beginAllDown(relay);
|
|
@@ -1961,13 +1966,13 @@ function turnName(name) {
|
|
|
1961
1966
|
}
|
|
1962
1967
|
return out;
|
|
1963
1968
|
}
|
|
1964
|
-
function u32$
|
|
1969
|
+
function u32$3(value) {
|
|
1965
1970
|
return Uint8Array.of(value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255);
|
|
1966
1971
|
}
|
|
1967
1972
|
/** What a release signs: `"ghostly-turn-release" || turnAddress(32) || turn(4) || toKey(32) || H(32)`. */
|
|
1968
1973
|
function turnReleaseMessage(address, turn, toKey, h) {
|
|
1969
1974
|
if (address.length !== 32 || toKey.length !== 32 || h.length !== 32) throw new Error("A release names 32-byte values");
|
|
1970
|
-
return concatBytes$1(RELEASE_DOMAIN, address, u32$
|
|
1975
|
+
return concatBytes$1(RELEASE_DOMAIN, address, u32$3(turn), toKey, h);
|
|
1971
1976
|
}
|
|
1972
1977
|
/** What the author signs: `"ghostly-turn" || turnAddress(32) || body[0..310]`. */
|
|
1973
1978
|
function turnSignedMessage(address, unsigned) {
|
|
@@ -1985,8 +1990,8 @@ function encodeTurnFields(fields) {
|
|
|
1985
1990
|
if (tombstone ? fields.active !== 255 : !held(fields.active)) throw new Error(tombstone ? "A tombstone names no active device" : "The active device has no slot");
|
|
1986
1991
|
const body = /* @__PURE__ */ new Uint8Array(310);
|
|
1987
1992
|
body[OFFSET.version] = 1;
|
|
1988
|
-
body.set(u32$
|
|
1989
|
-
body.set(u32$
|
|
1993
|
+
body.set(u32$3(fields.turn), OFFSET.turn);
|
|
1994
|
+
body.set(u32$3(fields.rev).subarray(1), OFFSET.rev);
|
|
1990
1995
|
body[OFFSET.author] = fields.author;
|
|
1991
1996
|
body[OFFSET.active] = fields.active;
|
|
1992
1997
|
body[OFFSET.count] = fields.slots.filter(Boolean).length;
|
|
@@ -2238,7 +2243,7 @@ var TURN_PUT_WINDOW_MS = 1e4;
|
|
|
2238
2243
|
var TURN_RAISE_READ_TIMEOUT_MS = 5e3;
|
|
2239
2244
|
/** `T`: how long a device that raised the turn waits, from the end of its put, before the read that may make it active. `T >= P + 2V`. */
|
|
2240
2245
|
var TURN_SETTLE_MS = 3e4;
|
|
2241
|
-
function compareBytes$
|
|
2246
|
+
function compareBytes$3(a, b) {
|
|
2242
2247
|
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
2243
2248
|
return a.length - b.length;
|
|
2244
2249
|
}
|
|
@@ -2249,7 +2254,7 @@ function compareBytes$2(a, b) {
|
|
|
2249
2254
|
* reader still agrees.
|
|
2250
2255
|
*/
|
|
2251
2256
|
function keptAtEqualSequence(a, b) {
|
|
2252
|
-
return compareBytes$
|
|
2257
|
+
return compareBytes$3(a.record.instance, b.record.instance) || compareBytes$3(a.record.body, b.record.body) || compareBytes$3(a.payload, b.payload);
|
|
2253
2258
|
}
|
|
2254
2259
|
var kept = (f) => ({
|
|
2255
2260
|
record: f.read.record,
|
|
@@ -3358,7 +3363,7 @@ var HANDOFF_CANCEL_REASONS = [
|
|
|
3358
3363
|
"stalled"
|
|
3359
3364
|
];
|
|
3360
3365
|
var KEY$3 = /^[A-Za-z0-9_-]{43}$/;
|
|
3361
|
-
var DIGEST$
|
|
3366
|
+
var DIGEST$2 = KEY$3;
|
|
3362
3367
|
var SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
|
3363
3368
|
var ID$5 = /^[A-Za-z0-9_-]{22}$/;
|
|
3364
3369
|
var B64$4 = /^[A-Za-z0-9_-]*$/;
|
|
@@ -3368,15 +3373,15 @@ var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
|
|
|
3368
3373
|
var PART = /^(db\/[A-Za-z0-9_-]{1,64}|local|devices|file\/[A-Za-z0-9_-]{43}|wallet\/[a-z0-9-]{1,32}\/[A-Za-z0-9_.-]{1,100})$/;
|
|
3369
3374
|
/** The largest part the reader accepts: a file of WISP 501 has no limit beneath this. */
|
|
3370
3375
|
var MAX_PART = 2 ** 50;
|
|
3371
|
-
var isCount = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
3372
|
-
var isText = (value, pattern) => typeof value === "string" && pattern.test(value);
|
|
3376
|
+
var isCount$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
3377
|
+
var isText$2 = (value, pattern) => typeof value === "string" && pattern.test(value);
|
|
3373
3378
|
var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
|
|
3374
3379
|
/** The part a file is: `file/<its SHA-256, base64url>`. */
|
|
3375
3380
|
var filePartName = (sha256B64) => `file/${sha256B64}`;
|
|
3376
3381
|
/** The digest a file part names, or null for any other part. */
|
|
3377
|
-
var filePartDigest = (name) => name.startsWith("file/") && DIGEST$
|
|
3382
|
+
var filePartDigest = (name) => name.startsWith("file/") && DIGEST$2.test(name.slice(5)) ? name.slice(5) : null;
|
|
3378
3383
|
function isPart(value) {
|
|
3379
|
-
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount(value[1], MAX_PART) && isText(value[2], DIGEST$
|
|
3384
|
+
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$2) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
|
|
3380
3385
|
}
|
|
3381
3386
|
/** Names compared as UTF-8 byte strings, as `H` sorts them. */
|
|
3382
3387
|
function compareNames(a, b) {
|
|
@@ -3404,7 +3409,7 @@ function sortParts(parts) {
|
|
|
3404
3409
|
* and every part the taker already held and keeps, as `[name, size, sha256]` sorted by name. Keys are base64url.
|
|
3405
3410
|
*/
|
|
3406
3411
|
function handoffDigest(turn, fromKey, toKey, parts) {
|
|
3407
|
-
if (!isCount(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3412
|
+
if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3408
3413
|
if (fromKey.length !== 32 || toKey.length !== 32) throw new Error("A device signing key is 32 bytes");
|
|
3409
3414
|
for (const part of parts) if (!isPart(part)) throw new Error("A part is [name, size, sha256]");
|
|
3410
3415
|
return sha256(utf8Encode(JSON.stringify([
|
|
@@ -3418,7 +3423,7 @@ function handoffDigest(turn, fromKey, toKey, parts) {
|
|
|
3418
3423
|
/** What `handoff-verified` signs: `["ghostly-handoff-verified", turnAddress, N + 1, H]`, the bytes in base64url. */
|
|
3419
3424
|
function handoffVerifiedMessage(turnAddress, turn, h) {
|
|
3420
3425
|
if (turnAddress.length !== 32 || h.length !== 32) throw new Error("A turn address and H are 32 bytes");
|
|
3421
|
-
if (!isCount(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3426
|
+
if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3422
3427
|
return utf8Encode(JSON.stringify([
|
|
3423
3428
|
"ghostly-handoff-verified",
|
|
3424
3429
|
toBase64Url(turnAddress),
|
|
@@ -3470,7 +3475,7 @@ function handoffStreamKey(ownSecret, peerPublic, k, transcriptHash) {
|
|
|
3470
3475
|
* does not open there. (XSalsa20-Poly1305 takes no associated data; the header inside the box does that job.)
|
|
3471
3476
|
*/
|
|
3472
3477
|
function pieceHeader(part, offset) {
|
|
3473
|
-
if (!isCount(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
|
|
3478
|
+
if (!isCount$1(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
|
|
3474
3479
|
const header = /* @__PURE__ */ new Uint8Array(16);
|
|
3475
3480
|
header.set(sha256(utf8Encode(`ghostly-handoff-piece/1${part}`)).subarray(0, 8), 0);
|
|
3476
3481
|
new DataView(header.buffer).setBigUint64(8, BigInt(offset));
|
|
@@ -3484,7 +3489,7 @@ function sealHandoffPiece(key, part, offset, piece, nonce = randomBytes(24)) {
|
|
|
3484
3489
|
if (key.length !== 32 || nonce.length !== 24) throw new Error("A stream key is 32 bytes and a nonce 24");
|
|
3485
3490
|
return concatBytes$1(nonce, xsalsa20poly1305(key, nonce).encrypt(concatBytes$1(pieceHeader(part, offset), piece)));
|
|
3486
3491
|
}
|
|
3487
|
-
var isPins = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length <= 16 && Object.entries(value).every(([type, version]) => /^[a-z0-9-]{1,32}$/.test(type) && isText(version, VERSION_TEXT));
|
|
3492
|
+
var isPins = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length <= 16 && Object.entries(value).every(([type, version]) => /^[a-z0-9-]{1,32}$/.test(type) && isText$2(version, VERSION_TEXT));
|
|
3488
3493
|
function handoffHelloFrame(hello) {
|
|
3489
3494
|
return {
|
|
3490
3495
|
t: HANDOFF_HELLO,
|
|
@@ -3493,12 +3498,12 @@ function handoffHelloFrame(hello) {
|
|
|
3493
3498
|
}
|
|
3494
3499
|
function readHandoffHello(frame) {
|
|
3495
3500
|
if (frame.t !== "handoff-hello" || frame.v !== 1) return null;
|
|
3496
|
-
if (!isText(frame.e, KEY$3) || fromBase64Url(frame.e).length !== 32) return null;
|
|
3497
|
-
if (!isText(frame.app, VERSION_TEXT) || !isCount(frame.db, 1e6) || !isPins(frame.pins)) return null;
|
|
3501
|
+
if (!isText$2(frame.e, KEY$3) || fromBase64Url(frame.e).length !== 32) return null;
|
|
3502
|
+
if (!isText$2(frame.app, VERSION_TEXT) || !isCount$1(frame.db, 1e6) || !isPins(frame.pins)) return null;
|
|
3498
3503
|
if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
|
|
3499
3504
|
if (typeof frame.metered !== "boolean") return null;
|
|
3500
|
-
if (frame.later !== void 0 && !isCount(frame.later)) return null;
|
|
3501
|
-
if (frame.id !== void 0 && !isText(frame.id, ID$5)) return null;
|
|
3505
|
+
if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
|
|
3506
|
+
if (frame.id !== void 0 && !isText$2(frame.id, ID$5)) return null;
|
|
3502
3507
|
const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
|
|
3503
3508
|
return {
|
|
3504
3509
|
v: 1,
|
|
@@ -3524,8 +3529,8 @@ var handoffOfferFrame = (turn, id, bytes) => ({
|
|
|
3524
3529
|
});
|
|
3525
3530
|
function readHandoffTurnFrame(frame) {
|
|
3526
3531
|
if (frame.t !== "handoff-request" && frame.t !== "handoff-offer") return null;
|
|
3527
|
-
if (!isCount(frame.turn, TURN_MAX) || !isText(frame.id, ID$5)) return null;
|
|
3528
|
-
if (frame.t === "handoff-offer") return isCount(frame.bytes) ? {
|
|
3532
|
+
if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$5)) return null;
|
|
3533
|
+
if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
|
|
3529
3534
|
turn: frame.turn,
|
|
3530
3535
|
id: frame.id,
|
|
3531
3536
|
bytes: frame.bytes
|
|
@@ -3558,8 +3563,8 @@ function readHandoffPake(frame) {
|
|
|
3558
3563
|
n: 3,
|
|
3559
3564
|
wrong: true
|
|
3560
3565
|
} : null;
|
|
3561
|
-
if (!isText(frame.m, B64$4) || !frame.m.length || frame.m.length > 4096) return null;
|
|
3562
|
-
if (frame.n === 3 && !isText(frame.c, KEY$3)) return null;
|
|
3566
|
+
if (!isText$2(frame.m, B64$4) || !frame.m.length || frame.m.length > 4096) return null;
|
|
3567
|
+
if (frame.n === 3 && !isText$2(frame.c, KEY$3)) return null;
|
|
3563
3568
|
return {
|
|
3564
3569
|
n: frame.n,
|
|
3565
3570
|
m: frame.m,
|
|
@@ -3567,12 +3572,12 @@ function readHandoffPake(frame) {
|
|
|
3567
3572
|
};
|
|
3568
3573
|
}
|
|
3569
3574
|
function readHandoffHave(frame) {
|
|
3570
|
-
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText(d, DIGEST$
|
|
3575
|
+
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$2))) return null;
|
|
3571
3576
|
const p = {};
|
|
3572
3577
|
if (frame.p !== void 0) {
|
|
3573
3578
|
if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
|
|
3574
3579
|
for (const [name, offset] of Object.entries(frame.p)) {
|
|
3575
|
-
if (!isHandoffPartName(name) || !isCount(offset, MAX_PART)) return null;
|
|
3580
|
+
if (!isHandoffPartName(name) || !isCount$1(offset, MAX_PART)) return null;
|
|
3576
3581
|
p[name] = offset;
|
|
3577
3582
|
}
|
|
3578
3583
|
}
|
|
@@ -3615,7 +3620,7 @@ var handoffDataFrame = (part, offset, sealed) => ({
|
|
|
3615
3620
|
d: toBase64Url(sealed)
|
|
3616
3621
|
});
|
|
3617
3622
|
function readHandoffAck(frame) {
|
|
3618
|
-
if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount(frame.o, MAX_PART)) return null;
|
|
3623
|
+
if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount$1(frame.o, MAX_PART)) return null;
|
|
3619
3624
|
return {
|
|
3620
3625
|
part: frame.p,
|
|
3621
3626
|
offset: frame.o
|
|
@@ -3623,7 +3628,7 @@ function readHandoffAck(frame) {
|
|
|
3623
3628
|
}
|
|
3624
3629
|
/** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
|
|
3625
3630
|
function readHandoffVerified(frame, turnAddress, turn, takerKey) {
|
|
3626
|
-
if (frame.t !== "handoff-verified" || !isText(frame.h, DIGEST$
|
|
3631
|
+
if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$2) || !isText$2(frame.s, SIGNATURE)) return null;
|
|
3627
3632
|
const h = fromBase64Url(frame.h);
|
|
3628
3633
|
try {
|
|
3629
3634
|
return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
|
|
@@ -3636,7 +3641,7 @@ var handoffReleaseFrame = (release) => ({
|
|
|
3636
3641
|
...release
|
|
3637
3642
|
});
|
|
3638
3643
|
function readHandoffDone(frame) {
|
|
3639
|
-
return frame.t === "handoff-done" && isCount(frame.seq) ? frame.seq : null;
|
|
3644
|
+
return frame.t === "handoff-done" && isCount$1(frame.seq) ? frame.seq : null;
|
|
3640
3645
|
}
|
|
3641
3646
|
/**
|
|
3642
3647
|
* Whether two devices can hand over (WISP 06 § Versions), seen from the taker: a taker whose database is older than
|
|
@@ -6141,6 +6146,8 @@ var DataLink = class {
|
|
|
6141
6146
|
disconnectTimer = null;
|
|
6142
6147
|
/** The offer this side answered in the current attempt, when it took it (this clock), and how many times it answered it again. */
|
|
6143
6148
|
answered = null;
|
|
6149
|
+
/** Answers refused since a connection last opened, each followed by a new offer at once (`ANSWER_REFUSED_REDIALS`). */
|
|
6150
|
+
refusedAnswers = 0;
|
|
6144
6151
|
constructor(options) {
|
|
6145
6152
|
this.options = options;
|
|
6146
6153
|
}
|
|
@@ -6246,8 +6253,8 @@ var DataLink = class {
|
|
|
6246
6253
|
sdp: this.remoteSdp
|
|
6247
6254
|
});
|
|
6248
6255
|
if (this.pc === pc && this.state === "offering") this.setState("connecting");
|
|
6249
|
-
} catch {
|
|
6250
|
-
if (this.pc === pc) this.
|
|
6256
|
+
} catch (error) {
|
|
6257
|
+
if (this.pc === pc) this.answerRefused(error);
|
|
6251
6258
|
}
|
|
6252
6259
|
}
|
|
6253
6260
|
}
|
|
@@ -6293,6 +6300,10 @@ var DataLink = class {
|
|
|
6293
6300
|
* else goes back to idle.
|
|
6294
6301
|
*/
|
|
6295
6302
|
failed() {
|
|
6303
|
+
if (this.state === "offering" && this.remoteSdp) {
|
|
6304
|
+
this.answerRefused("failed");
|
|
6305
|
+
return;
|
|
6306
|
+
}
|
|
6296
6307
|
const answered = this.state === "connecting" ? this.answered : null;
|
|
6297
6308
|
const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
|
|
6298
6309
|
const evidence = this.directEvidence();
|
|
@@ -6310,6 +6321,18 @@ var DataLink = class {
|
|
|
6310
6321
|
this.reset();
|
|
6311
6322
|
if (evidence) this.options.onDirect?.(evidence);
|
|
6312
6323
|
}
|
|
6324
|
+
/** The answer to this side's offer did not go in (`ANSWER_REFUSED_REDIALS`): the attempt ends, and the caller may offer again now. */
|
|
6325
|
+
answerRefused(error) {
|
|
6326
|
+
const again = this.refusedAnswers < 4;
|
|
6327
|
+
traceLink(this.options.myPubKeyZ32, "answer-refused", {
|
|
6328
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6329
|
+
again
|
|
6330
|
+
});
|
|
6331
|
+
this.reset();
|
|
6332
|
+
if (!again) return;
|
|
6333
|
+
this.refusedAnswers++;
|
|
6334
|
+
this.options.onAnswerReplaced?.();
|
|
6335
|
+
}
|
|
6313
6336
|
async answer(offer) {
|
|
6314
6337
|
this.setState("answering");
|
|
6315
6338
|
this.options.setFastPoll(true);
|
|
@@ -6388,6 +6411,7 @@ var DataLink = class {
|
|
|
6388
6411
|
if (this.pc !== pc) return;
|
|
6389
6412
|
this.clearTimers();
|
|
6390
6413
|
this.answered = null;
|
|
6414
|
+
this.refusedAnswers = 0;
|
|
6391
6415
|
this.fullGather = false;
|
|
6392
6416
|
this.setState("open");
|
|
6393
6417
|
this.options.setFastPoll(false);
|
|
@@ -6761,6 +6785,311 @@ function rememberAlone(alone, key, present) {
|
|
|
6761
6785
|
}
|
|
6762
6786
|
var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
|
|
6763
6787
|
//#endregion
|
|
6788
|
+
//#region ../core/src/canonicalJson.ts
|
|
6789
|
+
/** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
|
|
6790
|
+
function hasLoneSurrogate(s) {
|
|
6791
|
+
for (let i = 0; i < s.length; i++) {
|
|
6792
|
+
const c = s.charCodeAt(i);
|
|
6793
|
+
if (c >= 55296 && c <= 56319) {
|
|
6794
|
+
const next = s.charCodeAt(i + 1);
|
|
6795
|
+
if (next >= 56320 && next <= 57343) {
|
|
6796
|
+
i++;
|
|
6797
|
+
continue;
|
|
6798
|
+
}
|
|
6799
|
+
return true;
|
|
6800
|
+
}
|
|
6801
|
+
if (c >= 56320 && c <= 57343) return true;
|
|
6802
|
+
}
|
|
6803
|
+
return false;
|
|
6804
|
+
}
|
|
6805
|
+
function write(value, depth) {
|
|
6806
|
+
if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
|
|
6807
|
+
if (value === null) return "null";
|
|
6808
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
6809
|
+
if (typeof value === "number") {
|
|
6810
|
+
if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
|
|
6811
|
+
return JSON.stringify(value);
|
|
6812
|
+
}
|
|
6813
|
+
if (typeof value === "string") {
|
|
6814
|
+
if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
|
|
6815
|
+
return JSON.stringify(value);
|
|
6816
|
+
}
|
|
6817
|
+
if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
|
|
6818
|
+
if (typeof value === "object") {
|
|
6819
|
+
const record = value;
|
|
6820
|
+
return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
|
|
6821
|
+
if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
|
|
6822
|
+
return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
|
|
6823
|
+
}).join(",")}}`;
|
|
6824
|
+
}
|
|
6825
|
+
throw new Error(`Not a JSON value: ${typeof value}`);
|
|
6826
|
+
}
|
|
6827
|
+
/** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
|
|
6828
|
+
function canonicalJson(value) {
|
|
6829
|
+
return write(value, 0);
|
|
6830
|
+
}
|
|
6831
|
+
/** The canonical UTF-8 bytes of a JSON value. */
|
|
6832
|
+
function canonicalJsonBytes(value) {
|
|
6833
|
+
return utf8Encode(canonicalJson(value));
|
|
6834
|
+
}
|
|
6835
|
+
/**
|
|
6836
|
+
* Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
|
|
6837
|
+
* when writing the value again gives other bytes.
|
|
6838
|
+
*/
|
|
6839
|
+
function readCanonicalJson(bytes) {
|
|
6840
|
+
let value;
|
|
6841
|
+
try {
|
|
6842
|
+
value = JSON.parse(utf8Decode(bytes));
|
|
6843
|
+
} catch (error) {
|
|
6844
|
+
return {
|
|
6845
|
+
ok: false,
|
|
6846
|
+
reason: "not-json",
|
|
6847
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
6848
|
+
};
|
|
6849
|
+
}
|
|
6850
|
+
let again;
|
|
6851
|
+
try {
|
|
6852
|
+
again = canonicalJsonBytes(value);
|
|
6853
|
+
} catch (error) {
|
|
6854
|
+
return {
|
|
6855
|
+
ok: false,
|
|
6856
|
+
reason: "not-canonical",
|
|
6857
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
6858
|
+
};
|
|
6859
|
+
}
|
|
6860
|
+
if (!bytesEqual(again, bytes)) return {
|
|
6861
|
+
ok: false,
|
|
6862
|
+
reason: "not-canonical"
|
|
6863
|
+
};
|
|
6864
|
+
return {
|
|
6865
|
+
ok: true,
|
|
6866
|
+
value
|
|
6867
|
+
};
|
|
6868
|
+
}
|
|
6869
|
+
//#endregion
|
|
6870
|
+
//#region ../core/src/appStatements.ts
|
|
6871
|
+
/** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
|
|
6872
|
+
var APP_PREFIXES = {
|
|
6873
|
+
app: "ghostly-app/1",
|
|
6874
|
+
store: "ghostly-store/1",
|
|
6875
|
+
revoke: "ghostly-revoke/1"
|
|
6876
|
+
};
|
|
6877
|
+
var APP_STATEMENT_LIMITS = {
|
|
6878
|
+
/** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
|
|
6879
|
+
signatureBytes: 1024,
|
|
6880
|
+
/** Digests named by one revocation. */
|
|
6881
|
+
revokeDigests: 64,
|
|
6882
|
+
/** A revocation's reason, in characters. */
|
|
6883
|
+
reason: 200,
|
|
6884
|
+
/** Revocations in one `ghostly-revoke.json`. */
|
|
6885
|
+
revocations: 4096
|
|
6886
|
+
};
|
|
6887
|
+
/** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
|
|
6888
|
+
function isAppKey(value) {
|
|
6889
|
+
if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
|
|
6890
|
+
const bytes = fromZ32(value);
|
|
6891
|
+
return bytes.length === 32 && toZ32(bytes) === value;
|
|
6892
|
+
}
|
|
6893
|
+
/** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
|
|
6894
|
+
function isAppHash(value) {
|
|
6895
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
6896
|
+
return toBase64Url(fromBase64Url(value)) === value;
|
|
6897
|
+
}
|
|
6898
|
+
/** An app reference, `<publisher key>/<name>`. */
|
|
6899
|
+
var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
6900
|
+
function isAppRef(value) {
|
|
6901
|
+
if (typeof value !== "string") return false;
|
|
6902
|
+
return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
|
|
6903
|
+
}
|
|
6904
|
+
function appRef(publisher, name) {
|
|
6905
|
+
return `${publisher}/${name}`;
|
|
6906
|
+
}
|
|
6907
|
+
/** The publisher key of a reference. */
|
|
6908
|
+
function appRefPublisher(ref) {
|
|
6909
|
+
return ref.slice(0, 52);
|
|
6910
|
+
}
|
|
6911
|
+
/**
|
|
6912
|
+
* The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
|
|
6913
|
+
*/
|
|
6914
|
+
function appFingerprint(key) {
|
|
6915
|
+
return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
|
|
6916
|
+
}
|
|
6917
|
+
/** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
|
|
6918
|
+
function appSignedBytes(prefix, canonical) {
|
|
6919
|
+
const p = utf8Encode(prefix);
|
|
6920
|
+
const out = new Uint8Array(p.length + 1 + 32);
|
|
6921
|
+
out.set(p, 0);
|
|
6922
|
+
out.set(sha256(canonical), p.length + 1);
|
|
6923
|
+
return out;
|
|
6924
|
+
}
|
|
6925
|
+
/** Signs an object under a prefix: its canonical bytes and the signature statement. */
|
|
6926
|
+
async function signAppObject(prefix, value, signer) {
|
|
6927
|
+
const bytes = canonicalJsonBytes(value);
|
|
6928
|
+
const sig = await signer.sign(appSignedBytes(prefix, bytes));
|
|
6929
|
+
return {
|
|
6930
|
+
bytes,
|
|
6931
|
+
signature: {
|
|
6932
|
+
alg: "ed25519",
|
|
6933
|
+
key: toZ32(signer.publicKey),
|
|
6934
|
+
sig: toBase64Url(sig)
|
|
6935
|
+
}
|
|
6936
|
+
};
|
|
6937
|
+
}
|
|
6938
|
+
/**
|
|
6939
|
+
* Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
|
|
6940
|
+
* so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
|
|
6941
|
+
*/
|
|
6942
|
+
function strictVerify(signature, message, publicKey) {
|
|
6943
|
+
try {
|
|
6944
|
+
return ed25519.verify(signature, message, publicKey, { zip215: false });
|
|
6945
|
+
} catch {
|
|
6946
|
+
return false;
|
|
6947
|
+
}
|
|
6948
|
+
}
|
|
6949
|
+
var exactKeys$1 = (value, keys, optional = []) => {
|
|
6950
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
6951
|
+
const have = Object.keys(value);
|
|
6952
|
+
return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
|
|
6953
|
+
};
|
|
6954
|
+
/** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
|
|
6955
|
+
function isAppSignature(value) {
|
|
6956
|
+
if (!exactKeys$1(value, [
|
|
6957
|
+
"alg",
|
|
6958
|
+
"key",
|
|
6959
|
+
"sig"
|
|
6960
|
+
])) return false;
|
|
6961
|
+
if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
|
|
6962
|
+
return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
|
|
6963
|
+
}
|
|
6964
|
+
/** Reads a signature statement from its bytes, which must be canonical. */
|
|
6965
|
+
function readAppSignature(bytes) {
|
|
6966
|
+
if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
|
|
6967
|
+
ok: false,
|
|
6968
|
+
reason: "bad-signature-statement",
|
|
6969
|
+
detail: "Too large"
|
|
6970
|
+
};
|
|
6971
|
+
const read = readCanonicalJson(bytes);
|
|
6972
|
+
if (!read.ok) return {
|
|
6973
|
+
ok: false,
|
|
6974
|
+
reason: "bad-signature-statement",
|
|
6975
|
+
detail: read.reason
|
|
6976
|
+
};
|
|
6977
|
+
if (!isAppSignature(read.value)) return {
|
|
6978
|
+
ok: false,
|
|
6979
|
+
reason: "bad-signature-statement",
|
|
6980
|
+
detail: "Not {alg, key, sig}"
|
|
6981
|
+
};
|
|
6982
|
+
return {
|
|
6983
|
+
ok: true,
|
|
6984
|
+
signature: read.value
|
|
6985
|
+
};
|
|
6986
|
+
}
|
|
6987
|
+
/**
|
|
6988
|
+
* Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
|
|
6989
|
+
* statement names another key, `bad-signature` when it does not verify.
|
|
6990
|
+
*/
|
|
6991
|
+
function verifyAppSignature(prefix, canonical, signature, key) {
|
|
6992
|
+
if (!isAppSignature(signature)) return {
|
|
6993
|
+
ok: false,
|
|
6994
|
+
reason: "bad-signature-statement"
|
|
6995
|
+
};
|
|
6996
|
+
if (signature.key !== key) return {
|
|
6997
|
+
ok: false,
|
|
6998
|
+
reason: "signature-key"
|
|
6999
|
+
};
|
|
7000
|
+
return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
|
|
7001
|
+
ok: false,
|
|
7002
|
+
reason: "bad-signature"
|
|
7003
|
+
};
|
|
7004
|
+
}
|
|
7005
|
+
var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
|
|
7006
|
+
var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
7007
|
+
/** Checks a revocation statement's shape; the reason it is refused, or null. */
|
|
7008
|
+
function checkAppRevokeStatement(value) {
|
|
7009
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
|
|
7010
|
+
const v = value;
|
|
7011
|
+
const byDigests = "digests" in v;
|
|
7012
|
+
if (!exactKeys$1(v, [
|
|
7013
|
+
"ghostlyRevoke",
|
|
7014
|
+
"app",
|
|
7015
|
+
byDigests ? "digests" : "upTo"
|
|
7016
|
+
], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
|
|
7017
|
+
if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
|
|
7018
|
+
if (!isAppRef(v.app)) return "app is not a reference";
|
|
7019
|
+
if (byDigests) {
|
|
7020
|
+
const d = v.digests;
|
|
7021
|
+
if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
|
|
7022
|
+
if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
|
|
7023
|
+
} else if (!isSequence(v.upTo)) return "upTo is not a sequence";
|
|
7024
|
+
if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
|
|
7025
|
+
return null;
|
|
7026
|
+
}
|
|
7027
|
+
/** Signs a revocation with the publisher's key. */
|
|
7028
|
+
async function signAppRevocation(statement, signer) {
|
|
7029
|
+
const problem = checkAppRevokeStatement(statement);
|
|
7030
|
+
if (problem) throw new Error(`Not a revocation: ${problem}`);
|
|
7031
|
+
if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
|
|
7032
|
+
const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
|
|
7033
|
+
return {
|
|
7034
|
+
statement,
|
|
7035
|
+
signature
|
|
7036
|
+
};
|
|
7037
|
+
}
|
|
7038
|
+
/**
|
|
7039
|
+
* Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
|
|
7040
|
+
* the signature under `ghostly-revoke/1` by the publisher its `app` names.
|
|
7041
|
+
*/
|
|
7042
|
+
function readAppRevocation(value) {
|
|
7043
|
+
if (!exactKeys$1(value, ["statement", "signature"])) return {
|
|
7044
|
+
ok: false,
|
|
7045
|
+
reason: "bad-revocation",
|
|
7046
|
+
detail: "Keys are not statement and signature"
|
|
7047
|
+
};
|
|
7048
|
+
const problem = checkAppRevokeStatement(value.statement);
|
|
7049
|
+
if (problem) return {
|
|
7050
|
+
ok: false,
|
|
7051
|
+
reason: "bad-revocation",
|
|
7052
|
+
detail: problem
|
|
7053
|
+
};
|
|
7054
|
+
if (!isAppSignature(value.signature)) return {
|
|
7055
|
+
ok: false,
|
|
7056
|
+
reason: "bad-signature-statement"
|
|
7057
|
+
};
|
|
7058
|
+
const statement = value.statement;
|
|
7059
|
+
const checked = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(statement), value.signature, appRefPublisher(statement.app));
|
|
7060
|
+
if (!checked.ok) return checked;
|
|
7061
|
+
return {
|
|
7062
|
+
ok: true,
|
|
7063
|
+
revocation: {
|
|
7064
|
+
statement,
|
|
7065
|
+
signature: value.signature
|
|
7066
|
+
}
|
|
7067
|
+
};
|
|
7068
|
+
}
|
|
7069
|
+
/**
|
|
7070
|
+
* Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
|
|
7071
|
+
* file, as a store index's `revoked` does.
|
|
7072
|
+
*/
|
|
7073
|
+
function readAppRevocations(bytes) {
|
|
7074
|
+
const read = readCanonicalJson(bytes);
|
|
7075
|
+
if (!read.ok) return read;
|
|
7076
|
+
if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
|
|
7077
|
+
ok: false,
|
|
7078
|
+
reason: "bad-revocation",
|
|
7079
|
+
detail: "Not a list of at most 4096 revocations"
|
|
7080
|
+
};
|
|
7081
|
+
const revocations = [];
|
|
7082
|
+
for (const entry of read.value) {
|
|
7083
|
+
const one = readAppRevocation(entry);
|
|
7084
|
+
if (!one.ok) return one;
|
|
7085
|
+
revocations.push(one.revocation);
|
|
7086
|
+
}
|
|
7087
|
+
return {
|
|
7088
|
+
ok: true,
|
|
7089
|
+
revocations
|
|
7090
|
+
};
|
|
7091
|
+
}
|
|
7092
|
+
//#endregion
|
|
6764
7093
|
//#region ../core/src/messageTime.ts
|
|
6765
7094
|
/**
|
|
6766
7095
|
* 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
|
|
@@ -6832,7 +7161,16 @@ var STATUS_CARD_LIMITS = {
|
|
|
6832
7161
|
/** A button's id: what a press names. */
|
|
6833
7162
|
buttonId: 32,
|
|
6834
7163
|
/** A button's label, in characters. */
|
|
6835
|
-
buttonLabel: 40
|
|
7164
|
+
buttonLabel: 40,
|
|
7165
|
+
/** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
|
|
7166
|
+
appTitle: 40,
|
|
7167
|
+
appVersion: 32,
|
|
7168
|
+
/** A usage card's label ("Claude") and account, in characters (WISP 405 § Usage). */
|
|
7169
|
+
usageLabel: 24,
|
|
7170
|
+
/** A usage window's name ("5 h", "week"), in characters. */
|
|
7171
|
+
usageWindow: 16,
|
|
7172
|
+
/** Windows beside a usage card's own, shown in its details. */
|
|
7173
|
+
usageWindows: 3
|
|
6836
7174
|
};
|
|
6837
7175
|
/** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
|
|
6838
7176
|
var BUTTON_STYLES = [
|
|
@@ -6898,8 +7236,8 @@ var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
|
6898
7236
|
* them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
|
|
6899
7237
|
*/
|
|
6900
7238
|
function buttonLabelClash(buttons) {
|
|
6901
|
-
const labels = buttons.map((button) => isObject$
|
|
6902
|
-
const ids = buttons.map((button) => isObject$
|
|
7239
|
+
const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
|
|
7240
|
+
const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
|
|
6903
7241
|
for (const [at, label] of labels.entries()) {
|
|
6904
7242
|
if (!label) continue;
|
|
6905
7243
|
const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
|
|
@@ -6916,7 +7254,13 @@ function buttonLabelClash(buttons) {
|
|
|
6916
7254
|
};
|
|
6917
7255
|
}
|
|
6918
7256
|
}
|
|
6919
|
-
|
|
7257
|
+
/** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
|
|
7258
|
+
function appCardId(ref) {
|
|
7259
|
+
return `${ref.slice(53)}.${ref.slice(0, 16)}`;
|
|
7260
|
+
}
|
|
7261
|
+
/** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
|
|
7262
|
+
var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
7263
|
+
var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6920
7264
|
/** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
|
|
6921
7265
|
function cardLine(value, max) {
|
|
6922
7266
|
if (typeof value !== "string") return void 0;
|
|
@@ -6945,7 +7289,7 @@ function readLinks(raw) {
|
|
|
6945
7289
|
if (!Array.isArray(raw)) return [];
|
|
6946
7290
|
const links = [];
|
|
6947
7291
|
for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
|
|
6948
|
-
if (!isObject$
|
|
7292
|
+
if (!isObject$4(entry)) continue;
|
|
6949
7293
|
const url = cardUrl(entry.url);
|
|
6950
7294
|
if (!url) continue;
|
|
6951
7295
|
const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
|
|
@@ -6968,7 +7312,7 @@ function readTags(raw) {
|
|
|
6968
7312
|
return tags;
|
|
6969
7313
|
}
|
|
6970
7314
|
function readRun(raw, now) {
|
|
6971
|
-
if (!isObject$
|
|
7315
|
+
if (!isObject$4(raw)) return void 0;
|
|
6972
7316
|
const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
|
|
6973
7317
|
if (!at || !result) return void 0;
|
|
6974
7318
|
const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
|
|
@@ -6978,6 +7322,52 @@ function readRun(raw, now) {
|
|
|
6978
7322
|
...summary && { summary }
|
|
6979
7323
|
};
|
|
6980
7324
|
}
|
|
7325
|
+
/** A percent as a reader keeps it: a finite number, rounded and clamped into 0 to 100. */
|
|
7326
|
+
var percent = (value) => typeof value === "number" && Number.isFinite(value) ? Math.round(Math.min(100, Math.max(0, value))) : void 0;
|
|
7327
|
+
/** A time ahead of the reader (a reset, a next run) as it keeps it: whole, positive, at most a year ahead. */
|
|
7328
|
+
var ahead = (value, now) => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now + STATUS_CARD_LIMITS.nextRunMs ? value : void 0;
|
|
7329
|
+
/** A usage card as a reader keeps it (WISP 405 § Usage): dropped without a percent left or a used of limit. */
|
|
7330
|
+
function readUsage(raw, id, now) {
|
|
7331
|
+
const left = percent(raw.left);
|
|
7332
|
+
let used = count(raw.used), limit = count(raw.limit, 1);
|
|
7333
|
+
if (used === void 0 || limit === void 0 || used > limit) used = limit = void 0;
|
|
7334
|
+
if (left === void 0 && limit === void 0) return void 0;
|
|
7335
|
+
const label = cardLine(raw.label, STATUS_CARD_LIMITS.usageLabel), account = cardLine(raw.account, STATUS_CARD_LIMITS.usageLabel);
|
|
7336
|
+
const window = cardLine(raw.window, STATUS_CARD_LIMITS.usageWindow);
|
|
7337
|
+
const resetsAt = ahead(raw.resetsAt, now), updatedAt = time(raw.updatedAt, now + MESSAGE_CLOCK_SKEW_MS);
|
|
7338
|
+
const windows = [];
|
|
7339
|
+
if (Array.isArray(raw.windows)) for (const entry of raw.windows.slice(0, STATUS_CARD_LIMITS.usageWindows)) {
|
|
7340
|
+
if (!isObject$4(entry)) continue;
|
|
7341
|
+
const name = cardLine(entry.window, STATUS_CARD_LIMITS.usageWindow), share = percent(entry.left);
|
|
7342
|
+
if (!name || share === void 0) continue;
|
|
7343
|
+
const at = ahead(entry.resetsAt, now);
|
|
7344
|
+
windows.push({
|
|
7345
|
+
window: name,
|
|
7346
|
+
left: share,
|
|
7347
|
+
...at && { resetsAt: at }
|
|
7348
|
+
});
|
|
7349
|
+
}
|
|
7350
|
+
return {
|
|
7351
|
+
kind: "usage",
|
|
7352
|
+
id,
|
|
7353
|
+
...left !== void 0 && { left },
|
|
7354
|
+
...used !== void 0 && limit !== void 0 && {
|
|
7355
|
+
used,
|
|
7356
|
+
limit
|
|
7357
|
+
},
|
|
7358
|
+
...label && { label },
|
|
7359
|
+
...account && { account },
|
|
7360
|
+
...window && { window },
|
|
7361
|
+
...resetsAt && { resetsAt },
|
|
7362
|
+
...updatedAt && { updatedAt },
|
|
7363
|
+
...windows.length && { windows }
|
|
7364
|
+
};
|
|
7365
|
+
}
|
|
7366
|
+
/** How much of a usage card's quota is left, 0 to 100: its own percent, else worked out from used of limit. */
|
|
7367
|
+
function usageLeft(card) {
|
|
7368
|
+
if (card.left !== void 0) return card.left;
|
|
7369
|
+
return card.used !== void 0 && card.limit ? Math.round((card.limit - card.used) / card.limit * 100) : 0;
|
|
7370
|
+
}
|
|
6981
7371
|
/** The bytes a card takes on the wire: what `STATUS_CARD_LIMITS.bytes` bounds. */
|
|
6982
7372
|
function statusCardBytes(card) {
|
|
6983
7373
|
try {
|
|
@@ -6993,10 +7383,28 @@ function statusCardBytes(card) {
|
|
|
6993
7383
|
* routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
|
|
6994
7384
|
*/
|
|
6995
7385
|
function readStatusCard(raw, now = Date.now()) {
|
|
6996
|
-
if (!isObject$
|
|
7386
|
+
if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
|
|
6997
7387
|
const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
|
|
6998
7388
|
if (!id) return void 0;
|
|
7389
|
+
if (raw.kind === "app") {
|
|
7390
|
+
if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
|
|
7391
|
+
const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
|
|
7392
|
+
if (!title) return void 0;
|
|
7393
|
+
const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
|
|
7394
|
+
return {
|
|
7395
|
+
kind: "app",
|
|
7396
|
+
id,
|
|
7397
|
+
ref: raw.ref,
|
|
7398
|
+
digest: raw.digest,
|
|
7399
|
+
sequence: raw.sequence,
|
|
7400
|
+
title,
|
|
7401
|
+
...version && { version },
|
|
7402
|
+
...url && { url },
|
|
7403
|
+
...raw.opened === true && { opened: true }
|
|
7404
|
+
};
|
|
7405
|
+
}
|
|
6999
7406
|
const latest = now + MESSAGE_CLOCK_SKEW_MS;
|
|
7407
|
+
if (raw.kind === "usage") return readUsage(raw, id, now);
|
|
7000
7408
|
const links = readLinks(raw.links);
|
|
7001
7409
|
if (raw.kind === "task") {
|
|
7002
7410
|
const title = cardLine(raw.title, STATUS_CARD_LIMITS.title), status = oneOf(TASK_STATUSES, raw.status);
|
|
@@ -7006,7 +7414,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7006
7414
|
if (done === void 0 || total === void 0 || done > total) done = total = void 0;
|
|
7007
7415
|
const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
|
|
7008
7416
|
let pr;
|
|
7009
|
-
if (isObject$
|
|
7417
|
+
if (isObject$4(raw.pr)) {
|
|
7010
7418
|
const url = cardUrl(raw.pr.url);
|
|
7011
7419
|
if (url) {
|
|
7012
7420
|
const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
|
|
@@ -7024,7 +7432,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7024
7432
|
}
|
|
7025
7433
|
const items = [];
|
|
7026
7434
|
if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
|
|
7027
|
-
if (!isObject$
|
|
7435
|
+
if (!isObject$4(entry)) continue;
|
|
7028
7436
|
const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
|
|
7029
7437
|
if (text) items.push({
|
|
7030
7438
|
text,
|
|
@@ -7083,7 +7491,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7083
7491
|
if (!Array.isArray(raw.buttons)) return void 0;
|
|
7084
7492
|
const buttons = [], seen = /* @__PURE__ */ new Set();
|
|
7085
7493
|
for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
|
|
7086
|
-
if (!isObject$
|
|
7494
|
+
if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
|
|
7087
7495
|
const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
|
|
7088
7496
|
if (!label) continue;
|
|
7089
7497
|
seen.add(entry.id);
|
|
@@ -7112,10 +7520,10 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7112
7520
|
* at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
|
|
7113
7521
|
*/
|
|
7114
7522
|
function checkStatusCard(raw, now = Date.now()) {
|
|
7115
|
-
if (!isObject$
|
|
7523
|
+
if (!isObject$4(raw)) return { error: "A card is an object" };
|
|
7116
7524
|
const bytes = statusCardBytes(raw);
|
|
7117
7525
|
if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
|
|
7118
|
-
if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons") return { error: "kind is task, routine or
|
|
7526
|
+
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" };
|
|
7119
7527
|
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 -` };
|
|
7120
7528
|
const line = (field, value, max, required = false) => {
|
|
7121
7529
|
if (value === void 0 && !required) return null;
|
|
@@ -7129,20 +7537,53 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7129
7537
|
if (raw.links !== void 0) {
|
|
7130
7538
|
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}`);
|
|
7131
7539
|
else for (const [i, link] of raw.links.entries()) {
|
|
7132
|
-
if (!isObject$
|
|
7540
|
+
if (!isObject$4(link)) {
|
|
7133
7541
|
errors.push(`links[${i}] is an object`);
|
|
7134
7542
|
continue;
|
|
7135
7543
|
}
|
|
7136
7544
|
errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
|
|
7137
7545
|
}
|
|
7138
7546
|
}
|
|
7139
|
-
|
|
7547
|
+
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`;
|
|
7548
|
+
const reset = (field, value) => value === void 0 || ahead(value, now) !== void 0 ? null : `${field} is a time in milliseconds, within a year`;
|
|
7549
|
+
if (raw.kind === "usage") {
|
|
7550
|
+
if (raw.links !== void 0) errors.push("a usage card takes no links");
|
|
7551
|
+
if (raw.left === void 0 && raw.used === void 0) errors.push("left (a percent) or used and limit");
|
|
7552
|
+
errors.push(share("left", raw.left));
|
|
7553
|
+
if (raw.used === void 0 !== (raw.limit === void 0)) errors.push("used and limit go together");
|
|
7554
|
+
else if (raw.used !== void 0) {
|
|
7555
|
+
errors.push(whole("used", raw.used), whole("limit", raw.limit, 1));
|
|
7556
|
+
if (typeof raw.used === "number" && typeof raw.limit === "number" && raw.used > raw.limit) errors.push("used is at most limit");
|
|
7557
|
+
}
|
|
7558
|
+
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));
|
|
7559
|
+
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");
|
|
7560
|
+
if (raw.windows !== void 0) {
|
|
7561
|
+
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}`);
|
|
7562
|
+
else for (const [i, entry] of raw.windows.entries()) {
|
|
7563
|
+
if (!isObject$4(entry)) {
|
|
7564
|
+
errors.push(`windows[${i}] is an object`);
|
|
7565
|
+
continue;
|
|
7566
|
+
}
|
|
7567
|
+
errors.push(line(`windows[${i}].window`, entry.window, STATUS_CARD_LIMITS.usageWindow, true));
|
|
7568
|
+
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));
|
|
7569
|
+
}
|
|
7570
|
+
}
|
|
7571
|
+
} else if (raw.kind === "app") {
|
|
7572
|
+
if (raw.links !== void 0) errors.push("an app card takes no links");
|
|
7573
|
+
if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
|
|
7574
|
+
else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
|
|
7575
|
+
if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
|
|
7576
|
+
if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
|
|
7577
|
+
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
|
|
7578
|
+
if (raw.url !== void 0) errors.push(url("url", raw.url));
|
|
7579
|
+
if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
|
|
7580
|
+
} else if (raw.kind === "buttons") {
|
|
7140
7581
|
if (raw.links !== void 0) errors.push("buttons take no links");
|
|
7141
7582
|
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}`);
|
|
7142
7583
|
else {
|
|
7143
7584
|
const ids = /* @__PURE__ */ new Set();
|
|
7144
7585
|
for (const [i, button] of raw.buttons.entries()) {
|
|
7145
|
-
if (!isObject$
|
|
7586
|
+
if (!isObject$4(button)) {
|
|
7146
7587
|
errors.push(`buttons[${i}] is an object`);
|
|
7147
7588
|
continue;
|
|
7148
7589
|
}
|
|
@@ -7169,7 +7610,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7169
7610
|
}
|
|
7170
7611
|
errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
|
|
7171
7612
|
if (raw.pr !== void 0) {
|
|
7172
|
-
if (!isObject$
|
|
7613
|
+
if (!isObject$4(raw.pr)) errors.push("pr is an object");
|
|
7173
7614
|
else {
|
|
7174
7615
|
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));
|
|
7175
7616
|
if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
|
|
@@ -7201,7 +7642,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7201
7642
|
if (raw.items !== void 0) {
|
|
7202
7643
|
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}`);
|
|
7203
7644
|
else for (const [i, item] of raw.items.entries()) {
|
|
7204
|
-
if (!isObject$
|
|
7645
|
+
if (!isObject$4(item)) {
|
|
7205
7646
|
errors.push(`items[${i}] is an object`);
|
|
7206
7647
|
continue;
|
|
7207
7648
|
}
|
|
@@ -7215,7 +7656,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7215
7656
|
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`);
|
|
7216
7657
|
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");
|
|
7217
7658
|
const run = (field, value) => {
|
|
7218
|
-
if (!isObject$
|
|
7659
|
+
if (!isObject$4(value)) return errors.push(`${field} is an object`);
|
|
7219
7660
|
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`);
|
|
7220
7661
|
if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
|
|
7221
7662
|
errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
|
|
@@ -7266,6 +7707,21 @@ function cardTimeUtc(ms) {
|
|
|
7266
7707
|
* and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
|
|
7267
7708
|
*/
|
|
7268
7709
|
function statusCardText(card) {
|
|
7710
|
+
if (card.kind === "app") {
|
|
7711
|
+
const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
|
|
7712
|
+
return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
|
|
7713
|
+
}
|
|
7714
|
+
if (card.kind === "usage") {
|
|
7715
|
+
const left = `${usageLeft(card)}% left${card.window ? ` (${card.window})` : ""}`;
|
|
7716
|
+
return [
|
|
7717
|
+
`📊 ${card.label ?? "Usage"}`,
|
|
7718
|
+
...card.account ? [card.account] : [],
|
|
7719
|
+
left,
|
|
7720
|
+
...card.used !== void 0 && card.limit !== void 0 ? [`${card.used} of ${card.limit} used`] : [],
|
|
7721
|
+
...card.resetsAt ? [`resets ${cardTimeUtc(card.resetsAt)}`] : [],
|
|
7722
|
+
...(card.windows ?? []).map((w) => `${w.window} ${w.left}%`)
|
|
7723
|
+
].join(" · ");
|
|
7724
|
+
}
|
|
7269
7725
|
if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
|
|
7270
7726
|
if (card.kind === "task") {
|
|
7271
7727
|
const progress = taskProgress(card);
|
|
@@ -9806,6 +10262,14 @@ var TransportSwitch = class {
|
|
|
9806
10262
|
settled = "";
|
|
9807
10263
|
failed = "";
|
|
9808
10264
|
preparing = false;
|
|
10265
|
+
/** The coordinator's dial for the pending plan has started (`dialing`): from then on a newer choice waits for it. */
|
|
10266
|
+
dialStarted = false;
|
|
10267
|
+
/**
|
|
10268
|
+
* The contact's `paired-policy` says `replaces`: its app reads a `paired-switch-keep` for a plan a newer choice
|
|
10269
|
+
* replaced as that (`supersede`). An app before 1.1.5 reads it as its target missed and says the chat was kept on its
|
|
10270
|
+
* transport; 1.1.5 reads it right but does not say so.
|
|
10271
|
+
*/
|
|
10272
|
+
remoteReplaces = false;
|
|
9809
10273
|
/** This side chose while no session was open: the next one begins with it as a choice. */
|
|
9810
10274
|
apart = false;
|
|
9811
10275
|
constructor(options) {
|
|
@@ -9861,10 +10325,12 @@ var TransportSwitch = class {
|
|
|
9861
10325
|
context: this.context
|
|
9862
10326
|
});
|
|
9863
10327
|
}
|
|
10328
|
+
/** Older apps read the policy's own fields only, and drop `replaces`. */
|
|
9864
10329
|
announce() {
|
|
9865
10330
|
this.send({
|
|
9866
10331
|
t: "paired-policy",
|
|
9867
|
-
policy: this.local()
|
|
10332
|
+
policy: this.local(),
|
|
10333
|
+
replaces: true
|
|
9868
10334
|
});
|
|
9869
10335
|
}
|
|
9870
10336
|
/**
|
|
@@ -9881,6 +10347,7 @@ var TransportSwitch = class {
|
|
|
9881
10347
|
if (!migrated) {
|
|
9882
10348
|
this.failed = "";
|
|
9883
10349
|
this.remote = null;
|
|
10350
|
+
this.remoteReplaces = false;
|
|
9884
10351
|
}
|
|
9885
10352
|
if (this.apart) {
|
|
9886
10353
|
this.intent = Math.max(this.intent, this.lastRemote?.intent ?? 0) + 1;
|
|
@@ -9903,6 +10370,7 @@ var TransportSwitch = class {
|
|
|
9903
10370
|
} else if (userIntent) this.intent = Math.max(this.intent, this.remote?.intent ?? 0) + 1;
|
|
9904
10371
|
this.failed = "";
|
|
9905
10372
|
this.announce();
|
|
10373
|
+
this.supersede();
|
|
9906
10374
|
this.reconcile();
|
|
9907
10375
|
}
|
|
9908
10376
|
/**
|
|
@@ -9964,12 +10432,38 @@ var TransportSwitch = class {
|
|
|
9964
10432
|
this.clearPlan();
|
|
9965
10433
|
this.context = "";
|
|
9966
10434
|
this.remote = null;
|
|
10435
|
+
this.remoteReplaces = false;
|
|
9967
10436
|
}
|
|
9968
10437
|
clearPlan() {
|
|
9969
10438
|
if (this.timer) clearTimeout(this.timer);
|
|
9970
10439
|
this.timer = null;
|
|
9971
10440
|
this.plan = null;
|
|
9972
10441
|
this.preparing = false;
|
|
10442
|
+
this.dialStarted = false;
|
|
10443
|
+
}
|
|
10444
|
+
/** The coordinator starts dialling the pending plan (a move back to WebRTC first waits for the old session to retire). */
|
|
10445
|
+
dialing(plan) {
|
|
10446
|
+
if (this.plan === plan) this.dialStarted = true;
|
|
10447
|
+
}
|
|
10448
|
+
/**
|
|
10449
|
+
* A newer choice (this side's, or the contact's policy) while a plan both sides agreed has not started its dial: a
|
|
10450
|
+
* move back to WebRTC waits for the WebRTC session it left to retire (`SWITCH_RETIRE_MS`), and nothing has been tried
|
|
10451
|
+
* yet. The coordinator drops that plan, so the chat does not move there only to move again; the other side is told
|
|
10452
|
+
* with `paired-switch-keep`, and whatever the newer policies call for is planned next. A plan already dialling goes
|
|
10453
|
+
* on: a choice made then is planned once it ended (`replanIfMoved`). So does a plan the contact's app would take
|
|
10454
|
+
* that keep for a target missed (its policy did not say `replaces`): the newer choice is planned once it landed.
|
|
10455
|
+
*/
|
|
10456
|
+
supersede() {
|
|
10457
|
+
const plan = this.plan;
|
|
10458
|
+
if (!plan || !this.preparing || this.dialStarted || !this.context || !this.remote || this.options.key > this.options.peerKey || !this.remoteReplaces) return;
|
|
10459
|
+
if (this.choices(this.local(), this.remote)[0] === plan.choices[0]) return;
|
|
10460
|
+
this.send({
|
|
10461
|
+
t: "paired-switch-keep",
|
|
10462
|
+
id: plan.id
|
|
10463
|
+
});
|
|
10464
|
+
this.clearPlan();
|
|
10465
|
+
this.options.cancel();
|
|
10466
|
+
this.options.state();
|
|
9973
10467
|
}
|
|
9974
10468
|
fail(error) {
|
|
9975
10469
|
const target = this.plan?.choices[0], upgrade = !!this.plan?.upgrade;
|
|
@@ -10091,8 +10585,10 @@ var TransportSwitch = class {
|
|
|
10091
10585
|
if (!policy || this.remote && (policy.revision < this.remote.revision || policy.revision === this.remote.revision && JSON.stringify(policy) !== JSON.stringify(this.remote))) return true;
|
|
10092
10586
|
const changed = !this.remote || policy.revision !== this.remote.revision;
|
|
10093
10587
|
this.remote = this.lastRemote = policy;
|
|
10588
|
+
this.remoteReplaces = frame.replaces === true;
|
|
10094
10589
|
this.options.peer(policy);
|
|
10095
10590
|
if (changed && this.plan && !this.preparing) this.clearPlan();
|
|
10591
|
+
else if (changed) this.supersede();
|
|
10096
10592
|
this.reconcile();
|
|
10097
10593
|
return true;
|
|
10098
10594
|
}
|
|
@@ -10129,6 +10625,12 @@ var TransportSwitch = class {
|
|
|
10129
10625
|
return true;
|
|
10130
10626
|
}
|
|
10131
10627
|
if (frame.t === "paired-switch-keep") {
|
|
10628
|
+
if (this.preparing && this.remote && this.choices(this.local(), this.remote)[0] !== this.plan.choices[0]) {
|
|
10629
|
+
this.clearPlan();
|
|
10630
|
+
this.options.state();
|
|
10631
|
+
this.reconcile();
|
|
10632
|
+
return true;
|
|
10633
|
+
}
|
|
10132
10634
|
if (this.actual && this.plan.choices.includes(this.actual)) {
|
|
10133
10635
|
if (this.plan.choices[0] !== this.actual && !this.plan.upgrade) this.options.kept?.(this.plan.choices[0]);
|
|
10134
10636
|
this.settled = this.signature(this.plan.local, this.plan.remote);
|
|
@@ -11282,11 +11784,17 @@ var CAPS_READ_SPACING_MS = 15e3;
|
|
|
11282
11784
|
* already knows and looks the key up behind it, and a lookup that started before the publication landed finds the old
|
|
11283
11785
|
* one; a relay may serve an old copy for a while too. The envelope names each revision once, so one read was the last
|
|
11284
11786
|
* until the next envelope, minutes away, and the chat stayed on the DHT with no descriptors to dial.
|
|
11787
|
+
*
|
|
11788
|
+
* The first ones come soon: a contact's record that lists a native transport with no descriptor is the "Peer native
|
|
11789
|
+
* address unavailable" a dial ends with, and its revision with the descriptors is usually out a second or two after the
|
|
11790
|
+
* first (its endpoints were starting). Read 5 s, then 20 s after, two Desktops DHT direct with Iroh's relay out of reach
|
|
11791
|
+
* went live 11 to 28 s after their chats opened (2026-10-07). One read more than before, three in the first 7 s.
|
|
11285
11792
|
*/
|
|
11286
11793
|
var CAPS_READ_RETRY_MS = [
|
|
11287
|
-
|
|
11794
|
+
1e3,
|
|
11795
|
+
2e3,
|
|
11796
|
+
4e3,
|
|
11288
11797
|
15e3,
|
|
11289
|
-
3e4,
|
|
11290
11798
|
6e4
|
|
11291
11799
|
];
|
|
11292
11800
|
var NATIVE = ["iroh/1", "hyperdht/1"];
|
|
@@ -11878,7 +12386,7 @@ var KINDS$1 = [
|
|
|
11878
12386
|
var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
11879
12387
|
var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
|
|
11880
12388
|
var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
|
|
11881
|
-
var MAGIC$
|
|
12389
|
+
var MAGIC$2 = utf8Encode("GHLD");
|
|
11882
12390
|
var VERSION = 1;
|
|
11883
12391
|
var NONCE$2 = 24;
|
|
11884
12392
|
/** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
|
|
@@ -11944,7 +12452,7 @@ var HoldKeys = class {
|
|
|
11944
12452
|
new DataView(length.buffer).setUint32(0, header.length);
|
|
11945
12453
|
const nonce = randomBytes(NONCE$2);
|
|
11946
12454
|
const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
|
|
11947
|
-
return concatBytes$1(MAGIC$
|
|
12455
|
+
return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
|
|
11948
12456
|
}
|
|
11949
12457
|
/**
|
|
11950
12458
|
* Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
|
|
@@ -11954,11 +12462,11 @@ var HoldKeys = class {
|
|
|
11954
12462
|
open(bytes, expect = {}) {
|
|
11955
12463
|
const now = expect.now ?? Date.now();
|
|
11956
12464
|
if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
|
|
11957
|
-
if (bytes.length < MAGIC$
|
|
11958
|
-
if (bytes[MAGIC$
|
|
12465
|
+
if (bytes.length < MAGIC$2.length + 1 + NONCE$2 + 16 + 4 || !MAGIC$2.every((b, i) => bytes[i] === b)) throw new HoldRefusedError("format", "Not a held item");
|
|
12466
|
+
if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
|
|
11959
12467
|
let plain;
|
|
11960
12468
|
try {
|
|
11961
|
-
plain = xsalsa20poly1305(this.sealKey, bytes.subarray(MAGIC$
|
|
12469
|
+
plain = xsalsa20poly1305(this.sealKey, bytes.subarray(MAGIC$2.length + 1, MAGIC$2.length + 1 + NONCE$2)).decrypt(bytes.subarray(MAGIC$2.length + 1 + NONCE$2));
|
|
11962
12470
|
} catch {
|
|
11963
12471
|
throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
|
|
11964
12472
|
}
|
|
@@ -12164,6 +12672,18 @@ function readManifest(meta) {
|
|
|
12164
12672
|
*/
|
|
12165
12673
|
var OFFER_EXTENSIONS = ["ping/1"];
|
|
12166
12674
|
var MAX_HANDSHAKE_BYTES = 4096;
|
|
12675
|
+
/**
|
|
12676
|
+
* Frames received and not handled yet, at most: their count and their size together. Frames are handled one at a time,
|
|
12677
|
+
* each awaited (a group frame is decrypted, checked and stored before the next), and the peer sends at the network's
|
|
12678
|
+
* pace. A member back in a group answers a sync with a burst: a commit per epoch, its kept messages, the ones it hands
|
|
12679
|
+
* on, edits, reactions. With a bound of 64 frames a phone handling them a few milliseconds each failed its session
|
|
12680
|
+
* mid catch-up ("Session receive limit exceeded" then, "Session receive queue full" now, 2026-10-07). The size bound keeps the old worst case in memory
|
|
12681
|
+
* (64 frames of 60 KiB); the count only bounds a flood of tiny frames.
|
|
12682
|
+
*/
|
|
12683
|
+
var SESSION_RECEIVE_PENDING = {
|
|
12684
|
+
frames: 1024,
|
|
12685
|
+
bytes: 3932160
|
|
12686
|
+
};
|
|
12167
12687
|
var KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
|
|
12168
12688
|
var NONCE$1 = /^[A-Za-z0-9_-]{43}$/;
|
|
12169
12689
|
var SIG$2 = /^[A-Za-z0-9_-]{86}$/;
|
|
@@ -12195,6 +12715,11 @@ var PairedSession = class {
|
|
|
12195
12715
|
state = { status: "negotiating" };
|
|
12196
12716
|
/** The peer said nothing more within `authTimeoutMs`: the connection carried nothing, which proves nothing about the peer. */
|
|
12197
12717
|
authTimedOut = false;
|
|
12718
|
+
/**
|
|
12719
|
+
* The peer sent faster than this side handles, past `SESSION_RECEIVE_PENDING`: the session ends, which is no proof
|
|
12720
|
+
* of anything wrong with the peer (a burst of catch-up on a slow device), so the chat dials again.
|
|
12721
|
+
*/
|
|
12722
|
+
overloaded = false;
|
|
12198
12723
|
/** The seed's identity; null when a signer signs. */
|
|
12199
12724
|
identity;
|
|
12200
12725
|
signer;
|
|
@@ -12211,6 +12736,7 @@ var PairedSession = class {
|
|
|
12211
12736
|
confirming = false;
|
|
12212
12737
|
admission = null;
|
|
12213
12738
|
pending = 0;
|
|
12739
|
+
pendingBytes = 0;
|
|
12214
12740
|
queue = Promise.resolve();
|
|
12215
12741
|
timer = null;
|
|
12216
12742
|
constructor(channel, options) {
|
|
@@ -12298,7 +12824,12 @@ var PairedSession = class {
|
|
|
12298
12824
|
if (!(binding ? (binding.transport === "hyperdht/1" ? /^[a-f0-9]{128}$/.test(binding.context) : FINGERPRINT.test(binding.context)) && binding.identities.length === 2 && binding.identities.every((k) => FINGERPRINT.test(k)) && binding.identities[0] !== binding.identities[1] : this.options.fingerprints?.length === 2 && this.options.fingerprints.every((f) => FINGERPRINT.test(f))) || !this.offer.transports.includes(this.transport) || this.options.rendezvousKeys.some((k) => !KEY.test(k))) return this.fail("Invalid connection binding or unavailable transport");
|
|
12299
12825
|
this.channel.onMessage = (data) => {
|
|
12300
12826
|
if (this.stopped) return;
|
|
12301
|
-
|
|
12827
|
+
const size = data.length;
|
|
12828
|
+
if (size > 61440) return this.fail("Session frame too large");
|
|
12829
|
+
if (++this.pending > SESSION_RECEIVE_PENDING.frames || (this.pendingBytes += size) > SESSION_RECEIVE_PENDING.bytes) {
|
|
12830
|
+
this.overloaded = true;
|
|
12831
|
+
return this.fail("Session receive queue full");
|
|
12832
|
+
}
|
|
12302
12833
|
this.queue = this.queue.then(async () => {
|
|
12303
12834
|
if (this.stopped) return;
|
|
12304
12835
|
if (typeof data === "string" && data.startsWith("{\"t\":\"pair-")) {
|
|
@@ -12307,6 +12838,7 @@ var PairedSession = class {
|
|
|
12307
12838
|
} else if (this.state.status === "ready") await this.options.onApplication(data);
|
|
12308
12839
|
}).catch(() => this.fail("Invalid session negotiation")).finally(() => {
|
|
12309
12840
|
this.pending--;
|
|
12841
|
+
this.pendingBytes -= size;
|
|
12310
12842
|
});
|
|
12311
12843
|
};
|
|
12312
12844
|
this.timer = setTimeout(() => {
|
|
@@ -12574,6 +13106,11 @@ var BUTTONS_CAPABILITY = "buttons/1";
|
|
|
12574
13106
|
* (WISP 03), so an app back after an absence knows before any session whether a relay can carry it there at once.
|
|
12575
13107
|
*/
|
|
12576
13108
|
var UPGRADE_CAPABILITY = "upgrade/1";
|
|
13109
|
+
/**
|
|
13110
|
+
* Mini-apps in a 1:1 chat: `paired-app` frames on this session, live only (WISP 1200 § In a chat: `apps/1`,
|
|
13111
|
+
* `pairedApps.ts`). Offered only while the apps feature is on.
|
|
13112
|
+
*/
|
|
13113
|
+
var APPS_CAPABILITY = "apps/1";
|
|
12577
13114
|
/** Every capability this app knows on a session: what `receive` reports changes of. */
|
|
12578
13115
|
var KNOWN_SESSION_CAPABILITIES = [
|
|
12579
13116
|
CALLS_CAPABILITY,
|
|
@@ -12586,7 +13123,8 @@ var KNOWN_SESSION_CAPABILITIES = [
|
|
|
12586
13123
|
PIN_CAPABILITY,
|
|
12587
13124
|
STATUS_CARD_CAPABILITY,
|
|
12588
13125
|
BUTTONS_CAPABILITY,
|
|
12589
|
-
UPGRADE_CAPABILITY
|
|
13126
|
+
UPGRADE_CAPABILITY,
|
|
13127
|
+
APPS_CAPABILITY
|
|
12590
13128
|
];
|
|
12591
13129
|
var SESSION_CAPABILITIES_FRAME = "paired-capabilities";
|
|
12592
13130
|
var MAX_ENTRIES$1 = 32;
|
|
@@ -12653,6 +13191,269 @@ var SessionCapabilities = class {
|
|
|
12653
13191
|
}
|
|
12654
13192
|
};
|
|
12655
13193
|
//#endregion
|
|
13194
|
+
//#region ../core/src/pairedApps.ts
|
|
13195
|
+
/**
|
|
13196
|
+
* Mini-apps talking in a 1:1 chat (WISP 1200 § In a chat: `apps/1`): the same app on both sides sends JSON to the
|
|
13197
|
+
* other over the live session, in one frame with three forms.
|
|
13198
|
+
*
|
|
13199
|
+
* {"t":"paired-app","a":"<chat app id>","o":"open","v":"1.2.0"}
|
|
13200
|
+
* {"t":"paired-app","a":"<chat app id>","o":"close"}
|
|
13201
|
+
* {"t":"paired-app","a":"<chat app id>","d":<JSON value>}
|
|
13202
|
+
*
|
|
13203
|
+
* It needs `apps/1` in `paired-capabilities` on both sides. Live only: never on the DHT, never held, never queued for
|
|
13204
|
+
* later; while the session is not live a send fails with `offline`. An `open` goes when the person opens the app in
|
|
13205
|
+
* the chat, again on an update, and again on every session that comes back while the app is open. The receiver
|
|
13206
|
+
* enforces the limits (the sender's client may be the attacker): at most `APP_DATA_MAX_BYTES` of data, at most
|
|
13207
|
+
* `APP_RATE_LIMIT` frames a second per app, data only for an app open on both sides, at most `APP_PEER_OPEN_MAX`
|
|
13208
|
+
* apps the peer has open, and nothing unless both sides offer `apps/1`.
|
|
13209
|
+
*
|
|
13210
|
+
* The chat app id names one app in one chat: the first 16 bytes of HMAC-SHA-256 keyed by the two pinned participation
|
|
13211
|
+
* keys over the app reference `<publisher key in z-base32>/<name>`. One app's id differs in each chat, so ids cannot
|
|
13212
|
+
* be matched across a person's chats; it travels only inside the encrypted session.
|
|
13213
|
+
*/
|
|
13214
|
+
var APP_FRAME = "paired-app";
|
|
13215
|
+
var DOMAIN = "ghostly-apps/1";
|
|
13216
|
+
var APP_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
13217
|
+
/** Semantic versioning 2.0.0, as a manifest's `version` is. */
|
|
13218
|
+
var SEMVER$1 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
13219
|
+
/** A chat app id as it is on the wire: 22 base64url characters (16 bytes, no padding). */
|
|
13220
|
+
function isChatAppId(value) {
|
|
13221
|
+
return typeof value === "string" && APP_ID.test(value);
|
|
13222
|
+
}
|
|
13223
|
+
/** A version an `open` may carry: semantic versioning, at most `APP_VERSION_MAX` characters. */
|
|
13224
|
+
function isAppVersion(value) {
|
|
13225
|
+
return typeof value === "string" && value.length <= 64 && SEMVER$1.test(value);
|
|
13226
|
+
}
|
|
13227
|
+
function compareBytes$2(a, b) {
|
|
13228
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
13229
|
+
return a.length - b.length;
|
|
13230
|
+
}
|
|
13231
|
+
/**
|
|
13232
|
+
* The chat app id of `ref` in the chat between these two participation keys (raw, 32 bytes each, in either order):
|
|
13233
|
+
* HMAC-SHA-256 keyed by SHA-256(`ghostly-apps/1`, 0x00, the lower key, the higher key), over the UTF-8 reference;
|
|
13234
|
+
* its first 16 bytes, base64url without padding.
|
|
13235
|
+
*/
|
|
13236
|
+
function chatAppId(keyA, keyB, ref) {
|
|
13237
|
+
if (keyA.length !== 32 || keyB.length !== 32) throw new Error("A participation key is 32 bytes");
|
|
13238
|
+
if (!isAppRef(ref)) throw new Error("Not an app reference");
|
|
13239
|
+
const [low, high] = compareBytes$2(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA];
|
|
13240
|
+
const key = sha256(concatBytes$1(utf8Encode(DOMAIN), new Uint8Array([0]), low, high));
|
|
13241
|
+
return toBase64Url(hmac(sha256, key, utf8Encode(ref)).slice(0, 16));
|
|
13242
|
+
}
|
|
13243
|
+
/** The size of a value as compact UTF-8 JSON; Infinity when it is not JSON (undefined, a function, a cycle, a BigInt). */
|
|
13244
|
+
function appDataBytes(value) {
|
|
13245
|
+
let json;
|
|
13246
|
+
try {
|
|
13247
|
+
json = JSON.stringify(value);
|
|
13248
|
+
} catch {
|
|
13249
|
+
return Infinity;
|
|
13250
|
+
}
|
|
13251
|
+
return json === void 0 ? Infinity : utf8Encode(json).length;
|
|
13252
|
+
}
|
|
13253
|
+
function appOpenFrame(app, version) {
|
|
13254
|
+
if (!isChatAppId(app) || !isAppVersion(version)) throw new Error("Not an app open");
|
|
13255
|
+
return {
|
|
13256
|
+
t: APP_FRAME,
|
|
13257
|
+
a: app,
|
|
13258
|
+
o: "open",
|
|
13259
|
+
v: version
|
|
13260
|
+
};
|
|
13261
|
+
}
|
|
13262
|
+
function appCloseFrame(app) {
|
|
13263
|
+
if (!isChatAppId(app)) throw new Error("Not an app close");
|
|
13264
|
+
return {
|
|
13265
|
+
t: APP_FRAME,
|
|
13266
|
+
a: app,
|
|
13267
|
+
o: "close"
|
|
13268
|
+
};
|
|
13269
|
+
}
|
|
13270
|
+
function appDataFrame(app, data) {
|
|
13271
|
+
if (!isChatAppId(app) || appDataBytes(data) > 32768) throw new Error("Not app data");
|
|
13272
|
+
return {
|
|
13273
|
+
t: APP_FRAME,
|
|
13274
|
+
a: app,
|
|
13275
|
+
d: data
|
|
13276
|
+
};
|
|
13277
|
+
}
|
|
13278
|
+
/**
|
|
13279
|
+
* A `paired-app` frame as the peer sent it, or why a reader must refuse it: no valid `a` (22 base64url characters),
|
|
13280
|
+
* both or neither of `o` and `d`, an unknown `o`, an `open` without a valid `v`, or a `d` over `APP_DATA_MAX_BYTES`.
|
|
13281
|
+
* Other keys are ignored, so a later version can add some.
|
|
13282
|
+
*/
|
|
13283
|
+
function readAppFrame(frame) {
|
|
13284
|
+
const refuse = (refusal) => ({
|
|
13285
|
+
ok: false,
|
|
13286
|
+
refusal
|
|
13287
|
+
});
|
|
13288
|
+
if (frame?.t !== "paired-app") return refuse("not-app");
|
|
13289
|
+
if (!isChatAppId(frame.a)) return refuse("bad-a");
|
|
13290
|
+
const own = (key) => Object.prototype.hasOwnProperty.call(frame, key);
|
|
13291
|
+
const hasO = own("o"), hasD = own("d");
|
|
13292
|
+
if (hasO && hasD) return refuse("o-and-d");
|
|
13293
|
+
if (!hasO && !hasD) return refuse("no-o-or-d");
|
|
13294
|
+
if (hasD) return appDataBytes(frame.d) <= 32768 ? {
|
|
13295
|
+
ok: true,
|
|
13296
|
+
frame: {
|
|
13297
|
+
t: APP_FRAME,
|
|
13298
|
+
a: frame.a,
|
|
13299
|
+
d: frame.d
|
|
13300
|
+
}
|
|
13301
|
+
} : refuse("too-large");
|
|
13302
|
+
if (frame.o === "close") return {
|
|
13303
|
+
ok: true,
|
|
13304
|
+
frame: {
|
|
13305
|
+
t: APP_FRAME,
|
|
13306
|
+
a: frame.a,
|
|
13307
|
+
o: "close"
|
|
13308
|
+
}
|
|
13309
|
+
};
|
|
13310
|
+
if (frame.o !== "open") return refuse("unknown-o");
|
|
13311
|
+
return isAppVersion(frame.v) ? {
|
|
13312
|
+
ok: true,
|
|
13313
|
+
frame: {
|
|
13314
|
+
t: APP_FRAME,
|
|
13315
|
+
a: frame.a,
|
|
13316
|
+
o: "open",
|
|
13317
|
+
v: frame.v
|
|
13318
|
+
}
|
|
13319
|
+
} : refuse("bad-v");
|
|
13320
|
+
}
|
|
13321
|
+
/** A `paired-app` frame as the peer sent it, or null when a reader must refuse it (`readAppFrame` says why). */
|
|
13322
|
+
function parseAppFrame(frame) {
|
|
13323
|
+
const read = readAppFrame(frame);
|
|
13324
|
+
return read.ok ? read.frame : null;
|
|
13325
|
+
}
|
|
13326
|
+
/** Frames per app in a sliding window, for at most `max` apps at once. */
|
|
13327
|
+
var RateWindows = class {
|
|
13328
|
+
limit;
|
|
13329
|
+
max;
|
|
13330
|
+
windows = /* @__PURE__ */ new Map();
|
|
13331
|
+
constructor(limit, max) {
|
|
13332
|
+
this.limit = limit;
|
|
13333
|
+
this.max = max;
|
|
13334
|
+
}
|
|
13335
|
+
/** Counts one frame for `app` at `now`; false when it is over the limit (not counted then). */
|
|
13336
|
+
take(app, now) {
|
|
13337
|
+
let window = this.windows.get(app);
|
|
13338
|
+
if (!window) {
|
|
13339
|
+
if (this.windows.size >= this.max) this.prune(now);
|
|
13340
|
+
if (this.windows.size >= this.max) return false;
|
|
13341
|
+
window = [];
|
|
13342
|
+
this.windows.set(app, window);
|
|
13343
|
+
}
|
|
13344
|
+
while (window.length && now - window[0] >= 1e3) window.shift();
|
|
13345
|
+
if (window.length >= this.limit) return false;
|
|
13346
|
+
window.push(now);
|
|
13347
|
+
return true;
|
|
13348
|
+
}
|
|
13349
|
+
prune(now) {
|
|
13350
|
+
for (const [app, window] of this.windows) if (!window.length || now - window[window.length - 1] >= 1e3) this.windows.delete(app);
|
|
13351
|
+
}
|
|
13352
|
+
clear() {
|
|
13353
|
+
this.windows.clear();
|
|
13354
|
+
}
|
|
13355
|
+
};
|
|
13356
|
+
/**
|
|
13357
|
+
* The apps of one chat on its live sessions. `open` (this side's apps, by chat app id, with their versions) lasts
|
|
13358
|
+
* across sessions, so each new one says `open` again; what the peer has open lasts one session.
|
|
13359
|
+
*/
|
|
13360
|
+
var AppSessions = class {
|
|
13361
|
+
open;
|
|
13362
|
+
now;
|
|
13363
|
+
peer = /* @__PURE__ */ new Map();
|
|
13364
|
+
/** What the peer had open on a session that ended, held for `APP_RESUME_GRACE_MS` until it says so again. */
|
|
13365
|
+
held = /* @__PURE__ */ new Map();
|
|
13366
|
+
received = new RateWindows(50, 64);
|
|
13367
|
+
sent = new RateWindows(48, 64);
|
|
13368
|
+
constructor(open = /* @__PURE__ */ new Map(), now = Date.now) {
|
|
13369
|
+
this.open = open;
|
|
13370
|
+
this.now = now;
|
|
13371
|
+
}
|
|
13372
|
+
/** The peer has this app open on this session, at this version. */
|
|
13373
|
+
peerVersion(app) {
|
|
13374
|
+
return this.peer.get(app);
|
|
13375
|
+
}
|
|
13376
|
+
/** The apps the peer has open on this session. */
|
|
13377
|
+
get peerOpen() {
|
|
13378
|
+
return this.peer;
|
|
13379
|
+
}
|
|
13380
|
+
/**
|
|
13381
|
+
* A `paired-app` frame from the peer, on a session where both sides offer `apps/1`. Counted before it is read; the
|
|
13382
|
+
* event to pass on, or null when it was dropped (malformed, over the rate, a data frame for an app not open on both
|
|
13383
|
+
* sides, an `open` past `APP_PEER_OPEN_MAX`, a `close` of an app the peer did not have open).
|
|
13384
|
+
*/
|
|
13385
|
+
receive(frame) {
|
|
13386
|
+
if (!isChatAppId(frame?.a) || !this.received.take(frame.a, this.now())) return null;
|
|
13387
|
+
const parsed = parseAppFrame(frame);
|
|
13388
|
+
if (!parsed) return null;
|
|
13389
|
+
const app = parsed.a;
|
|
13390
|
+
if ("d" in parsed) return this.open.has(app) && this.peer.has(app) ? {
|
|
13391
|
+
app,
|
|
13392
|
+
d: parsed.d
|
|
13393
|
+
} : null;
|
|
13394
|
+
if (parsed.o === "close") return this.peer.delete(app) || this.held.delete(app) ? {
|
|
13395
|
+
app,
|
|
13396
|
+
o: "close"
|
|
13397
|
+
} : null;
|
|
13398
|
+
if (!this.peer.has(app) && this.peer.size >= 16) return null;
|
|
13399
|
+
this.held.delete(app);
|
|
13400
|
+
this.peer.set(app, parsed.v);
|
|
13401
|
+
return {
|
|
13402
|
+
app,
|
|
13403
|
+
o: "open",
|
|
13404
|
+
v: parsed.v
|
|
13405
|
+
};
|
|
13406
|
+
}
|
|
13407
|
+
/** Whether a data frame for `app` may go now, the session being live with `apps/1` agreed; counted when it may. */
|
|
13408
|
+
canSend(app, data) {
|
|
13409
|
+
if (!this.open.has(app)) return "not-open";
|
|
13410
|
+
if (appDataBytes(data) > 32768) return "too-large";
|
|
13411
|
+
if (!this.peer.has(app)) return "peer-closed";
|
|
13412
|
+
return this.sent.take(app, this.now()) ? null : "too-fast";
|
|
13413
|
+
}
|
|
13414
|
+
/**
|
|
13415
|
+
* The session ended, or `apps/1` is no longer agreed: what the peer had open is gone, and each such app hears a
|
|
13416
|
+
* `close` with `offline`. This side's open apps stay, and say `open` again on the next session.
|
|
13417
|
+
*/
|
|
13418
|
+
sessionEnded() {
|
|
13419
|
+
const closed = [.../* @__PURE__ */ new Set([...this.held.keys(), ...this.peer.keys()])].map((app) => ({
|
|
13420
|
+
app,
|
|
13421
|
+
o: "close",
|
|
13422
|
+
offline: true
|
|
13423
|
+
}));
|
|
13424
|
+
this.peer.clear();
|
|
13425
|
+
this.held.clear();
|
|
13426
|
+
this.received.clear();
|
|
13427
|
+
this.sent.clear();
|
|
13428
|
+
return closed;
|
|
13429
|
+
}
|
|
13430
|
+
/**
|
|
13431
|
+
* The session ended or was replaced, and another may follow at once: what the peer had open is held, not closed (no
|
|
13432
|
+
* event now). Nothing is sent to a held app; an `open` on the next session takes it back, a `close` closes it, and
|
|
13433
|
+
* `heldExpired` closes the rest once `APP_RESUME_GRACE_MS` has passed.
|
|
13434
|
+
*/
|
|
13435
|
+
sessionSuspended() {
|
|
13436
|
+
for (const [app, version] of this.peer) this.held.set(app, version);
|
|
13437
|
+
this.peer.clear();
|
|
13438
|
+
this.received.clear();
|
|
13439
|
+
this.sent.clear();
|
|
13440
|
+
}
|
|
13441
|
+
/** Whether any of the peer's apps is held, waiting for its next session. */
|
|
13442
|
+
get holding() {
|
|
13443
|
+
return this.held.size > 0;
|
|
13444
|
+
}
|
|
13445
|
+
/** The grace is over: each app still held is closed, with `offline`. */
|
|
13446
|
+
heldExpired() {
|
|
13447
|
+
const closed = [...this.held.keys()].map((app) => ({
|
|
13448
|
+
app,
|
|
13449
|
+
o: "close",
|
|
13450
|
+
offline: true
|
|
13451
|
+
}));
|
|
13452
|
+
this.held.clear();
|
|
13453
|
+
return closed;
|
|
13454
|
+
}
|
|
13455
|
+
};
|
|
13456
|
+
//#endregion
|
|
12656
13457
|
//#region ../core/src/webPush.ts
|
|
12657
13458
|
var WebPushError = class extends Error {};
|
|
12658
13459
|
var RECORD_SIZE = 4096;
|
|
@@ -13013,6 +13814,8 @@ function parsePinnedFrame(frame) {
|
|
|
13013
13814
|
* DHT, never stored, never counted as unread, no message id (older apps drop it). While the person keeps typing,
|
|
13014
13815
|
* `start` is said again at most every `TYPING_REFRESH_MS`; the receiver shows it until `stop`, a message from that
|
|
13015
13816
|
* contact, the end of the session, or `TYPING_TIMEOUT_MS` without a new `start` (so a dropped link never leaves it on).
|
|
13817
|
+
* A start standing when a session ends (a transport switch, a reconnect) is said again once the next one agreed
|
|
13818
|
+
* `typing/1`, if the person's last word is still that recent.
|
|
13016
13819
|
*
|
|
13017
13820
|
* A `start` may say what the contact is doing (`kind`: typing, recording a voice note, thinking) and, for bots, a
|
|
13018
13821
|
* short `status` line. Both are optional: an app that knows neither shows "typing…", and so does a kind it does not
|
|
@@ -13076,12 +13879,21 @@ function parseTypingActivity(frame) {
|
|
|
13076
13879
|
return typingActivity(frame?.kind, frame?.status);
|
|
13077
13880
|
}
|
|
13078
13881
|
var sameActivity = (a, b) => a?.kind === b?.kind && a?.status === b?.status;
|
|
13079
|
-
/**
|
|
13882
|
+
/**
|
|
13883
|
+
* This side's word: when to say `start` again, and whether a `stop` is owed. One per chat; what was said is per session
|
|
13884
|
+
* (`reset`), what the person is doing is not: a new session (a transport switch, a reconnect) says a standing start
|
|
13885
|
+
* again (`resume`), since the contact's reader cleared it when the old session ended.
|
|
13886
|
+
*
|
|
13887
|
+
* The rate window is not per session: the reader's is per chat too (it is not cleared when a session ends), so a
|
|
13888
|
+
* window that started afresh on each session could put a start past the reader's limit just after a switch.
|
|
13889
|
+
*/
|
|
13080
13890
|
var TypingSender = class {
|
|
13081
13891
|
now;
|
|
13082
13892
|
startedAt = 0;
|
|
13083
13893
|
standing = null;
|
|
13084
13894
|
recent = [];
|
|
13895
|
+
/** What the person is doing, and when it last said so: kept across sessions, until it stops. */
|
|
13896
|
+
wanted = null;
|
|
13085
13897
|
constructor(now = Date.now) {
|
|
13086
13898
|
this.now = now;
|
|
13087
13899
|
}
|
|
@@ -13092,6 +13904,33 @@ var TypingSender = class {
|
|
|
13092
13904
|
typing(activity = { kind: "typing" }) {
|
|
13093
13905
|
const now = this.now();
|
|
13094
13906
|
const word = typingActivity(activity.kind, activity.status);
|
|
13907
|
+
this.wanted = {
|
|
13908
|
+
activity: word,
|
|
13909
|
+
at: now
|
|
13910
|
+
};
|
|
13911
|
+
return this.say(word, now);
|
|
13912
|
+
}
|
|
13913
|
+
/**
|
|
13914
|
+
* The person typed while nothing can be said (no session, or `typing/1` not agreed on it yet): kept, so the start
|
|
13915
|
+
* goes once a session can carry it (`resume`).
|
|
13916
|
+
*/
|
|
13917
|
+
want(activity = { kind: "typing" }) {
|
|
13918
|
+
this.wanted = {
|
|
13919
|
+
activity: typingActivity(activity.kind, activity.status),
|
|
13920
|
+
at: this.now()
|
|
13921
|
+
};
|
|
13922
|
+
}
|
|
13923
|
+
/**
|
|
13924
|
+
* A new session agreed `typing/1`: the `start` to say on it when the person was still at it, its last word less than
|
|
13925
|
+
* `TYPING_TIMEOUT_MS` ago (as long as the contact would still have shown it). Null otherwise, when a start already
|
|
13926
|
+
* went on this session, or when the window is full (the person's next keystroke says it).
|
|
13927
|
+
*/
|
|
13928
|
+
resume() {
|
|
13929
|
+
const wanted = this.wanted, now = this.now();
|
|
13930
|
+
if (!wanted || this.startedAt || now - wanted.at >= 6e3) return null;
|
|
13931
|
+
return this.say(wanted.activity, now);
|
|
13932
|
+
}
|
|
13933
|
+
say(word, now) {
|
|
13095
13934
|
if (this.startedAt && now - this.startedAt < 3e3 && sameActivity(this.standing, word)) return null;
|
|
13096
13935
|
this.recent = this.recent.filter((at) => now - at < TYPING_RATE_WINDOW_MS);
|
|
13097
13936
|
if (this.recent.length >= 6) return null;
|
|
@@ -13100,8 +13939,12 @@ var TypingSender = class {
|
|
|
13100
13939
|
this.standing = word;
|
|
13101
13940
|
return typingFrame("start", word);
|
|
13102
13941
|
}
|
|
13103
|
-
/**
|
|
13104
|
-
stopped()
|
|
13942
|
+
/**
|
|
13943
|
+
* The person stopped (cleared the text, sent it, left the chat): a `stop`, only when a `start` is standing on this
|
|
13944
|
+
* session. Nothing is said again on the next one either way.
|
|
13945
|
+
*/
|
|
13946
|
+
stopped() {
|
|
13947
|
+
this.wanted = null;
|
|
13105
13948
|
if (!this.startedAt) return null;
|
|
13106
13949
|
this.recent.push(this.now());
|
|
13107
13950
|
this.startedAt = 0;
|
|
@@ -13112,7 +13955,7 @@ var TypingSender = class {
|
|
|
13112
13955
|
get active() {
|
|
13113
13956
|
return this.startedAt !== 0;
|
|
13114
13957
|
}
|
|
13115
|
-
/** The session ended: nothing is standing on the next one. */
|
|
13958
|
+
/** The session ended: nothing is standing on the next one until `resume` (or the next keystroke) says it. */
|
|
13116
13959
|
reset() {
|
|
13117
13960
|
this.startedAt = 0;
|
|
13118
13961
|
this.standing = null;
|
|
@@ -13236,7 +14079,7 @@ var FILE_LIMITS = {
|
|
|
13236
14079
|
sendRetryMs: 250
|
|
13237
14080
|
};
|
|
13238
14081
|
var ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
13239
|
-
var DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
14082
|
+
var DIGEST$1 = /^[A-Za-z0-9_-]{43}$/;
|
|
13240
14083
|
var DATA = /^[A-Za-z0-9_-]+$/;
|
|
13241
14084
|
var FILE_FRAMES = /* @__PURE__ */ new Set([
|
|
13242
14085
|
"pf-offer",
|
|
@@ -13980,7 +14823,7 @@ var ChatFiles = class {
|
|
|
13980
14823
|
return;
|
|
13981
14824
|
}
|
|
13982
14825
|
if (record.state !== "active" || incoming.verifying || !incoming.target) return;
|
|
13983
|
-
if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST.test(frame.digest)) return;
|
|
14826
|
+
if (frame.size !== record.file.size || typeof frame.digest !== "string" || !DIGEST$1.test(frame.digest)) return;
|
|
13984
14827
|
const digest = frame.digest;
|
|
13985
14828
|
await incoming.writing;
|
|
13986
14829
|
if (transferEnded(record) || record.state !== "active") return;
|
|
@@ -14993,6 +15836,142 @@ var CarriedTransport = class {
|
|
|
14993
15836
|
}
|
|
14994
15837
|
};
|
|
14995
15838
|
//#endregion
|
|
15839
|
+
//#region ../core/src/groupFiles.ts
|
|
15840
|
+
/**
|
|
15841
|
+
* Files and voice messages in groups (WISP 503 · Group Files). A file is announced as a group message carrying its
|
|
15842
|
+
* description; its bytes go over `files/3` between two members, from a member that holds it to one that asks for it.
|
|
15843
|
+
* The limits below are the profile's first numbers, kept in one place so they are easy to change.
|
|
15844
|
+
*/
|
|
15845
|
+
var GROUP_FILE_LIMITS = {
|
|
15846
|
+
/** The largest file a group takes. */
|
|
15847
|
+
maxBytes: 104857600,
|
|
15848
|
+
/** A file this size or smaller is fetched without asking (voice messages always are, up to `VOICE_LIMITS`). */
|
|
15849
|
+
autoBytes: 8388608,
|
|
15850
|
+
/** What a device keeps of a group's files it fetched without asking; past it, every file is asked about. */
|
|
15851
|
+
autoBytesPerGroup: 268435456,
|
|
15852
|
+
/** Bytes of group files a device serves to others in a day, when serving is on. */
|
|
15853
|
+
serveBytesPerDay: 1073741824,
|
|
15854
|
+
/** Transfers a holder serves at once, all groups together. */
|
|
15855
|
+
serveAtOnce: 3,
|
|
15856
|
+
/** File announcements a member sends to one group in a minute. */
|
|
15857
|
+
announcePerMinute: 8,
|
|
15858
|
+
/** File announcements taken from one member of a group in `announceWindowMs`; past it, shown as text, unfetched. */
|
|
15859
|
+
announceTaken: 30,
|
|
15860
|
+
announceWindowMs: 6e5,
|
|
15861
|
+
/** Holders remembered per file. */
|
|
15862
|
+
holders: 8,
|
|
15863
|
+
/**
|
|
15864
|
+
* Message ids a `group-have` named before their message came (a member back from away hears who holds what as its
|
|
15865
|
+
* catch-up begins), remembered with their holders for when it does.
|
|
15866
|
+
*/
|
|
15867
|
+
heardAhead: 256,
|
|
15868
|
+
/** Message ids in one `group-have`. */
|
|
15869
|
+
haveIds: 32,
|
|
15870
|
+
/** A private group's `group-have` goes on an edge at most this often; a community's, from a member, every `communityHaveEveryMs`. */
|
|
15871
|
+
haveEveryMs: 1e4,
|
|
15872
|
+
communityHaveEveryMs: 6e4,
|
|
15873
|
+
/**
|
|
15874
|
+
* How long a want waits for an offer before the next holder is asked; a transfer that does not move for as long is
|
|
15875
|
+
* left for the next holder too.
|
|
15876
|
+
*/
|
|
15877
|
+
wantWaitMs: 3e4,
|
|
15878
|
+
/** A file session closes this long after its last transfer ends. */
|
|
15879
|
+
sessionIdleMs: 6e4,
|
|
15880
|
+
/** File sessions opening at once, and open at once, all groups together. */
|
|
15881
|
+
sessionsOpening: 2,
|
|
15882
|
+
sessionsOpen: 4,
|
|
15883
|
+
/** The longest file name and type kept from a description. */
|
|
15884
|
+
nameChars: 255,
|
|
15885
|
+
mimeChars: 255
|
|
15886
|
+
};
|
|
15887
|
+
/**
|
|
15888
|
+
* The frames of WISP 503 § Getting the bytes, on an edge between two apps that announced `GROUP_VERSION_FILES`:
|
|
15889
|
+
* `group-want {g, id}` asks a holder for the file of message `id`; `group-want-no {g, id, why}` is a holder's no;
|
|
15890
|
+
* `group-have {g, ids}` says which files a member keeps (in a community, an application frame `{t, ids}` instead).
|
|
15891
|
+
*/
|
|
15892
|
+
var GROUP_WANT_FRAME = "group-want";
|
|
15893
|
+
var GROUP_WANT_NO_FRAME = "group-want-no";
|
|
15894
|
+
var GROUP_HAVE_FRAME = "group-have";
|
|
15895
|
+
var REFUSALS = [
|
|
15896
|
+
"gone",
|
|
15897
|
+
"busy",
|
|
15898
|
+
"refused"
|
|
15899
|
+
];
|
|
15900
|
+
/** A group message's id, as a private group (`<s>:<e>:<n>`) or a community (`<s>:<e>:<h>:<n>`) names it. */
|
|
15901
|
+
var MESSAGE_ID = /^[a-z0-9]{52}:\d{1,15}(:[A-Za-z0-9_-]{1,64})?:\d{1,15}$/;
|
|
15902
|
+
var isGroupMessageId = (value) => typeof value === "string" && MESSAGE_ID.test(value);
|
|
15903
|
+
function frameOf(value, t, groupId) {
|
|
15904
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
15905
|
+
const f = value;
|
|
15906
|
+
return f.t === t && (groupId === void 0 || f.g === groupId) ? f : null;
|
|
15907
|
+
}
|
|
15908
|
+
/** A `group-want` of group `groupId`: the message whose file is asked for, or null. */
|
|
15909
|
+
function readGroupWant(value, groupId) {
|
|
15910
|
+
const f = frameOf(value, GROUP_WANT_FRAME, groupId);
|
|
15911
|
+
return f && isGroupMessageId(f.id) ? { id: f.id } : null;
|
|
15912
|
+
}
|
|
15913
|
+
/** A holder's `group-want-no` of group `groupId`, or null. A reason this app does not know reads as `busy`. */
|
|
15914
|
+
function readGroupWantNo(value, groupId) {
|
|
15915
|
+
const f = frameOf(value, GROUP_WANT_NO_FRAME, groupId);
|
|
15916
|
+
if (!f || !isGroupMessageId(f.id)) return null;
|
|
15917
|
+
return {
|
|
15918
|
+
id: f.id,
|
|
15919
|
+
why: typeof f.why === "string" && REFUSALS.includes(f.why) ? f.why : "busy"
|
|
15920
|
+
};
|
|
15921
|
+
}
|
|
15922
|
+
/**
|
|
15923
|
+
* The message ids of a `group-have`: of group `groupId` on an edge, or (no `groupId`) a community's application frame.
|
|
15924
|
+
* At most `haveIds` message ids; a frame with more, or anything else, is null.
|
|
15925
|
+
*/
|
|
15926
|
+
function readGroupHave(value, groupId) {
|
|
15927
|
+
const f = frameOf(value, GROUP_HAVE_FRAME, groupId);
|
|
15928
|
+
if (!f || !Array.isArray(f.ids) || !f.ids.length || f.ids.length > GROUP_FILE_LIMITS.haveIds || !f.ids.every(isGroupMessageId)) return null;
|
|
15929
|
+
return [...new Set(f.ids)];
|
|
15930
|
+
}
|
|
15931
|
+
var DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
15932
|
+
/**
|
|
15933
|
+
* A description read from the wire, cleaned: the name made a display name (never a path), the type untrusted, the
|
|
15934
|
+
* size within the group limit, the digest the right shape. A malformed voice, video or picture description is dropped
|
|
15935
|
+
* on its own and the file is still a file; anything else malformed is no file at all (null), and the text shows.
|
|
15936
|
+
*/
|
|
15937
|
+
function readGroupFileMeta(value) {
|
|
15938
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
15939
|
+
const v = value;
|
|
15940
|
+
if (typeof v.name !== "string" || !v.name.trim() || [...v.name].length > GROUP_FILE_LIMITS.nameChars) return null;
|
|
15941
|
+
if (typeof v.mime !== "string" || v.mime.length > GROUP_FILE_LIMITS.mimeChars) return null;
|
|
15942
|
+
if (!Number.isSafeInteger(v.size) || v.size < 1 || v.size > GROUP_FILE_LIMITS.maxBytes) return null;
|
|
15943
|
+
if (typeof v.d !== "string" || !DIGEST.test(v.d)) return null;
|
|
15944
|
+
const mime = sanitizeMime(v.mime);
|
|
15945
|
+
const voice = v.voice === void 0 ? void 0 : parseVoiceMeta(v.voice, mime);
|
|
15946
|
+
const video = v.video === void 0 ? void 0 : parseVideoMeta(v.video, mime);
|
|
15947
|
+
const image = v.image === void 0 ? void 0 : parseImageMeta(v.image, mime);
|
|
15948
|
+
return {
|
|
15949
|
+
name: sanitizeFileName(v.name),
|
|
15950
|
+
mime,
|
|
15951
|
+
size: v.size,
|
|
15952
|
+
d: v.d,
|
|
15953
|
+
...voice ? { voice } : {},
|
|
15954
|
+
...video ? { video } : {},
|
|
15955
|
+
...image ? { image } : {}
|
|
15956
|
+
};
|
|
15957
|
+
}
|
|
15958
|
+
/**
|
|
15959
|
+
* The text an announcement carries when its author wrote no caption: what an app without group files shows instead
|
|
15960
|
+
* of the file. English, as wire text is; an app that knows the file shows the file, not this line.
|
|
15961
|
+
*/
|
|
15962
|
+
function groupFileFallback(file) {
|
|
15963
|
+
if (file.voice) return `🎤 Voice message (${formatVoiceDuration(file.voice.duration)}): update Ghostly to hear it`;
|
|
15964
|
+
return `${fileMessageText(file)} (${formatFileSize(file.size)}): update Ghostly to get files in groups`;
|
|
15965
|
+
}
|
|
15966
|
+
/**
|
|
15967
|
+
* Whether a device fetches a file by itself (WISP 503 § Automatic and asked downloads): a voice message always, any
|
|
15968
|
+
* other file up to `autoBytes` while what it took that way from the group stays within `autoBytesPerGroup`.
|
|
15969
|
+
*/
|
|
15970
|
+
function fetchesByItself(file, keptAutomatically, limits = GROUP_FILE_LIMITS) {
|
|
15971
|
+
if (file.voice) return true;
|
|
15972
|
+
return file.size <= limits.autoBytes && keptAutomatically + file.size <= limits.autoBytesPerGroup;
|
|
15973
|
+
}
|
|
15974
|
+
//#endregion
|
|
14996
15975
|
//#region ../core/src/pairingProgress.ts
|
|
14997
15976
|
var FIRST_STAGE = {
|
|
14998
15977
|
inviter: "publishing",
|
|
@@ -15279,6 +16258,13 @@ var QUIET_FRAMES = /* @__PURE__ */ new Set([
|
|
|
15279
16258
|
* `disconnected`) otherwise went on until its consent checks gave up, 27 s after the crash (CI run 36741701666).
|
|
15280
16259
|
*/
|
|
15281
16260
|
var PONG_WAIT_MS = 4e3;
|
|
16261
|
+
/** How long `handled` waits for the contact to answer its ping before it says it cannot tell. */
|
|
16262
|
+
var HANDLED_WAIT_MS = 1e4;
|
|
16263
|
+
/**
|
|
16264
|
+
* Back in front after an absence (`wake`), a live session that ends this soon after it was lost while the app was
|
|
16265
|
+
* away (a phone that put it to sleep): the chat resumes as after a restart, not as a session the contact closed.
|
|
16266
|
+
*/
|
|
16267
|
+
var WAKE_LOST_MS = 1e4;
|
|
15282
16268
|
/**
|
|
15283
16269
|
* A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
|
|
15284
16270
|
* with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
|
|
@@ -15446,6 +16432,8 @@ var GhostLink = class {
|
|
|
15446
16432
|
/** Typing on this session (`typing/1`): when to say `start` again, and the contact's word with its timeout. */
|
|
15447
16433
|
typingSender = new TypingSender();
|
|
15448
16434
|
typingReceiver = new TypingReceiver((typing, activity) => this.options.events?.onPeerTyping?.(typing, activity));
|
|
16435
|
+
/** Mini-apps on this session (`apps/1`): this side's open apps (kept across sessions), the peer's, the limits. */
|
|
16436
|
+
apps;
|
|
15449
16437
|
/** Reaction frames the contact may send per window; the rest go unconfirmed and come again. */
|
|
15450
16438
|
reactionsReceived = new ReactionWindow(REACTION_LIMITS.receive);
|
|
15451
16439
|
/** Pin frames the contact may send per window; the rest go unconfirmed and come again. */
|
|
@@ -15461,6 +16449,16 @@ var GhostLink = class {
|
|
|
15461
16449
|
/** The contact's transport policy as last seen on this session, to tell its explicit choices apart. */
|
|
15462
16450
|
peerPolicySeen = null;
|
|
15463
16451
|
unansweredPings = 0;
|
|
16452
|
+
/**
|
|
16453
|
+
* Pings sent on this session and pongs heard on it, counted from its start: the contact answers each ping in the order
|
|
16454
|
+
* frames come, so the pong to ping n says it handled everything sent before it (`handled`).
|
|
16455
|
+
*/
|
|
16456
|
+
pingsSent = 0;
|
|
16457
|
+
pongsHeard = 0;
|
|
16458
|
+
/** The liveness ping whose pong measures the round trip: its number in `pingsSent`. */
|
|
16459
|
+
rttPing = 0;
|
|
16460
|
+
/** `handled` callers, each waiting for the pong to its ping. */
|
|
16461
|
+
handledWaiters = [];
|
|
15464
16462
|
/** The last ping's `PONG_WAIT_MS`: running until something comes back from the contact. */
|
|
15465
16463
|
pongWait = null;
|
|
15466
16464
|
/** The last ping's wait ran out with nothing back, and nothing came since (`restartedRedial`). */
|
|
@@ -15474,7 +16472,8 @@ var GhostLink = class {
|
|
|
15474
16472
|
racing = false;
|
|
15475
16473
|
/**
|
|
15476
16474
|
* The WebRTC offer this side's dial made last, with fallback allowed: what a transport that comes up meanwhile joins.
|
|
15477
|
-
* `resume`: the dial that made it is this side back after an absence, over WebRTC (`resuming`)
|
|
16475
|
+
* `resume`: the dial that made it is this side back after an absence, over WebRTC (`resuming`); `woke`: the absence
|
|
16476
|
+
* was the app's in the background, not a restart (`resumeAfterAbsence`).
|
|
15478
16477
|
*/
|
|
15479
16478
|
offered;
|
|
15480
16479
|
/** The attempt (its epoch) whose direct transports were knocked on as its offer went out (`knockEarly`), and whether that is under way. */
|
|
@@ -15490,6 +16489,12 @@ var GhostLink = class {
|
|
|
15490
16489
|
answeringEpoch = -1;
|
|
15491
16490
|
lastAutoConnectAt = 0;
|
|
15492
16491
|
autoConnectFailures = 0;
|
|
16492
|
+
/** The look due when a failed dial's backoff ends (`lookAtRetry`). */
|
|
16493
|
+
retryLook = null;
|
|
16494
|
+
/** This side's offer is out to the data link and has not reached the relays yet. */
|
|
16495
|
+
offerUnsent = false;
|
|
16496
|
+
/** The last dial ended with its offer never out (`offerUnsent`): the contact saw nothing of it. */
|
|
16497
|
+
dialUnseen = false;
|
|
15493
16498
|
/** Native transports a record just read listed with no way to dial them (`record-undescribed`), until one describes them again. */
|
|
15494
16499
|
undescribed = /* @__PURE__ */ new Set();
|
|
15495
16500
|
/**
|
|
@@ -15546,8 +16551,15 @@ var GhostLink = class {
|
|
|
15546
16551
|
resuming;
|
|
15547
16552
|
/** Until then, a dial waits for the native endpoint `resuming` names to start (it is knocked on as it does). */
|
|
15548
16553
|
resumeWaitUntil = 0;
|
|
16554
|
+
/** What the last live session ran on, kept after it ended: a wake resumes on it (`resumeAfterAbsence`). */
|
|
16555
|
+
lastLiveOn;
|
|
16556
|
+
/** `resuming` is a wake's, not a restart's. */
|
|
16557
|
+
resumingWoke = false;
|
|
16558
|
+
/** Until then, a session that ends was lost while the app was away (`WAKE_LOST_MS`). */
|
|
16559
|
+
wokeUntil = 0;
|
|
15549
16560
|
constructor(options) {
|
|
15550
16561
|
this.options = options;
|
|
16562
|
+
this.apps = new AppSessions(options.appsOpen);
|
|
15551
16563
|
if (options.pairing?.credentials.signer && (options.dht || options.params.deliveryMode === "dht")) throw new Error("A link that signs through a signer has no DHT delivery");
|
|
15552
16564
|
this.deliveryMode = options.params.deliveryMode ?? "stream";
|
|
15553
16565
|
this.tracker = options.pairingProgress && options.pairing && !options.pairing.credentials.peerKey && this.deliveryMode !== "dht" ? new PairingTracker(options.pairingProgress.role, options.pairingProgress.startedAt, (progress) => {
|
|
@@ -15683,9 +16695,13 @@ var GhostLink = class {
|
|
|
15683
16695
|
this.tracker?.failed("publish", true, result.error);
|
|
15684
16696
|
return;
|
|
15685
16697
|
}
|
|
15686
|
-
if (result.signalOut)
|
|
16698
|
+
if (result.signalOut) {
|
|
16699
|
+
this.offerUnsent = false;
|
|
16700
|
+
this.dataLink.signalWentOut();
|
|
16701
|
+
}
|
|
15687
16702
|
this.tracker?.published();
|
|
15688
16703
|
this.publishRecovered();
|
|
16704
|
+
this.dialSeenNow();
|
|
15689
16705
|
},
|
|
15690
16706
|
onPeerAck: (ack) => events.onPeerAck?.(ack),
|
|
15691
16707
|
onCallSignal: (signal) => {
|
|
@@ -15780,6 +16796,10 @@ var GhostLink = class {
|
|
|
15780
16796
|
if (this.activeBinding) return;
|
|
15781
16797
|
events.onDataLinkState?.(state);
|
|
15782
16798
|
if (state === "open") this.afterRtc = void 0;
|
|
16799
|
+
if (state === "offering") {
|
|
16800
|
+
this.offerUnsent = true;
|
|
16801
|
+
this.dialUnseen = false;
|
|
16802
|
+
}
|
|
15783
16803
|
if (state === "answering") {
|
|
15784
16804
|
this.answeringEpoch = this.connectionEpoch;
|
|
15785
16805
|
if (this.resumingOverRtc) {
|
|
@@ -15793,6 +16813,8 @@ var GhostLink = class {
|
|
|
15793
16813
|
this.scheduleRace(this.offered.epoch, this.offered.at);
|
|
15794
16814
|
}
|
|
15795
16815
|
if (state !== "idle") return;
|
|
16816
|
+
this.dialUnseen = was === "offering" && this.offerUnsent;
|
|
16817
|
+
this.offerUnsent = false;
|
|
15796
16818
|
const unanswerable = was === "answering" && this.answeringEpoch === this.connectionEpoch && !this.channel && !this.stopped && !this.leaving;
|
|
15797
16819
|
if (unanswerable) this.rtcUnstarted();
|
|
15798
16820
|
this.clearRace();
|
|
@@ -15805,7 +16827,10 @@ var GhostLink = class {
|
|
|
15805
16827
|
return;
|
|
15806
16828
|
}
|
|
15807
16829
|
this.afterRtc = void 0;
|
|
15808
|
-
if (!this.dialing)
|
|
16830
|
+
if (!this.dialing) {
|
|
16831
|
+
this.attemptEnded();
|
|
16832
|
+
this.lookAtRetry();
|
|
16833
|
+
}
|
|
15809
16834
|
this.rejectWaiters(new GhostlyHttpError("unreachable", "Could not connect to the peer"));
|
|
15810
16835
|
if (unanswerable && this.dialsPastRtc()) this.redial();
|
|
15811
16836
|
},
|
|
@@ -16160,15 +17185,18 @@ var GhostLink = class {
|
|
|
16160
17185
|
this.dial().catch((error) => {
|
|
16161
17186
|
this.tracker?.failed("transport", true);
|
|
16162
17187
|
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
17188
|
+
this.lookAtRetry();
|
|
16163
17189
|
});
|
|
16164
17190
|
}
|
|
16165
17191
|
async stop(announce = true) {
|
|
16166
17192
|
this.stopped = true;
|
|
16167
17193
|
this.clearRace();
|
|
17194
|
+
if (this.retryLook) clearTimeout(this.retryLook);
|
|
16168
17195
|
if (this.dhtPinGrace) clearTimeout(this.dhtPinGrace);
|
|
16169
17196
|
if (this.waiting?.timer) clearTimeout(this.waiting.timer);
|
|
16170
17197
|
await this.dht?.stop();
|
|
16171
17198
|
this.disconnect();
|
|
17199
|
+
this.appsEnded();
|
|
16172
17200
|
await Promise.allSettled([...this.endpoints.values()].map((endpoint) => endpoint.close()));
|
|
16173
17201
|
this.endpoints.clear();
|
|
16174
17202
|
await this.session.stop(announce);
|
|
@@ -16560,6 +17588,7 @@ var GhostLink = class {
|
|
|
16560
17588
|
return;
|
|
16561
17589
|
}
|
|
16562
17590
|
this.endpoints.set(endpoint.transport, endpoint);
|
|
17591
|
+
traceLink(this.myPubKeyZ32, "endpoint-up", { transport: endpoint.transport });
|
|
16563
17592
|
endpoint.onConnection = ({ channel, binding }) => {
|
|
16564
17593
|
traceLink(this.myPubKeyZ32, "dialed-in", {
|
|
16565
17594
|
transport: binding.transport,
|
|
@@ -16570,6 +17599,7 @@ var GhostLink = class {
|
|
|
16570
17599
|
};
|
|
16571
17600
|
endpoint.onUnavailable = () => {
|
|
16572
17601
|
if (this.endpoints.get(endpoint.transport) !== endpoint) return;
|
|
17602
|
+
traceLink(this.myPubKeyZ32, "endpoint-gone", { transport: endpoint.transport });
|
|
16573
17603
|
this.endpoints.delete(endpoint.transport);
|
|
16574
17604
|
this.advertiseTransports();
|
|
16575
17605
|
this.options.events?.onTransportsChanged?.();
|
|
@@ -16705,7 +17735,10 @@ var GhostLink = class {
|
|
|
16705
17735
|
* simply be away. Two dials crossing are settled by key order (see `registerEndpoint`).
|
|
16706
17736
|
*/
|
|
16707
17737
|
async knock(transport) {
|
|
16708
|
-
if (this.resuming === transport)
|
|
17738
|
+
if (this.resuming === transport) {
|
|
17739
|
+
this.resuming = void 0;
|
|
17740
|
+
this.resumingWoke = false;
|
|
17741
|
+
}
|
|
16709
17742
|
const endpoint = this.endpoints.get(transport), descriptor = this.peerDescriptors[transport];
|
|
16710
17743
|
if (!endpoint || !descriptor || this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped) return;
|
|
16711
17744
|
if (!this.transportOffer().includes(transport)) return;
|
|
@@ -16858,8 +17891,11 @@ var GhostLink = class {
|
|
|
16858
17891
|
const remembered = this.peerTransports && rankTransports(local, this.peerFallback ? this.peerTransports : this.peerTransports.slice(0, 1), relayed);
|
|
16859
17892
|
const ranked = remembered?.length ? remembered : this.peerTransports ? rankTransports(local, this.peerTransports, relayed) : local.filter((t) => t === "webrtc/1");
|
|
16860
17893
|
const chosen = this.switcher.chosenTarget ?? this.choiceApart?.transport ?? this.resuming;
|
|
16861
|
-
const resume = this.resuming === "webrtc/1";
|
|
16862
|
-
if (chosen && chosen === this.resuming && ranked.includes(chosen))
|
|
17894
|
+
const resume = this.resuming === "webrtc/1", woke = resume && this.resumingWoke;
|
|
17895
|
+
if (chosen && chosen === this.resuming && ranked.includes(chosen)) {
|
|
17896
|
+
this.resuming = void 0;
|
|
17897
|
+
this.resumingWoke = false;
|
|
17898
|
+
}
|
|
16863
17899
|
const choices = chosen && ranked.includes(chosen) ? [chosen, ...ranked.filter((t) => t !== chosen)] : ranked;
|
|
16864
17900
|
if (!choices.length) {
|
|
16865
17901
|
if (this.options.packetTransports && this.autoConnectFailures <= PACKET_TRANSPORTS_FAST_ATTEMPTS) this.session.expectPeer();
|
|
@@ -16883,7 +17919,8 @@ var GhostLink = class {
|
|
|
16883
17919
|
this.offered = fallback ? standing ?? {
|
|
16884
17920
|
epoch,
|
|
16885
17921
|
at: offeredAt,
|
|
16886
|
-
...resume && { resume }
|
|
17922
|
+
...resume && { resume },
|
|
17923
|
+
...woke && { woke }
|
|
16887
17924
|
} : void 0;
|
|
16888
17925
|
const gate = this.dialGate;
|
|
16889
17926
|
this.dialGate = void 0;
|
|
@@ -17070,7 +18107,10 @@ var GhostLink = class {
|
|
|
17070
18107
|
if (this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped || !this.contactQuiet(1e4)) return;
|
|
17071
18108
|
if (!this.fallback || !this.peerFallback) return;
|
|
17072
18109
|
this.knockedEarly = epoch;
|
|
17073
|
-
if (this.resuming === "webrtc/1")
|
|
18110
|
+
if (this.resuming === "webrtc/1") {
|
|
18111
|
+
this.resuming = void 0;
|
|
18112
|
+
this.resumingWoke = false;
|
|
18113
|
+
}
|
|
17074
18114
|
const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
|
|
17075
18115
|
const offer = this.transportOffer();
|
|
17076
18116
|
const direct = TRANSPORTS.filter((t) => t !== "webrtc/1" && offer.includes(t) && !!this.peerTransports?.includes(t) && !relayed.includes(t) && this.canDial(t));
|
|
@@ -17093,7 +18133,7 @@ var GhostLink = class {
|
|
|
17093
18133
|
maybeKnockEarly(epoch) {
|
|
17094
18134
|
const next = this.afterRtc;
|
|
17095
18135
|
if (!this.offered?.resume || this.offered.epoch !== epoch || this.knockedEarly === epoch || next?.epoch !== epoch || epoch !== this.connectionEpoch) return;
|
|
17096
|
-
if (this.dialing || this.channel || this.stopped || !this.offerOut || !this.contactQuiet()) return;
|
|
18136
|
+
if (this.dialing || this.channel || this.stopped || !this.offerOut || !(this.offered.woke || this.contactQuiet())) return;
|
|
17097
18137
|
const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
|
|
17098
18138
|
const direct = next.rest.filter((t) => !relayed.includes(t) && this.canDial(t));
|
|
17099
18139
|
if (!direct.length) return;
|
|
@@ -17141,6 +18181,16 @@ var GhostLink = class {
|
|
|
17141
18181
|
dialsPastRtc() {
|
|
17142
18182
|
return (this.demotedUntil.get("webrtc/1") ?? 0) > Date.now() && this.fallback && this.peerFallback && TRANSPORTS.some((t) => this.canDial(t) && !!this.peerTransports?.includes(t) && this.transportOffer().includes(t));
|
|
17143
18183
|
}
|
|
18184
|
+
/**
|
|
18185
|
+
* A first pairing's inviter has no offer of the joiner's to wait for: WebRTC is missing here, or the joiner's record
|
|
18186
|
+
* says its app has none (a Linux Desktop's WebKitGTK), so its first packet carries none. A native transport the record
|
|
18187
|
+
* describes is dialled now. Waiting out `INVITER_DIAL_GRACE_MS` left two Linux Desktops 2.5 s more apart, and the
|
|
18188
|
+
* joiner, still reading, often went on without dialling for seconds (dht-direct on CI, 2026-10-07). Should the joiner
|
|
18189
|
+
* dial too, the two dials cross and the lower key's carries the chat (`parkCrossed`, `attachReplacement`).
|
|
18190
|
+
*/
|
|
18191
|
+
nothingToAwait() {
|
|
18192
|
+
return !(this.availableTransports.includes("webrtc/1") && (!this.peerTransports || this.peerTransports.includes("webrtc/1"))) && TRANSPORTS.some((t) => this.canDial(t) && !!this.peerTransports?.includes(t) && this.transportOffer().includes(t));
|
|
18193
|
+
}
|
|
17144
18194
|
/** Whether a native dial of `transport` can go now: this side's endpoint is up and the contact's descriptor known. */
|
|
17145
18195
|
canDial(transport) {
|
|
17146
18196
|
return transport !== "webrtc/1" && this.endpoints.has(transport) && !!this.peerDescriptors[transport];
|
|
@@ -17424,8 +18474,8 @@ var GhostLink = class {
|
|
|
17424
18474
|
* Only the lower key offers, so two peers coming online together do not collide. A first pairing is
|
|
17425
18475
|
* the exception: the joiner offers, whatever its key, in the very packet that says it is here (one hop
|
|
17426
18476
|
* less than presence, then the inviter's offer), and the inviter waits for that offer. An inviter that
|
|
17427
|
-
* sees the joiner but no offer for `INVITER_DIAL_GRACE_MS` (an app from before this rule) offers itself
|
|
17428
|
-
* the lower-key rule settles any collision, on both sides.
|
|
18477
|
+
* sees the joiner but no offer for `INVITER_DIAL_GRACE_MS` (an app from before this rule) offers itself,
|
|
18478
|
+
* and dials at once when no offer can come (`nothingToAwait`); the lower-key rule settles any collision, on both sides.
|
|
17429
18479
|
*/
|
|
17430
18480
|
maybeAutoConnect(presence) {
|
|
17431
18481
|
if (this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || !this.options.autoConnect || !presence.online || this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
@@ -17437,7 +18487,7 @@ var GhostLink = class {
|
|
|
17437
18487
|
const role = !!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? this.options.pairingProgress?.role : void 0;
|
|
17438
18488
|
if (role === "inviter") {
|
|
17439
18489
|
this.peerSeenAt ||= Date.now();
|
|
17440
|
-
if (Date.now() - this.peerSeenAt < 2500) {
|
|
18490
|
+
if (Date.now() - this.peerSeenAt < 2500 && !this.nothingToAwait()) {
|
|
17441
18491
|
this.session.expectPeer();
|
|
17442
18492
|
return;
|
|
17443
18493
|
}
|
|
@@ -17454,6 +18504,7 @@ var GhostLink = class {
|
|
|
17454
18504
|
failures: this.autoConnectFailures,
|
|
17455
18505
|
left: wait - (Date.now() - this.lastAutoConnectAt)
|
|
17456
18506
|
});
|
|
18507
|
+
this.lookAtRetry();
|
|
17457
18508
|
return;
|
|
17458
18509
|
}
|
|
17459
18510
|
traceLink(this.myPubKeyZ32, "dial", { failures: this.autoConnectFailures });
|
|
@@ -17462,6 +18513,7 @@ var GhostLink = class {
|
|
|
17462
18513
|
this.dial().catch((error) => {
|
|
17463
18514
|
this.tracker?.failed("transport", true);
|
|
17464
18515
|
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
18516
|
+
this.lookAtRetry();
|
|
17465
18517
|
});
|
|
17466
18518
|
}
|
|
17467
18519
|
/**
|
|
@@ -17481,8 +18533,42 @@ var GhostLink = class {
|
|
|
17481
18533
|
this.dial().catch((error) => {
|
|
17482
18534
|
this.tracker?.failed("transport", true);
|
|
17483
18535
|
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
18536
|
+
this.lookAtRetry();
|
|
17484
18537
|
});
|
|
17485
18538
|
}
|
|
18539
|
+
/**
|
|
18540
|
+
* A failed dial waits `dialWait` before the next, and only a look at the contact's record dials: the next one came at
|
|
18541
|
+
* the link's own pace (30 s in the background), which knows nothing of the backoff. A member let in to a group
|
|
18542
|
+
* reached one of its members 72 s after its welcome, 40 s of backoff and 32 s more for its next look (meshSignals on
|
|
18543
|
+
* CI, 2026-10-06). One look goes when the backoff ends (`liveAttempt.retryAt`), and its read decides, as any look's
|
|
18544
|
+
* does. A dial or a reset of the backoff since makes it moot: what comes next arms its own.
|
|
18545
|
+
*/
|
|
18546
|
+
lookAtRetry() {
|
|
18547
|
+
if (this.retryLook) clearTimeout(this.retryLook);
|
|
18548
|
+
this.retryLook = null;
|
|
18549
|
+
const from = this.lastAutoConnectAt;
|
|
18550
|
+
if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
|
|
18551
|
+
this.retryLook = setTimeout(() => {
|
|
18552
|
+
this.retryLook = null;
|
|
18553
|
+
if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
18554
|
+
traceLink(this.myPubKeyZ32, "retry-look", { failures: this.autoConnectFailures });
|
|
18555
|
+
this.session.lookNow();
|
|
18556
|
+
}, Math.max(0, from + this.dialWait() - Date.now()));
|
|
18557
|
+
}
|
|
18558
|
+
/**
|
|
18559
|
+
* A packet of this link reached the relays after a dial whose offer never did (the relays failed, or their budget held
|
|
18560
|
+
* it, for the whole attempt): the contact saw nothing of that dial, so it is no reason to wait, and the next goes now.
|
|
18561
|
+
* Without this a dial made during an outage set the wait, three minutes once it had grown, from when the relays
|
|
18562
|
+
* answered again.
|
|
18563
|
+
*/
|
|
18564
|
+
dialSeenNow() {
|
|
18565
|
+
if (!this.dialUnseen) return;
|
|
18566
|
+
this.dialUnseen = false;
|
|
18567
|
+
if (this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
18568
|
+
traceLink(this.myPubKeyZ32, "dial-unseen", { failures: this.autoConnectFailures });
|
|
18569
|
+
this.lastAutoConnectAt = 0;
|
|
18570
|
+
this.maybeAutoConnect(this.presence);
|
|
18571
|
+
}
|
|
17486
18572
|
/** How long after the last automatic dial the next one may go. A first pairing tries again sooner: the contact just read the invite and is waiting. */
|
|
17487
18573
|
dialWait() {
|
|
17488
18574
|
return !!this.tracker && !this.tracker.done && this.tracker.progress.stage !== "on-dht" ? Math.min(PAIRING_RETRY_MS * 2 ** this.autoConnectFailures, PAIRING_MAX_RETRY_MS) : Math.min(AUTO_CONNECT_RETRY_MS * 2 ** this.autoConnectFailures, AUTO_CONNECT_MAX_RETRY_MS);
|
|
@@ -17729,6 +18815,7 @@ var GhostLink = class {
|
|
|
17729
18815
|
if (this.options.statusCardSupport) offered.push(STATUS_CARD_CAPABILITY);
|
|
17730
18816
|
if (this.options.buttonsSupport) offered.push(BUTTONS_CAPABILITY);
|
|
17731
18817
|
if (this.options.upgradeSupport) offered.push(UPGRADE_CAPABILITY);
|
|
18818
|
+
if (this.options.appsSupport) offered.push(APPS_CAPABILITY);
|
|
17732
18819
|
if (this.options.deviceCapabilities) offered.push(...this.options.deviceCapabilities);
|
|
17733
18820
|
return offered;
|
|
17734
18821
|
}
|
|
@@ -17873,12 +18960,90 @@ var GhostLink = class {
|
|
|
17873
18960
|
*/
|
|
17874
18961
|
setTyping(typing, activity) {
|
|
17875
18962
|
if (!this.options.params.profile) return;
|
|
17876
|
-
|
|
18963
|
+
if (typing && !this.supportsTyping) {
|
|
18964
|
+
this.typingSender.want(activity);
|
|
18965
|
+
return;
|
|
18966
|
+
}
|
|
18967
|
+
const frame = typing ? this.typingSender.typing(activity) : this.typingSender.stopped();
|
|
17877
18968
|
if (!frame || !this.channel || !this.supportsTyping) return;
|
|
17878
18969
|
try {
|
|
17879
18970
|
this.channel.send(JSON.stringify(frame));
|
|
17880
18971
|
} catch {}
|
|
17881
18972
|
}
|
|
18973
|
+
resumeTyping() {
|
|
18974
|
+
const frame = this.typingSender.resume();
|
|
18975
|
+
if (!frame || !this.channel) return;
|
|
18976
|
+
try {
|
|
18977
|
+
this.channel.send(JSON.stringify(frame));
|
|
18978
|
+
} catch {}
|
|
18979
|
+
}
|
|
18980
|
+
/** Both sides offer `apps/1` on the open session: mini-apps can talk (WISP 1200 § In a chat). */
|
|
18981
|
+
get supportsApps() {
|
|
18982
|
+
return !!this.options.params.profile && this.isDataLinkOpen && this.sessionCapabilities.agreed("apps/1");
|
|
18983
|
+
}
|
|
18984
|
+
/** The apps the contact has open on this session (chat app id to version). */
|
|
18985
|
+
get peerApps() {
|
|
18986
|
+
return this.apps.peerOpen;
|
|
18987
|
+
}
|
|
18988
|
+
/**
|
|
18989
|
+
* This side opened an app in this chat (or updated it): `open` goes now when both sides offer `apps/1` on the live
|
|
18990
|
+
* session, and again on every session that comes back while the app stays open. Never on the DHT.
|
|
18991
|
+
*/
|
|
18992
|
+
openApp(app, version) {
|
|
18993
|
+
const frame = JSON.stringify(appOpenFrame(app, version));
|
|
18994
|
+
this.apps.open.set(app, version);
|
|
18995
|
+
if (this.supportsApps && this.channel) try {
|
|
18996
|
+
this.channel.send(frame);
|
|
18997
|
+
} catch {}
|
|
18998
|
+
}
|
|
18999
|
+
/** This side closed an app in this chat: `close` goes now when it can; nothing is said later. */
|
|
19000
|
+
closeApp(app) {
|
|
19001
|
+
const frame = JSON.stringify(appCloseFrame(app));
|
|
19002
|
+
if (!this.apps.open.delete(app)) return;
|
|
19003
|
+
if (this.supportsApps && this.channel) try {
|
|
19004
|
+
this.channel.send(frame);
|
|
19005
|
+
} catch {}
|
|
19006
|
+
}
|
|
19007
|
+
/**
|
|
19008
|
+
* One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
|
|
19009
|
+
* kept to send later.
|
|
19010
|
+
*/
|
|
19011
|
+
sendAppData(app, data) {
|
|
19012
|
+
if (!this.apps.open.has(app)) return "not-open";
|
|
19013
|
+
if (appDataBytes(data) > 32768) return "too-large";
|
|
19014
|
+
if (!this.channel || !this.isDataLinkOpen) return "offline";
|
|
19015
|
+
if (!this.supportsApps) return "peer-closed";
|
|
19016
|
+
const refused = this.apps.canSend(app, data);
|
|
19017
|
+
if (refused) return refused;
|
|
19018
|
+
try {
|
|
19019
|
+
this.channel.send(JSON.stringify(appDataFrame(app, data)));
|
|
19020
|
+
return null;
|
|
19021
|
+
} catch {
|
|
19022
|
+
return "offline";
|
|
19023
|
+
}
|
|
19024
|
+
}
|
|
19025
|
+
/** What the contact had open is gone (`apps/1` is no longer agreed, or this link stopped): each app hears it now. */
|
|
19026
|
+
appsEnded() {
|
|
19027
|
+
if (this.appsGrace) {
|
|
19028
|
+
clearTimeout(this.appsGrace);
|
|
19029
|
+
this.appsGrace = null;
|
|
19030
|
+
}
|
|
19031
|
+
for (const event of this.apps.sessionEnded()) this.options.events?.onAppFrame?.(event);
|
|
19032
|
+
}
|
|
19033
|
+
appsGrace = null;
|
|
19034
|
+
/**
|
|
19035
|
+
* The session ended or a new one took its place (a transport switch, a resume, a re-dial): what the contact had open
|
|
19036
|
+
* is held for `APP_RESUME_GRACE_MS` (WISP 1200 § In a chat). The next session's `open` takes each back, and the app
|
|
19037
|
+
* hears only that open, not a close first; one not said again by then is closed with `offline`.
|
|
19038
|
+
*/
|
|
19039
|
+
appsSuspended() {
|
|
19040
|
+
this.apps.sessionSuspended();
|
|
19041
|
+
if (!this.apps.holding || this.appsGrace) return;
|
|
19042
|
+
this.appsGrace = setTimeout(() => {
|
|
19043
|
+
this.appsGrace = null;
|
|
19044
|
+
for (const event of this.apps.heldExpired()) this.options.events?.onAppFrame?.(event);
|
|
19045
|
+
}, this.options.appResumeGraceMs ?? 1e4);
|
|
19046
|
+
}
|
|
17882
19047
|
/** Whether files/3 was agreed on the session open now; kept to say when that changes. */
|
|
17883
19048
|
filesOpen = false;
|
|
17884
19049
|
emitFilesSession(again = false) {
|
|
@@ -17951,15 +19116,24 @@ var GhostLink = class {
|
|
|
17951
19116
|
this.channel.send(JSON.stringify(pending));
|
|
17952
19117
|
} catch {}
|
|
17953
19118
|
}
|
|
17954
|
-
if (changed.includes("typing/1")
|
|
17955
|
-
this.
|
|
17956
|
-
|
|
19119
|
+
if (changed.includes("typing/1")) {
|
|
19120
|
+
if (!this.supportsTyping) {
|
|
19121
|
+
this.typingSender.reset();
|
|
19122
|
+
this.typingReceiver.clear();
|
|
19123
|
+
} else this.resumeTyping();
|
|
17957
19124
|
}
|
|
17958
19125
|
if (changed.includes("react/1")) this.options.events?.onReactionsSupport?.(this.supportsReactions);
|
|
17959
19126
|
if (changed.includes("edit/1")) this.options.events?.onEditSupport?.(this.supportsEdits);
|
|
17960
19127
|
if (changed.includes("pin/1")) this.options.events?.onPinSupport?.(this.supportsPins);
|
|
17961
19128
|
if (changed.includes("wake/1")) this.options.events?.onWakeSupport?.(this.supportsWake);
|
|
17962
19129
|
if (changed.includes("upgrade/1")) this.switcher.reconsider();
|
|
19130
|
+
if (changed.includes("apps/1")) {
|
|
19131
|
+
if (this.supportsApps && this.channel) for (const [app, version] of this.apps.open) try {
|
|
19132
|
+
this.channel.send(JSON.stringify(appOpenFrame(app, version)));
|
|
19133
|
+
} catch {}
|
|
19134
|
+
if (!this.supportsApps) this.appsEnded();
|
|
19135
|
+
this.options.events?.onAppsSupport?.(this.supportsApps);
|
|
19136
|
+
}
|
|
17963
19137
|
const devices = this.options.deviceCapabilities;
|
|
17964
19138
|
if (devices && changed.some((capability) => devices.includes(capability))) this.options.events?.onDeviceCapabilities?.(devices.filter((capability) => this.supportsDevice(capability)));
|
|
17965
19139
|
this.emitPairingState();
|
|
@@ -17990,7 +19164,8 @@ var GhostLink = class {
|
|
|
17990
19164
|
2,
|
|
17991
19165
|
3,
|
|
17992
19166
|
4,
|
|
17993
|
-
5
|
|
19167
|
+
5,
|
|
19168
|
+
6
|
|
17994
19169
|
]
|
|
17995
19170
|
}));
|
|
17996
19171
|
} catch {}
|
|
@@ -18059,7 +19234,9 @@ var GhostLink = class {
|
|
|
18059
19234
|
if (transport === "webrtc/1") {
|
|
18060
19235
|
this.rtcCandidateWaiter = done;
|
|
18061
19236
|
this.rtcRetired().then(() => {
|
|
18062
|
-
if (active
|
|
19237
|
+
if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) return;
|
|
19238
|
+
this.switcher.dialing(plan);
|
|
19239
|
+
return this.dataLink.connect();
|
|
18063
19240
|
}).catch(done.reject);
|
|
18064
19241
|
} else {
|
|
18065
19242
|
const endpoint = this.endpoints.get(transport), descriptor = plan.remote.descriptors[transport];
|
|
@@ -18067,6 +19244,7 @@ var GhostLink = class {
|
|
|
18067
19244
|
done.reject(/* @__PURE__ */ new Error("Peer native address unavailable"));
|
|
18068
19245
|
return;
|
|
18069
19246
|
}
|
|
19247
|
+
this.switcher.dialing(plan);
|
|
18070
19248
|
endpoint.connect(descriptor).then(({ channel, binding }) => {
|
|
18071
19249
|
if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) {
|
|
18072
19250
|
channel.close();
|
|
@@ -18267,9 +19445,10 @@ var GhostLink = class {
|
|
|
18267
19445
|
},
|
|
18268
19446
|
onFailure: () => {
|
|
18269
19447
|
if (paired.state.keyMismatch) this.options.events?.onPeerKeyRefused?.();
|
|
18270
|
-
if (this.unproven(channel) || paired.authTimedOut) {
|
|
19448
|
+
if (this.unproven(channel) || paired.authTimedOut || paired.overloaded) {
|
|
18271
19449
|
if (paired.state.keyMismatch) this.dht?.foreignKeySeen("stream");
|
|
18272
19450
|
if (this.unproven(channel)) traceLink(this.myPubKeyZ32, "dialed-in-refused", { keyMismatch: !!paired.state.keyMismatch });
|
|
19451
|
+
else if (paired.overloaded) traceLink(this.myPubKeyZ32, "receive-overload", { transport: binding?.transport ?? "webrtc/1" });
|
|
18273
19452
|
else traceLink(this.myPubKeyZ32, "auth-timeout", { transport: binding?.transport ?? "webrtc/1" });
|
|
18274
19453
|
migration?.reject(new Error(paired.state.error ?? "Candidate authentication failed"));
|
|
18275
19454
|
channel.close();
|
|
@@ -18349,6 +19528,7 @@ var GhostLink = class {
|
|
|
18349
19528
|
this.sessionCapabilities.reset();
|
|
18350
19529
|
this.typingSender.reset();
|
|
18351
19530
|
this.typingReceiver.clear();
|
|
19531
|
+
this.appsSuspended();
|
|
18352
19532
|
this.pairedHttp?.close();
|
|
18353
19533
|
this.pairedHttp = new PairedHttp(channel, this.options.getHostedHttpService, this.options.localFetch);
|
|
18354
19534
|
this.emitPairingState();
|
|
@@ -18405,11 +19585,13 @@ var GhostLink = class {
|
|
|
18405
19585
|
}
|
|
18406
19586
|
if (frame?.t === "paired-pong") {
|
|
18407
19587
|
this.peerAnswersPings = true;
|
|
18408
|
-
|
|
19588
|
+
const answered = ++this.pongsHeard;
|
|
19589
|
+
if (this.pingSentAt && answered >= this.rttPing) {
|
|
18409
19590
|
this.rtt = Date.now() - this.pingSentAt;
|
|
18410
19591
|
this.pingSentAt = 0;
|
|
18411
19592
|
this.options.events?.onRtt?.(this.rtt);
|
|
18412
19593
|
}
|
|
19594
|
+
for (const waiter of this.handledWaiters.filter((w) => w.ping <= answered)) waiter.done(true);
|
|
18413
19595
|
return;
|
|
18414
19596
|
}
|
|
18415
19597
|
if (frame?.t === "paired-bye") {
|
|
@@ -18529,6 +19711,12 @@ var GhostLink = class {
|
|
|
18529
19711
|
if (this.supportsTyping) this.typingReceiver.receive(frame);
|
|
18530
19712
|
return;
|
|
18531
19713
|
}
|
|
19714
|
+
if (frame?.t === "paired-app") {
|
|
19715
|
+
if (!this.supportsApps) return;
|
|
19716
|
+
const event = this.apps.receive(frame);
|
|
19717
|
+
if (event) this.options.events?.onAppFrame?.(event);
|
|
19718
|
+
return;
|
|
19719
|
+
}
|
|
18532
19720
|
if (frame?.t === "paired-reaction") {
|
|
18533
19721
|
if (!this.supportsReactions || !this.reactionsReceived.take()) return;
|
|
18534
19722
|
const reaction = parseReactionFrame(frame);
|
|
@@ -18726,8 +19914,7 @@ var GhostLink = class {
|
|
|
18726
19914
|
this.stopLiveness();
|
|
18727
19915
|
this.peerAnswersPings = peerAnswersPings;
|
|
18728
19916
|
const ping = () => {
|
|
18729
|
-
this.
|
|
18730
|
-
channel.send(JSON.stringify({ t: "paired-ping" }));
|
|
19917
|
+
this.sendPing(channel, true);
|
|
18731
19918
|
if (!this.peerAnswersPings || this.pongWait) return;
|
|
18732
19919
|
this.pongWait = setTimeout(() => {
|
|
18733
19920
|
this.pongWait = null;
|
|
@@ -18765,6 +19952,43 @@ var GhostLink = class {
|
|
|
18765
19952
|
this.peerAnswersPings = false;
|
|
18766
19953
|
this.pingSentAt = 0;
|
|
18767
19954
|
this.rtt = void 0;
|
|
19955
|
+
this.pingsSent = this.pongsHeard = this.rttPing = 0;
|
|
19956
|
+
for (const waiter of [...this.handledWaiters]) waiter.done(false);
|
|
19957
|
+
}
|
|
19958
|
+
/** `live`: a liveness ping, whose pong measures the round trip. */
|
|
19959
|
+
sendPing(channel, live) {
|
|
19960
|
+
if (live) this.pingSentAt = Date.now();
|
|
19961
|
+
channel.send(JSON.stringify({ t: "paired-ping" }));
|
|
19962
|
+
this.pingsSent++;
|
|
19963
|
+
if (live) this.rttPing = this.pingsSent;
|
|
19964
|
+
}
|
|
19965
|
+
/**
|
|
19966
|
+
* Resolves once the contact's app handled every frame sent on this session so far: it answers a ping in the order
|
|
19967
|
+
* frames come (every app since 1.0), so the pong to one sent now says so, however slowly it handles them. A group
|
|
19968
|
+
* member's catch-up answer goes a slice at a time on it (`GroupSessionHooks.handled`). `false` when it cannot tell:
|
|
19969
|
+
* no session, a contact that answers no ping, or no answer within `HANDLED_WAIT_MS`.
|
|
19970
|
+
*/
|
|
19971
|
+
handled() {
|
|
19972
|
+
const channel = this.channel;
|
|
19973
|
+
if (!channel || !this.peerAnswersPings || this.paired?.state.status !== "ready") return Promise.resolve(false);
|
|
19974
|
+
try {
|
|
19975
|
+
this.sendPing(channel, false);
|
|
19976
|
+
} catch {
|
|
19977
|
+
return Promise.resolve(false);
|
|
19978
|
+
}
|
|
19979
|
+
return new Promise((resolve) => {
|
|
19980
|
+
const waiter = {
|
|
19981
|
+
ping: this.pingsSent,
|
|
19982
|
+
done: (exact) => {
|
|
19983
|
+
clearTimeout(timer);
|
|
19984
|
+
const at = this.handledWaiters.indexOf(waiter);
|
|
19985
|
+
if (at >= 0) this.handledWaiters.splice(at, 1);
|
|
19986
|
+
resolve(exact);
|
|
19987
|
+
}
|
|
19988
|
+
};
|
|
19989
|
+
const timer = setTimeout(() => waiter.done(false), HANDLED_WAIT_MS);
|
|
19990
|
+
this.handledWaiters.push(waiter);
|
|
19991
|
+
});
|
|
18768
19992
|
}
|
|
18769
19993
|
/** Something came back on the session: the ping's wait is over. */
|
|
18770
19994
|
heardFromPeer() {
|
|
@@ -18802,6 +20026,10 @@ var GhostLink = class {
|
|
|
18802
20026
|
peerLost(why) {
|
|
18803
20027
|
if (this.stopped || this.leaving || this.yielding) return;
|
|
18804
20028
|
traceLink(this.myPubKeyZ32, "peer-lost", { why });
|
|
20029
|
+
if (Date.now() < this.wokeUntil) {
|
|
20030
|
+
this.wokeUntil = 0;
|
|
20031
|
+
this.resumeAfterAbsence();
|
|
20032
|
+
}
|
|
18805
20033
|
this.lostUntil = Date.now() + WATCH_PEER_MS;
|
|
18806
20034
|
this.autoConnectFailures = 0;
|
|
18807
20035
|
this.lastAutoConnectAt = 0;
|
|
@@ -18877,12 +20105,67 @@ var GhostLink = class {
|
|
|
18877
20105
|
this.autoConnectFailures = 0;
|
|
18878
20106
|
this.lastAutoConnectAt = 0;
|
|
18879
20107
|
this.session.pollNow();
|
|
18880
|
-
if (
|
|
20108
|
+
if (this.channel) {
|
|
20109
|
+
this.wokeUntil = Date.now() + WAKE_LOST_MS;
|
|
20110
|
+
this.probeAfterWake(this.channel);
|
|
20111
|
+
} else {
|
|
20112
|
+
this.resumeAfterAbsence();
|
|
18881
20113
|
if (!this.session.publishedRecently(RECENTLY_PUBLISHED_MS)) this.session.refreshAdvertisement();
|
|
18882
20114
|
this.maybeAutoConnect(this.presence);
|
|
18883
20115
|
}
|
|
18884
20116
|
}
|
|
20117
|
+
/**
|
|
20118
|
+
* Back in front with no session, after one was live: the contact likely did not notice this app go (a phone puts a
|
|
20119
|
+
* backgrounded app to sleep without a word), and reads this chat at its slow pace (30 s on the relays in the
|
|
20120
|
+
* background), so an offer or a fresh packet of this side's waited about that long for it. The chat resumes as after a
|
|
20121
|
+
* restart (WISP 100, "Back after a restart"): this side dials whatever its key, on the transport it was live on, and
|
|
20122
|
+
* knocks on the contact's other transports as its offer goes out, which reaches the contact at once.
|
|
20123
|
+
*/
|
|
20124
|
+
resumeAfterAbsence() {
|
|
20125
|
+
const transport = this.lastLiveOn;
|
|
20126
|
+
if (!transport || this.resuming || this.departed || !this.options.pairing?.credentials.peerKey || !this.options.params.profile || this.deliveryMode === "dht") return;
|
|
20127
|
+
this.lastLiveOn = void 0;
|
|
20128
|
+
this.resuming = transport;
|
|
20129
|
+
this.resumingWoke = true;
|
|
20130
|
+
this.knockedEarly = -1;
|
|
20131
|
+
traceLink(this.myPubKeyZ32, "wake-resume", { transport });
|
|
20132
|
+
if (transport !== "webrtc/1") {
|
|
20133
|
+
this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
|
|
20134
|
+
return;
|
|
20135
|
+
}
|
|
20136
|
+
if (this.dataLink.state === "answering" && this.answeringEpoch === this.connectionEpoch) {
|
|
20137
|
+
const epoch = this.connectionEpoch;
|
|
20138
|
+
setTimeout(() => this.maybeKnockAnswering(epoch), 0);
|
|
20139
|
+
return;
|
|
20140
|
+
}
|
|
20141
|
+
const offered = this.offered;
|
|
20142
|
+
if (!offered || offered.resume || offered.epoch !== this.connectionEpoch || !this.offerOut && !this.dialing) return;
|
|
20143
|
+
this.resuming = void 0;
|
|
20144
|
+
this.resumingWoke = false;
|
|
20145
|
+
offered.resume = true;
|
|
20146
|
+
offered.woke = true;
|
|
20147
|
+
if (!this.dialing) setTimeout(() => this.maybeKnockEarly(offered.epoch), 0);
|
|
20148
|
+
}
|
|
20149
|
+
/** A ping on the session right after a wake, with `PONG_WAIT_MS` for its answer: none, and the session is dead. */
|
|
20150
|
+
probeAfterWake(channel) {
|
|
20151
|
+
if (!this.peerAnswersPings || this.paired?.state.status !== "ready") return;
|
|
20152
|
+
this.unansweredPings++;
|
|
20153
|
+
try {
|
|
20154
|
+
this.sendPing(channel, true);
|
|
20155
|
+
} catch {
|
|
20156
|
+
this.dropDeadSession(channel);
|
|
20157
|
+
return;
|
|
20158
|
+
}
|
|
20159
|
+
if (this.pongWait) clearTimeout(this.pongWait);
|
|
20160
|
+
this.pongWait = setTimeout(() => {
|
|
20161
|
+
this.pongWait = null;
|
|
20162
|
+
if (this.channel !== channel || this.stopped) return;
|
|
20163
|
+
traceLink(this.myPubKeyZ32, "wake-unanswered", {});
|
|
20164
|
+
this.dropDeadSession(channel);
|
|
20165
|
+
}, PONG_WAIT_MS);
|
|
20166
|
+
}
|
|
18885
20167
|
detach() {
|
|
20168
|
+
if (this.channel && this.paired?.state.status === "ready") this.lastLiveOn = this.activeBinding?.transport ?? "webrtc/1";
|
|
18886
20169
|
this.stopLiveness();
|
|
18887
20170
|
const wasNative = !!this.activeBinding;
|
|
18888
20171
|
this.applicationOpen = false;
|
|
@@ -18908,6 +20191,7 @@ var GhostLink = class {
|
|
|
18908
20191
|
this.sessionCapabilities.reset();
|
|
18909
20192
|
this.typingSender.reset();
|
|
18910
20193
|
this.typingReceiver.clear();
|
|
20194
|
+
this.appsSuspended();
|
|
18911
20195
|
this.emitFilesSession();
|
|
18912
20196
|
if (this.peerGroupVersions) {
|
|
18913
20197
|
this.peerGroupVersions = null;
|
|
@@ -20771,7 +22055,7 @@ var DID_GHOSTLY_SERVICE = "GhostlyIdentityProof";
|
|
|
20771
22055
|
var METHOD_NAME = /^[a-z0-9]+$/;
|
|
20772
22056
|
var Z32_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
|
|
20773
22057
|
var B64URL = /^[A-Za-z0-9_-]*$/;
|
|
20774
|
-
var SEGMENT = /^[A-Za-z0-9._~-]{1,64}$/;
|
|
22058
|
+
var SEGMENT$1 = /^[A-Za-z0-9._~-]{1,64}$/;
|
|
20775
22059
|
var HEX64 = /^[a-f0-9]{64}$/;
|
|
20776
22060
|
var METHOD_LIST = "did:key, did:jwk, did:dht and did:web";
|
|
20777
22061
|
/** base64url without padding, strictly: decoding and encoding again gives the same text. */
|
|
@@ -20782,7 +22066,7 @@ function strictBase64Url(value) {
|
|
|
20782
22066
|
if (toBase64Url(bytes) !== value) throw new DidError("Not canonical base64url");
|
|
20783
22067
|
return bytes;
|
|
20784
22068
|
}
|
|
20785
|
-
var isObject$
|
|
22069
|
+
var isObject$3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
20786
22070
|
/**
|
|
20787
22071
|
* The DID a person typed, checked and in canonical form, or a message they can act on. Methods other than
|
|
20788
22072
|
* the four Ghostly checks are refused with "not supported yet"; a DID URL (a `#fragment`, `?query` or
|
|
@@ -20856,7 +22140,7 @@ function parseWeb(id) {
|
|
|
20856
22140
|
if (!(port >= 1 && port <= 65535) || String(port) !== portMatch[2]) throw new DidError("did:web: that port is not valid");
|
|
20857
22141
|
}
|
|
20858
22142
|
if (path.length > 8) throw new DidError("did:web: at most 8 path segments");
|
|
20859
|
-
for (const segment of path) if (!SEGMENT.test(segment) || segment === "." || segment === "..") throw new DidError("did:web: each path segment is letters, digits, dots, dashes, underscores or tildes");
|
|
22143
|
+
for (const segment of path) if (!SEGMENT$1.test(segment) || segment === "." || segment === "..") throw new DidError("did:web: each path segment is letters, digits, dots, dashes, underscores or tildes");
|
|
20860
22144
|
return {
|
|
20861
22145
|
id: [`${host}${port ? `%3A${port}` : ""}`, ...path].join(":"),
|
|
20862
22146
|
host,
|
|
@@ -20900,7 +22184,7 @@ function didWebFileNames(text, did, statement) {
|
|
|
20900
22184
|
} catch {
|
|
20901
22185
|
return false;
|
|
20902
22186
|
}
|
|
20903
|
-
return isObject$
|
|
22187
|
+
return isObject$3(body) && body.ghostly === 1 && body.did === did && body.statement === statement;
|
|
20904
22188
|
}
|
|
20905
22189
|
/** The service entry a did.json can carry instead of the file. */
|
|
20906
22190
|
function didGhostlyServiceEntry(did, key, proofId) {
|
|
@@ -20982,7 +22266,7 @@ var JWK_CURVES = {
|
|
|
20982
22266
|
};
|
|
20983
22267
|
/** A public JWK. Other curves are `undefined`; a malformed one throws. */
|
|
20984
22268
|
function jwkKey(jwk) {
|
|
20985
|
-
if (!isObject$
|
|
22269
|
+
if (!isObject$3(jwk)) throw new DidError("That JWK is not an object");
|
|
20986
22270
|
const spec = typeof jwk.crv === "string" ? JWK_CURVES[jwk.crv] : void 0;
|
|
20987
22271
|
if (!spec || jwk.kty !== spec.kty) return void 0;
|
|
20988
22272
|
const coordinate = (v) => {
|
|
@@ -21027,7 +22311,7 @@ function staticKey(did) {
|
|
|
21027
22311
|
} catch {
|
|
21028
22312
|
throw new DidError("That did:jwk does not hold a JSON Web Key");
|
|
21029
22313
|
}
|
|
21030
|
-
if (!isObject$
|
|
22314
|
+
if (!isObject$3(jwk)) throw new DidError("That did:jwk does not hold a JSON Web Key");
|
|
21031
22315
|
if ("d" in jwk || "p" in jwk || "k" in jwk) throw new DidError("That did:jwk contains a private key. Never share it: make a new key");
|
|
21032
22316
|
const key = jwkKey(jwk);
|
|
21033
22317
|
if (!key) throw new DidError("That did:jwk is not an Ed25519, secp256k1 or P-256 key");
|
|
@@ -21090,13 +22374,13 @@ var list = (value, name) => {
|
|
|
21090
22374
|
* `GhostlyIdentityProof` services are kept. Keys Ghostly cannot use are counted in `skipped`.
|
|
21091
22375
|
*/
|
|
21092
22376
|
function parseDidDocument(did, raw) {
|
|
21093
|
-
if (!isObject$
|
|
22377
|
+
if (!isObject$3(raw)) throw new DidError("The DID document is not a JSON object");
|
|
21094
22378
|
if (raw.id !== did) throw new DidError(typeof raw.id === "string" ? `The DID document is for another DID (${raw.id.slice(0, 80)})` : "The DID document has no id");
|
|
21095
22379
|
const keys = /* @__PURE__ */ new Map();
|
|
21096
22380
|
const unusable = /* @__PURE__ */ new Set();
|
|
21097
22381
|
let skipped = 0;
|
|
21098
22382
|
const add = (vm) => {
|
|
21099
|
-
if (!isObject$
|
|
22383
|
+
if (!isObject$3(vm)) {
|
|
21100
22384
|
skipped++;
|
|
21101
22385
|
return;
|
|
21102
22386
|
}
|
|
@@ -21127,10 +22411,10 @@ function parseDidDocument(did, raw) {
|
|
|
21127
22411
|
}
|
|
21128
22412
|
const services = [];
|
|
21129
22413
|
for (const s of list(raw.service, "service")) {
|
|
21130
|
-
if (!isObject$
|
|
22414
|
+
if (!isObject$3(s)) continue;
|
|
21131
22415
|
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
21132
22416
|
const endpoint = s.serviceEndpoint;
|
|
21133
|
-
if (!types.includes("GhostlyIdentityProof") || !isObject$
|
|
22417
|
+
if (!types.includes("GhostlyIdentityProof") || !isObject$3(endpoint)) continue;
|
|
21134
22418
|
if (typeof endpoint.key === "string" && Z32_KEY.test(endpoint.key) && typeof endpoint.proof === "string" && HEX64.test(endpoint.proof)) services.push({
|
|
21135
22419
|
id: typeof s.id === "string" ? s.id.slice(0, 200) : "",
|
|
21136
22420
|
key: endpoint.key,
|
|
@@ -21173,7 +22457,7 @@ function parseDidJws(input) {
|
|
|
21173
22457
|
} catch {
|
|
21174
22458
|
throw new DidError("The JWS header is not base64url JSON");
|
|
21175
22459
|
}
|
|
21176
|
-
if (!isObject$
|
|
22460
|
+
if (!isObject$3(header)) throw new DidError("The JWS header is not a JSON object");
|
|
21177
22461
|
const alg = header.alg;
|
|
21178
22462
|
if (typeof alg !== "string" || !DID_JWS_ALGS[alg]) throw new DidError(`The JWS uses ${typeof alg === "string" ? alg.slice(0, 16) : "no algorithm"}: Ghostly checks EdDSA, ES256K and ES256`);
|
|
21179
22463
|
if (header.kid !== void 0 && (typeof header.kid !== "string" || header.kid.length > 640)) throw new DidError("The JWS kid is not a key id");
|
|
@@ -21985,7 +23269,7 @@ function compactSize(n) {
|
|
|
21985
23269
|
throw new Error("Too large");
|
|
21986
23270
|
}
|
|
21987
23271
|
var varBytes = (b) => concatBytes$1(compactSize(b.length), b);
|
|
21988
|
-
var u32$
|
|
23272
|
+
var u32$2 = (n) => {
|
|
21989
23273
|
const b = /* @__PURE__ */ new Uint8Array(4);
|
|
21990
23274
|
new DataView(b.buffer).setUint32(0, n >>> 0, true);
|
|
21991
23275
|
return b;
|
|
@@ -21996,14 +23280,14 @@ var u64 = (n) => {
|
|
|
21996
23280
|
return b;
|
|
21997
23281
|
};
|
|
21998
23282
|
function encodeTx(tx, withWitness = tx.inputs.some((i) => i.witness.length > 0)) {
|
|
21999
|
-
const parts = [u32$
|
|
23283
|
+
const parts = [u32$2(tx.version)];
|
|
22000
23284
|
if (withWitness) parts.push(Uint8Array.of(0, 1));
|
|
22001
23285
|
parts.push(compactSize(tx.inputs.length));
|
|
22002
|
-
for (const i of tx.inputs) parts.push(i.txid, u32$
|
|
23286
|
+
for (const i of tx.inputs) parts.push(i.txid, u32$2(i.vout), varBytes(i.scriptSig), u32$2(i.sequence));
|
|
22003
23287
|
parts.push(compactSize(tx.outputs.length));
|
|
22004
23288
|
for (const o of tx.outputs) parts.push(u64(o.value), varBytes(o.script));
|
|
22005
23289
|
if (withWitness) for (const i of tx.inputs) parts.push(encodeWitness(i.witness));
|
|
22006
|
-
parts.push(u32$
|
|
23290
|
+
parts.push(u32$2(tx.lockTime));
|
|
22007
23291
|
return concatBytes$1(...parts);
|
|
22008
23292
|
}
|
|
22009
23293
|
var encodeWitness = (stack) => concatBytes$1(compactSize(stack.length), ...stack.map(varBytes));
|
|
@@ -22169,12 +23453,12 @@ function decodeBase64(text) {
|
|
|
22169
23453
|
/** BIP 143, for witness v0. `scriptCode` is the P2PKH script of the key. */
|
|
22170
23454
|
function sighashV0(tx, scriptCode, hashType) {
|
|
22171
23455
|
const input = tx.inputs[0];
|
|
22172
|
-
return sha256d(u32$
|
|
23456
|
+
return sha256d(u32$2(tx.version), sha256d(...tx.inputs.map((i) => concatBytes$1(i.txid, u32$2(i.vout)))), sha256d(...tx.inputs.map((i) => u32$2(i.sequence))), input.txid, u32$2(input.vout), varBytes(scriptCode), u64(0n), u32$2(input.sequence), sha256d(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script)))), u32$2(tx.lockTime), u32$2(hashType));
|
|
22173
23457
|
}
|
|
22174
23458
|
/** BIP 341 key path, SIGHASH_DEFAULT or SIGHASH_ALL, no annex. */
|
|
22175
23459
|
function sighashV1(tx, scriptPubKey, hashType) {
|
|
22176
23460
|
const input = tx.inputs[0];
|
|
22177
|
-
return schnorr.utils.taggedHash("TapSighash", concatBytes$1(Uint8Array.of(0, hashType), u32$
|
|
23461
|
+
return schnorr.utils.taggedHash("TapSighash", concatBytes$1(Uint8Array.of(0, hashType), u32$2(tx.version), u32$2(tx.lockTime), sha256(concatBytes$1(input.txid, u32$2(input.vout))), sha256(u64(0n)), sha256(varBytes(scriptPubKey)), sha256(u32$2(input.sequence)), sha256(concatBytes$1(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script))))), Uint8Array.of(0), u32$2(0)));
|
|
22178
23462
|
}
|
|
22179
23463
|
/** The original sighash, for P2PKH: the input's script replaced by the spent output's, the rest as is. */
|
|
22180
23464
|
function sighashLegacy(tx, scriptPubKey, hashType) {
|
|
@@ -22185,7 +23469,7 @@ function sighashLegacy(tx, scriptPubKey, hashType) {
|
|
|
22185
23469
|
witness: [],
|
|
22186
23470
|
scriptSig: n === 0 ? scriptPubKey : /* @__PURE__ */ new Uint8Array(0)
|
|
22187
23471
|
}))
|
|
22188
|
-
}, false), u32$
|
|
23472
|
+
}, false), u32$2(hashType));
|
|
22189
23473
|
}
|
|
22190
23474
|
/** A check that throws on malformed input (a point off the curve…) simply does not hold. */
|
|
22191
23475
|
function holds(check) {
|
|
@@ -22370,11 +23654,11 @@ function verifyBip322(address, message, signature) {
|
|
|
22370
23654
|
*/
|
|
22371
23655
|
var MAX_MESSAGE = 16384;
|
|
22372
23656
|
var MAX_SIGNATURE = 16384;
|
|
22373
|
-
var MAGIC = utf8Encode("Bitcoin Signed Message:\n");
|
|
23657
|
+
var MAGIC$1 = utf8Encode("Bitcoin Signed Message:\n");
|
|
22374
23658
|
/** The digest `signmessage` signs: double SHA-256 of the length-prefixed magic and message. */
|
|
22375
23659
|
function legacyMessageHash(message) {
|
|
22376
23660
|
const bytes = utf8Encode(message);
|
|
22377
|
-
return sha256d(compactSize(MAGIC.length), MAGIC, compactSize(bytes.length), bytes);
|
|
23661
|
+
return sha256d(compactSize(MAGIC$1.length), MAGIC$1, compactSize(bytes.length), bytes);
|
|
22378
23662
|
}
|
|
22379
23663
|
/** Header bytes 27-34 are P2PKH (uncompressed 27-30, compressed 31-34); 35-42 are BIP 137 SegWit headers. */
|
|
22380
23664
|
var isLegacySignature = (bytes) => bytes.length === 65 && bytes[0] >= 27 && bytes[0] <= 42;
|
|
@@ -22501,8 +23785,8 @@ var Reader = class {
|
|
|
22501
23785
|
if (!this.done) fail("Trailing bytes in SSH data");
|
|
22502
23786
|
}
|
|
22503
23787
|
};
|
|
22504
|
-
var u32 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
|
|
22505
|
-
var sshString = (b) => concatBytes$1(u32(b.length), b);
|
|
23788
|
+
var u32$1 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
|
|
23789
|
+
var sshString = (b) => concatBytes$1(u32$1(b.length), b);
|
|
22506
23790
|
function strictBase64(text, max) {
|
|
22507
23791
|
if (text.length > max || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) fail("Invalid base64");
|
|
22508
23792
|
const bytes = fromBase64(text);
|
|
@@ -22668,7 +23952,7 @@ async function verifySshSignature(armored, message, options = {}) {
|
|
|
22668
23952
|
userVerified: !!(flags & SK_USER_VERIFIED),
|
|
22669
23953
|
counter
|
|
22670
23954
|
};
|
|
22671
|
-
data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32(counter), sha256(signed));
|
|
23955
|
+
data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32$1(counter), sha256(signed));
|
|
22672
23956
|
}
|
|
22673
23957
|
r.end();
|
|
22674
23958
|
const verifyWith = async () => {
|
|
@@ -23078,6 +24362,64 @@ function indexOf(points, words, from) {
|
|
|
23078
24362
|
}
|
|
23079
24363
|
return -1;
|
|
23080
24364
|
}
|
|
24365
|
+
/**
|
|
24366
|
+
* Catch-up answers, a slice at a time (`CATCH_UP_SLICE`), with two slices at most not handled yet by the member's app
|
|
24367
|
+
* (`hooks.handled`): the next once it handled the one before the last, so the slices follow each other without a round
|
|
24368
|
+
* trip between them. All at once, a member back in a busy group was handed over a hundred frames on one session, and an
|
|
24369
|
+
* app that holds 64 waiting ended that session mid catch-up ("Session receive limit exceeded", 2026-10-07). The frames
|
|
24370
|
+
* and their order are what they were: only when they go changes.
|
|
24371
|
+
*/
|
|
24372
|
+
var CatchUpAnswers = class {
|
|
24373
|
+
hooks;
|
|
24374
|
+
/** Answers still going out, per member: what is left of each. */
|
|
24375
|
+
going = /* @__PURE__ */ new Map();
|
|
24376
|
+
constructor(hooks) {
|
|
24377
|
+
this.hooks = hooks;
|
|
24378
|
+
}
|
|
24379
|
+
/**
|
|
24380
|
+
* The first two slices go now, the rest without holding the group's other frames back. A newer answer to the same
|
|
24381
|
+
* member takes the place of what is left of the last; a frame the edge did not take ends it (the member asks again
|
|
24382
|
+
* when its edge opens).
|
|
24383
|
+
*/
|
|
24384
|
+
handOut(to, frames) {
|
|
24385
|
+
const handled = this.hooks.handled;
|
|
24386
|
+
if (!handled) {
|
|
24387
|
+
for (const frame of frames) this.hooks.send(to, frame);
|
|
24388
|
+
return;
|
|
24389
|
+
}
|
|
24390
|
+
const going = this.going.get(to);
|
|
24391
|
+
if (going) {
|
|
24392
|
+
going.splice(0, going.length, ...frames);
|
|
24393
|
+
return;
|
|
24394
|
+
}
|
|
24395
|
+
const rest = [...frames], told = [];
|
|
24396
|
+
const next = () => {
|
|
24397
|
+
if (!this.sendSlice(to, rest)) return false;
|
|
24398
|
+
if (rest.length) told.push(handled(to).catch(() => false));
|
|
24399
|
+
return true;
|
|
24400
|
+
};
|
|
24401
|
+
if (!next() || !next() || !rest.length) return;
|
|
24402
|
+
this.going.set(to, rest);
|
|
24403
|
+
(async () => {
|
|
24404
|
+
try {
|
|
24405
|
+
while (rest.length) {
|
|
24406
|
+
if (!await told.shift()) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
24407
|
+
if (!this.hooks.active() || !next()) break;
|
|
24408
|
+
}
|
|
24409
|
+
} finally {
|
|
24410
|
+
this.going.delete(to);
|
|
24411
|
+
}
|
|
24412
|
+
})();
|
|
24413
|
+
}
|
|
24414
|
+
/** The next slice of `rest` to `to`: false, and nothing left, when the edge did not take one. */
|
|
24415
|
+
sendSlice(to, rest) {
|
|
24416
|
+
for (const frame of rest.splice(0, 16)) if (this.hooks.send(to, frame) === false) {
|
|
24417
|
+
rest.length = 0;
|
|
24418
|
+
return false;
|
|
24419
|
+
}
|
|
24420
|
+
return true;
|
|
24421
|
+
}
|
|
24422
|
+
};
|
|
23081
24423
|
//#endregion
|
|
23082
24424
|
//#region ../core/src/groupSession.ts
|
|
23083
24425
|
/**
|
|
@@ -23129,7 +24471,15 @@ var GROUP_LIMITS = {
|
|
|
23129
24471
|
syncAnswers: 8,
|
|
23130
24472
|
syncWindowMs: 6e4,
|
|
23131
24473
|
/** Messages delivered from a copy that was not whole, remembered until a whole copy completes them. */
|
|
23132
|
-
provisional: 256
|
|
24474
|
+
provisional: 256,
|
|
24475
|
+
/**
|
|
24476
|
+
* Frames of one catch-up answer in a slice (`GroupSessionHooks.handled`), two slices at most not handled yet by the
|
|
24477
|
+
* member's app: the next goes once it handled the one before the last. An app holds 64 frames waiting at most before
|
|
24478
|
+
* 2026-10-07 (more ends the session), and a member back in a busy group was handed over a hundred at once.
|
|
24479
|
+
*/
|
|
24480
|
+
catchUpSlice: 16,
|
|
24481
|
+
/** Between two slices, when the member's app cannot say it handled one (`handled` resolves `false`). */
|
|
24482
|
+
catchUpPauseMs: 500
|
|
23133
24483
|
};
|
|
23134
24484
|
/** What every member should know about who can read what, in the words the apps show. */
|
|
23135
24485
|
var GROUP_READ_NOTE = `Everyone in the group can read everything sent while they are a member. Someone removed keeps what they already received and cannot read what comes after; someone who joins later cannot read what came before. Messages go directly to each member; whoever was away gets what they missed from any member who has it (the group's last ${GROUP_LIMITS.relay} messages) when they meet again.`;
|
|
@@ -23168,6 +24518,17 @@ var cardAad = (f) => JSON.stringify([
|
|
|
23168
24518
|
f.ts
|
|
23169
24519
|
]);
|
|
23170
24520
|
var isCardBox = isSealedBox(MAX_CARD_BOX);
|
|
24521
|
+
/** A file's description as JSON: a name and a type of 255 characters each, 128 voice bars or a 12 KiB poster, sealed. */
|
|
24522
|
+
var MAX_FILE_BOX = Math.ceil((GROUP_FILE_LIMITS.nameChars * 4 + GROUP_FILE_LIMITS.mimeChars + 17e3) * 4 / 3) + 4;
|
|
24523
|
+
var fileAad = (f) => JSON.stringify([
|
|
24524
|
+
"ghostly-group/1 file",
|
|
24525
|
+
f.g,
|
|
24526
|
+
f.e,
|
|
24527
|
+
f.s,
|
|
24528
|
+
f.n,
|
|
24529
|
+
f.ts
|
|
24530
|
+
]);
|
|
24531
|
+
var isFileBox = isSealedBox(MAX_FILE_BOX);
|
|
23171
24532
|
var B64$1 = /^[A-Za-z0-9_-]*$/;
|
|
23172
24533
|
var secretAad$1 = (g, e, member) => JSON.stringify([
|
|
23173
24534
|
"ghostly-group/1 secret",
|
|
@@ -23216,6 +24577,11 @@ var messageSignedWhole = (f) => utf8Encode(JSON.stringify([
|
|
|
23216
24577
|
f.sc.n,
|
|
23217
24578
|
f.sc.c
|
|
23218
24579
|
] : [],
|
|
24580
|
+
...f.fl ? [
|
|
24581
|
+
"fl",
|
|
24582
|
+
f.fl.n,
|
|
24583
|
+
f.fl.c
|
|
24584
|
+
] : [],
|
|
23219
24585
|
...f.o ? [
|
|
23220
24586
|
"o",
|
|
23221
24587
|
f.o.e,
|
|
@@ -23294,7 +24660,7 @@ function readFirstPlace(o, epoch) {
|
|
|
23294
24660
|
/** One of my own frames, among mine: `e:n`. */
|
|
23295
24661
|
var ownKey = (f) => `${f.e}:${f.n}`;
|
|
23296
24662
|
/** What a frame weighs in a log, roughly: its boxes plus the fixed fields around them. */
|
|
23297
|
-
var frameBytes = (f) => f.c.length + (f.m?.c.length ?? 0) + (f.r?.c.length ?? 0) + (f.sc?.c.length ?? 0) + 400;
|
|
24663
|
+
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;
|
|
23298
24664
|
/** Only the fields an edit frame has, as `clean` does for a message. */
|
|
23299
24665
|
var cleanEdit = (f) => ({
|
|
23300
24666
|
t: GROUP_EDIT_FRAME,
|
|
@@ -23333,6 +24699,10 @@ function clean(f) {
|
|
|
23333
24699
|
n: f.sc.n,
|
|
23334
24700
|
c: f.sc.c
|
|
23335
24701
|
} } : {},
|
|
24702
|
+
...isFileBox(f.fl) ? { fl: {
|
|
24703
|
+
n: f.fl.n,
|
|
24704
|
+
c: f.fl.c
|
|
24705
|
+
} } : {},
|
|
23336
24706
|
...readFirstPlace(f.o, f.e) ? { o: readFirstPlace(f.o, f.e) } : {},
|
|
23337
24707
|
...typeof f.xs === "string" && f.xs.length === 86 && B64$1.test(f.xs) ? { xs: f.xs } : {}
|
|
23338
24708
|
};
|
|
@@ -23409,6 +24779,8 @@ var GroupSession = class {
|
|
|
23409
24779
|
passOn = null;
|
|
23410
24780
|
/** Syncs answered per member, a few a minute (`GROUP_LIMITS.syncAnswers`). In memory only. */
|
|
23411
24781
|
syncsAnswered = /* @__PURE__ */ new Map();
|
|
24782
|
+
/** Catch-up answers still going out, a slice at a time (`handOut`). */
|
|
24783
|
+
answers;
|
|
23412
24784
|
/**
|
|
23413
24785
|
* Messages delivered from a copy that was not whole (handed on without its author's whole signature): not seen, so
|
|
23414
24786
|
* a sync still asks for them and a whole copy completes them. Oldest first, saved as `GroupState.provisional`, so a
|
|
@@ -23421,6 +24793,11 @@ var GroupSession = class {
|
|
|
23421
24793
|
this.hooks = hooks;
|
|
23422
24794
|
this.state = state;
|
|
23423
24795
|
this.identity = identityFromSeedB64(state.seedB64);
|
|
24796
|
+
this.answers = new CatchUpAnswers({
|
|
24797
|
+
send: (to, frame) => hooks.send(to, frame),
|
|
24798
|
+
...hooks.handled && { handled: (to) => hooks.handled(to) },
|
|
24799
|
+
active: () => this.state.status === "active"
|
|
24800
|
+
});
|
|
23424
24801
|
this.provisional = new Set((state.provisional ?? []).slice(-GROUP_LIMITS.provisional));
|
|
23425
24802
|
this.chainMovedAt = this.clock();
|
|
23426
24803
|
}
|
|
@@ -23545,6 +24922,15 @@ var GroupSession = class {
|
|
|
23545
24922
|
get others() {
|
|
23546
24923
|
return this.roster.map(([k]) => k).filter((k) => k !== this.myKey);
|
|
23547
24924
|
}
|
|
24925
|
+
/**
|
|
24926
|
+
* Whether `key` could read message `messageId` (`<s>:<e>:<n>`) and still can: in the roster of its epoch, and in the
|
|
24927
|
+
* roster now. A holder serves a group file only to such a member (WISP 503 § Asking).
|
|
24928
|
+
*/
|
|
24929
|
+
couldRead(key, messageId) {
|
|
24930
|
+
const e = Number(messageId.split(":")[1]);
|
|
24931
|
+
const then = this.state.chain.find((c) => c.e === e);
|
|
24932
|
+
return !!then && rosterHas(then.m, key) && rosterHas(this.roster, key);
|
|
24933
|
+
}
|
|
23548
24934
|
role(key) {
|
|
23549
24935
|
return this.roster.find(([k]) => k === key)?.[1];
|
|
23550
24936
|
}
|
|
@@ -23793,14 +25179,18 @@ var GroupSession = class {
|
|
|
23793
25179
|
* text that name members (everyone: the admin only); what does not hold is left out. `reply`: the message it
|
|
23794
25180
|
* answers, sealed apart from the text (`r`) like the mentions, so an older app still reads the text. `forwarded`: the
|
|
23795
25181
|
* hop count of a forwarded text (`f`, WISP 902 § Forwards). `card`: a status card, checked by the caller, sealed in a
|
|
23796
|
-
* box of its own (`sc`, WISP 405 · Status Cards); the text is its fallback.
|
|
25182
|
+
* box of its own (`sc`, WISP 405 · Status Cards); the text is its fallback. `file`: a file or voice message the text
|
|
25183
|
+
* announces (`fl`, WISP 503 · Group Files), its description checked by the caller; the text is its caption, or the
|
|
25184
|
+
* line an older app shows.
|
|
23797
25185
|
*/
|
|
23798
|
-
sendText(text, now = Date.now(), mentions = [], reply, forwarded, card) {
|
|
25186
|
+
sendText(text, now = Date.now(), mentions = [], reply, forwarded, card, file) {
|
|
23799
25187
|
return this.serialize(async () => {
|
|
23800
25188
|
if (this.state.status !== "active") return { error: this.state.statusReason ?? "You are no longer in this group" };
|
|
23801
25189
|
const trimmed = text.trim();
|
|
23802
25190
|
if (!trimmed) return { error: "Nothing to send" };
|
|
23803
25191
|
if (utf8Encode(trimmed).length > GROUP_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
|
|
25192
|
+
const described = file === void 0 ? void 0 : readGroupFileMeta(file);
|
|
25193
|
+
if (file !== void 0 && !described) return { error: "That file cannot go to a group" };
|
|
23804
25194
|
const epoch = this.epoch;
|
|
23805
25195
|
if (!this.secret(epoch)) return { error: "This epoch's key has not arrived yet. Wait for a member to catch you up." };
|
|
23806
25196
|
const n = this.nextSeq(epoch);
|
|
@@ -23811,7 +25201,8 @@ var GroupSession = class {
|
|
|
23811
25201
|
mentions: named,
|
|
23812
25202
|
reply: answers || void 0,
|
|
23813
25203
|
forwarded: fw,
|
|
23814
|
-
card
|
|
25204
|
+
card,
|
|
25205
|
+
file: described ?? void 0
|
|
23815
25206
|
});
|
|
23816
25207
|
this.logOwn(frame);
|
|
23817
25208
|
const id = groupMessageId(this.myKey, epoch, n);
|
|
@@ -23825,7 +25216,8 @@ var GroupSession = class {
|
|
|
23825
25216
|
...named.length ? { mentions: named } : {},
|
|
23826
25217
|
...answers ? { reply: answers } : {},
|
|
23827
25218
|
...fw ? { forwarded: fw } : {},
|
|
23828
|
-
...card ? { card } : {}
|
|
25219
|
+
...card ? { card } : {},
|
|
25220
|
+
...described ? { file: described } : {}
|
|
23829
25221
|
});
|
|
23830
25222
|
await this.say(frame);
|
|
23831
25223
|
return { id };
|
|
@@ -23866,6 +25258,7 @@ var GroupSession = class {
|
|
|
23866
25258
|
...extra.reply ? { r: encryptText(key, replyAad(header), JSON.stringify(extra.reply)) } : {},
|
|
23867
25259
|
...extra.forwarded ? { f: extra.forwarded } : {},
|
|
23868
25260
|
...extra.card ? { sc: encryptText(key, cardAad(header), JSON.stringify(extra.card)) } : {},
|
|
25261
|
+
...extra.file ? { fl: encryptText(key, fileAad(header), JSON.stringify(extra.file)) } : {},
|
|
23869
25262
|
...o ? { o } : {}
|
|
23870
25263
|
};
|
|
23871
25264
|
return {
|
|
@@ -23935,7 +25328,8 @@ var GroupSession = class {
|
|
|
23935
25328
|
mentions,
|
|
23936
25329
|
reply: this.openReply(key, f),
|
|
23937
25330
|
forwarded: readForwarded(f.f) ? f.f : void 0,
|
|
23938
|
-
card: this.openCard(key, f)
|
|
25331
|
+
card: this.openCard(key, f),
|
|
25332
|
+
file: this.openFile(key, f)
|
|
23939
25333
|
}, readFirstPlace(f.o, f.e) ?? {
|
|
23940
25334
|
e: f.e,
|
|
23941
25335
|
n: f.n
|
|
@@ -24270,6 +25664,7 @@ var GroupSession = class {
|
|
|
24270
25664
|
delete raw.r;
|
|
24271
25665
|
delete raw.f;
|
|
24272
25666
|
delete raw.sc;
|
|
25667
|
+
delete raw.fl;
|
|
24273
25668
|
delete raw.o;
|
|
24274
25669
|
delete raw.xs;
|
|
24275
25670
|
}
|
|
@@ -24300,6 +25695,7 @@ var GroupSession = class {
|
|
|
24300
25695
|
const reply = this.openReply(key, raw);
|
|
24301
25696
|
const forwarded = readForwarded(raw.f);
|
|
24302
25697
|
const card = this.openCard(key, raw);
|
|
25698
|
+
const file = this.openFile(key, raw);
|
|
24303
25699
|
const completes = whole && this.provisional.delete(id);
|
|
24304
25700
|
await this.hooks.message({
|
|
24305
25701
|
id,
|
|
@@ -24312,6 +25708,7 @@ var GroupSession = class {
|
|
|
24312
25708
|
...reply ? { reply } : {},
|
|
24313
25709
|
...forwarded ? { forwarded } : {},
|
|
24314
25710
|
...card ? { card } : {},
|
|
25711
|
+
...file ? { file } : {},
|
|
24315
25712
|
...completes ? { completes: true } : {}
|
|
24316
25713
|
});
|
|
24317
25714
|
if (!whole) {
|
|
@@ -24413,6 +25810,17 @@ var GroupSession = class {
|
|
|
24413
25810
|
return;
|
|
24414
25811
|
}
|
|
24415
25812
|
}
|
|
25813
|
+
/** The file a message announces (WISP 503); a box that does not open, or a description that does not hold, is no file. */
|
|
25814
|
+
openFile(key, raw) {
|
|
25815
|
+
if (!isFileBox(raw.fl)) return void 0;
|
|
25816
|
+
const plain = decryptText(key, fileAad(raw), raw.fl.n, raw.fl.c);
|
|
25817
|
+
if (plain === null) return void 0;
|
|
25818
|
+
try {
|
|
25819
|
+
return readGroupFileMeta(JSON.parse(plain)) ?? void 0;
|
|
25820
|
+
} catch {
|
|
25821
|
+
return;
|
|
25822
|
+
}
|
|
25823
|
+
}
|
|
24416
25824
|
/** The message a text answers; a box that does not open, or does not hold one, is no reply, not no message. */
|
|
24417
25825
|
openReply(key, raw) {
|
|
24418
25826
|
if (!isReplyBox(raw.r)) return void 0;
|
|
@@ -24566,9 +25974,10 @@ var GroupSession = class {
|
|
|
24566
25974
|
});
|
|
24567
25975
|
return;
|
|
24568
25976
|
}
|
|
25977
|
+
const answer = [];
|
|
24569
25978
|
for (let e = frame.e + 1; e <= this.epoch; e++) {
|
|
24570
25979
|
const commit = this.state.chain[e], secret = this.secret(e);
|
|
24571
|
-
|
|
25980
|
+
answer.push({
|
|
24572
25981
|
t: "group-commit",
|
|
24573
25982
|
g: this.id,
|
|
24574
25983
|
commit,
|
|
@@ -24580,7 +25989,7 @@ var GroupSession = class {
|
|
|
24580
25989
|
e,
|
|
24581
25990
|
s: sealSecret(from, this.secret(e), secretAad$1(this.id, e, from))
|
|
24582
25991
|
}));
|
|
24583
|
-
for (let i = 0; i < secrets.length; i += GROUP_LIMITS.secretsPerFrame)
|
|
25992
|
+
for (let i = 0; i < secrets.length; i += GROUP_LIMITS.secretsPerFrame) answer.push({
|
|
24584
25993
|
t: "group-secrets",
|
|
24585
25994
|
g: this.id,
|
|
24586
25995
|
secrets: secrets.slice(i, i + GROUP_LIMITS.secretsPerFrame)
|
|
@@ -24588,12 +25997,24 @@ var GroupSession = class {
|
|
|
24588
25997
|
const lacks = missingIn(frame, from);
|
|
24589
25998
|
const asked = new Set(Array.isArray(frame.ask) ? frame.ask.filter((k) => typeof k === "string" && k !== from && k !== this.myKey && rosterHas(this.roster, k)) : []);
|
|
24590
25999
|
const kept = asked.size ? (this.state.relay ?? []).filter((f) => asked.has(f.s) && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from) && lacks(f)) : [];
|
|
24591
|
-
|
|
26000
|
+
answer.push(...asWritten(this.ownLacked(from, frame), kept));
|
|
24592
26001
|
for (const author of asked) {
|
|
24593
26002
|
const edits = (this.state.relayEdits ?? []).filter((f) => f.s === author && this.state.chain[f.e] && rosterHas(this.state.chain[f.e].m, from));
|
|
24594
|
-
|
|
26003
|
+
answer.push(...edits.slice(-GROUP_LIMITS.handOnEdits));
|
|
24595
26004
|
}
|
|
24596
|
-
this.
|
|
26005
|
+
const meta = this.metaFor(from, frame.mt);
|
|
26006
|
+
if (meta) answer.push(meta);
|
|
26007
|
+
this.handOut(from, answer);
|
|
26008
|
+
}
|
|
26009
|
+
/**
|
|
26010
|
+
* A catch-up answer, a slice at a time (`GROUP_LIMITS.catchUpSlice`), with two slices at most not handled yet by the
|
|
26011
|
+
* member's app (`hooks.handled`), as a community's goes (`CatchUpAnswers`). All at once, a member back in a busy group
|
|
26012
|
+
* was handed over a hundred frames on one session, and an app that holds 64 waiting ended that session mid catch-up
|
|
26013
|
+
* ("Session receive limit exceeded", 2026-10-07). A newer answer to the same member takes the place of what is left of
|
|
26014
|
+
* the last; a frame the edge did not take ends it (the member asks again when its edge opens).
|
|
26015
|
+
*/
|
|
26016
|
+
handOut(to, frames) {
|
|
26017
|
+
this.answers.handOut(to, frames);
|
|
24597
26018
|
}
|
|
24598
26019
|
/** My own messages a member's sync says it lacks, from my bounded log, for the epochs it was a member of. */
|
|
24599
26020
|
handOwn(to, frame) {
|
|
@@ -24672,10 +26093,14 @@ var GroupSession = class {
|
|
|
24672
26093
|
}
|
|
24673
26094
|
/** My statement, to a member whose sync says it holds an older one (or none); nothing to an app that says nothing. */
|
|
24674
26095
|
offerMeta(to, theirTag) {
|
|
26096
|
+
const frame = this.metaFor(to, theirTag);
|
|
26097
|
+
if (frame) this.hooks.send(to, frame);
|
|
26098
|
+
}
|
|
26099
|
+
metaFor(to, theirTag) {
|
|
24675
26100
|
const meta = this.state.meta, theirs = parseGroupMetaTag(theirTag);
|
|
24676
|
-
if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !rosterHas(this.roster, to)) return;
|
|
26101
|
+
if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !rosterHas(this.roster, to)) return null;
|
|
24677
26102
|
const secret = this.secret(this.epoch);
|
|
24678
|
-
|
|
26103
|
+
return secret ? wrapGroupMeta(meta, this.epoch, epochKeys(secret, this.id, this.epoch).message) : null;
|
|
24679
26104
|
}
|
|
24680
26105
|
/**
|
|
24681
26106
|
* A metadata statement: kept when it is newer than mine, its commit is on my chain, its signer was
|
|
@@ -24959,7 +26384,15 @@ var COMMUNITY_LIMITS = {
|
|
|
24959
26384
|
* waits for the window.
|
|
24960
26385
|
*/
|
|
24961
26386
|
syncAnswers: 16,
|
|
24962
|
-
syncWindowMs: 6e4
|
|
26387
|
+
syncWindowMs: 6e4,
|
|
26388
|
+
/**
|
|
26389
|
+
* Frames of one catch-up answer in a slice (`CommunitySessionHooks.handled`), two slices at most not handled yet by
|
|
26390
|
+
* the member's app: a member back after the store filled was handed 258 at once, and an app holds 64 waiting at most
|
|
26391
|
+
* before 2026-10-07 (more ends the session). As a private group's (`GROUP_LIMITS.catchUpSlice`).
|
|
26392
|
+
*/
|
|
26393
|
+
catchUpSlice: 16,
|
|
26394
|
+
/** Between two slices, when the member's app cannot say it handled one (`handled` resolves `false`). */
|
|
26395
|
+
catchUpPauseMs: 500
|
|
24963
26396
|
};
|
|
24964
26397
|
/** What every member should know about who can read what, and what the link does, in the words the apps show. */
|
|
24965
26398
|
var GROUP_READ_NOTE_COMMUNITY = `Anyone with the group's link can join, and any member can let people in. Everyone in the group can read everything sent while they are a member; someone removed cannot read what comes after, someone who joins later cannot read what came before. Members who were away get the last ${COMMUNITY_LIMITS.store} messages from whoever is there when they return.`;
|
|
@@ -25184,7 +26617,7 @@ var pairAad = (f, to) => JSON.stringify([
|
|
|
25184
26617
|
f.ts,
|
|
25185
26618
|
to
|
|
25186
26619
|
]);
|
|
25187
|
-
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
26620
|
+
var isObject$2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
25188
26621
|
var messageSigned = (f) => utf8Encode(JSON.stringify([
|
|
25189
26622
|
"ghostly-group/2 msg",
|
|
25190
26623
|
f.g,
|
|
@@ -25269,6 +26702,8 @@ var CommunitySession = class {
|
|
|
25269
26702
|
asked = /* @__PURE__ */ new Map();
|
|
25270
26703
|
/** Syncs answered per member, a few a minute (`COMMUNITY_LIMITS.syncAnswers`). In memory only. */
|
|
25271
26704
|
syncsAnswered = /* @__PURE__ */ new Map();
|
|
26705
|
+
/** Catch-up answers still going out, a slice at a time (`receiveSync`). */
|
|
26706
|
+
answers;
|
|
25272
26707
|
queue = Promise.resolve();
|
|
25273
26708
|
/** When the commit I follow last changed (`adopt`), by `hooks.clock`. */
|
|
25274
26709
|
chainMovedAt = 0;
|
|
@@ -25281,6 +26716,11 @@ var CommunitySession = class {
|
|
|
25281
26716
|
this.hooks = hooks;
|
|
25282
26717
|
this.state = state;
|
|
25283
26718
|
this.identity = identityFromSeedB64(state.seedB64);
|
|
26719
|
+
this.answers = new CatchUpAnswers({
|
|
26720
|
+
send: (to, frame) => hooks.direct(to, frame),
|
|
26721
|
+
...hooks.handled && { handled: (to) => hooks.handled(to) },
|
|
26722
|
+
active: () => this.state.status === "active"
|
|
26723
|
+
});
|
|
25284
26724
|
this.rebuild();
|
|
25285
26725
|
if ((state.status === "active" || state.status === "lost") && this.bestBranch().tip !== this.topHash) this.serialize(() => this.chooseBranch());
|
|
25286
26726
|
}
|
|
@@ -25454,6 +26894,15 @@ var CommunitySession = class {
|
|
|
25454
26894
|
for (const roster of this.rosters.values()) if (rosterHas(roster, key)) return true;
|
|
25455
26895
|
return false;
|
|
25456
26896
|
}
|
|
26897
|
+
/**
|
|
26898
|
+
* Whether `key` could read message `messageId` (`<s>:<e>:<h>:<n>`) and still can: a member of the roster it was sealed
|
|
26899
|
+
* for, and of the roster now. A holder serves a group file only to such a member (WISP 503 § Asking).
|
|
26900
|
+
*/
|
|
26901
|
+
couldRead(key, messageId) {
|
|
26902
|
+
const [, e, h] = messageId.split(":");
|
|
26903
|
+
const found = h === void 0 ? void 0 : this.commitByShort(Number(e), h);
|
|
26904
|
+
return !!found && rosterHas(this.rosterAt(found.hash) ?? [], key) && rosterHas(this.roster, key);
|
|
26905
|
+
}
|
|
25457
26906
|
/** Someone the chain took out (removed or left) and who is not back in. */
|
|
25458
26907
|
wasRemoved(key) {
|
|
25459
26908
|
return this.outIndex(key) >= 0;
|
|
@@ -26071,23 +27520,29 @@ var CommunitySession = class {
|
|
|
26071
27520
|
* count against the text's 16 KiB, so the box stays within what older apps accept. `reply`: the message it
|
|
26072
27521
|
* answers (`r`), counted the same way. `forwarded`: a forwarded text's hop count (`fw`, WISP 903 § Forwards). `card`:
|
|
26073
27522
|
* a status card, checked by the caller (`sc`, WISP 405 · Status Cards), counted the same way; the text is its fallback.
|
|
27523
|
+
* `file`: a file or voice message the text announces (`fl`, WISP 503 · Group Files), counted the same way; the text is
|
|
27524
|
+
* its caption, or the line an older app shows.
|
|
26074
27525
|
*/
|
|
26075
|
-
sendText(text, nick, now = Date.now(), mentions = [], reply, forwarded, card) {
|
|
27526
|
+
sendText(text, nick, now = Date.now(), mentions = [], reply, forwarded, card, file) {
|
|
26076
27527
|
return this.serialize(async () => {
|
|
26077
27528
|
if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
|
|
26078
27529
|
const trimmed = text.trim();
|
|
26079
27530
|
if (!trimmed) return { error: "Nothing to send" };
|
|
27531
|
+
const described = file === void 0 ? void 0 : readGroupFileMeta(file);
|
|
27532
|
+
if (file !== void 0 && !described) return { error: "That file cannot go to a group" };
|
|
26080
27533
|
const named = validMentions(wireMentions(mentions), trimmed, false);
|
|
26081
27534
|
const answers = reply && readReply(wireReply(reply), groupReplyAuthor);
|
|
26082
27535
|
const replyBytes = answers ? utf8Encode(JSON.stringify(answers)).length : 0;
|
|
27536
|
+
const fileBytes = described ? utf8Encode(JSON.stringify(described)).length + 8 : 0;
|
|
26083
27537
|
const hops = readForwarded(forwarded);
|
|
26084
|
-
if (utf8Encode(trimmed).length + mentionsBytes(named) + replyBytes + (hops ? 16 : 0) + (card ? statusCardBytes(card) + 8 : 0) > COMMUNITY_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
|
|
27538
|
+
if (utf8Encode(trimmed).length + mentionsBytes(named) + replyBytes + fileBytes + (hops ? 16 : 0) + (card ? statusCardBytes(card) + 8 : 0) > COMMUNITY_LIMITS.textBytes) return { error: "Message exceeds 16 KiB" };
|
|
26085
27539
|
const sent = await this.sendPayload(() => ({
|
|
26086
27540
|
text: trimmed,
|
|
26087
27541
|
...named.length ? { m: named } : {},
|
|
26088
27542
|
...answers ? { r: answers } : {},
|
|
26089
27543
|
...hops ? { fw: hops } : {},
|
|
26090
|
-
...card ? { sc: card } : {}
|
|
27544
|
+
...card ? { sc: card } : {},
|
|
27545
|
+
...described ? { fl: described } : {}
|
|
26091
27546
|
}), nick, now);
|
|
26092
27547
|
if ("error" in sent) return sent;
|
|
26093
27548
|
await this.hooks.message({
|
|
@@ -26100,7 +27555,8 @@ var CommunitySession = class {
|
|
|
26100
27555
|
...named.length ? { mentions: named } : {},
|
|
26101
27556
|
...answers ? { reply: answers } : {},
|
|
26102
27557
|
...hops ? { forwarded: hops } : {},
|
|
26103
|
-
...card ? { card } : {}
|
|
27558
|
+
...card ? { card } : {},
|
|
27559
|
+
...described ? { file: described } : {}
|
|
26104
27560
|
});
|
|
26105
27561
|
await this.say(sent.frame);
|
|
26106
27562
|
return { id: sent.id };
|
|
@@ -26114,7 +27570,7 @@ var CommunitySession = class {
|
|
|
26114
27570
|
sendApp(frame, nick, now = Date.now()) {
|
|
26115
27571
|
return this.serialize(async () => {
|
|
26116
27572
|
if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
|
|
26117
|
-
if (!isObject(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
27573
|
+
if (!isObject$2(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
26118
27574
|
const sent = await this.sendPayload(() => ({ x: frame }), nick, now);
|
|
26119
27575
|
if ("error" in sent) return sent;
|
|
26120
27576
|
await this.say(sent.frame);
|
|
@@ -26163,7 +27619,7 @@ var CommunitySession = class {
|
|
|
26163
27619
|
return this.serialize(async () => {
|
|
26164
27620
|
if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
|
|
26165
27621
|
if (to === this.myKey || !rosterHas(this.roster, to)) return { error: "Not a member of this group" };
|
|
26166
|
-
if (!isObject(payload)) return { error: "Nothing to send" };
|
|
27622
|
+
if (!isObject$2(payload)) return { error: "Nothing to send" };
|
|
26167
27623
|
const plain = utf8Encode(JSON.stringify(payload));
|
|
26168
27624
|
if (plain.length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
26169
27625
|
const sent = await this.sendPayload((header) => ({ p: {
|
|
@@ -26276,7 +27732,7 @@ var CommunitySession = class {
|
|
|
26276
27732
|
try {
|
|
26277
27733
|
parsed = plain === null ? null : JSON.parse(plain);
|
|
26278
27734
|
} catch {}
|
|
26279
|
-
if (!isObject(parsed) || isObject(parsed.p)) continue;
|
|
27735
|
+
if (!isObject$2(parsed) || isObject$2(parsed.p)) continue;
|
|
26280
27736
|
const o = readOriginal(parsed.o, f) ?? {
|
|
26281
27737
|
e: f.e,
|
|
26282
27738
|
h: f.h,
|
|
@@ -26424,7 +27880,7 @@ var CommunitySession = class {
|
|
|
26424
27880
|
} catch {
|
|
26425
27881
|
return false;
|
|
26426
27882
|
}
|
|
26427
|
-
if (!isObject(parsed)) return false;
|
|
27883
|
+
if (!isObject$2(parsed)) return false;
|
|
26428
27884
|
const original = parsed.o === void 0 ? null : readOriginal(parsed.o, raw);
|
|
26429
27885
|
if (parsed.o !== void 0 && !original) return false;
|
|
26430
27886
|
const first = original ? {
|
|
@@ -26442,8 +27898,8 @@ var CommunitySession = class {
|
|
|
26442
27898
|
const text = typeof parsed.text === "string" ? parsed.text.slice(0, COMMUNITY_LIMITS.textBytes) : void 0;
|
|
26443
27899
|
if ([
|
|
26444
27900
|
text !== void 0,
|
|
26445
|
-
isObject(parsed.x),
|
|
26446
|
-
isObject(parsed.p)
|
|
27901
|
+
isObject$2(parsed.x),
|
|
27902
|
+
isObject$2(parsed.p)
|
|
26447
27903
|
].filter(Boolean).length !== 1) return false;
|
|
26448
27904
|
const nick = typeof parsed.nick === "string" ? sanitizeNick(parsed.nick) : void 0;
|
|
26449
27905
|
if (nick && this.state.nicks[raw.s] !== nick) {
|
|
@@ -26456,6 +27912,7 @@ var CommunitySession = class {
|
|
|
26456
27912
|
const reply = parsed.r === void 0 ? void 0 : readReply(parsed.r, groupReplyAuthor);
|
|
26457
27913
|
const forwarded = readForwarded(parsed.fw);
|
|
26458
27914
|
const card = parsed.sc === void 0 ? void 0 : readStatusCard(parsed.sc);
|
|
27915
|
+
const file = parsed.fl === void 0 ? void 0 : readGroupFileMeta(parsed.fl) ?? void 0;
|
|
26459
27916
|
await this.hooks.message({
|
|
26460
27917
|
id,
|
|
26461
27918
|
sender: raw.s,
|
|
@@ -26466,9 +27923,10 @@ var CommunitySession = class {
|
|
|
26466
27923
|
...mentions.length ? { mentions } : {},
|
|
26467
27924
|
...reply ? { reply } : {},
|
|
26468
27925
|
...forwarded ? { forwarded } : {},
|
|
26469
|
-
...card ? { card } : {}
|
|
27926
|
+
...card ? { card } : {},
|
|
27927
|
+
...file ? { file } : {}
|
|
26470
27928
|
});
|
|
26471
|
-
} else if (isObject(parsed.x)) await this.hooks.app?.({
|
|
27929
|
+
} else if (isObject$2(parsed.x)) await this.hooks.app?.({
|
|
26472
27930
|
id,
|
|
26473
27931
|
sender: raw.s,
|
|
26474
27932
|
epoch: raw.e,
|
|
@@ -26484,7 +27942,7 @@ var CommunitySession = class {
|
|
|
26484
27942
|
try {
|
|
26485
27943
|
opened = plain && plain.length <= COMMUNITY_LIMITS.appBytes ? JSON.parse(utf8Decode(plain)) : null;
|
|
26486
27944
|
} catch {}
|
|
26487
|
-
if (isObject(opened)) await this.hooks.pair?.({
|
|
27945
|
+
if (isObject$2(opened)) await this.hooks.pair?.({
|
|
26488
27946
|
id,
|
|
26489
27947
|
sender: raw.s,
|
|
26490
27948
|
epoch: raw.e,
|
|
@@ -26558,10 +28016,15 @@ var CommunitySession = class {
|
|
|
26558
28016
|
this.ask(to);
|
|
26559
28017
|
}
|
|
26560
28018
|
ask(from) {
|
|
28019
|
+
const sync = this.question(from);
|
|
28020
|
+
if (sync) this.hooks.direct(from, sync);
|
|
28021
|
+
}
|
|
28022
|
+
/** My sync for a member, unless I asked it a moment ago. */
|
|
28023
|
+
question(from) {
|
|
26561
28024
|
const now = this.hooks.clock?.() ?? Date.now();
|
|
26562
|
-
if (now - (this.asked.get(from) ?? 0) < 5e3) return;
|
|
28025
|
+
if (now - (this.asked.get(from) ?? 0) < 5e3) return null;
|
|
26563
28026
|
this.asked.set(from, now);
|
|
26564
|
-
|
|
28027
|
+
return this.syncFrame();
|
|
26565
28028
|
}
|
|
26566
28029
|
async replayWaiting() {
|
|
26567
28030
|
await this.metaFollowsChain();
|
|
@@ -26579,6 +28042,11 @@ var CommunitySession = class {
|
|
|
26579
28042
|
let answered = this.syncsAnswered.get(from);
|
|
26580
28043
|
if (!answered) this.syncsAnswered.set(from, answered = new RateWindow(COMMUNITY_LIMITS.syncAnswers, COMMUNITY_LIMITS.syncWindowMs, () => this.hooks.clock?.() ?? Date.now()));
|
|
26581
28044
|
if (!answered.take()) return;
|
|
28045
|
+
const answer = [];
|
|
28046
|
+
const ask = () => {
|
|
28047
|
+
const sync = this.question(from);
|
|
28048
|
+
if (sync) answer.push(sync);
|
|
28049
|
+
};
|
|
26582
28050
|
const theirs = this.known.get(frame.h);
|
|
26583
28051
|
let start;
|
|
26584
28052
|
if (theirs && this.mainIndex.has(frame.h)) start = this.mainIndex.get(frame.h) + 1;
|
|
@@ -26588,18 +28056,18 @@ var CommunitySession = class {
|
|
|
26588
28056
|
} else {
|
|
26589
28057
|
const common = (Array.isArray(frame.loc) ? frame.loc : []).find((x) => typeof x === "string" && this.mainIndex.has(x));
|
|
26590
28058
|
start = common !== void 0 ? this.mainIndex.get(common) + 1 : Math.max(0, this.state.chain.length - COMMUNITY_LIMITS.side);
|
|
26591
|
-
|
|
28059
|
+
ask();
|
|
26592
28060
|
}
|
|
26593
28061
|
for (let i = start; i < this.state.chain.length; i++) {
|
|
26594
28062
|
const commit = this.state.chain[i], h = communityCommitHash(commit);
|
|
26595
|
-
|
|
28063
|
+
answer.push({
|
|
26596
28064
|
t: "group-commit",
|
|
26597
28065
|
v: 2,
|
|
26598
28066
|
g: this.id,
|
|
26599
28067
|
commit
|
|
26600
28068
|
});
|
|
26601
28069
|
const secret = this.state.secrets[h];
|
|
26602
|
-
if (secret && rosterHas(this.rosterAt(h) ?? [], from) && FRESH.has(commit.k))
|
|
28070
|
+
if (secret && rosterHas(this.rosterAt(h) ?? [], from) && FRESH.has(commit.k)) answer.push({
|
|
26603
28071
|
t: "group-secret",
|
|
26604
28072
|
v: 2,
|
|
26605
28073
|
g: this.id,
|
|
@@ -26613,7 +28081,7 @@ var CommunitySession = class {
|
|
|
26613
28081
|
h,
|
|
26614
28082
|
s: sealSecret(from, fromBase64Url(this.state.secrets[h]), secretAad(this.id, h, from))
|
|
26615
28083
|
}));
|
|
26616
|
-
if (secrets.length)
|
|
28084
|
+
if (secrets.length) answer.push({
|
|
26617
28085
|
t: "group-secrets",
|
|
26618
28086
|
v: 2,
|
|
26619
28087
|
g: this.id,
|
|
@@ -26630,8 +28098,8 @@ var CommunitySession = class {
|
|
|
26630
28098
|
if (!reads && !(mine && rosterHas(this.roster, from))) continue;
|
|
26631
28099
|
const high = have[stored.s]?.[seenKey(stored.e, stored.h)];
|
|
26632
28100
|
if (Number.isSafeInteger(high) && high >= stored.n) continue;
|
|
26633
|
-
if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed);
|
|
26634
|
-
|
|
28101
|
+
if (!this.mainIndex.has(found.hash)) this.handSide(from, found.hash, handed, answer);
|
|
28102
|
+
answer.push(stored);
|
|
26635
28103
|
if (mine && reads) unheard.delete(frameKey(stored));
|
|
26636
28104
|
}
|
|
26637
28105
|
if (unheard.size !== (this.state.unheard?.length ?? 0)) {
|
|
@@ -26640,30 +28108,32 @@ var CommunitySession = class {
|
|
|
26640
28108
|
}
|
|
26641
28109
|
if (rosterHas(this.roster, from)) {
|
|
26642
28110
|
const entry = this.entryFrame(from);
|
|
26643
|
-
if (entry)
|
|
28111
|
+
if (entry) answer.push(entry);
|
|
26644
28112
|
}
|
|
26645
|
-
for (const request of this.state.pendingLeaves)
|
|
28113
|
+
for (const request of this.state.pendingLeaves) answer.push({
|
|
26646
28114
|
t: "group-leave",
|
|
26647
28115
|
v: 2,
|
|
26648
28116
|
g: this.id,
|
|
26649
28117
|
...request
|
|
26650
28118
|
});
|
|
26651
|
-
this.
|
|
26652
|
-
|
|
28119
|
+
const meta = this.metaFor(from, frame.mt);
|
|
28120
|
+
if (meta) answer.push(meta);
|
|
28121
|
+
ask();
|
|
28122
|
+
this.answers.handOut(from, answer);
|
|
26653
28123
|
}
|
|
26654
|
-
/** The commits of a branch I do not follow, from where it leaves mine, and the secrets I hold of those `to` was in
|
|
26655
|
-
handSide(to, tip, handed) {
|
|
28124
|
+
/** The commits of a branch I do not follow, from where it leaves mine, and the secrets I hold of those `to` was in, onto `answer`. */
|
|
28125
|
+
handSide(to, tip, handed, answer) {
|
|
26656
28126
|
for (const h of this.pathToMain(tip) ?? []) {
|
|
26657
28127
|
if (handed.has(h)) continue;
|
|
26658
28128
|
handed.add(h);
|
|
26659
|
-
|
|
28129
|
+
answer.push({
|
|
26660
28130
|
t: "group-commit",
|
|
26661
28131
|
v: 2,
|
|
26662
28132
|
g: this.id,
|
|
26663
28133
|
commit: this.known.get(h)
|
|
26664
28134
|
});
|
|
26665
28135
|
const secret = this.state.secrets[h];
|
|
26666
|
-
if (secret && rosterHas(this.rosterAt(h) ?? [], to))
|
|
28136
|
+
if (secret && rosterHas(this.rosterAt(h) ?? [], to)) answer.push({
|
|
26667
28137
|
t: "group-secret",
|
|
26668
28138
|
v: 2,
|
|
26669
28139
|
g: this.id,
|
|
@@ -26762,12 +28232,11 @@ var CommunitySession = class {
|
|
|
26762
28232
|
const meta = this.state.meta, secret = this.state.secrets[this.topHash];
|
|
26763
28233
|
return meta && secret ? wrapGroupMeta(meta, this.topHash, epochKeys(fromBase64Url(secret), this.id, this.epoch).message, true) : null;
|
|
26764
28234
|
}
|
|
26765
|
-
/** My statement,
|
|
26766
|
-
|
|
28235
|
+
/** My statement, for a member whose sync says it holds an older one (or none); none for an app that says nothing. */
|
|
28236
|
+
metaFor(to, theirTag) {
|
|
26767
28237
|
const meta = this.state.meta, theirs = parseGroupMetaTag(theirTag);
|
|
26768
|
-
if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !this.isMember || !rosterHas(this.roster, to)) return;
|
|
26769
|
-
|
|
26770
|
-
if (frame) this.hooks.direct(to, frame);
|
|
28238
|
+
if (!meta || theirs === null || !groupMetaNewer(meta, theirs) || !this.isMember || !rosterHas(this.roster, to)) return null;
|
|
28239
|
+
return this.metaFrame();
|
|
26771
28240
|
}
|
|
26772
28241
|
/**
|
|
26773
28242
|
* A metadata statement (from its author's edge or a hub's): kept when it is newer than mine, its
|
|
@@ -26939,6 +28408,672 @@ var GroupTypingBoard = class {
|
|
|
26939
28408
|
}
|
|
26940
28409
|
};
|
|
26941
28410
|
//#endregion
|
|
26942
|
-
export { checkAtprotoProofRecord as $, HANDOFF_VERIFIED as $a, replySnippet as $i, freshHubs as $n, OwnInviteError as $o, IdentityCheckUnavailable as $r, fromBase64Url as $s, isPubkyKey as $t, GROUP_EDIT_TEXT_BYTES as A, CALL_SIGNAL_MAX_AGE_MS as Aa, parseVoiceMeta as Ai, findEndpoint as An, SET_UPDATE as Ao, newWakeToken as Ar, DEFAULT_RELAYS as As, encodeDidDhtPacket as At, sameSshKey as B, HTTP_SERVICE_PROTO as Ba, COMMUNITY_REACTION_FRAME as Bi, SIGNAL_FRESH_MS as Bn, enrollCancelFrame as Bo, HOLD_DATED_BACK_MS as Br, RELAY_PAYLOAD_MAX_BYTES as Bs, USDT_PUBLIC_RPC as Bt, groupWakes as C, GhostlyHttpError as Ca, safeBlobType as Ci, resolveLightningDestination as Cn, readHandoffHave as Co, nextPinNumber as Cr, firstDeviceSetSecret as Cs, parseDidSignaturePaste as Ct, GroupSession as D, LIMITS as Da, VOICE_LIMITS as Di, ENDPOINT as Dn, readHandoffVerified as Do, WAKE_CALL_INTERVAL_MS as Dr, signTurnRelease as Ds, didDhtDocument as Dt, GROUP_READ_NOTE as E, webLocalFetch as Ea, PLAYABLE_AUDIO as Ei, decodeBolt11 as En, readHandoffTurnFrame as Eo, readPin as Er, signTurnPacket as Es, strictBase64Url as Et, SSHSIG_MAX_ARMOR as F, callSignalHeardAt as Fa, RateWindow as Fi, INVITE_TAKEN as Fn, verifySetUpdate as Fo, vapidKeysMatch as Fr, traceLink as Fs, ONCHAIN_PROVIDER as Ft, decodeAnyBitcoinAddress as G, HANDOFF_DONE as Ga, queueReaction as Gi, MESH_HUBS as Gn, enrollHelloFrame as Go, readManifest as Gr, createIdentity as Gs, validatePaymentTarget as Gt, verifySshSignature as H, serviceIdFromName as Ha, REACTION_LIMITS as Hi, SIGNAL_REPLY_MS as Hn, enrollDigits as Ho, HoldKeys as Hr, newerPacket as Hs, assertTokenUnits as Ht, SSHSIG_NAMESPACE as I, extractParamsFromSdp as Ia, dhtEditId as Ii, LIVENESS_PING_MS as In, ENROLL_DONE as Io, BUTTONS_CAPABILITY as Ir, budgetRetryMs as Is, PaymentPreflightError as It, ATPROTO_PROOF_COLLECTION as J, HANDOFF_OFFER as Ja, readReaction as Ji, pickMeshHubs as Jn, readEnrollHello as Jo, capsDescriptors as Jr, publicKeyFromZ32 as Js, SPARK_PROVIDER as Jt, ATPROTO_DECLARED_SCOPE as K, HANDOFF_HAVE as Ka, reactionEmoji as Ki, mayBeHub as Kn, enrollProofFrame as Ko, CapsExchange as Kr, identityFromSeed as Ks, walletNetworkOf as Kt, SshSigError as L, parseCallSignal as La, parseLinkPreview as Li, pairedMessageFrame as Ln, ENROLL_GRANT as Lo, EDIT_CAPABILITY as Lr, isDiscoveryBudgetError as Ls, SEPOLIA_TEST_USDT as Lt, parseCommunityEdit as M, answersOffer as Ma, parseVideoMeta as Mi, parseCashuRequestPayload as Mn, setAckFrame as Mo, relayRequest as Mr, currentRelays as Ms, signDidDhtPacket as Mt, mentionsMember as N, buildSdpFromSignal as Na, EDIT_BUFFER_MS as Ni, parseFedimintRequestPayload as Nn, setUpdateFrame as No, wakeRequest as Nr, normalizeRelayUrl as Ns, ETHEREUM_USDT as Nt, COMMUNITY_EDIT_FRAME as O, decodeControl as Oa, baseMime as Oi, cashuRequestPayload as On, sealHandoffPiece as Oo, WakeLimiter as Or, turnKeys as Os, didDhtFromPublicKey as Ot, groupName as P, callRtcConfig as Pa, EDIT_RATE_WINDOW_MS as Pi, GhostLink as Pn, signTombstone as Po, checkPushEndpoint as Pr, setLinkTraceSink as Ps, EVM_TEST_CHAINS as Pt, atprotoProofRkey as Q, HANDOFF_TIMINGS as Qa, readReply as Qi, doorHubs as Qn, readDeviceInvite as Qo, IDENTITY_REVOCATION_LABEL as Qr, bytesEqual as Qs, PUBKY_PROOF_MAX_BYTES as Qt, parseSshPublicKey as R, signalHasVideo as Ra, forwardedAgain as Ri, CarriedTransport as Rn, ENROLL_HELLO as Ro, UPGRADE_CAPABILITY as Rr, withRequestOptions as Rs, SEPOLIA_TEST_USDT_FAUCET as Rt, groupWakeFrame as S, presenceSeenAt as Sa, readImageMeta as Si, requestLnurlInvoice as Sn, readHandoffDone as So, mayPin as Sr, TURN_MAX as Ss, parseDidDocument as St, GROUP_REACTED_FRAME as T, parseLocalTarget as Ta, sanitizeMime as Ti, engineText as Tn, readHandoffPake as To, pinNumberHolds as Tr, readTurnPacket as Ts, staticDidDocument as Tt, verifyBitcoinMessage as U, HANDOFF_ACK as Ua, ReactionWindow as Ui, groupSignalFrame as Un, enrollDoneFrame as Uo, HoldRefusedError as Ur, openRelayPayload as Us, assertWholeSats as Ut, sshSignCommand as V, LEGACY_SERVICES as Va, GROUP_REACTION_FRAME as Vi, SIGNAL_HOLD_MS as Vn, enrollCancelReason as Vo, HOLD_LIMITS as Vr, createRelayPayload as Vs, WALLET_NETWORKS as Vt, BitcoinAddressError as W, HANDOFF_CANCEL as Wa, nextReactionNumber as Wi, readGroupSignal as Wn, enrollGrantFrame as Wo, newMailbox as Wr, parseRelayPayload as Ws, formatPaymentAmount as Wt, atprotoDidMethod as X, HANDOFF_PIECE_BYTES as Xa, wireReaction as Xi, beaconKeys as Xn, createDeviceInvite as Xo, IDENTITY_MAX_EVIDENCE as Xr, MAX_AVATAR_LENGTH as Xs, sparkInvoiceDetails as Xt, ATPROTO_PROOF_SCOPE as Y, HANDOFF_PAKE as Ya, validReactionNumber as Yi, COMMUNITY_TOPOLOGY as Yn, verifyEnrollProof as Yo, dialDescriptors as Yr, verify as Ys, sparkAddressKind as Yt, atprotoProofRecord as Z, HANDOFF_REQUEST as Za, pairedReplyAuthor as Zi, beaconRecords as Zn, deviceInviteJoinerParams as Zo, IDENTITY_MAX_VALIDITY as Zr, sanitizeAvatar as Zs, isBitcoinAddress as Zt, knockRecords as _, verifyCommitSignature as _a, DHT_MESSAGE_TTL as _i, recordsFromTxt as _n, handoffStreamKey as _o, TypingSender as _r, TURN_RAISE_READ_TIMEOUT_MS as _s, didWebFileNames as _t, sealGroupTyping as a, checkStatusCard as aa, newIdentityBinding as ai, pubkyProofPath as an, handoffCancelFrame as ao, pickHubs as ar, DEVICES_CAPABILITY as as, verifyAtprotoRecordProof as at, GROUP_WAKE_FRAME as b, EXPECT_PEER_MS as ba, dhtFileId as bi, txtValue as bn, readHandoffAck as bo, GROUP_PIN_FRAME as br, turnPutSummary as bs, normalizeDid as bt, GROUP_READ_NOTE_COMMUNITY as c, arrivalKey as ca, lastSharedWithMe as ci, DOMAIN_FILE_MAX_BYTES as cn, handoffDataFrame as co, readBeaconHead as cr, deviceEchoFrame as cs, DID_JWS_MAX_LENGTH as ct, encodeCommunityLink as d, receivedTimestamp as da, validExternalKey as di, domainTxtRecord as dn, handoffHelloFrame as do, ChatFiles as dr, deviceLinkPairing as ds, checkDidSignature as dt, BUTTON_ID as ea, randomBytes as ec, IdentityExchange as ei, isPubkyProofFolder as en, filePartName as eo, lobbyKeys as er, createChatInvite as es, isAtprotoDid as et, KNOCK_TTL_MS as f, shownTime as fa, TRANSPORTS as fi, domainWellKnownFile as fn, handoffManifestFrames as fo, FILE_LIMITS as fr, devicePingFrame as fs, didGhostlyServiceEntry as ft, knockIdentity as g, rosterHas as ga, DHT_FILE_TOO_LARGE as gi, normalizeDomain as gn, handoffRetryAfter as go, TYPING_REFRESH_MS as gr, TURN_PUT_WINDOW_MS as gs, didWebFile as gt, entryParams as h, MEMBER_KEY as ha, irohRelayUrl as hi, nip05RootKey as hn, handoffReleaseFrame as ho, TYPING_KINDS as hr, webCryptoSigner as hs, didWebDocumentUrl as ht, openGroupTyping as i, buttonLabelClash as ia, utf8Encode as ic, identityStatement as ii, pubkyProofFile as in, handoffBusyFrame as io, newerHead as ir, inviteOwnership as is, AtprotoProofError as it, carryMentions as j, RTC_CONFIG as ja, voicePeaksOf as ji, isFederationId as jn, readSetUpdate as jo, parseWakeFrame as jr, RelayTransport as js, resolveDidDht as jt, GROUP_EDIT_FRAME as k, encodeControl as ka, fileMessageText as ki, fedimintRequestPayload as kn, SET_ACK as ko, deviceWakeRequest as kr, turnName as ks, encodeDidDhtDeactivated as kt, communityHasFrame as l, claimedTime as la, PeerProofs as li, decodeDnsResponse as ln, handoffDigest as lo, readLobby as lr, deviceEchoNonce as ls, DidError as lt, encodeGroupEntryLink as m, MAX_GROUP_CHAIN as ma, irohDescriptor as mi, isPublicAddress as mn, handoffPakeFrame as mo, transferStalled as mr, seedSigner as ms, didSignedMessages as mt, GroupTypingBoard as n, RUN_RESULTS as na, toZ32 as nc, emptyIdentityLedger as ni, normalizePubkyKey as nn, handoffAttemptFailed as no, mergeBeacon as nr, encodeInviteCode as ns, parseAtprotoDidDocument as nt, COMMUNITY_LIMITS as o, sameButtonText as oa, receivedIdentityStatus as oi, homeserverWebEndpoint as on, handoffContext as oo, rankHubs as or, ENROLL_CAPABILITY as os, DID_DOCUMENT_MAX_BYTES as ot, decodeGroupEntryLink as p, GROUP_MEMBER_CAP as pa, automaticTransport as pi, encodeDnsQuery as pn, handoffOfferFrame as po, transferEnded as pr, newDeviceSetSecret as ps, didJwsAlg as pt, ATPROTO_FULL_SCOPE as q, HANDOFF_HELLO as qa, reactionIsNewer as qi, meshHubs as qn, readEnrollGrant as qo, DHT_TEXT_CAPABILITY as qr, identityFromSeedB64 as qs, SPARK_NETWORKS as qt, groupTypingEpoch as r, STATUS_CARD_LIMITS as ra, utf8Decode as rc, identityRevocationValue as ri, pubkyProofCapability as rn, handoffAttemptSucceeded as ro, mergeLobby as rr, inviteLink as rs, shortAtprotoDid as rt, CommunitySession as s, statusCardText as sa, revokesIdentity as si, pubkyHomeserverOf as sn, handoffContextProof as so, readBeacon as sr, HANDOFF_CAPABILITY as ss, DID_FILE_MAX_BYTES as st, GROUP_TYPING_FRAME as t, ITEM_STATES as ta, toBase64Url as tc, boundVerifiedIdentity as ti, newPubkyProofFolder as tn, handoffAttemptAllowed as to, lobbyRecords as tr, decodeInviteCode as ts, normalizeAtprotoHandle as tt, decodeCommunityLink as u, heardTime as ua, emptyProofLedger as ui, dnsProofName as un, handoffEphemeral as uo, shouldBeHub as ur, deviceKeyZ32 as us, checkDidJws as ut, mergeKnocks as v, edgeParams as va, peekDhtMailbox as vi, recordsFromWellKnown as vn, handoffVersions as vo, sanitizeTypingStatus as vr, TURN_SETTLE_MS as vs, didWebFileUrl as vt, parseGroupWakeFrame as w, formatLocalTarget as wa, sanitizeFileName as wi, engineError as wn, readHandoffHello as wo, pinIsNewer as wr, nextTurnPosition as ws, signingKeys as wt, GroupWakeLimiter as x, RELAY_POLL_INTERVALS as xa, parseImageMeta as xi, wellKnownUrl as xn, readHandoffCancel as xo, PIN_LIMITS as xr, TURN_LAST_SEQUENCE as xs, parseDid as xt, readKnocks as y, DirectPathWatch as ya, DHT_FILE_CAPABILITY as yi, recordsName as yn, newHandoffId as yo, typingActivity as yr, classifyTurnRead as ys, didWebHost as yt, parseSshSignature as z, emptyLinkRecords as za, readForwarded as zi, GROUP_SIGNAL_FRAME as zn, ENROLL_PROOF as zo, HOLD_CAPABILITY as zr, PacketTooLargeError as zs, TEST_USDT_FAUCET_AMOUNT as zt };
|
|
28411
|
+
//#region ../core/src/appBundle.ts
|
|
28412
|
+
var APP_BUNDLE_MAGIC = "GHOSTLYAPP1";
|
|
28413
|
+
var MAGIC = utf8Encode(APP_BUNDLE_MAGIC);
|
|
28414
|
+
var APP_BUNDLE_LIMITS = {
|
|
28415
|
+
/** The whole bundle, phase 1. */
|
|
28416
|
+
bundleBytes: 16777216,
|
|
28417
|
+
files: 64,
|
|
28418
|
+
manifestBytes: 65536,
|
|
28419
|
+
/** A path, in bytes (paths are ASCII). */
|
|
28420
|
+
pathBytes: 128,
|
|
28421
|
+
iconBytes: 262144,
|
|
28422
|
+
screenshots: 8,
|
|
28423
|
+
/** Lengths in Unicode code points. */
|
|
28424
|
+
title: 40,
|
|
28425
|
+
tagline: 80,
|
|
28426
|
+
description: 2e3,
|
|
28427
|
+
releaseNotes: 500,
|
|
28428
|
+
version: 64,
|
|
28429
|
+
license: 128,
|
|
28430
|
+
/** An https URL, in characters. */
|
|
28431
|
+
url: 512,
|
|
28432
|
+
sources: 8
|
|
28433
|
+
};
|
|
28434
|
+
var APP_ICON_PATH = "icon.png";
|
|
28435
|
+
var SCREENSHOT_TYPES = /\.(png|jpg|webp)$/;
|
|
28436
|
+
/**
|
|
28437
|
+
* The permissions of phase 1 (WISP 1200 · Permissions). `internet`: the app reaches HTTPS and WSS servers of its
|
|
28438
|
+
* choosing (the client runs it in its network runner); without it, an app has no network.
|
|
28439
|
+
*/
|
|
28440
|
+
var APP_PERMISSIONS = [
|
|
28441
|
+
"chat",
|
|
28442
|
+
"internet",
|
|
28443
|
+
"name"
|
|
28444
|
+
];
|
|
28445
|
+
var APP_CLIENTS = [
|
|
28446
|
+
"web",
|
|
28447
|
+
"desktop",
|
|
28448
|
+
"extension"
|
|
28449
|
+
];
|
|
28450
|
+
/**
|
|
28451
|
+
* Where an app shows (WISP 1200 · Manifest, `view`). `chat`, the default when the manifest has none: inside a 1:1 chat
|
|
28452
|
+
* only, beside it on a wide screen. `full`: full screen, alone or from a chat, never beside one.
|
|
28453
|
+
*/
|
|
28454
|
+
var APP_VIEWS = ["chat", "full"];
|
|
28455
|
+
/** The view of a manifest: `chat` when it names none. */
|
|
28456
|
+
var appViewOf = (manifest) => manifest.view ?? "chat";
|
|
28457
|
+
var REQUIRED = [
|
|
28458
|
+
"ghostlyApp",
|
|
28459
|
+
"publisher",
|
|
28460
|
+
"name",
|
|
28461
|
+
"version",
|
|
28462
|
+
"sequence",
|
|
28463
|
+
"kind",
|
|
28464
|
+
"title",
|
|
28465
|
+
"tagline",
|
|
28466
|
+
"entry",
|
|
28467
|
+
"permissions",
|
|
28468
|
+
"runtime",
|
|
28469
|
+
"license",
|
|
28470
|
+
"files"
|
|
28471
|
+
];
|
|
28472
|
+
var OPTIONAL = [
|
|
28473
|
+
"description",
|
|
28474
|
+
"view",
|
|
28475
|
+
"sources",
|
|
28476
|
+
"proofs",
|
|
28477
|
+
"homepage",
|
|
28478
|
+
"support",
|
|
28479
|
+
"releaseNotes"
|
|
28480
|
+
];
|
|
28481
|
+
/** Reserved for phase 2 and refused in phase 1. */
|
|
28482
|
+
var RESERVED = ["price", "recovery"];
|
|
28483
|
+
var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
28484
|
+
var HOST = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$/;
|
|
28485
|
+
var SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
28486
|
+
var codePoints = (s) => [...s].length;
|
|
28487
|
+
/** One line: no control character. */
|
|
28488
|
+
var isLine$1 = (v, max, min = 0) => typeof v === "string" && codePoints(v) >= min && codePoints(v) <= max && !/\p{Cc}/u.test(v);
|
|
28489
|
+
/** Text of several lines: no control character but the line feed. */
|
|
28490
|
+
var isText$1 = (v, max) => typeof v === "string" && codePoints(v) <= max && !/[^\P{Cc}\n]/u.test(v);
|
|
28491
|
+
var isSafeCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
|
|
28492
|
+
var isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
28493
|
+
/**
|
|
28494
|
+
* An https URL as WISP 1200 takes one anywhere: at most 512 characters, no user or password, and on jsDelivr only
|
|
28495
|
+
* `https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/...`, since a branch, a tag, a range or `latest` is
|
|
28496
|
+
* served from a copy that moves.
|
|
28497
|
+
*/
|
|
28498
|
+
function isAppUrl(value) {
|
|
28499
|
+
if (typeof value !== "string" || value.length > APP_BUNDLE_LIMITS.url || !value.startsWith("https://")) return false;
|
|
28500
|
+
let url;
|
|
28501
|
+
try {
|
|
28502
|
+
url = new URL(value);
|
|
28503
|
+
} catch {
|
|
28504
|
+
return false;
|
|
28505
|
+
}
|
|
28506
|
+
if (url.protocol !== "https:" || url.username || url.password || !url.hostname) return false;
|
|
28507
|
+
const host = url.hostname.toLowerCase();
|
|
28508
|
+
if (host === "jsdelivr.net" || host.endsWith(".jsdelivr.net")) return host === "cdn.jsdelivr.net" && /^\/gh\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}\/./.test(url.pathname);
|
|
28509
|
+
return true;
|
|
28510
|
+
}
|
|
28511
|
+
/**
|
|
28512
|
+
* An SPDX license expression, read lightly: identifiers (`MIT`, `GPL-3.0-or-later`, `LicenseRef-x`, `Apache-2.0+`)
|
|
28513
|
+
* joined by `AND` and `OR`, `WITH` an exception, and parentheses; or `proprietary`.
|
|
28514
|
+
*/
|
|
28515
|
+
function isAppLicense(value) {
|
|
28516
|
+
if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.license) return false;
|
|
28517
|
+
const tokens = value.match(/\(|\)|[^\s()]+/g);
|
|
28518
|
+
if (!tokens || tokens.join("").length !== value.replace(/\s/g, "").length) return false;
|
|
28519
|
+
let i = 0;
|
|
28520
|
+
const id = () => {
|
|
28521
|
+
const t = tokens[i];
|
|
28522
|
+
if (t && /^[A-Za-z0-9][A-Za-z0-9.-]*\+?$/.test(t) && ![
|
|
28523
|
+
"AND",
|
|
28524
|
+
"OR",
|
|
28525
|
+
"WITH"
|
|
28526
|
+
].includes(t)) {
|
|
28527
|
+
i++;
|
|
28528
|
+
return true;
|
|
28529
|
+
}
|
|
28530
|
+
return false;
|
|
28531
|
+
};
|
|
28532
|
+
const term = (depth) => {
|
|
28533
|
+
if (tokens[i] === "(") {
|
|
28534
|
+
i++;
|
|
28535
|
+
if (depth > 8 || !expr(depth + 1) || tokens[i] !== ")") return false;
|
|
28536
|
+
i++;
|
|
28537
|
+
return true;
|
|
28538
|
+
}
|
|
28539
|
+
if (!id()) return false;
|
|
28540
|
+
if (tokens[i] === "WITH") {
|
|
28541
|
+
i++;
|
|
28542
|
+
return id();
|
|
28543
|
+
}
|
|
28544
|
+
return true;
|
|
28545
|
+
};
|
|
28546
|
+
const expr = (depth) => {
|
|
28547
|
+
if (!term(depth)) return false;
|
|
28548
|
+
while (tokens[i] === "AND" || tokens[i] === "OR") {
|
|
28549
|
+
i++;
|
|
28550
|
+
if (!term(depth)) return false;
|
|
28551
|
+
}
|
|
28552
|
+
return true;
|
|
28553
|
+
};
|
|
28554
|
+
return expr(0) && i === tokens.length;
|
|
28555
|
+
}
|
|
28556
|
+
/** A path of a bundle: relative, `/`-separated, `[A-Za-z0-9._-]` per segment, no `.` or `..`, at most 128 bytes. */
|
|
28557
|
+
function isAppPath(value) {
|
|
28558
|
+
if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.pathBytes) return false;
|
|
28559
|
+
return value.split("/").every((s) => SEGMENT.test(s) && s !== "." && s !== "..");
|
|
28560
|
+
}
|
|
28561
|
+
var bad = (field, detail = `${field} is not valid`) => ({
|
|
28562
|
+
ok: false,
|
|
28563
|
+
reason: "bad-field",
|
|
28564
|
+
detail
|
|
28565
|
+
});
|
|
28566
|
+
/** Checks a parsed manifest against every rule and bound of WISP 1200 that the manifest alone decides. */
|
|
28567
|
+
function checkAppManifest(value) {
|
|
28568
|
+
if (!isObject$1(value)) return bad("manifest", "The manifest is not an object");
|
|
28569
|
+
if (value.ghostlyApp !== void 0 && value.ghostlyApp !== 1) return {
|
|
28570
|
+
ok: false,
|
|
28571
|
+
reason: "unsupported-format",
|
|
28572
|
+
detail: "ghostlyApp is not 1"
|
|
28573
|
+
};
|
|
28574
|
+
for (const key of Object.keys(value)) {
|
|
28575
|
+
if (RESERVED.includes(key)) return {
|
|
28576
|
+
ok: false,
|
|
28577
|
+
reason: "reserved-key",
|
|
28578
|
+
detail: key
|
|
28579
|
+
};
|
|
28580
|
+
if (!REQUIRED.includes(key) && !OPTIONAL.includes(key)) return {
|
|
28581
|
+
ok: false,
|
|
28582
|
+
reason: "unknown-key",
|
|
28583
|
+
detail: key
|
|
28584
|
+
};
|
|
28585
|
+
}
|
|
28586
|
+
for (const key of REQUIRED) if (!(key in value)) return {
|
|
28587
|
+
ok: false,
|
|
28588
|
+
reason: "missing-key",
|
|
28589
|
+
detail: key
|
|
28590
|
+
};
|
|
28591
|
+
const m = value;
|
|
28592
|
+
if (!isAppKey(m.publisher)) return bad("publisher");
|
|
28593
|
+
if (typeof m.name !== "string" || !APP_NAME.test(m.name)) return bad("name");
|
|
28594
|
+
if (typeof m.version !== "string" || m.version.length > APP_BUNDLE_LIMITS.version || !SEMVER.test(m.version)) return bad("version");
|
|
28595
|
+
if (!isSafeCount(m.sequence, 1)) return bad("sequence");
|
|
28596
|
+
if (m.kind !== "mini-app") return bad("kind");
|
|
28597
|
+
if (!isLine$1(m.title, APP_BUNDLE_LIMITS.title, 1)) return bad("title");
|
|
28598
|
+
if (!isLine$1(m.tagline, APP_BUNDLE_LIMITS.tagline, 1)) return bad("tagline");
|
|
28599
|
+
if (m.description !== void 0 && !isText$1(m.description, APP_BUNDLE_LIMITS.description)) return bad("description");
|
|
28600
|
+
if (m.releaseNotes !== void 0 && !isLine$1(m.releaseNotes, APP_BUNDLE_LIMITS.releaseNotes)) return bad("releaseNotes");
|
|
28601
|
+
const permissions = m.permissions;
|
|
28602
|
+
if (!Array.isArray(permissions) || new Set(permissions).size !== permissions.length || !permissions.every((p) => APP_PERMISSIONS.includes(p))) return bad("permissions");
|
|
28603
|
+
if (m.view !== void 0 && !APP_VIEWS.includes(m.view)) return bad("view", `view is "chat" or "full"`);
|
|
28604
|
+
const runtime = m.runtime;
|
|
28605
|
+
if (!isObject$1(runtime) || Object.keys(runtime).length !== 2 || typeof runtime.host !== "string" || !HOST.test(runtime.host)) return bad("runtime");
|
|
28606
|
+
const clients = runtime.clients;
|
|
28607
|
+
if (!Array.isArray(clients) || clients.length < 1 || new Set(clients).size !== clients.length || !clients.every((c) => APP_CLIENTS.includes(c))) return bad("runtime");
|
|
28608
|
+
if (!isAppLicense(m.license)) return bad("license");
|
|
28609
|
+
if (m.sources !== void 0 && (!Array.isArray(m.sources) || m.sources.length > APP_BUNDLE_LIMITS.sources || !m.sources.every(isAppUrl))) return bad("sources");
|
|
28610
|
+
if (m.homepage !== void 0 && !isAppUrl(m.homepage)) return bad("homepage");
|
|
28611
|
+
if (m.support !== void 0 && !isAppUrl(m.support)) return bad("support");
|
|
28612
|
+
if (m.proofs !== void 0) {
|
|
28613
|
+
if (!Array.isArray(m.proofs)) return bad("proofs");
|
|
28614
|
+
if (m.proofs.length > 0) return {
|
|
28615
|
+
ok: false,
|
|
28616
|
+
reason: "proofs-unsupported",
|
|
28617
|
+
detail: "Publisher proofs come after phase 1"
|
|
28618
|
+
};
|
|
28619
|
+
}
|
|
28620
|
+
const files = m.files;
|
|
28621
|
+
if (!Array.isArray(files)) return bad("files");
|
|
28622
|
+
if (files.length > APP_BUNDLE_LIMITS.files) return {
|
|
28623
|
+
ok: false,
|
|
28624
|
+
reason: "too-many-files",
|
|
28625
|
+
detail: `${files.length} files`
|
|
28626
|
+
};
|
|
28627
|
+
const lower = /* @__PURE__ */ new Set();
|
|
28628
|
+
let previous = "";
|
|
28629
|
+
let screenshots = 0;
|
|
28630
|
+
for (const f of files) {
|
|
28631
|
+
if (!isObject$1(f) || Object.keys(f).length !== 3 || !isAppPath(f.path) || !isSafeCount(f.size, 0) || !isAppHash(f.sha256)) return isObject$1(f) && typeof f.path === "string" && !isAppPath(f.path) ? {
|
|
28632
|
+
ok: false,
|
|
28633
|
+
reason: "bad-path",
|
|
28634
|
+
detail: f.path
|
|
28635
|
+
} : bad("files", "A file is not {path, size, sha256}");
|
|
28636
|
+
if (lower.has(f.path.toLowerCase())) return {
|
|
28637
|
+
ok: false,
|
|
28638
|
+
reason: "path-collision",
|
|
28639
|
+
detail: f.path
|
|
28640
|
+
};
|
|
28641
|
+
lower.add(f.path.toLowerCase());
|
|
28642
|
+
if (f.path <= previous) return {
|
|
28643
|
+
ok: false,
|
|
28644
|
+
reason: "files-unsorted",
|
|
28645
|
+
detail: f.path
|
|
28646
|
+
};
|
|
28647
|
+
previous = f.path;
|
|
28648
|
+
if (f.size > APP_BUNDLE_LIMITS.bundleBytes) return bad("files", `${f.path} is larger than a bundle`);
|
|
28649
|
+
if (f.path.startsWith("screenshots/")) {
|
|
28650
|
+
if (!SCREENSHOT_TYPES.test(f.path)) return {
|
|
28651
|
+
ok: false,
|
|
28652
|
+
reason: "bad-path",
|
|
28653
|
+
detail: `${f.path}: a screenshot is .png, .jpg or .webp`
|
|
28654
|
+
};
|
|
28655
|
+
if (++screenshots > APP_BUNDLE_LIMITS.screenshots) return {
|
|
28656
|
+
ok: false,
|
|
28657
|
+
reason: "too-many-screenshots"
|
|
28658
|
+
};
|
|
28659
|
+
}
|
|
28660
|
+
if (f.path === "icon.png" && f.size > APP_BUNDLE_LIMITS.iconBytes) return {
|
|
28661
|
+
ok: false,
|
|
28662
|
+
reason: "bad-icon",
|
|
28663
|
+
detail: "icon.png is larger than 256 KiB"
|
|
28664
|
+
};
|
|
28665
|
+
}
|
|
28666
|
+
const entry = m.entry;
|
|
28667
|
+
if (typeof entry !== "string" || !entry.endsWith(".html") || !files.some((f) => isObject$1(f) && f.path === entry)) return {
|
|
28668
|
+
ok: false,
|
|
28669
|
+
reason: "bad-entry",
|
|
28670
|
+
detail: "The entry is not an .html file of the bundle"
|
|
28671
|
+
};
|
|
28672
|
+
return {
|
|
28673
|
+
ok: true,
|
|
28674
|
+
manifest: m
|
|
28675
|
+
};
|
|
28676
|
+
}
|
|
28677
|
+
/** The digest of a version: SHA-256 of its canonical manifest bytes, base64url. */
|
|
28678
|
+
function appDigest(manifestBytes) {
|
|
28679
|
+
return toBase64Url(sha256(manifestBytes));
|
|
28680
|
+
}
|
|
28681
|
+
var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
|
|
28682
|
+
/** Reads and checks a bundle (the order of the checks is part of the format: see above). */
|
|
28683
|
+
function readAppBundle(bytes) {
|
|
28684
|
+
if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
|
|
28685
|
+
ok: false,
|
|
28686
|
+
reason: "too-large"
|
|
28687
|
+
};
|
|
28688
|
+
const head = bytes.subarray(0, MAGIC.length);
|
|
28689
|
+
if (!head.every((b, i) => b === MAGIC[i])) return {
|
|
28690
|
+
ok: false,
|
|
28691
|
+
reason: "magic"
|
|
28692
|
+
};
|
|
28693
|
+
if (head.length < MAGIC.length) return {
|
|
28694
|
+
ok: false,
|
|
28695
|
+
reason: "truncated"
|
|
28696
|
+
};
|
|
28697
|
+
let at = MAGIC.length;
|
|
28698
|
+
if (bytes.length < at + 4) return {
|
|
28699
|
+
ok: false,
|
|
28700
|
+
reason: "truncated"
|
|
28701
|
+
};
|
|
28702
|
+
const manifestLength = u32(bytes, at);
|
|
28703
|
+
at += 4;
|
|
28704
|
+
if (manifestLength > APP_BUNDLE_LIMITS.manifestBytes) return {
|
|
28705
|
+
ok: false,
|
|
28706
|
+
reason: "manifest-too-large"
|
|
28707
|
+
};
|
|
28708
|
+
if (bytes.length < at + manifestLength + 4) return {
|
|
28709
|
+
ok: false,
|
|
28710
|
+
reason: "truncated"
|
|
28711
|
+
};
|
|
28712
|
+
const manifestBytes = bytes.subarray(at, at + manifestLength);
|
|
28713
|
+
at += manifestLength;
|
|
28714
|
+
const statementLength = u32(bytes, at);
|
|
28715
|
+
at += 4;
|
|
28716
|
+
if (bytes.length < at + statementLength) return {
|
|
28717
|
+
ok: false,
|
|
28718
|
+
reason: "truncated"
|
|
28719
|
+
};
|
|
28720
|
+
const statementBytes = bytes.subarray(at, at + statementLength);
|
|
28721
|
+
at += statementLength;
|
|
28722
|
+
const read = readCanonicalJson(manifestBytes);
|
|
28723
|
+
if (!read.ok) return read;
|
|
28724
|
+
const checked = checkAppManifest(read.value);
|
|
28725
|
+
if (!checked.ok) return checked;
|
|
28726
|
+
const manifest = checked.manifest;
|
|
28727
|
+
const statement = readAppSignature(statementBytes);
|
|
28728
|
+
if (!statement.ok) return statement;
|
|
28729
|
+
const total = manifest.files.reduce((sum, f) => sum + f.size, 0);
|
|
28730
|
+
const rest = bytes.length - at;
|
|
28731
|
+
if (total > rest) return {
|
|
28732
|
+
ok: false,
|
|
28733
|
+
reason: "truncated"
|
|
28734
|
+
};
|
|
28735
|
+
if (total < rest) return {
|
|
28736
|
+
ok: false,
|
|
28737
|
+
reason: "trailing-bytes",
|
|
28738
|
+
detail: `${rest - total} bytes past the last file`
|
|
28739
|
+
};
|
|
28740
|
+
const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
|
|
28741
|
+
if (!signed.ok) return signed;
|
|
28742
|
+
const files = /* @__PURE__ */ new Map();
|
|
28743
|
+
for (const f of manifest.files) {
|
|
28744
|
+
const content = bytes.subarray(at, at + f.size);
|
|
28745
|
+
at += f.size;
|
|
28746
|
+
if (toBase64Url(sha256(content)) !== f.sha256) return {
|
|
28747
|
+
ok: false,
|
|
28748
|
+
reason: "file-hash",
|
|
28749
|
+
detail: f.path
|
|
28750
|
+
};
|
|
28751
|
+
files.set(f.path, content);
|
|
28752
|
+
}
|
|
28753
|
+
const icon = files.get(APP_ICON_PATH);
|
|
28754
|
+
if (icon) {
|
|
28755
|
+
const size = imageSize(icon);
|
|
28756
|
+
if (!size || icon[0] !== 137 || size.width !== size.height) return {
|
|
28757
|
+
ok: false,
|
|
28758
|
+
reason: "bad-icon",
|
|
28759
|
+
detail: "icon.png is not a square PNG"
|
|
28760
|
+
};
|
|
28761
|
+
}
|
|
28762
|
+
return {
|
|
28763
|
+
ok: true,
|
|
28764
|
+
bundle: {
|
|
28765
|
+
manifest,
|
|
28766
|
+
manifestBytes,
|
|
28767
|
+
digest: appDigest(manifestBytes),
|
|
28768
|
+
signature: statement.signature,
|
|
28769
|
+
files
|
|
28770
|
+
}
|
|
28771
|
+
};
|
|
28772
|
+
}
|
|
28773
|
+
/** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
|
|
28774
|
+
function assembleAppBundle(manifestBytes, statementBytes, files) {
|
|
28775
|
+
const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
|
|
28776
|
+
const out = new Uint8Array(total);
|
|
28777
|
+
const view = new DataView(out.buffer);
|
|
28778
|
+
let at = 0;
|
|
28779
|
+
out.set(MAGIC, at);
|
|
28780
|
+
at += MAGIC.length;
|
|
28781
|
+
view.setUint32(at, manifestBytes.length);
|
|
28782
|
+
at += 4;
|
|
28783
|
+
out.set(manifestBytes, at);
|
|
28784
|
+
at += manifestBytes.length;
|
|
28785
|
+
view.setUint32(at, statementBytes.length);
|
|
28786
|
+
at += 4;
|
|
28787
|
+
out.set(statementBytes, at);
|
|
28788
|
+
at += statementBytes.length;
|
|
28789
|
+
for (const f of files) {
|
|
28790
|
+
out.set(f, at);
|
|
28791
|
+
at += f.length;
|
|
28792
|
+
}
|
|
28793
|
+
return out;
|
|
28794
|
+
}
|
|
28795
|
+
//#endregion
|
|
28796
|
+
//#region ../core/src/appStore.ts
|
|
28797
|
+
var APP_STORE_LIMITS = {
|
|
28798
|
+
/** The index's bytes, uncompressed. */
|
|
28799
|
+
indexBytes: 16777216,
|
|
28800
|
+
/** How far ahead `expires` may be. */
|
|
28801
|
+
expiresAheadS: 7776e3,
|
|
28802
|
+
name: 40,
|
|
28803
|
+
description: 2e3,
|
|
28804
|
+
title: 40,
|
|
28805
|
+
tagline: 80,
|
|
28806
|
+
/** `category`, `developer`, `submitter`. */
|
|
28807
|
+
label: 40,
|
|
28808
|
+
reason: 200,
|
|
28809
|
+
urls: 4,
|
|
28810
|
+
apps: 4096,
|
|
28811
|
+
removed: 4096,
|
|
28812
|
+
revoked: 4096
|
|
28813
|
+
};
|
|
28814
|
+
var APP_STORE_KINDS = ["curated", "indexed"];
|
|
28815
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
28816
|
+
var isLine = (v, max, min = 0) => typeof v === "string" && [...v].length >= min && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
28817
|
+
var isText = (v, max) => typeof v === "string" && [...v].length <= max && !/[^\P{Cc}\n]/u.test(v);
|
|
28818
|
+
var isCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
|
|
28819
|
+
var fine = { ok: true };
|
|
28820
|
+
function keysOf(value, required, optional) {
|
|
28821
|
+
for (const k of Object.keys(value)) if (!required.includes(k) && !optional.includes(k)) return {
|
|
28822
|
+
ok: false,
|
|
28823
|
+
reason: "unknown-key",
|
|
28824
|
+
detail: k
|
|
28825
|
+
};
|
|
28826
|
+
for (const k of required) if (!(k in value)) return {
|
|
28827
|
+
ok: false,
|
|
28828
|
+
reason: "missing-key",
|
|
28829
|
+
detail: k
|
|
28830
|
+
};
|
|
28831
|
+
return fine;
|
|
28832
|
+
}
|
|
28833
|
+
var LISTING_REQUIRED = [
|
|
28834
|
+
"ref",
|
|
28835
|
+
"sequence",
|
|
28836
|
+
"digest",
|
|
28837
|
+
"urls",
|
|
28838
|
+
"title",
|
|
28839
|
+
"tagline"
|
|
28840
|
+
];
|
|
28841
|
+
var LISTING_OPTIONAL = [
|
|
28842
|
+
"category",
|
|
28843
|
+
"developer",
|
|
28844
|
+
"submitter",
|
|
28845
|
+
"repo",
|
|
28846
|
+
"support"
|
|
28847
|
+
];
|
|
28848
|
+
/** Checks one entry of `apps`, which is also the whole of a `listing.json`. */
|
|
28849
|
+
function checkAppListing(value) {
|
|
28850
|
+
if (!isObject(value)) return {
|
|
28851
|
+
ok: false,
|
|
28852
|
+
reason: "bad-field",
|
|
28853
|
+
detail: "A listing is not an object"
|
|
28854
|
+
};
|
|
28855
|
+
const keys = keysOf(value, LISTING_REQUIRED, LISTING_OPTIONAL);
|
|
28856
|
+
if (!keys.ok) return keys;
|
|
28857
|
+
const bad = (field) => ({
|
|
28858
|
+
ok: false,
|
|
28859
|
+
reason: "bad-field",
|
|
28860
|
+
detail: `apps: ${field}`
|
|
28861
|
+
});
|
|
28862
|
+
if (!isAppRef(value.ref)) return bad("ref");
|
|
28863
|
+
if (!isCount(value.sequence, 1)) return bad("sequence");
|
|
28864
|
+
if (!isAppHash(value.digest)) return bad("digest");
|
|
28865
|
+
const urls = value.urls;
|
|
28866
|
+
if (!Array.isArray(urls) || urls.length < 1 || urls.length > APP_STORE_LIMITS.urls || !urls.every(isAppUrl)) return bad("urls");
|
|
28867
|
+
if (!isLine(value.title, APP_STORE_LIMITS.title, 1)) return bad("title");
|
|
28868
|
+
if (!isLine(value.tagline, APP_STORE_LIMITS.tagline, 1)) return bad("tagline");
|
|
28869
|
+
for (const k of [
|
|
28870
|
+
"category",
|
|
28871
|
+
"developer",
|
|
28872
|
+
"submitter"
|
|
28873
|
+
]) if (value[k] !== void 0 && !isLine(value[k], APP_STORE_LIMITS.label, 1)) return bad(k);
|
|
28874
|
+
for (const k of ["repo", "support"]) if (value[k] !== void 0 && !isAppUrl(value[k])) return bad(k);
|
|
28875
|
+
return fine;
|
|
28876
|
+
}
|
|
28877
|
+
function checkRemoval(value) {
|
|
28878
|
+
if (!isObject(value)) return {
|
|
28879
|
+
ok: false,
|
|
28880
|
+
reason: "bad-field",
|
|
28881
|
+
detail: "A removal is not an object"
|
|
28882
|
+
};
|
|
28883
|
+
const keys = keysOf(value, [
|
|
28884
|
+
"ref",
|
|
28885
|
+
"digest",
|
|
28886
|
+
"reason",
|
|
28887
|
+
"at"
|
|
28888
|
+
], []);
|
|
28889
|
+
if (!keys.ok) return keys;
|
|
28890
|
+
if (!isAppRef(value.ref) || !isAppHash(value.digest) || !isLine(value.reason, APP_STORE_LIMITS.reason, 1) || !isCount(value.at, 0)) return {
|
|
28891
|
+
ok: false,
|
|
28892
|
+
reason: "bad-field",
|
|
28893
|
+
detail: "removed: not {ref, digest, reason, at}"
|
|
28894
|
+
};
|
|
28895
|
+
return fine;
|
|
28896
|
+
}
|
|
28897
|
+
var STORE_REQUIRED = [
|
|
28898
|
+
"ghostlyStore",
|
|
28899
|
+
"key",
|
|
28900
|
+
"name",
|
|
28901
|
+
"kind",
|
|
28902
|
+
"sequence",
|
|
28903
|
+
"expires",
|
|
28904
|
+
"apps",
|
|
28905
|
+
"removed",
|
|
28906
|
+
"revoked"
|
|
28907
|
+
];
|
|
28908
|
+
/**
|
|
28909
|
+
* Checks an index's fields and bounds, and every revocation it carries (one that does not verify refuses the index:
|
|
28910
|
+
* the store copied it, so a broken one is the store's fault). The signature and `expires` are `readAppStore`'s.
|
|
28911
|
+
*/
|
|
28912
|
+
function checkAppStoreIndex(value) {
|
|
28913
|
+
if (!isObject(value)) return {
|
|
28914
|
+
ok: false,
|
|
28915
|
+
reason: "bad-field",
|
|
28916
|
+
detail: "The index is not an object"
|
|
28917
|
+
};
|
|
28918
|
+
if (value.ghostlyStore !== void 0 && value.ghostlyStore !== 1) return {
|
|
28919
|
+
ok: false,
|
|
28920
|
+
reason: "unsupported-format",
|
|
28921
|
+
detail: "ghostlyStore is not 1"
|
|
28922
|
+
};
|
|
28923
|
+
const keys = keysOf(value, STORE_REQUIRED, ["description"]);
|
|
28924
|
+
if (!keys.ok) return keys;
|
|
28925
|
+
const bad = (field) => ({
|
|
28926
|
+
ok: false,
|
|
28927
|
+
reason: "bad-field",
|
|
28928
|
+
detail: field
|
|
28929
|
+
});
|
|
28930
|
+
if (!isAppKey(value.key)) return bad("key");
|
|
28931
|
+
if (!isLine(value.name, APP_STORE_LIMITS.name, 1)) return bad("name");
|
|
28932
|
+
if (value.description !== void 0 && !isText(value.description, APP_STORE_LIMITS.description)) return bad("description");
|
|
28933
|
+
if (!APP_STORE_KINDS.includes(value.kind)) return bad("kind");
|
|
28934
|
+
if (!isCount(value.sequence, 1)) return bad("sequence");
|
|
28935
|
+
if (!isCount(value.expires, 0)) return bad("expires");
|
|
28936
|
+
const { apps, removed, revoked } = value;
|
|
28937
|
+
if (!Array.isArray(apps) || apps.length > APP_STORE_LIMITS.apps) return bad("apps");
|
|
28938
|
+
const refs = /* @__PURE__ */ new Set();
|
|
28939
|
+
for (const listing of apps) {
|
|
28940
|
+
const checked = checkAppListing(listing);
|
|
28941
|
+
if (!checked.ok) return checked;
|
|
28942
|
+
const ref = listing.ref;
|
|
28943
|
+
if (refs.has(ref)) return {
|
|
28944
|
+
ok: false,
|
|
28945
|
+
reason: "duplicate-app",
|
|
28946
|
+
detail: ref
|
|
28947
|
+
};
|
|
28948
|
+
refs.add(ref);
|
|
28949
|
+
}
|
|
28950
|
+
if (!Array.isArray(removed) || removed.length > APP_STORE_LIMITS.removed) return bad("removed");
|
|
28951
|
+
for (const entry of removed) {
|
|
28952
|
+
const checked = checkRemoval(entry);
|
|
28953
|
+
if (!checked.ok) return checked;
|
|
28954
|
+
}
|
|
28955
|
+
if (!Array.isArray(revoked) || revoked.length > APP_STORE_LIMITS.revoked) return bad("revoked");
|
|
28956
|
+
for (const entry of revoked) {
|
|
28957
|
+
const read = readAppRevocation(entry);
|
|
28958
|
+
if (!read.ok) return {
|
|
28959
|
+
ok: false,
|
|
28960
|
+
reason: "bad-revocation",
|
|
28961
|
+
detail: read.detail ?? read.reason
|
|
28962
|
+
};
|
|
28963
|
+
}
|
|
28964
|
+
return fine;
|
|
28965
|
+
}
|
|
28966
|
+
/**
|
|
28967
|
+
* Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and
|
|
28968
|
+
* revocations, the signature statement, the key the reader holds for this store (when it holds one), the signature,
|
|
28969
|
+
* then `expires` against `now` (seconds): more than 90 days ahead is refused, past is `expired`.
|
|
28970
|
+
*/
|
|
28971
|
+
function readAppStore(indexBytes, sigBytes, now, heldKey) {
|
|
28972
|
+
if (indexBytes.length > APP_STORE_LIMITS.indexBytes) return {
|
|
28973
|
+
ok: false,
|
|
28974
|
+
reason: "too-large"
|
|
28975
|
+
};
|
|
28976
|
+
const read = readCanonicalJson(indexBytes);
|
|
28977
|
+
if (!read.ok) return read;
|
|
28978
|
+
const checked = checkAppStoreIndex(read.value);
|
|
28979
|
+
if (!checked.ok) return checked;
|
|
28980
|
+
const index = read.value;
|
|
28981
|
+
const statement = readAppSignature(sigBytes);
|
|
28982
|
+
if (!statement.ok) return statement;
|
|
28983
|
+
if (heldKey !== void 0 && index.key !== heldKey) return {
|
|
28984
|
+
ok: false,
|
|
28985
|
+
reason: "store-key",
|
|
28986
|
+
detail: "Another key than the store's"
|
|
28987
|
+
};
|
|
28988
|
+
const signed = verifyAppSignature(APP_PREFIXES.store, indexBytes, statement.signature, index.key);
|
|
28989
|
+
if (!signed.ok) return signed;
|
|
28990
|
+
if (index.expires > now + APP_STORE_LIMITS.expiresAheadS) return {
|
|
28991
|
+
ok: false,
|
|
28992
|
+
reason: "expires-too-far"
|
|
28993
|
+
};
|
|
28994
|
+
return {
|
|
28995
|
+
ok: true,
|
|
28996
|
+
store: {
|
|
28997
|
+
index,
|
|
28998
|
+
digest: toBase64Url(sha256(indexBytes)),
|
|
28999
|
+
expired: index.expires < now
|
|
29000
|
+
}
|
|
29001
|
+
};
|
|
29002
|
+
}
|
|
29003
|
+
/** Signs an index with the store key: its canonical bytes and the `ghostly-store.sig` bytes. Throws on a bad index. */
|
|
29004
|
+
async function signAppStore(index, signer) {
|
|
29005
|
+
if (index.key !== toZ32(signer.publicKey)) throw new Error("A store index is signed by its own key");
|
|
29006
|
+
const checked = checkAppStoreIndex(JSON.parse(JSON.stringify(index)));
|
|
29007
|
+
if (!checked.ok) throw new Error(`Not a valid store index (${checked.reason}${checked.detail ? `: ${checked.detail}` : ""})`);
|
|
29008
|
+
const { bytes, signature } = await signAppObject(APP_PREFIXES.store, index, signer);
|
|
29009
|
+
return {
|
|
29010
|
+
indexBytes: bytes,
|
|
29011
|
+
sigBytes: canonicalJsonBytes(signature),
|
|
29012
|
+
signature
|
|
29013
|
+
};
|
|
29014
|
+
}
|
|
29015
|
+
//#endregion
|
|
29016
|
+
//#region ../core/src/appUpdates.ts
|
|
29017
|
+
function appUpdateDecision(installed, candidate) {
|
|
29018
|
+
if (installed.ref !== candidate.ref) return "other-app";
|
|
29019
|
+
if (candidate.sequence > installed.sequence) return "update";
|
|
29020
|
+
if (candidate.sequence < installed.sequence) return "rollback";
|
|
29021
|
+
return candidate.digest === installed.digest ? "same" : "equivocation";
|
|
29022
|
+
}
|
|
29023
|
+
/** The permissions an update adds: when there is any, the update waits for the person, who sees only these. */
|
|
29024
|
+
function addedAppPermissions(granted, requested) {
|
|
29025
|
+
return requested.filter((p) => !granted.includes(p));
|
|
29026
|
+
}
|
|
29027
|
+
function planAppUpdate(installed, candidate) {
|
|
29028
|
+
const decision = appUpdateDecision(installed, candidate);
|
|
29029
|
+
if (decision !== "update") return {
|
|
29030
|
+
action: "refuse",
|
|
29031
|
+
decision
|
|
29032
|
+
};
|
|
29033
|
+
const added = addedAppPermissions(installed.permissions, candidate.permissions);
|
|
29034
|
+
return added.length ? {
|
|
29035
|
+
action: "ask",
|
|
29036
|
+
added
|
|
29037
|
+
} : { action: "install" };
|
|
29038
|
+
}
|
|
29039
|
+
/**
|
|
29040
|
+
* The revocation that stops a version, or null: one signed by the app's own publisher (a reader verified it) that names
|
|
29041
|
+
* its digest, or reaches its `sequence` with `upTo`.
|
|
29042
|
+
*/
|
|
29043
|
+
function appRevocationFor(version, revocations) {
|
|
29044
|
+
const publisher = appRefPublisher(version.ref);
|
|
29045
|
+
for (const r of revocations) {
|
|
29046
|
+
const s = r.statement;
|
|
29047
|
+
if (s.app !== version.ref || r.signature.key !== publisher) continue;
|
|
29048
|
+
if ("digests" in s ? s.digests.includes(version.digest) : version.sequence <= s.upTo) return r;
|
|
29049
|
+
}
|
|
29050
|
+
return null;
|
|
29051
|
+
}
|
|
29052
|
+
function checkAppBeforeRun(version, stores, revocations = []) {
|
|
29053
|
+
const revocation = appRevocationFor(version, [...revocations, ...stores.flatMap((s) => s.revoked)]);
|
|
29054
|
+
if (revocation) return {
|
|
29055
|
+
status: "revoked",
|
|
29056
|
+
revocation
|
|
29057
|
+
};
|
|
29058
|
+
const by = stores.flatMap((s) => s.removed.filter((r) => r.ref === version.ref && r.digest === version.digest).map((r) => ({
|
|
29059
|
+
store: s.key,
|
|
29060
|
+
name: s.name,
|
|
29061
|
+
reason: r.reason,
|
|
29062
|
+
at: r.at
|
|
29063
|
+
})));
|
|
29064
|
+
return by.length ? {
|
|
29065
|
+
status: "removed",
|
|
29066
|
+
by
|
|
29067
|
+
} : { status: "ok" };
|
|
29068
|
+
}
|
|
29069
|
+
function appStoreDecision(held, read) {
|
|
29070
|
+
if (!held) return "new";
|
|
29071
|
+
if (held.key !== read.key) return "other-key";
|
|
29072
|
+
if (read.sequence > held.sequence) return "update";
|
|
29073
|
+
if (read.sequence < held.sequence) return "rollback";
|
|
29074
|
+
return read.digest === held.digest ? "same" : "equivocation";
|
|
29075
|
+
}
|
|
29076
|
+
//#endregion
|
|
29077
|
+
export { parseSshPublicKey as $, signAppRevocation as $a, formatFileSize as $i, GROUP_FILE_LIMITS as $n, handoffEphemeral as $o, WAKE_CALL_INTERVAL_MS as $r, deviceKeyZ32 as $s, SEPOLIA_TEST_USDT_FAUCET as $t, entryParams as A, replySnippet as Aa, createRelayPayload as Ac, IdentityExchange as Ai, nip05RootKey as An, LEGACY_SERVICES as Ao, newerHead as Ar, enrollCancelReason as As, didWebDocumentUrl as At, GROUP_REACTED_FRAME as B, claimedTime as Ba, sanitizeAvatar as Bc, emptyProofLedger as Bi, engineText as Bn, HANDOFF_REQUEST as Bo, transferStalled as Br, deviceInviteJoinerParams as Bs, staticDidDocument as Bt, GROUP_READ_NOTE_COMMUNITY as C, reactionEmoji as Ca, setLinkTraceSink as Cc, DHT_TEXT_CAPABILITY as Ci, DOMAIN_FILE_MAX_BYTES as Cn, callRtcConfig as Co, beaconRecords as Cr, signTombstone as Cs, DID_JWS_MAX_LENGTH as Ct, KNOCK_TTL_MS as D, wireReaction as Da, withRequestOptions as Dc, IDENTITY_MAX_VALIDITY as Di, domainWellKnownFile as Dn, signalHasVideo as Do, lobbyRecords as Dr, ENROLL_HELLO as Ds, didGhostlyServiceEntry as Dt, encodeCommunityLink as E, validReactionNumber as Ea, isDiscoveryBudgetError as Ec, IDENTITY_MAX_EVIDENCE as Ei, domainTxtRecord as En, parseCallSignal as Eo, lobbyKeys as Er, ENROLL_GRANT as Es, checkDidSignature as Et, GROUP_WAKE_FRAME as F, buttonLabelClash as Fa, identityFromSeed as Fc, newIdentityBinding as Fi, txtValue as Fn, HANDOFF_HAVE as Fo, readLobby as Fr, enrollProofFrame as Fs, normalizeDid as Ft, GROUP_EDIT_TEXT_BYTES as G, APP_STATEMENT_LIMITS as Ga, toZ32 as Gc, irohRelayUrl as Gi, findEndpoint as Gn, handoffAttemptFailed as Go, typingActivity as Gr, encodeInviteCode as Gs, encodeDidDhtPacket as Gt, GroupSession as H, receivedTimestamp as Ha, fromBase64Url as Hc, TRANSPORTS as Hi, ENDPOINT as Hn, HANDOFF_VERIFIED as Ho, TYPING_REFRESH_MS as Hr, OwnInviteError as Hs, didDhtDocument as Ht, GroupWakeLimiter as I, checkStatusCard as Ia, identityFromSeedB64 as Ic, receivedIdentityStatus as Ii, wellKnownUrl as In, HANDOFF_HELLO as Io, shouldBeHub as Ir, readEnrollGrant as Is, parseDid as It, mentionsMember as J, checkAppRevokeStatement as Ja, peekDhtMailbox as Ji, parseFedimintRequestPayload as Jn, handoffCancelFrame as Jo, mayPin as Jr, DEVICES_CAPABILITY as Js, ETHEREUM_USDT as Jt, carryMentions as K, appFingerprint as Ka, utf8Decode as Kc, DHT_FILE_TOO_LARGE as Ki, isFederationId as Kn, handoffAttemptSucceeded as Ko, GROUP_PIN_FRAME as Kr, inviteLink as Ks, resolveDidDht as Kt, groupWakeFrame as L, sameButtonText as La, publicKeyFromZ32 as Lc, revokesIdentity as Li, requestLnurlInvoice as Ln, HANDOFF_OFFER as Lo, ChatFiles as Lr, readEnrollHello as Ls, parseDidDocument as Lt, knockRecords as M, ITEM_STATES as Ma, openRelayPayload as Mc, emptyIdentityLedger as Mi, recordsFromTxt as Mn, HANDOFF_ACK as Mo, rankHubs as Mr, enrollDoneFrame as Ms, didWebFileNames as Mt, mergeKnocks as N, RUN_RESULTS as Na, parseRelayPayload as Nc, identityRevocationValue as Ni, recordsFromWellKnown as Nn, HANDOFF_CANCEL as No, readBeacon as Nr, enrollGrantFrame as Ns, didWebFileUrl as Nt, decodeGroupEntryLink as O, pairedReplyAuthor as Oa, PacketTooLargeError as Oc, IDENTITY_REVOCATION_LABEL as Oi, encodeDnsQuery as On, emptyLinkRecords as Oo, mergeBeacon as Or, ENROLL_PROOF as Os, didJwsAlg as Ot, readKnocks as P, STATUS_CARD_LIMITS as Pa, createIdentity as Pc, identityStatement as Pi, recordsName as Pn, HANDOFF_DONE as Po, readBeaconHead as Pr, enrollHelloFrame as Ps, didWebHost as Pt, SshSigError as Q, signAppObject as Qa, readImageMeta as Qi, pairedMessageFrame as Qn, handoffDigest as Qo, readPin as Qr, deviceEchoNonce as Qs, SEPOLIA_TEST_USDT as Qt, groupWakes as R, statusCardText as Ra, verify as Rc, lastSharedWithMe as Ri, resolveLightningDestination as Rn, HANDOFF_PAKE as Ro, FILE_LIMITS as Rr, verifyEnrollProof as Rs, parseDidSignaturePaste as Rt, CommunitySession as S, queueReaction as Sa, normalizeRelayUrl as Sc, CapsExchange as Si, pubkyHomeserverOf as Sn, buildSdpFromSignal as So, beaconKeys as Sr, setUpdateFrame as Ss, DID_FILE_MAX_BYTES as St, decodeCommunityLink as T, readReaction as Ta, budgetRetryMs as Tc, dialDescriptors as Ti, dnsProofName as Tn, extractParamsFromSdp as To, freshHubs as Tr, ENROLL_DONE as Ts, checkDidJws as Tt, COMMUNITY_EDIT_FRAME as U, shownTime as Ua, randomBytes as Uc, automaticTransport as Ui, cashuRequestPayload as Un, filePartName as Uo, TypingSender as Ur, createChatInvite as Us, didDhtFromPublicKey as Ut, GROUP_READ_NOTE as V, heardTime as Va, bytesEqual as Vc, validExternalKey as Vi, decodeBolt11 as Vn, HANDOFF_TIMINGS as Vo, TYPING_KINDS as Vr, readDeviceInvite as Vs, strictBase64Url as Vt, GROUP_EDIT_FRAME as W, APP_PREFIXES as Wa, toBase64Url as Wc, irohDescriptor as Wi, fedimintRequestPayload as Wn, handoffAttemptAllowed as Wo, sanitizeTypingStatus as Wr, decodeInviteCode as Ws, encodeDidDhtDeactivated as Wt, SSHSIG_MAX_ARMOR as X, isAppRef as Xa, dhtFileId as Xi, INVITE_TAKEN as Xn, handoffContextProof as Xo, pinIsNewer as Xr, HANDOFF_CAPABILITY as Xs, ONCHAIN_PROVIDER as Xt, groupName as Y, isAppKey as Ya, DHT_FILE_CAPABILITY as Yi, GhostLink as Yn, handoffContext as Yo, nextPinNumber as Yr, ENROLL_CAPABILITY as Ys, EVM_TEST_CHAINS as Yt, SSHSIG_NAMESPACE as Z, readAppRevocations as Za, parseImageMeta as Zi, LIVENESS_PING_MS as Zn, handoffDataFrame as Zo, pinNumberHolds as Zr, deviceEchoFrame as Zs, PaymentPreflightError as Zt, GroupTypingBoard as _, COMMUNITY_REACTION_FRAME as _a, turnKeys as _c, HOLD_LIMITS as _i, normalizePubkyKey as _n, decodeControl as _o, MESH_HUBS as _r, sealHandoffPiece as _s, parseAtprotoDidDocument as _t, checkAppStoreIndex as a, baseMime as aa, TURN_PUT_WINDOW_MS as ac, wakeRequest as ai, formatPaymentAmount as an, rosterHas as ao, isGroupMessageId as ar, handoffRetryAfter as as, BitcoinAddressError as at, sealGroupTyping as b, ReactionWindow as ba, RelayTransport as bc, newMailbox as bi, pubkyProofPath as bn, RTC_CONFIG as bo, pickMeshHubs as br, readSetUpdate as bs, verifyAtprotoRecordProof as bt, APP_BUNDLE_LIMITS as c, voicePeaksOf as ca, classifyTurnRead as cc, chatAppId as ci, SPARK_NETWORKS as cn, DirectPathWatch as co, readGroupWant as cr, newHandoffId as cs, ATPROTO_FULL_SCOPE as ct, appViewOf as d, EDIT_RATE_WINDOW_MS as da, TURN_MAX as dc, BUTTONS_CAPABILITY as di, sparkInvoiceDetails as dn, presenceSeenAt as do, GROUP_SIGNAL_FRAME as dr, readHandoffDone as ds, atprotoDidMethod as dt, safeBlobType as ea, deviceLinkPairing as ec, WakeLimiter as ei, TEST_USDT_FAUCET_AMOUNT as en, canonicalJson as eo, GROUP_HAVE_FRAME as er, handoffHelloFrame as es, parseSshSignature as et, assembleAppBundle as f, RateWindow as fa, firstDeviceSetSecret as fc, EDIT_CAPABILITY as fi, isBitcoinAddress as fn, GhostlyHttpError as fo, SIGNAL_FRESH_MS as fr, readHandoffHave as fs, atprotoProofRecord as ft, GROUP_TYPING_FRAME as g, readForwarded as ga, signTurnRelease as gc, HOLD_DATED_BACK_MS as gi, newPubkyProofFolder as gn, LIMITS as go, readGroupSignal as gr, readHandoffVerified as gs, normalizeAtprotoHandle as gt, readAppBundle as h, forwardedAgain as ha, signTurnPacket as hc, HOLD_CAPABILITY as hi, isPubkyProofFolder as hn, webLocalFetch as ho, groupSignalFrame as hr, readHandoffTurnFrame as hs, isAtprotoDid as ht, planAppUpdate as i, VOICE_LIMITS as ia, webCryptoSigner as ic, relayRequest as ii, assertWholeSats as in, MEMBER_KEY as io, groupFileFallback as ir, handoffReleaseFrame as is, verifyBitcoinMessage as it, knockIdentity as j, BUTTON_ID as ja, newerPacket as jc, boundVerifiedIdentity as ji, normalizeDomain as jn, serviceIdFromName as jo, pickHubs as jr, enrollDigits as js, didWebFile as jt, encodeGroupEntryLink as k, readReply as ka, RELAY_PAYLOAD_MAX_BYTES as kc, IdentityCheckUnavailable as ki, isPublicAddress as kn, HTTP_SERVICE_PROTO as ko, mergeLobby as kr, enrollCancelFrame as ks, didSignedMessages as kt, APP_BUNDLE_MAGIC as l, parseVideoMeta as la, turnPutSummary as lc, isAppVersion as li, SPARK_PROVIDER as ln, EXPECT_PEER_MS as lo, readGroupWantNo as lr, readHandoffAck as ls, ATPROTO_PROOF_COLLECTION as lt, isAppUrl as m, parseLinkPreview as ma, readTurnPacket as mc, UPGRADE_CAPABILITY as mi, isPubkyKey as mn, parseLocalTarget as mo, SIGNAL_REPLY_MS as mr, readHandoffPake as ms, checkAtprotoProofRecord as mt, appUpdateDecision as n, sanitizeMime as na, newDeviceSetSecret as nc, newWakeToken as ni, WALLET_NETWORKS as nn, GROUP_MEMBER_CAP as no, GROUP_WANT_NO_FRAME as nr, handoffOfferFrame as ns, sshSignCommand as nt, readAppStore as o, fileMessageText as oa, TURN_RAISE_READ_TIMEOUT_MS as oc, checkPushEndpoint as oi, validatePaymentTarget as on, verifyCommitSignature as oo, readGroupFileMeta as or, handoffStreamKey as os, decodeAnyBitcoinAddress as ot, checkAppManifest as p, dhtEditId as pa, nextTurnPosition as pc, STATUS_CARD_CAPABILITY as pi, PUBKY_PROOF_MAX_BYTES as pn, formatLocalTarget as po, SIGNAL_HOLD_MS as pr, readHandoffHello as ps, atprotoProofRkey as pt, parseCommunityEdit as q, appRef as qa, utf8Encode as qc, DHT_MESSAGE_TTL as qi, parseCashuRequestPayload as qn, handoffBusyFrame as qo, PIN_LIMITS as qr, inviteOwnership as qs, signDidDhtPacket as qt, checkAppBeforeRun as r, PLAYABLE_AUDIO as ra, seedSigner as rc, parseWakeFrame as ri, assertTokenUnits as rn, MAX_GROUP_CHAIN as ro, fetchesByItself as rr, handoffPakeFrame as rs, verifySshSignature as rt, signAppStore as s, parseVoiceMeta as sa, TURN_SETTLE_MS as sc, vapidKeysMatch as si, walletNetworkOf as sn, edgeParams as so, readGroupHave as sr, handoffVersions as ss, ATPROTO_DECLARED_SCOPE as st, appStoreDecision as t, sanitizeFileName as ta, devicePingFrame as tc, deviceWakeRequest as ti, USDT_PUBLIC_RPC as tn, canonicalJsonBytes as to, GROUP_WANT_FRAME as tr, handoffManifestFrames as ts, sameSshKey as tt, appDigest as u, EDIT_BUFFER_MS as ua, TURN_LAST_SEQUENCE as uc, APPS_CAPABILITY as ui, sparkAddressKind as un, RELAY_POLL_INTERVALS as uo, CarriedTransport as ur, readHandoffCancel as us, ATPROTO_PROOF_SCOPE as ut, groupTypingEpoch as v, GROUP_REACTION_FRAME as va, turnName as vc, HoldKeys as vi, pubkyProofCapability as vn, encodeControl as vo, mayBeHub as vr, SET_ACK as vs, shortAtprotoDid as vt, communityHasFrame as w, reactionIsNewer as wa, traceLink as wc, capsDescriptors as wi, decodeDnsResponse as wn, callSignalHeardAt as wo, doorHubs as wr, verifySetUpdate as ws, DidError as wt, COMMUNITY_LIMITS as x, nextReactionNumber as xa, currentRelays as xc, readManifest as xi, homeserverWebEndpoint as xn, answersOffer as xo, COMMUNITY_TOPOLOGY as xr, setAckFrame as xs, DID_DOCUMENT_MAX_BYTES as xt, openGroupTyping as y, REACTION_LIMITS as ya, DEFAULT_RELAYS as yc, HoldRefusedError as yi, pubkyProofFile as yn, CALL_SIGNAL_MAX_AGE_MS as yo, meshHubs as yr, SET_UPDATE as ys, AtprotoProofError as yt, parseGroupWakeFrame as z, arrivalKey as za, MAX_AVATAR_LENGTH as zc, PeerProofs as zi, engineError as zn, HANDOFF_PIECE_BYTES as zo, transferEnded as zr, createDeviceInvite as zs, signingKeys as zt };
|
|
26943
29078
|
|
|
26944
|
-
//# sourceMappingURL=src-
|
|
29079
|
+
//# sourceMappingURL=src-DtxVo2Rm.js.map
|