@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.
Files changed (46) hide show
  1. package/README.md +7 -2
  2. package/dist/assets/argon2id-088uQfnH.js.map +1 -1
  3. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  4. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  5. package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -0
  6. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  7. package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
  8. package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
  9. package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
  10. package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
  11. package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
  12. package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
  13. package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
  14. package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
  15. package/dist/assets/{hyperdhtRelay-CHMS7_KO.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
  16. package/dist/assets/{hyperdhtRelay-CHMS7_KO.js.map → hyperdhtRelay-w9cwVqGZ.js.map} +1 -1
  17. package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
  18. package/dist/assets/idb-BIdus0WX.js.map +1 -0
  19. package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
  20. package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
  21. package/dist/assets/legacy_ciphers-JJOu0JXL.js.map +1 -1
  22. package/dist/assets/{mainline-B0UTU0-4.js → mainline-BabMhm8L.js} +2 -2
  23. package/dist/assets/{mainline-B0UTU0-4.js.map → mainline-BabMhm8L.js.map} +1 -1
  24. package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -1
  25. package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -1
  26. package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -1
  27. package/dist/assets/openpgp-BBFr-zHQ.js.map +1 -1
  28. package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
  29. package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
  30. package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
  31. package/dist/assets/{server-9AM0ta6q.js → server-TamKsfmC.js} +1601 -262
  32. package/dist/assets/server-TamKsfmC.js.map +1 -0
  33. package/dist/assets/sha512-BYId-G-T.js.map +1 -1
  34. package/dist/assets/{src-C_E99PGx.js → src-C6qQYrAH.js} +2190 -207
  35. package/dist/assets/src-C6qQYrAH.js.map +1 -0
  36. package/dist/assets/storage-Bya5b2Xt.js.map +1 -1
  37. package/dist/assets/tree-store-N362nuzZ.js.map +1 -1
  38. package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -1
  39. package/dist/assets/{web-OUQl4Q7L.js → web-DwgdhfcY.js} +16 -16
  40. package/dist/assets/{web-OUQl4Q7L.js.map → web-DwgdhfcY.js.map} +1 -1
  41. package/dist/ghostly.mjs +905 -29
  42. package/dist/ghostly.mjs.map +1 -1
  43. package/package.json +7 -7
  44. package/dist/assets/idb-Co2lObpU.js.map +0 -1
  45. package/dist/assets/server-9AM0ta6q.js.map +0 -1
  46. 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$2(value) {
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$2(turn), toKey, h);
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$2(fields.turn), OFFSET.turn);
1989
- body.set(u32$2(fields.rev).subarray(1), OFFSET.rev);
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$2(a, b) {
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$2(a.record.instance, b.record.instance) || compareBytes$2(a.record.body, b.record.body) || compareBytes$2(a.payload, b.payload);
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, ICE_SETTLE_MS);
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
- async connect() {
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
- this.setState("offering");
6125
- this.options.setFastPoll(true, true);
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.reset();
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 evidence = Date.now() - this.connectingSince >= 5e3 ? this.directEvidence() : null;
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, { stallMs: GATHER_STALL_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$2(button) ? cardLine(button.label, Infinity) : void 0);
6845
- const ids = buttons.map((button) => isObject$2(button) && typeof button.id === "string" ? button.id : void 0);
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
- var isObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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$2(entry)) continue;
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$2(raw)) return void 0;
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$2(raw) || statusCardBytes(raw) > STATUS_CARD_LIMITS.bytes) return void 0;
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$2(raw.pr)) {
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$2(entry)) continue;
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$2(entry) || typeof entry.id !== "string" || !BUTTON_ID.test(entry.id) || seen.has(entry.id)) continue;
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$2(raw)) return { error: "A card is an object" };
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 buttons" };
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$2(link)) {
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 === "buttons") {
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$2(button)) {
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$2(raw.pr)) errors.push("pr is an object");
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$2(item)) {
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$2(value)) return errors.push(`${field} is an object`);
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 = [...name.replace(INVISIBLE, "").replace(/[/\\:]/g, "").replace(/^[\s.]+/, "")].slice(0, MAX_NAME_LENGTH).join("").trim();
7976
- if (WINDOWS_DEVICE.test(clean)) return [..."_" + clean].slice(0, MAX_NAME_LENGTH).join("");
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) this.failed = "";
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.unreached(target, error, error);
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.options.state(void 0, choices[0]);
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
- this.unreached(target, "The transport change failed. Retry or choose another transport.");
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") break;
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
- 5e3,
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$1 = utf8Encode("GHLD");
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$1, new Uint8Array([VERSION]), nonce, box);
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$1.length + 1 + NONCE$2 + 16 + 4 || !MAGIC$1.every((b, i) => bytes[i] === b)) throw new HoldRefusedError("format", "Not a held item");
11841
- if (bytes[MAGIC$1.length] !== VERSION) throw new HoldRefusedError("format", "Held item from a newer Ghostly");
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$1.length + 1, MAGIC$1.length + 1 + NONCE$2)).decrypt(bytes.subarray(MAGIC$1.length + 1 + NONCE$2));
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/webPush.ts
12531
- var WebPushError = class extends Error {};
12532
- var RECORD_SIZE = 4096;
12533
- /** Push services refuse a token valid for more than 24 hours; 12 leaves room for skewed clocks. */
12534
- var TOKEN_SECONDS = 43200;
12535
- function point(value, what) {
12536
- let bytes;
12537
- try {
12538
- bytes = fromBase64Url(value);
12539
- } catch {
12540
- throw new WebPushError(`${what} is not base64url`);
12541
- }
12542
- if (bytes.length !== 65 || bytes[0] !== 4) throw new WebPushError(`${what} is not an uncompressed P-256 key`);
12543
- try {
12544
- p256.Point.fromBytes(bytes);
12545
- } catch {
12546
- throw new WebPushError(`${what} is not a point on P-256`);
12547
- }
12548
- return bytes;
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
- /** Whether a VAPID key pair is well formed and its halves belong together. */
12551
- function vapidKeysMatch(keys) {
12552
- try {
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 push services a browser subscribes with: Google (Chrome, Edge on Android, Brave, Opera), Apple (Safari), Mozilla
12561
- * (Firefox), Microsoft (Edge on Windows). The same list as the push relay's (infra/services/push-relay/relay.mjs).
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 checkPushEndpoint(endpoint) {
12574
- let url;
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
- url = new URL(endpoint);
13118
+ json = JSON.stringify(value);
12577
13119
  } catch {
12578
- throw new WebPushError("The push endpoint is not a URL");
13120
+ return Infinity;
12579
13121
  }
12580
- if (url.protocol !== "https:") throw new WebPushError("The push endpoint is not https");
12581
- if (url.username || url.password) throw new WebPushError("The push endpoint carries credentials");
12582
- if (url.port) throw new WebPushError("The push endpoint names a port");
12583
- const host = url.hostname.toLowerCase();
12584
- if (!PUSH_SERVICE_HOSTS.some((pattern) => pattern.test(host))) throw new WebPushError("The push endpoint is not a push service");
12585
- if (endpoint.length > 2048) throw new WebPushError("The push endpoint is too long");
12586
- return url;
12587
- }
12588
- /** `Authorization: vapid t=<JWT>, k=<public key>` for a request to `endpoint` (RFC 8292). */
12589
- function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
12590
- const audience = checkPushEndpoint(endpoint).origin;
12591
- const encode = (value) => toBase64Url(utf8Encode(JSON.stringify(value)));
12592
- const unsigned = `${encode({
12593
- typ: "JWT",
12594
- alg: "ES256"
12595
- })}.${encode({
12596
- aud: audience,
12597
- exp: Math.floor(now / 1e3) + TOKEN_SECONDS,
12598
- sub: subject
12599
- })}`;
12600
- return `vapid t=${unsigned}.${toBase64Url(p256.sign(utf8Encode(unsigned), fromBase64Url(keys.privateKey)))}, k=${keys.publicKey}`;
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
- /** This side's word: when to say `start` again, and whether a `stop` is owed. One per session. */
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
- /** The person stopped (cleared the text, sent it, left the chat): a `stop`, only when a `start` is standing. */
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
- /** The WebRTC offer this side's dial made last, with fallback allowed: what a transport that comes up meanwhile joins. */
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
- if (!this.dialing) this.session.ensureAdvertised();
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) this.dataLink.signalWentOut();
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 === "answering") this.answeringEpoch = this.connectionEpoch;
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) this.attemptEnded();
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") this.dht?.expect();
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 === "offering") this.disconnect();
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) this.resuming = void 0;
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
- if (chosen && chosen === this.resuming && ranked.includes(chosen)) this.resuming = void 0;
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 offeredAt = Date.now();
16672
- this.offered = fallback ? {
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
- await this.dataLink.connect();
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
- const frame = typing ? this.supportsTyping ? this.typingSender.typing(activity) : null : this.typingSender.stopped();
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") && !this.supportsTyping) {
17613
- this.typingSender.reset();
17614
- this.typingReceiver.clear();
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.dataLink.connect().catch(done.reject);
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 (!this.channel) {
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$1 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
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$1(body) && body.ghostly === 1 && body.did === did && body.statement === statement;
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$1(jwk)) throw new DidError("That JWK is not an object");
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$1(jwk)) throw new DidError("That did:jwk does not hold a JSON Web Key");
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$1(raw)) throw new DidError("The DID document is not a JSON object");
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$1(vm)) {
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$1(s)) continue;
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$1(endpoint)) continue;
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$1(header)) throw new DidError("The JWS header is not a JSON object");
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$1 = (n) => {
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$1(tx.version)];
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$1(i.vout), varBytes(i.scriptSig), u32$1(i.sequence));
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$1(tx.lockTime));
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$1(tx.version), sha256d(...tx.inputs.map((i) => concatBytes$1(i.txid, u32$1(i.vout)))), sha256d(...tx.inputs.map((i) => u32$1(i.sequence))), input.txid, u32$1(input.vout), varBytes(scriptCode), u64(0n), u32$1(input.sequence), sha256d(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script)))), u32$1(tx.lockTime), u32$1(hashType));
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$1(tx.version), u32$1(tx.lockTime), sha256(concatBytes$1(input.txid, u32$1(input.vout))), sha256(u64(0n)), sha256(varBytes(scriptPubKey)), sha256(u32$1(input.sequence)), sha256(concatBytes$1(...tx.outputs.map((o) => concatBytes$1(u64(o.value), varBytes(o.script))))), Uint8Array.of(0), u32$1(0)));
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$1(hashType));
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
- export { checkAtprotoProofRecord as $, filePartName as $a, BUTTON_ID as $i, freshHubs as $n, createChatInvite as $o, IdentityExchange as $r, randomBytes as $s, isPubkyKey as $t, GROUP_EDIT_TEXT_BYTES as A, RTC_CONFIG as Aa, voicePeaksOf as Ai, findEndpoint as An, readSetUpdate as Ao, newWakeToken as Ar, RelayTransport as As, encodeDidDhtPacket as At, sameSshKey as B, LEGACY_SERVICES as Ba, GROUP_REACTION_FRAME as Bi, SIGNAL_FRESH_MS as Bn, enrollCancelReason as Bo, HOLD_LIMITS as Br, createRelayPayload as Bs, USDT_PUBLIC_RPC as Bt, groupWakes as C, formatLocalTarget as Ca, sanitizeFileName as Ci, resolveLightningDestination as Cn, readHandoffHello as Co, nextPinNumber as Cr, nextTurnPosition as Cs, parseDidSignaturePaste as Ct, GroupSession as D, decodeControl as Da, baseMime as Di, ENDPOINT as Dn, sealHandoffPiece as Do, WAKE_CALL_INTERVAL_MS as Dr, turnKeys as Ds, didDhtDocument as Dt, GROUP_READ_NOTE as E, LIMITS as Ea, VOICE_LIMITS as Ei, decodeBolt11 as En, readHandoffVerified as Eo, readPin as Er, signTurnRelease as Es, strictBase64Url as Et, SSHSIG_MAX_ARMOR as F, extractParamsFromSdp as Fa, dhtEditId as Fi, INVITE_TAKEN as Fn, ENROLL_DONE as Fo, vapidKeysMatch as Fr, budgetRetryMs as Fs, ONCHAIN_PROVIDER as Ft, decodeAnyBitcoinAddress as G, HANDOFF_HAVE as Ga, reactionEmoji as Gi, MESH_HUBS as Gn, enrollProofFrame as Go, CapsExchange as Gr, identityFromSeed as Gs, validatePaymentTarget as Gt, verifySshSignature as H, HANDOFF_ACK as Ha, ReactionWindow as Hi, SIGNAL_REPLY_MS as Hn, enrollDoneFrame as Ho, HoldRefusedError as Hr, openRelayPayload as Hs, assertTokenUnits as Ht, SSHSIG_NAMESPACE as I, parseCallSignal as Ia, parseLinkPreview as Ii, LIVENESS_PING_MS as In, ENROLL_GRANT as Io, BUTTONS_CAPABILITY as Ir, isDiscoveryBudgetError as Is, PaymentPreflightError as It, ATPROTO_PROOF_COLLECTION as J, HANDOFF_PAKE as Ja, validReactionNumber as Ji, pickMeshHubs as Jn, verifyEnrollProof as Jo, dialDescriptors as Jr, verify as Js, SPARK_PROVIDER as Jt, ATPROTO_DECLARED_SCOPE as K, HANDOFF_HELLO as Ka, reactionIsNewer as Ki, mayBeHub as Kn, readEnrollGrant as Ko, DHT_TEXT_CAPABILITY as Kr, identityFromSeedB64 as Ks, walletNetworkOf as Kt, SshSigError as L, signalHasVideo as La, forwardedAgain as Li, pairedMessageFrame as Ln, ENROLL_HELLO as Lo, EDIT_CAPABILITY as Lr, withRequestOptions as Ls, SEPOLIA_TEST_USDT as Lt, parseCommunityEdit as M, buildSdpFromSignal as Ma, EDIT_BUFFER_MS as Mi, parseCashuRequestPayload as Mn, setUpdateFrame as Mo, relayRequest as Mr, normalizeRelayUrl as Ms, signDidDhtPacket as Mt, mentionsMember as N, callRtcConfig as Na, EDIT_RATE_WINDOW_MS as Ni, parseFedimintRequestPayload as Nn, signTombstone as No, wakeRequest as Nr, setLinkTraceSink as Ns, ETHEREUM_USDT as Nt, COMMUNITY_EDIT_FRAME as O, encodeControl as Oa, fileMessageText as Oi, cashuRequestPayload as On, SET_ACK as Oo, WakeLimiter as Or, turnName as Os, didDhtFromPublicKey as Ot, groupName as P, callSignalHeardAt as Pa, RateWindow as Pi, GhostLink as Pn, verifySetUpdate as Po, checkPushEndpoint as Pr, traceLink as Ps, EVM_TEST_CHAINS as Pt, atprotoProofRkey as Q, HANDOFF_VERIFIED as Qa, replySnippet as Qi, doorHubs as Qn, OwnInviteError as Qo, IdentityCheckUnavailable as Qr, fromBase64Url as Qs, PUBKY_PROOF_MAX_BYTES as Qt, parseSshPublicKey as R, emptyLinkRecords as Ra, readForwarded as Ri, CarriedTransport as Rn, ENROLL_PROOF as Ro, HOLD_CAPABILITY as Rr, PacketTooLargeError as Rs, SEPOLIA_TEST_USDT_FAUCET as Rt, groupWakeFrame as S, GhostlyHttpError as Sa, safeBlobType as Si, requestLnurlInvoice as Sn, readHandoffHave as So, mayPin as Sr, firstDeviceSetSecret as Ss, parseDidDocument as St, GROUP_REACTED_FRAME as T, webLocalFetch as Ta, PLAYABLE_AUDIO as Ti, engineText as Tn, readHandoffTurnFrame as To, pinNumberHolds as Tr, signTurnPacket as Ts, staticDidDocument as Tt, verifyBitcoinMessage as U, HANDOFF_CANCEL as Ua, nextReactionNumber as Ui, groupSignalFrame as Un, enrollGrantFrame as Uo, newMailbox as Ur, parseRelayPayload as Us, assertWholeSats as Ut, sshSignCommand as V, serviceIdFromName as Va, REACTION_LIMITS as Vi, SIGNAL_HOLD_MS as Vn, enrollDigits as Vo, HoldKeys as Vr, newerPacket as Vs, WALLET_NETWORKS as Vt, BitcoinAddressError as W, HANDOFF_DONE as Wa, queueReaction as Wi, readGroupSignal as Wn, enrollHelloFrame as Wo, readManifest as Wr, createIdentity as Ws, formatPaymentAmount as Wt, atprotoDidMethod as X, HANDOFF_REQUEST as Xa, pairedReplyAuthor as Xi, beaconKeys as Xn, deviceInviteJoinerParams as Xo, IDENTITY_MAX_VALIDITY as Xr, sanitizeAvatar as Xs, sparkInvoiceDetails as Xt, ATPROTO_PROOF_SCOPE as Y, HANDOFF_PIECE_BYTES as Ya, wireReaction as Yi, COMMUNITY_TOPOLOGY as Yn, createDeviceInvite as Yo, IDENTITY_MAX_EVIDENCE as Yr, MAX_AVATAR_LENGTH as Ys, sparkAddressKind as Yt, atprotoProofRecord as Z, HANDOFF_TIMINGS as Za, readReply as Zi, beaconRecords as Zn, readDeviceInvite as Zo, IDENTITY_REVOCATION_LABEL as Zr, bytesEqual as Zs, isBitcoinAddress as Zt, knockRecords as _, edgeParams as _a, peekDhtMailbox as _i, recordsFromTxt as _n, handoffVersions as _o, TypingSender as _r, TURN_SETTLE_MS as _s, didWebFileNames as _t, sealGroupTyping as a, sameButtonText as aa, receivedIdentityStatus as ai, pubkyProofPath as an, handoffContext as ao, pickHubs as ar, ENROLL_CAPABILITY as as, verifyAtprotoRecordProof as at, GROUP_WAKE_FRAME as b, RELAY_POLL_INTERVALS as ba, parseImageMeta as bi, txtValue as bn, readHandoffCancel as bo, GROUP_PIN_FRAME as br, TURN_LAST_SEQUENCE as bs, normalizeDid as bt, GROUP_READ_NOTE_COMMUNITY as c, claimedTime as ca, PeerProofs as ci, DOMAIN_FILE_MAX_BYTES as cn, handoffDigest as co, readBeaconHead as cr, deviceEchoNonce as cs, DID_JWS_MAX_LENGTH as ct, encodeCommunityLink as d, shownTime as da, TRANSPORTS as di, domainTxtRecord as dn, handoffManifestFrames as do, ChatFiles as dr, devicePingFrame as ds, checkDidSignature as dt, ITEM_STATES as ea, toBase64Url as ec, boundVerifiedIdentity as ei, isPubkyProofFolder as en, handoffAttemptAllowed as eo, lobbyKeys as er, decodeInviteCode as es, isAtprotoDid as et, KNOCK_TTL_MS as f, GROUP_MEMBER_CAP as fa, automaticTransport as fi, domainWellKnownFile as fn, handoffOfferFrame as fo, FILE_LIMITS as fr, newDeviceSetSecret as fs, didGhostlyServiceEntry as ft, knockIdentity as g, verifyCommitSignature as ga, DHT_MESSAGE_TTL as gi, normalizeDomain as gn, handoffStreamKey as go, TYPING_REFRESH_MS as gr, TURN_RAISE_READ_TIMEOUT_MS as gs, didWebFile as gt, entryParams as h, rosterHas as ha, DHT_FILE_TOO_LARGE as hi, nip05RootKey as hn, handoffRetryAfter as ho, TYPING_KINDS as hr, TURN_PUT_WINDOW_MS as hs, didWebDocumentUrl as ht, openGroupTyping as i, checkStatusCard as ia, newIdentityBinding as ii, pubkyProofFile as in, handoffCancelFrame as io, newerHead as ir, DEVICES_CAPABILITY as is, AtprotoProofError as it, carryMentions as j, answersOffer as ja, parseVideoMeta as ji, isFederationId as jn, setAckFrame as jo, parseWakeFrame as jr, currentRelays as js, resolveDidDht as jt, GROUP_EDIT_FRAME as k, CALL_SIGNAL_MAX_AGE_MS as ka, parseVoiceMeta as ki, fedimintRequestPayload as kn, SET_UPDATE as ko, deviceWakeRequest as kr, DEFAULT_RELAYS as ks, encodeDidDhtDeactivated as kt, communityHasFrame as l, heardTime as la, emptyProofLedger as li, decodeDnsResponse as ln, handoffEphemeral as lo, readLobby as lr, deviceKeyZ32 as ls, DidError as lt, encodeGroupEntryLink as m, MEMBER_KEY as ma, irohRelayUrl as mi, isPublicAddress as mn, handoffReleaseFrame as mo, transferStalled as mr, webCryptoSigner as ms, didSignedMessages as mt, GroupTypingBoard as n, STATUS_CARD_LIMITS as na, utf8Decode as nc, identityRevocationValue as ni, normalizePubkyKey as nn, handoffAttemptSucceeded as no, mergeBeacon as nr, inviteLink as ns, parseAtprotoDidDocument as nt, COMMUNITY_LIMITS as o, statusCardText as oa, revokesIdentity as oi, homeserverWebEndpoint as on, handoffContextProof as oo, rankHubs as or, HANDOFF_CAPABILITY as os, DID_DOCUMENT_MAX_BYTES as ot, decodeGroupEntryLink as p, MAX_GROUP_CHAIN as pa, irohDescriptor as pi, encodeDnsQuery as pn, handoffPakeFrame as po, transferEnded as pr, seedSigner as ps, didJwsAlg as pt, ATPROTO_FULL_SCOPE as q, HANDOFF_OFFER as qa, readReaction as qi, meshHubs as qn, readEnrollHello as qo, capsDescriptors as qr, publicKeyFromZ32 as qs, SPARK_NETWORKS as qt, groupTypingEpoch as r, buttonLabelClash as ra, utf8Encode as rc, identityStatement as ri, pubkyProofCapability as rn, handoffBusyFrame as ro, mergeLobby as rr, inviteOwnership as rs, shortAtprotoDid as rt, CommunitySession as s, arrivalKey as sa, lastSharedWithMe as si, pubkyHomeserverOf as sn, handoffDataFrame as so, readBeacon as sr, deviceEchoFrame as ss, DID_FILE_MAX_BYTES as st, GROUP_TYPING_FRAME as t, RUN_RESULTS as ta, toZ32 as tc, emptyIdentityLedger as ti, newPubkyProofFolder as tn, handoffAttemptFailed as to, lobbyRecords as tr, encodeInviteCode as ts, normalizeAtprotoHandle as tt, decodeCommunityLink as u, receivedTimestamp as ua, validExternalKey as ui, dnsProofName as un, handoffHelloFrame as uo, shouldBeHub as ur, deviceLinkPairing as us, checkDidJws as ut, mergeKnocks as v, DirectPathWatch as va, DHT_FILE_CAPABILITY as vi, recordsFromWellKnown as vn, newHandoffId as vo, sanitizeTypingStatus as vr, classifyTurnRead as vs, didWebFileUrl as vt, parseGroupWakeFrame as w, parseLocalTarget as wa, sanitizeMime as wi, engineError as wn, readHandoffPake as wo, pinIsNewer as wr, readTurnPacket as ws, signingKeys as wt, GroupWakeLimiter as x, presenceSeenAt as xa, readImageMeta as xi, wellKnownUrl as xn, readHandoffDone as xo, PIN_LIMITS as xr, TURN_MAX as xs, parseDid as xt, readKnocks as y, EXPECT_PEER_MS as ya, dhtFileId as yi, recordsName as yn, readHandoffAck as yo, typingActivity as yr, turnPutSummary as ys, didWebHost as yt, parseSshSignature as z, HTTP_SERVICE_PROTO as za, COMMUNITY_REACTION_FRAME as zi, GROUP_SIGNAL_FRAME as zn, enrollCancelFrame as zo, HOLD_DATED_BACK_MS as zr, RELAY_PAYLOAD_MAX_BYTES as zs, TEST_USDT_FAUCET_AMOUNT as zt };
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-C_E99PGx.js.map
28559
+ //# sourceMappingURL=src-C6qQYrAH.js.map