@ghostlytools/cli 1.1.3 → 1.1.5
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 +7 -2
- package/dist/assets/argon2id-088uQfnH.js.map +1 -1
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -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-CHMS7_KO.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
- package/dist/assets/{hyperdhtRelay-CHMS7_KO.js.map → hyperdhtRelay-w9cwVqGZ.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/legacy_ciphers-JJOu0JXL.js.map +1 -1
- package/dist/assets/{mainline-B0UTU0-4.js → mainline-BabMhm8L.js} +2 -2
- package/dist/assets/{mainline-B0UTU0-4.js.map → mainline-BabMhm8L.js.map} +1 -1
- package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -1
- package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -1
- package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -1
- package/dist/assets/openpgp-BBFr-zHQ.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-9AM0ta6q.js → server-TamKsfmC.js} +1601 -262
- package/dist/assets/server-TamKsfmC.js.map +1 -0
- package/dist/assets/sha512-BYId-G-T.js.map +1 -1
- package/dist/assets/{src-C_E99PGx.js → src-C6qQYrAH.js} +2190 -207
- package/dist/assets/src-C6qQYrAH.js.map +1 -0
- package/dist/assets/storage-Bya5b2Xt.js.map +1 -1
- package/dist/assets/tree-store-N362nuzZ.js.map +1 -1
- package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -1
- package/dist/assets/{web-OUQl4Q7L.js → web-DwgdhfcY.js} +16 -16
- package/dist/assets/{web-OUQl4Q7L.js.map → web-DwgdhfcY.js.map} +1 -1
- package/dist/ghostly.mjs +905 -29
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +7 -7
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-9AM0ta6q.js.map +0 -1
- package/dist/assets/src-C_E99PGx.js.map +0 -1
|
@@ -1961,13 +1961,13 @@ function turnName(name) {
|
|
|
1961
1961
|
}
|
|
1962
1962
|
return out;
|
|
1963
1963
|
}
|
|
1964
|
-
function u32$
|
|
1964
|
+
function u32$3(value) {
|
|
1965
1965
|
return Uint8Array.of(value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255);
|
|
1966
1966
|
}
|
|
1967
1967
|
/** What a release signs: `"ghostly-turn-release" || turnAddress(32) || turn(4) || toKey(32) || H(32)`. */
|
|
1968
1968
|
function turnReleaseMessage(address, turn, toKey, h) {
|
|
1969
1969
|
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$
|
|
1970
|
+
return concatBytes$1(RELEASE_DOMAIN, address, u32$3(turn), toKey, h);
|
|
1971
1971
|
}
|
|
1972
1972
|
/** What the author signs: `"ghostly-turn" || turnAddress(32) || body[0..310]`. */
|
|
1973
1973
|
function turnSignedMessage(address, unsigned) {
|
|
@@ -1985,8 +1985,8 @@ function encodeTurnFields(fields) {
|
|
|
1985
1985
|
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
1986
|
const body = /* @__PURE__ */ new Uint8Array(310);
|
|
1987
1987
|
body[OFFSET.version] = 1;
|
|
1988
|
-
body.set(u32$
|
|
1989
|
-
body.set(u32$
|
|
1988
|
+
body.set(u32$3(fields.turn), OFFSET.turn);
|
|
1989
|
+
body.set(u32$3(fields.rev).subarray(1), OFFSET.rev);
|
|
1990
1990
|
body[OFFSET.author] = fields.author;
|
|
1991
1991
|
body[OFFSET.active] = fields.active;
|
|
1992
1992
|
body[OFFSET.count] = fields.slots.filter(Boolean).length;
|
|
@@ -2238,7 +2238,7 @@ var TURN_PUT_WINDOW_MS = 1e4;
|
|
|
2238
2238
|
var TURN_RAISE_READ_TIMEOUT_MS = 5e3;
|
|
2239
2239
|
/** `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
2240
|
var TURN_SETTLE_MS = 3e4;
|
|
2241
|
-
function compareBytes$
|
|
2241
|
+
function compareBytes$3(a, b) {
|
|
2242
2242
|
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
2243
2243
|
return a.length - b.length;
|
|
2244
2244
|
}
|
|
@@ -2249,7 +2249,7 @@ function compareBytes$2(a, b) {
|
|
|
2249
2249
|
* reader still agrees.
|
|
2250
2250
|
*/
|
|
2251
2251
|
function keptAtEqualSequence(a, b) {
|
|
2252
|
-
return compareBytes$
|
|
2252
|
+
return compareBytes$3(a.record.instance, b.record.instance) || compareBytes$3(a.record.body, b.record.body) || compareBytes$3(a.payload, b.payload);
|
|
2253
2253
|
}
|
|
2254
2254
|
var kept = (f) => ({
|
|
2255
2255
|
record: f.read.record,
|
|
@@ -3368,15 +3368,15 @@ var VERSION_TEXT = /^[0-9A-Za-z.+_-]{1,40}$/;
|
|
|
3368
3368
|
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
3369
|
/** The largest part the reader accepts: a file of WISP 501 has no limit beneath this. */
|
|
3370
3370
|
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);
|
|
3371
|
+
var isCount$1 = (value, max = Number.MAX_SAFE_INTEGER) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
3372
|
+
var isText$2 = (value, pattern) => typeof value === "string" && pattern.test(value);
|
|
3373
3373
|
var isHandoffPartName = (name) => typeof name === "string" && PART.test(name);
|
|
3374
3374
|
/** The part a file is: `file/<its SHA-256, base64url>`. */
|
|
3375
3375
|
var filePartName = (sha256B64) => `file/${sha256B64}`;
|
|
3376
3376
|
/** The digest a file part names, or null for any other part. */
|
|
3377
3377
|
var filePartDigest = (name) => name.startsWith("file/") && DIGEST$1.test(name.slice(5)) ? name.slice(5) : null;
|
|
3378
3378
|
function isPart(value) {
|
|
3379
|
-
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount(value[1], MAX_PART) && isText(value[2], DIGEST$1) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
|
|
3379
|
+
return Array.isArray(value) && value.length === 3 && isHandoffPartName(value[0]) && isCount$1(value[1], MAX_PART) && isText$2(value[2], DIGEST$1) && (filePartDigest(value[0]) === null || filePartDigest(value[0]) === value[2]);
|
|
3380
3380
|
}
|
|
3381
3381
|
/** Names compared as UTF-8 byte strings, as `H` sorts them. */
|
|
3382
3382
|
function compareNames(a, b) {
|
|
@@ -3404,7 +3404,7 @@ function sortParts(parts) {
|
|
|
3404
3404
|
* and every part the taker already held and keeps, as `[name, size, sha256]` sorted by name. Keys are base64url.
|
|
3405
3405
|
*/
|
|
3406
3406
|
function handoffDigest(turn, fromKey, toKey, parts) {
|
|
3407
|
-
if (!isCount(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3407
|
+
if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3408
3408
|
if (fromKey.length !== 32 || toKey.length !== 32) throw new Error("A device signing key is 32 bytes");
|
|
3409
3409
|
for (const part of parts) if (!isPart(part)) throw new Error("A part is [name, size, sha256]");
|
|
3410
3410
|
return sha256(utf8Encode(JSON.stringify([
|
|
@@ -3418,7 +3418,7 @@ function handoffDigest(turn, fromKey, toKey, parts) {
|
|
|
3418
3418
|
/** What `handoff-verified` signs: `["ghostly-handoff-verified", turnAddress, N + 1, H]`, the bytes in base64url. */
|
|
3419
3419
|
function handoffVerifiedMessage(turnAddress, turn, h) {
|
|
3420
3420
|
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");
|
|
3421
|
+
if (!isCount$1(turn, TURN_MAX)) throw new Error("A turn is at most 2^32 - 2");
|
|
3422
3422
|
return utf8Encode(JSON.stringify([
|
|
3423
3423
|
"ghostly-handoff-verified",
|
|
3424
3424
|
toBase64Url(turnAddress),
|
|
@@ -3470,7 +3470,7 @@ function handoffStreamKey(ownSecret, peerPublic, k, transcriptHash) {
|
|
|
3470
3470
|
* does not open there. (XSalsa20-Poly1305 takes no associated data; the header inside the box does that job.)
|
|
3471
3471
|
*/
|
|
3472
3472
|
function pieceHeader(part, offset) {
|
|
3473
|
-
if (!isCount(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
|
|
3473
|
+
if (!isCount$1(offset, MAX_PART)) throw new Error("A piece's offset is a whole number of bytes");
|
|
3474
3474
|
const header = /* @__PURE__ */ new Uint8Array(16);
|
|
3475
3475
|
header.set(sha256(utf8Encode(`ghostly-handoff-piece/1${part}`)).subarray(0, 8), 0);
|
|
3476
3476
|
new DataView(header.buffer).setBigUint64(8, BigInt(offset));
|
|
@@ -3484,7 +3484,7 @@ function sealHandoffPiece(key, part, offset, piece, nonce = randomBytes(24)) {
|
|
|
3484
3484
|
if (key.length !== 32 || nonce.length !== 24) throw new Error("A stream key is 32 bytes and a nonce 24");
|
|
3485
3485
|
return concatBytes$1(nonce, xsalsa20poly1305(key, nonce).encrypt(concatBytes$1(pieceHeader(part, offset), piece)));
|
|
3486
3486
|
}
|
|
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));
|
|
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$2(version, VERSION_TEXT));
|
|
3488
3488
|
function handoffHelloFrame(hello) {
|
|
3489
3489
|
return {
|
|
3490
3490
|
t: HANDOFF_HELLO,
|
|
@@ -3493,12 +3493,12 @@ function handoffHelloFrame(hello) {
|
|
|
3493
3493
|
}
|
|
3494
3494
|
function readHandoffHello(frame) {
|
|
3495
3495
|
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;
|
|
3496
|
+
if (!isText$2(frame.e, KEY$3) || fromBase64Url(frame.e).length !== 32) return null;
|
|
3497
|
+
if (!isText$2(frame.app, VERSION_TEXT) || !isCount$1(frame.db, 1e6) || !isPins(frame.pins)) return null;
|
|
3498
3498
|
if (typeof frame.room !== "number" || !Number.isSafeInteger(frame.room) || frame.room < -1) return null;
|
|
3499
3499
|
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;
|
|
3500
|
+
if (frame.later !== void 0 && !isCount$1(frame.later)) return null;
|
|
3501
|
+
if (frame.id !== void 0 && !isText$2(frame.id, ID$5)) return null;
|
|
3502
3502
|
const kind = DEVICE_KINDS.includes(frame.kind) ? frame.kind : "web";
|
|
3503
3503
|
return {
|
|
3504
3504
|
v: 1,
|
|
@@ -3524,8 +3524,8 @@ var handoffOfferFrame = (turn, id, bytes) => ({
|
|
|
3524
3524
|
});
|
|
3525
3525
|
function readHandoffTurnFrame(frame) {
|
|
3526
3526
|
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) ? {
|
|
3527
|
+
if (!isCount$1(frame.turn, TURN_MAX) || !isText$2(frame.id, ID$5)) return null;
|
|
3528
|
+
if (frame.t === "handoff-offer") return isCount$1(frame.bytes) ? {
|
|
3529
3529
|
turn: frame.turn,
|
|
3530
3530
|
id: frame.id,
|
|
3531
3531
|
bytes: frame.bytes
|
|
@@ -3558,8 +3558,8 @@ function readHandoffPake(frame) {
|
|
|
3558
3558
|
n: 3,
|
|
3559
3559
|
wrong: true
|
|
3560
3560
|
} : 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;
|
|
3561
|
+
if (!isText$2(frame.m, B64$4) || !frame.m.length || frame.m.length > 4096) return null;
|
|
3562
|
+
if (frame.n === 3 && !isText$2(frame.c, KEY$3)) return null;
|
|
3563
3563
|
return {
|
|
3564
3564
|
n: frame.n,
|
|
3565
3565
|
m: frame.m,
|
|
@@ -3567,12 +3567,12 @@ function readHandoffPake(frame) {
|
|
|
3567
3567
|
};
|
|
3568
3568
|
}
|
|
3569
3569
|
function readHandoffHave(frame) {
|
|
3570
|
-
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText(d, DIGEST$1))) return null;
|
|
3570
|
+
if (frame.t !== "handoff-have" || !Array.isArray(frame.d) || frame.d.length > 300 || !frame.d.every((d) => isText$2(d, DIGEST$1))) return null;
|
|
3571
3571
|
const p = {};
|
|
3572
3572
|
if (frame.p !== void 0) {
|
|
3573
3573
|
if (!frame.p || typeof frame.p !== "object" || Array.isArray(frame.p) || Object.keys(frame.p).length > 300) return null;
|
|
3574
3574
|
for (const [name, offset] of Object.entries(frame.p)) {
|
|
3575
|
-
if (!isHandoffPartName(name) || !isCount(offset, MAX_PART)) return null;
|
|
3575
|
+
if (!isHandoffPartName(name) || !isCount$1(offset, MAX_PART)) return null;
|
|
3576
3576
|
p[name] = offset;
|
|
3577
3577
|
}
|
|
3578
3578
|
}
|
|
@@ -3615,7 +3615,7 @@ var handoffDataFrame = (part, offset, sealed) => ({
|
|
|
3615
3615
|
d: toBase64Url(sealed)
|
|
3616
3616
|
});
|
|
3617
3617
|
function readHandoffAck(frame) {
|
|
3618
|
-
if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount(frame.o, MAX_PART)) return null;
|
|
3618
|
+
if (frame.t !== "handoff-ack" || !isHandoffPartName(frame.p) || !isCount$1(frame.o, MAX_PART)) return null;
|
|
3619
3619
|
return {
|
|
3620
3620
|
part: frame.p,
|
|
3621
3621
|
offset: frame.o
|
|
@@ -3623,7 +3623,7 @@ function readHandoffAck(frame) {
|
|
|
3623
3623
|
}
|
|
3624
3624
|
/** `H` of a `handoff-verified` that B's key signed for this turn address and turn, or null. */
|
|
3625
3625
|
function readHandoffVerified(frame, turnAddress, turn, takerKey) {
|
|
3626
|
-
if (frame.t !== "handoff-verified" || !isText(frame.h, DIGEST$1) || !isText(frame.s, SIGNATURE)) return null;
|
|
3626
|
+
if (frame.t !== "handoff-verified" || !isText$2(frame.h, DIGEST$1) || !isText$2(frame.s, SIGNATURE)) return null;
|
|
3627
3627
|
const h = fromBase64Url(frame.h);
|
|
3628
3628
|
try {
|
|
3629
3629
|
return verify(fromBase64Url(frame.s), handoffVerifiedMessage(turnAddress, turn, h), takerKey) ? h : null;
|
|
@@ -3636,7 +3636,7 @@ var handoffReleaseFrame = (release) => ({
|
|
|
3636
3636
|
...release
|
|
3637
3637
|
});
|
|
3638
3638
|
function readHandoffDone(frame) {
|
|
3639
|
-
return frame.t === "handoff-done" && isCount(frame.seq) ? frame.seq : null;
|
|
3639
|
+
return frame.t === "handoff-done" && isCount$1(frame.seq) ? frame.seq : null;
|
|
3640
3640
|
}
|
|
3641
3641
|
/**
|
|
3642
3642
|
* Whether two devices can hand over (WISP 06 § Versions), seen from the taker: a taker whose database is older than
|
|
@@ -4349,7 +4349,7 @@ function buildSdpFromSignal(signal) {
|
|
|
4349
4349
|
function sdpHasCandidates(sdp) {
|
|
4350
4350
|
return /^a=candidate:/m.test(sdp ?? "");
|
|
4351
4351
|
}
|
|
4352
|
-
/** How long to keep collecting after the candidate we were waiting for showed up. */
|
|
4352
|
+
/** How long to keep collecting after the candidate we were waiting for showed up (a call's; a chat's data link sets its own). */
|
|
4353
4353
|
var ICE_SETTLE_MS = 400;
|
|
4354
4354
|
/** IPv4 ranges that are not reachable from the internet: this network, private, shared (CGNAT), loopback, link-local, the documentation ones, benchmarking, multicast and up. */
|
|
4355
4355
|
var NOT_GLOBAL_IPV4 = [
|
|
@@ -4393,7 +4393,7 @@ function isGlobalIpv4Host(candidate) {
|
|
|
4393
4393
|
* (`isGlobalIpv4Host`): a device with no NAT gets none, and its offers waited
|
|
4394
4394
|
* the whole timeout whenever gathering did not complete.
|
|
4395
4395
|
*/
|
|
4396
|
-
function waitForIceGathering(pc, timeoutMs = 1e4, { stallMs, fresh = false } = {}) {
|
|
4396
|
+
function waitForIceGathering(pc, timeoutMs = 1e4, { stallMs, fresh = false, settleMs = ICE_SETTLE_MS, hostBoundMs } = {}) {
|
|
4397
4397
|
return new Promise((resolve) => {
|
|
4398
4398
|
if (pc.iceGatheringState === "complete" && (!fresh || sdpHasCandidates(pc.localDescription?.sdp))) {
|
|
4399
4399
|
resolve();
|
|
@@ -4406,6 +4406,7 @@ function waitForIceGathering(pc, timeoutMs = 1e4, { stallMs, fresh = false } = {
|
|
|
4406
4406
|
const finish = () => {
|
|
4407
4407
|
clearTimeout(timeout);
|
|
4408
4408
|
if (stalled) clearTimeout(stalled);
|
|
4409
|
+
if (bounded) clearTimeout(bounded);
|
|
4409
4410
|
if (settle) clearTimeout(settle);
|
|
4410
4411
|
pc.removeEventListener("icegatheringstatechange", onState);
|
|
4411
4412
|
pc.removeEventListener("icecandidate", onCandidate);
|
|
@@ -4415,13 +4416,16 @@ function waitForIceGathering(pc, timeoutMs = 1e4, { stallMs, fresh = false } = {
|
|
|
4415
4416
|
const stalled = stallMs === void 0 ? null : setTimeout(() => {
|
|
4416
4417
|
if (!found && !sdpHasCandidates(pc.localDescription?.sdp)) finish();
|
|
4417
4418
|
}, stallMs);
|
|
4419
|
+
const bounded = hostBoundMs === void 0 ? null : setTimeout(() => {
|
|
4420
|
+
if (found || sdpHasCandidates(pc.localDescription?.sdp)) finish();
|
|
4421
|
+
}, hostBoundMs);
|
|
4418
4422
|
const onState = () => {
|
|
4419
4423
|
if (pc.iceGatheringState === "complete" && (!fresh || sdpHasCandidates(pc.localDescription?.sdp))) finish();
|
|
4420
4424
|
};
|
|
4421
4425
|
const onCandidate = (event) => {
|
|
4422
4426
|
if (event.candidate) found = true;
|
|
4423
4427
|
const line = event.candidate?.candidate;
|
|
4424
|
-
if (!settle && line && (line.includes(wanted) || !usesTurn && isGlobalIpv4Host(line))) settle = setTimeout(finish,
|
|
4428
|
+
if (!settle && line && (line.includes(wanted) || !usesTurn && isGlobalIpv4Host(line))) settle = setTimeout(finish, settleMs);
|
|
4425
4429
|
};
|
|
4426
4430
|
pc.addEventListener("icegatheringstatechange", onState);
|
|
4427
4431
|
pc.addEventListener("icecandidate", onCandidate);
|
|
@@ -6087,11 +6091,27 @@ var DirectPathWatch = class {
|
|
|
6087
6091
|
this.onChange(blocked);
|
|
6088
6092
|
}
|
|
6089
6093
|
};
|
|
6094
|
+
//#endregion
|
|
6095
|
+
//#region ../core/src/datalink.ts
|
|
6096
|
+
/**
|
|
6097
|
+
* An attempt ended from outside (another transport went live first) that had both descriptions for this long and no
|
|
6098
|
+
* connection says what one that failed does: a path that exists connects within a second or two.
|
|
6099
|
+
*/
|
|
6100
|
+
var STALLED_EVIDENCE_MS = 5e3;
|
|
6090
6101
|
/** A candidate another network can reach: server reflexive (STUN answered), relayed (TURN), or learnt from the peer. */
|
|
6091
6102
|
var PUBLIC_CANDIDATE = /^a=candidate:.* typ (srflx|relay|prflx)\b/m;
|
|
6092
6103
|
var CONNECT_TIMEOUT_MS = 9e4;
|
|
6093
6104
|
var ICE_GATHERING_TIMEOUT_MS = 5e3;
|
|
6094
6105
|
/**
|
|
6106
|
+
* An offer with its host candidates waits this long at most for a server reflexive one (STUN answered), not
|
|
6107
|
+
* `ICE_GATHERING_TIMEOUT_MS`. Where the STUN servers answer, they do in some 50 ms (0.44 s an offer took here, with the
|
|
6108
|
+
* settle); where they do not, every offer of a first pairing waited the full 5 s, twice when the inviter offered too
|
|
6109
|
+
* (One's e2e runs: 13 offers of 89 at 5 s, 9 more at 1.3-3 s, 2026-10-05). An offer without one still connects to most
|
|
6110
|
+
* contacts (their checks learn this side's address from its own); behind a restricted NAT on the contact's side it may
|
|
6111
|
+
* not, and the next offer of the chat then waits as long as it takes (`fullGather`).
|
|
6112
|
+
*/
|
|
6113
|
+
var OFFER_HOST_GATHER_MS = 2e3;
|
|
6114
|
+
/**
|
|
6095
6115
|
* A connection with no candidate at all by then has stalled: Chromium, rarely and under load, gathers none, and an
|
|
6096
6116
|
* offer or answer without one can never connect. It is made again in its place, up to `GATHER_ATTEMPTS` in all.
|
|
6097
6117
|
*/
|
|
@@ -6105,6 +6125,13 @@ var DataLink = class {
|
|
|
6105
6125
|
state = "idle";
|
|
6106
6126
|
pc = null;
|
|
6107
6127
|
myOfferTs = 0;
|
|
6128
|
+
/**
|
|
6129
|
+
* An offer of this side's that went without a public candidate came to nothing (it was answered, and no path
|
|
6130
|
+
* connected): the next one waits for its STUN servers as long as it takes (`ICE_GATHERING_TIMEOUT_MS`).
|
|
6131
|
+
*/
|
|
6132
|
+
fullGather = false;
|
|
6133
|
+
/** The offer out has no public candidate (as it went: the connection's description gains any that come later). */
|
|
6134
|
+
offerLacksPublic = false;
|
|
6108
6135
|
lastSignalTs = 0;
|
|
6109
6136
|
/** The peer's description as this connection was given it: Chrome shows it as `remoteDescription` only once applied. */
|
|
6110
6137
|
remoteSdp = null;
|
|
@@ -6114,21 +6141,41 @@ var DataLink = class {
|
|
|
6114
6141
|
disconnectTimer = null;
|
|
6115
6142
|
/** The offer this side answered in the current attempt, when it took it (this clock), and how many times it answered it again. */
|
|
6116
6143
|
answered = null;
|
|
6144
|
+
/** Answers refused since a connection last opened, each followed by a new offer at once (`ANSWER_REFUSED_REDIALS`). */
|
|
6145
|
+
refusedAnswers = 0;
|
|
6117
6146
|
constructor(options) {
|
|
6118
6147
|
this.options = options;
|
|
6119
6148
|
}
|
|
6120
6149
|
/** Offers a connection to the peer. Resolves once the offer is published. */
|
|
6121
|
-
|
|
6150
|
+
/**
|
|
6151
|
+
* Makes an offer and publishes it. `gate`: gathered ahead, while the caller still finds out whether the peer is there
|
|
6152
|
+
* (a joiner reading the inviter's record): the link stays idle meanwhile, and the offer goes once the gate says yes.
|
|
6153
|
+
* On no, the connection is dropped, nothing having been said, and `"held"` is the answer.
|
|
6154
|
+
*/
|
|
6155
|
+
async connect(gate) {
|
|
6122
6156
|
if (this.state !== "idle") return;
|
|
6123
6157
|
this.answered = null;
|
|
6124
|
-
|
|
6125
|
-
|
|
6158
|
+
if (!gate) {
|
|
6159
|
+
this.setState("offering");
|
|
6160
|
+
this.options.setFastPoll(true, true);
|
|
6161
|
+
}
|
|
6126
6162
|
try {
|
|
6127
6163
|
const pc = await this.gathered(true, async (pc) => {
|
|
6128
6164
|
await pc.setLocalDescription(await pc.createOffer());
|
|
6129
6165
|
});
|
|
6130
6166
|
if (!pc) return;
|
|
6167
|
+
if (gate) {
|
|
6168
|
+
const go = await gate;
|
|
6169
|
+
if (this.pc !== pc || this.state !== "idle") return;
|
|
6170
|
+
if (!go) {
|
|
6171
|
+
this.teardown();
|
|
6172
|
+
return "held";
|
|
6173
|
+
}
|
|
6174
|
+
this.setState("offering");
|
|
6175
|
+
this.options.setFastPoll(true, true);
|
|
6176
|
+
}
|
|
6131
6177
|
this.myOfferTs = Date.now();
|
|
6178
|
+
this.offerLacksPublic = !PUBLIC_CANDIDATE.test(pc.localDescription?.sdp ?? "");
|
|
6132
6179
|
const signal = {
|
|
6133
6180
|
t: "o",
|
|
6134
6181
|
ts: this.myOfferTs,
|
|
@@ -6201,8 +6248,8 @@ var DataLink = class {
|
|
|
6201
6248
|
sdp: this.remoteSdp
|
|
6202
6249
|
});
|
|
6203
6250
|
if (this.pc === pc && this.state === "offering") this.setState("connecting");
|
|
6204
|
-
} catch {
|
|
6205
|
-
if (this.pc === pc) this.
|
|
6251
|
+
} catch (error) {
|
|
6252
|
+
if (this.pc === pc) this.answerRefused(error);
|
|
6206
6253
|
}
|
|
6207
6254
|
}
|
|
6208
6255
|
}
|
|
@@ -6221,11 +6268,17 @@ var DataLink = class {
|
|
|
6221
6268
|
}
|
|
6222
6269
|
}
|
|
6223
6270
|
close() {
|
|
6224
|
-
const
|
|
6271
|
+
const stalled = Date.now() - this.connectingSince >= STALLED_EVIDENCE_MS;
|
|
6272
|
+
const evidence = stalled ? this.directEvidence() : null;
|
|
6273
|
+
this.noteNoPublic(stalled);
|
|
6225
6274
|
this.answered = null;
|
|
6226
6275
|
this.reset();
|
|
6227
6276
|
if (evidence) this.options.onDirect?.(evidence);
|
|
6228
6277
|
}
|
|
6278
|
+
/** This side's offer went with no public candidate, was answered, and came to nothing: the next offer gathers for as long as it takes. */
|
|
6279
|
+
noteNoPublic(ended) {
|
|
6280
|
+
if (ended && this.myOfferTs && this.offerLacksPublic && this.state === "connecting") this.fullGather = true;
|
|
6281
|
+
}
|
|
6229
6282
|
/**
|
|
6230
6283
|
* What this attempt says about direct connections if it ends now without opening: nothing unless both descriptions
|
|
6231
6284
|
* were exchanged (this side's offer answered, or its answer out). Then, what this device gathered: no public
|
|
@@ -6242,9 +6295,14 @@ var DataLink = class {
|
|
|
6242
6295
|
* else goes back to idle.
|
|
6243
6296
|
*/
|
|
6244
6297
|
failed() {
|
|
6298
|
+
if (this.state === "offering" && this.remoteSdp) {
|
|
6299
|
+
this.answerRefused("failed");
|
|
6300
|
+
return;
|
|
6301
|
+
}
|
|
6245
6302
|
const answered = this.state === "connecting" ? this.answered : null;
|
|
6246
6303
|
const left = answered ? CONNECT_TIMEOUT_MS - (Date.now() - answered.at) : 0;
|
|
6247
6304
|
const evidence = this.directEvidence();
|
|
6305
|
+
this.noteNoPublic(true);
|
|
6248
6306
|
if (answered && answered.again < 2 && left > REANSWER_MARGIN_MS && this.options.offerStanding?.(answered.offer.ts)) {
|
|
6249
6307
|
answered.again++;
|
|
6250
6308
|
traceLink(this.options.myPubKeyZ32, "reanswer", {
|
|
@@ -6258,6 +6316,18 @@ var DataLink = class {
|
|
|
6258
6316
|
this.reset();
|
|
6259
6317
|
if (evidence) this.options.onDirect?.(evidence);
|
|
6260
6318
|
}
|
|
6319
|
+
/** The answer to this side's offer did not go in (`ANSWER_REFUSED_REDIALS`): the attempt ends, and the caller may offer again now. */
|
|
6320
|
+
answerRefused(error) {
|
|
6321
|
+
const again = this.refusedAnswers < 4;
|
|
6322
|
+
traceLink(this.options.myPubKeyZ32, "answer-refused", {
|
|
6323
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6324
|
+
again
|
|
6325
|
+
});
|
|
6326
|
+
this.reset();
|
|
6327
|
+
if (!again) return;
|
|
6328
|
+
this.refusedAnswers++;
|
|
6329
|
+
this.options.onAnswerReplaced?.();
|
|
6330
|
+
}
|
|
6261
6331
|
async answer(offer) {
|
|
6262
6332
|
this.setState("answering");
|
|
6263
6333
|
this.options.setFastPoll(true);
|
|
@@ -6296,7 +6366,11 @@ var DataLink = class {
|
|
|
6296
6366
|
await describe(pc);
|
|
6297
6367
|
if (this.pc !== pc) return null;
|
|
6298
6368
|
const gathering = Date.now();
|
|
6299
|
-
await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
|
|
6369
|
+
await waitForIceGathering(pc, ICE_GATHERING_TIMEOUT_MS, {
|
|
6370
|
+
stallMs: GATHER_STALL_MS,
|
|
6371
|
+
settleMs: 150,
|
|
6372
|
+
...offer && !this.fullGather ? { hostBoundMs: OFFER_HOST_GATHER_MS } : {}
|
|
6373
|
+
});
|
|
6300
6374
|
if (this.pc !== pc) return null;
|
|
6301
6375
|
const ms = Date.now() - gathering;
|
|
6302
6376
|
if (sdpHasCandidates(pc.localDescription?.sdp)) {
|
|
@@ -6332,6 +6406,8 @@ var DataLink = class {
|
|
|
6332
6406
|
if (this.pc !== pc) return;
|
|
6333
6407
|
this.clearTimers();
|
|
6334
6408
|
this.answered = null;
|
|
6409
|
+
this.refusedAnswers = 0;
|
|
6410
|
+
this.fullGather = false;
|
|
6335
6411
|
this.setState("open");
|
|
6336
6412
|
this.options.setFastPoll(false);
|
|
6337
6413
|
this.options.publishSignal(null);
|
|
@@ -6704,6 +6780,311 @@ function rememberAlone(alone, key, present) {
|
|
|
6704
6780
|
}
|
|
6705
6781
|
var GROUP_ADMIN_OFF_ERROR = "Managing this group is off on this device. Turn on Manage groups from this device first.";
|
|
6706
6782
|
//#endregion
|
|
6783
|
+
//#region ../core/src/canonicalJson.ts
|
|
6784
|
+
/** True when `s` holds a lone UTF-16 surrogate, which I-JSON (and so JCS) does not allow. */
|
|
6785
|
+
function hasLoneSurrogate(s) {
|
|
6786
|
+
for (let i = 0; i < s.length; i++) {
|
|
6787
|
+
const c = s.charCodeAt(i);
|
|
6788
|
+
if (c >= 55296 && c <= 56319) {
|
|
6789
|
+
const next = s.charCodeAt(i + 1);
|
|
6790
|
+
if (next >= 56320 && next <= 57343) {
|
|
6791
|
+
i++;
|
|
6792
|
+
continue;
|
|
6793
|
+
}
|
|
6794
|
+
return true;
|
|
6795
|
+
}
|
|
6796
|
+
if (c >= 56320 && c <= 57343) return true;
|
|
6797
|
+
}
|
|
6798
|
+
return false;
|
|
6799
|
+
}
|
|
6800
|
+
function write(value, depth) {
|
|
6801
|
+
if (depth > 64) throw new Error("JSON nested deeper than 64 levels");
|
|
6802
|
+
if (value === null) return "null";
|
|
6803
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
6804
|
+
if (typeof value === "number") {
|
|
6805
|
+
if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
|
|
6806
|
+
return JSON.stringify(value);
|
|
6807
|
+
}
|
|
6808
|
+
if (typeof value === "string") {
|
|
6809
|
+
if (hasLoneSurrogate(value)) throw new Error("A JSON string holds a lone surrogate");
|
|
6810
|
+
return JSON.stringify(value);
|
|
6811
|
+
}
|
|
6812
|
+
if (Array.isArray(value)) return `[${value.map((v) => write(v, depth + 1)).join(",")}]`;
|
|
6813
|
+
if (typeof value === "object") {
|
|
6814
|
+
const record = value;
|
|
6815
|
+
return `{${Object.keys(value).filter((k) => record[k] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((k) => {
|
|
6816
|
+
if (hasLoneSurrogate(k)) throw new Error("A JSON key holds a lone surrogate");
|
|
6817
|
+
return `${JSON.stringify(k)}:${write(record[k], depth + 1)}`;
|
|
6818
|
+
}).join(",")}}`;
|
|
6819
|
+
}
|
|
6820
|
+
throw new Error(`Not a JSON value: ${typeof value}`);
|
|
6821
|
+
}
|
|
6822
|
+
/** The canonical text of a JSON value. Throws on a value JSON cannot hold (a non-finite number, a lone surrogate). */
|
|
6823
|
+
function canonicalJson(value) {
|
|
6824
|
+
return write(value, 0);
|
|
6825
|
+
}
|
|
6826
|
+
/** The canonical UTF-8 bytes of a JSON value. */
|
|
6827
|
+
function canonicalJsonBytes(value) {
|
|
6828
|
+
return utf8Encode(canonicalJson(value));
|
|
6829
|
+
}
|
|
6830
|
+
/**
|
|
6831
|
+
* Reads bytes that must be the canonical form of a JSON value: `not-json` when they are not UTF-8 JSON, `not-canonical`
|
|
6832
|
+
* when writing the value again gives other bytes.
|
|
6833
|
+
*/
|
|
6834
|
+
function readCanonicalJson(bytes) {
|
|
6835
|
+
let value;
|
|
6836
|
+
try {
|
|
6837
|
+
value = JSON.parse(utf8Decode(bytes));
|
|
6838
|
+
} catch (error) {
|
|
6839
|
+
return {
|
|
6840
|
+
ok: false,
|
|
6841
|
+
reason: "not-json",
|
|
6842
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
6843
|
+
};
|
|
6844
|
+
}
|
|
6845
|
+
let again;
|
|
6846
|
+
try {
|
|
6847
|
+
again = canonicalJsonBytes(value);
|
|
6848
|
+
} catch (error) {
|
|
6849
|
+
return {
|
|
6850
|
+
ok: false,
|
|
6851
|
+
reason: "not-canonical",
|
|
6852
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
6853
|
+
};
|
|
6854
|
+
}
|
|
6855
|
+
if (!bytesEqual(again, bytes)) return {
|
|
6856
|
+
ok: false,
|
|
6857
|
+
reason: "not-canonical"
|
|
6858
|
+
};
|
|
6859
|
+
return {
|
|
6860
|
+
ok: true,
|
|
6861
|
+
value
|
|
6862
|
+
};
|
|
6863
|
+
}
|
|
6864
|
+
//#endregion
|
|
6865
|
+
//#region ../core/src/appStatements.ts
|
|
6866
|
+
/** The prefixes of phase 1. The others of the WISP (reports, reviews, licences, rotation, publisher proofs) come later. */
|
|
6867
|
+
var APP_PREFIXES = {
|
|
6868
|
+
app: "ghostly-app/1",
|
|
6869
|
+
store: "ghostly-store/1",
|
|
6870
|
+
revoke: "ghostly-revoke/1"
|
|
6871
|
+
};
|
|
6872
|
+
var APP_STATEMENT_LIMITS = {
|
|
6873
|
+
/** A signature statement's canonical bytes: 52 + 86 characters of values and the keys around them. */
|
|
6874
|
+
signatureBytes: 1024,
|
|
6875
|
+
/** Digests named by one revocation. */
|
|
6876
|
+
revokeDigests: 64,
|
|
6877
|
+
/** A revocation's reason, in characters. */
|
|
6878
|
+
reason: 200,
|
|
6879
|
+
/** Revocations in one `ghostly-revoke.json`. */
|
|
6880
|
+
revocations: 4096
|
|
6881
|
+
};
|
|
6882
|
+
/** A z-base32 Ed25519 public key: 52 characters that decode to 32 bytes and encode back to the same text. */
|
|
6883
|
+
function isAppKey(value) {
|
|
6884
|
+
if (typeof value !== "string" || !/^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(value)) return false;
|
|
6885
|
+
const bytes = fromZ32(value);
|
|
6886
|
+
return bytes.length === 32 && toZ32(bytes) === value;
|
|
6887
|
+
}
|
|
6888
|
+
/** A SHA-256 in base64url without padding: 43 characters that decode to 32 bytes and encode back to the same text. */
|
|
6889
|
+
function isAppHash(value) {
|
|
6890
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
6891
|
+
return toBase64Url(fromBase64Url(value)) === value;
|
|
6892
|
+
}
|
|
6893
|
+
/** An app reference, `<publisher key>/<name>`. */
|
|
6894
|
+
var APP_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
6895
|
+
function isAppRef(value) {
|
|
6896
|
+
if (typeof value !== "string") return false;
|
|
6897
|
+
return value.indexOf("/") === 52 && isAppKey(value.slice(0, 52)) && APP_NAME.test(value.slice(53));
|
|
6898
|
+
}
|
|
6899
|
+
function appRef(publisher, name) {
|
|
6900
|
+
return `${publisher}/${name}`;
|
|
6901
|
+
}
|
|
6902
|
+
/** The publisher key of a reference. */
|
|
6903
|
+
function appRefPublisher(ref) {
|
|
6904
|
+
return ref.slice(0, 52);
|
|
6905
|
+
}
|
|
6906
|
+
/**
|
|
6907
|
+
* The publisher fingerprint people see: the first 16 z-base32 characters of the key (80 bits), in four groups.
|
|
6908
|
+
*/
|
|
6909
|
+
function appFingerprint(key) {
|
|
6910
|
+
return (key.slice(0, 16).match(/.{1,4}/g) ?? []).join(" ");
|
|
6911
|
+
}
|
|
6912
|
+
/** The bytes a signature covers: the prefix, a zero byte, then the SHA-256 of the object's canonical bytes. */
|
|
6913
|
+
function appSignedBytes(prefix, canonical) {
|
|
6914
|
+
const p = utf8Encode(prefix);
|
|
6915
|
+
const out = new Uint8Array(p.length + 1 + 32);
|
|
6916
|
+
out.set(p, 0);
|
|
6917
|
+
out.set(sha256(canonical), p.length + 1);
|
|
6918
|
+
return out;
|
|
6919
|
+
}
|
|
6920
|
+
/** Signs an object under a prefix: its canonical bytes and the signature statement. */
|
|
6921
|
+
async function signAppObject(prefix, value, signer) {
|
|
6922
|
+
const bytes = canonicalJsonBytes(value);
|
|
6923
|
+
const sig = await signer.sign(appSignedBytes(prefix, bytes));
|
|
6924
|
+
return {
|
|
6925
|
+
bytes,
|
|
6926
|
+
signature: {
|
|
6927
|
+
alg: "ed25519",
|
|
6928
|
+
key: toZ32(signer.publicKey),
|
|
6929
|
+
sig: toBase64Url(sig)
|
|
6930
|
+
}
|
|
6931
|
+
};
|
|
6932
|
+
}
|
|
6933
|
+
/**
|
|
6934
|
+
* Ed25519 verification as RFC 8032 writes it (`zip215: false`): canonical encodings of the point and the scalar only,
|
|
6935
|
+
* so every parser (this one, a Rust one with `verify_strict`) accepts exactly the same signatures.
|
|
6936
|
+
*/
|
|
6937
|
+
function strictVerify(signature, message, publicKey) {
|
|
6938
|
+
try {
|
|
6939
|
+
return ed25519.verify(signature, message, publicKey, { zip215: false });
|
|
6940
|
+
} catch {
|
|
6941
|
+
return false;
|
|
6942
|
+
}
|
|
6943
|
+
}
|
|
6944
|
+
var exactKeys$1 = (value, keys, optional = []) => {
|
|
6945
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
6946
|
+
const have = Object.keys(value);
|
|
6947
|
+
return keys.every((k) => have.includes(k)) && have.every((k) => keys.includes(k) || optional.includes(k));
|
|
6948
|
+
};
|
|
6949
|
+
/** Checks the shape of a signature statement: exactly `alg`, `key` and `sig`, a 64-byte signature. */
|
|
6950
|
+
function isAppSignature(value) {
|
|
6951
|
+
if (!exactKeys$1(value, [
|
|
6952
|
+
"alg",
|
|
6953
|
+
"key",
|
|
6954
|
+
"sig"
|
|
6955
|
+
])) return false;
|
|
6956
|
+
if (value.alg !== "ed25519" || !isAppKey(value.key)) return false;
|
|
6957
|
+
return typeof value.sig === "string" && /^[A-Za-z0-9_-]{86}$/.test(value.sig) && toBase64Url(fromBase64Url(value.sig)) === value.sig;
|
|
6958
|
+
}
|
|
6959
|
+
/** Reads a signature statement from its bytes, which must be canonical. */
|
|
6960
|
+
function readAppSignature(bytes) {
|
|
6961
|
+
if (bytes.length > APP_STATEMENT_LIMITS.signatureBytes) return {
|
|
6962
|
+
ok: false,
|
|
6963
|
+
reason: "bad-signature-statement",
|
|
6964
|
+
detail: "Too large"
|
|
6965
|
+
};
|
|
6966
|
+
const read = readCanonicalJson(bytes);
|
|
6967
|
+
if (!read.ok) return {
|
|
6968
|
+
ok: false,
|
|
6969
|
+
reason: "bad-signature-statement",
|
|
6970
|
+
detail: read.reason
|
|
6971
|
+
};
|
|
6972
|
+
if (!isAppSignature(read.value)) return {
|
|
6973
|
+
ok: false,
|
|
6974
|
+
reason: "bad-signature-statement",
|
|
6975
|
+
detail: "Not {alg, key, sig}"
|
|
6976
|
+
};
|
|
6977
|
+
return {
|
|
6978
|
+
ok: true,
|
|
6979
|
+
signature: read.value
|
|
6980
|
+
};
|
|
6981
|
+
}
|
|
6982
|
+
/**
|
|
6983
|
+
* Verifies a signature over an object's canonical bytes under a prefix, made by `key`: `signature-key` when the
|
|
6984
|
+
* statement names another key, `bad-signature` when it does not verify.
|
|
6985
|
+
*/
|
|
6986
|
+
function verifyAppSignature(prefix, canonical, signature, key) {
|
|
6987
|
+
if (!isAppSignature(signature)) return {
|
|
6988
|
+
ok: false,
|
|
6989
|
+
reason: "bad-signature-statement"
|
|
6990
|
+
};
|
|
6991
|
+
if (signature.key !== key) return {
|
|
6992
|
+
ok: false,
|
|
6993
|
+
reason: "signature-key"
|
|
6994
|
+
};
|
|
6995
|
+
return strictVerify(fromBase64Url(signature.sig), appSignedBytes(prefix, canonical), fromZ32(key)) ? { ok: true } : {
|
|
6996
|
+
ok: false,
|
|
6997
|
+
reason: "bad-signature"
|
|
6998
|
+
};
|
|
6999
|
+
}
|
|
7000
|
+
var isSequence = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
|
|
7001
|
+
var isPlainLine = (v, max) => typeof v === "string" && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
7002
|
+
/** Checks a revocation statement's shape; the reason it is refused, or null. */
|
|
7003
|
+
function checkAppRevokeStatement(value) {
|
|
7004
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return "Not an object";
|
|
7005
|
+
const v = value;
|
|
7006
|
+
const byDigests = "digests" in v;
|
|
7007
|
+
if (!exactKeys$1(v, [
|
|
7008
|
+
"ghostlyRevoke",
|
|
7009
|
+
"app",
|
|
7010
|
+
byDigests ? "digests" : "upTo"
|
|
7011
|
+
], ["reason"])) return "Keys are not ghostlyRevoke, app, and exactly one of digests and upTo (reason optional)";
|
|
7012
|
+
if (v.ghostlyRevoke !== 1) return "ghostlyRevoke is not 1";
|
|
7013
|
+
if (!isAppRef(v.app)) return "app is not a reference";
|
|
7014
|
+
if (byDigests) {
|
|
7015
|
+
const d = v.digests;
|
|
7016
|
+
if (!Array.isArray(d) || d.length < 1 || d.length > APP_STATEMENT_LIMITS.revokeDigests) return "digests is not 1 to 64 digests";
|
|
7017
|
+
if (!d.every(isAppHash) || new Set(d).size !== d.length) return "digests holds a bad or repeated digest";
|
|
7018
|
+
} else if (!isSequence(v.upTo)) return "upTo is not a sequence";
|
|
7019
|
+
if (v.reason !== void 0 && !isPlainLine(v.reason, APP_STATEMENT_LIMITS.reason)) return "reason is not one line of 200 characters";
|
|
7020
|
+
return null;
|
|
7021
|
+
}
|
|
7022
|
+
/** Signs a revocation with the publisher's key. */
|
|
7023
|
+
async function signAppRevocation(statement, signer) {
|
|
7024
|
+
const problem = checkAppRevokeStatement(statement);
|
|
7025
|
+
if (problem) throw new Error(`Not a revocation: ${problem}`);
|
|
7026
|
+
if (toZ32(signer.publicKey) !== appRefPublisher(statement.app)) throw new Error("A revocation is signed by its app's publisher");
|
|
7027
|
+
const { signature } = await signAppObject(APP_PREFIXES.revoke, statement, signer);
|
|
7028
|
+
return {
|
|
7029
|
+
statement,
|
|
7030
|
+
signature
|
|
7031
|
+
};
|
|
7032
|
+
}
|
|
7033
|
+
/**
|
|
7034
|
+
* Reads one signed revocation (a parsed value, from `ghostly-revoke.json` or a store index's `revoked`): its shape, then
|
|
7035
|
+
* the signature under `ghostly-revoke/1` by the publisher its `app` names.
|
|
7036
|
+
*/
|
|
7037
|
+
function readAppRevocation(value) {
|
|
7038
|
+
if (!exactKeys$1(value, ["statement", "signature"])) return {
|
|
7039
|
+
ok: false,
|
|
7040
|
+
reason: "bad-revocation",
|
|
7041
|
+
detail: "Keys are not statement and signature"
|
|
7042
|
+
};
|
|
7043
|
+
const problem = checkAppRevokeStatement(value.statement);
|
|
7044
|
+
if (problem) return {
|
|
7045
|
+
ok: false,
|
|
7046
|
+
reason: "bad-revocation",
|
|
7047
|
+
detail: problem
|
|
7048
|
+
};
|
|
7049
|
+
if (!isAppSignature(value.signature)) return {
|
|
7050
|
+
ok: false,
|
|
7051
|
+
reason: "bad-signature-statement"
|
|
7052
|
+
};
|
|
7053
|
+
const statement = value.statement;
|
|
7054
|
+
const checked = verifyAppSignature(APP_PREFIXES.revoke, canonicalJsonBytes(statement), value.signature, appRefPublisher(statement.app));
|
|
7055
|
+
if (!checked.ok) return checked;
|
|
7056
|
+
return {
|
|
7057
|
+
ok: true,
|
|
7058
|
+
revocation: {
|
|
7059
|
+
statement,
|
|
7060
|
+
signature: value.signature
|
|
7061
|
+
}
|
|
7062
|
+
};
|
|
7063
|
+
}
|
|
7064
|
+
/**
|
|
7065
|
+
* Reads `ghostly-revoke.json`: canonical bytes holding a list of signed revocations. One that does not verify refuses the
|
|
7066
|
+
* file, as a store index's `revoked` does.
|
|
7067
|
+
*/
|
|
7068
|
+
function readAppRevocations(bytes) {
|
|
7069
|
+
const read = readCanonicalJson(bytes);
|
|
7070
|
+
if (!read.ok) return read;
|
|
7071
|
+
if (!Array.isArray(read.value) || read.value.length > APP_STATEMENT_LIMITS.revocations) return {
|
|
7072
|
+
ok: false,
|
|
7073
|
+
reason: "bad-revocation",
|
|
7074
|
+
detail: "Not a list of at most 4096 revocations"
|
|
7075
|
+
};
|
|
7076
|
+
const revocations = [];
|
|
7077
|
+
for (const entry of read.value) {
|
|
7078
|
+
const one = readAppRevocation(entry);
|
|
7079
|
+
if (!one.ok) return one;
|
|
7080
|
+
revocations.push(one.revocation);
|
|
7081
|
+
}
|
|
7082
|
+
return {
|
|
7083
|
+
ok: true,
|
|
7084
|
+
revocations
|
|
7085
|
+
};
|
|
7086
|
+
}
|
|
7087
|
+
//#endregion
|
|
6707
7088
|
//#region ../core/src/messageTime.ts
|
|
6708
7089
|
/**
|
|
6709
7090
|
* 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
|
|
@@ -6775,7 +7156,10 @@ var STATUS_CARD_LIMITS = {
|
|
|
6775
7156
|
/** A button's id: what a press names. */
|
|
6776
7157
|
buttonId: 32,
|
|
6777
7158
|
/** A button's label, in characters. */
|
|
6778
|
-
buttonLabel: 40
|
|
7159
|
+
buttonLabel: 40,
|
|
7160
|
+
/** An app card's title and version, in characters (WISP 405 § An app): the manifest's bounds. */
|
|
7161
|
+
appTitle: 40,
|
|
7162
|
+
appVersion: 32
|
|
6779
7163
|
};
|
|
6780
7164
|
/** How a button looks: `primary` is the one highlighted, `danger` warns; `neutral` when none is said. */
|
|
6781
7165
|
var BUTTON_STYLES = [
|
|
@@ -6841,8 +7225,8 @@ var sameButtonText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
|
6841
7225
|
* them (one line, cleaned). `at` is the button that clashes, `with` the one it clashes with.
|
|
6842
7226
|
*/
|
|
6843
7227
|
function buttonLabelClash(buttons) {
|
|
6844
|
-
const labels = buttons.map((button) => isObject$
|
|
6845
|
-
const ids = buttons.map((button) => isObject$
|
|
7228
|
+
const labels = buttons.map((button) => isObject$4(button) ? cardLine(button.label, Infinity) : void 0);
|
|
7229
|
+
const ids = buttons.map((button) => isObject$4(button) && typeof button.id === "string" ? button.id : void 0);
|
|
6846
7230
|
for (const [at, label] of labels.entries()) {
|
|
6847
7231
|
if (!label) continue;
|
|
6848
7232
|
const twin = labels.findIndex((other, i) => i < at && !!other && sameButtonText(other, label));
|
|
@@ -6859,7 +7243,13 @@ function buttonLabelClash(buttons) {
|
|
|
6859
7243
|
};
|
|
6860
7244
|
}
|
|
6861
7245
|
}
|
|
6862
|
-
|
|
7246
|
+
/** An app card's id, made from its reference: the app's name, a dot, the first 16 characters of the publisher key. */
|
|
7247
|
+
function appCardId(ref) {
|
|
7248
|
+
return `${ref.slice(53)}.${ref.slice(0, 16)}`;
|
|
7249
|
+
}
|
|
7250
|
+
/** An app card's `sequence`: a whole number from 0 to 2^53 - 1. */
|
|
7251
|
+
var appSequence = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
7252
|
+
var isObject$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6863
7253
|
/** One line of display text: runs of space (line breaks included) become one space, then the display rules for names. */
|
|
6864
7254
|
function cardLine(value, max) {
|
|
6865
7255
|
if (typeof value !== "string") return void 0;
|
|
@@ -6888,7 +7278,7 @@ function readLinks(raw) {
|
|
|
6888
7278
|
if (!Array.isArray(raw)) return [];
|
|
6889
7279
|
const links = [];
|
|
6890
7280
|
for (const entry of raw.slice(0, STATUS_CARD_LIMITS.links)) {
|
|
6891
|
-
if (!isObject$
|
|
7281
|
+
if (!isObject$4(entry)) continue;
|
|
6892
7282
|
const url = cardUrl(entry.url);
|
|
6893
7283
|
if (!url) continue;
|
|
6894
7284
|
const label = cardLine(entry.label, STATUS_CARD_LIMITS.linkLabel);
|
|
@@ -6911,7 +7301,7 @@ function readTags(raw) {
|
|
|
6911
7301
|
return tags;
|
|
6912
7302
|
}
|
|
6913
7303
|
function readRun(raw, now) {
|
|
6914
|
-
if (!isObject$
|
|
7304
|
+
if (!isObject$4(raw)) return void 0;
|
|
6915
7305
|
const at = time(raw.at, now + MESSAGE_CLOCK_SKEW_MS), result = oneOf(RUN_RESULTS, raw.result);
|
|
6916
7306
|
if (!at || !result) return void 0;
|
|
6917
7307
|
const summary = cardLine(raw.summary, STATUS_CARD_LIMITS.line);
|
|
@@ -6936,9 +7326,26 @@ function statusCardBytes(card) {
|
|
|
6936
7326
|
* routine's next run up to a year ahead). The message is always kept: without a card it shows its text.
|
|
6937
7327
|
*/
|
|
6938
7328
|
function readStatusCard(raw, now = Date.now()) {
|
|
6939
|
-
if (!isObject$
|
|
7329
|
+
if (!isObject$4(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
|
|
6940
7330
|
const id = typeof raw.id === "string" && ID$4.test(raw.id) ? raw.id : void 0;
|
|
6941
7331
|
if (!id) return void 0;
|
|
7332
|
+
if (raw.kind === "app") {
|
|
7333
|
+
if (!isAppRef(raw.ref) || id !== appCardId(raw.ref) || !isAppHash(raw.digest) || !appSequence(raw.sequence)) return void 0;
|
|
7334
|
+
const title = cardLine(raw.title, STATUS_CARD_LIMITS.appTitle);
|
|
7335
|
+
if (!title) return void 0;
|
|
7336
|
+
const version = cardLine(raw.version, STATUS_CARD_LIMITS.appVersion), url = cardUrl(raw.url);
|
|
7337
|
+
return {
|
|
7338
|
+
kind: "app",
|
|
7339
|
+
id,
|
|
7340
|
+
ref: raw.ref,
|
|
7341
|
+
digest: raw.digest,
|
|
7342
|
+
sequence: raw.sequence,
|
|
7343
|
+
title,
|
|
7344
|
+
...version && { version },
|
|
7345
|
+
...url && { url },
|
|
7346
|
+
...raw.opened === true && { opened: true }
|
|
7347
|
+
};
|
|
7348
|
+
}
|
|
6942
7349
|
const latest = now + MESSAGE_CLOCK_SKEW_MS;
|
|
6943
7350
|
const links = readLinks(raw.links);
|
|
6944
7351
|
if (raw.kind === "task") {
|
|
@@ -6949,7 +7356,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
6949
7356
|
if (done === void 0 || total === void 0 || done > total) done = total = void 0;
|
|
6950
7357
|
const step = cardLine(raw.step, STATUS_CARD_LIMITS.line), branch = cardLine(raw.branch, STATUS_CARD_LIMITS.branch);
|
|
6951
7358
|
let pr;
|
|
6952
|
-
if (isObject$
|
|
7359
|
+
if (isObject$4(raw.pr)) {
|
|
6953
7360
|
const url = cardUrl(raw.pr.url);
|
|
6954
7361
|
if (url) {
|
|
6955
7362
|
const number = count(raw.pr.number, 1), additions = count(raw.pr.additions), deletions = count(raw.pr.deletions), files = count(raw.pr.files);
|
|
@@ -6967,7 +7374,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
6967
7374
|
}
|
|
6968
7375
|
const items = [];
|
|
6969
7376
|
if (Array.isArray(raw.items)) for (const entry of raw.items.slice(0, STATUS_CARD_LIMITS.items)) {
|
|
6970
|
-
if (!isObject$
|
|
7377
|
+
if (!isObject$4(entry)) continue;
|
|
6971
7378
|
const text = cardLine(entry.text, STATUS_CARD_LIMITS.line);
|
|
6972
7379
|
if (text) items.push({
|
|
6973
7380
|
text,
|
|
@@ -7026,7 +7433,7 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7026
7433
|
if (!Array.isArray(raw.buttons)) return void 0;
|
|
7027
7434
|
const buttons = [], seen = /* @__PURE__ */ new Set();
|
|
7028
7435
|
for (const entry of raw.buttons.slice(0, STATUS_CARD_LIMITS.buttons)) {
|
|
7029
|
-
if (!isObject$
|
|
7436
|
+
if (!isObject$4(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
|
|
7030
7437
|
const label = cardLine(entry.label, STATUS_CARD_LIMITS.buttonLabel);
|
|
7031
7438
|
if (!label) continue;
|
|
7032
7439
|
seen.add(entry.id);
|
|
@@ -7055,10 +7462,10 @@ function readStatusCard(raw, now = Date.now()) {
|
|
|
7055
7462
|
* at once and nothing it said is lost on the way. Line breaks and invisible characters are cleaned, as a reader would.
|
|
7056
7463
|
*/
|
|
7057
7464
|
function checkStatusCard(raw, now = Date.now()) {
|
|
7058
|
-
if (!isObject$
|
|
7465
|
+
if (!isObject$4(raw)) return { error: "A card is an object" };
|
|
7059
7466
|
const bytes = statusCardBytes(raw);
|
|
7060
7467
|
if (bytes > STATUS_CARD_LIMITS.bytes) return { error: `The card takes ${bytes} bytes; at most ${STATUS_CARD_LIMITS.bytes}` };
|
|
7061
|
-
if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons") return { error: "kind is task, routine or
|
|
7468
|
+
if (raw.kind !== "task" && raw.kind !== "routine" && raw.kind !== "buttons" && raw.kind !== "app") return { error: "kind is task, routine, buttons or app" };
|
|
7062
7469
|
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 -` };
|
|
7063
7470
|
const line = (field, value, max, required = false) => {
|
|
7064
7471
|
if (value === void 0 && !required) return null;
|
|
@@ -7072,20 +7479,29 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7072
7479
|
if (raw.links !== void 0) {
|
|
7073
7480
|
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}`);
|
|
7074
7481
|
else for (const [i, link] of raw.links.entries()) {
|
|
7075
|
-
if (!isObject$
|
|
7482
|
+
if (!isObject$4(link)) {
|
|
7076
7483
|
errors.push(`links[${i}] is an object`);
|
|
7077
7484
|
continue;
|
|
7078
7485
|
}
|
|
7079
7486
|
errors.push(url(`links[${i}].url`, link.url), line(`links[${i}].label`, link.label, STATUS_CARD_LIMITS.linkLabel));
|
|
7080
7487
|
}
|
|
7081
7488
|
}
|
|
7082
|
-
if (raw.kind === "
|
|
7489
|
+
if (raw.kind === "app") {
|
|
7490
|
+
if (raw.links !== void 0) errors.push("an app card takes no links");
|
|
7491
|
+
if (!isAppRef(raw.ref)) errors.push("ref is <publisher key in z-base32>/<name>");
|
|
7492
|
+
else if (raw.id !== appCardId(raw.ref)) errors.push(`id is ${appCardId(raw.ref)}, made from ref`);
|
|
7493
|
+
if (!isAppHash(raw.digest)) errors.push("digest is a SHA-256 in base64url without padding");
|
|
7494
|
+
if (!appSequence(raw.sequence)) errors.push("sequence is a whole number from 0 to 2^53 - 1");
|
|
7495
|
+
errors.push(line("title", raw.title, STATUS_CARD_LIMITS.appTitle, true), line("version", raw.version, STATUS_CARD_LIMITS.appVersion));
|
|
7496
|
+
if (raw.url !== void 0) errors.push(url("url", raw.url));
|
|
7497
|
+
if (raw.opened !== void 0 && raw.opened !== true) errors.push("opened is true or left out");
|
|
7498
|
+
} else if (raw.kind === "buttons") {
|
|
7083
7499
|
if (raw.links !== void 0) errors.push("buttons take no links");
|
|
7084
7500
|
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}`);
|
|
7085
7501
|
else {
|
|
7086
7502
|
const ids = /* @__PURE__ */ new Set();
|
|
7087
7503
|
for (const [i, button] of raw.buttons.entries()) {
|
|
7088
|
-
if (!isObject$
|
|
7504
|
+
if (!isObject$4(button)) {
|
|
7089
7505
|
errors.push(`buttons[${i}] is an object`);
|
|
7090
7506
|
continue;
|
|
7091
7507
|
}
|
|
@@ -7112,7 +7528,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7112
7528
|
}
|
|
7113
7529
|
errors.push(line("step", raw.step, STATUS_CARD_LIMITS.line), line("branch", raw.branch, STATUS_CARD_LIMITS.branch));
|
|
7114
7530
|
if (raw.pr !== void 0) {
|
|
7115
|
-
if (!isObject$
|
|
7531
|
+
if (!isObject$4(raw.pr)) errors.push("pr is an object");
|
|
7116
7532
|
else {
|
|
7117
7533
|
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));
|
|
7118
7534
|
if (raw.pr.state !== void 0 && !oneOf(PR_STATES, raw.pr.state)) errors.push(`pr.state is one of ${PR_STATES.join(", ")}`);
|
|
@@ -7144,7 +7560,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7144
7560
|
if (raw.items !== void 0) {
|
|
7145
7561
|
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}`);
|
|
7146
7562
|
else for (const [i, item] of raw.items.entries()) {
|
|
7147
|
-
if (!isObject$
|
|
7563
|
+
if (!isObject$4(item)) {
|
|
7148
7564
|
errors.push(`items[${i}] is an object`);
|
|
7149
7565
|
continue;
|
|
7150
7566
|
}
|
|
@@ -7158,7 +7574,7 @@ function checkStatusCard(raw, now = Date.now()) {
|
|
|
7158
7574
|
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`);
|
|
7159
7575
|
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");
|
|
7160
7576
|
const run = (field, value) => {
|
|
7161
|
-
if (!isObject$
|
|
7577
|
+
if (!isObject$4(value)) return errors.push(`${field} is an object`);
|
|
7162
7578
|
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`);
|
|
7163
7579
|
if (!oneOf(RUN_RESULTS, value.result)) errors.push(`${field}.result is one of ${RUN_RESULTS.join(", ")}`);
|
|
7164
7580
|
errors.push(line(`${field}.summary`, value.summary, STATUS_CARD_LIMITS.line));
|
|
@@ -7209,6 +7625,10 @@ function cardTimeUtc(ms) {
|
|
|
7209
7625
|
* and search finds. Plain lines, the first naming the card, no bars or layout that would read badly elsewhere.
|
|
7210
7626
|
*/
|
|
7211
7627
|
function statusCardText(card) {
|
|
7628
|
+
if (card.kind === "app") {
|
|
7629
|
+
const named = `${card.title}${card.version ? ` ${card.version}` : ""}`;
|
|
7630
|
+
return [card.opened ? `🧩 Opened ${named} in this chat (Ghostly app)` : `🧩 ${named} (Ghostly app)`, ...card.url ? [card.url] : []].join("\n");
|
|
7631
|
+
}
|
|
7212
7632
|
if (card.kind === "buttons") return `Reply: ${card.buttons.map((b) => b.label).join(" / ")}`;
|
|
7213
7633
|
if (card.kind === "task") {
|
|
7214
7634
|
const progress = taskProgress(card);
|
|
@@ -7970,10 +8390,20 @@ var INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
|
7970
8390
|
* "CON", "nul.txt", "COM1 .log" open a device there, not a file.
|
|
7971
8391
|
*/
|
|
7972
8392
|
var WINDOWS_DEVICE = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9¹²³]|LPT[0-9¹²³])\s*(\.|$)/i;
|
|
8393
|
+
/**
|
|
8394
|
+
* At most `MAX_NAME_LENGTH` characters. A longer name loses the end of its stem, not its extension (up to 15
|
|
8395
|
+
* characters after the last dot): "…report.pdf" stays a PDF to the system it is saved on.
|
|
8396
|
+
*/
|
|
8397
|
+
function shorten(name) {
|
|
8398
|
+
const chars = [...name];
|
|
8399
|
+
if (chars.length <= MAX_NAME_LENGTH) return name;
|
|
8400
|
+
const extension = [.../\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? ""];
|
|
8401
|
+
return chars.slice(0, MAX_NAME_LENGTH - extension.length).join("").trimEnd() + extension.join("");
|
|
8402
|
+
}
|
|
7973
8403
|
/** A file name is display text and a download suggestion, never a path. */
|
|
7974
8404
|
function sanitizeFileName(name) {
|
|
7975
|
-
const clean =
|
|
7976
|
-
if (WINDOWS_DEVICE.test(clean)) return
|
|
8405
|
+
const clean = shorten(name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "").trim());
|
|
8406
|
+
if (WINDOWS_DEVICE.test(clean)) return shorten("_" + clean);
|
|
7977
8407
|
return clean || "file";
|
|
7978
8408
|
}
|
|
7979
8409
|
function sanitizeMime(mime) {
|
|
@@ -8451,6 +8881,8 @@ var DhtDelivery = class DhtDelivery {
|
|
|
8451
8881
|
/** Until when the relays' request budget holds publications back: what is due then goes at once. */
|
|
8452
8882
|
budgetUntil = 0;
|
|
8453
8883
|
errors = {};
|
|
8884
|
+
/** When the device's network last came back (`networkBack`): a read or publication started before failed for want of it. */
|
|
8885
|
+
networkBackAt = -Infinity;
|
|
8454
8886
|
foreignKeySeenAt;
|
|
8455
8887
|
/** When the pinned mailbox was last looked in while the contact had not said it uses it. */
|
|
8456
8888
|
pinnedProbeAt = 0;
|
|
@@ -8640,6 +9072,18 @@ var DhtDelivery = class DhtDelivery {
|
|
|
8640
9072
|
this.tick();
|
|
8641
9073
|
}
|
|
8642
9074
|
/**
|
|
9075
|
+
* The device's network came back: a read or publication error from while it was away says nothing now (No Pkarr
|
|
9076
|
+
* relay reachable). It goes at once, and the mailbox is read now: one that still fails says so again.
|
|
9077
|
+
*/
|
|
9078
|
+
networkBack() {
|
|
9079
|
+
this.networkBackAt = Date.now();
|
|
9080
|
+
if (!this.errors.read && !this.errors.publish) return;
|
|
9081
|
+
delete this.errors.read;
|
|
9082
|
+
delete this.errors.publish;
|
|
9083
|
+
this.changed();
|
|
9084
|
+
this.tick();
|
|
9085
|
+
}
|
|
9086
|
+
/**
|
|
8643
9087
|
* Reads the contact's mailbox at the signaling pace for a while (a fresh packet of a contact not pinned yet).
|
|
8644
9088
|
* `signal`: the contact is leaving DHT only (a fresh packet on its link key, or its offer), and the live link waits
|
|
8645
9089
|
* on a read to say so: the next read goes now, and it and the next ones while the contact still says DHT only
|
|
@@ -9286,18 +9730,20 @@ var DhtDelivery = class DhtDelivery {
|
|
|
9286
9730
|
const signal = this.signalReads > 0;
|
|
9287
9731
|
this.urgent = false;
|
|
9288
9732
|
if (signal) this.signalReads--;
|
|
9733
|
+
const readAt = Date.now();
|
|
9289
9734
|
try {
|
|
9290
9735
|
await this.read(background, signal);
|
|
9291
9736
|
if (!this.running) return;
|
|
9292
9737
|
delete this.errors.read;
|
|
9293
9738
|
} catch (error) {
|
|
9294
|
-
if (!isDiscoveryBudgetError(error)) this.errors.read = `Could not read DHT delivery: ${error instanceof Error ? error.message : String(error)}`;
|
|
9739
|
+
if (!isDiscoveryBudgetError(error) && readAt >= this.networkBackAt) this.errors.read = `Could not read DHT delivery: ${error instanceof Error ? error.message : String(error)}`;
|
|
9295
9740
|
}
|
|
9296
9741
|
if (this.state.peerMode !== "dht") this.signalReads = 0;
|
|
9742
|
+
const publishAt = Date.now();
|
|
9297
9743
|
try {
|
|
9298
9744
|
await this.publish();
|
|
9299
9745
|
} catch (error) {
|
|
9300
|
-
if (!isDiscoveryBudgetError(error)) this.errors.publish = `Could not publish DHT delivery: ${error instanceof Error ? error.message : String(error)}`;
|
|
9746
|
+
if (!isDiscoveryBudgetError(error) && publishAt >= this.networkBackAt) this.errors.publish = `Could not publish DHT delivery: ${error instanceof Error ? error.message : String(error)}`;
|
|
9301
9747
|
}
|
|
9302
9748
|
this.changed();
|
|
9303
9749
|
});
|
|
@@ -9723,6 +10169,8 @@ var TransportSwitch = class {
|
|
|
9723
10169
|
settled = "";
|
|
9724
10170
|
failed = "";
|
|
9725
10171
|
preparing = false;
|
|
10172
|
+
/** The coordinator's dial for the pending plan has started (`dialing`): from then on a newer choice waits for it. */
|
|
10173
|
+
dialStarted = false;
|
|
9726
10174
|
/** This side chose while no session was open: the next one begins with it as a choice. */
|
|
9727
10175
|
apart = false;
|
|
9728
10176
|
constructor(options) {
|
|
@@ -9795,7 +10243,10 @@ var TransportSwitch = class {
|
|
|
9795
10243
|
begin(context, actual, migrated = false) {
|
|
9796
10244
|
const landed = migrated ? this.plan : null;
|
|
9797
10245
|
this.settled = landed ? this.signature(landed.local, landed.remote) : "";
|
|
9798
|
-
if (!migrated)
|
|
10246
|
+
if (!migrated) {
|
|
10247
|
+
this.failed = "";
|
|
10248
|
+
this.remote = null;
|
|
10249
|
+
}
|
|
9799
10250
|
if (this.apart) {
|
|
9800
10251
|
this.intent = Math.max(this.intent, this.lastRemote?.intent ?? 0) + 1;
|
|
9801
10252
|
this.apart = false;
|
|
@@ -9817,6 +10268,7 @@ var TransportSwitch = class {
|
|
|
9817
10268
|
} else if (userIntent) this.intent = Math.max(this.intent, this.remote?.intent ?? 0) + 1;
|
|
9818
10269
|
this.failed = "";
|
|
9819
10270
|
this.announce();
|
|
10271
|
+
this.supersede();
|
|
9820
10272
|
this.reconcile();
|
|
9821
10273
|
}
|
|
9822
10274
|
/**
|
|
@@ -9846,7 +10298,7 @@ var TransportSwitch = class {
|
|
|
9846
10298
|
keep(reason) {
|
|
9847
10299
|
const target = this.plan?.choices[0];
|
|
9848
10300
|
if (this.plan) this.settled = this.signature(this.plan.local, this.plan.remote);
|
|
9849
|
-
if (target && target !== this.actual) this.options.kept?.(target, reason);
|
|
10301
|
+
if (target && target !== this.actual && !this.plan?.upgrade) this.options.kept?.(target, reason);
|
|
9850
10302
|
this.send({
|
|
9851
10303
|
t: "paired-switch-keep",
|
|
9852
10304
|
id: this.plan?.id
|
|
@@ -9884,9 +10336,33 @@ var TransportSwitch = class {
|
|
|
9884
10336
|
this.timer = null;
|
|
9885
10337
|
this.plan = null;
|
|
9886
10338
|
this.preparing = false;
|
|
10339
|
+
this.dialStarted = false;
|
|
10340
|
+
}
|
|
10341
|
+
/** The coordinator starts dialling the pending plan (a move back to WebRTC first waits for the old session to retire). */
|
|
10342
|
+
dialing(plan) {
|
|
10343
|
+
if (this.plan === plan) this.dialStarted = true;
|
|
10344
|
+
}
|
|
10345
|
+
/**
|
|
10346
|
+
* A newer choice (this side's, or the contact's policy) while a plan both sides agreed has not started its dial: a
|
|
10347
|
+
* move back to WebRTC waits for the WebRTC session it left to retire (`SWITCH_RETIRE_MS`), and nothing has been tried
|
|
10348
|
+
* yet. The coordinator drops that plan, so the chat does not move there only to move again; the other side is told
|
|
10349
|
+
* with `paired-switch-keep`, and whatever the newer policies call for is planned next. A plan already dialling goes
|
|
10350
|
+
* on: a choice made then is planned once it ended (`replanIfMoved`).
|
|
10351
|
+
*/
|
|
10352
|
+
supersede() {
|
|
10353
|
+
const plan = this.plan;
|
|
10354
|
+
if (!plan || !this.preparing || this.dialStarted || !this.context || !this.remote || this.options.key > this.options.peerKey) return;
|
|
10355
|
+
if (this.choices(this.local(), this.remote)[0] === plan.choices[0]) return;
|
|
10356
|
+
this.send({
|
|
10357
|
+
t: "paired-switch-keep",
|
|
10358
|
+
id: plan.id
|
|
10359
|
+
});
|
|
10360
|
+
this.clearPlan();
|
|
10361
|
+
this.options.cancel();
|
|
10362
|
+
this.options.state();
|
|
9887
10363
|
}
|
|
9888
10364
|
fail(error) {
|
|
9889
|
-
const target = this.plan?.choices[0];
|
|
10365
|
+
const target = this.plan?.choices[0], upgrade = !!this.plan?.upgrade;
|
|
9890
10366
|
if (this.plan) this.failed = this.signature(this.plan.local, this.plan.remote);
|
|
9891
10367
|
this.send({
|
|
9892
10368
|
t: "paired-switch-failed",
|
|
@@ -9894,7 +10370,8 @@ var TransportSwitch = class {
|
|
|
9894
10370
|
});
|
|
9895
10371
|
this.clearPlan();
|
|
9896
10372
|
this.options.cancel();
|
|
9897
|
-
this.
|
|
10373
|
+
if (upgrade) this.options.state();
|
|
10374
|
+
else this.unreached(target, error, error);
|
|
9898
10375
|
this.replanIfMoved();
|
|
9899
10376
|
}
|
|
9900
10377
|
/**
|
|
@@ -9916,13 +10393,29 @@ var TransportSwitch = class {
|
|
|
9916
10393
|
const ranked = rankTransports(allowedTransports(local), allowedTransports(remote), relayed);
|
|
9917
10394
|
if (!ranked.length) return [];
|
|
9918
10395
|
const winner = this.winner(local, remote);
|
|
9919
|
-
const target = winner.intent === 0 && this.actual && ranked.includes(this.actual) ? this.actual : ranked.includes(winner.preferred) ? winner.preferred : ranked[0];
|
|
10396
|
+
const target = this.upgrading(winner, ranked, relayed) ? ranked[0] : winner.intent === 0 && this.actual && ranked.includes(this.actual) ? this.actual : ranked.includes(winner.preferred) ? winner.preferred : ranked[0];
|
|
9920
10397
|
return [target, ...local.fallback && remote.fallback ? ranked.filter((t) => t !== target) : []];
|
|
9921
10398
|
}
|
|
10399
|
+
/** Nobody chose, the session is on a relayed transport, a direct one ranks first, and both sides move (`upgrade/1`). */
|
|
10400
|
+
upgrading(winner, ranked, relayed) {
|
|
10401
|
+
return winner.intent === 0 && !!this.actual && relayed.includes(this.actual) && ranked.includes(this.actual) && ranked[0] !== this.actual && !relayed.includes(ranked[0]) && !!this.options.upgrade?.();
|
|
10402
|
+
}
|
|
10403
|
+
isUpgrade(local, remote) {
|
|
10404
|
+
const relayed = relayedTransports(local.descriptors, remote.descriptors);
|
|
10405
|
+
return this.upgrading(this.winner(local, remote), rankTransports(allowedTransports(local), allowedTransports(remote), relayed), relayed);
|
|
10406
|
+
}
|
|
10407
|
+
/**
|
|
10408
|
+
* Something `upgrade` depends on changed (what the contact's app offers on this session, said once it is ready): plan
|
|
10409
|
+
* again. The session's first agreement, made before that, settled on the relay it is on; a move off it is not that.
|
|
10410
|
+
*/
|
|
10411
|
+
reconsider() {
|
|
10412
|
+
if (this.context && this.remote && !this.plan && this.isUpgrade(this.local(), this.remote) && this.settled === this.signature(this.local(), this.remote)) this.settled = "";
|
|
10413
|
+
this.reconcile();
|
|
10414
|
+
}
|
|
9922
10415
|
install(plan) {
|
|
9923
10416
|
this.clearPlan();
|
|
9924
10417
|
this.plan = plan;
|
|
9925
|
-
this.options.state(void 0, plan.choices[0]);
|
|
10418
|
+
this.options.state(void 0, plan.upgrade ? void 0 : plan.choices[0]);
|
|
9926
10419
|
this.timer = setTimeout(() => this.fail("Transport change timed out. Your previous connection is kept when available; retry or choose another transport."), this.options.timeoutMs ?? 3e4);
|
|
9927
10420
|
}
|
|
9928
10421
|
reconcile() {
|
|
@@ -9944,11 +10437,16 @@ var TransportSwitch = class {
|
|
|
9944
10437
|
return;
|
|
9945
10438
|
}
|
|
9946
10439
|
if (signature === this.failed) return;
|
|
9947
|
-
this.
|
|
10440
|
+
const upgrade = this.isUpgrade(local, remote);
|
|
10441
|
+
this.options.state(void 0, upgrade ? void 0 : choices[0]);
|
|
9948
10442
|
if (this.options.key > this.options.peerKey) {
|
|
9949
10443
|
if (!this.timer) this.timer = setTimeout(() => {
|
|
9950
10444
|
this.failed = signature;
|
|
9951
10445
|
this.clearPlan();
|
|
10446
|
+
if (upgrade) {
|
|
10447
|
+
this.options.state();
|
|
10448
|
+
return;
|
|
10449
|
+
}
|
|
9952
10450
|
this.unreached(choices[0], "Your contact did not acknowledge the transport change. Retry when both peers are connected.", "Your contact did not answer");
|
|
9953
10451
|
}, this.options.timeoutMs ?? 3e4);
|
|
9954
10452
|
return;
|
|
@@ -9957,7 +10455,8 @@ var TransportSwitch = class {
|
|
|
9957
10455
|
id: `${signature}:${choices[0]}`,
|
|
9958
10456
|
local,
|
|
9959
10457
|
remote,
|
|
9960
|
-
choices
|
|
10458
|
+
choices,
|
|
10459
|
+
...upgrade && { upgrade }
|
|
9961
10460
|
};
|
|
9962
10461
|
this.install(plan);
|
|
9963
10462
|
this.send({
|
|
@@ -9984,6 +10483,7 @@ var TransportSwitch = class {
|
|
|
9984
10483
|
this.remote = this.lastRemote = policy;
|
|
9985
10484
|
this.options.peer(policy);
|
|
9986
10485
|
if (changed && this.plan && !this.preparing) this.clearPlan();
|
|
10486
|
+
else if (changed) this.supersede();
|
|
9987
10487
|
this.reconcile();
|
|
9988
10488
|
return true;
|
|
9989
10489
|
}
|
|
@@ -9998,7 +10498,8 @@ var TransportSwitch = class {
|
|
|
9998
10498
|
id: String(frame.id),
|
|
9999
10499
|
local,
|
|
10000
10500
|
remote,
|
|
10001
|
-
choices
|
|
10501
|
+
choices,
|
|
10502
|
+
...this.isUpgrade(local, remote) && { upgrade: true }
|
|
10002
10503
|
});
|
|
10003
10504
|
this.options.prepare(this.plan, false);
|
|
10004
10505
|
this.send({
|
|
@@ -10009,17 +10510,24 @@ var TransportSwitch = class {
|
|
|
10009
10510
|
}
|
|
10010
10511
|
if (!this.plan || frame.id !== this.plan.id) return true;
|
|
10011
10512
|
if (frame.t === "paired-switch-failed") {
|
|
10012
|
-
const target = this.plan.choices[0];
|
|
10513
|
+
const target = this.plan.choices[0], upgrade = !!this.plan.upgrade;
|
|
10013
10514
|
this.failed = this.signature(this.plan.local, this.plan.remote);
|
|
10014
10515
|
this.clearPlan();
|
|
10015
10516
|
this.options.cancel();
|
|
10016
|
-
|
|
10517
|
+
if (upgrade) this.options.state();
|
|
10518
|
+
else this.unreached(target, "The transport change failed. Retry or choose another transport.");
|
|
10017
10519
|
this.replanIfMoved();
|
|
10018
10520
|
return true;
|
|
10019
10521
|
}
|
|
10020
10522
|
if (frame.t === "paired-switch-keep") {
|
|
10523
|
+
if (this.preparing && this.remote && this.choices(this.local(), this.remote)[0] !== this.plan.choices[0]) {
|
|
10524
|
+
this.clearPlan();
|
|
10525
|
+
this.options.state();
|
|
10526
|
+
this.reconcile();
|
|
10527
|
+
return true;
|
|
10528
|
+
}
|
|
10021
10529
|
if (this.actual && this.plan.choices.includes(this.actual)) {
|
|
10022
|
-
if (this.plan.choices[0] !== this.actual) this.options.kept?.(this.plan.choices[0]);
|
|
10530
|
+
if (this.plan.choices[0] !== this.actual && !this.plan.upgrade) this.options.kept?.(this.plan.choices[0]);
|
|
10023
10531
|
this.settled = this.signature(this.plan.local, this.plan.remote);
|
|
10024
10532
|
this.clearPlan();
|
|
10025
10533
|
this.options.state();
|
|
@@ -10438,7 +10946,7 @@ var lastOf = (timeline, side, proofs) => {
|
|
|
10438
10946
|
/**
|
|
10439
10947
|
* The timeline after one step. A share already on it is updated in place, never repeated: a request sent again at
|
|
10440
10948
|
* a reconnect, a second share of a proof the contact already verified. A new attempt after a failure or a stop is
|
|
10441
|
-
* a new entry. A stop is written once.
|
|
10949
|
+
* a new entry. A stop is written once; a withdrawal later found revoked becomes a revocation in place.
|
|
10442
10950
|
*/
|
|
10443
10951
|
function applyIdentityEvent(timeline, event, at) {
|
|
10444
10952
|
const out = [...timeline];
|
|
@@ -10498,7 +11006,13 @@ function applyIdentityEvent(timeline, event, at) {
|
|
|
10498
11006
|
break;
|
|
10499
11007
|
}
|
|
10500
11008
|
case "stopped":
|
|
10501
|
-
if (last?.kind === "stopped")
|
|
11009
|
+
if (last?.kind === "stopped") {
|
|
11010
|
+
if (last.reason === "withdrawn" && event.reason === "revoked") out[i] = {
|
|
11011
|
+
...last,
|
|
11012
|
+
reason: "revoked"
|
|
11013
|
+
};
|
|
11014
|
+
break;
|
|
11015
|
+
}
|
|
10502
11016
|
add({
|
|
10503
11017
|
proof: event.proof,
|
|
10504
11018
|
side: event.side,
|
|
@@ -11165,11 +11679,17 @@ var CAPS_READ_SPACING_MS = 15e3;
|
|
|
11165
11679
|
* already knows and looks the key up behind it, and a lookup that started before the publication landed finds the old
|
|
11166
11680
|
* one; a relay may serve an old copy for a while too. The envelope names each revision once, so one read was the last
|
|
11167
11681
|
* until the next envelope, minutes away, and the chat stayed on the DHT with no descriptors to dial.
|
|
11682
|
+
*
|
|
11683
|
+
* The first ones come soon: a contact's record that lists a native transport with no descriptor is the "Peer native
|
|
11684
|
+
* address unavailable" a dial ends with, and its revision with the descriptors is usually out a second or two after the
|
|
11685
|
+
* first (its endpoints were starting). Read 5 s, then 20 s after, two Desktops DHT direct with Iroh's relay out of reach
|
|
11686
|
+
* went live 11 to 28 s after their chats opened (2026-10-07). One read more than before, three in the first 7 s.
|
|
11168
11687
|
*/
|
|
11169
11688
|
var CAPS_READ_RETRY_MS = [
|
|
11170
|
-
|
|
11689
|
+
1e3,
|
|
11690
|
+
2e3,
|
|
11691
|
+
4e3,
|
|
11171
11692
|
15e3,
|
|
11172
|
-
3e4,
|
|
11173
11693
|
6e4
|
|
11174
11694
|
];
|
|
11175
11695
|
var NATIVE = ["iroh/1", "hyperdht/1"];
|
|
@@ -11761,7 +12281,7 @@ var KINDS$1 = [
|
|
|
11761
12281
|
var WIRE_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
11762
12282
|
var MAILBOX = /^[A-Za-z0-9_-]{22}$/;
|
|
11763
12283
|
var SIG$3 = /^[A-Za-z0-9_-]{86}$/;
|
|
11764
|
-
var MAGIC$
|
|
12284
|
+
var MAGIC$2 = utf8Encode("GHLD");
|
|
11765
12285
|
var VERSION = 1;
|
|
11766
12286
|
var NONCE$2 = 24;
|
|
11767
12287
|
/** Why a bundle, manifest or pointer was refused. Never a reason to trust it a little. */
|
|
@@ -11827,7 +12347,7 @@ var HoldKeys = class {
|
|
|
11827
12347
|
new DataView(length.buffer).setUint32(0, header.length);
|
|
11828
12348
|
const nonce = randomBytes(NONCE$2);
|
|
11829
12349
|
const box = xsalsa20poly1305(this.sealKey, nonce).encrypt(concatBytes$1(length, header, body));
|
|
11830
|
-
return concatBytes$1(MAGIC$
|
|
12350
|
+
return concatBytes$1(MAGIC$2, new Uint8Array([VERSION]), nonce, box);
|
|
11831
12351
|
}
|
|
11832
12352
|
/**
|
|
11833
12353
|
* Opens a bundle the contact sealed for me. Everything is checked before anything is returned: the
|
|
@@ -11837,11 +12357,11 @@ var HoldKeys = class {
|
|
|
11837
12357
|
open(bytes, expect = {}) {
|
|
11838
12358
|
const now = expect.now ?? Date.now();
|
|
11839
12359
|
if (bytes.length > (expect.maxBytes ?? HOLD_LIMITS.maxBundleBytes)) throw new HoldRefusedError("size", "The held item is larger than allowed");
|
|
11840
|
-
if (bytes.length < MAGIC$
|
|
11841
|
-
if (bytes[MAGIC$
|
|
12360
|
+
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");
|
|
12361
|
+
if (bytes[MAGIC$2.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
|
|
11842
12362
|
let plain;
|
|
11843
12363
|
try {
|
|
11844
|
-
plain = xsalsa20poly1305(this.sealKey, bytes.subarray(MAGIC$
|
|
12364
|
+
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));
|
|
11845
12365
|
} catch {
|
|
11846
12366
|
throw new HoldRefusedError("tampered", "The held item was changed or is not for this chat");
|
|
11847
12367
|
}
|
|
@@ -12449,6 +12969,19 @@ var STATUS_CARD_CAPABILITY = "status-card/1";
|
|
|
12449
12969
|
* a press reads as an ordinary reply there. It tells a bot whether its contact can press at all.
|
|
12450
12970
|
*/
|
|
12451
12971
|
var BUTTONS_CAPABILITY = "buttons/1";
|
|
12972
|
+
/**
|
|
12973
|
+
* A session live on a relayed transport while a direct one ranks first, with nobody's choice behind it, moves to the
|
|
12974
|
+
* direct one once, in place, its signaling on the session (WISP 100 § Back to a quiet contact): what a chat that came
|
|
12975
|
+
* back over a relay at once (rather than wait for an offer's answer) needs to end where a direct path would have put
|
|
12976
|
+
* it. Both sides must say it: the switch is planned by one and checked by the other. Also in the capability record
|
|
12977
|
+
* (WISP 03), so an app back after an absence knows before any session whether a relay can carry it there at once.
|
|
12978
|
+
*/
|
|
12979
|
+
var UPGRADE_CAPABILITY = "upgrade/1";
|
|
12980
|
+
/**
|
|
12981
|
+
* Mini-apps in a 1:1 chat: `paired-app` frames on this session, live only (WISP 1200 § In a chat: `apps/1`,
|
|
12982
|
+
* `pairedApps.ts`). Offered only while the apps feature is on.
|
|
12983
|
+
*/
|
|
12984
|
+
var APPS_CAPABILITY = "apps/1";
|
|
12452
12985
|
/** Every capability this app knows on a session: what `receive` reports changes of. */
|
|
12453
12986
|
var KNOWN_SESSION_CAPABILITIES = [
|
|
12454
12987
|
CALLS_CAPABILITY,
|
|
@@ -12460,7 +12993,9 @@ var KNOWN_SESSION_CAPABILITIES = [
|
|
|
12460
12993
|
WAKE_SESSION_CAPABILITY,
|
|
12461
12994
|
PIN_CAPABILITY,
|
|
12462
12995
|
STATUS_CARD_CAPABILITY,
|
|
12463
|
-
BUTTONS_CAPABILITY
|
|
12996
|
+
BUTTONS_CAPABILITY,
|
|
12997
|
+
UPGRADE_CAPABILITY,
|
|
12998
|
+
APPS_CAPABILITY
|
|
12464
12999
|
];
|
|
12465
13000
|
var SESSION_CAPABILITIES_FRAME = "paired-capabilities";
|
|
12466
13001
|
var MAX_ENTRIES$1 = 32;
|
|
@@ -12527,77 +13062,340 @@ var SessionCapabilities = class {
|
|
|
12527
13062
|
}
|
|
12528
13063
|
};
|
|
12529
13064
|
//#endregion
|
|
12530
|
-
//#region ../core/src/
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
13065
|
+
//#region ../core/src/pairedApps.ts
|
|
13066
|
+
/**
|
|
13067
|
+
* 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
|
|
13068
|
+
* other over the live session, in one frame with three forms.
|
|
13069
|
+
*
|
|
13070
|
+
* {"t":"paired-app","a":"<chat app id>","o":"open","v":"1.2.0"}
|
|
13071
|
+
* {"t":"paired-app","a":"<chat app id>","o":"close"}
|
|
13072
|
+
* {"t":"paired-app","a":"<chat app id>","d":<JSON value>}
|
|
13073
|
+
*
|
|
13074
|
+
* It needs `apps/1` in `paired-capabilities` on both sides. Live only: never on the DHT, never held, never queued for
|
|
13075
|
+
* later; while the session is not live a send fails with `offline`. An `open` goes when the person opens the app in
|
|
13076
|
+
* the chat, again on an update, and again on every session that comes back while the app is open. The receiver
|
|
13077
|
+
* enforces the limits (the sender's client may be the attacker): at most `APP_DATA_MAX_BYTES` of data, at most
|
|
13078
|
+
* `APP_RATE_LIMIT` frames a second per app, data only for an app open on both sides, at most `APP_PEER_OPEN_MAX`
|
|
13079
|
+
* apps the peer has open, and nothing unless both sides offer `apps/1`.
|
|
13080
|
+
*
|
|
13081
|
+
* The chat app id names one app in one chat: the first 16 bytes of HMAC-SHA-256 keyed by the two pinned participation
|
|
13082
|
+
* keys over the app reference `<publisher key in z-base32>/<name>`. One app's id differs in each chat, so ids cannot
|
|
13083
|
+
* be matched across a person's chats; it travels only inside the encrypted session.
|
|
13084
|
+
*/
|
|
13085
|
+
var APP_FRAME = "paired-app";
|
|
13086
|
+
var DOMAIN = "ghostly-apps/1";
|
|
13087
|
+
var APP_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
13088
|
+
/** Semantic versioning 2.0.0, as a manifest's `version` is. */
|
|
13089
|
+
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-]+)*))?$/;
|
|
13090
|
+
/** A chat app id as it is on the wire: 22 base64url characters (16 bytes, no padding). */
|
|
13091
|
+
function isChatAppId(value) {
|
|
13092
|
+
return typeof value === "string" && APP_ID.test(value);
|
|
13093
|
+
}
|
|
13094
|
+
/** A version an `open` may carry: semantic versioning, at most `APP_VERSION_MAX` characters. */
|
|
13095
|
+
function isAppVersion(value) {
|
|
13096
|
+
return typeof value === "string" && value.length <= 64 && SEMVER$1.test(value);
|
|
12549
13097
|
}
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
const secret = fromBase64Url(keys.privateKey);
|
|
12554
|
-
return secret.length === 32 && toBase64Url(p256.getPublicKey(secret, false)) === toBase64Url(point(keys.publicKey, "The VAPID key"));
|
|
12555
|
-
} catch {
|
|
12556
|
-
return false;
|
|
12557
|
-
}
|
|
13098
|
+
function compareBytes$2(a, b) {
|
|
13099
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
13100
|
+
return a.length - b.length;
|
|
12558
13101
|
}
|
|
12559
13102
|
/**
|
|
12560
|
-
* The
|
|
12561
|
-
*
|
|
12562
|
-
|
|
12563
|
-
var PUSH_SERVICE_HOSTS = [
|
|
12564
|
-
/^fcm\.googleapis\.com$/,
|
|
12565
|
-
/(^|\.)push\.apple\.com$/,
|
|
12566
|
-
/(^|\.)push\.services\.mozilla\.com$/,
|
|
12567
|
-
/(^|\.)notify\.windows\.com$/
|
|
12568
|
-
];
|
|
12569
|
-
/**
|
|
12570
|
-
* Only https, on one of the push services' host names and their default port, without credentials. The endpoint came
|
|
12571
|
-
* from a contact: it must not turn this app into a way to post to the contact's choice of address, local or not.
|
|
13103
|
+
* The chat app id of `ref` in the chat between these two participation keys (raw, 32 bytes each, in either order):
|
|
13104
|
+
* HMAC-SHA-256 keyed by SHA-256(`ghostly-apps/1`, 0x00, the lower key, the higher key), over the UTF-8 reference;
|
|
13105
|
+
* its first 16 bytes, base64url without padding.
|
|
12572
13106
|
*/
|
|
12573
|
-
function
|
|
12574
|
-
|
|
13107
|
+
function chatAppId(keyA, keyB, ref) {
|
|
13108
|
+
if (keyA.length !== 32 || keyB.length !== 32) throw new Error("A participation key is 32 bytes");
|
|
13109
|
+
if (!isAppRef(ref)) throw new Error("Not an app reference");
|
|
13110
|
+
const [low, high] = compareBytes$2(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA];
|
|
13111
|
+
const key = sha256(concatBytes$1(utf8Encode(DOMAIN), new Uint8Array([0]), low, high));
|
|
13112
|
+
return toBase64Url(hmac(sha256, key, utf8Encode(ref)).slice(0, 16));
|
|
13113
|
+
}
|
|
13114
|
+
/** The size of a value as compact UTF-8 JSON; Infinity when it is not JSON (undefined, a function, a cycle, a BigInt). */
|
|
13115
|
+
function appDataBytes(value) {
|
|
13116
|
+
let json;
|
|
12575
13117
|
try {
|
|
12576
|
-
|
|
13118
|
+
json = JSON.stringify(value);
|
|
12577
13119
|
} catch {
|
|
12578
|
-
|
|
13120
|
+
return Infinity;
|
|
12579
13121
|
}
|
|
12580
|
-
|
|
12581
|
-
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
12600
|
-
|
|
13122
|
+
return json === void 0 ? Infinity : utf8Encode(json).length;
|
|
13123
|
+
}
|
|
13124
|
+
function appOpenFrame(app, version) {
|
|
13125
|
+
if (!isChatAppId(app) || !isAppVersion(version)) throw new Error("Not an app open");
|
|
13126
|
+
return {
|
|
13127
|
+
t: APP_FRAME,
|
|
13128
|
+
a: app,
|
|
13129
|
+
o: "open",
|
|
13130
|
+
v: version
|
|
13131
|
+
};
|
|
13132
|
+
}
|
|
13133
|
+
function appCloseFrame(app) {
|
|
13134
|
+
if (!isChatAppId(app)) throw new Error("Not an app close");
|
|
13135
|
+
return {
|
|
13136
|
+
t: APP_FRAME,
|
|
13137
|
+
a: app,
|
|
13138
|
+
o: "close"
|
|
13139
|
+
};
|
|
13140
|
+
}
|
|
13141
|
+
function appDataFrame(app, data) {
|
|
13142
|
+
if (!isChatAppId(app) || appDataBytes(data) > 32768) throw new Error("Not app data");
|
|
13143
|
+
return {
|
|
13144
|
+
t: APP_FRAME,
|
|
13145
|
+
a: app,
|
|
13146
|
+
d: data
|
|
13147
|
+
};
|
|
13148
|
+
}
|
|
13149
|
+
/**
|
|
13150
|
+
* A `paired-app` frame as the peer sent it, or why a reader must refuse it: no valid `a` (22 base64url characters),
|
|
13151
|
+
* both or neither of `o` and `d`, an unknown `o`, an `open` without a valid `v`, or a `d` over `APP_DATA_MAX_BYTES`.
|
|
13152
|
+
* Other keys are ignored, so a later version can add some.
|
|
13153
|
+
*/
|
|
13154
|
+
function readAppFrame(frame) {
|
|
13155
|
+
const refuse = (refusal) => ({
|
|
13156
|
+
ok: false,
|
|
13157
|
+
refusal
|
|
13158
|
+
});
|
|
13159
|
+
if (frame?.t !== "paired-app") return refuse("not-app");
|
|
13160
|
+
if (!isChatAppId(frame.a)) return refuse("bad-a");
|
|
13161
|
+
const own = (key) => Object.prototype.hasOwnProperty.call(frame, key);
|
|
13162
|
+
const hasO = own("o"), hasD = own("d");
|
|
13163
|
+
if (hasO && hasD) return refuse("o-and-d");
|
|
13164
|
+
if (!hasO && !hasD) return refuse("no-o-or-d");
|
|
13165
|
+
if (hasD) return appDataBytes(frame.d) <= 32768 ? {
|
|
13166
|
+
ok: true,
|
|
13167
|
+
frame: {
|
|
13168
|
+
t: APP_FRAME,
|
|
13169
|
+
a: frame.a,
|
|
13170
|
+
d: frame.d
|
|
13171
|
+
}
|
|
13172
|
+
} : refuse("too-large");
|
|
13173
|
+
if (frame.o === "close") return {
|
|
13174
|
+
ok: true,
|
|
13175
|
+
frame: {
|
|
13176
|
+
t: APP_FRAME,
|
|
13177
|
+
a: frame.a,
|
|
13178
|
+
o: "close"
|
|
13179
|
+
}
|
|
13180
|
+
};
|
|
13181
|
+
if (frame.o !== "open") return refuse("unknown-o");
|
|
13182
|
+
return isAppVersion(frame.v) ? {
|
|
13183
|
+
ok: true,
|
|
13184
|
+
frame: {
|
|
13185
|
+
t: APP_FRAME,
|
|
13186
|
+
a: frame.a,
|
|
13187
|
+
o: "open",
|
|
13188
|
+
v: frame.v
|
|
13189
|
+
}
|
|
13190
|
+
} : refuse("bad-v");
|
|
13191
|
+
}
|
|
13192
|
+
/** A `paired-app` frame as the peer sent it, or null when a reader must refuse it (`readAppFrame` says why). */
|
|
13193
|
+
function parseAppFrame(frame) {
|
|
13194
|
+
const read = readAppFrame(frame);
|
|
13195
|
+
return read.ok ? read.frame : null;
|
|
13196
|
+
}
|
|
13197
|
+
/** Frames per app in a sliding window, for at most `max` apps at once. */
|
|
13198
|
+
var RateWindows = class {
|
|
13199
|
+
limit;
|
|
13200
|
+
max;
|
|
13201
|
+
windows = /* @__PURE__ */ new Map();
|
|
13202
|
+
constructor(limit, max) {
|
|
13203
|
+
this.limit = limit;
|
|
13204
|
+
this.max = max;
|
|
13205
|
+
}
|
|
13206
|
+
/** Counts one frame for `app` at `now`; false when it is over the limit (not counted then). */
|
|
13207
|
+
take(app, now) {
|
|
13208
|
+
let window = this.windows.get(app);
|
|
13209
|
+
if (!window) {
|
|
13210
|
+
if (this.windows.size >= this.max) this.prune(now);
|
|
13211
|
+
if (this.windows.size >= this.max) return false;
|
|
13212
|
+
window = [];
|
|
13213
|
+
this.windows.set(app, window);
|
|
13214
|
+
}
|
|
13215
|
+
while (window.length && now - window[0] >= 1e3) window.shift();
|
|
13216
|
+
if (window.length >= this.limit) return false;
|
|
13217
|
+
window.push(now);
|
|
13218
|
+
return true;
|
|
13219
|
+
}
|
|
13220
|
+
prune(now) {
|
|
13221
|
+
for (const [app, window] of this.windows) if (!window.length || now - window[window.length - 1] >= 1e3) this.windows.delete(app);
|
|
13222
|
+
}
|
|
13223
|
+
clear() {
|
|
13224
|
+
this.windows.clear();
|
|
13225
|
+
}
|
|
13226
|
+
};
|
|
13227
|
+
/**
|
|
13228
|
+
* The apps of one chat on its live sessions. `open` (this side's apps, by chat app id, with their versions) lasts
|
|
13229
|
+
* across sessions, so each new one says `open` again; what the peer has open lasts one session.
|
|
13230
|
+
*/
|
|
13231
|
+
var AppSessions = class {
|
|
13232
|
+
open;
|
|
13233
|
+
now;
|
|
13234
|
+
peer = /* @__PURE__ */ new Map();
|
|
13235
|
+
/** What the peer had open on a session that ended, held for `APP_RESUME_GRACE_MS` until it says so again. */
|
|
13236
|
+
held = /* @__PURE__ */ new Map();
|
|
13237
|
+
received = new RateWindows(50, 64);
|
|
13238
|
+
sent = new RateWindows(48, 64);
|
|
13239
|
+
constructor(open = /* @__PURE__ */ new Map(), now = Date.now) {
|
|
13240
|
+
this.open = open;
|
|
13241
|
+
this.now = now;
|
|
13242
|
+
}
|
|
13243
|
+
/** The peer has this app open on this session, at this version. */
|
|
13244
|
+
peerVersion(app) {
|
|
13245
|
+
return this.peer.get(app);
|
|
13246
|
+
}
|
|
13247
|
+
/** The apps the peer has open on this session. */
|
|
13248
|
+
get peerOpen() {
|
|
13249
|
+
return this.peer;
|
|
13250
|
+
}
|
|
13251
|
+
/**
|
|
13252
|
+
* A `paired-app` frame from the peer, on a session where both sides offer `apps/1`. Counted before it is read; the
|
|
13253
|
+
* event to pass on, or null when it was dropped (malformed, over the rate, a data frame for an app not open on both
|
|
13254
|
+
* sides, an `open` past `APP_PEER_OPEN_MAX`, a `close` of an app the peer did not have open).
|
|
13255
|
+
*/
|
|
13256
|
+
receive(frame) {
|
|
13257
|
+
if (!isChatAppId(frame?.a) || !this.received.take(frame.a, this.now())) return null;
|
|
13258
|
+
const parsed = parseAppFrame(frame);
|
|
13259
|
+
if (!parsed) return null;
|
|
13260
|
+
const app = parsed.a;
|
|
13261
|
+
if ("d" in parsed) return this.open.has(app) && this.peer.has(app) ? {
|
|
13262
|
+
app,
|
|
13263
|
+
d: parsed.d
|
|
13264
|
+
} : null;
|
|
13265
|
+
if (parsed.o === "close") return this.peer.delete(app) || this.held.delete(app) ? {
|
|
13266
|
+
app,
|
|
13267
|
+
o: "close"
|
|
13268
|
+
} : null;
|
|
13269
|
+
if (!this.peer.has(app) && this.peer.size >= 16) return null;
|
|
13270
|
+
this.held.delete(app);
|
|
13271
|
+
this.peer.set(app, parsed.v);
|
|
13272
|
+
return {
|
|
13273
|
+
app,
|
|
13274
|
+
o: "open",
|
|
13275
|
+
v: parsed.v
|
|
13276
|
+
};
|
|
13277
|
+
}
|
|
13278
|
+
/** Whether a data frame for `app` may go now, the session being live with `apps/1` agreed; counted when it may. */
|
|
13279
|
+
canSend(app, data) {
|
|
13280
|
+
if (!this.open.has(app)) return "not-open";
|
|
13281
|
+
if (appDataBytes(data) > 32768) return "too-large";
|
|
13282
|
+
if (!this.peer.has(app)) return "peer-closed";
|
|
13283
|
+
return this.sent.take(app, this.now()) ? null : "too-fast";
|
|
13284
|
+
}
|
|
13285
|
+
/**
|
|
13286
|
+
* The session ended, or `apps/1` is no longer agreed: what the peer had open is gone, and each such app hears a
|
|
13287
|
+
* `close` with `offline`. This side's open apps stay, and say `open` again on the next session.
|
|
13288
|
+
*/
|
|
13289
|
+
sessionEnded() {
|
|
13290
|
+
const closed = [.../* @__PURE__ */ new Set([...this.held.keys(), ...this.peer.keys()])].map((app) => ({
|
|
13291
|
+
app,
|
|
13292
|
+
o: "close",
|
|
13293
|
+
offline: true
|
|
13294
|
+
}));
|
|
13295
|
+
this.peer.clear();
|
|
13296
|
+
this.held.clear();
|
|
13297
|
+
this.received.clear();
|
|
13298
|
+
this.sent.clear();
|
|
13299
|
+
return closed;
|
|
13300
|
+
}
|
|
13301
|
+
/**
|
|
13302
|
+
* The session ended or was replaced, and another may follow at once: what the peer had open is held, not closed (no
|
|
13303
|
+
* event now). Nothing is sent to a held app; an `open` on the next session takes it back, a `close` closes it, and
|
|
13304
|
+
* `heldExpired` closes the rest once `APP_RESUME_GRACE_MS` has passed.
|
|
13305
|
+
*/
|
|
13306
|
+
sessionSuspended() {
|
|
13307
|
+
for (const [app, version] of this.peer) this.held.set(app, version);
|
|
13308
|
+
this.peer.clear();
|
|
13309
|
+
this.received.clear();
|
|
13310
|
+
this.sent.clear();
|
|
13311
|
+
}
|
|
13312
|
+
/** Whether any of the peer's apps is held, waiting for its next session. */
|
|
13313
|
+
get holding() {
|
|
13314
|
+
return this.held.size > 0;
|
|
13315
|
+
}
|
|
13316
|
+
/** The grace is over: each app still held is closed, with `offline`. */
|
|
13317
|
+
heldExpired() {
|
|
13318
|
+
const closed = [...this.held.keys()].map((app) => ({
|
|
13319
|
+
app,
|
|
13320
|
+
o: "close",
|
|
13321
|
+
offline: true
|
|
13322
|
+
}));
|
|
13323
|
+
this.held.clear();
|
|
13324
|
+
return closed;
|
|
13325
|
+
}
|
|
13326
|
+
};
|
|
13327
|
+
//#endregion
|
|
13328
|
+
//#region ../core/src/webPush.ts
|
|
13329
|
+
var WebPushError = class extends Error {};
|
|
13330
|
+
var RECORD_SIZE = 4096;
|
|
13331
|
+
/** Push services refuse a token valid for more than 24 hours; 12 leaves room for skewed clocks. */
|
|
13332
|
+
var TOKEN_SECONDS = 43200;
|
|
13333
|
+
function point(value, what) {
|
|
13334
|
+
let bytes;
|
|
13335
|
+
try {
|
|
13336
|
+
bytes = fromBase64Url(value);
|
|
13337
|
+
} catch {
|
|
13338
|
+
throw new WebPushError(`${what} is not base64url`);
|
|
13339
|
+
}
|
|
13340
|
+
if (bytes.length !== 65 || bytes[0] !== 4) throw new WebPushError(`${what} is not an uncompressed P-256 key`);
|
|
13341
|
+
try {
|
|
13342
|
+
p256.Point.fromBytes(bytes);
|
|
13343
|
+
} catch {
|
|
13344
|
+
throw new WebPushError(`${what} is not a point on P-256`);
|
|
13345
|
+
}
|
|
13346
|
+
return bytes;
|
|
13347
|
+
}
|
|
13348
|
+
/** Whether a VAPID key pair is well formed and its halves belong together. */
|
|
13349
|
+
function vapidKeysMatch(keys) {
|
|
13350
|
+
try {
|
|
13351
|
+
const secret = fromBase64Url(keys.privateKey);
|
|
13352
|
+
return secret.length === 32 && toBase64Url(p256.getPublicKey(secret, false)) === toBase64Url(point(keys.publicKey, "The VAPID key"));
|
|
13353
|
+
} catch {
|
|
13354
|
+
return false;
|
|
13355
|
+
}
|
|
13356
|
+
}
|
|
13357
|
+
/**
|
|
13358
|
+
* The push services a browser subscribes with: Google (Chrome, Edge on Android, Brave, Opera), Apple (Safari), Mozilla
|
|
13359
|
+
* (Firefox), Microsoft (Edge on Windows). The same list as the push relay's (infra/services/push-relay/relay.mjs).
|
|
13360
|
+
*/
|
|
13361
|
+
var PUSH_SERVICE_HOSTS = [
|
|
13362
|
+
/^fcm\.googleapis\.com$/,
|
|
13363
|
+
/(^|\.)push\.apple\.com$/,
|
|
13364
|
+
/(^|\.)push\.services\.mozilla\.com$/,
|
|
13365
|
+
/(^|\.)notify\.windows\.com$/
|
|
13366
|
+
];
|
|
13367
|
+
/**
|
|
13368
|
+
* Only https, on one of the push services' host names and their default port, without credentials. The endpoint came
|
|
13369
|
+
* from a contact: it must not turn this app into a way to post to the contact's choice of address, local or not.
|
|
13370
|
+
*/
|
|
13371
|
+
function checkPushEndpoint(endpoint) {
|
|
13372
|
+
let url;
|
|
13373
|
+
try {
|
|
13374
|
+
url = new URL(endpoint);
|
|
13375
|
+
} catch {
|
|
13376
|
+
throw new WebPushError("The push endpoint is not a URL");
|
|
13377
|
+
}
|
|
13378
|
+
if (url.protocol !== "https:") throw new WebPushError("The push endpoint is not https");
|
|
13379
|
+
if (url.username || url.password) throw new WebPushError("The push endpoint carries credentials");
|
|
13380
|
+
if (url.port) throw new WebPushError("The push endpoint names a port");
|
|
13381
|
+
const host = url.hostname.toLowerCase();
|
|
13382
|
+
if (!PUSH_SERVICE_HOSTS.some((pattern) => pattern.test(host))) throw new WebPushError("The push endpoint is not a push service");
|
|
13383
|
+
if (endpoint.length > 2048) throw new WebPushError("The push endpoint is too long");
|
|
13384
|
+
return url;
|
|
13385
|
+
}
|
|
13386
|
+
/** `Authorization: vapid t=<JWT>, k=<public key>` for a request to `endpoint` (RFC 8292). */
|
|
13387
|
+
function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
|
|
13388
|
+
const audience = checkPushEndpoint(endpoint).origin;
|
|
13389
|
+
const encode = (value) => toBase64Url(utf8Encode(JSON.stringify(value)));
|
|
13390
|
+
const unsigned = `${encode({
|
|
13391
|
+
typ: "JWT",
|
|
13392
|
+
alg: "ES256"
|
|
13393
|
+
})}.${encode({
|
|
13394
|
+
aud: audience,
|
|
13395
|
+
exp: Math.floor(now / 1e3) + TOKEN_SECONDS,
|
|
13396
|
+
sub: subject
|
|
13397
|
+
})}`;
|
|
13398
|
+
return `vapid t=${unsigned}.${toBase64Url(p256.sign(utf8Encode(unsigned), fromBase64Url(keys.privateKey)))}, k=${keys.publicKey}`;
|
|
12601
13399
|
}
|
|
12602
13400
|
/**
|
|
12603
13401
|
* `plaintext` encrypted for a subscription (RFC 8291 with RFC 8188 `aes128gcm`, one record). `sender` fixes
|
|
@@ -12887,6 +13685,8 @@ function parsePinnedFrame(frame) {
|
|
|
12887
13685
|
* DHT, never stored, never counted as unread, no message id (older apps drop it). While the person keeps typing,
|
|
12888
13686
|
* `start` is said again at most every `TYPING_REFRESH_MS`; the receiver shows it until `stop`, a message from that
|
|
12889
13687
|
* contact, the end of the session, or `TYPING_TIMEOUT_MS` without a new `start` (so a dropped link never leaves it on).
|
|
13688
|
+
* A start standing when a session ends (a transport switch, a reconnect) is said again once the next one agreed
|
|
13689
|
+
* `typing/1`, if the person's last word is still that recent.
|
|
12890
13690
|
*
|
|
12891
13691
|
* A `start` may say what the contact is doing (`kind`: typing, recording a voice note, thinking) and, for bots, a
|
|
12892
13692
|
* short `status` line. Both are optional: an app that knows neither shows "typing…", and so does a kind it does not
|
|
@@ -12950,12 +13750,21 @@ function parseTypingActivity(frame) {
|
|
|
12950
13750
|
return typingActivity(frame?.kind, frame?.status);
|
|
12951
13751
|
}
|
|
12952
13752
|
var sameActivity = (a, b) => a?.kind === b?.kind && a?.status === b?.status;
|
|
12953
|
-
/**
|
|
13753
|
+
/**
|
|
13754
|
+
* This side's word: when to say `start` again, and whether a `stop` is owed. One per chat; what was said is per session
|
|
13755
|
+
* (`reset`), what the person is doing is not: a new session (a transport switch, a reconnect) says a standing start
|
|
13756
|
+
* again (`resume`), since the contact's reader cleared it when the old session ended.
|
|
13757
|
+
*
|
|
13758
|
+
* The rate window is not per session: the reader's is per chat too (it is not cleared when a session ends), so a
|
|
13759
|
+
* window that started afresh on each session could put a start past the reader's limit just after a switch.
|
|
13760
|
+
*/
|
|
12954
13761
|
var TypingSender = class {
|
|
12955
13762
|
now;
|
|
12956
13763
|
startedAt = 0;
|
|
12957
13764
|
standing = null;
|
|
12958
13765
|
recent = [];
|
|
13766
|
+
/** What the person is doing, and when it last said so: kept across sessions, until it stops. */
|
|
13767
|
+
wanted = null;
|
|
12959
13768
|
constructor(now = Date.now) {
|
|
12960
13769
|
this.now = now;
|
|
12961
13770
|
}
|
|
@@ -12966,6 +13775,33 @@ var TypingSender = class {
|
|
|
12966
13775
|
typing(activity = { kind: "typing" }) {
|
|
12967
13776
|
const now = this.now();
|
|
12968
13777
|
const word = typingActivity(activity.kind, activity.status);
|
|
13778
|
+
this.wanted = {
|
|
13779
|
+
activity: word,
|
|
13780
|
+
at: now
|
|
13781
|
+
};
|
|
13782
|
+
return this.say(word, now);
|
|
13783
|
+
}
|
|
13784
|
+
/**
|
|
13785
|
+
* The person typed while nothing can be said (no session, or `typing/1` not agreed on it yet): kept, so the start
|
|
13786
|
+
* goes once a session can carry it (`resume`).
|
|
13787
|
+
*/
|
|
13788
|
+
want(activity = { kind: "typing" }) {
|
|
13789
|
+
this.wanted = {
|
|
13790
|
+
activity: typingActivity(activity.kind, activity.status),
|
|
13791
|
+
at: this.now()
|
|
13792
|
+
};
|
|
13793
|
+
}
|
|
13794
|
+
/**
|
|
13795
|
+
* A new session agreed `typing/1`: the `start` to say on it when the person was still at it, its last word less than
|
|
13796
|
+
* `TYPING_TIMEOUT_MS` ago (as long as the contact would still have shown it). Null otherwise, when a start already
|
|
13797
|
+
* went on this session, or when the window is full (the person's next keystroke says it).
|
|
13798
|
+
*/
|
|
13799
|
+
resume() {
|
|
13800
|
+
const wanted = this.wanted, now = this.now();
|
|
13801
|
+
if (!wanted || this.startedAt || now - wanted.at >= 6e3) return null;
|
|
13802
|
+
return this.say(wanted.activity, now);
|
|
13803
|
+
}
|
|
13804
|
+
say(word, now) {
|
|
12969
13805
|
if (this.startedAt && now - this.startedAt < 3e3 && sameActivity(this.standing, word)) return null;
|
|
12970
13806
|
this.recent = this.recent.filter((at) => now - at < TYPING_RATE_WINDOW_MS);
|
|
12971
13807
|
if (this.recent.length >= 6) return null;
|
|
@@ -12974,8 +13810,12 @@ var TypingSender = class {
|
|
|
12974
13810
|
this.standing = word;
|
|
12975
13811
|
return typingFrame("start", word);
|
|
12976
13812
|
}
|
|
12977
|
-
/**
|
|
13813
|
+
/**
|
|
13814
|
+
* The person stopped (cleared the text, sent it, left the chat): a `stop`, only when a `start` is standing on this
|
|
13815
|
+
* session. Nothing is said again on the next one either way.
|
|
13816
|
+
*/
|
|
12978
13817
|
stopped() {
|
|
13818
|
+
this.wanted = null;
|
|
12979
13819
|
if (!this.startedAt) return null;
|
|
12980
13820
|
this.recent.push(this.now());
|
|
12981
13821
|
this.startedAt = 0;
|
|
@@ -12986,7 +13826,7 @@ var TypingSender = class {
|
|
|
12986
13826
|
get active() {
|
|
12987
13827
|
return this.startedAt !== 0;
|
|
12988
13828
|
}
|
|
12989
|
-
/** The session ended: nothing is standing on the next one. */
|
|
13829
|
+
/** The session ended: nothing is standing on the next one until `resume` (or the next keystroke) says it. */
|
|
12990
13830
|
reset() {
|
|
12991
13831
|
this.startedAt = 0;
|
|
12992
13832
|
this.standing = null;
|
|
@@ -15041,6 +15881,12 @@ var DHT_PIN_GRACE_MS = 1e4;
|
|
|
15041
15881
|
var RESUME_WAIT_MS = 1e4;
|
|
15042
15882
|
/** Two dials crossing: how long the lower key's side waits for its own to connect before it takes the contact's. */
|
|
15043
15883
|
var CROSSED_WAIT_MS = 3e3;
|
|
15884
|
+
/** A WebRTC attempt under way, not yet open: what a member's packet saying it has no WebRTC ends (`peerPacketTransports`). */
|
|
15885
|
+
var WEBRTC_ATTEMPT = /* @__PURE__ */ new Set([
|
|
15886
|
+
"offering",
|
|
15887
|
+
"answering",
|
|
15888
|
+
"connecting"
|
|
15889
|
+
]);
|
|
15044
15890
|
/** What a joiner is told when someone else used the invite first (`DhtDeliveryView.inviteTaken`). */
|
|
15045
15891
|
var INVITE_TAKEN = "Someone else joined with this invite first. Ask your contact for a new one.";
|
|
15046
15892
|
/**
|
|
@@ -15148,6 +15994,11 @@ var QUIET_FRAMES = /* @__PURE__ */ new Set([
|
|
|
15148
15994
|
*/
|
|
15149
15995
|
var PONG_WAIT_MS = 4e3;
|
|
15150
15996
|
/**
|
|
15997
|
+
* Back in front after an absence (`wake`), a live session that ends this soon after it was lost while the app was
|
|
15998
|
+
* away (a phone that put it to sleep): the chat resumes as after a restart, not as a session the contact closed.
|
|
15999
|
+
*/
|
|
16000
|
+
var WAKE_LOST_MS = 1e4;
|
|
16001
|
+
/**
|
|
15151
16002
|
* A `paired-message` frame. The preview (`pv`) is left out when the frame would pass `MAX_PAIRED_MESSAGE_FRAME`
|
|
15152
16003
|
* with it: the text matters, the card does not. A reply (`r`, WISP 401 § Replies) is a few hundred bytes at most
|
|
15153
16004
|
* and always goes, and so does a forwarded message's hop count (`fw`, WISP 401 § Forwards) and a status card (`sc`,
|
|
@@ -15186,6 +16037,11 @@ function pairedMessageFrame(id, ts, m, preview, reply, forwarded, card) {
|
|
|
15186
16037
|
* names the transport. One just read that names it with no descriptor says its endpoint is not up (WISP 03: still
|
|
15187
16038
|
* starting, or no free listener there): the one known is not dialled meanwhile (none, `undefined`).
|
|
15188
16039
|
*/
|
|
16040
|
+
/** The endpoint a descriptor names (Iroh's endpoint id, HyperDHT's public key), whatever relay it is homed on. */
|
|
16041
|
+
function endpointId(transport, descriptor) {
|
|
16042
|
+
const d = descriptor;
|
|
16043
|
+
return transport === "iroh/1" ? d?.id : d?.publicKey;
|
|
16044
|
+
}
|
|
15189
16045
|
function recordDescriptor(transport, known, record, newer, listed) {
|
|
15190
16046
|
if (!record) return newer && listed ? void 0 : known;
|
|
15191
16047
|
if (!known) return record;
|
|
@@ -15240,6 +16096,9 @@ var GhostLink = class {
|
|
|
15240
16096
|
endpoints = /* @__PURE__ */ new Map();
|
|
15241
16097
|
activeBinding;
|
|
15242
16098
|
dialing = false;
|
|
16099
|
+
/** A joiner's dial made as the link starts (`dialEarly`): its offer goes once the first look finds the inviter. */
|
|
16100
|
+
dialGate;
|
|
16101
|
+
firstLookDone;
|
|
15243
16102
|
dhtPinGrace = null;
|
|
15244
16103
|
/** Native attempts that failed in a row, and transports demoted until when (WISP 100, demotion). */
|
|
15245
16104
|
nativeFailures = /* @__PURE__ */ new Map();
|
|
@@ -15306,6 +16165,8 @@ var GhostLink = class {
|
|
|
15306
16165
|
/** Typing on this session (`typing/1`): when to say `start` again, and the contact's word with its timeout. */
|
|
15307
16166
|
typingSender = new TypingSender();
|
|
15308
16167
|
typingReceiver = new TypingReceiver((typing, activity) => this.options.events?.onPeerTyping?.(typing, activity));
|
|
16168
|
+
/** Mini-apps on this session (`apps/1`): this side's open apps (kept across sessions), the peer's, the limits. */
|
|
16169
|
+
apps;
|
|
15309
16170
|
/** Reaction frames the contact may send per window; the rest go unconfirmed and come again. */
|
|
15310
16171
|
reactionsReceived = new ReactionWindow(REACTION_LIMITS.receive);
|
|
15311
16172
|
/** Pin frames the contact may send per window; the rest go unconfirmed and come again. */
|
|
@@ -15323,6 +16184,8 @@ var GhostLink = class {
|
|
|
15323
16184
|
unansweredPings = 0;
|
|
15324
16185
|
/** The last ping's `PONG_WAIT_MS`: running until something comes back from the contact. */
|
|
15325
16186
|
pongWait = null;
|
|
16187
|
+
/** The last ping's wait ran out with nothing back, and nothing came since (`restartedRedial`). */
|
|
16188
|
+
pongLate = false;
|
|
15326
16189
|
openWaiters = [];
|
|
15327
16190
|
/** Set while a WebRTC attempt this side dialled is pending: the ranked transports to try if it fails. */
|
|
15328
16191
|
afterRtc;
|
|
@@ -15330,8 +16193,15 @@ var GhostLink = class {
|
|
|
15330
16193
|
raceTimer = null;
|
|
15331
16194
|
/** Set while `race` dials: the offer ending meanwhile is followed up by the race. */
|
|
15332
16195
|
racing = false;
|
|
15333
|
-
/**
|
|
16196
|
+
/**
|
|
16197
|
+
* The WebRTC offer this side's dial made last, with fallback allowed: what a transport that comes up meanwhile joins.
|
|
16198
|
+
* `resume`: the dial that made it is this side back after an absence, over WebRTC (`resuming`); `woke`: the absence
|
|
16199
|
+
* was the app's in the background, not a restart (`resumeAfterAbsence`).
|
|
16200
|
+
*/
|
|
15334
16201
|
offered;
|
|
16202
|
+
/** The attempt (its epoch) whose direct transports were knocked on as its offer went out (`knockEarly`), and whether that is under way. */
|
|
16203
|
+
knockedEarly = -1;
|
|
16204
|
+
knockingEarly = false;
|
|
15335
16205
|
/** When that offer's answer was applied (`RACE_ANSWERED_MS`); 0 while the data link is not connecting on it. */
|
|
15336
16206
|
answeredAt = 0;
|
|
15337
16207
|
/** This side's offer is out unanswered, or answered and not connected yet: what is ranked after WebRTC may race it. */
|
|
@@ -15342,8 +16212,21 @@ var GhostLink = class {
|
|
|
15342
16212
|
answeringEpoch = -1;
|
|
15343
16213
|
lastAutoConnectAt = 0;
|
|
15344
16214
|
autoConnectFailures = 0;
|
|
16215
|
+
/** The look due when a failed dial's backoff ends (`lookAtRetry`). */
|
|
16216
|
+
retryLook = null;
|
|
16217
|
+
/** This side's offer is out to the data link and has not reached the relays yet. */
|
|
16218
|
+
offerUnsent = false;
|
|
16219
|
+
/** The last dial ended with its offer never out (`offerUnsent`): the contact saw nothing of it. */
|
|
16220
|
+
dialUnseen = false;
|
|
15345
16221
|
/** Native transports a record just read listed with no way to dial them (`record-undescribed`), until one describes them again. */
|
|
15346
16222
|
undescribed = /* @__PURE__ */ new Set();
|
|
16223
|
+
/**
|
|
16224
|
+
* The native dial under way (`dialNative`): its attempt, its transport, the endpoint it went to (`redescribedDial`),
|
|
16225
|
+
* and whether it started with this device offline (`networkBack`).
|
|
16226
|
+
*/
|
|
16227
|
+
nativeDial = null;
|
|
16228
|
+
/** When the device's network last came back (`wake({ network: true })`): a dial under way across it is no failure. */
|
|
16229
|
+
networkBackAt = -Infinity;
|
|
15347
16230
|
/** The peer's packet I last looked fast for its offer after (its timestamp): once per packet. */
|
|
15348
16231
|
offerAwaitedFor = 0;
|
|
15349
16232
|
/** Whether the stream was blocked (DHT-only on either side) when last looked at. */
|
|
@@ -15391,8 +16274,15 @@ var GhostLink = class {
|
|
|
15391
16274
|
resuming;
|
|
15392
16275
|
/** Until then, a dial waits for the native endpoint `resuming` names to start (it is knocked on as it does). */
|
|
15393
16276
|
resumeWaitUntil = 0;
|
|
16277
|
+
/** What the last live session ran on, kept after it ended: a wake resumes on it (`resumeAfterAbsence`). */
|
|
16278
|
+
lastLiveOn;
|
|
16279
|
+
/** `resuming` is a wake's, not a restart's. */
|
|
16280
|
+
resumingWoke = false;
|
|
16281
|
+
/** Until then, a session that ends was lost while the app was away (`WAKE_LOST_MS`). */
|
|
16282
|
+
wokeUntil = 0;
|
|
15394
16283
|
constructor(options) {
|
|
15395
16284
|
this.options = options;
|
|
16285
|
+
this.apps = new AppSessions(options.appsOpen);
|
|
15396
16286
|
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");
|
|
15397
16287
|
this.deliveryMode = options.params.deliveryMode ?? "stream";
|
|
15398
16288
|
this.tracker = options.pairingProgress && options.pairing && !options.pairing.credentials.peerKey && this.deliveryMode !== "dht" ? new PairingTracker(options.pairingProgress.role, options.pairingProgress.startedAt, (progress) => {
|
|
@@ -15501,7 +16391,10 @@ var GhostLink = class {
|
|
|
15501
16391
|
firstPublish: options.firstPublish ?? (this.tracker && options.pairingProgress?.role === "joiner" || this.resuming ? "after-first-poll" : "at-start"),
|
|
15502
16392
|
events: {
|
|
15503
16393
|
onFirstPoll: () => {
|
|
15504
|
-
|
|
16394
|
+
const online = this.session.peerPresence.online, early = this.firstLookDone;
|
|
16395
|
+
this.firstLookDone = void 0;
|
|
16396
|
+
early?.(online);
|
|
16397
|
+
if (!this.dialing || early && !online) this.session.ensureAdvertised();
|
|
15505
16398
|
},
|
|
15506
16399
|
onMessages: (messages, batch) => {
|
|
15507
16400
|
if (options.params.profile) return;
|
|
@@ -15525,9 +16418,13 @@ var GhostLink = class {
|
|
|
15525
16418
|
this.tracker?.failed("publish", true, result.error);
|
|
15526
16419
|
return;
|
|
15527
16420
|
}
|
|
15528
|
-
if (result.signalOut)
|
|
16421
|
+
if (result.signalOut) {
|
|
16422
|
+
this.offerUnsent = false;
|
|
16423
|
+
this.dataLink.signalWentOut();
|
|
16424
|
+
}
|
|
15529
16425
|
this.tracker?.published();
|
|
15530
16426
|
this.publishRecovered();
|
|
16427
|
+
this.dialSeenNow();
|
|
15531
16428
|
},
|
|
15532
16429
|
onPeerAck: (ack) => events.onPeerAck?.(ack),
|
|
15533
16430
|
onCallSignal: (signal) => {
|
|
@@ -15622,13 +16519,25 @@ var GhostLink = class {
|
|
|
15622
16519
|
if (this.activeBinding) return;
|
|
15623
16520
|
events.onDataLinkState?.(state);
|
|
15624
16521
|
if (state === "open") this.afterRtc = void 0;
|
|
15625
|
-
if (state === "
|
|
16522
|
+
if (state === "offering") {
|
|
16523
|
+
this.offerUnsent = true;
|
|
16524
|
+
this.dialUnseen = false;
|
|
16525
|
+
}
|
|
16526
|
+
if (state === "answering") {
|
|
16527
|
+
this.answeringEpoch = this.connectionEpoch;
|
|
16528
|
+
if (this.resumingOverRtc) {
|
|
16529
|
+
const epoch = this.connectionEpoch;
|
|
16530
|
+
setTimeout(() => this.maybeKnockAnswering(epoch), 0);
|
|
16531
|
+
}
|
|
16532
|
+
}
|
|
15626
16533
|
this.answeredAt = 0;
|
|
15627
16534
|
if (state === "connecting" && was === "offering" && this.offered?.epoch === this.connectionEpoch) {
|
|
15628
16535
|
this.answeredAt = Date.now();
|
|
15629
16536
|
this.scheduleRace(this.offered.epoch, this.offered.at);
|
|
15630
16537
|
}
|
|
15631
16538
|
if (state !== "idle") return;
|
|
16539
|
+
this.dialUnseen = was === "offering" && this.offerUnsent;
|
|
16540
|
+
this.offerUnsent = false;
|
|
15632
16541
|
const unanswerable = was === "answering" && this.answeringEpoch === this.connectionEpoch && !this.channel && !this.stopped && !this.leaving;
|
|
15633
16542
|
if (unanswerable) this.rtcUnstarted();
|
|
15634
16543
|
this.clearRace();
|
|
@@ -15641,7 +16550,10 @@ var GhostLink = class {
|
|
|
15641
16550
|
return;
|
|
15642
16551
|
}
|
|
15643
16552
|
this.afterRtc = void 0;
|
|
15644
|
-
if (!this.dialing)
|
|
16553
|
+
if (!this.dialing) {
|
|
16554
|
+
this.attemptEnded();
|
|
16555
|
+
this.lookAtRetry();
|
|
16556
|
+
}
|
|
15645
16557
|
this.rejectWaiters(new GhostlyHttpError("unreachable", "Could not connect to the peer"));
|
|
15646
16558
|
if (unanswerable && this.dialsPastRtc()) this.redial();
|
|
15647
16559
|
},
|
|
@@ -15700,7 +16612,8 @@ var GhostLink = class {
|
|
|
15700
16612
|
},
|
|
15701
16613
|
unreached: (target, reason) => {
|
|
15702
16614
|
this.unreached(target, reason);
|
|
15703
|
-
}
|
|
16615
|
+
},
|
|
16616
|
+
upgrade: () => !!this.options.upgradeSupport && this.sessionCapabilities.agreed("upgrade/1")
|
|
15704
16617
|
});
|
|
15705
16618
|
if (options.native?.automatic === false) this.switcher.choseApart();
|
|
15706
16619
|
}
|
|
@@ -15967,16 +16880,46 @@ var GhostLink = class {
|
|
|
15967
16880
|
this.session.expectPeer();
|
|
15968
16881
|
this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
|
|
15969
16882
|
}
|
|
15970
|
-
if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner")
|
|
16883
|
+
if (this.tracker && !this.tracker.done && this.options.pairingProgress?.role === "joiner") {
|
|
16884
|
+
this.dht?.expect();
|
|
16885
|
+
this.dialEarly();
|
|
16886
|
+
}
|
|
15971
16887
|
this.dht?.start();
|
|
15972
16888
|
}
|
|
16889
|
+
/**
|
|
16890
|
+
* A joiner's offer is gathered as the link starts, while its first look at the inviter's record is under way, and goes
|
|
16891
|
+
* once that look finds the inviter (`dialGate`): the two used to come one after the other, the read (0.15 to 0.8 s)
|
|
16892
|
+
* then the gathering (0.2 s to 2 s).
|
|
16893
|
+
*/
|
|
16894
|
+
dialEarly() {
|
|
16895
|
+
if (!this.options.params.profile || !this.options.autoConnect || this.deliveryMode === "dht" || this.streamBlocked || this.dialing || this.channel) return;
|
|
16896
|
+
const startedAt = this.options.pairingProgress?.startedAt ?? 0;
|
|
16897
|
+
if (Date.now() - startedAt > 6e4) return;
|
|
16898
|
+
this.dialGate = new Promise((resolve) => {
|
|
16899
|
+
this.firstLookDone = (online) => {
|
|
16900
|
+
if (online) {
|
|
16901
|
+
this.lastAutoConnectAt = Date.now();
|
|
16902
|
+
this.autoConnectFailures++;
|
|
16903
|
+
}
|
|
16904
|
+
resolve(online);
|
|
16905
|
+
};
|
|
16906
|
+
});
|
|
16907
|
+
traceLink(this.myPubKeyZ32, "dial", { early: true });
|
|
16908
|
+
this.dial().catch((error) => {
|
|
16909
|
+
this.tracker?.failed("transport", true);
|
|
16910
|
+
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
16911
|
+
this.lookAtRetry();
|
|
16912
|
+
});
|
|
16913
|
+
}
|
|
15973
16914
|
async stop(announce = true) {
|
|
15974
16915
|
this.stopped = true;
|
|
15975
16916
|
this.clearRace();
|
|
16917
|
+
if (this.retryLook) clearTimeout(this.retryLook);
|
|
15976
16918
|
if (this.dhtPinGrace) clearTimeout(this.dhtPinGrace);
|
|
15977
16919
|
if (this.waiting?.timer) clearTimeout(this.waiting.timer);
|
|
15978
16920
|
await this.dht?.stop();
|
|
15979
16921
|
this.disconnect();
|
|
16922
|
+
this.appsEnded();
|
|
15980
16923
|
await Promise.allSettled([...this.endpoints.values()].map((endpoint) => endpoint.close()));
|
|
15981
16924
|
this.endpoints.clear();
|
|
15982
16925
|
await this.session.stop(announce);
|
|
@@ -16021,18 +16964,32 @@ var GhostLink = class {
|
|
|
16021
16964
|
});
|
|
16022
16965
|
}
|
|
16023
16966
|
/** Closes what a switch replaced once the contact has had time to move too. */
|
|
16024
|
-
retire(old, also) {
|
|
16967
|
+
retire(old, also, rtc = false) {
|
|
16025
16968
|
if (this.retiring.has(old)) return;
|
|
16969
|
+
let closed = () => {};
|
|
16970
|
+
const done = new Promise((resolve) => {
|
|
16971
|
+
closed = resolve;
|
|
16972
|
+
});
|
|
16026
16973
|
const close = () => {
|
|
16027
16974
|
this.retiring.delete(old);
|
|
16028
16975
|
old.close();
|
|
16029
16976
|
also();
|
|
16977
|
+
closed();
|
|
16030
16978
|
};
|
|
16031
16979
|
this.retiring.set(old, {
|
|
16032
16980
|
close,
|
|
16033
|
-
timer: setTimeout(close, SWITCH_RETIRE_MS)
|
|
16981
|
+
timer: setTimeout(close, SWITCH_RETIRE_MS),
|
|
16982
|
+
rtc,
|
|
16983
|
+
closed: done
|
|
16034
16984
|
});
|
|
16035
16985
|
}
|
|
16986
|
+
/**
|
|
16987
|
+
* The WebRTC session a switch moved off is still retiring (`SWITCH_RETIRE_MS`): its data link is open, and a new offer
|
|
16988
|
+
* cannot go until it closes. Resolves once it has (at once when none is).
|
|
16989
|
+
*/
|
|
16990
|
+
rtcRetired() {
|
|
16991
|
+
return Promise.all([...this.retiring.values()].filter((entry) => entry.rtc).map((entry) => entry.closed)).then(() => {});
|
|
16992
|
+
}
|
|
16036
16993
|
closeRetired() {
|
|
16037
16994
|
for (const { timer, close } of [...this.retiring.values()]) {
|
|
16038
16995
|
clearTimeout(timer);
|
|
@@ -16104,7 +17061,7 @@ var GhostLink = class {
|
|
|
16104
17061
|
const listed = !!wanted && !!this.peerRecordTransports?.includes(wanted), dialable = !!wanted && !!this.peerDescriptors[wanted];
|
|
16105
17062
|
this.peerRecordTransports = [...transports];
|
|
16106
17063
|
let changed = false, redescribed = false, again = false, news = false;
|
|
16107
|
-
const renamed = /* @__PURE__ */ new Set();
|
|
17064
|
+
const renamed = /* @__PURE__ */ new Set(), reidentified = /* @__PURE__ */ new Set();
|
|
16108
17065
|
for (const t of ["iroh/1", "hyperdht/1"]) {
|
|
16109
17066
|
const known = this.peerDescriptors[t], next = recordDescriptor(t, known, descriptors[t], fresh, transports.includes(t));
|
|
16110
17067
|
if (next === known) continue;
|
|
@@ -16124,6 +17081,7 @@ var GhostLink = class {
|
|
|
16124
17081
|
changed = true;
|
|
16125
17082
|
redescribed ||= t === wanted;
|
|
16126
17083
|
renamed.add(t);
|
|
17084
|
+
if (known && endpointId(t, known) !== endpointId(t, next)) reidentified.add(t);
|
|
16127
17085
|
this.nativeFailures.delete(t);
|
|
16128
17086
|
this.demotedUntil.delete(t);
|
|
16129
17087
|
}
|
|
@@ -16149,6 +17107,8 @@ var GhostLink = class {
|
|
|
16149
17107
|
this.redial();
|
|
16150
17108
|
return;
|
|
16151
17109
|
}
|
|
17110
|
+
if (held && heldOn && reidentified.has(heldOn) && this.restartedRedial(held, heldOn)) return;
|
|
17111
|
+
if (this.redescribedDial()) return;
|
|
16152
17112
|
if (this.channel || this.dialing) return;
|
|
16153
17113
|
if (news || !again) {
|
|
16154
17114
|
this.autoConnectFailures = 0;
|
|
@@ -16351,6 +17311,7 @@ var GhostLink = class {
|
|
|
16351
17311
|
return;
|
|
16352
17312
|
}
|
|
16353
17313
|
this.endpoints.set(endpoint.transport, endpoint);
|
|
17314
|
+
traceLink(this.myPubKeyZ32, "endpoint-up", { transport: endpoint.transport });
|
|
16354
17315
|
endpoint.onConnection = ({ channel, binding }) => {
|
|
16355
17316
|
traceLink(this.myPubKeyZ32, "dialed-in", {
|
|
16356
17317
|
transport: binding.transport,
|
|
@@ -16361,6 +17322,7 @@ var GhostLink = class {
|
|
|
16361
17322
|
};
|
|
16362
17323
|
endpoint.onUnavailable = () => {
|
|
16363
17324
|
if (this.endpoints.get(endpoint.transport) !== endpoint) return;
|
|
17325
|
+
traceLink(this.myPubKeyZ32, "endpoint-gone", { transport: endpoint.transport });
|
|
16364
17326
|
this.endpoints.delete(endpoint.transport);
|
|
16365
17327
|
this.advertiseTransports();
|
|
16366
17328
|
this.options.events?.onTransportsChanged?.();
|
|
@@ -16427,7 +17389,7 @@ var GhostLink = class {
|
|
|
16427
17389
|
if (!said) return;
|
|
16428
17390
|
const transports = said.transports, descriptors = dialDescriptors(said.descriptors);
|
|
16429
17391
|
traceLink(this.myPubKeyZ32, "packet-transports", { transports });
|
|
16430
|
-
if (!transports.includes("webrtc/1") && !this.channel && this.dataLink.state
|
|
17392
|
+
if (!transports.includes("webrtc/1") && !this.channel && WEBRTC_ATTEMPT.has(this.dataLink.state)) this.disconnect();
|
|
16431
17393
|
this.options.events?.onPacketTransports?.(transports, descriptors);
|
|
16432
17394
|
this.learnPeerTransports(transports, descriptors, true);
|
|
16433
17395
|
}
|
|
@@ -16496,7 +17458,10 @@ var GhostLink = class {
|
|
|
16496
17458
|
* simply be away. Two dials crossing are settled by key order (see `registerEndpoint`).
|
|
16497
17459
|
*/
|
|
16498
17460
|
async knock(transport) {
|
|
16499
|
-
if (this.resuming === transport)
|
|
17461
|
+
if (this.resuming === transport) {
|
|
17462
|
+
this.resuming = void 0;
|
|
17463
|
+
this.resumingWoke = false;
|
|
17464
|
+
}
|
|
16500
17465
|
const endpoint = this.endpoints.get(transport), descriptor = this.peerDescriptors[transport];
|
|
16501
17466
|
if (!endpoint || !descriptor || this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped) return;
|
|
16502
17467
|
if (!this.transportOffer().includes(transport)) return;
|
|
@@ -16649,7 +17614,11 @@ var GhostLink = class {
|
|
|
16649
17614
|
const remembered = this.peerTransports && rankTransports(local, this.peerFallback ? this.peerTransports : this.peerTransports.slice(0, 1), relayed);
|
|
16650
17615
|
const ranked = remembered?.length ? remembered : this.peerTransports ? rankTransports(local, this.peerTransports, relayed) : local.filter((t) => t === "webrtc/1");
|
|
16651
17616
|
const chosen = this.switcher.chosenTarget ?? this.choiceApart?.transport ?? this.resuming;
|
|
16652
|
-
|
|
17617
|
+
const resume = this.resuming === "webrtc/1", woke = resume && this.resumingWoke;
|
|
17618
|
+
if (chosen && chosen === this.resuming && ranked.includes(chosen)) {
|
|
17619
|
+
this.resuming = void 0;
|
|
17620
|
+
this.resumingWoke = false;
|
|
17621
|
+
}
|
|
16653
17622
|
const choices = chosen && ranked.includes(chosen) ? [chosen, ...ranked.filter((t) => t !== chosen)] : ranked;
|
|
16654
17623
|
if (!choices.length) {
|
|
16655
17624
|
if (this.options.packetTransports && this.autoConnectFailures <= PACKET_TRANSPORTS_FAST_ATTEMPTS) this.session.expectPeer();
|
|
@@ -16668,12 +17637,22 @@ var GhostLink = class {
|
|
|
16668
17637
|
epoch,
|
|
16669
17638
|
rest
|
|
16670
17639
|
} : void 0;
|
|
16671
|
-
const
|
|
16672
|
-
|
|
17640
|
+
const standing = this.offerOut && this.offered?.epoch === epoch ? this.offered : void 0;
|
|
17641
|
+
const offeredAt = standing?.at ?? Date.now();
|
|
17642
|
+
this.offered = fallback ? standing ?? {
|
|
16673
17643
|
epoch,
|
|
16674
|
-
at: offeredAt
|
|
17644
|
+
at: offeredAt,
|
|
17645
|
+
...resume && { resume },
|
|
17646
|
+
...woke && { woke }
|
|
16675
17647
|
} : void 0;
|
|
16676
|
-
|
|
17648
|
+
const gate = this.dialGate;
|
|
17649
|
+
this.dialGate = void 0;
|
|
17650
|
+
if (await this.dataLink.connect(gate) === "held") {
|
|
17651
|
+
traceLink(this.myPubKeyZ32, "dial-held", {});
|
|
17652
|
+
this.afterRtc = void 0;
|
|
17653
|
+
this.offered = void 0;
|
|
17654
|
+
return;
|
|
17655
|
+
}
|
|
16677
17656
|
if (this.dataLink.state !== "idle" || epoch !== this.connectionEpoch) {
|
|
16678
17657
|
this.scheduleRace(epoch, offeredAt);
|
|
16679
17658
|
return;
|
|
@@ -16695,6 +17674,7 @@ var GhostLink = class {
|
|
|
16695
17674
|
if (epoch === this.connectionEpoch) this.attemptEnded(error instanceof Error ? error.message : String(error));
|
|
16696
17675
|
throw error;
|
|
16697
17676
|
} finally {
|
|
17677
|
+
this.dialGate = void 0;
|
|
16698
17678
|
if (epoch === this.connectionEpoch) {
|
|
16699
17679
|
this.dialing = false;
|
|
16700
17680
|
if (!this.channel && !this.afterRtc && this.dataLink.state === "idle") this.attemptEnded();
|
|
@@ -16711,6 +17691,12 @@ var GhostLink = class {
|
|
|
16711
17691
|
const descriptor = this.peerDescriptors[transport];
|
|
16712
17692
|
if (!endpoint || !descriptor) return /* @__PURE__ */ new Error("Peer native address unavailable; reconnect WebRTC once to exchange endpoints");
|
|
16713
17693
|
const started = Date.now();
|
|
17694
|
+
const dialed = this.nativeDial = {
|
|
17695
|
+
epoch,
|
|
17696
|
+
transport,
|
|
17697
|
+
endpoint: endpointId(transport, descriptor),
|
|
17698
|
+
offline: this.isOffline()
|
|
17699
|
+
};
|
|
16714
17700
|
try {
|
|
16715
17701
|
const { channel, binding } = await endpoint.connect(descriptor);
|
|
16716
17702
|
this.nativeFailures.delete(transport);
|
|
@@ -16729,6 +17715,10 @@ var GhostLink = class {
|
|
|
16729
17715
|
});
|
|
16730
17716
|
if (epoch !== this.connectionEpoch) return true;
|
|
16731
17717
|
this.attemptFailed(transport, error instanceof Error ? error.message : String(error));
|
|
17718
|
+
if (dialed.offline || this.isOffline() || this.networkBackAt >= started) {
|
|
17719
|
+
traceLink(this.myPubKeyZ32, "dial-failed-offline", { transport });
|
|
17720
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
17721
|
+
}
|
|
16732
17722
|
const failures = (this.nativeFailures.get(transport) ?? 0) + 1;
|
|
16733
17723
|
if (failures >= 3) {
|
|
16734
17724
|
this.nativeFailures.delete(transport);
|
|
@@ -16736,7 +17726,58 @@ var GhostLink = class {
|
|
|
16736
17726
|
traceLink(this.myPubKeyZ32, "demote", { transport });
|
|
16737
17727
|
} else this.nativeFailures.set(transport, failures);
|
|
16738
17728
|
return error instanceof Error ? error : new Error(String(error));
|
|
17729
|
+
} finally {
|
|
17730
|
+
if (this.nativeDial === dialed) this.nativeDial = null;
|
|
17731
|
+
}
|
|
17732
|
+
}
|
|
17733
|
+
/**
|
|
17734
|
+
* The native dial under way went to an endpoint the contact's newer packet no longer names for that transport: its
|
|
17735
|
+
* app started again (a page reloading, as both devices of a handoff do), and the endpoint dialled went with the old
|
|
17736
|
+
* one. That dial would end only at its connect timeout (Iroh: 20 s, then the next attempt's wait): it is let go, and
|
|
17737
|
+
* the endpoint the packet names dialled now. Not while a WebRTC offer of this attempt stands: the race that follows
|
|
17738
|
+
* it keeps its own order.
|
|
17739
|
+
*/
|
|
17740
|
+
redescribedDial() {
|
|
17741
|
+
const dial = this.nativeDial;
|
|
17742
|
+
if (!dial || dial.epoch !== this.connectionEpoch || !this.dialing || this.channel || this.dataLink.state !== "idle") return false;
|
|
17743
|
+
const now = this.peerDescriptors[dial.transport];
|
|
17744
|
+
if (!now || endpointId(dial.transport, now) === dial.endpoint) return false;
|
|
17745
|
+
traceLink(this.myPubKeyZ32, "redescribed-dial", { transport: dial.transport });
|
|
17746
|
+
this.nativeDial = null;
|
|
17747
|
+
this.connectionEpoch++;
|
|
17748
|
+
this.dialing = false;
|
|
17749
|
+
this.clearRace();
|
|
17750
|
+
this.afterRtc = void 0;
|
|
17751
|
+
this.autoConnectFailures = Math.max(0, this.autoConnectFailures - 1);
|
|
17752
|
+
this.redial();
|
|
17753
|
+
return true;
|
|
17754
|
+
}
|
|
17755
|
+
/** Whether this device has no network now (`GhostLinkOptions.offline`). */
|
|
17756
|
+
isOffline() {
|
|
17757
|
+
return this.options.offline ? this.options.offline() : globalThis.navigator?.onLine === false;
|
|
17758
|
+
}
|
|
17759
|
+
/**
|
|
17760
|
+
* The device's network came back. A native dial that started while it was away would end only at its connect timeout
|
|
17761
|
+
* (Iroh: 20 s), well past the return: it is let go, and the chat dialled again now. Its WebRTC offer, if one stands,
|
|
17762
|
+
* was made with no network too, and goes with it.
|
|
17763
|
+
*/
|
|
17764
|
+
networkBack() {
|
|
17765
|
+
this.networkBackAt = Date.now();
|
|
17766
|
+
const dial = this.nativeDial;
|
|
17767
|
+
if (!dial?.offline || dial.epoch !== this.connectionEpoch || !this.dialing || this.channel) return;
|
|
17768
|
+
if (this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
|
|
17769
|
+
traceLink(this.myPubKeyZ32, "offline-dial", { transport: dial.transport });
|
|
17770
|
+
this.nativeDial = null;
|
|
17771
|
+
if (this.dataLink.state !== "idle") this.disconnect();
|
|
17772
|
+
else {
|
|
17773
|
+
this.connectionEpoch++;
|
|
17774
|
+
this.dialing = false;
|
|
17775
|
+
this.clearRace();
|
|
17776
|
+
this.afterRtc = void 0;
|
|
16739
17777
|
}
|
|
17778
|
+
this.autoConnectFailures = 0;
|
|
17779
|
+
this.lastAutoConnectAt = 0;
|
|
17780
|
+
this.redial();
|
|
16740
17781
|
}
|
|
16741
17782
|
/** A WebRTC attempt this side dialled ended without opening: the next ranked transports, in order. */
|
|
16742
17783
|
async dialAfterRtc(next) {
|
|
@@ -16772,12 +17813,68 @@ var GhostLink = class {
|
|
|
16772
17813
|
const answered = this.answeredAt ? this.answeredAt + RACE_ANSWERED_MS : Infinity;
|
|
16773
17814
|
const at = next.rest.filter((t) => t !== "webrtc/1").map((t) => Math.max(Math.min(offeredAt + (relayed.includes(t) ? RACE_RELAYED_MS : RACE_DIRECT_MS), answered), this.canDial(t) ? 0 : now + RACE_RETRY_MS));
|
|
16774
17815
|
if (!at.length) return;
|
|
17816
|
+
if (this.offered?.resume && this.knockedEarly !== epoch) setTimeout(() => this.maybeKnockEarly(epoch), 0);
|
|
16775
17817
|
this.raceTimer = setTimeout(() => {
|
|
16776
17818
|
this.raceTimer = null;
|
|
16777
17819
|
this.race(epoch, offeredAt);
|
|
16778
17820
|
}, Math.max(0, Math.min(...at) - now));
|
|
16779
17821
|
}
|
|
16780
17822
|
/**
|
|
17823
|
+
* Back after an absence, this side answers an offer the contact made while it was away (it dials whatever its key):
|
|
17824
|
+
* the contact looks for that answer at the pace of an offer out a while (every 8 s on the relays), and its attempt may
|
|
17825
|
+
* end before it does. The direct transports both run are knocked on meanwhile, and the relayed ones where both apps
|
|
17826
|
+
* move off a relay once live (`upgrade/1`): the first live wins, and a knock that does not connect counts as nothing.
|
|
17827
|
+
*/
|
|
17828
|
+
maybeKnockAnswering(epoch) {
|
|
17829
|
+
if (!this.resumingOverRtc || epoch !== this.connectionEpoch || this.knockedEarly === epoch || this.dataLink.state === "idle") return;
|
|
17830
|
+
if (this.channel || this.dialing || this.stopped || this.streamBlocked || this.keyStopped || !this.contactQuiet(1e4)) return;
|
|
17831
|
+
if (!this.fallback || !this.peerFallback) return;
|
|
17832
|
+
this.knockedEarly = epoch;
|
|
17833
|
+
if (this.resuming === "webrtc/1") {
|
|
17834
|
+
this.resuming = void 0;
|
|
17835
|
+
this.resumingWoke = false;
|
|
17836
|
+
}
|
|
17837
|
+
const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
|
|
17838
|
+
const offer = this.transportOffer();
|
|
17839
|
+
const direct = TRANSPORTS.filter((t) => t !== "webrtc/1" && offer.includes(t) && !!this.peerTransports?.includes(t) && !relayed.includes(t) && this.canDial(t));
|
|
17840
|
+
if (!direct.length) return;
|
|
17841
|
+
this.knockEarly(epoch, direct, () => this.dataLink.state === "answering" || this.dataLink.state === "connecting");
|
|
17842
|
+
}
|
|
17843
|
+
/** Back after an absence over WebRTC: not dialled yet, or this attempt's offer is the resume's (`offered.resume`). */
|
|
17844
|
+
get resumingOverRtc() {
|
|
17845
|
+
return this.resuming === "webrtc/1" || !!this.offered?.resume && this.offered.epoch === this.connectionEpoch;
|
|
17846
|
+
}
|
|
17847
|
+
/** Both apps move a chat off a relay once live (`upgrade/1`): this one, and the contact's by its capability record. */
|
|
17848
|
+
upgradesFromRelay() {
|
|
17849
|
+
return !!this.options.upgradeSupport && !!this.options.peerUpgrades?.();
|
|
17850
|
+
}
|
|
17851
|
+
/** The contact's newest packet is older than `EXPECT_PEER_MS`: it is not signalling, and reads at a slow pace. */
|
|
17852
|
+
contactQuiet(ms = EXPECT_PEER_MS) {
|
|
17853
|
+
return Date.now() - presenceSeenAt(this.session.peerPresence) >= ms;
|
|
17854
|
+
}
|
|
17855
|
+
/** The direct transports of an attempt that resumes to a quiet contact, knocked on while its offer stands (`scheduleRace`). */
|
|
17856
|
+
maybeKnockEarly(epoch) {
|
|
17857
|
+
const next = this.afterRtc;
|
|
17858
|
+
if (!this.offered?.resume || this.offered.epoch !== epoch || this.knockedEarly === epoch || next?.epoch !== epoch || epoch !== this.connectionEpoch) return;
|
|
17859
|
+
if (this.dialing || this.channel || this.stopped || !this.offerOut || !(this.offered.woke || this.contactQuiet())) return;
|
|
17860
|
+
const relayed = this.upgradesFromRelay() ? [] : this.relayedTransports;
|
|
17861
|
+
const direct = next.rest.filter((t) => !relayed.includes(t) && this.canDial(t));
|
|
17862
|
+
if (!direct.length) return;
|
|
17863
|
+
this.knockedEarly = epoch;
|
|
17864
|
+
this.knockEarly(epoch, direct);
|
|
17865
|
+
}
|
|
17866
|
+
async knockEarly(epoch, transports, standing = () => this.offerOut) {
|
|
17867
|
+
this.knockingEarly = true;
|
|
17868
|
+
try {
|
|
17869
|
+
for (const transport of transports) {
|
|
17870
|
+
if (epoch !== this.connectionEpoch || this.channel || this.stopped || !standing()) return;
|
|
17871
|
+
await this.knock(transport);
|
|
17872
|
+
}
|
|
17873
|
+
} finally {
|
|
17874
|
+
this.knockingEarly = false;
|
|
17875
|
+
}
|
|
17876
|
+
}
|
|
17877
|
+
/**
|
|
16781
17878
|
* A native endpoint that started while this side's offer is out unanswered: its transport was not up when the dial
|
|
16782
17879
|
* ranked what to try, and joins the race (and what the offer ending goes on to) if both sides allow it.
|
|
16783
17880
|
*/
|
|
@@ -16822,6 +17919,13 @@ var GhostLink = class {
|
|
|
16822
17919
|
*/
|
|
16823
17920
|
async race(epoch, offeredAt) {
|
|
16824
17921
|
const next = this.afterRtc;
|
|
17922
|
+
if (this.knockingEarly && next?.epoch === epoch && epoch === this.connectionEpoch && !this.channel) {
|
|
17923
|
+
this.raceTimer = setTimeout(() => {
|
|
17924
|
+
this.raceTimer = null;
|
|
17925
|
+
this.race(epoch, offeredAt);
|
|
17926
|
+
}, RACE_RETRY_MS);
|
|
17927
|
+
return;
|
|
17928
|
+
}
|
|
16825
17929
|
if (!next || next.epoch !== epoch || epoch !== this.connectionEpoch || this.channel || this.dialing || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.offerOut) return;
|
|
16826
17930
|
const relayed = this.relayedTransports;
|
|
16827
17931
|
const late = Date.now() - offeredAt >= 4e4 || this.answeredAt > 0 && Date.now() - this.answeredAt >= 6e3;
|
|
@@ -17113,6 +18217,7 @@ var GhostLink = class {
|
|
|
17113
18217
|
failures: this.autoConnectFailures,
|
|
17114
18218
|
left: wait - (Date.now() - this.lastAutoConnectAt)
|
|
17115
18219
|
});
|
|
18220
|
+
this.lookAtRetry();
|
|
17116
18221
|
return;
|
|
17117
18222
|
}
|
|
17118
18223
|
traceLink(this.myPubKeyZ32, "dial", { failures: this.autoConnectFailures });
|
|
@@ -17121,6 +18226,7 @@ var GhostLink = class {
|
|
|
17121
18226
|
this.dial().catch((error) => {
|
|
17122
18227
|
this.tracker?.failed("transport", true);
|
|
17123
18228
|
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
18229
|
+
this.lookAtRetry();
|
|
17124
18230
|
});
|
|
17125
18231
|
}
|
|
17126
18232
|
/**
|
|
@@ -17140,8 +18246,42 @@ var GhostLink = class {
|
|
|
17140
18246
|
this.dial().catch((error) => {
|
|
17141
18247
|
this.tracker?.failed("transport", true);
|
|
17142
18248
|
this.dialFailed(error instanceof Error ? error.message : String(error));
|
|
18249
|
+
this.lookAtRetry();
|
|
17143
18250
|
});
|
|
17144
18251
|
}
|
|
18252
|
+
/**
|
|
18253
|
+
* A failed dial waits `dialWait` before the next, and only a look at the contact's record dials: the next one came at
|
|
18254
|
+
* the link's own pace (30 s in the background), which knows nothing of the backoff. A member let in to a group
|
|
18255
|
+
* reached one of its members 72 s after its welcome, 40 s of backoff and 32 s more for its next look (meshSignals on
|
|
18256
|
+
* CI, 2026-10-06). One look goes when the backoff ends (`liveAttempt.retryAt`), and its read decides, as any look's
|
|
18257
|
+
* does. A dial or a reset of the backoff since makes it moot: what comes next arms its own.
|
|
18258
|
+
*/
|
|
18259
|
+
lookAtRetry() {
|
|
18260
|
+
if (this.retryLook) clearTimeout(this.retryLook);
|
|
18261
|
+
this.retryLook = null;
|
|
18262
|
+
const from = this.lastAutoConnectAt;
|
|
18263
|
+
if (!from || this.stopped || this.leaving || this.streamBlocked || this.keyStopped || !this.options.autoConnect) return;
|
|
18264
|
+
this.retryLook = setTimeout(() => {
|
|
18265
|
+
this.retryLook = null;
|
|
18266
|
+
if (this.lastAutoConnectAt !== from || this.stopped || this.leaving || this.yielding || this.streamBlocked || this.keyStopped || this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
18267
|
+
traceLink(this.myPubKeyZ32, "retry-look", { failures: this.autoConnectFailures });
|
|
18268
|
+
this.session.lookNow();
|
|
18269
|
+
}, Math.max(0, from + this.dialWait() - Date.now()));
|
|
18270
|
+
}
|
|
18271
|
+
/**
|
|
18272
|
+
* A packet of this link reached the relays after a dial whose offer never did (the relays failed, or their budget held
|
|
18273
|
+
* it, for the whole attempt): the contact saw nothing of that dial, so it is no reason to wait, and the next goes now.
|
|
18274
|
+
* Without this a dial made during an outage set the wait, three minutes once it had grown, from when the relays
|
|
18275
|
+
* answered again.
|
|
18276
|
+
*/
|
|
18277
|
+
dialSeenNow() {
|
|
18278
|
+
if (!this.dialUnseen) return;
|
|
18279
|
+
this.dialUnseen = false;
|
|
18280
|
+
if (this.channel || this.dialing || this.dataLink.state !== "idle") return;
|
|
18281
|
+
traceLink(this.myPubKeyZ32, "dial-unseen", { failures: this.autoConnectFailures });
|
|
18282
|
+
this.lastAutoConnectAt = 0;
|
|
18283
|
+
this.maybeAutoConnect(this.presence);
|
|
18284
|
+
}
|
|
17145
18285
|
/** 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. */
|
|
17146
18286
|
dialWait() {
|
|
17147
18287
|
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);
|
|
@@ -17387,6 +18527,8 @@ var GhostLink = class {
|
|
|
17387
18527
|
if (this.options.pinSupport) offered.push(PIN_CAPABILITY);
|
|
17388
18528
|
if (this.options.statusCardSupport) offered.push(STATUS_CARD_CAPABILITY);
|
|
17389
18529
|
if (this.options.buttonsSupport) offered.push(BUTTONS_CAPABILITY);
|
|
18530
|
+
if (this.options.upgradeSupport) offered.push(UPGRADE_CAPABILITY);
|
|
18531
|
+
if (this.options.appsSupport) offered.push(APPS_CAPABILITY);
|
|
17390
18532
|
if (this.options.deviceCapabilities) offered.push(...this.options.deviceCapabilities);
|
|
17391
18533
|
return offered;
|
|
17392
18534
|
}
|
|
@@ -17531,12 +18673,90 @@ var GhostLink = class {
|
|
|
17531
18673
|
*/
|
|
17532
18674
|
setTyping(typing, activity) {
|
|
17533
18675
|
if (!this.options.params.profile) return;
|
|
17534
|
-
|
|
18676
|
+
if (typing && !this.supportsTyping) {
|
|
18677
|
+
this.typingSender.want(activity);
|
|
18678
|
+
return;
|
|
18679
|
+
}
|
|
18680
|
+
const frame = typing ? this.typingSender.typing(activity) : this.typingSender.stopped();
|
|
17535
18681
|
if (!frame || !this.channel || !this.supportsTyping) return;
|
|
17536
18682
|
try {
|
|
17537
18683
|
this.channel.send(JSON.stringify(frame));
|
|
17538
18684
|
} catch {}
|
|
17539
18685
|
}
|
|
18686
|
+
resumeTyping() {
|
|
18687
|
+
const frame = this.typingSender.resume();
|
|
18688
|
+
if (!frame || !this.channel) return;
|
|
18689
|
+
try {
|
|
18690
|
+
this.channel.send(JSON.stringify(frame));
|
|
18691
|
+
} catch {}
|
|
18692
|
+
}
|
|
18693
|
+
/** Both sides offer `apps/1` on the open session: mini-apps can talk (WISP 1200 § In a chat). */
|
|
18694
|
+
get supportsApps() {
|
|
18695
|
+
return !!this.options.params.profile && this.isDataLinkOpen && this.sessionCapabilities.agreed("apps/1");
|
|
18696
|
+
}
|
|
18697
|
+
/** The apps the contact has open on this session (chat app id to version). */
|
|
18698
|
+
get peerApps() {
|
|
18699
|
+
return this.apps.peerOpen;
|
|
18700
|
+
}
|
|
18701
|
+
/**
|
|
18702
|
+
* This side opened an app in this chat (or updated it): `open` goes now when both sides offer `apps/1` on the live
|
|
18703
|
+
* session, and again on every session that comes back while the app stays open. Never on the DHT.
|
|
18704
|
+
*/
|
|
18705
|
+
openApp(app, version) {
|
|
18706
|
+
const frame = JSON.stringify(appOpenFrame(app, version));
|
|
18707
|
+
this.apps.open.set(app, version);
|
|
18708
|
+
if (this.supportsApps && this.channel) try {
|
|
18709
|
+
this.channel.send(frame);
|
|
18710
|
+
} catch {}
|
|
18711
|
+
}
|
|
18712
|
+
/** This side closed an app in this chat: `close` goes now when it can; nothing is said later. */
|
|
18713
|
+
closeApp(app) {
|
|
18714
|
+
const frame = JSON.stringify(appCloseFrame(app));
|
|
18715
|
+
if (!this.apps.open.delete(app)) return;
|
|
18716
|
+
if (this.supportsApps && this.channel) try {
|
|
18717
|
+
this.channel.send(frame);
|
|
18718
|
+
} catch {}
|
|
18719
|
+
}
|
|
18720
|
+
/**
|
|
18721
|
+
* One data frame of an app open on both sides, on the live session only. An error when it did not go: nothing is
|
|
18722
|
+
* kept to send later.
|
|
18723
|
+
*/
|
|
18724
|
+
sendAppData(app, data) {
|
|
18725
|
+
if (!this.apps.open.has(app)) return "not-open";
|
|
18726
|
+
if (appDataBytes(data) > 32768) return "too-large";
|
|
18727
|
+
if (!this.channel || !this.isDataLinkOpen) return "offline";
|
|
18728
|
+
if (!this.supportsApps) return "peer-closed";
|
|
18729
|
+
const refused = this.apps.canSend(app, data);
|
|
18730
|
+
if (refused) return refused;
|
|
18731
|
+
try {
|
|
18732
|
+
this.channel.send(JSON.stringify(appDataFrame(app, data)));
|
|
18733
|
+
return null;
|
|
18734
|
+
} catch {
|
|
18735
|
+
return "offline";
|
|
18736
|
+
}
|
|
18737
|
+
}
|
|
18738
|
+
/** What the contact had open is gone (`apps/1` is no longer agreed, or this link stopped): each app hears it now. */
|
|
18739
|
+
appsEnded() {
|
|
18740
|
+
if (this.appsGrace) {
|
|
18741
|
+
clearTimeout(this.appsGrace);
|
|
18742
|
+
this.appsGrace = null;
|
|
18743
|
+
}
|
|
18744
|
+
for (const event of this.apps.sessionEnded()) this.options.events?.onAppFrame?.(event);
|
|
18745
|
+
}
|
|
18746
|
+
appsGrace = null;
|
|
18747
|
+
/**
|
|
18748
|
+
* The session ended or a new one took its place (a transport switch, a resume, a re-dial): what the contact had open
|
|
18749
|
+
* is held for `APP_RESUME_GRACE_MS` (WISP 1200 § In a chat). The next session's `open` takes each back, and the app
|
|
18750
|
+
* hears only that open, not a close first; one not said again by then is closed with `offline`.
|
|
18751
|
+
*/
|
|
18752
|
+
appsSuspended() {
|
|
18753
|
+
this.apps.sessionSuspended();
|
|
18754
|
+
if (!this.apps.holding || this.appsGrace) return;
|
|
18755
|
+
this.appsGrace = setTimeout(() => {
|
|
18756
|
+
this.appsGrace = null;
|
|
18757
|
+
for (const event of this.apps.heldExpired()) this.options.events?.onAppFrame?.(event);
|
|
18758
|
+
}, this.options.appResumeGraceMs ?? 1e4);
|
|
18759
|
+
}
|
|
17540
18760
|
/** Whether files/3 was agreed on the session open now; kept to say when that changes. */
|
|
17541
18761
|
filesOpen = false;
|
|
17542
18762
|
emitFilesSession(again = false) {
|
|
@@ -17609,14 +18829,24 @@ var GhostLink = class {
|
|
|
17609
18829
|
this.channel.send(JSON.stringify(pending));
|
|
17610
18830
|
} catch {}
|
|
17611
18831
|
}
|
|
17612
|
-
if (changed.includes("typing/1")
|
|
17613
|
-
this.
|
|
17614
|
-
|
|
18832
|
+
if (changed.includes("typing/1")) {
|
|
18833
|
+
if (!this.supportsTyping) {
|
|
18834
|
+
this.typingSender.reset();
|
|
18835
|
+
this.typingReceiver.clear();
|
|
18836
|
+
} else this.resumeTyping();
|
|
17615
18837
|
}
|
|
17616
18838
|
if (changed.includes("react/1")) this.options.events?.onReactionsSupport?.(this.supportsReactions);
|
|
17617
18839
|
if (changed.includes("edit/1")) this.options.events?.onEditSupport?.(this.supportsEdits);
|
|
17618
18840
|
if (changed.includes("pin/1")) this.options.events?.onPinSupport?.(this.supportsPins);
|
|
17619
18841
|
if (changed.includes("wake/1")) this.options.events?.onWakeSupport?.(this.supportsWake);
|
|
18842
|
+
if (changed.includes("upgrade/1")) this.switcher.reconsider();
|
|
18843
|
+
if (changed.includes("apps/1")) {
|
|
18844
|
+
if (this.supportsApps && this.channel) for (const [app, version] of this.apps.open) try {
|
|
18845
|
+
this.channel.send(JSON.stringify(appOpenFrame(app, version)));
|
|
18846
|
+
} catch {}
|
|
18847
|
+
if (!this.supportsApps) this.appsEnded();
|
|
18848
|
+
this.options.events?.onAppsSupport?.(this.supportsApps);
|
|
18849
|
+
}
|
|
17620
18850
|
const devices = this.options.deviceCapabilities;
|
|
17621
18851
|
if (devices && changed.some((capability) => devices.includes(capability))) this.options.events?.onDeviceCapabilities?.(devices.filter((capability) => this.supportsDevice(capability)));
|
|
17622
18852
|
this.emitPairingState();
|
|
@@ -17715,13 +18945,18 @@ var GhostLink = class {
|
|
|
17715
18945
|
};
|
|
17716
18946
|
if (transport === "webrtc/1") {
|
|
17717
18947
|
this.rtcCandidateWaiter = done;
|
|
17718
|
-
this.
|
|
18948
|
+
this.rtcRetired().then(() => {
|
|
18949
|
+
if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) return;
|
|
18950
|
+
this.switcher.dialing(plan);
|
|
18951
|
+
return this.dataLink.connect();
|
|
18952
|
+
}).catch(done.reject);
|
|
17719
18953
|
} else {
|
|
17720
18954
|
const endpoint = this.endpoints.get(transport), descriptor = plan.remote.descriptors[transport];
|
|
17721
18955
|
if (!endpoint || !descriptor) {
|
|
17722
18956
|
done.reject(/* @__PURE__ */ new Error("Peer native address unavailable"));
|
|
17723
18957
|
return;
|
|
17724
18958
|
}
|
|
18959
|
+
this.switcher.dialing(plan);
|
|
17725
18960
|
endpoint.connect(descriptor).then(({ channel, binding }) => {
|
|
17726
18961
|
if (!active || epoch !== this.candidateEpoch || this.switcher.pending !== plan) {
|
|
17727
18962
|
channel.close();
|
|
@@ -17969,7 +19204,7 @@ var GhostLink = class {
|
|
|
17969
19204
|
oldPaired?.stop();
|
|
17970
19205
|
if (oldChannel) this.retire(oldChannel, () => {
|
|
17971
19206
|
if (!oldBinding && this.activeBinding) this.dataLink.close();
|
|
17972
|
-
});
|
|
19207
|
+
}, !oldBinding);
|
|
17973
19208
|
this.session.setDataLinkOpen(true);
|
|
17974
19209
|
if (migration.plan) switched = {
|
|
17975
19210
|
from,
|
|
@@ -18004,6 +19239,7 @@ var GhostLink = class {
|
|
|
18004
19239
|
this.sessionCapabilities.reset();
|
|
18005
19240
|
this.typingSender.reset();
|
|
18006
19241
|
this.typingReceiver.clear();
|
|
19242
|
+
this.appsSuspended();
|
|
18007
19243
|
this.pairedHttp?.close();
|
|
18008
19244
|
this.pairedHttp = new PairedHttp(channel, this.options.getHostedHttpService, this.options.localFetch);
|
|
18009
19245
|
this.emitPairingState();
|
|
@@ -18184,6 +19420,12 @@ var GhostLink = class {
|
|
|
18184
19420
|
if (this.supportsTyping) this.typingReceiver.receive(frame);
|
|
18185
19421
|
return;
|
|
18186
19422
|
}
|
|
19423
|
+
if (frame?.t === "paired-app") {
|
|
19424
|
+
if (!this.supportsApps) return;
|
|
19425
|
+
const event = this.apps.receive(frame);
|
|
19426
|
+
if (event) this.options.events?.onAppFrame?.(event);
|
|
19427
|
+
return;
|
|
19428
|
+
}
|
|
18187
19429
|
if (frame?.t === "paired-reaction") {
|
|
18188
19430
|
if (!this.supportsReactions || !this.reactionsReceived.take()) return;
|
|
18189
19431
|
const reaction = parseReactionFrame(frame);
|
|
@@ -18388,6 +19630,7 @@ var GhostLink = class {
|
|
|
18388
19630
|
this.pongWait = null;
|
|
18389
19631
|
if (this.channel !== channel || this.stopped) return;
|
|
18390
19632
|
traceLink(this.myPubKeyZ32, "pong-late", { unanswered: this.unansweredPings });
|
|
19633
|
+
this.pongLate = true;
|
|
18391
19634
|
this.session.pollNow();
|
|
18392
19635
|
}, PONG_WAIT_MS);
|
|
18393
19636
|
};
|
|
@@ -18424,6 +19667,24 @@ var GhostLink = class {
|
|
|
18424
19667
|
heardFromPeer() {
|
|
18425
19668
|
if (this.pongWait) clearTimeout(this.pongWait);
|
|
18426
19669
|
this.pongWait = null;
|
|
19670
|
+
this.pongLate = false;
|
|
19671
|
+
}
|
|
19672
|
+
/**
|
|
19673
|
+
* The session runs on a native transport, its last ping went unanswered, and the contact's newer packet names another
|
|
19674
|
+
* endpoint for that transport: the contact's app started again (a web page reloading, as a device that takes the
|
|
19675
|
+
* profile does twice), and the endpoint this session runs on went with it, with nothing said. A browser's relay-only
|
|
19676
|
+
* Iroh closes nothing as a page goes, and QUIC notices only at its idle timeout (30 s): until then this side held a
|
|
19677
|
+
* session nothing answered, and the contact, whose key does not dial, waited for it to dial (a Use here said hello
|
|
19678
|
+
* into that session and gave up "unreachable", Omarchy 2026-10-05). The session is let go, and the new endpoint
|
|
19679
|
+
* dialled now, as when the session closes.
|
|
19680
|
+
*/
|
|
19681
|
+
restartedRedial(channel, transport) {
|
|
19682
|
+
if (!this.pongLate || this.channel !== channel || this.paired?.state.status !== "ready") return false;
|
|
19683
|
+
traceLink(this.myPubKeyZ32, "restarted-redial", { transport });
|
|
19684
|
+
this.stopLiveness();
|
|
19685
|
+
this.disconnect();
|
|
19686
|
+
this.peerLost("restarted");
|
|
19687
|
+
return true;
|
|
18427
19688
|
}
|
|
18428
19689
|
dropDeadSession(channel) {
|
|
18429
19690
|
this.stopLiveness();
|
|
@@ -18438,6 +19699,10 @@ var GhostLink = class {
|
|
|
18438
19699
|
peerLost(why) {
|
|
18439
19700
|
if (this.stopped || this.leaving || this.yielding) return;
|
|
18440
19701
|
traceLink(this.myPubKeyZ32, "peer-lost", { why });
|
|
19702
|
+
if (Date.now() < this.wokeUntil) {
|
|
19703
|
+
this.wokeUntil = 0;
|
|
19704
|
+
this.resumeAfterAbsence();
|
|
19705
|
+
}
|
|
18441
19706
|
this.lostUntil = Date.now() + WATCH_PEER_MS;
|
|
18442
19707
|
this.autoConnectFailures = 0;
|
|
18443
19708
|
this.lastAutoConnectAt = 0;
|
|
@@ -18503,18 +19768,78 @@ var GhostLink = class {
|
|
|
18503
19768
|
}
|
|
18504
19769
|
/**
|
|
18505
19770
|
* Someone is here again (the chat was opened, the window came back): look now, and try at once rather
|
|
18506
|
-
* than after the wait between failed attempts.
|
|
19771
|
+
* than after the wait between failed attempts. `network`: the device's network just came back.
|
|
18507
19772
|
*/
|
|
18508
|
-
wake() {
|
|
19773
|
+
wake(params = {}) {
|
|
19774
|
+
if (params.network) {
|
|
19775
|
+
this.dht?.networkBack();
|
|
19776
|
+
this.networkBack();
|
|
19777
|
+
}
|
|
18509
19778
|
this.autoConnectFailures = 0;
|
|
18510
19779
|
this.lastAutoConnectAt = 0;
|
|
18511
19780
|
this.session.pollNow();
|
|
18512
|
-
if (
|
|
19781
|
+
if (this.channel) {
|
|
19782
|
+
this.wokeUntil = Date.now() + WAKE_LOST_MS;
|
|
19783
|
+
this.probeAfterWake(this.channel);
|
|
19784
|
+
} else {
|
|
19785
|
+
this.resumeAfterAbsence();
|
|
18513
19786
|
if (!this.session.publishedRecently(RECENTLY_PUBLISHED_MS)) this.session.refreshAdvertisement();
|
|
18514
19787
|
this.maybeAutoConnect(this.presence);
|
|
18515
19788
|
}
|
|
18516
19789
|
}
|
|
19790
|
+
/**
|
|
19791
|
+
* Back in front with no session, after one was live: the contact likely did not notice this app go (a phone puts a
|
|
19792
|
+
* backgrounded app to sleep without a word), and reads this chat at its slow pace (30 s on the relays in the
|
|
19793
|
+
* background), so an offer or a fresh packet of this side's waited about that long for it. The chat resumes as after a
|
|
19794
|
+
* restart (WISP 100, "Back after a restart"): this side dials whatever its key, on the transport it was live on, and
|
|
19795
|
+
* knocks on the contact's other transports as its offer goes out, which reaches the contact at once.
|
|
19796
|
+
*/
|
|
19797
|
+
resumeAfterAbsence() {
|
|
19798
|
+
const transport = this.lastLiveOn;
|
|
19799
|
+
if (!transport || this.resuming || this.departed || !this.options.pairing?.credentials.peerKey || !this.options.params.profile || this.deliveryMode === "dht") return;
|
|
19800
|
+
this.lastLiveOn = void 0;
|
|
19801
|
+
this.resuming = transport;
|
|
19802
|
+
this.resumingWoke = true;
|
|
19803
|
+
this.knockedEarly = -1;
|
|
19804
|
+
traceLink(this.myPubKeyZ32, "wake-resume", { transport });
|
|
19805
|
+
if (transport !== "webrtc/1") {
|
|
19806
|
+
this.resumeWaitUntil = Date.now() + RESUME_WAIT_MS;
|
|
19807
|
+
return;
|
|
19808
|
+
}
|
|
19809
|
+
if (this.dataLink.state === "answering" && this.answeringEpoch === this.connectionEpoch) {
|
|
19810
|
+
const epoch = this.connectionEpoch;
|
|
19811
|
+
setTimeout(() => this.maybeKnockAnswering(epoch), 0);
|
|
19812
|
+
return;
|
|
19813
|
+
}
|
|
19814
|
+
const offered = this.offered;
|
|
19815
|
+
if (!offered || offered.resume || offered.epoch !== this.connectionEpoch || !this.offerOut && !this.dialing) return;
|
|
19816
|
+
this.resuming = void 0;
|
|
19817
|
+
this.resumingWoke = false;
|
|
19818
|
+
offered.resume = true;
|
|
19819
|
+
offered.woke = true;
|
|
19820
|
+
if (!this.dialing) setTimeout(() => this.maybeKnockEarly(offered.epoch), 0);
|
|
19821
|
+
}
|
|
19822
|
+
/** A ping on the session right after a wake, with `PONG_WAIT_MS` for its answer: none, and the session is dead. */
|
|
19823
|
+
probeAfterWake(channel) {
|
|
19824
|
+
if (!this.peerAnswersPings || this.paired?.state.status !== "ready") return;
|
|
19825
|
+
this.unansweredPings++;
|
|
19826
|
+
try {
|
|
19827
|
+
this.pingSentAt = Date.now();
|
|
19828
|
+
channel.send(JSON.stringify({ t: "paired-ping" }));
|
|
19829
|
+
} catch {
|
|
19830
|
+
this.dropDeadSession(channel);
|
|
19831
|
+
return;
|
|
19832
|
+
}
|
|
19833
|
+
if (this.pongWait) clearTimeout(this.pongWait);
|
|
19834
|
+
this.pongWait = setTimeout(() => {
|
|
19835
|
+
this.pongWait = null;
|
|
19836
|
+
if (this.channel !== channel || this.stopped) return;
|
|
19837
|
+
traceLink(this.myPubKeyZ32, "wake-unanswered", {});
|
|
19838
|
+
this.dropDeadSession(channel);
|
|
19839
|
+
}, PONG_WAIT_MS);
|
|
19840
|
+
}
|
|
18517
19841
|
detach() {
|
|
19842
|
+
if (this.channel && this.paired?.state.status === "ready") this.lastLiveOn = this.activeBinding?.transport ?? "webrtc/1";
|
|
18518
19843
|
this.stopLiveness();
|
|
18519
19844
|
const wasNative = !!this.activeBinding;
|
|
18520
19845
|
this.applicationOpen = false;
|
|
@@ -18540,6 +19865,7 @@ var GhostLink = class {
|
|
|
18540
19865
|
this.sessionCapabilities.reset();
|
|
18541
19866
|
this.typingSender.reset();
|
|
18542
19867
|
this.typingReceiver.clear();
|
|
19868
|
+
this.appsSuspended();
|
|
18543
19869
|
this.emitFilesSession();
|
|
18544
19870
|
if (this.peerGroupVersions) {
|
|
18545
19871
|
this.peerGroupVersions = null;
|
|
@@ -20403,7 +21729,7 @@ var DID_GHOSTLY_SERVICE = "GhostlyIdentityProof";
|
|
|
20403
21729
|
var METHOD_NAME = /^[a-z0-9]+$/;
|
|
20404
21730
|
var Z32_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
|
|
20405
21731
|
var B64URL = /^[A-Za-z0-9_-]*$/;
|
|
20406
|
-
var SEGMENT = /^[A-Za-z0-9._~-]{1,64}$/;
|
|
21732
|
+
var SEGMENT$1 = /^[A-Za-z0-9._~-]{1,64}$/;
|
|
20407
21733
|
var HEX64 = /^[a-f0-9]{64}$/;
|
|
20408
21734
|
var METHOD_LIST = "did:key, did:jwk, did:dht and did:web";
|
|
20409
21735
|
/** base64url without padding, strictly: decoding and encoding again gives the same text. */
|
|
@@ -20414,7 +21740,7 @@ function strictBase64Url(value) {
|
|
|
20414
21740
|
if (toBase64Url(bytes) !== value) throw new DidError("Not canonical base64url");
|
|
20415
21741
|
return bytes;
|
|
20416
21742
|
}
|
|
20417
|
-
var isObject$
|
|
21743
|
+
var isObject$3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
20418
21744
|
/**
|
|
20419
21745
|
* The DID a person typed, checked and in canonical form, or a message they can act on. Methods other than
|
|
20420
21746
|
* the four Ghostly checks are refused with "not supported yet"; a DID URL (a `#fragment`, `?query` or
|
|
@@ -20488,7 +21814,7 @@ function parseWeb(id) {
|
|
|
20488
21814
|
if (!(port >= 1 && port <= 65535) || String(port) !== portMatch[2]) throw new DidError("did:web: that port is not valid");
|
|
20489
21815
|
}
|
|
20490
21816
|
if (path.length > 8) throw new DidError("did:web: at most 8 path segments");
|
|
20491
|
-
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");
|
|
21817
|
+
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");
|
|
20492
21818
|
return {
|
|
20493
21819
|
id: [`${host}${port ? `%3A${port}` : ""}`, ...path].join(":"),
|
|
20494
21820
|
host,
|
|
@@ -20532,7 +21858,7 @@ function didWebFileNames(text, did, statement) {
|
|
|
20532
21858
|
} catch {
|
|
20533
21859
|
return false;
|
|
20534
21860
|
}
|
|
20535
|
-
return isObject$
|
|
21861
|
+
return isObject$3(body) && body.ghostly === 1 && body.did === did && body.statement === statement;
|
|
20536
21862
|
}
|
|
20537
21863
|
/** The service entry a did.json can carry instead of the file. */
|
|
20538
21864
|
function didGhostlyServiceEntry(did, key, proofId) {
|
|
@@ -20614,7 +21940,7 @@ var JWK_CURVES = {
|
|
|
20614
21940
|
};
|
|
20615
21941
|
/** A public JWK. Other curves are `undefined`; a malformed one throws. */
|
|
20616
21942
|
function jwkKey(jwk) {
|
|
20617
|
-
if (!isObject$
|
|
21943
|
+
if (!isObject$3(jwk)) throw new DidError("That JWK is not an object");
|
|
20618
21944
|
const spec = typeof jwk.crv === "string" ? JWK_CURVES[jwk.crv] : void 0;
|
|
20619
21945
|
if (!spec || jwk.kty !== spec.kty) return void 0;
|
|
20620
21946
|
const coordinate = (v) => {
|
|
@@ -20659,7 +21985,7 @@ function staticKey(did) {
|
|
|
20659
21985
|
} catch {
|
|
20660
21986
|
throw new DidError("That did:jwk does not hold a JSON Web Key");
|
|
20661
21987
|
}
|
|
20662
|
-
if (!isObject$
|
|
21988
|
+
if (!isObject$3(jwk)) throw new DidError("That did:jwk does not hold a JSON Web Key");
|
|
20663
21989
|
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");
|
|
20664
21990
|
const key = jwkKey(jwk);
|
|
20665
21991
|
if (!key) throw new DidError("That did:jwk is not an Ed25519, secp256k1 or P-256 key");
|
|
@@ -20722,13 +22048,13 @@ var list = (value, name) => {
|
|
|
20722
22048
|
* `GhostlyIdentityProof` services are kept. Keys Ghostly cannot use are counted in `skipped`.
|
|
20723
22049
|
*/
|
|
20724
22050
|
function parseDidDocument(did, raw) {
|
|
20725
|
-
if (!isObject$
|
|
22051
|
+
if (!isObject$3(raw)) throw new DidError("The DID document is not a JSON object");
|
|
20726
22052
|
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");
|
|
20727
22053
|
const keys = /* @__PURE__ */ new Map();
|
|
20728
22054
|
const unusable = /* @__PURE__ */ new Set();
|
|
20729
22055
|
let skipped = 0;
|
|
20730
22056
|
const add = (vm) => {
|
|
20731
|
-
if (!isObject$
|
|
22057
|
+
if (!isObject$3(vm)) {
|
|
20732
22058
|
skipped++;
|
|
20733
22059
|
return;
|
|
20734
22060
|
}
|
|
@@ -20759,10 +22085,10 @@ function parseDidDocument(did, raw) {
|
|
|
20759
22085
|
}
|
|
20760
22086
|
const services = [];
|
|
20761
22087
|
for (const s of list(raw.service, "service")) {
|
|
20762
|
-
if (!isObject$
|
|
22088
|
+
if (!isObject$3(s)) continue;
|
|
20763
22089
|
const types = Array.isArray(s.type) ? s.type : [s.type];
|
|
20764
22090
|
const endpoint = s.serviceEndpoint;
|
|
20765
|
-
if (!types.includes("GhostlyIdentityProof") || !isObject$
|
|
22091
|
+
if (!types.includes("GhostlyIdentityProof") || !isObject$3(endpoint)) continue;
|
|
20766
22092
|
if (typeof endpoint.key === "string" && Z32_KEY.test(endpoint.key) && typeof endpoint.proof === "string" && HEX64.test(endpoint.proof)) services.push({
|
|
20767
22093
|
id: typeof s.id === "string" ? s.id.slice(0, 200) : "",
|
|
20768
22094
|
key: endpoint.key,
|
|
@@ -20805,7 +22131,7 @@ function parseDidJws(input) {
|
|
|
20805
22131
|
} catch {
|
|
20806
22132
|
throw new DidError("The JWS header is not base64url JSON");
|
|
20807
22133
|
}
|
|
20808
|
-
if (!isObject$
|
|
22134
|
+
if (!isObject$3(header)) throw new DidError("The JWS header is not a JSON object");
|
|
20809
22135
|
const alg = header.alg;
|
|
20810
22136
|
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`);
|
|
20811
22137
|
if (header.kid !== void 0 && (typeof header.kid !== "string" || header.kid.length > 640)) throw new DidError("The JWS kid is not a key id");
|
|
@@ -21617,7 +22943,7 @@ function compactSize(n) {
|
|
|
21617
22943
|
throw new Error("Too large");
|
|
21618
22944
|
}
|
|
21619
22945
|
var varBytes = (b) => concatBytes$1(compactSize(b.length), b);
|
|
21620
|
-
var u32$
|
|
22946
|
+
var u32$2 = (n) => {
|
|
21621
22947
|
const b = /* @__PURE__ */ new Uint8Array(4);
|
|
21622
22948
|
new DataView(b.buffer).setUint32(0, n >>> 0, true);
|
|
21623
22949
|
return b;
|
|
@@ -21628,14 +22954,14 @@ var u64 = (n) => {
|
|
|
21628
22954
|
return b;
|
|
21629
22955
|
};
|
|
21630
22956
|
function encodeTx(tx, withWitness = tx.inputs.some((i) => i.witness.length > 0)) {
|
|
21631
|
-
const parts = [u32$
|
|
22957
|
+
const parts = [u32$2(tx.version)];
|
|
21632
22958
|
if (withWitness) parts.push(Uint8Array.of(0, 1));
|
|
21633
22959
|
parts.push(compactSize(tx.inputs.length));
|
|
21634
|
-
for (const i of tx.inputs) parts.push(i.txid, u32$
|
|
22960
|
+
for (const i of tx.inputs) parts.push(i.txid, u32$2(i.vout), varBytes(i.scriptSig), u32$2(i.sequence));
|
|
21635
22961
|
parts.push(compactSize(tx.outputs.length));
|
|
21636
22962
|
for (const o of tx.outputs) parts.push(u64(o.value), varBytes(o.script));
|
|
21637
22963
|
if (withWitness) for (const i of tx.inputs) parts.push(encodeWitness(i.witness));
|
|
21638
|
-
parts.push(u32$
|
|
22964
|
+
parts.push(u32$2(tx.lockTime));
|
|
21639
22965
|
return concatBytes$1(...parts);
|
|
21640
22966
|
}
|
|
21641
22967
|
var encodeWitness = (stack) => concatBytes$1(compactSize(stack.length), ...stack.map(varBytes));
|
|
@@ -21801,12 +23127,12 @@ function decodeBase64(text) {
|
|
|
21801
23127
|
/** BIP 143, for witness v0. `scriptCode` is the P2PKH script of the key. */
|
|
21802
23128
|
function sighashV0(tx, scriptCode, hashType) {
|
|
21803
23129
|
const input = tx.inputs[0];
|
|
21804
|
-
return sha256d(u32$
|
|
23130
|
+
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));
|
|
21805
23131
|
}
|
|
21806
23132
|
/** BIP 341 key path, SIGHASH_DEFAULT or SIGHASH_ALL, no annex. */
|
|
21807
23133
|
function sighashV1(tx, scriptPubKey, hashType) {
|
|
21808
23134
|
const input = tx.inputs[0];
|
|
21809
|
-
return schnorr.utils.taggedHash("TapSighash", concatBytes$1(Uint8Array.of(0, hashType), u32$
|
|
23135
|
+
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)));
|
|
21810
23136
|
}
|
|
21811
23137
|
/** The original sighash, for P2PKH: the input's script replaced by the spent output's, the rest as is. */
|
|
21812
23138
|
function sighashLegacy(tx, scriptPubKey, hashType) {
|
|
@@ -21817,7 +23143,7 @@ function sighashLegacy(tx, scriptPubKey, hashType) {
|
|
|
21817
23143
|
witness: [],
|
|
21818
23144
|
scriptSig: n === 0 ? scriptPubKey : /* @__PURE__ */ new Uint8Array(0)
|
|
21819
23145
|
}))
|
|
21820
|
-
}, false), u32$
|
|
23146
|
+
}, false), u32$2(hashType));
|
|
21821
23147
|
}
|
|
21822
23148
|
/** A check that throws on malformed input (a point off the curve…) simply does not hold. */
|
|
21823
23149
|
function holds(check) {
|
|
@@ -22002,11 +23328,11 @@ function verifyBip322(address, message, signature) {
|
|
|
22002
23328
|
*/
|
|
22003
23329
|
var MAX_MESSAGE = 16384;
|
|
22004
23330
|
var MAX_SIGNATURE = 16384;
|
|
22005
|
-
var MAGIC = utf8Encode("Bitcoin Signed Message:\n");
|
|
23331
|
+
var MAGIC$1 = utf8Encode("Bitcoin Signed Message:\n");
|
|
22006
23332
|
/** The digest `signmessage` signs: double SHA-256 of the length-prefixed magic and message. */
|
|
22007
23333
|
function legacyMessageHash(message) {
|
|
22008
23334
|
const bytes = utf8Encode(message);
|
|
22009
|
-
return sha256d(compactSize(MAGIC.length), MAGIC, compactSize(bytes.length), bytes);
|
|
23335
|
+
return sha256d(compactSize(MAGIC$1.length), MAGIC$1, compactSize(bytes.length), bytes);
|
|
22010
23336
|
}
|
|
22011
23337
|
/** Header bytes 27-34 are P2PKH (uncompressed 27-30, compressed 31-34); 35-42 are BIP 137 SegWit headers. */
|
|
22012
23338
|
var isLegacySignature = (bytes) => bytes.length === 65 && bytes[0] >= 27 && bytes[0] <= 42;
|
|
@@ -22133,8 +23459,8 @@ var Reader = class {
|
|
|
22133
23459
|
if (!this.done) fail("Trailing bytes in SSH data");
|
|
22134
23460
|
}
|
|
22135
23461
|
};
|
|
22136
|
-
var u32 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
|
|
22137
|
-
var sshString = (b) => concatBytes$1(u32(b.length), b);
|
|
23462
|
+
var u32$1 = (n) => Uint8Array.of(n >>> 24, n >>> 16 & 255, n >>> 8 & 255, n & 255);
|
|
23463
|
+
var sshString = (b) => concatBytes$1(u32$1(b.length), b);
|
|
22138
23464
|
function strictBase64(text, max) {
|
|
22139
23465
|
if (text.length > max || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) fail("Invalid base64");
|
|
22140
23466
|
const bytes = fromBase64(text);
|
|
@@ -22300,7 +23626,7 @@ async function verifySshSignature(armored, message, options = {}) {
|
|
|
22300
23626
|
userVerified: !!(flags & SK_USER_VERIFIED),
|
|
22301
23627
|
counter
|
|
22302
23628
|
};
|
|
22303
|
-
data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32(counter), sha256(signed));
|
|
23629
|
+
data = concatBytes$1(sha256(utf8Encode(key.application)), Uint8Array.of(flags), u32$1(counter), sha256(signed));
|
|
22304
23630
|
}
|
|
22305
23631
|
r.end();
|
|
22306
23632
|
const verifyWith = async () => {
|
|
@@ -24816,7 +26142,7 @@ var pairAad = (f, to) => JSON.stringify([
|
|
|
24816
26142
|
f.ts,
|
|
24817
26143
|
to
|
|
24818
26144
|
]);
|
|
24819
|
-
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
26145
|
+
var isObject$2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
24820
26146
|
var messageSigned = (f) => utf8Encode(JSON.stringify([
|
|
24821
26147
|
"ghostly-group/2 msg",
|
|
24822
26148
|
f.g,
|
|
@@ -25746,7 +27072,7 @@ var CommunitySession = class {
|
|
|
25746
27072
|
sendApp(frame, nick, now = Date.now()) {
|
|
25747
27073
|
return this.serialize(async () => {
|
|
25748
27074
|
if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
|
|
25749
|
-
if (!isObject(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
27075
|
+
if (!isObject$2(frame) || utf8Encode(JSON.stringify(frame)).length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
25750
27076
|
const sent = await this.sendPayload(() => ({ x: frame }), nick, now);
|
|
25751
27077
|
if ("error" in sent) return sent;
|
|
25752
27078
|
await this.say(sent.frame);
|
|
@@ -25795,7 +27121,7 @@ var CommunitySession = class {
|
|
|
25795
27121
|
return this.serialize(async () => {
|
|
25796
27122
|
if (!this.isMember) return { error: this.state.statusReason ?? "You are not in this group" };
|
|
25797
27123
|
if (to === this.myKey || !rosterHas(this.roster, to)) return { error: "Not a member of this group" };
|
|
25798
|
-
if (!isObject(payload)) return { error: "Nothing to send" };
|
|
27124
|
+
if (!isObject$2(payload)) return { error: "Nothing to send" };
|
|
25799
27125
|
const plain = utf8Encode(JSON.stringify(payload));
|
|
25800
27126
|
if (plain.length > COMMUNITY_LIMITS.appBytes) return { error: "Too large for the group" };
|
|
25801
27127
|
const sent = await this.sendPayload((header) => ({ p: {
|
|
@@ -25908,7 +27234,7 @@ var CommunitySession = class {
|
|
|
25908
27234
|
try {
|
|
25909
27235
|
parsed = plain === null ? null : JSON.parse(plain);
|
|
25910
27236
|
} catch {}
|
|
25911
|
-
if (!isObject(parsed) || isObject(parsed.p)) continue;
|
|
27237
|
+
if (!isObject$2(parsed) || isObject$2(parsed.p)) continue;
|
|
25912
27238
|
const o = readOriginal(parsed.o, f) ?? {
|
|
25913
27239
|
e: f.e,
|
|
25914
27240
|
h: f.h,
|
|
@@ -26056,7 +27382,7 @@ var CommunitySession = class {
|
|
|
26056
27382
|
} catch {
|
|
26057
27383
|
return false;
|
|
26058
27384
|
}
|
|
26059
|
-
if (!isObject(parsed)) return false;
|
|
27385
|
+
if (!isObject$2(parsed)) return false;
|
|
26060
27386
|
const original = parsed.o === void 0 ? null : readOriginal(parsed.o, raw);
|
|
26061
27387
|
if (parsed.o !== void 0 && !original) return false;
|
|
26062
27388
|
const first = original ? {
|
|
@@ -26074,8 +27400,8 @@ var CommunitySession = class {
|
|
|
26074
27400
|
const text = typeof parsed.text === "string" ? parsed.text.slice(0, COMMUNITY_LIMITS.textBytes) : void 0;
|
|
26075
27401
|
if ([
|
|
26076
27402
|
text !== void 0,
|
|
26077
|
-
isObject(parsed.x),
|
|
26078
|
-
isObject(parsed.p)
|
|
27403
|
+
isObject$2(parsed.x),
|
|
27404
|
+
isObject$2(parsed.p)
|
|
26079
27405
|
].filter(Boolean).length !== 1) return false;
|
|
26080
27406
|
const nick = typeof parsed.nick === "string" ? sanitizeNick(parsed.nick) : void 0;
|
|
26081
27407
|
if (nick && this.state.nicks[raw.s] !== nick) {
|
|
@@ -26100,7 +27426,7 @@ var CommunitySession = class {
|
|
|
26100
27426
|
...forwarded ? { forwarded } : {},
|
|
26101
27427
|
...card ? { card } : {}
|
|
26102
27428
|
});
|
|
26103
|
-
} else if (isObject(parsed.x)) await this.hooks.app?.({
|
|
27429
|
+
} else if (isObject$2(parsed.x)) await this.hooks.app?.({
|
|
26104
27430
|
id,
|
|
26105
27431
|
sender: raw.s,
|
|
26106
27432
|
epoch: raw.e,
|
|
@@ -26116,7 +27442,7 @@ var CommunitySession = class {
|
|
|
26116
27442
|
try {
|
|
26117
27443
|
opened = plain && plain.length <= COMMUNITY_LIMITS.appBytes ? JSON.parse(utf8Decode(plain)) : null;
|
|
26118
27444
|
} catch {}
|
|
26119
|
-
if (isObject(opened)) await this.hooks.pair?.({
|
|
27445
|
+
if (isObject$2(opened)) await this.hooks.pair?.({
|
|
26120
27446
|
id,
|
|
26121
27447
|
sender: raw.s,
|
|
26122
27448
|
epoch: raw.e,
|
|
@@ -26571,6 +27897,663 @@ var GroupTypingBoard = class {
|
|
|
26571
27897
|
}
|
|
26572
27898
|
};
|
|
26573
27899
|
//#endregion
|
|
26574
|
-
|
|
27900
|
+
//#region ../core/src/appBundle.ts
|
|
27901
|
+
var APP_BUNDLE_MAGIC = "GHOSTLYAPP1";
|
|
27902
|
+
var MAGIC = utf8Encode(APP_BUNDLE_MAGIC);
|
|
27903
|
+
var APP_BUNDLE_LIMITS = {
|
|
27904
|
+
/** The whole bundle, phase 1. */
|
|
27905
|
+
bundleBytes: 16777216,
|
|
27906
|
+
files: 64,
|
|
27907
|
+
manifestBytes: 65536,
|
|
27908
|
+
/** A path, in bytes (paths are ASCII). */
|
|
27909
|
+
pathBytes: 128,
|
|
27910
|
+
iconBytes: 262144,
|
|
27911
|
+
screenshots: 8,
|
|
27912
|
+
/** Lengths in Unicode code points. */
|
|
27913
|
+
title: 40,
|
|
27914
|
+
tagline: 80,
|
|
27915
|
+
description: 2e3,
|
|
27916
|
+
releaseNotes: 500,
|
|
27917
|
+
version: 64,
|
|
27918
|
+
license: 128,
|
|
27919
|
+
/** An https URL, in characters. */
|
|
27920
|
+
url: 512,
|
|
27921
|
+
sources: 8
|
|
27922
|
+
};
|
|
27923
|
+
var APP_ICON_PATH = "icon.png";
|
|
27924
|
+
var SCREENSHOT_TYPES = /\.(png|jpg|webp)$/;
|
|
27925
|
+
/**
|
|
27926
|
+
* The permissions of phase 1 (WISP 1200 · Permissions). `internet`: the app reaches HTTPS and WSS servers of its
|
|
27927
|
+
* choosing (the client runs it in its network runner); without it, an app has no network.
|
|
27928
|
+
*/
|
|
27929
|
+
var APP_PERMISSIONS = [
|
|
27930
|
+
"chat",
|
|
27931
|
+
"internet",
|
|
27932
|
+
"name"
|
|
27933
|
+
];
|
|
27934
|
+
var APP_CLIENTS = [
|
|
27935
|
+
"web",
|
|
27936
|
+
"desktop",
|
|
27937
|
+
"extension"
|
|
27938
|
+
];
|
|
27939
|
+
var REQUIRED = [
|
|
27940
|
+
"ghostlyApp",
|
|
27941
|
+
"publisher",
|
|
27942
|
+
"name",
|
|
27943
|
+
"version",
|
|
27944
|
+
"sequence",
|
|
27945
|
+
"kind",
|
|
27946
|
+
"title",
|
|
27947
|
+
"tagline",
|
|
27948
|
+
"entry",
|
|
27949
|
+
"permissions",
|
|
27950
|
+
"runtime",
|
|
27951
|
+
"license",
|
|
27952
|
+
"files"
|
|
27953
|
+
];
|
|
27954
|
+
var OPTIONAL = [
|
|
27955
|
+
"description",
|
|
27956
|
+
"sources",
|
|
27957
|
+
"proofs",
|
|
27958
|
+
"homepage",
|
|
27959
|
+
"support",
|
|
27960
|
+
"releaseNotes"
|
|
27961
|
+
];
|
|
27962
|
+
/** Reserved for phase 2 and refused in phase 1. */
|
|
27963
|
+
var RESERVED = ["price", "recovery"];
|
|
27964
|
+
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-]+)*))?$/;
|
|
27965
|
+
var HOST = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)(\.(0|[1-9]\d*))?$/;
|
|
27966
|
+
var SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
27967
|
+
var codePoints = (s) => [...s].length;
|
|
27968
|
+
/** One line: no control character. */
|
|
27969
|
+
var isLine$1 = (v, max, min = 0) => typeof v === "string" && codePoints(v) >= min && codePoints(v) <= max && !/\p{Cc}/u.test(v);
|
|
27970
|
+
/** Text of several lines: no control character but the line feed. */
|
|
27971
|
+
var isText$1 = (v, max) => typeof v === "string" && codePoints(v) <= max && !/[^\P{Cc}\n]/u.test(v);
|
|
27972
|
+
var isSafeCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
|
|
27973
|
+
var isObject$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27974
|
+
/**
|
|
27975
|
+
* An https URL as WISP 1200 takes one anywhere: at most 512 characters, no user or password, and on jsDelivr only
|
|
27976
|
+
* `https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/...`, since a branch, a tag, a range or `latest` is
|
|
27977
|
+
* served from a copy that moves.
|
|
27978
|
+
*/
|
|
27979
|
+
function isAppUrl(value) {
|
|
27980
|
+
if (typeof value !== "string" || value.length > APP_BUNDLE_LIMITS.url || !value.startsWith("https://")) return false;
|
|
27981
|
+
let url;
|
|
27982
|
+
try {
|
|
27983
|
+
url = new URL(value);
|
|
27984
|
+
} catch {
|
|
27985
|
+
return false;
|
|
27986
|
+
}
|
|
27987
|
+
if (url.protocol !== "https:" || url.username || url.password || !url.hostname) return false;
|
|
27988
|
+
const host = url.hostname.toLowerCase();
|
|
27989
|
+
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);
|
|
27990
|
+
return true;
|
|
27991
|
+
}
|
|
27992
|
+
/**
|
|
27993
|
+
* An SPDX license expression, read lightly: identifiers (`MIT`, `GPL-3.0-or-later`, `LicenseRef-x`, `Apache-2.0+`)
|
|
27994
|
+
* joined by `AND` and `OR`, `WITH` an exception, and parentheses; or `proprietary`.
|
|
27995
|
+
*/
|
|
27996
|
+
function isAppLicense(value) {
|
|
27997
|
+
if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.license) return false;
|
|
27998
|
+
const tokens = value.match(/\(|\)|[^\s()]+/g);
|
|
27999
|
+
if (!tokens || tokens.join("").length !== value.replace(/\s/g, "").length) return false;
|
|
28000
|
+
let i = 0;
|
|
28001
|
+
const id = () => {
|
|
28002
|
+
const t = tokens[i];
|
|
28003
|
+
if (t && /^[A-Za-z0-9][A-Za-z0-9.-]*\+?$/.test(t) && ![
|
|
28004
|
+
"AND",
|
|
28005
|
+
"OR",
|
|
28006
|
+
"WITH"
|
|
28007
|
+
].includes(t)) {
|
|
28008
|
+
i++;
|
|
28009
|
+
return true;
|
|
28010
|
+
}
|
|
28011
|
+
return false;
|
|
28012
|
+
};
|
|
28013
|
+
const term = (depth) => {
|
|
28014
|
+
if (tokens[i] === "(") {
|
|
28015
|
+
i++;
|
|
28016
|
+
if (depth > 8 || !expr(depth + 1) || tokens[i] !== ")") return false;
|
|
28017
|
+
i++;
|
|
28018
|
+
return true;
|
|
28019
|
+
}
|
|
28020
|
+
if (!id()) return false;
|
|
28021
|
+
if (tokens[i] === "WITH") {
|
|
28022
|
+
i++;
|
|
28023
|
+
return id();
|
|
28024
|
+
}
|
|
28025
|
+
return true;
|
|
28026
|
+
};
|
|
28027
|
+
const expr = (depth) => {
|
|
28028
|
+
if (!term(depth)) return false;
|
|
28029
|
+
while (tokens[i] === "AND" || tokens[i] === "OR") {
|
|
28030
|
+
i++;
|
|
28031
|
+
if (!term(depth)) return false;
|
|
28032
|
+
}
|
|
28033
|
+
return true;
|
|
28034
|
+
};
|
|
28035
|
+
return expr(0) && i === tokens.length;
|
|
28036
|
+
}
|
|
28037
|
+
/** A path of a bundle: relative, `/`-separated, `[A-Za-z0-9._-]` per segment, no `.` or `..`, at most 128 bytes. */
|
|
28038
|
+
function isAppPath(value) {
|
|
28039
|
+
if (typeof value !== "string" || value.length < 1 || value.length > APP_BUNDLE_LIMITS.pathBytes) return false;
|
|
28040
|
+
return value.split("/").every((s) => SEGMENT.test(s) && s !== "." && s !== "..");
|
|
28041
|
+
}
|
|
28042
|
+
var bad = (field, detail = `${field} is not valid`) => ({
|
|
28043
|
+
ok: false,
|
|
28044
|
+
reason: "bad-field",
|
|
28045
|
+
detail
|
|
28046
|
+
});
|
|
28047
|
+
/** Checks a parsed manifest against every rule and bound of WISP 1200 that the manifest alone decides. */
|
|
28048
|
+
function checkAppManifest(value) {
|
|
28049
|
+
if (!isObject$1(value)) return bad("manifest", "The manifest is not an object");
|
|
28050
|
+
if (value.ghostlyApp !== void 0 && value.ghostlyApp !== 1) return {
|
|
28051
|
+
ok: false,
|
|
28052
|
+
reason: "unsupported-format",
|
|
28053
|
+
detail: "ghostlyApp is not 1"
|
|
28054
|
+
};
|
|
28055
|
+
for (const key of Object.keys(value)) {
|
|
28056
|
+
if (RESERVED.includes(key)) return {
|
|
28057
|
+
ok: false,
|
|
28058
|
+
reason: "reserved-key",
|
|
28059
|
+
detail: key
|
|
28060
|
+
};
|
|
28061
|
+
if (!REQUIRED.includes(key) && !OPTIONAL.includes(key)) return {
|
|
28062
|
+
ok: false,
|
|
28063
|
+
reason: "unknown-key",
|
|
28064
|
+
detail: key
|
|
28065
|
+
};
|
|
28066
|
+
}
|
|
28067
|
+
for (const key of REQUIRED) if (!(key in value)) return {
|
|
28068
|
+
ok: false,
|
|
28069
|
+
reason: "missing-key",
|
|
28070
|
+
detail: key
|
|
28071
|
+
};
|
|
28072
|
+
const m = value;
|
|
28073
|
+
if (!isAppKey(m.publisher)) return bad("publisher");
|
|
28074
|
+
if (typeof m.name !== "string" || !APP_NAME.test(m.name)) return bad("name");
|
|
28075
|
+
if (typeof m.version !== "string" || m.version.length > APP_BUNDLE_LIMITS.version || !SEMVER.test(m.version)) return bad("version");
|
|
28076
|
+
if (!isSafeCount(m.sequence, 1)) return bad("sequence");
|
|
28077
|
+
if (m.kind !== "mini-app") return bad("kind");
|
|
28078
|
+
if (!isLine$1(m.title, APP_BUNDLE_LIMITS.title, 1)) return bad("title");
|
|
28079
|
+
if (!isLine$1(m.tagline, APP_BUNDLE_LIMITS.tagline, 1)) return bad("tagline");
|
|
28080
|
+
if (m.description !== void 0 && !isText$1(m.description, APP_BUNDLE_LIMITS.description)) return bad("description");
|
|
28081
|
+
if (m.releaseNotes !== void 0 && !isLine$1(m.releaseNotes, APP_BUNDLE_LIMITS.releaseNotes)) return bad("releaseNotes");
|
|
28082
|
+
const permissions = m.permissions;
|
|
28083
|
+
if (!Array.isArray(permissions) || new Set(permissions).size !== permissions.length || !permissions.every((p) => APP_PERMISSIONS.includes(p))) return bad("permissions");
|
|
28084
|
+
const runtime = m.runtime;
|
|
28085
|
+
if (!isObject$1(runtime) || Object.keys(runtime).length !== 2 || typeof runtime.host !== "string" || !HOST.test(runtime.host)) return bad("runtime");
|
|
28086
|
+
const clients = runtime.clients;
|
|
28087
|
+
if (!Array.isArray(clients) || clients.length < 1 || new Set(clients).size !== clients.length || !clients.every((c) => APP_CLIENTS.includes(c))) return bad("runtime");
|
|
28088
|
+
if (!isAppLicense(m.license)) return bad("license");
|
|
28089
|
+
if (m.sources !== void 0 && (!Array.isArray(m.sources) || m.sources.length > APP_BUNDLE_LIMITS.sources || !m.sources.every(isAppUrl))) return bad("sources");
|
|
28090
|
+
if (m.homepage !== void 0 && !isAppUrl(m.homepage)) return bad("homepage");
|
|
28091
|
+
if (m.support !== void 0 && !isAppUrl(m.support)) return bad("support");
|
|
28092
|
+
if (m.proofs !== void 0) {
|
|
28093
|
+
if (!Array.isArray(m.proofs)) return bad("proofs");
|
|
28094
|
+
if (m.proofs.length > 0) return {
|
|
28095
|
+
ok: false,
|
|
28096
|
+
reason: "proofs-unsupported",
|
|
28097
|
+
detail: "Publisher proofs come after phase 1"
|
|
28098
|
+
};
|
|
28099
|
+
}
|
|
28100
|
+
const files = m.files;
|
|
28101
|
+
if (!Array.isArray(files)) return bad("files");
|
|
28102
|
+
if (files.length > APP_BUNDLE_LIMITS.files) return {
|
|
28103
|
+
ok: false,
|
|
28104
|
+
reason: "too-many-files",
|
|
28105
|
+
detail: `${files.length} files`
|
|
28106
|
+
};
|
|
28107
|
+
const lower = /* @__PURE__ */ new Set();
|
|
28108
|
+
let previous = "";
|
|
28109
|
+
let screenshots = 0;
|
|
28110
|
+
for (const f of files) {
|
|
28111
|
+
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) ? {
|
|
28112
|
+
ok: false,
|
|
28113
|
+
reason: "bad-path",
|
|
28114
|
+
detail: f.path
|
|
28115
|
+
} : bad("files", "A file is not {path, size, sha256}");
|
|
28116
|
+
if (lower.has(f.path.toLowerCase())) return {
|
|
28117
|
+
ok: false,
|
|
28118
|
+
reason: "path-collision",
|
|
28119
|
+
detail: f.path
|
|
28120
|
+
};
|
|
28121
|
+
lower.add(f.path.toLowerCase());
|
|
28122
|
+
if (f.path <= previous) return {
|
|
28123
|
+
ok: false,
|
|
28124
|
+
reason: "files-unsorted",
|
|
28125
|
+
detail: f.path
|
|
28126
|
+
};
|
|
28127
|
+
previous = f.path;
|
|
28128
|
+
if (f.size > APP_BUNDLE_LIMITS.bundleBytes) return bad("files", `${f.path} is larger than a bundle`);
|
|
28129
|
+
if (f.path.startsWith("screenshots/")) {
|
|
28130
|
+
if (!SCREENSHOT_TYPES.test(f.path)) return {
|
|
28131
|
+
ok: false,
|
|
28132
|
+
reason: "bad-path",
|
|
28133
|
+
detail: `${f.path}: a screenshot is .png, .jpg or .webp`
|
|
28134
|
+
};
|
|
28135
|
+
if (++screenshots > APP_BUNDLE_LIMITS.screenshots) return {
|
|
28136
|
+
ok: false,
|
|
28137
|
+
reason: "too-many-screenshots"
|
|
28138
|
+
};
|
|
28139
|
+
}
|
|
28140
|
+
if (f.path === "icon.png" && f.size > APP_BUNDLE_LIMITS.iconBytes) return {
|
|
28141
|
+
ok: false,
|
|
28142
|
+
reason: "bad-icon",
|
|
28143
|
+
detail: "icon.png is larger than 256 KiB"
|
|
28144
|
+
};
|
|
28145
|
+
}
|
|
28146
|
+
const entry = m.entry;
|
|
28147
|
+
if (typeof entry !== "string" || !entry.endsWith(".html") || !files.some((f) => isObject$1(f) && f.path === entry)) return {
|
|
28148
|
+
ok: false,
|
|
28149
|
+
reason: "bad-entry",
|
|
28150
|
+
detail: "The entry is not an .html file of the bundle"
|
|
28151
|
+
};
|
|
28152
|
+
return {
|
|
28153
|
+
ok: true,
|
|
28154
|
+
manifest: m
|
|
28155
|
+
};
|
|
28156
|
+
}
|
|
28157
|
+
/** The digest of a version: SHA-256 of its canonical manifest bytes, base64url. */
|
|
28158
|
+
function appDigest(manifestBytes) {
|
|
28159
|
+
return toBase64Url(sha256(manifestBytes));
|
|
28160
|
+
}
|
|
28161
|
+
var u32 = (b, at) => (b[at] << 24 >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3];
|
|
28162
|
+
/** Reads and checks a bundle (the order of the checks is part of the format: see above). */
|
|
28163
|
+
function readAppBundle(bytes) {
|
|
28164
|
+
if (bytes.length > APP_BUNDLE_LIMITS.bundleBytes) return {
|
|
28165
|
+
ok: false,
|
|
28166
|
+
reason: "too-large"
|
|
28167
|
+
};
|
|
28168
|
+
const head = bytes.subarray(0, MAGIC.length);
|
|
28169
|
+
if (!head.every((b, i) => b === MAGIC[i])) return {
|
|
28170
|
+
ok: false,
|
|
28171
|
+
reason: "magic"
|
|
28172
|
+
};
|
|
28173
|
+
if (head.length < MAGIC.length) return {
|
|
28174
|
+
ok: false,
|
|
28175
|
+
reason: "truncated"
|
|
28176
|
+
};
|
|
28177
|
+
let at = MAGIC.length;
|
|
28178
|
+
if (bytes.length < at + 4) return {
|
|
28179
|
+
ok: false,
|
|
28180
|
+
reason: "truncated"
|
|
28181
|
+
};
|
|
28182
|
+
const manifestLength = u32(bytes, at);
|
|
28183
|
+
at += 4;
|
|
28184
|
+
if (manifestLength > APP_BUNDLE_LIMITS.manifestBytes) return {
|
|
28185
|
+
ok: false,
|
|
28186
|
+
reason: "manifest-too-large"
|
|
28187
|
+
};
|
|
28188
|
+
if (bytes.length < at + manifestLength + 4) return {
|
|
28189
|
+
ok: false,
|
|
28190
|
+
reason: "truncated"
|
|
28191
|
+
};
|
|
28192
|
+
const manifestBytes = bytes.subarray(at, at + manifestLength);
|
|
28193
|
+
at += manifestLength;
|
|
28194
|
+
const statementLength = u32(bytes, at);
|
|
28195
|
+
at += 4;
|
|
28196
|
+
if (bytes.length < at + statementLength) return {
|
|
28197
|
+
ok: false,
|
|
28198
|
+
reason: "truncated"
|
|
28199
|
+
};
|
|
28200
|
+
const statementBytes = bytes.subarray(at, at + statementLength);
|
|
28201
|
+
at += statementLength;
|
|
28202
|
+
const read = readCanonicalJson(manifestBytes);
|
|
28203
|
+
if (!read.ok) return read;
|
|
28204
|
+
const checked = checkAppManifest(read.value);
|
|
28205
|
+
if (!checked.ok) return checked;
|
|
28206
|
+
const manifest = checked.manifest;
|
|
28207
|
+
const statement = readAppSignature(statementBytes);
|
|
28208
|
+
if (!statement.ok) return statement;
|
|
28209
|
+
const total = manifest.files.reduce((sum, f) => sum + f.size, 0);
|
|
28210
|
+
const rest = bytes.length - at;
|
|
28211
|
+
if (total > rest) return {
|
|
28212
|
+
ok: false,
|
|
28213
|
+
reason: "truncated"
|
|
28214
|
+
};
|
|
28215
|
+
if (total < rest) return {
|
|
28216
|
+
ok: false,
|
|
28217
|
+
reason: "trailing-bytes",
|
|
28218
|
+
detail: `${rest - total} bytes past the last file`
|
|
28219
|
+
};
|
|
28220
|
+
const signed = verifyAppSignature(APP_PREFIXES.app, manifestBytes, statement.signature, manifest.publisher);
|
|
28221
|
+
if (!signed.ok) return signed;
|
|
28222
|
+
const files = /* @__PURE__ */ new Map();
|
|
28223
|
+
for (const f of manifest.files) {
|
|
28224
|
+
const content = bytes.subarray(at, at + f.size);
|
|
28225
|
+
at += f.size;
|
|
28226
|
+
if (toBase64Url(sha256(content)) !== f.sha256) return {
|
|
28227
|
+
ok: false,
|
|
28228
|
+
reason: "file-hash",
|
|
28229
|
+
detail: f.path
|
|
28230
|
+
};
|
|
28231
|
+
files.set(f.path, content);
|
|
28232
|
+
}
|
|
28233
|
+
const icon = files.get(APP_ICON_PATH);
|
|
28234
|
+
if (icon) {
|
|
28235
|
+
const size = imageSize(icon);
|
|
28236
|
+
if (!size || icon[0] !== 137 || size.width !== size.height) return {
|
|
28237
|
+
ok: false,
|
|
28238
|
+
reason: "bad-icon",
|
|
28239
|
+
detail: "icon.png is not a square PNG"
|
|
28240
|
+
};
|
|
28241
|
+
}
|
|
28242
|
+
return {
|
|
28243
|
+
ok: true,
|
|
28244
|
+
bundle: {
|
|
28245
|
+
manifest,
|
|
28246
|
+
manifestBytes,
|
|
28247
|
+
digest: appDigest(manifestBytes),
|
|
28248
|
+
signature: statement.signature,
|
|
28249
|
+
files
|
|
28250
|
+
}
|
|
28251
|
+
};
|
|
28252
|
+
}
|
|
28253
|
+
/** Puts a bundle's parts together, checking nothing: for builders, and for tests that need a broken bundle. */
|
|
28254
|
+
function assembleAppBundle(manifestBytes, statementBytes, files) {
|
|
28255
|
+
const total = MAGIC.length + 8 + manifestBytes.length + statementBytes.length + files.reduce((s, f) => s + f.length, 0);
|
|
28256
|
+
const out = new Uint8Array(total);
|
|
28257
|
+
const view = new DataView(out.buffer);
|
|
28258
|
+
let at = 0;
|
|
28259
|
+
out.set(MAGIC, at);
|
|
28260
|
+
at += MAGIC.length;
|
|
28261
|
+
view.setUint32(at, manifestBytes.length);
|
|
28262
|
+
at += 4;
|
|
28263
|
+
out.set(manifestBytes, at);
|
|
28264
|
+
at += manifestBytes.length;
|
|
28265
|
+
view.setUint32(at, statementBytes.length);
|
|
28266
|
+
at += 4;
|
|
28267
|
+
out.set(statementBytes, at);
|
|
28268
|
+
at += statementBytes.length;
|
|
28269
|
+
for (const f of files) {
|
|
28270
|
+
out.set(f, at);
|
|
28271
|
+
at += f.length;
|
|
28272
|
+
}
|
|
28273
|
+
return out;
|
|
28274
|
+
}
|
|
28275
|
+
//#endregion
|
|
28276
|
+
//#region ../core/src/appStore.ts
|
|
28277
|
+
var APP_STORE_LIMITS = {
|
|
28278
|
+
/** The index's bytes, uncompressed. */
|
|
28279
|
+
indexBytes: 16777216,
|
|
28280
|
+
/** How far ahead `expires` may be. */
|
|
28281
|
+
expiresAheadS: 7776e3,
|
|
28282
|
+
name: 40,
|
|
28283
|
+
description: 2e3,
|
|
28284
|
+
title: 40,
|
|
28285
|
+
tagline: 80,
|
|
28286
|
+
/** `category`, `developer`, `submitter`. */
|
|
28287
|
+
label: 40,
|
|
28288
|
+
reason: 200,
|
|
28289
|
+
urls: 4,
|
|
28290
|
+
apps: 4096,
|
|
28291
|
+
removed: 4096,
|
|
28292
|
+
revoked: 4096
|
|
28293
|
+
};
|
|
28294
|
+
var APP_STORE_KINDS = ["curated", "indexed"];
|
|
28295
|
+
var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
28296
|
+
var isLine = (v, max, min = 0) => typeof v === "string" && [...v].length >= min && [...v].length <= max && !/\p{Cc}/u.test(v);
|
|
28297
|
+
var isText = (v, max) => typeof v === "string" && [...v].length <= max && !/[^\P{Cc}\n]/u.test(v);
|
|
28298
|
+
var isCount = (v, min) => typeof v === "number" && Number.isSafeInteger(v) && v >= min;
|
|
28299
|
+
var fine = { ok: true };
|
|
28300
|
+
function keysOf(value, required, optional) {
|
|
28301
|
+
for (const k of Object.keys(value)) if (!required.includes(k) && !optional.includes(k)) return {
|
|
28302
|
+
ok: false,
|
|
28303
|
+
reason: "unknown-key",
|
|
28304
|
+
detail: k
|
|
28305
|
+
};
|
|
28306
|
+
for (const k of required) if (!(k in value)) return {
|
|
28307
|
+
ok: false,
|
|
28308
|
+
reason: "missing-key",
|
|
28309
|
+
detail: k
|
|
28310
|
+
};
|
|
28311
|
+
return fine;
|
|
28312
|
+
}
|
|
28313
|
+
var LISTING_REQUIRED = [
|
|
28314
|
+
"ref",
|
|
28315
|
+
"sequence",
|
|
28316
|
+
"digest",
|
|
28317
|
+
"urls",
|
|
28318
|
+
"title",
|
|
28319
|
+
"tagline"
|
|
28320
|
+
];
|
|
28321
|
+
var LISTING_OPTIONAL = [
|
|
28322
|
+
"category",
|
|
28323
|
+
"developer",
|
|
28324
|
+
"submitter",
|
|
28325
|
+
"repo",
|
|
28326
|
+
"support"
|
|
28327
|
+
];
|
|
28328
|
+
/** Checks one entry of `apps`, which is also the whole of a `listing.json`. */
|
|
28329
|
+
function checkAppListing(value) {
|
|
28330
|
+
if (!isObject(value)) return {
|
|
28331
|
+
ok: false,
|
|
28332
|
+
reason: "bad-field",
|
|
28333
|
+
detail: "A listing is not an object"
|
|
28334
|
+
};
|
|
28335
|
+
const keys = keysOf(value, LISTING_REQUIRED, LISTING_OPTIONAL);
|
|
28336
|
+
if (!keys.ok) return keys;
|
|
28337
|
+
const bad = (field) => ({
|
|
28338
|
+
ok: false,
|
|
28339
|
+
reason: "bad-field",
|
|
28340
|
+
detail: `apps: ${field}`
|
|
28341
|
+
});
|
|
28342
|
+
if (!isAppRef(value.ref)) return bad("ref");
|
|
28343
|
+
if (!isCount(value.sequence, 1)) return bad("sequence");
|
|
28344
|
+
if (!isAppHash(value.digest)) return bad("digest");
|
|
28345
|
+
const urls = value.urls;
|
|
28346
|
+
if (!Array.isArray(urls) || urls.length < 1 || urls.length > APP_STORE_LIMITS.urls || !urls.every(isAppUrl)) return bad("urls");
|
|
28347
|
+
if (!isLine(value.title, APP_STORE_LIMITS.title, 1)) return bad("title");
|
|
28348
|
+
if (!isLine(value.tagline, APP_STORE_LIMITS.tagline, 1)) return bad("tagline");
|
|
28349
|
+
for (const k of [
|
|
28350
|
+
"category",
|
|
28351
|
+
"developer",
|
|
28352
|
+
"submitter"
|
|
28353
|
+
]) if (value[k] !== void 0 && !isLine(value[k], APP_STORE_LIMITS.label, 1)) return bad(k);
|
|
28354
|
+
for (const k of ["repo", "support"]) if (value[k] !== void 0 && !isAppUrl(value[k])) return bad(k);
|
|
28355
|
+
return fine;
|
|
28356
|
+
}
|
|
28357
|
+
function checkRemoval(value) {
|
|
28358
|
+
if (!isObject(value)) return {
|
|
28359
|
+
ok: false,
|
|
28360
|
+
reason: "bad-field",
|
|
28361
|
+
detail: "A removal is not an object"
|
|
28362
|
+
};
|
|
28363
|
+
const keys = keysOf(value, [
|
|
28364
|
+
"ref",
|
|
28365
|
+
"digest",
|
|
28366
|
+
"reason",
|
|
28367
|
+
"at"
|
|
28368
|
+
], []);
|
|
28369
|
+
if (!keys.ok) return keys;
|
|
28370
|
+
if (!isAppRef(value.ref) || !isAppHash(value.digest) || !isLine(value.reason, APP_STORE_LIMITS.reason, 1) || !isCount(value.at, 0)) return {
|
|
28371
|
+
ok: false,
|
|
28372
|
+
reason: "bad-field",
|
|
28373
|
+
detail: "removed: not {ref, digest, reason, at}"
|
|
28374
|
+
};
|
|
28375
|
+
return fine;
|
|
28376
|
+
}
|
|
28377
|
+
var STORE_REQUIRED = [
|
|
28378
|
+
"ghostlyStore",
|
|
28379
|
+
"key",
|
|
28380
|
+
"name",
|
|
28381
|
+
"kind",
|
|
28382
|
+
"sequence",
|
|
28383
|
+
"expires",
|
|
28384
|
+
"apps",
|
|
28385
|
+
"removed",
|
|
28386
|
+
"revoked"
|
|
28387
|
+
];
|
|
28388
|
+
/**
|
|
28389
|
+
* Checks an index's fields and bounds, and every revocation it carries (one that does not verify refuses the index:
|
|
28390
|
+
* the store copied it, so a broken one is the store's fault). The signature and `expires` are `readAppStore`'s.
|
|
28391
|
+
*/
|
|
28392
|
+
function checkAppStoreIndex(value) {
|
|
28393
|
+
if (!isObject(value)) return {
|
|
28394
|
+
ok: false,
|
|
28395
|
+
reason: "bad-field",
|
|
28396
|
+
detail: "The index is not an object"
|
|
28397
|
+
};
|
|
28398
|
+
if (value.ghostlyStore !== void 0 && value.ghostlyStore !== 1) return {
|
|
28399
|
+
ok: false,
|
|
28400
|
+
reason: "unsupported-format",
|
|
28401
|
+
detail: "ghostlyStore is not 1"
|
|
28402
|
+
};
|
|
28403
|
+
const keys = keysOf(value, STORE_REQUIRED, ["description"]);
|
|
28404
|
+
if (!keys.ok) return keys;
|
|
28405
|
+
const bad = (field) => ({
|
|
28406
|
+
ok: false,
|
|
28407
|
+
reason: "bad-field",
|
|
28408
|
+
detail: field
|
|
28409
|
+
});
|
|
28410
|
+
if (!isAppKey(value.key)) return bad("key");
|
|
28411
|
+
if (!isLine(value.name, APP_STORE_LIMITS.name, 1)) return bad("name");
|
|
28412
|
+
if (value.description !== void 0 && !isText(value.description, APP_STORE_LIMITS.description)) return bad("description");
|
|
28413
|
+
if (!APP_STORE_KINDS.includes(value.kind)) return bad("kind");
|
|
28414
|
+
if (!isCount(value.sequence, 1)) return bad("sequence");
|
|
28415
|
+
if (!isCount(value.expires, 0)) return bad("expires");
|
|
28416
|
+
const { apps, removed, revoked } = value;
|
|
28417
|
+
if (!Array.isArray(apps) || apps.length > APP_STORE_LIMITS.apps) return bad("apps");
|
|
28418
|
+
const refs = /* @__PURE__ */ new Set();
|
|
28419
|
+
for (const listing of apps) {
|
|
28420
|
+
const checked = checkAppListing(listing);
|
|
28421
|
+
if (!checked.ok) return checked;
|
|
28422
|
+
const ref = listing.ref;
|
|
28423
|
+
if (refs.has(ref)) return {
|
|
28424
|
+
ok: false,
|
|
28425
|
+
reason: "duplicate-app",
|
|
28426
|
+
detail: ref
|
|
28427
|
+
};
|
|
28428
|
+
refs.add(ref);
|
|
28429
|
+
}
|
|
28430
|
+
if (!Array.isArray(removed) || removed.length > APP_STORE_LIMITS.removed) return bad("removed");
|
|
28431
|
+
for (const entry of removed) {
|
|
28432
|
+
const checked = checkRemoval(entry);
|
|
28433
|
+
if (!checked.ok) return checked;
|
|
28434
|
+
}
|
|
28435
|
+
if (!Array.isArray(revoked) || revoked.length > APP_STORE_LIMITS.revoked) return bad("revoked");
|
|
28436
|
+
for (const entry of revoked) {
|
|
28437
|
+
const read = readAppRevocation(entry);
|
|
28438
|
+
if (!read.ok) return {
|
|
28439
|
+
ok: false,
|
|
28440
|
+
reason: "bad-revocation",
|
|
28441
|
+
detail: read.detail ?? read.reason
|
|
28442
|
+
};
|
|
28443
|
+
}
|
|
28444
|
+
return fine;
|
|
28445
|
+
}
|
|
28446
|
+
/**
|
|
28447
|
+
* Reads `ghostly-store.json` with its `ghostly-store.sig`, in this order: size, JSON, canonical bytes, fields and
|
|
28448
|
+
* revocations, the signature statement, the key the reader holds for this store (when it holds one), the signature,
|
|
28449
|
+
* then `expires` against `now` (seconds): more than 90 days ahead is refused, past is `expired`.
|
|
28450
|
+
*/
|
|
28451
|
+
function readAppStore(indexBytes, sigBytes, now, heldKey) {
|
|
28452
|
+
if (indexBytes.length > APP_STORE_LIMITS.indexBytes) return {
|
|
28453
|
+
ok: false,
|
|
28454
|
+
reason: "too-large"
|
|
28455
|
+
};
|
|
28456
|
+
const read = readCanonicalJson(indexBytes);
|
|
28457
|
+
if (!read.ok) return read;
|
|
28458
|
+
const checked = checkAppStoreIndex(read.value);
|
|
28459
|
+
if (!checked.ok) return checked;
|
|
28460
|
+
const index = read.value;
|
|
28461
|
+
const statement = readAppSignature(sigBytes);
|
|
28462
|
+
if (!statement.ok) return statement;
|
|
28463
|
+
if (heldKey !== void 0 && index.key !== heldKey) return {
|
|
28464
|
+
ok: false,
|
|
28465
|
+
reason: "store-key",
|
|
28466
|
+
detail: "Another key than the store's"
|
|
28467
|
+
};
|
|
28468
|
+
const signed = verifyAppSignature(APP_PREFIXES.store, indexBytes, statement.signature, index.key);
|
|
28469
|
+
if (!signed.ok) return signed;
|
|
28470
|
+
if (index.expires > now + APP_STORE_LIMITS.expiresAheadS) return {
|
|
28471
|
+
ok: false,
|
|
28472
|
+
reason: "expires-too-far"
|
|
28473
|
+
};
|
|
28474
|
+
return {
|
|
28475
|
+
ok: true,
|
|
28476
|
+
store: {
|
|
28477
|
+
index,
|
|
28478
|
+
digest: toBase64Url(sha256(indexBytes)),
|
|
28479
|
+
expired: index.expires < now
|
|
28480
|
+
}
|
|
28481
|
+
};
|
|
28482
|
+
}
|
|
28483
|
+
/** Signs an index with the store key: its canonical bytes and the `ghostly-store.sig` bytes. Throws on a bad index. */
|
|
28484
|
+
async function signAppStore(index, signer) {
|
|
28485
|
+
if (index.key !== toZ32(signer.publicKey)) throw new Error("A store index is signed by its own key");
|
|
28486
|
+
const checked = checkAppStoreIndex(JSON.parse(JSON.stringify(index)));
|
|
28487
|
+
if (!checked.ok) throw new Error(`Not a valid store index (${checked.reason}${checked.detail ? `: ${checked.detail}` : ""})`);
|
|
28488
|
+
const { bytes, signature } = await signAppObject(APP_PREFIXES.store, index, signer);
|
|
28489
|
+
return {
|
|
28490
|
+
indexBytes: bytes,
|
|
28491
|
+
sigBytes: canonicalJsonBytes(signature),
|
|
28492
|
+
signature
|
|
28493
|
+
};
|
|
28494
|
+
}
|
|
28495
|
+
//#endregion
|
|
28496
|
+
//#region ../core/src/appUpdates.ts
|
|
28497
|
+
function appUpdateDecision(installed, candidate) {
|
|
28498
|
+
if (installed.ref !== candidate.ref) return "other-app";
|
|
28499
|
+
if (candidate.sequence > installed.sequence) return "update";
|
|
28500
|
+
if (candidate.sequence < installed.sequence) return "rollback";
|
|
28501
|
+
return candidate.digest === installed.digest ? "same" : "equivocation";
|
|
28502
|
+
}
|
|
28503
|
+
/** The permissions an update adds: when there is any, the update waits for the person, who sees only these. */
|
|
28504
|
+
function addedAppPermissions(granted, requested) {
|
|
28505
|
+
return requested.filter((p) => !granted.includes(p));
|
|
28506
|
+
}
|
|
28507
|
+
function planAppUpdate(installed, candidate) {
|
|
28508
|
+
const decision = appUpdateDecision(installed, candidate);
|
|
28509
|
+
if (decision !== "update") return {
|
|
28510
|
+
action: "refuse",
|
|
28511
|
+
decision
|
|
28512
|
+
};
|
|
28513
|
+
const added = addedAppPermissions(installed.permissions, candidate.permissions);
|
|
28514
|
+
return added.length ? {
|
|
28515
|
+
action: "ask",
|
|
28516
|
+
added
|
|
28517
|
+
} : { action: "install" };
|
|
28518
|
+
}
|
|
28519
|
+
/**
|
|
28520
|
+
* The revocation that stops a version, or null: one signed by the app's own publisher (a reader verified it) that names
|
|
28521
|
+
* its digest, or reaches its `sequence` with `upTo`.
|
|
28522
|
+
*/
|
|
28523
|
+
function appRevocationFor(version, revocations) {
|
|
28524
|
+
const publisher = appRefPublisher(version.ref);
|
|
28525
|
+
for (const r of revocations) {
|
|
28526
|
+
const s = r.statement;
|
|
28527
|
+
if (s.app !== version.ref || r.signature.key !== publisher) continue;
|
|
28528
|
+
if ("digests" in s ? s.digests.includes(version.digest) : version.sequence <= s.upTo) return r;
|
|
28529
|
+
}
|
|
28530
|
+
return null;
|
|
28531
|
+
}
|
|
28532
|
+
function checkAppBeforeRun(version, stores, revocations = []) {
|
|
28533
|
+
const revocation = appRevocationFor(version, [...revocations, ...stores.flatMap((s) => s.revoked)]);
|
|
28534
|
+
if (revocation) return {
|
|
28535
|
+
status: "revoked",
|
|
28536
|
+
revocation
|
|
28537
|
+
};
|
|
28538
|
+
const by = stores.flatMap((s) => s.removed.filter((r) => r.ref === version.ref && r.digest === version.digest).map((r) => ({
|
|
28539
|
+
store: s.key,
|
|
28540
|
+
name: s.name,
|
|
28541
|
+
reason: r.reason,
|
|
28542
|
+
at: r.at
|
|
28543
|
+
})));
|
|
28544
|
+
return by.length ? {
|
|
28545
|
+
status: "removed",
|
|
28546
|
+
by
|
|
28547
|
+
} : { status: "ok" };
|
|
28548
|
+
}
|
|
28549
|
+
function appStoreDecision(held, read) {
|
|
28550
|
+
if (!held) return "new";
|
|
28551
|
+
if (held.key !== read.key) return "other-key";
|
|
28552
|
+
if (read.sequence > held.sequence) return "update";
|
|
28553
|
+
if (read.sequence < held.sequence) return "rollback";
|
|
28554
|
+
return read.digest === held.digest ? "same" : "equivocation";
|
|
28555
|
+
}
|
|
28556
|
+
//#endregion
|
|
28557
|
+
export { parseSshSignature as $, parseLocalTarget as $a, parseLinkPreview as $i, GROUP_SIGNAL_FRAME as $n, readHandoffPake as $o, EDIT_CAPABILITY as $r, readTurnPacket as $s, TEST_USDT_FAUCET_AMOUNT as $t, knockIdentity as A, APP_STATEMENT_LIMITS as Aa, toZ32 as Ac, irohDescriptor as Ai, normalizeDomain as An, handoffAttemptFailed as Ao, TYPING_REFRESH_MS as Ar, encodeInviteCode as As, didWebFile as At, GROUP_READ_NOTE as B, canonicalJsonBytes as Ba, sanitizeFileName as Bi, decodeBolt11 as Bn, handoffManifestFrames as Bo, readPin as Br, devicePingFrame as Bs, strictBase64Url as Bt, communityHasFrame as C, statusCardText as Ca, verify as Cc, revokesIdentity as Ci, decodeDnsResponse as Cn, HANDOFF_PAKE as Co, readLobby as Cr, verifyEnrollProof as Cs, DidError as Ct, decodeGroupEntryLink as D, receivedTimestamp as Da, fromBase64Url as Dc, validExternalKey as Di, encodeDnsQuery as Dn, HANDOFF_VERIFIED as Do, transferEnded as Dr, OwnInviteError as Ds, didJwsAlg as Dt, KNOCK_TTL_MS as E, heardTime as Ea, bytesEqual as Ec, emptyProofLedger as Ei, domainWellKnownFile as En, HANDOFF_TIMINGS as Eo, FILE_LIMITS as Er, readDeviceInvite as Es, didGhostlyServiceEntry as Et, GroupWakeLimiter as F, isAppRef as Fa, DHT_FILE_CAPABILITY as Fi, wellKnownUrl as Fn, handoffContextProof as Fo, PIN_LIMITS as Fr, HANDOFF_CAPABILITY as Fs, parseDid as Ft, carryMentions as G, verifyCommitSignature as Ga, fileMessageText as Gi, isFederationId as Gn, handoffStreamKey as Go, parseWakeFrame as Gr, TURN_RAISE_READ_TIMEOUT_MS as Gs, resolveDidDht as Gt, COMMUNITY_EDIT_FRAME as H, MAX_GROUP_CHAIN as Ha, PLAYABLE_AUDIO as Hi, cashuRequestPayload as Hn, handoffPakeFrame as Ho, WakeLimiter as Hr, seedSigner as Hs, didDhtFromPublicKey as Ht, groupWakeFrame as I, readAppRevocations as Ia, dhtFileId as Ii, requestLnurlInvoice as In, handoffDataFrame as Io, mayPin as Ir, deviceEchoFrame as Is, parseDidDocument as It, groupName as J, EXPECT_PEER_MS as Ja, parseVideoMeta as Ji, GhostLink as Jn, readHandoffAck as Jo, checkPushEndpoint as Jr, turnPutSummary as Js, EVM_TEST_CHAINS as Jt, parseCommunityEdit as K, edgeParams as Ka, parseVoiceMeta as Ki, parseCashuRequestPayload as Kn, handoffVersions as Ko, relayRequest as Kr, TURN_SETTLE_MS as Ks, signDidDhtPacket as Kt, groupWakes as L, signAppObject as La, parseImageMeta as Li, resolveLightningDestination as Ln, handoffDigest as Lo, nextPinNumber as Lr, deviceEchoNonce as Ls, parseDidSignaturePaste as Lt, mergeKnocks as M, appRef as Ma, utf8Encode as Mc, DHT_FILE_TOO_LARGE as Mi, recordsFromWellKnown as Mn, handoffBusyFrame as Mo, sanitizeTypingStatus as Mr, inviteOwnership as Ms, didWebFileUrl as Mt, readKnocks as N, checkAppRevokeStatement as Na, DHT_MESSAGE_TTL as Ni, recordsName as Nn, handoffCancelFrame as No, typingActivity as Nr, DEVICES_CAPABILITY as Ns, didWebHost as Nt, encodeGroupEntryLink as O, shownTime as Oa, randomBytes as Oc, TRANSPORTS as Oi, isPublicAddress as On, filePartName as Oo, transferStalled as Or, createChatInvite as Os, didSignedMessages as Ot, GROUP_WAKE_FRAME as P, isAppKey as Pa, peekDhtMailbox as Pi, txtValue as Pn, handoffContext as Po, GROUP_PIN_FRAME as Pr, ENROLL_CAPABILITY as Ps, normalizeDid as Pt, parseSshPublicKey as Q, formatLocalTarget as Qa, dhtEditId as Qi, CarriedTransport as Qn, readHandoffHello as Qo, BUTTONS_CAPABILITY as Qr, nextTurnPosition as Qs, SEPOLIA_TEST_USDT_FAUCET as Qt, parseGroupWakeFrame as R, signAppRevocation as Ra, readImageMeta as Ri, engineError as Rn, handoffEphemeral as Ro, pinIsNewer as Rr, deviceKeyZ32 as Rs, signingKeys as Rt, GROUP_READ_NOTE_COMMUNITY as S, sameButtonText as Sa, publicKeyFromZ32 as Sc, receivedIdentityStatus as Si, DOMAIN_FILE_MAX_BYTES as Sn, HANDOFF_OFFER as So, readBeaconHead as Sr, readEnrollHello as Ss, DID_JWS_MAX_LENGTH as St, encodeCommunityLink as T, claimedTime as Ta, sanitizeAvatar as Tc, PeerProofs as Ti, domainTxtRecord as Tn, HANDOFF_REQUEST as To, ChatFiles as Tr, deviceInviteJoinerParams as Ts, checkDidSignature as Tt, GROUP_EDIT_FRAME as U, MEMBER_KEY as Ua, VOICE_LIMITS as Ui, fedimintRequestPayload as Un, handoffReleaseFrame as Uo, deviceWakeRequest as Ur, webCryptoSigner as Us, encodeDidDhtDeactivated as Ut, GroupSession as V, GROUP_MEMBER_CAP as Va, sanitizeMime as Vi, ENDPOINT as Vn, handoffOfferFrame as Vo, WAKE_CALL_INTERVAL_MS as Vr, newDeviceSetSecret as Vs, didDhtDocument as Vt, GROUP_EDIT_TEXT_BYTES as W, rosterHas as Wa, baseMime as Wi, findEndpoint as Wn, handoffRetryAfter as Wo, newWakeToken as Wr, TURN_PUT_WINDOW_MS as Ws, encodeDidDhtPacket as Wt, SSHSIG_NAMESPACE as X, presenceSeenAt as Xa, EDIT_RATE_WINDOW_MS as Xi, LIVENESS_PING_MS as Xn, readHandoffDone as Xo, chatAppId as Xr, TURN_MAX as Xs, PaymentPreflightError as Xt, SSHSIG_MAX_ARMOR as Y, RELAY_POLL_INTERVALS as Ya, EDIT_BUFFER_MS as Yi, INVITE_TAKEN as Yn, readHandoffCancel as Yo, vapidKeysMatch as Yr, TURN_LAST_SEQUENCE as Ys, ONCHAIN_PROVIDER as Yt, SshSigError as Z, GhostlyHttpError as Za, RateWindow as Zi, pairedMessageFrame as Zn, readHandoffHave as Zo, isAppVersion as Zr, firstDeviceSetSecret as Zs, SEPOLIA_TEST_USDT as Zt, groupTypingEpoch as _, ITEM_STATES as _a, openRelayPayload as _c, boundVerifiedIdentity as _i, pubkyProofCapability as _n, HANDOFF_ACK as _o, mergeLobby as _r, enrollDoneFrame as _s, shortAtprotoDid as _t, checkAppStoreIndex as a, ReactionWindow as aa, RelayTransport as ac, HoldRefusedError as ai, validatePaymentTarget as an, RTC_CONFIG as ao, MESH_HUBS as ar, readSetUpdate as as, decodeAnyBitcoinAddress as at, COMMUNITY_LIMITS as b, buttonLabelClash as ba, identityFromSeed as bc, identityStatement as bi, homeserverWebEndpoint as bn, HANDOFF_HAVE as bo, rankHubs as br, enrollProofFrame as bs, DID_DOCUMENT_MAX_BYTES as bt, APP_BUNDLE_LIMITS as c, reactionEmoji as ca, setLinkTraceSink as cc, CapsExchange as ci, SPARK_PROVIDER as cn, callRtcConfig as co, pickMeshHubs as cr, signTombstone as cs, ATPROTO_PROOF_COLLECTION as ct, assembleAppBundle as d, validReactionNumber as da, isDiscoveryBudgetError as dc, dialDescriptors as di, isBitcoinAddress as dn, parseCallSignal as do, beaconRecords as dr, ENROLL_GRANT as ds, atprotoProofRecord as dt, forwardedAgain as ea, signTurnPacket as ec, UPGRADE_CAPABILITY as ei, USDT_PUBLIC_RPC as en, webLocalFetch as eo, SIGNAL_FRESH_MS as er, readHandoffTurnFrame as es, sameSshKey as et, checkAppManifest as f, wireReaction as fa, withRequestOptions as fc, IDENTITY_MAX_EVIDENCE as fi, PUBKY_PROOF_MAX_BYTES as fn, signalHasVideo as fo, doorHubs as fr, ENROLL_HELLO as fs, atprotoProofRkey as ft, GroupTypingBoard as g, BUTTON_ID as ga, newerPacket as gc, IdentityExchange as gi, normalizePubkyKey as gn, serviceIdFromName as go, mergeBeacon as gr, enrollDigits as gs, parseAtprotoDidDocument as gt, GROUP_TYPING_FRAME as h, replySnippet as ha, createRelayPayload as hc, IdentityCheckUnavailable as hi, newPubkyProofFolder as hn, LEGACY_SERVICES as ho, lobbyRecords as hr, enrollCancelReason as hs, normalizeAtprotoHandle as ht, planAppUpdate as i, REACTION_LIMITS as ia, DEFAULT_RELAYS as ic, HoldKeys as ii, formatPaymentAmount as in, CALL_SIGNAL_MAX_AGE_MS as io, readGroupSignal as ir, SET_UPDATE as is, BitcoinAddressError as it, knockRecords as j, appFingerprint as ja, utf8Decode as jc, irohRelayUrl as ji, recordsFromTxt as jn, handoffAttemptSucceeded as jo, TypingSender as jr, inviteLink as js, didWebFileNames as jt, entryParams as k, APP_PREFIXES as ka, toBase64Url as kc, automaticTransport as ki, nip05RootKey as kn, handoffAttemptAllowed as ko, TYPING_KINDS as kr, decodeInviteCode as ks, didWebDocumentUrl as kt, APP_BUNDLE_MAGIC as l, reactionIsNewer as la, traceLink as lc, DHT_TEXT_CAPABILITY as li, sparkAddressKind as ln, callSignalHeardAt as lo, COMMUNITY_TOPOLOGY as lr, verifySetUpdate as ls, ATPROTO_PROOF_SCOPE as lt, readAppBundle as m, readReply as ma, RELAY_PAYLOAD_MAX_BYTES as mc, IDENTITY_REVOCATION_LABEL as mi, isPubkyProofFolder as mn, HTTP_SERVICE_PROTO as mo, lobbyKeys as mr, enrollCancelFrame as ms, isAtprotoDid as mt, appUpdateDecision as n, COMMUNITY_REACTION_FRAME as na, turnKeys as nc, HOLD_DATED_BACK_MS as ni, assertTokenUnits as nn, decodeControl as no, SIGNAL_REPLY_MS as nr, sealHandoffPiece as ns, verifySshSignature as nt, readAppStore as o, nextReactionNumber as oa, currentRelays as oc, newMailbox as oi, walletNetworkOf as on, answersOffer as oo, mayBeHub as or, setAckFrame as os, ATPROTO_DECLARED_SCOPE as ot, isAppUrl as p, pairedReplyAuthor as pa, PacketTooLargeError as pc, IDENTITY_MAX_VALIDITY as pi, isPubkyKey as pn, emptyLinkRecords as po, freshHubs as pr, ENROLL_PROOF as ps, checkAtprotoProofRecord as pt, mentionsMember as q, DirectPathWatch as qa, voicePeaksOf as qi, parseFedimintRequestPayload as qn, newHandoffId as qo, wakeRequest as qr, classifyTurnRead as qs, ETHEREUM_USDT as qt, checkAppBeforeRun as r, GROUP_REACTION_FRAME as ra, turnName as rc, HOLD_LIMITS as ri, assertWholeSats as rn, encodeControl as ro, groupSignalFrame as rr, SET_ACK as rs, verifyBitcoinMessage as rt, signAppStore as s, queueReaction as sa, normalizeRelayUrl as sc, readManifest as si, SPARK_NETWORKS as sn, buildSdpFromSignal as so, meshHubs as sr, setUpdateFrame as ss, ATPROTO_FULL_SCOPE as st, appStoreDecision as t, readForwarded as ta, signTurnRelease as tc, HOLD_CAPABILITY as ti, WALLET_NETWORKS as tn, LIMITS as to, SIGNAL_HOLD_MS as tr, readHandoffVerified as ts, sshSignCommand as tt, appDigest as u, readReaction as ua, budgetRetryMs as uc, capsDescriptors as ui, sparkInvoiceDetails as un, extractParamsFromSdp as uo, beaconKeys as ur, ENROLL_DONE as us, atprotoDidMethod as ut, openGroupTyping as v, RUN_RESULTS as va, parseRelayPayload as vc, emptyIdentityLedger as vi, pubkyProofFile as vn, HANDOFF_CANCEL as vo, newerHead as vr, enrollGrantFrame as vs, AtprotoProofError as vt, decodeCommunityLink as w, arrivalKey as wa, MAX_AVATAR_LENGTH as wc, lastSharedWithMe as wi, dnsProofName as wn, HANDOFF_PIECE_BYTES as wo, shouldBeHub as wr, createDeviceInvite as ws, checkDidJws as wt, CommunitySession as x, checkStatusCard as xa, identityFromSeedB64 as xc, newIdentityBinding as xi, pubkyHomeserverOf as xn, HANDOFF_HELLO as xo, readBeacon as xr, readEnrollGrant as xs, DID_FILE_MAX_BYTES as xt, sealGroupTyping as y, STATUS_CARD_LIMITS as ya, createIdentity as yc, identityRevocationValue as yi, pubkyProofPath as yn, HANDOFF_DONE as yo, pickHubs as yr, enrollHelloFrame as ys, verifyAtprotoRecordProof as yt, GROUP_REACTED_FRAME as z, canonicalJson as za, safeBlobType as zi, engineText as zn, handoffHelloFrame as zo, pinNumberHolds as zr, deviceLinkPairing as zs, staticDidDocument as zt };
|
|
26575
28558
|
|
|
26576
|
-
//# sourceMappingURL=src-
|
|
28559
|
+
//# sourceMappingURL=src-C6qQYrAH.js.map
|