@ghostlytools/cli 1.2.4 → 1.3.1

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 (100) hide show
  1. package/README.md +25 -9
  2. package/dist/assets/api-Dq2QXQDk.js +4172 -0
  3. package/dist/assets/api-Dq2QXQDk.js.map +1 -0
  4. package/dist/assets/api-TiB5QSG9.js +3 -0
  5. package/dist/assets/apiKit-CA2x5GJF.js +200 -0
  6. package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
  7. package/dist/assets/apps-BxV9i_Y4.js +786 -0
  8. package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
  9. package/dist/assets/apps-C-T8jeji.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
  12. package/dist/assets/backup-Bugnn3Ue.js +2 -0
  13. package/dist/assets/backup-CnMN2oUK.js +731 -0
  14. package/dist/assets/backup-CnMN2oUK.js.map +1 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoind-CZchoh43.js +349 -0
  17. package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
  18. package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
  19. package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
  20. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  21. package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
  22. package/dist/assets/buttons-CvNBzh2S.js +496 -0
  23. package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
  24. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
  25. package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
  26. package/dist/assets/envelope-WjingYSc.js +90 -0
  27. package/dist/assets/envelope-WjingYSc.js.map +1 -0
  28. package/dist/assets/errors-DO5z6h_l.js +105 -0
  29. package/dist/assets/errors-DO5z6h_l.js.map +1 -0
  30. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  31. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  32. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  33. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  34. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  35. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  36. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  37. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  38. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  39. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  40. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  41. package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
  42. package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
  43. package/dist/assets/host-CWt-jtqz.js +4673 -0
  44. package/dist/assets/host-CWt-jtqz.js.map +1 -0
  45. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  46. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  47. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
  48. package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
  49. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  50. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  51. package/dist/assets/irohWeb-4oChIOIp.js +544 -0
  52. package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
  53. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  54. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  55. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  56. package/dist/assets/main-DbqcdShN.js +4409 -0
  57. package/dist/assets/main-DbqcdShN.js.map +1 -0
  58. package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
  59. package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
  60. package/dist/assets/mints-BrlY773j.js +74 -0
  61. package/dist/assets/mints-BrlY773j.js.map +1 -0
  62. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  63. package/dist/assets/networkReason-CetKkTPo.js +154 -0
  64. package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
  65. package/dist/assets/nwc-CgkqyvsE.js +612 -0
  66. package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
  67. package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
  68. package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
  69. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  70. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  71. package/dist/assets/profiles-BHc9pEYg.js +139 -0
  72. package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
  73. package/dist/assets/registry-DlAKO0Fy.js +3793 -0
  74. package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
  75. package/dist/assets/registry-OBJBnHVQ.js +2 -0
  76. package/dist/assets/resolve-CouHhCpJ.js +591 -0
  77. package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
  78. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  79. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  80. package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
  81. package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
  82. package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
  83. package/dist/assets/src-DDzSERyc.js.map +1 -0
  84. package/dist/assets/turnRefs-B87dtrHM.js +323 -0
  85. package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
  86. package/dist/assets/types-C4_KafL_.js +122 -0
  87. package/dist/assets/types-C4_KafL_.js.map +1 -0
  88. package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
  89. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
  90. package/dist/ghostly.mjs +4 -22077
  91. package/dist/ghostly.mjs.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  94. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  95. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  96. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  97. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  98. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  99. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  100. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -1,23 +1,28 @@
1
- import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
- import { $ as parseCommunityEdit, $a as receivedTimestamp, $c as identityFromSeed, $o as HANDOFF_PAKE, $r as vapidKeysMatch, $s as verifyEnrollProof, $t as signDidDhtPacket, A as encodeCommunityLink, Aa as reactionEmoji, Ac as signTurnPacket, Ai as DHT_TEXT_CAPABILITY, Ao as webLocalFetch, Ar as FILE_LIMITS, As as readHandoffTurnFrame, B as GroupWakeLimiter, Bc as normalizeRelayUrl, Bi as identityRevocationValue, Bn as INVITE_TAKEN, Br as mayPin, C as openGroupTyping, Ca as readForwarded, Cc as classifyTurnRead, Co as DHT_POLL_INTERVALS, Cr as pickHubs, Cs as newHandoffId, D as GROUP_READ_NOTE_COMMUNITY, Da as ReactionWindow, Dc as firstDeviceSetSecret, Do as GhostlyHttpError, Dr as readLobby, Ds as readHandoffHave, E as CommunitySession, Ea as REACTION_LIMITS, Ec as TURN_MAX, Eo as presenceSeenAt, Er as readBeaconHead, Es as readHandoffDone, F as knockIdentity, Fa as pairedReplyAuthor, Fc as turnStatedVerifier, Fi as IDENTITY_REVOCATION_LABEL, Fn as findEndpoint, Fo as RTC_CONFIG, Fr as TypingSender, Fs as readSetUpdate, Gc as withRequestOptions, Gi as lastSharedWithMe, Gn as GROUP_FILE_LIMITS, Go as LEGACY_SERVICES, Gr as WAKE_CALL_INTERVAL_MS, Gs as enrollCancelReason, H as groupWakes, Hc as traceLink, Hi as newIdentityBinding, Hn as pairedMessageFrame, Hr as pinIsNewer, I as knockRecords, Ia as readReply, Ic as turnVerifierMark, In as isFederationId, Is as setAckFrame, J as frameBytes, Ja as sameButtonText, Jc as createRelayPayload, Ji as validExternalKey, Jo as HANDOFF_CANCEL, Jr as newWakeToken, Js as enrollGrantFrame, Jt as didDhtDocument, K as GROUP_READ_NOTE, Kc as PacketTooLargeError, Ki as PeerProofs, Ko as serviceIdFromName, Kr as WakeLimiter, Ks as enrollDigits, L as mergeKnocks, La as replySnippet, Lc as DEFAULT_RELAYS, Li as IdentityExchange, Ln as parseCashuRequestPayload, Lr as typingActivity, Ls as setUpdateFrame, M as decodeGroupEntryLink, Ma as readReaction, Mc as turnInstance, Mi as dialDescriptors, Mn as ENDPOINT, Mo as decodeControl, Mr as transferStalled, Ms as sealHandoffPiece, N as encodeGroupEntryLink, Na as validReactionNumber, Nc as turnKeys, Nn as cashuRequestPayload, No as encodeControl, O as communityHasFrame, Oa as nextReactionNumber, Oc as nextTurnPosition, Oo as formatLocalTarget, Or as shouldBeHub, Os as readHandoffHello, P as entryParams, Pa as wireReaction, Pc as turnName, Pn as fedimintRequestPayload, Q as carryMentions, Qa as heardTime, Qc as createIdentity, Qn as readGroupFileMeta, Qo as HANDOFF_OFFER, Qr as checkPushEndpoint, Qs as readEnrollHello, R as readKnocks, Ra as BUTTON_ID, Rc as RelayTransport, Rn as parseFedimintRequestPayload, Rs as signTombstone, S as groupTypingEpoch, Sa as forwardedAgain, Sc as TURN_SETTLE_MS, So as DirectPathWatch, Sr as newerHead, Ss as handoffVersions, St as normalizeAtprotoHandle, T as COMMUNITY_LIMITS, Ta as GROUP_REACTION_FRAME, Tc as TURN_LAST_SEQUENCE, To as RELAY_POLL_INTERVALS, Tr as readBeacon, Ts as readHandoffCancel, U as parseGroupWakeFrame, Ua as STATUS_CARD_LIMITS, Uc as budgetRetryMs, Ui as receivedIdentityStatus, Un as engineError, Uo as emptyLinkRecords, Ur as pinNumberHolds, V as groupWakeFrame, Vi as identityStatement, Vn as LIVENESS_PING_MS, Vo as parseCallSignal, Vr as nextPinNumber, W as GROUP_LIMITS, Wc as isDiscoveryBudgetError, Wi as revokesIdentity, Wn as engineText, Wo as HTTP_SERVICE_PROTO, Wr as readPin, Ws as enrollCancelFrame, Xa as arrivalKey, Xi as automaticTransport, Xn as groupFileFallback, Xo as HANDOFF_HAVE, Xr as relayRequest, Xs as enrollProofFrame, Xt as encodeDidDhtDeactivated, Ya as statusCardText, Yi as TRANSPORTS, Yo as HANDOFF_DONE, Yr as parseWakeFrame, Ys as enrollHelloFrame, Yt as didDhtFromPublicKey, Z as GROUP_EDIT_TEXT_BYTES, Za as claimedTime, Zo as HANDOFF_HELLO, Zr as wakeRequest, Zs as readEnrollGrant, Zt as encodeDidDhtPacket, _a as EDIT_BUFFER_MS, _c as newDeviceSetSecret, _n as sparkInvoiceDetails, _o as MEMBER_KEY, _r as freshHubs, _s as handoffOfferFrame, a as planAppUpdate, ac as decodeInviteCode, ai as STATUS_CARD_CAPABILITY, al as bytesEqual, as as handoffAttemptAllowed, ba as dhtEditId, bn as isPubkyKey, bo as verifyCommitSignature, br as mergeBeacon, bs as handoffRetryAfter, ca as sanitizeFileName, cc as inviteOwnership, ci as HOLD_DATED_BACK_MS, cl as toBase64Url, cn as WALLET_NETWORKS, co as readAppRevocation, cr as readGroupSignal, cs as handoffBusyFrame, d as APP_BUNDLE_LIMITS, dc as HANDOFF_CAPABILITY, di as HoldRefusedError, dl as utf8Encode, dn as formatPaymentAmount, dr as meshHubs, ds as handoffContextProof, ea as DHT_MESSAGE_TTL, ec as createDeviceInvite, ei as chatAppId, el as identityFromSeedB64, en as ETHEREUM_USDT, eo as shownTime, es as HANDOFF_PIECE_BYTES, et as mentionsMember, f as APP_BUNDLE_MAGIC, fc as deviceEchoFrame, fi as isHoldUrl, fn as validatePaymentTarget, fo as canonicalJson, fr as pickMeshHubs, fs as handoffDataFrame, ga as parseVideoMeta, gc as devicePingFrame, gn as sparkAddressKind, gr as doorHubs, gs as handoffManifestFrames, h as appViewOf, hc as deviceLinkPairing, hn as SPARK_PROVIDER, ho as GROUP_MEMBER_CAP, hr as beaconRecords, hs as handoffHelloFrame, i as checkAppBeforeRun, ia as parseImageMeta, ic as createChatInvite, ii as EDIT_CAPABILITY, il as sanitizeAvatar, in as SEPOLIA_TEST_USDT, io as appRef, is as filePartName, ja as reactionIsNewer, jc as signTurnRelease, ji as capsDescriptors, jn as decodeBolt11, jo as LIMITS, jr as transferEnded, js as readHandoffVerified, k as decodeCommunityLink, ka as queueReaction, kc as readTurnPacket, ki as CapsExchange, ko as parseLocalTarget, kr as ChatFiles, ks as readHandoffPake, l as readAppStoreSteps, la as sanitizeMime, lc as DEVICES_CAPABILITY, li as HOLD_LIMITS, ll as toZ32, ln as assertTokenUnits, lr as MESH_HUBS, ls as handoffCancelFrame, m as appDigest, ma as parseVoiceMeta, mc as deviceKeyZ32, mi as readManifest, mn as SPARK_NETWORKS, mo as readCanonicalJson, mr as beaconKeys, ms as handoffEphemeral, n as appStoreDecision, na as DHT_FILE_CAPABILITY, nc as readDeviceInvite, ni as APPS_CAPABILITY, nl as verify, nn as ONCHAIN_PROVIDER, no as APP_STATEMENT_LIMITS, nr as CarriedTransport, ns as HANDOFF_TIMINGS, o as APP_STORE_LIMITS, oa as formatFileSize, oc as encodeInviteCode, oi as UPGRADE_CAPABILITY, ol as fromBase64Url, on as TEST_USDT_FAUCET_AMOUNT, oo as isAppKey, os as handoffAttemptFailed, pa as fileMessageText, pc as deviceEchoNonce, pi as newMailbox, pn as walletNetworkOf, pr as COMMUNITY_TOPOLOGY, ps as handoffDigest, q as GroupSession, qa as checkStatusCard, qi as emptyProofLedger, qo as HANDOFF_ACK, qr as deviceWakeRequest, qs as enrollDoneFrame, r as appUpdateDecision, ra as dhtFileId, rc as OwnInviteError, ri as BUTTONS_CAPABILITY, rn as PaymentPreflightError, ro as appFingerprint, rs as HANDOFF_VERIFIED, sa as safeBlobType, si as HOLD_CAPABILITY, sl as randomBytes, sn as USDT_PUBLIC_RPC, so as isAppRef, sr as groupSignalFrame, ss as handoffAttemptSucceeded, t as appRevocationFor, ta as peekDhtMailbox, tc as deviceInviteJoinerParams, ti as isAppVersion, tl as publicKeyFromZ32, tn as EVM_TEST_CHAINS, ts as HANDOFF_REQUEST, tt as groupName, uc as ENROLL_CAPABILITY, ui as HoldKeys, ul as utf8Decode, un as assertWholeSats, ur as mayBeHub, us as handoffContext, v as isAppUrl, va as EDIT_RATE_WINDOW_MS, vc as seedSigner, vn as isBitcoinAddress, vo as commitHash, vr as lobbyKeys, vs as handoffPakeFrame, w as sealGroupTyping, wa as COMMUNITY_REACTION_FRAME, wc as turnPutSummary, wr as rankHubs, ws as readHandoffAck, x as GroupTypingBoard, xa as parseLinkPreview, xc as TURN_RAISE_READ_TIMEOUT_MS, xo as edgeParams, xr as mergeLobby, xs as handoffStreamKey, xt as isAtprotoDid, y as readAppBundle, ya as RateWindow, yc as webCryptoSigner, yo as rosterHas, yr as lobbyRecords, ys as handoffReleaseFrame, zc as currentRelays, zi as emptyIdentityLedger, zn as GhostLink, zr as PIN_LIMITS, zs as verifySetUpdate } from "./src-CbLkHfz8.js";
3
- import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-CTF6LF5B.js";
4
- import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-BcYZhNgF.js";
5
- import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
6
- import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
1
+ import { n as traceJoin } from "./joinTrace-CjRDFy85.js";
2
+ import { $c as budgetRetryMs, $i as PeerProofs, $o as HTTP_SERVICE_PROTO, $r as deviceWakeRequest, $s as enrollCancelFrame, $t as didDhtFromPublicKey, A as CommunitySession, Aa as COMMUNITY_REACTION_FRAME, Ao as DirectPathWatch, Ar as rankHubs, As as handoffStreamKey, B as knockRecords, Ba as wireReaction, Bc as signTurnPacket, Bn as isFederationId, Bo as LIMITS, Bs as readHandoffVerified, C as sha256Paced, Ca as EDIT_BUFFER_MS, Cc as deviceEchoNonce, Co as GROUP_MEMBER_CAP, Cr as freshHubs, Cs as handoffEphemeral, D as openGroupTyping, Da as parseLinkPreview, Dc as newDeviceSetSecret, Do as rosterHas, Dr as mergeLobby, Ds as handoffPakeFrame, E as groupTypingEpoch, Ea as dhtEditId, Ec as devicePingFrame, Eo as commitHash, Er as mergeBeacon, Es as handoffOfferFrame, Et as normalizeAtprotoHandle, Fa as queueReaction, Fc as TURN_LAST_SEQUENCE, Fn as decodeBolt11, Fo as presenceSeenAt, Fr as ChatFiles, Fs as readHandoffDone, G as groupWakeFrame, Gc as turnStatedVerifier, Gn as LIVENESS_PING_MS, Gr as PIN_LIMITS, Gs as setAckFrame, H as readKnocks, Ha as readReply, Hc as turnInstance, Hi as IDENTITY_REVOCATION_LABEL, Hn as parseFedimintRequestPayload, Ho as encodeControl, I as decodeGroupEntryLink, Ia as reactionEmoji, Ic as TURN_MAX, Ii as CapsExchange, In as ENDPOINT, Io as GhostlyHttpError, Ir as FILE_LIMITS, Is as readHandoffHave, J as GROUP_LIMITS, Jc as RelayTransport, Ji as identityStatement, Jn as pairedMessageFrame, Jr as pinIsNewer, Js as verifySetUpdate, K as groupWakes, Kc as turnVerifierMark, Ki as emptyIdentityLedger, Kn as PAIRED_FRAME_TOO_LARGE, Kr as mayPin, Ks as setUpdateFrame, L as encodeGroupEntryLink, La as reactionIsNewer, Lc as firstDeviceSetSecret, Li as DHT_TEXT_CAPABILITY, Ln as cashuRequestPayload, Lo as formatLocalTarget, Lr as transferEnded, Ls as readHandoffHello, M as communityHasFrame, Ma as REACTION_LIMITS, Mc as TURN_SETTLE_MS, Mo as DHT_POLL_INTERVALS, Mr as readBeaconHead, Ms as newHandoffId, N as decodeCommunityLink, Na as ReactionWindow, Nc as classifyTurnRead, Nr as readLobby, Ns as readHandoffAck, O as sealGroupTyping, Oa as forwardedAgain, Oc as seedSigner, Oo as verifyCommitSignature, Or as newerHead, Os as handoffReleaseFrame, P as encodeCommunityLink, Pa as nextReactionNumber, Pc as turnPutSummary, Po as RELAY_POLL_INTERVALS, Pr as shouldBeHub, Ps as readHandoffCancel, Q as frameBytes, Qc as traceLink, Qi as lastSharedWithMe, Qo as emptyLinkRecords, Qr as WakeLimiter, Qt as didDhtDocument, R as entryParams, Ra as readReaction, Rc as nextTurnPosition, Ri as capsDescriptors, Rn as fedimintRequestPayload, Ro as parseLocalTarget, Rr as transferStalled, Rs as readHandoffPake, S as readAppBundleAsync, Sa as parseVideoMeta, Sc as deviceEchoFrame, Sn as isBitcoinAddress, So as readCanonicalJson, Sr as doorHubs, Ss as handoffDigest, T as GroupTypingBoard, Ta as RateWindow, Tc as deviceLinkPairing, To as MEMBER_KEY, Tr as lobbyRecords, Ts as handoffManifestFrames, Tt as isAtprotoDid, Ua as replySnippet, Uc as turnKeys, Un as GhostLink, Ur as typingActivity, V as mergeKnocks, Va as pairedReplyAuthor, Vc as signTurnRelease, Vn as parseCashuRequestPayload, Vo as decodeControl, Vr as TypingSender, Vs as sealHandoffPiece, W as GroupWakeLimiter, Wa as BUTTON_ID, Wc as turnName, Wi as IdentityExchange, Wn as INVITE_TAKEN, Wo as RTC_CONFIG, Ws as readSetUpdate, X as GROUP_READ_NOTE, Xc as normalizeRelayUrl, Xi as receivedIdentityStatus, Xn as engineText, Xo as parseCallSignal, Xr as readPin, Ya as STATUS_CARD_LIMITS, Yc as currentRelays, Yi as newIdentityBinding, Yn as engineError, Yr as pinNumberHolds, Z as GroupSession, Zi as revokesIdentity, Zn as GROUP_FILE_LIMITS, Zr as WAKE_CALL_INTERVAL_MS, _c as inviteOwnership, _i as HoldKeys, _l as randomBytes, _n as walletNetworkOf, _r as meshHubs, _s as handoffBusyFrame, a as planAppUpdate, ac as enrollProofFrame, ai as vapidKeysMatch, an as EVM_TEST_CHAINS, ao as heardTime, as as HANDOFF_HAVE, at as groupName, b as pacer, ba as parseVoiceMeta, bc as ENROLL_CAPABILITY, bi as newMailbox, bl as utf8Decode, bn as sparkAddressKind, bo as canonicalJson, br as beaconKeys, bs as handoffContextProof, ca as peekDhtMailbox, cc as verifyEnrollProof, ci as isAppVersion, cl as createIdentity, cn as SEPOLIA_TEST_USDT, cr as CarriedTransport, cs as HANDOFF_PAKE, d as APP_BUNDLE_LIMITS, da as parseImageMeta, dc as readDeviceInvite, di as EDIT_CAPABILITY, dl as publicKeyFromZ32, dn as USDT_PUBLIC_RPC, do as appRef, ds as HANDOFF_TIMINGS, ea as emptyProofLedger, ec as enrollCancelReason, ei as newWakeToken, el as isDiscoveryBudgetError, en as encodeDidDhtDeactivated, eo as checkStatusCard, es as LEGACY_SERVICES, f as APP_BUNDLE_MAGIC, fc as OwnInviteError, fi as STATUS_CARD_CAPABILITY, fl as verify, fn as WALLET_NETWORKS, fs as HANDOFF_VERIFIED, g as appViewOf, gi as HOLD_LIMITS, gl as fromBase64Url, gn as validatePaymentTarget, go as readAppRevocation, gr as mayBeHub, gs as handoffAttemptSucceeded, h as appDigest, ha as sanitizeMime, hc as encodeInviteCode, hi as HOLD_DATED_BACK_MS, hl as bytesEqual, hn as formatPaymentAmount, ho as isAppRef, hr as MESH_HUBS, hs as handoffAttemptFailed, i as checkAppBeforeRun, ic as enrollHelloFrame, ii as checkPushEndpoint, il as createRelayPayload, in as ETHEREUM_USDT, io as claimedTime, ir as readGroupFileMeta, is as HANDOFF_DONE, it as mentionsMember, j as GROUP_READ_NOTE_COMMUNITY, ja as GROUP_REACTION_FRAME, jc as TURN_RAISE_READ_TIMEOUT_MS, jr as readBeacon, js as handoffVersions, k as COMMUNITY_LIMITS, ka as readForwarded, kc as webCryptoSigner, ko as edgeParams, kr as pickHubs, ks as handoffRetryAfter, l as readAppStoreSteps, la as DHT_FILE_CAPABILITY, lc as createDeviceInvite, li as APPS_CAPABILITY, ll as identityFromSeed, lo as APP_STATEMENT_LIMITS, ls as HANDOFF_PIECE_BYTES, ma as sanitizeFileName, mc as decodeInviteCode, mi as HOLD_CAPABILITY, ml as sanitizeAvatar, mn as assertWholeSats, mo as isAppKey, mr as readGroupSignal, ms as handoffAttemptAllowed, n as appStoreDecision, na as TRANSPORTS, nc as enrollDoneFrame, ni as relayRequest, nl as PacketTooLargeError, no as statusCardText, nr as groupFileFallback, ns as HANDOFF_ACK, nt as carryMentions, o as APP_STORE_LIMITS, oc as readEnrollGrant, on as ONCHAIN_PROVIDER, oo as receivedTimestamp, os as HANDOFF_HELLO, p as APP_ICON_PATH, pa as formatFileSize, pc as createChatInvite, pi as UPGRADE_CAPABILITY, pn as assertTokenUnits, po as isAppHash, pr as groupSignalFrame, ps as filePartName, q as parseGroupWakeFrame, qc as DEFAULT_RELAYS, qi as identityRevocationValue, qn as pairedMessageFits, qr as nextPinNumber, qs as signTombstone, r as appUpdateDecision, ra as automaticTransport, rc as enrollGrantFrame, ri as wakeRequest, rn as signDidDhtPacket, ro as arrivalKey, rs as HANDOFF_CANCEL, rt as parseCommunityEdit, sa as DHT_MESSAGE_TTL, sc as readEnrollHello, si as chatAppId, sn as PaymentPreflightError, so as shownTime, ss as HANDOFF_OFFER, t as appRevocationFor, ta as validExternalKey, tc as enrollDigits, ti as parseWakeFrame, tl as withRequestOptions, tn as encodeDidDhtPacket, to as sameButtonText, ts as serviceIdFromName, tt as GROUP_EDIT_TEXT_BYTES, ua as dhtFileId, uc as deviceInviteJoinerParams, ui as BUTTONS_CAPABILITY, ul as identityFromSeedB64, un as TEST_USDT_FAUCET_AMOUNT, uo as appFingerprint, us as HANDOFF_REQUEST, vi as HoldRefusedError, vl as toBase64Url, vn as SPARK_NETWORKS, vr as pickMeshHubs, vs as handoffCancelFrame, wa as EDIT_RATE_WINDOW_MS, wc as deviceKeyZ32, wn as isPubkyKey, wr as lobbyKeys, ws as handoffHelloFrame, xc as HANDOFF_CAPABILITY, xi as readManifest, xl as utf8Encode, xn as sparkInvoiceDetails, xr as beaconRecords, xs as handoffDataFrame, y as isAppUrl, ya as fileMessageText, yc as DEVICES_CAPABILITY, yi as isHoldUrl, yl as toZ32, yn as SPARK_PROVIDER, yr as COMMUNITY_TOPOLOGY, ys as handoffContext, z as knockIdentity, za as validReactionNumber, zc as readTurnPacket, zi as dialDescriptors, zn as findEndpoint, zo as webLocalFetch, zs as readHandoffTurnFrame } from "./src-DDzSERyc.js";
3
+ import { i as SourceUnreachableError, n as PROVIDER_PLATFORMS, o as isNothingSpentError, r as SourceConfigError, t as NothingSpentError, u as redact } from "./types-C4_KafL_.js";
4
+ import { t as bitcoindRpc } from "./bitcoindProvider-DGw3xBSa.js";
5
+ import { t as nwc } from "./nwcProvider-4eVz_8HP.js";
6
+ import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-B5L8wuOm.js";
7
+ import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-CetKkTPo.js";
8
+ import { $ as setSingleWriterGate, A as LightningCards, B as breezDatabaseInUse, C as FileAppender, D as streamStored, E as storedSize, F as ProviderSources, G as ModeChanged, H as dropBreezDatabase, I as sourceKey, J as networkLabel, K as ModeGate, L as CASHU_MINT_SOURCE, M as isRecoveryPhrase, N as normalizePhrase, P as lightningPaying, Q as setAwayWallets, R as cashuMint, S as WALLET_TYPES, T as removeStored, U as iceServerProblem, V as breezDatabasesFor, W as iceServerSetting, X as refuseAway, Y as awayFrom, Z as requireTurn, _ as crossNetwork, a as storedGroupFiles, b as walletInstances, c as receivedPairedReply, d as paymentAlias, f as paymentWireId, g as createTiming, h as createFailure, i as groupOfFile, j as cardsKey, k as CASHU_CARD, l as replyRef, m as assertConfirmedReal, n as groupFileId, o as groupReply, p as WALLET_NAMES, q as WrongNetworkError, r as groupFileText, s as pairedWireReply, t as GroupFileDesk, u as replyTo, v as paymentNetwork, w as readStored$1, y as paymentNetworksOf, z as breezDatabase } from "./groupFiles-IEUW3DQz.js";
9
+ import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb-4oChIOIp.js";
10
+ import { c as isTestMint, d as paysItsOwnInvoices, l as isWorthlessMint, n as DEFAULT_MINTS, o as fakesLightning, r as TEST_MINT, t as BITCOIN_INVOICE_ON_TESTNET, u as mintNetwork } from "./mints-BrlY773j.js";
11
+ import { _ as reserveAdapterIds, f as setOwnDidSource, g as registeredOnchainProviders, h as registeredLightningProviders, l as chosenResolver, n as resolveAtprotoDid, p as onAdaptersChanged, t as assertPublicServer, v as boundedIdentityFetch, y as verifyIdentity } from "./resolve-CouHhCpJ.js";
12
+ import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-Di7VZtmU.js";
13
+ import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-WjingYSc.js";
14
+ import { t as hyperdhtRelayProblem } from "./hyperdhtRelay-BT_wRxab.js";
7
15
  import { hkdf } from "@noble/hashes/hkdf.js";
8
16
  import { sha256 } from "@noble/hashes/sha2.js";
9
17
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
10
18
  import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
11
19
  import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
12
20
  import { secp256k1 } from "@noble/curves/secp256k1.js";
13
- import { wordlist } from "@scure/bip39/wordlists/english.js";
14
- import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
15
- import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
21
+ import { verifyEvent } from "nostr-tools/pure";
16
22
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
23
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
17
24
  import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
18
- import { Address, OutScript, Transaction } from "@scure/btc-signer";
19
- import * as nip04 from "nostr-tools/nip04";
20
- import * as nip44 from "nostr-tools/nip44";
25
+ import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
21
26
  /** Evidence older than this says nothing about the clock now. */
22
27
  var CLOCK_EVIDENCE_MS = 18e5;
23
28
  /** Sources kept of each kind: a profile has a few relays, and may have hundreds of contacts. */
@@ -178,9 +183,23 @@ async function migrateWalletNetworks(now = Date.now()) {
178
183
  return report;
179
184
  }
180
185
  //#endregion
186
+ //#region ../browser/src/engine/paymentAdapters/viewNews.ts
187
+ /**
188
+ * Whether a wallet's view reads otherwise after a poll: only then is the poll news to the engine, which makes the whole
189
+ * wallet view again for it. Compared as written, not by reference: a poll builds its history and expiry anew each time.
190
+ * A view that cannot be written out is news.
191
+ */
192
+ function viewNews(before, after) {
193
+ try {
194
+ return JSON.stringify(before) !== JSON.stringify(after);
195
+ } catch {
196
+ return true;
197
+ }
198
+ }
199
+ //#endregion
181
200
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
182
201
  /** The Ethereum libraries, a large part of the app: loaded once a USDT wallet is made, opened or restored, not with the engine. */
183
- var loadUsdt = () => import("./usdt-Qf4UuygS.js");
202
+ var loadUsdt = () => import("./usdt-Fj8CQO2z.js");
184
203
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
185
204
  var DEFAULT_USDT = {
186
205
  network: "ethereum",
@@ -217,6 +236,7 @@ var UsdtWallet = class {
217
236
  balance: "0",
218
237
  gasBalance: "0"
219
238
  };
239
+ /** `changed(false)`: a balance read that found nothing new; the receipts are checked, the wallet view stays. */
220
240
  constructor(network, changed) {
221
241
  this.network = network;
222
242
  this.changed = changed;
@@ -403,6 +423,7 @@ var UsdtWallet = class {
403
423
  clearTimeout(this.timer);
404
424
  const adapter = this.adapter;
405
425
  if (!adapter) return;
426
+ const before = this.view;
406
427
  try {
407
428
  const address = await this.gate.within(adapter.address()), balances = await this.gate.within(adapter.balances());
408
429
  if (adapter !== this.adapter) return;
@@ -422,7 +443,7 @@ var UsdtWallet = class {
422
443
  error: "RPC unavailable. Balance may be stale."
423
444
  };
424
445
  }
425
- this.changed();
446
+ this.changed(viewNews(before, this.view));
426
447
  if (this.adapter) this.timer = setTimeout(() => void this.refresh(), 8e3);
427
448
  }
428
449
  /** Testnet on Sepolia: 1,000 TEST-USDT from Aave's faucet, paid with this wallet's own test ETH. */
@@ -529,21 +550,6 @@ var UsdtWallet = class {
529
550
  }
530
551
  };
531
552
  //#endregion
532
- //#region ../browser/src/shared/ice.ts
533
- /**
534
- * What is wrong with a TURN/STUN server, or null. Browsers refuse to create any WebRTC connection when
535
- * one server in the list is malformed, so a bad entry here would silently cut off calls and files.
536
- */
537
- function iceServerProblem(server) {
538
- const urls = server.urls.split(/[\s,]+/).filter(Boolean);
539
- if (!urls.length) return "Enter the TURN server address";
540
- for (const url of urls) {
541
- if (!/^(stun|turns?):[^\s/?#]+(\?transport=(udp|tcp))?$/i.test(url)) return `“${url}” is not a TURN or STUN address (turn:host:port)`;
542
- if (/^turns?:/i.test(url) && (!server.username?.trim() || !server.credential?.trim())) return "A TURN server needs its username and credential";
543
- }
544
- return null;
545
- }
546
- //#endregion
547
553
  //#region ../browser/src/engine/paymentAdapters/backup.ts
548
554
  /**
549
555
  * Wallet database versions a backup may carry. 3 is what SDK 0.4.74-0.4.76 made (Ghostly 1.1.3 and earlier);
@@ -642,7 +648,7 @@ async function restoreArkDatabase(walletId, snapshot) {
642
648
  //#endregion
643
649
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
644
650
  /** The Ark SDK, a large part of the app: loaded once an Ark wallet is made, opened or restored, not with the engine. */
645
- var loadArkade = () => import("./arkade-CdnKGDbg.js");
651
+ var loadArkade = () => import("./arkade-C_qDmHnm.js");
646
652
  /** How long a Testnet wallet about to make way for another waits for its server to say what it holds. */
647
653
  var RETIRE_READ_MS = 1e4;
648
654
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
@@ -693,6 +699,7 @@ var ArkWallet = class {
693
699
  locked: true,
694
700
  balance: 0
695
701
  };
702
+ /** `changed(false)`: a balance read that found nothing new; the receipts are checked, the wallet view stays. */
696
703
  constructor(network, changed) {
697
704
  this.network = network;
698
705
  this.changed = changed;
@@ -1001,6 +1008,7 @@ var ArkWallet = class {
1001
1008
  });
1002
1009
  if (this.adapter !== adapter) return;
1003
1010
  const balancesRead = this.view.read || !failed.some((what) => what === "balance" || what === "incoming" || what === "recoverable");
1011
+ const before = this.view;
1004
1012
  this.view = {
1005
1013
  configured: true,
1006
1014
  locked: false,
@@ -1018,7 +1026,7 @@ var ArkWallet = class {
1018
1026
  ...expiresAt !== void 0 ? { expiresAt } : {},
1019
1027
  error: failed.length ? `Could not read the ${failed.join(", ")} from the Ark provider. Last values may be stale.` : void 0
1020
1028
  };
1021
- this.changed();
1029
+ this.changed(viewNews(before, this.view));
1022
1030
  if (this.adapter) this.timer = setTimeout(() => void this.refresh(), 1e4);
1023
1031
  }
1024
1032
  /** Swept outputs back into the balance (refused while expired ones wait for the sweep); the next refresh shows them. */
@@ -1086,10 +1094,10 @@ var NETWORK = {
1086
1094
  signet: "Signet",
1087
1095
  regtest: "Regtest"
1088
1096
  };
1089
- var loading$3;
1097
+ var loading$4;
1090
1098
  /** Loads the WebAssembly once, on first use: profiles that never open a Bark wallet never fetch its 7.7 MB. */
1091
1099
  function loadBarkSdk() {
1092
- return loading$3 ??= (async () => {
1100
+ return loading$4 ??= (async () => {
1093
1101
  const [bark, wasm] = await Promise.all([import("@secondts/bark/web"), import("./bark_ffi_wasm_bg-EY2uc0Bx.js")]);
1094
1102
  await bark.default({ module_or_path: wasm.default });
1095
1103
  return {
@@ -1128,7 +1136,7 @@ function loadBarkSdk() {
1128
1136
  }
1129
1137
  };
1130
1138
  })().catch((error) => {
1131
- loading$3 = void 0;
1139
+ loading$4 = void 0;
1132
1140
  throw error;
1133
1141
  });
1134
1142
  }
@@ -1493,6 +1501,7 @@ var BarkWallet = class {
1493
1501
  locked: true,
1494
1502
  balance: 0
1495
1503
  };
1504
+ /** `changed(false)`: a balance read that found nothing new; the receipts are checked, the wallet view stays. */
1496
1505
  constructor(network, changed, sdk = loadBarkSdk) {
1497
1506
  this.network = network;
1498
1507
  this.changed = changed;
@@ -1778,6 +1787,7 @@ var BarkWallet = class {
1778
1787
  }
1779
1788
  if (this.adapter !== adapter) return;
1780
1789
  const balancesRead = this.view.read || balance !== void 0 && onchain !== void 0;
1790
+ const before = this.view;
1781
1791
  this.view = {
1782
1792
  ...this.idle(adapter.config),
1783
1793
  locked: false,
@@ -1792,7 +1802,7 @@ var BarkWallet = class {
1792
1802
  expiry,
1793
1803
  error: failed.length ? `Could not read the ${failed.join(", ")} from the Bark server. Last values may be stale.` : void 0
1794
1804
  };
1795
- this.changed();
1805
+ this.changed(viewNews(before, this.view));
1796
1806
  if (this.adapter) this.timer = setTimeout(() => void this.refresh(), 1e4);
1797
1807
  }
1798
1808
  /** On-chain coins into Ark: the board confirms on-chain, then they are spendable. */
@@ -2649,10 +2659,10 @@ var FedimintAdapter = class {
2649
2659
  };
2650
2660
  //#endregion
2651
2661
  //#region ../browser/src/engine/paymentAdapters/providers/breezSdk.ts
2652
- var loading$2;
2662
+ var loading$3;
2653
2663
  /** Loads the WebAssembly once, on first use: profiles that never pick Breez never fetch its 12.8 MB. */
2654
2664
  function loadBreezSdk() {
2655
- return loading$2 ??= (async () => {
2665
+ return loading$3 ??= (async () => {
2656
2666
  const breez = await import("./web-DwgdhfcY.js");
2657
2667
  await breez.default();
2658
2668
  return { async connect({ network, mnemonic, apiKey, storage }) {
@@ -2668,11 +2678,11 @@ function loadBreezSdk() {
2668
2678
  });
2669
2679
  } };
2670
2680
  })().catch((error) => {
2671
- loading$2 = void 0;
2681
+ loading$3 = void 0;
2672
2682
  throw error;
2673
2683
  });
2674
2684
  }
2675
- var hex$9 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
2685
+ var hex$8 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
2676
2686
  var breezNetwork = (network) => network === "bitcoin" ? "mainnet" : "regtest";
2677
2687
  var DISCONNECT_MS = 1e4;
2678
2688
  /** One SDK instance per wallet storage: two over one database would each think the leaves are theirs. */
@@ -2684,7 +2694,7 @@ var open = /* @__PURE__ */ new Map();
2684
2694
  */
2685
2695
  async function openBreez(params, sdk = loadBreezSdk, storage = breezDatabase) {
2686
2696
  const name = await storage(params.network, params.mnemonic);
2687
- const key = `${name}|${params.apiKey ? hex$9(sha256(new TextEncoder().encode(params.apiKey))).slice(0, 8) : ""}`;
2697
+ const key = `${name}|${params.apiKey ? hex$8(sha256(new TextEncoder().encode(params.apiKey))).slice(0, 8) : ""}`;
2688
2698
  let entry = open.get(key);
2689
2699
  if (!entry) {
2690
2700
  const wallet = sdk().then((module) => module.connect({
@@ -3018,6 +3028,7 @@ var SparkWallet = class {
3018
3028
  locked: true,
3019
3029
  balance: 0
3020
3030
  };
3031
+ /** `changed(false)`: a balance read that found nothing new; the receipts are checked, the wallet view stays. */
3021
3032
  constructor(network, changed, sdk = loadBreezSdk) {
3022
3033
  this.network = network;
3023
3034
  this.changed = changed;
@@ -3233,6 +3244,7 @@ var SparkWallet = class {
3233
3244
  const history = await read("history", () => adapter.history(), this.view.history);
3234
3245
  if (this.adapter !== adapter) return;
3235
3246
  const balancesRead = this.view.read || !failed.includes("balance");
3247
+ const before = this.view;
3236
3248
  this.view = {
3237
3249
  ...this.idle(this.saved),
3238
3250
  locked: false,
@@ -3242,7 +3254,7 @@ var SparkWallet = class {
3242
3254
  history,
3243
3255
  error: failed.length ? `Could not read the ${failed.join(", ")} from Spark. Last values may be stale.` : void 0
3244
3256
  };
3245
- this.changed();
3257
+ this.changed(viewNews(before, this.view));
3246
3258
  if (this.adapter) this.timer = setTimeout(() => void this.refresh(), sparkTiming.pollMs);
3247
3259
  }
3248
3260
  /** Where a contact pays one request: a Spark invoice made for it (its amount, its memo, a quarter of an hour). */
@@ -3354,6 +3366,17 @@ var PaymentCoordinator = class {
3354
3366
  adapters;
3355
3367
  changed;
3356
3368
  active = /* @__PURE__ */ new Map();
3369
+ /**
3370
+ * The reviews still pending (an Approve may send them), by id: what `dropStale` looks at, so that its minute's sweep
3371
+ * does not read every review ever made. `null` until the store was read once. An id leaves when the sweep finds its
3372
+ * review no longer pending, or gone.
3373
+ */
3374
+ pending = null;
3375
+ /** Reviews read from the store past the coordinator (`refreshWallet`'s list): the pending ones are swept too. */
3376
+ notePending(reviews) {
3377
+ const pending = this.pending ??= /* @__PURE__ */ new Set();
3378
+ for (const review of reviews) if (review.state === "pending") pending.add(review.id);
3379
+ }
3357
3380
  constructor(repository, adapters, changed = () => {}) {
3358
3381
  this.repository = repository;
3359
3382
  this.adapters = adapters;
@@ -3384,6 +3407,7 @@ var PaymentCoordinator = class {
3384
3407
  review,
3385
3408
  prepared
3386
3409
  });
3410
+ this.pending?.add(review.id);
3387
3411
  this.changed(review);
3388
3412
  return review;
3389
3413
  }
@@ -3454,12 +3478,20 @@ var PaymentCoordinator = class {
3454
3478
  */
3455
3479
  async dropStale(now = Date.now(), unpayable = () => false) {
3456
3480
  const dropped = [];
3457
- for (const { review } of await this.repository.list()) {
3458
- if (review.state !== "pending" || review.expiresAt > now && !unpayable(review)) continue;
3481
+ if (!this.pending) this.notePending((await this.repository.list()).map(({ review }) => review));
3482
+ const pending = this.pending;
3483
+ for (const id of [...pending]) {
3484
+ const review = (await this.repository.get(id))?.review;
3485
+ if (!review || review.state !== "pending") {
3486
+ pending.delete(id);
3487
+ continue;
3488
+ }
3489
+ if (review.expiresAt > now && !unpayable(review)) continue;
3459
3490
  try {
3460
3491
  const cancelled = await this.cancel(review.id);
3461
3492
  if (cancelled.state === "cancelled") dropped.push(cancelled);
3462
3493
  } catch {}
3494
+ pending.delete(id);
3463
3495
  }
3464
3496
  return dropped;
3465
3497
  }
@@ -3791,7 +3823,7 @@ var PAGE = 100;
3791
3823
  var PAGES = 10;
3792
3824
  /** Without any record of a payment, it is called failed only this long after its invoice expired. */
3793
3825
  var LOST_AFTER_MS = 36e5;
3794
- var hex$8 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
3826
+ var hex$7 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
3795
3827
  var sats$4 = (value) => {
3796
3828
  const n = Number(value);
3797
3829
  if (!Number.isSafeInteger(n) || n < 0) throw new Error("Breez returned an invalid amount");
@@ -3806,7 +3838,7 @@ function idempotencyKey(paymentHash, attempt) {
3806
3838
  const b = sha256(new TextEncoder().encode(`ghostly-breez-pay:${paymentHash}:${attempt}`)).slice(0, 16);
3807
3839
  b[6] = b[6] & 15 | 64;
3808
3840
  b[8] = b[8] & 63 | 128;
3809
- const h = hex$8(b);
3841
+ const h = hex$7(b);
3810
3842
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
3811
3843
  }
3812
3844
  /** The payment hash a Breez payment is about, when it is one (Lightning, or a Spark HTLC). */
@@ -4049,7 +4081,7 @@ var appLoader = async () => {
4049
4081
  wasm: await fetch(url.default)
4050
4082
  };
4051
4083
  };
4052
- var loading$1;
4084
+ var loading$2;
4053
4085
  /**
4054
4086
  * Loads the WebAssembly once, on first use: profiles that never set up a BDK wallet never fetch its 2 MB.
4055
4087
  * The package is wasm-pack's `bundler` build, whose entry imports the `.wasm` as an ES module (which
@@ -4057,7 +4089,7 @@ var loading$1;
4057
4089
  * that entry would.
4058
4090
  */
4059
4091
  function loadBdk(loader = appLoader) {
4060
- return loading$1 ??= (async () => {
4092
+ return loading$2 ??= (async () => {
4061
4093
  const { glue, wasm } = await loader();
4062
4094
  const imports = { [WASM_IMPORTS]: glue };
4063
4095
  let instance;
@@ -4071,7 +4103,7 @@ function loadBdk(loader = appLoader) {
4071
4103
  instance.exports.__wbindgen_start?.();
4072
4104
  return glue;
4073
4105
  })().catch((error) => {
4074
- loading$1 = void 0;
4106
+ loading$2 = void 0;
4075
4107
  throw error;
4076
4108
  });
4077
4109
  }
@@ -4137,7 +4169,7 @@ var idbBdkStore = {
4137
4169
  });
4138
4170
  }
4139
4171
  };
4140
- var hex$7 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
4172
+ var hex$6 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
4141
4173
  var unhex = (text) => {
4142
4174
  if (!/^([0-9a-f]{2})+$/.test(text)) throw new Error("Not hex");
4143
4175
  return Uint8Array.from(text.match(/../g), (h) => parseInt(h, 16));
@@ -4351,7 +4383,7 @@ var BdkOnchain = class BdkOnchain {
4351
4383
  throw new SourceConfigError(`bad descriptor: the recovery phrase does not make a ${SCRIPT[config.script].label} wallet (${message$3(error)})`, error);
4352
4384
  }
4353
4385
  const fingerprint = /\[([0-9a-f]{8})\//.exec(publicDescriptor)?.[1] ?? "";
4354
- const key = `bdkWallet-${config.network}-${hex$7(sha256(new TextEncoder().encode(publicDescriptor))).slice(0, 32)}`;
4386
+ const key = `bdkWallet-${config.network}-${hex$6(sha256(new TextEncoder().encode(publicDescriptor))).slice(0, 32)}`;
4355
4387
  const walletStore = deps.store ?? idbBdkStore;
4356
4388
  let stored = await walletStore.load(key);
4357
4389
  if (stored) try {
@@ -4449,7 +4481,7 @@ var BdkOnchain = class BdkOnchain {
4449
4481
  amount,
4450
4482
  fee,
4451
4483
  feeRate,
4452
- signed: hex$7(tx.to_bytes())
4484
+ signed: hex$6(tx.to_bytes())
4453
4485
  };
4454
4486
  }
4455
4487
  /** What was signed is what was reviewed: one output of exactly the amount to the address, the rest ours. */
@@ -4702,435 +4734,6 @@ var bdk = {
4702
4734
  }
4703
4735
  };
4704
4736
  //#endregion
4705
- //#region ../browser/src/engine/paymentAdapters/providers/bitcoind.ts
4706
- var BitcoindRpcError = class extends Error {
4707
- kind;
4708
- code;
4709
- constructor(kind, message, code) {
4710
- super(message);
4711
- this.kind = kind;
4712
- this.code = code;
4713
- this.name = "BitcoindRpcError";
4714
- }
4715
- /** The node never ran the call: it was not sent, could not connect, or the credentials were refused. */
4716
- get neverRan() {
4717
- return this.kind === "refused" || this.kind === "connect" || this.kind === "auth";
4718
- }
4719
- };
4720
- /** The Tauri command, through the Desktop host. */
4721
- function tauriTransport$1(invoke) {
4722
- if (!invoke) throw new Error("Bitcoin Core needs the Ghostly desktop app: a browser cannot reach its RPC");
4723
- return (call) => invoke("bitcoind_rpc", { ...call });
4724
- }
4725
- /** Bitcoin Core's JSON-RPC error codes this module acts on (src/rpc/protocol.h). */
4726
- var RPC = {
4727
- INVALID_ADDRESS_OR_KEY: -5,
4728
- VERIFY_ERROR: -25,
4729
- VERIFY_REJECTED: -26,
4730
- VERIFY_ALREADY_IN_CHAIN: -27,
4731
- DESERIALIZATION_ERROR: -22
4732
- };
4733
- var CHAINS = {
4734
- main: "bitcoin",
4735
- test: "testnet",
4736
- testnet4: "testnet",
4737
- signet: "signet",
4738
- regtest: "regtest"
4739
- };
4740
- /** A fee rate for the test networks when the node has too few blocks to estimate one (regtest always). */
4741
- var TEST_FALLBACK_FEE_RATE = 2;
4742
- var TXID = /^[0-9a-f]{64}$/;
4743
- var WALLET_NAME = /^[A-Za-z0-9._-]{0,64}$/;
4744
- /** Address encodings, for btc-signer to read the outputs back as addresses. */
4745
- var TESTNET_ENCODING = {
4746
- bech32: "tb",
4747
- pubKeyHash: 111,
4748
- scriptHash: 196,
4749
- wif: 239
4750
- };
4751
- var BTC_NETWORKS = {
4752
- bitcoin: {
4753
- bech32: "bc",
4754
- pubKeyHash: 0,
4755
- scriptHash: 5,
4756
- wif: 128
4757
- },
4758
- testnet: TESTNET_ENCODING,
4759
- signet: TESTNET_ENCODING,
4760
- mutinynet: TESTNET_ENCODING,
4761
- regtest: {
4762
- ...TESTNET_ENCODING,
4763
- bech32: "bcrt"
4764
- }
4765
- };
4766
- var TX_OPTS = {
4767
- allowUnknownOutputs: true,
4768
- allowUnknownInputs: true,
4769
- disableScriptCheck: true,
4770
- allowLegacyWitnessUtxo: true
4771
- };
4772
- /** Whole sats as the exact decimal string bitcoind reads as BTC (never a float). */
4773
- var satsToBtc = (sats) => {
4774
- if (!Number.isSafeInteger(sats) || sats < 0) throw new Error("Amounts are whole sats");
4775
- const digits = sats.toString().padStart(9, "0");
4776
- return `${digits.slice(0, -8)}.${digits.slice(-8)}`;
4777
- };
4778
- /** A BTC amount from bitcoind (a JSON number with at most 8 decimals) as whole sats. */
4779
- var btcToSats = (btc) => {
4780
- if (typeof btc !== "number" || !Number.isFinite(btc)) throw new Error("The node returned an amount that is not a number");
4781
- return Math.round(btc * 1e8);
4782
- };
4783
- var object = (value, what) => {
4784
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`The node returned no ${what}`);
4785
- return value;
4786
- };
4787
- var text$2 = (value, what) => {
4788
- if (typeof value !== "string" || !value) throw new Error(`The node returned no ${what}`);
4789
- return value;
4790
- };
4791
- var BitcoindOnchain = class BitcoindOnchain {
4792
- config;
4793
- transport;
4794
- closed = false;
4795
- network;
4796
- constructor(config, transport) {
4797
- this.config = config;
4798
- this.transport = transport;
4799
- }
4800
- /** Connects and checks the wallet is there and the node is on a network of `mode`. */
4801
- static async connect(config, mode, transport, signal) {
4802
- const provider = new BitcoindOnchain(config, transport);
4803
- signal?.addEventListener("abort", () => void provider.close(), { once: true });
4804
- const { network } = await provider.info();
4805
- if (networkMode(network) !== mode) throw new Error(mode === "mainnet" ? `This node is on ${network}, not Bitcoin mainnet` : "This node is on Bitcoin mainnet: set it up as a Mainnet wallet instead");
4806
- if (object(await provider.call("getwalletinfo"), "wallet information").private_keys_enabled === false) throw new Error("This wallet has no private keys: it cannot sign a payment");
4807
- return provider;
4808
- }
4809
- async call(method, ...params) {
4810
- if (this.closed) throw new BitcoindRpcError("refused", "This Bitcoin Core source is closed");
4811
- try {
4812
- return await this.transport({
4813
- ...this.config,
4814
- method,
4815
- params
4816
- });
4817
- } catch (error) {
4818
- if (error instanceof BitcoindRpcError) throw error;
4819
- const failure = error;
4820
- if (failure && typeof failure === "object" && typeof failure.kind === "string" && typeof failure.message === "string") throw new BitcoindRpcError(failure.kind, failure.message, failure.code);
4821
- throw new BitcoindRpcError("transport", error instanceof Error ? error.message : String(error));
4822
- }
4823
- }
4824
- async info() {
4825
- const chain = object(await this.call("getblockchaininfo"), "chain information").chain;
4826
- const network = typeof chain === "string" ? CHAINS[chain] : void 0;
4827
- if (!network) throw new Error(`Ghostly does not know the chain "${String(chain).slice(0, 20)}"`);
4828
- this.network = network;
4829
- return {
4830
- network,
4831
- alias: this.config.wallet || "Bitcoin Core"
4832
- };
4833
- }
4834
- async receiveAddress() {
4835
- return text$2(await this.call("getnewaddress", "Ghostly"), "address");
4836
- }
4837
- async balance() {
4838
- const mine = object(object(await this.call("getbalances"), "balances").mine, "balances");
4839
- return {
4840
- confirmed: btcToSats(mine.trusted),
4841
- unconfirmed: btcToSats(mine.untrusted_pending)
4842
- };
4843
- }
4844
- async feeRate(request) {
4845
- if (request.feeRate !== void 0) {
4846
- if (!Number.isFinite(request.feeRate) || request.feeRate < 1 || request.feeRate > 1e4) throw new Error("The fee rate must be between 1 and 10,000 sat/vB");
4847
- return request.feeRate;
4848
- }
4849
- const estimate = object(await this.call("estimatesmartfee", 6), "fee estimate");
4850
- if (typeof estimate.feerate === "number" && estimate.feerate > 0) return Math.max(1, Math.ceil(estimate.feerate * 1e5 * 1e3) / 1e3);
4851
- if ((this.network ?? (await this.info()).network) === "bitcoin") throw new Error("Your node cannot estimate a fee yet. Nothing was sent.");
4852
- return TEST_FALLBACK_FEE_RATE;
4853
- }
4854
- async prepareSend(request) {
4855
- if (!Number.isSafeInteger(request.amount) || request.amount <= 0) throw new Error("Amounts are whole sats");
4856
- const network = this.network ?? (await this.info()).network;
4857
- const feeRate = await this.feeRate(request);
4858
- const funded = object(await this.call("walletcreatefundedpsbt", [], [{ [request.address]: satsToBtc(request.amount) }], 0, {
4859
- fee_rate: feeRate,
4860
- lockUnspents: true,
4861
- replaceable: true
4862
- }), "funded transaction");
4863
- const inputs = this.outpoints(Transaction.fromPSBT(base64.decode(text$2(funded.psbt, "funded transaction")), TX_OPTS));
4864
- try {
4865
- const fee = btcToSats(funded.fee);
4866
- if (fee > request.feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${request.feeCap}`);
4867
- const signed = object(await this.call("walletprocesspsbt", funded.psbt, true, "ALL"), "signed transaction");
4868
- if (signed.complete !== true) throw new Error("The wallet could not sign this payment (is it locked, or watch-only?)");
4869
- const final = object(await this.call("finalizepsbt", signed.psbt, false), "finalized transaction");
4870
- if (final.complete !== true) throw new Error("The wallet could not finalize this payment");
4871
- return this.check(Transaction.fromPSBT(base64.decode(text$2(final.psbt, "finalized transaction")), TX_OPTS), request, inputs, feeRate, network);
4872
- } catch (error) {
4873
- await this.unlock(inputs).catch(() => {});
4874
- throw error;
4875
- }
4876
- }
4877
- /** The transaction the node signed is exactly the one asked for, before anyone reviews it. */
4878
- check(tx, request, funded, feeRate, network) {
4879
- const same = (a, b) => a.length === b.length && a.every((o, i) => o.txid === b[i].txid && o.vout === b[i].vout);
4880
- if (!same(this.outpoints(tx), funded)) throw new Error("The node signed other coins than it funded the payment with");
4881
- const script = (() => {
4882
- try {
4883
- return hex.encode(OutScript.encode(Address(BTC_NETWORKS[network]).decode(request.address)));
4884
- } catch {
4885
- return;
4886
- }
4887
- })();
4888
- let paid = 0;
4889
- for (let i = 0; i < tx.outputsLength; i++) {
4890
- const output = tx.getOutput(i);
4891
- if (script !== void 0 && output.script && hex.encode(output.script) === script) paid += Number(output.amount ?? 0n);
4892
- }
4893
- if (paid !== request.amount) throw new Error("The node built a transaction that does not pay the address and amount asked for");
4894
- const fee = Number(tx.fee);
4895
- if (!Number.isSafeInteger(fee) || fee < 0 || fee > request.feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${request.feeCap}`);
4896
- const txid = tx.id;
4897
- if (!TXID.test(txid)) throw new Error("The node returned a transaction without a valid id");
4898
- return {
4899
- txid,
4900
- address: request.address,
4901
- amount: request.amount,
4902
- fee,
4903
- feeRate,
4904
- signed: hex.encode(tx.extract())
4905
- };
4906
- }
4907
- outpoints(tx) {
4908
- const out = [];
4909
- for (let i = 0; i < tx.inputsLength; i++) {
4910
- const input = tx.getInput(i);
4911
- if (!input.txid || input.index === void 0) throw new Error("The node returned a transaction with an unreadable input");
4912
- out.push({
4913
- txid: hex.encode(input.txid),
4914
- vout: input.index
4915
- });
4916
- }
4917
- return out;
4918
- }
4919
- async unlock(outpoints) {
4920
- if (outpoints.length) await this.call("lockunspent", true, outpoints);
4921
- }
4922
- signedTx(prepared) {
4923
- if (!/^[0-9a-f]+$/.test(prepared.signed)) throw new Error("This prepared payment has no signed transaction");
4924
- const tx = Transaction.fromRaw(hex.decode(prepared.signed), TX_OPTS);
4925
- if (tx.id !== prepared.txid) throw new Error("This prepared payment's transaction is not the one reviewed");
4926
- return tx;
4927
- }
4928
- async broadcast(prepared) {
4929
- const tx = this.signedTx(prepared);
4930
- let txid;
4931
- try {
4932
- txid = await this.call("sendrawtransaction", prepared.signed);
4933
- } catch (error) {
4934
- if (!(error instanceof BitcoindRpcError)) throw error;
4935
- if (error.kind === "rpc" && error.code === RPC.VERIFY_ALREADY_IN_CHAIN) return prepared.txid;
4936
- const rejected = error.kind === "rpc" && [
4937
- RPC.VERIFY_ERROR,
4938
- RPC.VERIFY_REJECTED,
4939
- RPC.DESERIALIZATION_ERROR
4940
- ].includes(error.code ?? 0);
4941
- if (!error.neverRan && !rejected) throw error;
4942
- const seen = rejected ? await this.known(prepared.txid) : await this.known(prepared.txid).catch(() => void 0);
4943
- if (seen) {
4944
- if (["mempool", "confirmed"].includes((await this.status(prepared)).state)) return prepared.txid;
4945
- throw error;
4946
- }
4947
- if (seen === false) await this.unlock(this.outpoints(tx)).catch(() => {});
4948
- throw new NothingSpentError(`Your node did not send it: ${error.message}`);
4949
- }
4950
- if (txid !== prepared.txid) throw new Error("The node broadcast a different transaction than the one reviewed");
4951
- return prepared.txid;
4952
- }
4953
- /** Whether the wallet knows this transaction (it does once its node accepted it, even if it was dropped since). */
4954
- async known(txid) {
4955
- try {
4956
- await this.call("gettransaction", txid);
4957
- return true;
4958
- } catch (error) {
4959
- if (error instanceof BitcoindRpcError && error.kind === "rpc" && error.code === RPC.INVALID_ADDRESS_OR_KEY) return false;
4960
- throw error;
4961
- }
4962
- }
4963
- async status(prepared) {
4964
- if (!TXID.test(prepared.txid)) throw new Error("Not a transaction id");
4965
- let tx;
4966
- try {
4967
- tx = object(await this.call("gettransaction", prepared.txid), "transaction");
4968
- } catch (error) {
4969
- if (error instanceof BitcoindRpcError && error.kind === "rpc" && error.code === RPC.INVALID_ADDRESS_OR_KEY) return {
4970
- state: "missing",
4971
- confirmations: 0
4972
- };
4973
- throw error;
4974
- }
4975
- const confirmations = typeof tx.confirmations === "number" ? tx.confirmations : 0;
4976
- if (confirmations > 0) return {
4977
- state: "confirmed",
4978
- confirmations
4979
- };
4980
- if (confirmations < 0) return {
4981
- state: "conflicted",
4982
- confirmations: 0
4983
- };
4984
- if (Array.isArray(tx.mempoolconflicts) && tx.mempoolconflicts.length > 0) return {
4985
- state: "conflicted",
4986
- confirmations: 0
4987
- };
4988
- try {
4989
- await this.call("getmempoolentry", prepared.txid);
4990
- return {
4991
- state: "mempool",
4992
- confirmations: 0
4993
- };
4994
- } catch (error) {
4995
- if (error instanceof BitcoindRpcError && error.kind === "rpc" && error.code === RPC.INVALID_ADDRESS_OR_KEY) return {
4996
- state: "missing",
4997
- confirmations: 0
4998
- };
4999
- throw error;
5000
- }
5001
- }
5002
- async release(prepared) {
5003
- await this.unlock(this.outpoints(this.signedTx(prepared)));
5004
- }
5005
- async history(limit) {
5006
- const count = Math.max(1, Math.min(200, Math.floor(limit) * 3));
5007
- const entries = await this.call("listtransactions", "*", count, 0, true);
5008
- if (!Array.isArray(entries)) throw new Error("The node returned no transaction list");
5009
- const byTxid = /* @__PURE__ */ new Map();
5010
- for (const raw of entries) {
5011
- if (!raw || typeof raw.txid !== "string" || !TXID.test(raw.txid) || raw.category === "orphan") continue;
5012
- const tx = byTxid.get(raw.txid) ?? {
5013
- txid: raw.txid,
5014
- amount: 0,
5015
- confirmations: typeof raw.confirmations === "number" ? Math.max(0, raw.confirmations) : 0
5016
- };
5017
- tx.amount += btcToSats(raw.amount);
5018
- if (raw.category === "send" && typeof raw.fee === "number" && tx.fee === void 0) tx.fee = Math.abs(btcToSats(raw.fee));
5019
- if (typeof raw.time === "number") tx.timestamp = raw.time * 1e3;
5020
- byTxid.delete(raw.txid);
5021
- byTxid.set(raw.txid, tx);
5022
- }
5023
- return [...byTxid.values()].reverse().slice(0, Math.max(0, limit));
5024
- }
5025
- /** The wallet's receives on this address, newest first: only `receive` entries paying exactly it count. */
5026
- async received(address) {
5027
- const entries = await this.call("listtransactions", "*", 200, 0, true);
5028
- if (!Array.isArray(entries)) throw new Error("The node returned no transaction list");
5029
- const byTxid = /* @__PURE__ */ new Map();
5030
- for (const raw of entries) {
5031
- if (!raw || raw.category !== "receive" || raw.address !== address || typeof raw.txid !== "string" || !TXID.test(raw.txid)) continue;
5032
- const tx = byTxid.get(raw.txid) ?? {
5033
- txid: raw.txid,
5034
- amount: 0,
5035
- confirmations: typeof raw.confirmations === "number" ? Math.max(0, raw.confirmations) : 0
5036
- };
5037
- tx.amount += btcToSats(raw.amount);
5038
- byTxid.delete(raw.txid);
5039
- byTxid.set(raw.txid, tx);
5040
- }
5041
- return [...byTxid.values()].reverse();
5042
- }
5043
- async close() {
5044
- this.closed = true;
5045
- }
5046
- };
5047
- /** `user:password` in the password field (the .cookie file's contents) when no user is given. */
5048
- function credentials({ config, secrets }) {
5049
- const password = secrets.password ?? "";
5050
- const user = config.user?.trim() ?? "";
5051
- if (!user && password.includes(":")) {
5052
- const at = password.indexOf(":");
5053
- return {
5054
- user: password.slice(0, at),
5055
- password: password.slice(at + 1)
5056
- };
5057
- }
5058
- return {
5059
- user,
5060
- password
5061
- };
5062
- }
5063
- var bitcoindRpc = {
5064
- id: "bitcoind",
5065
- label: "Bitcoin Core",
5066
- kind: "onchain",
5067
- description: "A wallet on your own Bitcoin Core node, over its RPC. Your node holds the keys and signs. Needs the desktop app.",
5068
- networks: [
5069
- "bitcoin",
5070
- "testnet",
5071
- "signet",
5072
- "regtest"
5073
- ],
5074
- platforms: ["desktop"],
5075
- experimental: true,
5076
- fields: [
5077
- {
5078
- name: "url",
5079
- label: "RPC address",
5080
- kind: "url",
5081
- placeholder: "http://127.0.0.1:8332",
5082
- defaults: {
5083
- mainnet: "http://127.0.0.1:8332",
5084
- testnet: "http://127.0.0.1:38332"
5085
- },
5086
- help: "Your node's rpcbind and rpcport: 8332 mainnet, 38332 signet, 48332 testnet4, 18443 regtest."
5087
- },
5088
- {
5089
- name: "wallet",
5090
- label: "Wallet name",
5091
- kind: "text",
5092
- optional: true,
5093
- placeholder: "ghostly",
5094
- help: "A wallet loaded in the node (bitcoin-cli listwallets). Empty: its default wallet."
5095
- },
5096
- {
5097
- name: "user",
5098
- label: "RPC user",
5099
- kind: "text",
5100
- optional: true,
5101
- help: "Leave empty to paste the .cookie file below."
5102
- },
5103
- {
5104
- name: "password",
5105
- label: "RPC password or cookie",
5106
- kind: "secret",
5107
- help: "rpcpassword, or the contents of the node's .cookie file (it changes when bitcoind restarts)."
5108
- }
5109
- ],
5110
- validate(settings) {
5111
- const url = (() => {
5112
- try {
5113
- return new URL(settings.config.url?.trim() ?? "");
5114
- } catch {
5115
- return;
5116
- }
5117
- })();
5118
- if (!url || !["http:", "https:"].includes(url.protocol)) throw new Error("Enter the node's RPC address, like http://127.0.0.1:8332");
5119
- if (url.username || url.password) throw new Error("Put the RPC user and password in their own fields, not in the address");
5120
- if (url.search || url.hash) throw new Error("The RPC address cannot have a query or fragment");
5121
- if (!WALLET_NAME.test(settings.config.wallet?.trim() ?? "") || [".", ".."].includes(settings.config.wallet?.trim() ?? "")) throw new Error("A wallet name has only letters, digits, '-', '_' and '.'");
5122
- const { user, password } = credentials(settings);
5123
- if (!user || !password) throw new Error("Enter the RPC user and password, or paste the .cookie file");
5124
- },
5125
- async create(settings, host) {
5126
- return BitcoindOnchain.connect({
5127
- url: settings.config.url.trim(),
5128
- wallet: settings.config.wallet?.trim() ?? "",
5129
- ...credentials(settings)
5130
- }, host.mode, tauriTransport$1(host.invoke), host.signal);
5131
- }
5132
- };
5133
- //#endregion
5134
4737
  //#region ../browser/src/engine/paymentAdapters/providers/fedimint.ts
5135
4738
  var FEDIMINT_SOURCE = "fedimint";
5136
4739
  /** How long an invoice made here stays payable. */
@@ -5702,15 +5305,15 @@ var CLN_RUNE_COMMAND = `lightning-cli createrune restrictions='[[${CLN_METHODS.m
5702
5305
  var PAY_TIMEOUT_S$1 = 60;
5703
5306
  var CALL_TIMEOUT_MS = 3e4;
5704
5307
  var PAY_CALL_TIMEOUT_MS = 9e4;
5705
- var INVOICE_EXPIRY_S$2 = 3600;
5706
- var NETWORKS$2 = {
5308
+ var INVOICE_EXPIRY_S$1 = 3600;
5309
+ var NETWORKS$1 = {
5707
5310
  bitcoin: "bitcoin",
5708
5311
  testnet: "testnet",
5709
5312
  testnet4: "testnet",
5710
5313
  signet: "signet",
5711
5314
  regtest: "regtest"
5712
5315
  };
5713
- var HEX64$4 = /^[0-9a-f]{64}$/;
5316
+ var HEX64$3 = /^[0-9a-f]{64}$/;
5714
5317
  var NODE_ID = /^0[23][0-9a-f]{64}$/;
5715
5318
  var msatToSat = (msat) => {
5716
5319
  const value = typeof msat === "number" ? msat : typeof msat === "string" ? Number(msat.replace(/msat$/, "")) : NaN;
@@ -5788,7 +5391,7 @@ var CoreLightning = class CoreLightning {
5788
5391
  }
5789
5392
  async info() {
5790
5393
  const info = await this.rpc.call("getinfo", {}, { timeoutMs: CALL_TIMEOUT_MS });
5791
- const network = NETWORKS$2[info?.network];
5394
+ const network = NETWORKS$1[info?.network];
5792
5395
  if (!network) throw new Error(`The node is on a network Ghostly does not know (${String(info?.network).slice(0, 20)})`);
5793
5396
  if (this.nodeId && info.id !== this.nodeId) throw new Error("The node answered with another id");
5794
5397
  return {
@@ -5814,7 +5417,7 @@ var CoreLightning = class CoreLightning {
5814
5417
  amount_msat: amount * 1e3,
5815
5418
  label,
5816
5419
  description: (memo || "Ghostly").slice(0, 200),
5817
- expiry: INVOICE_EXPIRY_S$2
5420
+ expiry: INVOICE_EXPIRY_S$1
5818
5421
  }, { timeoutMs: CALL_TIMEOUT_MS });
5819
5422
  const decoded = typeof created?.bolt11 === "string" && created.bolt11.length < 8e3 ? decodeBolt11(created.bolt11) : null;
5820
5423
  if (!decoded || decoded.paymentHash !== created.payment_hash || decoded.amountSat !== amount) throw new Error("The node returned an invoice that does not match the request");
@@ -5914,7 +5517,7 @@ var CoreLightning = class CoreLightning {
5914
5517
  }
5915
5518
  /** What the node's own record of this payment hash says: `none` when it never tried. */
5916
5519
  async lookup(paymentHash) {
5917
- if (!HEX64$4.test(paymentHash)) throw new Error("Invalid payment hash");
5520
+ if (!HEX64$3.test(paymentHash)) throw new Error("Invalid payment hash");
5918
5521
  const { pays } = await this.rpc.call("listpays", { payment_hash: paymentHash }, { timeoutMs: CALL_TIMEOUT_MS });
5919
5522
  const mine = (pays ?? []).filter((p) => p.payment_hash === paymentHash);
5920
5523
  const complete = mine.find((p) => p.status === "complete");
@@ -5987,672 +5590,6 @@ var coreLightning = {
5987
5590
  return CoreLightning.connect(settingsOf(settings), host.signal);
5988
5591
  }
5989
5592
  };
5990
- //#endregion
5991
- //#region ../browser/src/engine/paymentAdapters/providers/nwc.ts
5992
- /**
5993
- * Lightning through a wallet reached over Nostr Wallet Connect (NIP-47): Alby Hub, a node's NWC service, a
5994
- * hosted wallet. The person pastes the wallet's `nostr+walletconnect://` URI; requests are signed with the
5995
- * key it carries, encrypted to the wallet (NIP-44, or NIP-04 when its info event offers nothing else) and
5996
- * sent through its relay; the answers come back the same way. That wallet holds the sats, applies its own
5997
- * fee limit, and is custodial or not depending on what it is.
5998
- *
5999
- * Money safety (PROVIDERS.md): a request that never reached the relay, or an error code the wallet answers
6000
- * before it tries to pay, is `NothingSpentError`. A `pay_invoice` whose answer does not come back in time,
6001
- * or any other error, is an unknown outcome, and `paymentStatus` asks `lookup_invoice` until it ends.
6002
- */
6003
- var KIND = {
6004
- info: 13194,
6005
- request: 23194,
6006
- response: 23195
6007
- };
6008
- /** A relay message, an encrypted answer, an info event, an invoice: nothing bigger is read. */
6009
- var MAX_MESSAGE = 262144;
6010
- var MAX_CONTENT = 65536;
6011
- var MAX_INFO = 4096;
6012
- var MAX_INVOICE = 7089;
6013
- var MAX_URI = 2048;
6014
- var MAX_RELAYS = 5;
6015
- var CONNECT_MS = 1e4;
6016
- var ACK_MS = 1e4;
6017
- var REQUEST_MS$1 = 2e4;
6018
- /** A payment may take a while to route; an answer after this is an unknown outcome, looked up later. */
6019
- var PAY_MS = 9e4;
6020
- /** The wallet must not start a payment it receives after this (NIP-47 `expiration`). */
6021
- var PAY_EXPIRY_S = 60;
6022
- /** A payment the wallet never heard of is failed once its invoice can no longer be paid, with room for clocks. */
6023
- var EXPIRED_MARGIN_MS = 6e5;
6024
- var INVOICE_EXPIRY_S$1 = 3600;
6025
- /** Error codes a wallet answers before it tries to pay: nothing can have left it. (`BAD_REQUEST`: Alby Hub, an invoice it cannot read.) */
6026
- var REFUSED_BEFORE_PAYING = /* @__PURE__ */ new Set([
6027
- "RATE_LIMITED",
6028
- "NOT_IMPLEMENTED",
6029
- "INSUFFICIENT_BALANCE",
6030
- "QUOTA_EXCEEDED",
6031
- "RESTRICTED",
6032
- "UNAUTHORIZED",
6033
- "UNSUPPORTED_ENCRYPTION",
6034
- "BAD_REQUEST"
6035
- ]);
6036
- var NETWORKS$1 = {
6037
- mainnet: "bitcoin",
6038
- bitcoin: "bitcoin",
6039
- testnet: "testnet",
6040
- testnet3: "testnet",
6041
- testnet4: "testnet",
6042
- signet: "signet",
6043
- mutinynet: "mutinynet",
6044
- regtest: "regtest"
6045
- };
6046
- var HEX64$3 = /^[0-9a-f]{64}$/;
6047
- var hex$6 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
6048
- var bytes$2 = (text) => Uint8Array.from(text.match(/../g), (b) => parseInt(b, 16));
6049
- var now$1 = () => Math.floor(Date.now() / 1e3);
6050
- var isMsat = (value) => Number.isSafeInteger(value) && value >= 0;
6051
- /** Only secure relays, except on this machine (a local test relay). */
6052
- function checkRelay(address) {
6053
- let relay;
6054
- try {
6055
- relay = new URL(address);
6056
- } catch {
6057
- throw new Error("The connection URI has a relay that is not a URL");
6058
- }
6059
- if (relay.username || relay.password || relay.hash) throw new Error("The connection URI has a relay with credentials in it");
6060
- const loopback = [
6061
- "127.0.0.1",
6062
- "localhost",
6063
- "[::1]"
6064
- ].includes(relay.hostname);
6065
- if (!(relay.protocol === "wss:" || relay.protocol === "ws:" && loopback)) throw new Error("Use a wallet whose relay is secure (wss://)");
6066
- return relay.href;
6067
- }
6068
- /** Reads a `nostr+walletconnect://` URI. Its errors never repeat it: it holds a secret. */
6069
- function parseNwcUri(input) {
6070
- const text = (input ?? "").trim();
6071
- if (text.length > MAX_URI) throw new Error("That connection URI is too long");
6072
- const match = /^(?:nostr\+walletconnect|nostrwalletconnect):(?:\/\/)?([0-9a-fA-F]{64})\/?\?([^#]*)$/.exec(text);
6073
- if (!match) throw new Error("That is not a Nostr Wallet Connect URI (nostr+walletconnect://…)");
6074
- const params = new URLSearchParams(match[2]);
6075
- const secret = (params.get("secret") ?? "").toLowerCase();
6076
- if (!HEX64$3.test(secret) || /^0+$/.test(secret)) throw new Error("The connection URI has no valid secret");
6077
- const relays = [...new Set(params.getAll("relay").map(checkRelay))];
6078
- if (!relays.length) throw new Error("The connection URI names no relay");
6079
- if (relays.length > MAX_RELAYS) throw new Error("The connection URI names too many relays");
6080
- return {
6081
- walletPubkey: match[1].toLowerCase(),
6082
- relays,
6083
- secret: bytes$2(secret)
6084
- };
6085
- }
6086
- /** The request never reached the relay (not connected, refused by it): the wallet cannot have seen it. */
6087
- var NotDelivered = class extends Error {
6088
- constructor(message) {
6089
- super(message);
6090
- this.name = "NotDelivered";
6091
- }
6092
- };
6093
- /** An error the wallet answered, with its NIP-47 code. */
6094
- var NwcError = class extends Error {
6095
- code;
6096
- constructor(code, message) {
6097
- super(message);
6098
- this.code = code;
6099
- this.name = "NwcError";
6100
- }
6101
- };
6102
- /** No answer in time. The request may or may not have been acted on. */
6103
- var NoAnswer = class extends Error {
6104
- constructor(method) {
6105
- super(`The wallet did not answer ${method} in time`);
6106
- this.name = "NoAnswer";
6107
- }
6108
- };
6109
- /**
6110
- * A minimal NIP-01 client for one relay: it knows exactly whether an event was written to an open socket
6111
- * and whether the relay acknowledged it, which is what tells "nothing sent" from "unknown" apart.
6112
- * It reconnects when asked to send again, and subscriptions follow it.
6113
- */
6114
- var RelayConnection = class {
6115
- url;
6116
- WebSocketImpl;
6117
- socket;
6118
- opening;
6119
- subs = /* @__PURE__ */ new Map();
6120
- acks = /* @__PURE__ */ new Map();
6121
- closed = false;
6122
- constructor(url, WebSocketImpl = globalThis.WebSocket) {
6123
- this.url = url;
6124
- this.WebSocketImpl = WebSocketImpl;
6125
- }
6126
- open() {
6127
- if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("The wallet connection is closed"));
6128
- if (this.socket?.readyState === 1) return Promise.resolve(this.socket);
6129
- if (!this.opening) {
6130
- const opening = this.connectSocket();
6131
- this.opening = opening;
6132
- opening.catch(() => {}).finally(() => {
6133
- if (this.opening === opening) this.opening = void 0;
6134
- });
6135
- }
6136
- return this.opening;
6137
- }
6138
- connectSocket() {
6139
- return new Promise((resolve, reject) => {
6140
- let settled = false;
6141
- const socket = new this.WebSocketImpl(this.url);
6142
- const done = (error) => {
6143
- if (settled) return;
6144
- settled = true;
6145
- clearTimeout(timer);
6146
- if (error) {
6147
- try {
6148
- socket.close();
6149
- } catch {}
6150
- reject(error);
6151
- return;
6152
- }
6153
- if (this.closed) {
6154
- socket.close();
6155
- reject(/* @__PURE__ */ new Error("The wallet connection is closed"));
6156
- return;
6157
- }
6158
- this.socket = socket;
6159
- for (const [id, sub] of this.subs) this.write([
6160
- "REQ",
6161
- id,
6162
- sub.filter
6163
- ]);
6164
- resolve(socket);
6165
- };
6166
- const timer = setTimeout(() => done(/* @__PURE__ */ new Error("The wallet's relay did not answer")), CONNECT_MS);
6167
- socket.onopen = () => done();
6168
- socket.onerror = () => done(/* @__PURE__ */ new Error("Could not reach the wallet's relay"));
6169
- socket.onclose = () => {
6170
- done(/* @__PURE__ */ new Error("The wallet's relay closed the connection"));
6171
- if (this.socket !== socket) return;
6172
- this.socket = void 0;
6173
- for (const [id, ack] of this.acks) {
6174
- this.acks.delete(id);
6175
- ack.fail(/* @__PURE__ */ new Error("The relay connection dropped before it confirmed the request"));
6176
- }
6177
- };
6178
- socket.onmessage = (message) => {
6179
- if (this.socket === socket) this.receive(message.data);
6180
- };
6181
- });
6182
- }
6183
- subscribe(id, filter, sub) {
6184
- this.subs.set(id, {
6185
- ...sub,
6186
- filter
6187
- });
6188
- this.write([
6189
- "REQ",
6190
- id,
6191
- filter
6192
- ]);
6193
- }
6194
- unsubscribe(id) {
6195
- if (this.subs.delete(id)) this.write(["CLOSE", id]);
6196
- }
6197
- /**
6198
- * Events matching `filter` the relay has stored, until it says that is all (or `ms` pass). With
6199
- * `waitForOne`, a relay that has none yet is listened to until one arrives live, within `ms`.
6200
- */
6201
- async fetch(filter, { ms = CONNECT_MS, waitForOne = false } = {}) {
6202
- await this.open();
6203
- const id = `q${hex$6(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(6)))}`, events = [];
6204
- try {
6205
- await new Promise((resolve) => {
6206
- const timer = setTimeout(resolve, ms);
6207
- let stored = false;
6208
- const finish = () => {
6209
- clearTimeout(timer);
6210
- resolve();
6211
- };
6212
- this.subscribe(id, filter, {
6213
- event: (e) => {
6214
- if (events.length < 20) events.push(e);
6215
- if (stored) finish();
6216
- },
6217
- eose: () => {
6218
- stored = true;
6219
- if (events.length || !waitForOne) finish();
6220
- }
6221
- });
6222
- });
6223
- } finally {
6224
- this.unsubscribe(id);
6225
- }
6226
- return events;
6227
- }
6228
- /**
6229
- * Resolves once the relay accepted the event. `NotDelivered` when it certainly did not reach it (no open
6230
- * socket, refused); any other rejection means it may have.
6231
- */
6232
- async publish(event) {
6233
- let socket;
6234
- try {
6235
- socket = await this.open();
6236
- } catch (error) {
6237
- throw new NotDelivered(error instanceof Error ? error.message : "Could not reach the wallet's relay");
6238
- }
6239
- return new Promise((resolve, reject) => {
6240
- const timer = setTimeout(() => {
6241
- this.acks.delete(event.id);
6242
- reject(/* @__PURE__ */ new Error("The relay did not confirm the request"));
6243
- }, ACK_MS);
6244
- this.acks.set(event.id, {
6245
- ok: () => {
6246
- clearTimeout(timer);
6247
- resolve();
6248
- },
6249
- fail: (error) => {
6250
- clearTimeout(timer);
6251
- reject(error);
6252
- }
6253
- });
6254
- if (socket.readyState !== 1 || socket !== this.socket) {
6255
- this.acks.delete(event.id);
6256
- clearTimeout(timer);
6257
- reject(new NotDelivered("The relay connection closed"));
6258
- return;
6259
- }
6260
- socket.send(JSON.stringify(["EVENT", event]));
6261
- });
6262
- }
6263
- close() {
6264
- this.closed = true;
6265
- for (const [id, ack] of this.acks) {
6266
- this.acks.delete(id);
6267
- ack.fail(/* @__PURE__ */ new Error("The wallet connection is closed"));
6268
- }
6269
- this.subs.clear();
6270
- try {
6271
- this.socket?.close();
6272
- } catch {}
6273
- this.socket = void 0;
6274
- }
6275
- write(message) {
6276
- if (this.socket?.readyState === 1) this.socket.send(JSON.stringify(message));
6277
- }
6278
- receive(data) {
6279
- if (typeof data !== "string" || data.length > MAX_MESSAGE) return;
6280
- let message;
6281
- try {
6282
- message = JSON.parse(data);
6283
- } catch {
6284
- return;
6285
- }
6286
- if (!Array.isArray(message) || typeof message[1] !== "string") return;
6287
- const [type, id] = message;
6288
- if (type === "EVENT") this.subs.get(id)?.event(message[2]);
6289
- else if (type === "EOSE") this.subs.get(id)?.eose?.();
6290
- else if (type === "OK") {
6291
- const ack = this.acks.get(id);
6292
- if (!ack) return;
6293
- this.acks.delete(id);
6294
- const reason = typeof message[3] === "string" ? message[3].slice(0, 120) : "";
6295
- if (message[2] === true || reason.startsWith("duplicate:")) ack.ok();
6296
- else ack.fail(new NotDelivered(`The relay refused the request${reason ? ` (${reason})` : ""}`));
6297
- }
6298
- }
6299
- };
6300
- var NwcLightning = class NwcLightning {
6301
- connection;
6302
- relay;
6303
- encryption;
6304
- options;
6305
- /** From the info event, then narrowed to what this connection may do (`get_info`). */
6306
- capabilities;
6307
- clientPubkey;
6308
- subscription = `nwc${hex$6(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(6)))}`;
6309
- pending = /* @__PURE__ */ new Map();
6310
- conversationKey;
6311
- network = "bitcoin";
6312
- alias;
6313
- closed = false;
6314
- constructor(connection, relay, encryption, methods, options) {
6315
- this.connection = connection;
6316
- this.relay = relay;
6317
- this.encryption = encryption;
6318
- this.options = options;
6319
- this.clientPubkey = getPublicKey(connection.secret);
6320
- if (encryption === "nip44_v2") this.conversationKey = nip44.v2.utils.getConversationKey(connection.secret, connection.walletPubkey);
6321
- this.capabilities = this.capabilitiesOf(methods);
6322
- }
6323
- /**
6324
- * Reaches the wallet through the first relay that answers, reads its info event (the methods and the
6325
- * encryption it offers), listens for its answers, and asks `get_info` for its network.
6326
- */
6327
- static async connect(uri, options = {}) {
6328
- const connection = parseNwcUri(uri);
6329
- options.signal?.throwIfAborted();
6330
- let relay, lastError;
6331
- for (const url of connection.relays) {
6332
- const candidate = new RelayConnection(url, options.WebSocket);
6333
- try {
6334
- await candidate.open();
6335
- relay = candidate;
6336
- break;
6337
- } catch (error) {
6338
- candidate.close();
6339
- lastError = error;
6340
- }
6341
- }
6342
- if (!relay) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("Could not reach the wallet's relay");
6343
- let wallet;
6344
- try {
6345
- const info = NwcLightning.newestInfo(await relay.fetch({
6346
- kinds: [KIND.info],
6347
- authors: [connection.walletPubkey],
6348
- limit: 5
6349
- }, {
6350
- waitForOne: true,
6351
- ms: options.infoMs
6352
- }), connection.walletPubkey);
6353
- if (!info) throw new Error(`The wallet has not published its NWC info on ${new URL(relay.url).host}: check the URI, or that the wallet is running`);
6354
- const offered = (info.tags.find((t) => t[0] === "encryption")?.slice(1).join(" ") ?? "").split(/\s+/).filter(Boolean);
6355
- const encryption = offered.includes("nip44_v2") ? "nip44_v2" : !offered.length || offered.includes("nip04") ? "nip04" : void 0;
6356
- if (!encryption) throw new Error("The wallet offers no encryption Ghostly speaks (NIP-44 or NIP-04)");
6357
- const methods = new Set(info.content.split(/\s+/).filter(Boolean));
6358
- wallet = new NwcLightning(connection, relay, encryption, methods, options);
6359
- wallet.listen();
6360
- if (options.signal) {
6361
- const opened = wallet;
6362
- if (options.signal.aborted) throw options.signal.reason;
6363
- options.signal.addEventListener("abort", () => void opened.close(), { once: true });
6364
- }
6365
- await wallet.readInfo(methods);
6366
- return wallet;
6367
- } catch (error) {
6368
- if (wallet) await wallet.close();
6369
- else relay.close();
6370
- throw error;
6371
- }
6372
- }
6373
- static newestInfo(events, walletPubkey) {
6374
- return events.filter((e) => e && e.kind === KIND.info && e.pubkey === walletPubkey && typeof e.content === "string" && e.content.length <= MAX_INFO && Array.isArray(e.tags) && verifyEvent(e)).sort((a, b) => b.created_at - a.created_at)[0];
6375
- }
6376
- capabilitiesOf(methods) {
6377
- const lookup = methods.has("lookup_invoice");
6378
- return {
6379
- receive: methods.has("make_invoice"),
6380
- send: methods.has("pay_invoice") && lookup,
6381
- balance: methods.has("get_balance"),
6382
- lookup
6383
- };
6384
- }
6385
- /**
6386
- * `get_info` says the network and the methods this connection may use (the info event lists the
6387
- * service's). Without it the wallet is taken to be on Bitcoin: a test wallet is then refused in the
6388
- * Testnet mode and its invoices in the Mainnet one, while a real-money wallet never passes for a test one.
6389
- */
6390
- async readInfo(offered) {
6391
- if (!offered.has("get_info")) return;
6392
- let info;
6393
- try {
6394
- info = await this.request("get_info", {});
6395
- } catch (error) {
6396
- if (error instanceof NwcError && [
6397
- "RESTRICTED",
6398
- "UNAUTHORIZED",
6399
- "NOT_IMPLEMENTED"
6400
- ].includes(error.code)) return;
6401
- throw error;
6402
- }
6403
- if (info.network !== void 0) {
6404
- const network = typeof info.network === "string" ? NETWORKS$1[info.network.toLowerCase()] : void 0;
6405
- if (!network) throw new Error("The wallet is on a network Ghostly does not know");
6406
- this.network = network;
6407
- }
6408
- if (typeof info.alias === "string" && info.alias.trim()) this.alias = info.alias.trim().slice(0, 40);
6409
- if (Array.isArray(info.methods) && info.methods.every((m) => typeof m === "string")) this.capabilities = this.capabilitiesOf(new Set(info.methods));
6410
- }
6411
- listen() {
6412
- this.relay.subscribe(this.subscription, {
6413
- kinds: [KIND.response],
6414
- authors: [this.connection.walletPubkey],
6415
- "#p": [this.clientPubkey],
6416
- since: now$1() - 60
6417
- }, { event: (e) => this.answer(e) });
6418
- }
6419
- encrypt(text) {
6420
- return this.conversationKey ? nip44.v2.encrypt(text, this.conversationKey) : nip04.encrypt(this.connection.secret, this.connection.walletPubkey, text);
6421
- }
6422
- decrypt(content) {
6423
- if (this.conversationKey && !content.includes("?iv=")) return nip44.v2.decrypt(content, this.conversationKey);
6424
- return nip04.decrypt(this.connection.secret, this.connection.walletPubkey, content);
6425
- }
6426
- /**
6427
- * Sends one request and waits for its answer. `NotDelivered` when it never reached the relay; `NwcError`
6428
- * for an error the wallet answered; `NoAnswer` (or a relay error) when the outcome is unknown.
6429
- */
6430
- async request(method, params, { ms = this.options.requestMs ?? REQUEST_MS$1, expiresIn } = {}) {
6431
- if (this.closed) throw new NotDelivered("The wallet connection is closed");
6432
- const tags = [["p", this.connection.walletPubkey]];
6433
- if (this.conversationKey) tags.push(["encryption", "nip44_v2"]);
6434
- if (expiresIn) tags.push(["expiration", String(now$1() + expiresIn)]);
6435
- const event = finalizeEvent({
6436
- kind: KIND.request,
6437
- created_at: now$1(),
6438
- tags,
6439
- content: this.encrypt(JSON.stringify({
6440
- method,
6441
- params
6442
- }))
6443
- }, this.connection.secret);
6444
- const answer = new Promise((resolve, reject) => {
6445
- const timer = setTimeout(() => {
6446
- this.pending.delete(event.id);
6447
- reject(new NoAnswer(method));
6448
- }, ms);
6449
- this.pending.set(event.id, {
6450
- method,
6451
- resolve,
6452
- reject,
6453
- timer
6454
- });
6455
- });
6456
- const delivered = this.relay.publish(event).then(() => void 0, (error) => {
6457
- const waiting = this.pending.get(event.id);
6458
- if (!(error instanceof NotDelivered) || !waiting) return void 0;
6459
- clearTimeout(waiting.timer);
6460
- this.pending.delete(event.id);
6461
- return error;
6462
- });
6463
- return Promise.race([answer, delivered.then((error) => error ? Promise.reject(error) : answer)]);
6464
- }
6465
- /** Reads an answer: from the wallet, signed, about a request of ours, of the method asked. Anything else is dropped. */
6466
- answer(raw) {
6467
- const event = raw;
6468
- if (!event || typeof event !== "object" || event.kind !== KIND.response || event.pubkey !== this.connection.walletPubkey) return;
6469
- if (typeof event.content !== "string" || event.content.length > MAX_CONTENT || !Array.isArray(event.tags)) return;
6470
- const ref = event.tags.find((t) => Array.isArray(t) && t[0] === "e")?.[1];
6471
- const pending = typeof ref === "string" ? this.pending.get(ref) : void 0;
6472
- if (!pending || !event.tags.some((t) => Array.isArray(t) && t[0] === "p" && t[1] === this.clientPubkey)) return;
6473
- if (!verifyEvent(event)) return;
6474
- let body;
6475
- try {
6476
- body = JSON.parse(this.decrypt(event.content));
6477
- } catch {
6478
- return;
6479
- }
6480
- if (!body || typeof body !== "object" || body.result_type !== pending.method) return;
6481
- clearTimeout(pending.timer);
6482
- this.pending.delete(ref);
6483
- if (body.error) {
6484
- const code = typeof body.error.code === "string" ? body.error.code.slice(0, 40) : "OTHER";
6485
- const message = typeof body.error.message === "string" && body.error.message.trim() ? body.error.message.trim().slice(0, 200) : code;
6486
- pending.reject(new NwcError(code, message));
6487
- } else if (body.result && typeof body.result === "object" && !Array.isArray(body.result)) pending.resolve(body.result);
6488
- else pending.reject(new NwcError("OTHER", "The wallet sent an empty answer"));
6489
- }
6490
- async lookup(paymentHash) {
6491
- const tx = await this.request("lookup_invoice", { payment_hash: paymentHash });
6492
- if (tx.payment_hash !== void 0 && String(tx.payment_hash).toLowerCase() !== paymentHash) throw new Error("The wallet answered about another invoice");
6493
- return tx;
6494
- }
6495
- async info() {
6496
- let balance;
6497
- if (this.capabilities.balance) {
6498
- const result = await this.request("get_balance", {});
6499
- if (!isMsat(result.balance)) throw new Error("The wallet returned an invalid balance");
6500
- balance = Math.floor(result.balance / 1e3);
6501
- }
6502
- return {
6503
- network: this.network,
6504
- alias: `${this.alias ?? "NWC wallet"} via ${new URL(this.relay.url).host}`,
6505
- balance
6506
- };
6507
- }
6508
- async createInvoice(amount, memo) {
6509
- if (!Number.isSafeInteger(amount) || amount <= 0 || !Number.isSafeInteger(amount * 1e3)) throw new Error("Invalid amount");
6510
- const result = await this.request("make_invoice", {
6511
- amount: amount * 1e3,
6512
- ...memo ? { description: memo.slice(0, 200) } : {},
6513
- expiry: INVOICE_EXPIRY_S$1
6514
- });
6515
- const text = typeof result.invoice === "string" && result.invoice.length <= MAX_INVOICE ? result.invoice : "";
6516
- const decoded = decodeBolt11(text);
6517
- if (!decoded?.paymentHash || decoded.amountMsat !== BigInt(amount) * 1000n) throw new Error("The wallet returned an invoice that does not match the request");
6518
- if (result.payment_hash !== void 0 && String(result.payment_hash).toLowerCase() !== decoded.paymentHash) throw new Error("The wallet returned an invoice that does not match its payment hash");
6519
- return {
6520
- invoice: decoded.invoice,
6521
- paymentHash: decoded.paymentHash,
6522
- amount,
6523
- expiresAt: decoded.expiresAt * 1e3
6524
- };
6525
- }
6526
- async invoiceStatus(invoice) {
6527
- let tx;
6528
- try {
6529
- tx = await this.lookup(invoice.paymentHash);
6530
- } catch (error) {
6531
- if (error instanceof NwcError && error.code === "NOT_FOUND" && invoice.expiresAt < Date.now()) return { state: "expired" };
6532
- throw error;
6533
- }
6534
- const expired = invoice.expiresAt < Date.now();
6535
- if (tx.type === "outgoing") return { state: expired ? "expired" : "open" };
6536
- const state = tx.state ?? (tx.settled_at ? "settled" : "pending");
6537
- if (state === "settled") return {
6538
- state: "paid",
6539
- amount: isMsat(tx.amount) ? Math.floor(tx.amount / 1e3) : invoice.amount
6540
- };
6541
- if (state === "expired" || state === "failed") return { state: "expired" };
6542
- return { state: expired ? "expired" : "open" };
6543
- }
6544
- /**
6545
- * Pays through the wallet. NIP-47 carries no fee limit: the wallet applies its own, and the fee it
6546
- * reports is passed on as it is.
6547
- */
6548
- async payInvoice(invoice, _maxFee) {
6549
- const decoded = decodeBolt11(invoice);
6550
- if (!decoded?.paymentHash || decoded.amountSat === null) throw new NothingSpentError("That invoice cannot be paid through NWC");
6551
- if (!this.capabilities.send) throw new NothingSpentError("This wallet connection may not pay and look up invoices");
6552
- let result;
6553
- try {
6554
- result = await this.request("pay_invoice", { invoice: decoded.invoice }, {
6555
- ms: this.options.payMs ?? PAY_MS,
6556
- expiresIn: PAY_EXPIRY_S
6557
- });
6558
- } catch (error) {
6559
- if (error instanceof NotDelivered) throw new NothingSpentError(`The payment did not reach the wallet: ${error.message}`);
6560
- if (error instanceof NwcError && REFUSED_BEFORE_PAYING.has(error.code)) throw new NothingSpentError(`The wallet refused the payment: ${error.message}`);
6561
- if (error instanceof NwcError) return this.confirmFailure(decoded.invoice, decoded.paymentHash, error);
6562
- throw error;
6563
- }
6564
- const preimage = typeof result.preimage === "string" ? result.preimage.toLowerCase() : "";
6565
- if (!HEX64$3.test(preimage) || hex$6(sha256(bytes$2(preimage))) !== decoded.paymentHash) throw new Error("The wallet said it paid, without a proof that matches the invoice");
6566
- return {
6567
- state: "paid",
6568
- preimage,
6569
- fee: isMsat(result.fees_paid) ? Math.ceil(result.fees_paid / 1e3) : void 0
6570
- };
6571
- }
6572
- /**
6573
- * An error after the wallet may have tried (`PAYMENT_FAILED` "may be due to a timeout", `INTERNAL`,
6574
- * `OTHER`…): its own record decides. Only a payment it records as failed is `NothingSpentError`; one it
6575
- * records as paid is paid; anything else is pending, or unknown when it cannot be asked.
6576
- */
6577
- async confirmFailure(invoice, paymentHash, failure) {
6578
- let status;
6579
- try {
6580
- status = await this.paymentStatus({
6581
- invoice,
6582
- paymentHash
6583
- });
6584
- } catch {
6585
- throw new Error(`The wallet said the payment failed (${failure.message}), and cannot confirm it yet`);
6586
- }
6587
- if (status.state === "failed") throw new NothingSpentError(`The payment failed: ${failure.message}`);
6588
- return status.state === "paid" ? {
6589
- state: "paid",
6590
- fee: status.fee,
6591
- preimage: status.preimage
6592
- } : { state: "pending" };
6593
- }
6594
- async paymentStatus(payment) {
6595
- let tx;
6596
- try {
6597
- tx = await this.lookup(payment.paymentHash);
6598
- } catch (error) {
6599
- if (!(error instanceof NwcError && error.code === "NOT_FOUND")) throw error;
6600
- return { state: (decodeBolt11(payment.invoice)?.expiresAt ?? Infinity) * 1e3 + EXPIRED_MARGIN_MS < Date.now() ? "failed" : "pending" };
6601
- }
6602
- if (tx.type === "incoming") return { state: "pending" };
6603
- const preimage = typeof tx.preimage === "string" && HEX64$3.test(tx.preimage.toLowerCase()) ? tx.preimage.toLowerCase() : void 0;
6604
- const proven = !!preimage && hex$6(sha256(bytes$2(preimage))) === payment.paymentHash;
6605
- const state = tx.state ?? (tx.settled_at || proven ? "settled" : "pending");
6606
- const fee = isMsat(tx.fees_paid) ? Math.ceil(tx.fees_paid / 1e3) : void 0;
6607
- if (state === "settled") {
6608
- if (preimage && !proven) throw new Error("The wallet's proof of payment does not match the invoice");
6609
- return {
6610
- state: "paid",
6611
- fee,
6612
- preimage
6613
- };
6614
- }
6615
- if (state === "failed" || state === "expired") return { state: "failed" };
6616
- return { state: "pending" };
6617
- }
6618
- async close() {
6619
- if (this.closed) return;
6620
- this.closed = true;
6621
- for (const [id, pending] of this.pending) {
6622
- clearTimeout(pending.timer);
6623
- this.pending.delete(id);
6624
- pending.reject(/* @__PURE__ */ new Error("The wallet connection is closed"));
6625
- }
6626
- this.relay.close();
6627
- }
6628
- };
6629
- var nwc = {
6630
- id: "nwc",
6631
- label: "Nostr Wallet Connect",
6632
- kind: "lightning",
6633
- description: "A wallet you connect with its NWC URI (Alby Hub, a node's NWC service, a hosted wallet). That wallet holds the sats and sets its own fee limit: custodial or not depends on the wallet.",
6634
- networks: [
6635
- "bitcoin",
6636
- "testnet",
6637
- "signet",
6638
- "mutinynet",
6639
- "regtest"
6640
- ],
6641
- platforms: PROVIDER_PLATFORMS,
6642
- fields: [{
6643
- name: "uri",
6644
- label: "Connection URI",
6645
- kind: "secret",
6646
- placeholder: "nostr+walletconnect://…",
6647
- help: "From your wallet's app connections. Allow making, paying and looking up invoices, and reading the balance."
6648
- }],
6649
- validate({ secrets }) {
6650
- parseNwcUri(secrets.uri ?? "");
6651
- },
6652
- async create({ secrets }, host) {
6653
- return NwcLightning.connect(secrets.uri, { signal: host.signal });
6654
- }
6655
- };
6656
5593
  /** What the recommended macaroon grants: `lncli bakemacaroon info:read invoices:read invoices:write offchain:read offchain:write`. */
6657
5594
  var LND_SCOPE = [
6658
5595
  "info:read",
@@ -7767,6 +6704,28 @@ function defaultRegistry() {
7767
6704
  };
7768
6705
  }
7769
6706
  //#endregion
6707
+ //#region ../browser/src/proofs/load.ts
6708
+ /**
6709
+ * The identity proof providers, loaded on first need and not with the app: they and their libraries (OpenPGP, the
6710
+ * Bitcoin signer, the DID, AT Protocol and OpenID Connect checks) are a large part of the code, and the first screen
6711
+ * draws none of it. The engine loads them with the profile's proofs; the UI once it draws something about identities.
6712
+ */
6713
+ var registry;
6714
+ var loading$1;
6715
+ var listeners = /* @__PURE__ */ new Set();
6716
+ function loadIdentityProviders() {
6717
+ return loading$1 ??= import("./registry-OBJBnHVQ.js").then((m) => {
6718
+ registry = m;
6719
+ for (const listener of listeners) listener();
6720
+ return m;
6721
+ }, (error) => {
6722
+ loading$1 = void 0;
6723
+ throw error;
6724
+ });
6725
+ }
6726
+ /** The providers (built in, from plugins, the test ones when asked for), once loaded; none before. */
6727
+ var loadedIdentityProviders = () => registry?.identityProviders() ?? [];
6728
+ //#endregion
7770
6729
  //#region ../browser/src/engine/identities.ts
7771
6730
  var KEY$2 = "identityProofs";
7772
6731
  var REVOCATIONS = "identityRevocations";
@@ -7807,14 +6766,17 @@ var IdentityProofs = class {
7807
6766
  /** Chats whose current connection already got the hello: "ready" is reported more than once per connection. */
7808
6767
  greeted = /* @__PURE__ */ new Set();
7809
6768
  fetch;
7810
- constructor(host, providers = identityProviders) {
6769
+ constructor(host, providers = loadedIdentityProviders) {
7811
6770
  this.host = host;
7812
6771
  this.providers = providers;
7813
6772
  this.fetch = boundedIdentityFetch({ online: host.online });
7814
6773
  }
6774
+ /** The profile's proofs, and the providers that check and show them: loaded here, not with the app. */
7815
6775
  async load() {
6776
+ const providers = loadIdentityProviders();
7816
6777
  const settings = await store(STORES$1.settings, "readonly");
7817
6778
  [this.proofs, this.revocations] = await Promise.all([wrap(settings.get(KEY$2)).then((v) => v ?? []), wrap(settings.get(REVOCATIONS)).then((v) => v ?? [])]);
6779
+ await providers;
7818
6780
  }
7819
6781
  /** Background work: republish revocations, look up the contacts' proofs for revocations. */
7820
6782
  start() {
@@ -11154,18 +10116,39 @@ var DEFAULT_APP_STORES = [{
11154
10116
  url: "https://raw.githubusercontent.com/MiguelMedeiros/ghostly-store/HEAD/ghostly-store.json",
11155
10117
  key: "y379ia3t1urwudj8o4w1qwmuwp1mcxyf956b5r7pqup7stce6diy"
11156
10118
  }];
10119
+ /**
10120
+ * The apps that come with Ghostly. A short list kept here on purpose, never "everything in the official store": an
10121
+ * entry is added with the pin of its bundle in apps/ui/src/assets/apps/built-in.json, which the build reads from the
10122
+ * app's own repository (apps/ui/scripts/built-in-apps.mjs); no bundle is kept in this repository.
10123
+ *
10124
+ * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.0 (`sequence` 4), at the
10125
+ * commit of its repository that the official store lists.
10126
+ */
10127
+ var BUILT_IN_APPS = [{
10128
+ publisher: "odcgw6wjw8dynqop84r47jbjcdqossbgrfiejd367e14hgxmjcho",
10129
+ name: "chess",
10130
+ digest: "EEnFmP0grTkTe_9qgapQsEEZzWB9FcpoimWkHnfecVs",
10131
+ url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@c665ab194478145fd9b9a4777e6c0d1893969649/app.ghostlyapp",
10132
+ title: "Chess",
10133
+ tagline: "Play chess with a contact, live in your chat"
10134
+ }];
11157
10135
  var APP_STORAGE_LIMITS = {
11158
10136
  /** A key, in UTF-8 bytes. */
11159
10137
  keyBytes: 256,
11160
10138
  /** A value, as compact JSON in UTF-8 bytes. */
11161
10139
  valueBytes: 65536,
11162
10140
  /**
11163
- * Counted for each key beside its own bytes and its value's: what the row costs on the device (the app's reference,
11164
- * the scope and the key again, in the row's own key and in its two indexes). Without it a scope of one-byte values
11165
- * held 20 times its 5 MiB.
10141
+ * Counted for each key beside its own bytes and its value's: what the row costs on the device (the app's reference and
10142
+ * the scope, in the row's own key and in its two indexes). Without it a scope of one-byte values held 20 times its 5 MiB.
11166
10143
  */
11167
10144
  rowBytes: 128,
11168
- /** Every key and value of one app in one scope, with `rowBytes` for each key. */
10145
+ /**
10146
+ * How many times a key's bytes count. The device keeps a key four times (the row's own key, its value and both
10147
+ * indexes), three of them as UTF-16, about 10 bytes for each byte of the key: counted once, a scope of 256-byte keys
10148
+ * took 40 to 55 MB for its 5 MiB.
10149
+ */
10150
+ keyTimes: 8,
10151
+ /** Every key and value of one app in one scope, with `rowBytes` for each key and its bytes `keyTimes` over. */
11169
10152
  scopeBytes: 5242880
11170
10153
  };
11171
10154
  var APP_CHECK_TIMINGS = {
@@ -11178,22 +10161,74 @@ var APP_CHECK_TIMINGS = {
11178
10161
  /** Revocations are checked this long at a time before the page's thread is let go. */
11179
10162
  var REVOCATIONS_SLICE_MS = 10;
11180
10163
  /**
10164
+ * `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first):
10165
+ * at most this many, and of the ones whose list is held at most one file's limit of bytes in all. A publisher's file is
10166
+ * small; nine large ones beside one app's URLs, or one that changes at every check, would stay for the engine's life.
10167
+ */
10168
+ var REVOCATION_FILES_KEPT = {
10169
+ max: 64,
10170
+ bytes: APP_FETCH_LIMITS.revocationsBytes
10171
+ };
10172
+ /**
11181
10173
  * Revocations of store indexes whose signature was checked, kept by `appRevocationId` so an index that changes around
11182
10174
  * them does not have each checked again (the one read longest ago dropped first): four indexes' worth, about 1 MiB.
11183
10175
  */
11184
10176
  var STORE_REVOCATIONS_KEPT = 4 * APP_STORE_LIMITS.revoked;
11185
- /** `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first). */
11186
- var REVOCATION_FILES_KEPT = 64;
11187
10177
  /** A fetched bundle kept for its install screen: at most this many, for this long. */
11188
10178
  var STAGED = {
11189
10179
  max: 4,
11190
10180
  ms: 6e5
11191
10181
  };
10182
+ /**
10183
+ * Checked bundles kept in memory: the most recently used `min`, and beyond them each one a running app used (`entry`,
10184
+ * `file`, `fileAt`) within `ms`. The engine is not told when an app closes; one not used for that long goes, and is read
10185
+ * and checked again if it still runs.
10186
+ */
10187
+ var BUNDLES_KEPT = {
10188
+ min: 2,
10189
+ ms: 6e4
10190
+ };
11192
10191
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11193
10192
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11194
10193
  var bundleId = (digest) => `app-${digest}`;
11195
- /** What a row takes of its scope's 5 MiB: its key and value, and `rowBytes`. Not kept in the row, so rows from before count alike. */
11196
- var costOf = (row) => APP_STORAGE_LIMITS.rowBytes + row.size;
10194
+ /**
10195
+ * The key of a scope's counter row (`AppScopeCount` as JSON in `value`, `size` 0): an app's key is never empty
10196
+ * (`keyOf`), and the app's own rows of a scope are `keysOf`, which leaves it out.
10197
+ */
10198
+ var COUNT_KEY = "";
10199
+ var NO_ROWS = {
10200
+ rows: 0,
10201
+ keyBytes: 0,
10202
+ size: 0
10203
+ };
10204
+ /**
10205
+ * What rows take of their scope's 5 MiB: `rowBytes` each, their keys `keyTimes` over and their values. Not kept, so a
10206
+ * count from before counts alike.
10207
+ */
10208
+ var costOf = ({ rows, keyBytes, size }) => rows * APP_STORAGE_LIMITS.rowBytes + size + (APP_STORAGE_LIMITS.keyTimes - 1) * keyBytes;
10209
+ /** One row as a count, `sign` -1 to take it away. */
10210
+ var countOf = ({ key, size }, sign = 1) => ({
10211
+ rows: sign,
10212
+ keyBytes: sign * byteLength(key),
10213
+ size: sign * size
10214
+ });
10215
+ var plus = (a, b) => ({
10216
+ rows: a.rows + b.rows,
10217
+ keyBytes: a.keyBytes + b.keyBytes,
10218
+ size: a.size + b.size
10219
+ });
10220
+ function readCount(value) {
10221
+ try {
10222
+ const c = JSON.parse(value);
10223
+ return [
10224
+ c.rows,
10225
+ c.keyBytes,
10226
+ c.size
10227
+ ].every((n) => Number.isSafeInteger(n) && n >= 0) ? c : null;
10228
+ } catch {
10229
+ return null;
10230
+ }
10231
+ }
11197
10232
  /**
11198
10233
  * A listing's URLs this client reads, in order, a jsDelivr copy first: one this client reads names a full commit, so it
11199
10234
  * holds exactly the version the store reviewed, where a raw `HEAD` may have moved on to one it has not (WISP 1200 §
@@ -11209,6 +10244,46 @@ function fail(code, words) {
11209
10244
  throw new Error(`${code}: ${words}`);
11210
10245
  }
11211
10246
  /**
10247
+ * The key, in `STORES.settings`, of what the person did about the apps that come with Ghostly (one of
10248
+ * `PROFILE_SETTINGS_KEYS`: "Clear all data" drops it, and the profile is offered them as a new one is).
10249
+ */
10250
+ var BUILT_IN_APPS_KEY = "appBuiltIns";
10251
+ /** The marks as kept, read with nothing taken on trust: a record that is not one is no mark. */
10252
+ function builtInMarks(kept) {
10253
+ const removed = {};
10254
+ const held = kept?.removed;
10255
+ if (held && typeof held === "object") {
10256
+ for (const [ref, at] of Object.entries(held)) if (isAppRef(ref) && typeof at === "number") removed[ref] = at;
10257
+ }
10258
+ return { removed };
10259
+ }
10260
+ /** Queues, on the settings store of an open transaction, a change of the built-in apps' marks. */
10261
+ function markBuiltIns(settings, change) {
10262
+ const read = settings.get(BUILT_IN_APPS_KEY);
10263
+ read.onsuccess = () => {
10264
+ const marks = builtInMarks(read.result);
10265
+ change(marks);
10266
+ settings.put(marks, BUILT_IN_APPS_KEY);
10267
+ };
10268
+ }
10269
+ var builtInRef = (app) => appRef(app.publisher, app.name);
10270
+ /**
10271
+ * The entries of the built-in list this profile is offered (WISP 1200 § Built-in apps): each reference once, not
10272
+ * installed (`installed`, by reference) and not removed by the person. An entry that is not a whole one (its key, its
10273
+ * name, its digest, a URL off the list of `appFetch.ts`, no title or tagline) is skipped, as a build with no bundle
10274
+ * for it would be.
10275
+ */
10276
+ function builtInsDue(list, installed, marks) {
10277
+ const due = /* @__PURE__ */ new Map();
10278
+ for (const app of list) {
10279
+ if (!isAppKey(app.publisher) || !isAppHash(app.digest) || !isAppFetchUrl(app.url) || !app.title || !app.tagline) continue;
10280
+ const ref = builtInRef(app);
10281
+ if (!isAppRef(ref) || due.has(ref) || installed.has(ref) || marks.removed[ref] !== void 0) continue;
10282
+ due.set(ref, app);
10283
+ }
10284
+ return [...due.values()];
10285
+ }
10286
+ /**
11212
10287
  * Why a pasted link is not read: a github.com page that is neither a repository nor a file of one (`github-link`), so
11213
10288
  * the person is not told GitHub is off the list; any other host (`host`).
11214
10289
  */
@@ -11231,8 +10306,26 @@ function withRevocations(app, read) {
11231
10306
  revocations: [...merged.values()]
11232
10307
  } : app;
11233
10308
  }
11234
- /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
10309
+ /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`, counter rows included. */
11235
10310
  var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
10311
+ /** The app's own rows of one scope: its counter row left out. */
10312
+ var keysOf = (ref, scope) => IDBKeyRange.bound([
10313
+ ref,
10314
+ scope,
10315
+ COUNT_KEY
10316
+ ], [
10317
+ ref,
10318
+ scope,
10319
+ []
10320
+ ], true);
10321
+ /** A scope's counter row (`COUNT_KEY`). */
10322
+ var counterRow = (ref, scope, count) => ({
10323
+ ref,
10324
+ scope,
10325
+ key: COUNT_KEY,
10326
+ value: JSON.stringify(count),
10327
+ size: 0
10328
+ });
11236
10329
  function done(tx) {
11237
10330
  return new Promise((resolve, reject) => {
11238
10331
  tx.oncomplete = () => resolve();
@@ -11265,8 +10358,10 @@ function peekAppManifest(bytes) {
11265
10358
  var Apps = class {
11266
10359
  host;
11267
10360
  staged = /* @__PURE__ */ new Map();
11268
- /** Bundles read back and checked, by digest, least recently used first: the ones running are read once, not on every `file`. */
10361
+ /** Bundles read back and checked, by digest, least recently used first (`BUNDLES_KEPT`): the ones running are read once, not on every `file`. */
11269
10362
  verified = /* @__PURE__ */ new Map();
10363
+ /** When a running app last used its bundle, by digest (`BUNDLES_KEPT`): an icon drawn is not a use. */
10364
+ runUsed = /* @__PURE__ */ new Map();
11270
10365
  /** Reads of a bundle under way, by digest: icons drawn at once share one read. */
11271
10366
  verifying = /* @__PURE__ */ new Map();
11272
10367
  /**
@@ -11274,7 +10369,7 @@ var Apps = class {
11274
10369
  * page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
11275
10370
  */
11276
10371
  icons = /* @__PURE__ */ new Map();
11277
- /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
10372
+ /** What each `ref\0scope` holds, read once from its counter row and kept in step by the writes here. */
11278
10373
  usage = /* @__PURE__ */ new Map();
11279
10374
  queues = /* @__PURE__ */ new Map();
11280
10375
  timer = null;
@@ -11298,7 +10393,7 @@ var Apps = class {
11298
10393
  * index listing the same is not peeked again at every check.
11299
10394
  */
11300
10395
  listingMisses = /* @__PURE__ */ new Map();
11301
- /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused. */
10396
+ /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused, and the bytes the held list counts for. */
11302
10397
  revocationFiles = /* @__PURE__ */ new Map();
11303
10398
  /** Revocations read in a store's index and verified, by `appRevocationId`: at most `STORE_REVOCATIONS_KEPT`. */
11304
10399
  storeRevocations = /* @__PURE__ */ new Set();
@@ -11391,6 +10486,59 @@ var Apps = class {
11391
10486
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
11392
10487
  return await this.app(ref) ?? fail("not-installed", "This app is not installed");
11393
10488
  }
10489
+ /** The built-in list's entry of this reference, if it has one. */
10490
+ builtIn(ref) {
10491
+ return (this.host.builtIns ?? BUILT_IN_APPS).find((app) => builtInRef(app) === ref);
10492
+ }
10493
+ async builtInMarks() {
10494
+ return builtInMarks(await wrap((await store(STORES$1.settings, "readonly")).get(BUILT_IN_APPS_KEY)));
10495
+ }
10496
+ /**
10497
+ * The built-in apps this profile is offered (WISP 1200 § Built-in apps): shown with the installed ones, with nothing
10498
+ * stored and nothing granted. Opening one is an install like any other, from the bundle the build carries.
10499
+ */
10500
+ async builtIns() {
10501
+ const list = this.host.builtIns ?? BUILT_IN_APPS;
10502
+ if (!list.length || !this.host.readBuiltIn) return [];
10503
+ const [installed, marks] = await Promise.all([this.installed(), this.builtInMarks()]);
10504
+ return builtInsDue(list, new Set(installed.map((a) => a.ref)), marks).map((app) => ({
10505
+ ref: builtInRef(app),
10506
+ publisher: app.publisher,
10507
+ name: app.name,
10508
+ fingerprint: appFingerprint(app.publisher),
10509
+ digest: app.digest,
10510
+ url: app.url,
10511
+ title: app.title,
10512
+ tagline: app.tagline
10513
+ }));
10514
+ }
10515
+ /**
10516
+ * The bundle the build carries for a built-in app, checked as a fetched one is, and against its entry: the digest the
10517
+ * list pins and the entry's reference. Anything else in that file is not this app, and nothing of it is kept.
10518
+ */
10519
+ async bundled(app) {
10520
+ if (!this.host.readBuiltIn) fail("not-carried", "This build does not carry this app");
10521
+ let bytes;
10522
+ try {
10523
+ bytes = await this.host.readBuiltIn(app);
10524
+ } catch {
10525
+ fail("not-carried", "This build does not carry this app");
10526
+ }
10527
+ if (!(bytes instanceof Uint8Array) || bytes.length > APP_BUNDLE_LIMITS.bundleBytes) fail("not-carried", "This build does not carry this app");
10528
+ const read = await readAppBundleAsync(bytes);
10529
+ if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
10530
+ if (read.bundle.digest !== app.digest || versionOf(read.bundle.manifest, read.bundle.digest).ref !== builtInRef(app)) fail("unlisted", "The app this build carries is not the one it names");
10531
+ return {
10532
+ bundle: read.bundle,
10533
+ bytes
10534
+ };
10535
+ }
10536
+ /** The built-in entry of this reference while the profile is offered it (`builtIns`), else undefined. */
10537
+ async offered(ref) {
10538
+ const app = this.builtIn(ref);
10539
+ if (!app || !this.host.readBuiltIn) return void 0;
10540
+ return builtInsDue([app], await this.app(ref) ? /* @__PURE__ */ new Set([ref]) : /* @__PURE__ */ new Set(), await this.builtInMarks())[0];
10541
+ }
11394
10542
  /**
11395
10543
  * An installed app that may still run (WISP 1200 § Takedowns): a running app whose version its publisher revoked, or
11396
10544
  * a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
@@ -11543,7 +10691,7 @@ var Apps = class {
11543
10691
  */
11544
10692
  async writeBundle(digest, bytes) {
11545
10693
  const id = bundleId(digest);
11546
- const expected = toBase64Url(sha256(bytes));
10694
+ const expected = toBase64Url(await sha256Paced(bytes));
11547
10695
  let files;
11548
10696
  let stored;
11549
10697
  try {
@@ -11588,6 +10736,7 @@ var Apps = class {
11588
10736
  }
11589
10737
  async removeBundle(kind, digest) {
11590
10738
  this.verified.delete(digest);
10739
+ this.runUsed.delete(digest);
11591
10740
  this.icons.delete(digest);
11592
10741
  await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
11593
10742
  }
@@ -11597,11 +10746,13 @@ var Apps = class {
11597
10746
  if (app?.digest !== digest && app?.pending?.digest !== digest) await this.removeBundle(kind, digest);
11598
10747
  }
11599
10748
  /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
11600
- verifiedBundle(app) {
10749
+ verifiedBundle(app, running) {
10750
+ if (running) this.runUsed.set(app.digest, this.now());
11601
10751
  const kept = this.verified.get(app.digest);
11602
10752
  if (kept) {
11603
10753
  this.verified.delete(app.digest);
11604
10754
  this.verified.set(app.digest, kept);
10755
+ this.dropUnusedBundles();
11605
10756
  return Promise.resolve(kept);
11606
10757
  }
11607
10758
  const under = this.verifying.get(app.digest);
@@ -11609,11 +10760,10 @@ var Apps = class {
11609
10760
  const reading = (async () => {
11610
10761
  const bytes = await this.readBundleBytes(app.bytes, app.digest);
11611
10762
  if (!bytes) fail("needs-files", "This app's files are not on this device yet");
11612
- const read = readAppBundle(bytes);
10763
+ const read = await readAppBundleAsync(bytes);
11613
10764
  if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
11614
10765
  this.verified.set(app.digest, read.bundle);
11615
- const room = Math.max(2, this.started.size + (this.started.has(app.ref) ? 0 : 1));
11616
- while (this.verified.size > room) this.verified.delete(this.verified.keys().next().value);
10766
+ this.dropUnusedBundles();
11617
10767
  this.icons.set(app.digest, read.bundle.files.get("icon.png")?.slice() ?? null);
11618
10768
  return read.bundle;
11619
10769
  })().finally(() => {
@@ -11622,9 +10772,21 @@ var Apps = class {
11622
10772
  this.verifying.set(app.digest, reading);
11623
10773
  return reading;
11624
10774
  }
10775
+ /**
10776
+ * Apps running at once do not push each other's bundle out, which every `file` read would then read and check again
10777
+ * whole; one closed (or only drawn as an icon) goes once two newer ones are kept, at the next bundle read or hit.
10778
+ */
10779
+ dropUnusedBundles() {
10780
+ for (const digest of this.verified.keys()) {
10781
+ if (this.verified.size <= BUNDLES_KEPT.min) break;
10782
+ if (this.now() - (this.runUsed.get(digest) ?? -Infinity) < BUNDLES_KEPT.ms) continue;
10783
+ this.verified.delete(digest);
10784
+ this.runUsed.delete(digest);
10785
+ }
10786
+ }
11625
10787
  async fetchBundle(url) {
11626
10788
  const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
11627
- const read = readAppBundle(bytes);
10789
+ const read = await readAppBundleAsync(bytes);
11628
10790
  if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
11629
10791
  return {
11630
10792
  bundle: read.bundle,
@@ -11640,7 +10802,7 @@ var Apps = class {
11640
10802
  */
11641
10803
  async readStoreAt(url, heldKey, held) {
11642
10804
  const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
11643
- if (held?.index && held.digest !== void 0 && toBase64Url(sha256(indexBytes)) === held.digest) return {
10805
+ if (held?.index && held.digest !== void 0 && toBase64Url(await sha256Paced(indexBytes)) === held.digest) return {
11644
10806
  ok: true,
11645
10807
  store: {
11646
10808
  index: held.index,
@@ -11649,14 +10811,11 @@ var Apps = class {
11649
10811
  }
11650
10812
  };
11651
10813
  const steps = readAppStoreSteps(indexBytes, sigBytes, this.nowS(), heldKey, this.checkedRevocations);
11652
- let since = performance.now();
10814
+ const pause = pacer(REVOCATIONS_SLICE_MS);
11653
10815
  for (;;) {
11654
10816
  const step = steps.next();
11655
10817
  if (step.done) return step.value;
11656
- if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11657
- await new Promise((resolve) => setTimeout(resolve, 0));
11658
- since = performance.now();
11659
- }
10818
+ await pause();
11660
10819
  }
11661
10820
  }
11662
10821
  /**
@@ -11680,19 +10839,16 @@ var Apps = class {
11680
10839
  * engine shares the page's thread, and each entry is a signature check. The same bytes are not checked again.
11681
10840
  */
11682
10841
  async readRevocationsFile(bytes) {
11683
- const id = toBase64Url(sha256(bytes));
10842
+ const id = toBase64Url(await sha256Paced(bytes));
11684
10843
  const known = this.revocationFiles.get(id);
11685
- if (known !== void 0) return known;
10844
+ if (known !== void 0) return known.revocations;
11686
10845
  const read = readCanonicalJson(bytes);
11687
10846
  let out = null;
11688
10847
  if (read.ok && Array.isArray(read.value) && read.value.length <= APP_STATEMENT_LIMITS.revocations) {
11689
10848
  out = [];
11690
- let since = performance.now();
10849
+ const pause = pacer(REVOCATIONS_SLICE_MS);
11691
10850
  for (const entry of read.value) {
11692
- if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11693
- await new Promise((resolve) => setTimeout(resolve, 0));
11694
- since = performance.now();
11695
- }
10851
+ await pause();
11696
10852
  const one = readAppRevocation(entry);
11697
10853
  if (!one.ok) {
11698
10854
  out = null;
@@ -11702,8 +10858,17 @@ var Apps = class {
11702
10858
  }
11703
10859
  }
11704
10860
  this.revocationFiles.delete(id);
11705
- this.revocationFiles.set(id, out);
11706
- for (const old of this.revocationFiles.keys()) if (this.revocationFiles.size > REVOCATION_FILES_KEPT) this.revocationFiles.delete(old);
10861
+ this.revocationFiles.set(id, {
10862
+ revocations: out,
10863
+ bytes: out ? bytes.length : 0
10864
+ });
10865
+ let held = 0;
10866
+ for (const file of this.revocationFiles.values()) held += file.bytes;
10867
+ for (const [old, file] of this.revocationFiles) {
10868
+ if (this.revocationFiles.size <= REVOCATION_FILES_KEPT.max && held <= REVOCATION_FILES_KEPT.bytes) break;
10869
+ this.revocationFiles.delete(old);
10870
+ held -= file.bytes;
10871
+ }
11707
10872
  return out;
11708
10873
  }
11709
10874
  summary(s) {
@@ -11881,6 +11046,12 @@ var Apps = class {
11881
11046
  let urls;
11882
11047
  let expect = null;
11883
11048
  let fromStore;
11049
+ if ("builtIn" in source) {
11050
+ if (!isAppRef(source.builtIn)) fail("bad-ref", "Not an app reference");
11051
+ const app = await this.offered(source.builtIn) ?? fail("not-offered", "This app does not come with Ghostly here");
11052
+ const { bundle, bytes } = await this.bundled(app);
11053
+ return this.stage(bundle, bytes, app.url, void 0, true);
11054
+ }
11884
11055
  if ("url" in source) urls = [typeof source.url === "string" && appPasteUrl(source.url, "app.ghostlyapp") || notReadable(source.url, "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS")];
11885
11056
  else if ("store" in source) {
11886
11057
  const s = await this.storeRecord(source.store);
@@ -11898,6 +11069,11 @@ var Apps = class {
11898
11069
  const { ref, url, sequence, digest } = source.card;
11899
11070
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
11900
11071
  if (!isAppFetchUrl(url)) fail("host", "A card's app is read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit");
11072
+ const carried = typeof digest === "string" ? await this.offered(ref) : void 0;
11073
+ if (carried && carried.digest === digest) {
11074
+ const held = await this.bundled(carried).catch(() => null);
11075
+ if (held) return this.stage(held.bundle, held.bytes, carried.url, void 0, true);
11076
+ }
11901
11077
  urls = [url];
11902
11078
  expect = {
11903
11079
  ref,
@@ -11929,7 +11105,7 @@ var Apps = class {
11929
11105
  const oldest = this.staged.values().next().value;
11930
11106
  if (oldest) this.sweep = setTimeout(() => this.sweepStaged(), STAGED.ms - (this.now() - oldest.at));
11931
11107
  }
11932
- async stage(bundle, bytes, from, fromStore) {
11108
+ async stage(bundle, bytes, from, fromStore, carried = false) {
11933
11109
  for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
11934
11110
  this.staged.delete(bundle.digest);
11935
11111
  while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
@@ -11960,7 +11136,8 @@ var Apps = class {
11960
11136
  asks,
11961
11137
  run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
11962
11138
  listedBy,
11963
- unknownPublisher: !listedBy.some((s) => s.kind === "curated")
11139
+ unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
11140
+ ...carried && { carried: true }
11964
11141
  };
11965
11142
  }
11966
11143
  /**
@@ -12014,6 +11191,7 @@ var Apps = class {
12014
11191
  const kind = await this.writeBundle(digest, bytes);
12015
11192
  const next = this.versionRecord(installed, bundle, kind, from, fromStore);
12016
11193
  await this.putApp(next);
11194
+ if (!installed && this.builtIn(version.ref)) await this.keepBuiltIn(version.ref);
12017
11195
  if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12018
11196
  if (installed?.pending && next.pending?.digest !== installed.pending.digest && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
12019
11197
  this.staged.delete(digest);
@@ -12052,7 +11230,7 @@ var Apps = class {
12052
11230
  const installed = await this.installedOrFail(ref);
12053
11231
  const pending = installed.pending ?? fail("no-update", "No update waits for this app");
12054
11232
  const bytes = await this.readBundleBytes(pending.bytes, pending.digest);
12055
- const read = bytes ? readAppBundle(bytes) : null;
11233
+ const read = bytes ? await readAppBundleAsync(bytes) : null;
12056
11234
  if (!read?.ok || read.bundle.digest !== pending.digest) {
12057
11235
  const { pending: _p, ...rest } = installed;
12058
11236
  await this.putApp(rest);
@@ -12287,6 +11465,26 @@ var Apps = class {
12287
11465
  const app = await this.installedOrFail(ref);
12288
11466
  const stores = await this.stores();
12289
11467
  if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
11468
+ const carried = this.builtIn(app.ref);
11469
+ if (carried && carried.digest === app.digest) {
11470
+ const held = await this.bundled(carried).catch(() => null);
11471
+ if (held) {
11472
+ const kind = await this.writeBundle(app.digest, held.bytes);
11473
+ const now = await this.app(app.ref);
11474
+ if (!now || now.digest !== app.digest) {
11475
+ await this.removeBundle(kind, app.digest);
11476
+ fail("not-installed", "This app is not installed");
11477
+ }
11478
+ if (now.bytes !== kind) await this.putApp({
11479
+ ...now,
11480
+ bytes: kind
11481
+ });
11482
+ return this.runCheckOf({
11483
+ ...now,
11484
+ bytes: kind
11485
+ }, stores);
11486
+ }
11487
+ }
12290
11488
  const urls = [app.from];
12291
11489
  for (const s of stores) {
12292
11490
  if (app.store !== void 0 && s.key !== app.store) continue;
@@ -12294,6 +11492,14 @@ var Apps = class {
12294
11492
  }
12295
11493
  if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
12296
11494
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
11495
+ try {
11496
+ if (peekAppManifest(await this.host.fetch(url, {
11497
+ maxBytes: PEEK_BYTES,
11498
+ peek: true
11499
+ }))?.digest !== app.digest) continue;
11500
+ } catch {
11501
+ continue;
11502
+ }
12297
11503
  let fetched;
12298
11504
  try {
12299
11505
  fetched = await this.fetchBundle(url);
@@ -12341,7 +11547,7 @@ var Apps = class {
12341
11547
  if (run.status === "removed" && runAnyway !== true) fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12342
11548
  if (run.status === "needs-files") fail("needs-files", "This app's files are not on this device yet");
12343
11549
  if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
12344
- const bundle = await this.verifiedBundle(app);
11550
+ const bundle = await this.verifiedBundle(app, true);
12345
11551
  const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
12346
11552
  this.ran.add(app.ref);
12347
11553
  const versions = this.started.get(app.ref) ?? /* @__PURE__ */ new Map();
@@ -12357,6 +11563,29 @@ var Apps = class {
12357
11563
  };
12358
11564
  }
12359
11565
  /**
11566
+ * Where a file of the installed bundle lies in this device's file storage, for a client that hands the bytes to the
11567
+ * app itself (Desktop: Rust reads them, checks their SHA-256 and gives them to the app's window, so they never cross
11568
+ * the Ghostly window). The same checks as `file`; null where the bundle is not in such storage, and for the icon,
11569
+ * which `file` answers from memory.
11570
+ */
11571
+ async fileAt({ ref, path, digest }) {
11572
+ if (path === "icon.png") return null;
11573
+ const app = await this.runningOrFail(ref, digest);
11574
+ if (digest !== void 0 && digest !== app.digest) fail("no-file", "No such file in this app");
11575
+ if (app.bytes !== "native") return null;
11576
+ const bundle = await this.verifiedBundle(app, true);
11577
+ const file = typeof path === "string" ? bundle.files.get(path) : void 0;
11578
+ const listed = bundle.manifest.files.find((f) => f.path === path);
11579
+ if (!file || !listed) fail("no-file", "No such file in this app");
11580
+ return {
11581
+ space: fileSpace(),
11582
+ id: bundleId(app.digest),
11583
+ offset: file.byteOffset,
11584
+ length: file.length,
11585
+ sha256: listed.sha256
11586
+ };
11587
+ }
11588
+ /**
12360
11589
  * A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`): a copy, which the broker transfers to
12361
11590
  * the app as it is. `digest`: the version the frame runs.
12362
11591
  */
@@ -12366,7 +11595,7 @@ var Apps = class {
12366
11595
  const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
12367
11596
  if (icon) return icon.slice();
12368
11597
  if (icon === null) fail("no-file", "No such file in this app");
12369
- const bundle = await this.verifiedBundle(app);
11598
+ const bundle = await this.verifiedBundle(app, path !== APP_ICON_PATH);
12370
11599
  if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12371
11600
  return bundle.files.get(path).slice();
12372
11601
  }
@@ -12397,6 +11626,7 @@ var Apps = class {
12397
11626
  run: await this.runCheckOf(app, stores),
12398
11627
  listedBy,
12399
11628
  unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
11629
+ ...this.builtIn(app.ref)?.digest === app.digest && { builtIn: true },
12400
11630
  ...app.pending && { pending: {
12401
11631
  sequence: app.pending.sequence,
12402
11632
  version: app.pending.manifest.version,
@@ -12416,23 +11646,44 @@ var Apps = class {
12416
11646
  }
12417
11647
  /**
12418
11648
  * Uninstalls an app: its bundle (and a waiting update's), its record and its storage in every scope. The person chose
12419
- * it, after the offer of an export (`exportData`).
11649
+ * it, after the offer of an export (`exportData`). A built-in app is marked removed in the same transaction, and one
11650
+ * the person never opened is only marked: no start and no update offers it again (WISP 1200 § Built-in apps).
12420
11651
  */
12421
11652
  async uninstall({ ref }) {
12422
- const app = await this.installedOrFail(ref);
12423
- const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
12424
- tx.objectStore(STORES$1.apps).delete(app.ref);
12425
- tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
11653
+ if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
11654
+ const app = await this.app(ref);
11655
+ const builtIn = this.builtIn(ref) !== void 0;
11656
+ if (!app && !builtIn) fail("not-installed", "This app is not installed");
11657
+ const tx = (await openDb()).transaction([
11658
+ STORES$1.apps,
11659
+ STORES$1.appStorage,
11660
+ ...builtIn ? [STORES$1.settings] : []
11661
+ ], "readwrite");
11662
+ tx.objectStore(STORES$1.apps).delete(ref);
11663
+ tx.objectStore(STORES$1.appStorage).delete(rowsOf(ref));
11664
+ if (builtIn) markBuiltIns(tx.objectStore(STORES$1.settings), (marks) => {
11665
+ marks.removed[ref] ??= this.now();
11666
+ });
12426
11667
  try {
12427
11668
  await done(tx);
12428
11669
  } finally {
12429
- this.held.delete(app.ref);
11670
+ this.held.delete(ref);
12430
11671
  }
12431
- for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
12432
- this.started.delete(app.ref);
11672
+ for (const key of [...this.usage.keys()]) if (key.startsWith(`${ref}\0`)) this.usage.delete(key);
11673
+ this.started.delete(ref);
11674
+ if (!app) return;
12433
11675
  await this.removeBundle(app.bytes, app.digest);
12434
11676
  if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
12435
11677
  }
11678
+ /** Takes the "removed by the person" mark off a built-in app, if it has one. */
11679
+ async keepBuiltIn(ref) {
11680
+ if ((await this.builtInMarks()).removed[ref] === void 0) return;
11681
+ const tx = (await openDb()).transaction(STORES$1.settings, "readwrite");
11682
+ markBuiltIns(tx.objectStore(STORES$1.settings), (marks) => {
11683
+ delete marks.removed[ref];
11684
+ });
11685
+ await done(tx);
11686
+ }
12436
11687
  scopeOf(scope) {
12437
11688
  if (scope === "alone" || typeof scope === "string" && this.host.isChat(scope)) return scope;
12438
11689
  fail("bad-scope", "Not a chat of this profile");
@@ -12451,13 +11702,24 @@ var Apps = class {
12451
11702
  });
12452
11703
  return run;
12453
11704
  }
11705
+ /**
11706
+ * What a scope holds: its counter row, when the number of rows agrees with it (one key count, no value read). A
11707
+ * scope without one (written before it was kept) or where it disagrees (rows written by an older client) is
11708
+ * counted from its rows once.
11709
+ */
12454
11710
  async usageOf(ref, scope) {
12455
11711
  const id = `${ref}\0${scope}`;
12456
- let used = this.usage.get(id);
12457
- if (used === void 0) {
12458
- used = (await wrap((await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX).getAll([ref, scope]))).reduce((sum, r) => sum + costOf(r), 0);
12459
- this.usage.set(id, used);
12460
- }
11712
+ const cached = this.usage.get(id);
11713
+ if (cached) return cached;
11714
+ const rows = await store(STORES$1.appStorage, "readonly");
11715
+ const [counter, n] = await Promise.all([wrap(rows.get([
11716
+ ref,
11717
+ scope,
11718
+ COUNT_KEY
11719
+ ])), wrap(rows.count(keysOf(ref, scope)))]);
11720
+ let used = counter ? readCount(counter.value) : n ? null : NO_ROWS;
11721
+ if (!used || used.rows !== n) used = (await wrap((await store(STORES$1.appStorage, "readonly")).getAll(keysOf(ref, scope)))).reduce((sum, r) => plus(sum, countOf(r)), NO_ROWS);
11722
+ this.usage.set(id, used);
12461
11723
  return used;
12462
11724
  }
12463
11725
  async storageGet({ ref, scope, key, digest }) {
@@ -12471,8 +11733,8 @@ var Apps = class {
12471
11733
  }
12472
11734
  /**
12473
11735
  * Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope, each counted
12474
- * with `rowBytes`. A scope already past that (filled before a key counted `rowBytes`) still takes a write that does
12475
- * not grow it, so the app can make room.
11736
+ * as `costOf` says. A scope already past that (filled under an older count) still takes a write that does not grow
11737
+ * it, so the app can make room.
12476
11738
  */
12477
11739
  async storageSet({ ref, scope, key, value, digest }) {
12478
11740
  const app = await this.runningOrFail(ref, digest);
@@ -12495,20 +11757,24 @@ var Apps = class {
12495
11757
  where,
12496
11758
  name
12497
11759
  ]));
12498
- const next = used - (old ? costOf(old) : 0) + APP_STORAGE_LIMITS.rowBytes + size;
12499
- if (next > APP_STORAGE_LIMITS.scopeBytes && next > used) fail("full", "This app's storage in this chat is full (5 MiB)");
11760
+ const next = plus(old ? plus(used, countOf(old, -1)) : used, countOf({
11761
+ key: name,
11762
+ size
11763
+ }));
11764
+ if (costOf(next) > APP_STORAGE_LIMITS.scopeBytes && costOf(next) > costOf(used)) fail("full", "This app's storage in this chat is full (5 MiB)");
12500
11765
  const writing = await store(STORES$1.appStorage, "readwrite");
12501
11766
  if (where !== "alone" && !this.host.isChat(where)) {
12502
11767
  this.usage.delete(`${app.ref}\0${where}`);
12503
11768
  fail("bad-scope", "Not a chat of this profile");
12504
11769
  }
12505
- await wrap(writing.put({
11770
+ writing.put({
12506
11771
  ref: app.ref,
12507
11772
  scope: where,
12508
11773
  key: name,
12509
11774
  value: text,
12510
11775
  size
12511
- }));
11776
+ });
11777
+ await wrap(writing.put(counterRow(app.ref, where, next)));
12512
11778
  this.usage.set(`${app.ref}\0${where}`, next);
12513
11779
  });
12514
11780
  }
@@ -12523,39 +11789,73 @@ var Apps = class {
12523
11789
  name
12524
11790
  ]));
12525
11791
  if (!old) return;
12526
- await wrap((await store(STORES$1.appStorage, "readwrite")).delete([
11792
+ const next = plus(used, countOf(old, -1));
11793
+ const writing = await store(STORES$1.appStorage, "readwrite");
11794
+ writing.delete([
12527
11795
  app.ref,
12528
11796
  where,
12529
11797
  name
12530
- ]));
12531
- this.usage.set(`${app.ref}\0${where}`, used - costOf(old));
11798
+ ]);
11799
+ await (next.rows ? wrap(writing.put(counterRow(app.ref, where, next))) : wrap(writing.delete([
11800
+ app.ref,
11801
+ where,
11802
+ COUNT_KEY
11803
+ ])));
11804
+ this.usage.set(`${app.ref}\0${where}`, next);
12532
11805
  });
12533
11806
  }
12534
11807
  async storageKeys({ ref, scope, digest }) {
12535
11808
  const app = await this.runningOrFail(ref, digest);
12536
- return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
11809
+ return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(keysOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
12537
11810
  }
12538
11811
  /** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
12539
11812
  async exportData({ ref }) {
12540
- const app = await this.installedOrFail(ref);
12541
- const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(rowsOf(app.ref)));
12542
- const scopes = /* @__PURE__ */ new Map();
12543
- for (const row of rows) {
12544
- const out = scopes.get(row.scope) ?? {
11813
+ return (await this.exportDataText({ ref })).map(({ app, scope, entries }) => {
11814
+ const out = {
12545
11815
  ghostlyAppData: 1,
12546
- app: app.ref,
12547
- scope: row.scope,
11816
+ app,
11817
+ scope,
12548
11818
  entries: {}
12549
11819
  };
12550
- Object.defineProperty(out.entries, row.key, {
12551
- value: JSON.parse(row.value),
11820
+ for (const [key, json] of entries) Object.defineProperty(out.entries, key, {
11821
+ value: JSON.parse(json),
12552
11822
  enumerable: true,
12553
11823
  writable: true,
12554
11824
  configurable: true
12555
11825
  });
12556
- scopes.set(row.scope, out);
11826
+ return out;
11827
+ });
11828
+ }
11829
+ /**
11830
+ * `exportData` with each value as the JSON text it is kept as, for a file written without parsing and writing every
11831
+ * value again. Read a scope at a time, the thread let go between them: ten chats of 5 MiB read at once held it for
11832
+ * the whole read.
11833
+ */
11834
+ async exportDataText({ ref }) {
11835
+ const app = await this.installedOrFail(ref);
11836
+ const scopes = [];
11837
+ const index = (await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX);
11838
+ await new Promise((resolve, reject) => {
11839
+ const request = index.openKeyCursor(rowsOf(app.ref), "nextunique");
11840
+ request.onerror = () => reject(request.error);
11841
+ request.onsuccess = () => {
11842
+ const cursor = request.result;
11843
+ if (!cursor) return resolve();
11844
+ scopes.push(cursor.key[1]);
11845
+ cursor.continue();
11846
+ };
11847
+ });
11848
+ const out = [];
11849
+ for (const [i, scope] of scopes.entries()) {
11850
+ if (i) await new Promise((resolve) => setTimeout(resolve, 0));
11851
+ const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(keysOf(app.ref, scope)));
11852
+ if (rows.length) out.push({
11853
+ app: app.ref,
11854
+ scope,
11855
+ entries: rows.map((row) => [row.key, row.value])
11856
+ });
12557
11857
  }
12558
- return [...scopes.values()];
11858
+ return out;
12559
11859
  }
12560
11860
  };
12561
11861
  //#endregion
@@ -16680,7 +15980,7 @@ function handoffNotices(was, handoff, session) {
16680
15980
  * device link must offer every transport both devices run: a Linux Desktop has no WebRTC, and an Iroh relay that
16681
15981
  * one side cannot reach leaves HyperDHT as the only way through.
16682
15982
  */
16683
- var NATIVE_RETRY_MS = 1e4;
15983
+ var NATIVE_RETRY_MS$1 = 1e4;
16684
15984
  var NATIVE_RETRY_MAX_MS = 12e4;
16685
15985
  /** How often a standby reads the turn while its screen shows (WISP 06 § When a device checks), and the jitter on it. */
16686
15986
  var STANDBY_TURN_EVERY_MS = 6e5;
@@ -16989,7 +16289,7 @@ var DeviceLinks = class {
16989
16289
  }
16990
16290
  retryNative(running) {
16991
16291
  if (running.nativeRetry || this.stopped || this.running.get(running.id) !== running) return;
16992
- const wait = Math.min(NATIVE_RETRY_MS * 2 ** running.nativeMisses, NATIVE_RETRY_MAX_MS);
16292
+ const wait = Math.min(NATIVE_RETRY_MS$1 * 2 ** running.nativeMisses, NATIVE_RETRY_MAX_MS);
16993
16293
  running.nativeMisses++;
16994
16294
  running.nativeRetry = setTimeout(() => {
16995
16295
  running.nativeRetry = void 0;
@@ -19363,6 +18663,7 @@ var FileDesk = class {
19363
18663
  openSource: (record) => this.openSource(linkId, record),
19364
18664
  changed: (record, transferred, progress) => this.changed(linkId, record, transferred, progress),
19365
18665
  room: async () => (await fileBytes()).room(),
18666
+ ...this.deps.sendData && { sendData: (id, offset, data) => this.deps.sendData(linkId, id, offset, data) },
19366
18667
  writable: () => this.deps.writable?.(linkId)
19367
18668
  });
19368
18669
  this.chats.set(linkId, made);
@@ -19382,6 +18683,7 @@ var FileDesk = class {
19382
18683
  records.push(file.wire3);
19383
18684
  if (file.wire3.direction === "in") chat.local.set(file.wire3.id, file.id);
19384
18685
  if (!transferEnded(file.wire3)) this.restored.add(file.id);
18686
+ if (file.wire3.state === "done") continue;
19385
18687
  this.deps.transfers.set(file.id, this.view(chat, file.wire3, file.wire3.confirmed, transferStalled(file.wire3, Date.now(), this.restored.has(file.id), Date.now())));
19386
18688
  }
19387
18689
  chat.files.restore(records);
@@ -19822,7 +19124,7 @@ var FileDesk = class {
19822
19124
  view.room = this.deps.transfers.get(id)?.room;
19823
19125
  this.room(id);
19824
19126
  }
19825
- this.deps.transfers.set(id, view);
19127
+ if (record.state !== "done" || this.underway.has(id) || this.deps.transfers.has(id)) this.deps.transfers.set(id, view);
19826
19128
  if (!transferEnded(record)) {
19827
19129
  this.underway.add(id);
19828
19130
  this.ended.delete(id);
@@ -20350,6 +19652,10 @@ var Outbox = class {
20350
19652
  get sameLink() {
20351
19653
  return !this.stopped && this.closed === this.began;
20352
19654
  }
19655
+ /** One message as stored now: by its id when the store can, else picked out of the chat's history. */
19656
+ async one(id) {
19657
+ return this.store.get ? this.store.get(id) : (await this.store.read()).find((m) => m.id === id);
19658
+ }
20353
19659
  /** A new message a turn could not send (the link closed under it, or the ones before it still wait): it waits too. */
20354
19660
  async waitsItsTurn(message) {
20355
19661
  await this.store.update(message.id, "waiting", "Waits for the messages before it to go.", {
@@ -20454,7 +19760,7 @@ var Outbox = class {
20454
19760
  this.busy.add(id);
20455
19761
  let again;
20456
19762
  try {
20457
- let message = read ?? (await this.store.read()).find((m) => m.id === id);
19763
+ let message = read ?? await this.one(id);
20458
19764
  if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
20459
19765
  const wait = this.waiting.get(id);
20460
19766
  if (wait?.timer) {
@@ -20495,7 +19801,7 @@ var Outbox = class {
20495
19801
  again = this.reopenedWhileBusy.delete(id);
20496
19802
  }
20497
19803
  if (again) {
20498
- const message = (await this.store.read()).find((m) => m.id === id);
19804
+ const message = await this.one(id);
20499
19805
  if (message) await this.again(message);
20500
19806
  }
20501
19807
  }
@@ -20505,7 +19811,7 @@ var Outbox = class {
20505
19811
  * nothing about it is unconfirmed; it counts no attempt until it goes.
20506
19812
  */
20507
19813
  async wait(id, reason) {
20508
- const message = (await this.store.read()).find((m) => m.id === id);
19814
+ const message = await this.one(id);
20509
19815
  if (!message || this.stopped) return;
20510
19816
  await this.store.update(id, "waiting", reason, { resendUntil: message.resendUntil ?? this.now() + this.policy.windowMs });
20511
19817
  this.schedule(id);
@@ -20595,7 +19901,7 @@ var Outbox = class {
20595
19901
  */
20596
19902
  async attempt(id, passed) {
20597
19903
  if (this.stopped || this.busy.has(id)) return true;
20598
- const message = (await this.store.read()).find((m) => m.id === id);
19904
+ const message = await this.one(id);
20599
19905
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
20600
19906
  this.forget(id);
20601
19907
  return true;
@@ -20622,7 +19928,7 @@ var Outbox = class {
20622
19928
  }
20623
19929
  /** After a failed attempt: queued again, unless its window or its attempts are spent. */
20624
19930
  async requeue(id, reason) {
20625
- const message = (await this.store.read()).find((m) => m.id === id);
19931
+ const message = await this.one(id);
20626
19932
  if (message && message.delivery !== "delivered") await this.queue(message, reason);
20627
19933
  }
20628
19934
  async queue(message, reason) {
@@ -20670,7 +19976,7 @@ var Outbox = class {
20670
19976
  this.turn(async () => {
20671
19977
  if (this.receipts.get(id) !== timer) return;
20672
19978
  this.receipts.delete(id);
20673
- const message = (await this.store.read()).find((m) => m.id === id);
19979
+ const message = await this.one(id);
20674
19980
  if (message?.via === "pkarr") await this.expired(message);
20675
19981
  else await this.requeue(id, "No receipt received yet.");
20676
19982
  }).catch(() => {});
@@ -21357,8 +20663,9 @@ var db = {
21357
20663
  tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("Link storage failed"));
21358
20664
  });
21359
20665
  },
21360
- async patchLink(linkId, patch) {
21361
- const tx = (await openDb()).transaction(STORES$1.links, "readwrite");
20666
+ /** `relaxed`: for what a power cut may cost (a round trip, a note made again later), no disk sync of its own. */
20667
+ async patchLink(linkId, patch, relaxed = false) {
20668
+ const tx = (await openDb()).transaction(STORES$1.links, "readwrite", relaxed ? RELAXED : void 0);
21362
20669
  const links = tx.objectStore(STORES$1.links);
21363
20670
  const request = links.get(linkId);
21364
20671
  request.onsuccess = () => {
@@ -21619,6 +20926,32 @@ var db = {
21619
20926
  });
21620
20927
  return result;
21621
20928
  },
20929
+ /**
20930
+ * `patchMessage` on these rows of one history, all in one transaction: one disk sync for all of them. Resolves to
20931
+ * the rows changed, in the order of `ids`.
20932
+ */
20933
+ async patchMessages(linkId, ids, change) {
20934
+ const tx = (await openDb()).transaction(STORES$1.messages, "readwrite");
20935
+ const messages = tx.objectStore(STORES$1.messages);
20936
+ const results = [];
20937
+ ids.forEach((id, i) => {
20938
+ const request = messages.get([linkId, id]);
20939
+ request.onsuccess = () => {
20940
+ const message = request.result;
20941
+ const patch = message && change(message);
20942
+ if (!patch) return;
20943
+ messages.put(results[i] = {
20944
+ ...message,
20945
+ ...patch
20946
+ });
20947
+ };
20948
+ });
20949
+ await new Promise((resolve, reject) => {
20950
+ tx.oncomplete = () => resolve();
20951
+ tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("Message storage failed"));
20952
+ });
20953
+ return results.filter((row) => !!row);
20954
+ },
21622
20955
  async deleteMessage(linkId, messageId) {
21623
20956
  await wrap((await store(STORES$1.messages, "readwrite")).delete([linkId, messageId]));
21624
20957
  },
@@ -22021,6 +21354,202 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
22021
21354
  };
22022
21355
  }
22023
21356
  //#endregion
21357
+ //#region ../browser/src/engine/groupAuthorOrder.ts
21358
+ /**
21359
+ * How long a member's message may still be placed again below that member's older ones (WISP 400, requirement 10):
21360
+ * about a catch-up, whose answers end within the three minutes the app waits for the first.
21361
+ */
21362
+ var AUTHOR_ORDER_WINDOW_MS = 18e4;
21363
+ AUTHOR_ORDER_WINDOW_MS / 2;
21364
+ /** Whether `a` was written after `b` by the same member: a later epoch, or a higher number in the same one. */
21365
+ function after(a, b) {
21366
+ return a.epoch > b.epoch || a.epoch === b.epoch && a.seq > b.seq;
21367
+ }
21368
+ /**
21369
+ * A member's messages in a private group in the order that member wrote them (WISP 400, requirement 10, and WISP 902
21370
+ * § Catch-up). A message is placed where it arrives. When one comes of a member whose later messages were placed here
21371
+ * moments ago (two catch-up answers raced: the author's own newest 32 and another member's hand-on of the older ones),
21372
+ * those later ones are placed again below it, in the order they were written, and the lines that came after them before
21373
+ * the older one (another member's, mine) go along below them, in their order: a reply to the newest stays below it. A
21374
+ * row only ever moves down, so nobody else's lines change order. While messages of that member are still missing
21375
+ * (`missing`), the rows wait for them, so each row moves about once in a whole catch-up.
21376
+ */
21377
+ var GroupAuthorOrder = class {
21378
+ host;
21379
+ linkOf;
21380
+ missing;
21381
+ /** Per group: the members' rows followed, in the order they are placed in the history. */
21382
+ rows = /* @__PURE__ */ new Map();
21383
+ /** Per group: the members with a row placed above an older one of theirs, since when, and when their last row came. */
21384
+ due = /* @__PURE__ */ new Map();
21385
+ running = /* @__PURE__ */ new Set();
21386
+ constructor(host, linkOf, missing = () => false) {
21387
+ this.host = host;
21388
+ this.linkOf = linkOf;
21389
+ this.missing = missing;
21390
+ }
21391
+ /** A member's message was stored in the group's history, new: `at` is when, on the engine's clock. */
21392
+ placed(groupId, row) {
21393
+ if (!this.host.placeAgain) return;
21394
+ const rows = (this.rows.get(groupId) ?? []).filter((r) => r.at > row.at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21395
+ rows.push(row);
21396
+ this.rows.set(groupId, rows);
21397
+ let due = this.due.get(groupId);
21398
+ if (!due?.has(row.sender) && !rows.some((r) => r.sender === row.sender && after(r, row))) return;
21399
+ if (!due) this.due.set(groupId, due = /* @__PURE__ */ new Map());
21400
+ due.set(row.sender, {
21401
+ since: due.get(row.sender)?.since ?? row.at,
21402
+ last: row.at
21403
+ });
21404
+ if (!this.missing(groupId, row.sender)) this.run(groupId, row.at);
21405
+ }
21406
+ /** Time passed: rows nothing more is on its way for, or that waited long enough, are placed again. */
21407
+ tick(now) {
21408
+ for (const groupId of this.due.keys()) this.run(groupId, now);
21409
+ }
21410
+ /** The group's history is gone (left or forgotten). */
21411
+ forget(groupId) {
21412
+ this.rows.delete(groupId);
21413
+ this.due.delete(groupId);
21414
+ }
21415
+ /**
21416
+ * One group's rows put in order, a member at a time; what comes meanwhile is put in order by the next turn, so a
21417
+ * catch-up's frames that come together move each row once, not once per frame.
21418
+ */
21419
+ async run(groupId, now) {
21420
+ if (this.running.has(groupId)) return;
21421
+ this.running.add(groupId);
21422
+ try {
21423
+ for (;;) {
21424
+ const due = this.due.get(groupId);
21425
+ const ready = [...due ?? []].filter(([sender, { since, last }]) => now - last >= 15e3 || now - since >= 9e4 || !this.missing(groupId, sender)).map(([sender]) => sender);
21426
+ if (!ready.length) break;
21427
+ for (const sender of ready) due.delete(sender);
21428
+ if (!due.size) this.due.delete(groupId);
21429
+ for (const sender of ready) {
21430
+ const rows = this.rows.get(groupId) ?? [];
21431
+ const mine = rows.filter((r) => r.sender === sender);
21432
+ let from, below;
21433
+ for (const r of [...mine].reverse()) {
21434
+ if (below && after(r, below) && (!from || after(from, r))) from = r;
21435
+ if (!below || after(below, r)) below = r;
21436
+ }
21437
+ if (!from) continue;
21438
+ const newer = mine.filter((r) => !after(from, r)).sort((a, b) => after(a, b) ? 1 : after(b, a) ? -1 : 0);
21439
+ const ids = new Set(newer.map((r) => r.id));
21440
+ const first = rows.findIndex((r) => ids.has(r.id));
21441
+ let end = rows.length;
21442
+ for (let i = first + 1, top = rows[first]; i < rows.length; i++) {
21443
+ if (rows[i].sender !== sender) continue;
21444
+ if (after(top, rows[i])) {
21445
+ end = i;
21446
+ break;
21447
+ }
21448
+ top = rows[i];
21449
+ }
21450
+ const followers = rows.slice(first, end).filter((r) => r.sender !== sender);
21451
+ for (const r of followers) ids.add(r.id);
21452
+ const moved = [...newer, ...followers];
21453
+ this.rows.set(groupId, [...rows.filter((r) => !ids.has(r.id)), ...moved.map((r) => ({
21454
+ ...r,
21455
+ at: Math.max(r.at, now)
21456
+ }))]);
21457
+ await this.host.placeAgain(this.linkOf(groupId), moved.map((r) => r.id)).catch(() => {});
21458
+ }
21459
+ }
21460
+ } finally {
21461
+ this.running.delete(groupId);
21462
+ }
21463
+ }
21464
+ };
21465
+ //#endregion
21466
+ //#region ../browser/src/engine/groupLineOrder.ts
21467
+ /**
21468
+ * The lines of a group's history ("… joined", "… is no longer a member") among the messages of their moment, for a
21469
+ * member back from away (WISP 902 § Catch-up). A message sealed under an epoch was written before its author knew of
21470
+ * the next commit, so the line of that commit goes below it; a message under the commit's epoch or a later one was
21471
+ * written knowing it, and stays below the line. A catch-up brings the commits first (a commit opens the messages of
21472
+ * its epoch) and, in a private group, the answers of several members race, so the messages of the epoch before can come
21473
+ * after the line. Then the line, and the rows after it written knowing it, are placed again below them, in their order;
21474
+ * no clock is read. A row only ever moves down, and only rows placed here in the last few minutes, so a live
21475
+ * conversation read long ago never moves. Moves made meanwhile go in one turn: a row moves about once a catch-up page.
21476
+ */
21477
+ var GroupLineOrder = class {
21478
+ host;
21479
+ /** Per history: the rows followed, in the order they are placed in it. */
21480
+ rows = /* @__PURE__ */ new Map();
21481
+ due = /* @__PURE__ */ new Set();
21482
+ running = /* @__PURE__ */ new Set();
21483
+ constructor(host) {
21484
+ this.host = host;
21485
+ }
21486
+ /**
21487
+ * A row is being stored, a line of the history or a message sealed under this epoch, and takes its place now: `at` is
21488
+ * when, on the engine's clock. Call what this returns with whether it was new: a message below a line of a later
21489
+ * epoch takes the line (and the rows after it written knowing it) below it.
21490
+ */
21491
+ placing(linkId, row, at) {
21492
+ if (!this.host.placeAgain) return () => {};
21493
+ const followed = {
21494
+ ...row,
21495
+ at
21496
+ };
21497
+ const rows = (this.rows.get(linkId) ?? []).filter((r) => r.at > at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
21498
+ rows.push(followed);
21499
+ this.rows.set(linkId, rows);
21500
+ return (stored) => {
21501
+ const now = this.rows.get(linkId);
21502
+ if (!stored) {
21503
+ if (now) this.rows.set(linkId, now.filter((r) => r !== followed));
21504
+ return;
21505
+ }
21506
+ if (!followed.line && now?.some((r) => r.line && r.epoch > followed.epoch && now.indexOf(r) < now.indexOf(followed))) this.run(linkId);
21507
+ };
21508
+ }
21509
+ /** These rows were placed again below everything (`GroupAuthorOrder`), in this order. */
21510
+ moved(linkId, ids) {
21511
+ const rows = this.rows.get(linkId);
21512
+ if (!rows) return;
21513
+ const moved = ids.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
21514
+ if (!moved.length) return;
21515
+ this.rows.set(linkId, [...rows.filter((r) => !moved.includes(r)), ...moved]);
21516
+ if (moved.some((r) => !r.line)) this.run(linkId);
21517
+ }
21518
+ /** The group's history is gone (left or forgotten). */
21519
+ forget(linkId) {
21520
+ this.rows.delete(linkId);
21521
+ this.due.delete(linkId);
21522
+ }
21523
+ /** The history's rows put in order; what comes while a move is written goes in the next turn. */
21524
+ run(linkId) {
21525
+ this.due.add(linkId);
21526
+ if (this.running.has(linkId)) return;
21527
+ this.running.add(linkId);
21528
+ (async () => {
21529
+ try {
21530
+ while (this.due.delete(linkId)) {
21531
+ const rows = this.rows.get(linkId) ?? [];
21532
+ const order = [];
21533
+ for (const row of rows) {
21534
+ const above = row.line ? -1 : order.findIndex((r) => r.line && r.epoch > row.epoch);
21535
+ if (above < 0) order.push(row);
21536
+ else order.splice(above, 0, row);
21537
+ }
21538
+ let from = 0;
21539
+ while (from < rows.length && rows[from] === order[from]) from++;
21540
+ if (from === rows.length) continue;
21541
+ const last = rows[rows.length - 1].at;
21542
+ for (const r of order.slice(from)) r.at = Math.max(r.at, last);
21543
+ this.rows.set(linkId, order);
21544
+ await this.host.placeAgain(linkId, order.slice(from).map((r) => r.id)).catch(() => {});
21545
+ }
21546
+ } finally {
21547
+ this.running.delete(linkId);
21548
+ }
21549
+ })();
21550
+ }
21551
+ };
21552
+ //#endregion
22024
21553
  //#region ../browser/src/engine/hubClocks.ts
22025
21554
  /**
22026
21555
  * A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
@@ -22274,11 +21803,14 @@ var Communities = class {
22274
21803
  /** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
22275
21804
  frames = new FramesTaken();
22276
21805
  carriers = /* @__PURE__ */ new Map();
21806
+ /** The lines of each history among the messages of their moment (`GroupLineOrder`). */
21807
+ lineOrder;
22277
21808
  constructor(host, store, timings = COMMUNITY_TIMINGS, random = Math.random) {
22278
21809
  this.host = host;
22279
21810
  this.store = store;
22280
21811
  this.timings = timings;
22281
21812
  this.random = random;
21813
+ this.lineOrder = new GroupLineOrder(host);
22282
21814
  }
22283
21815
  /** Application frames and pair payloads handed to the engine, one after the other per group. */
22284
21816
  deliveries = /* @__PURE__ */ new Map();
@@ -22377,6 +21909,7 @@ var Communities = class {
22377
21909
  accepted: true,
22378
21910
  viaLink: true,
22379
21911
  stage: this.joinStage(group),
21912
+ since: group.joining.since,
22380
21913
  ...this.knockingWith.get(group.id) ? { waiting: this.knockingWith.get(group.id) } : {}
22381
21914
  } : void 0;
22382
21915
  if (invitation && !this.keptWhileKnocking(live)) return [{
@@ -22590,22 +22123,45 @@ var Communities = class {
22590
22123
  /**
22591
22124
  * Leaves: the request goes to the hubs, which keep it and commit it (any member can), and the
22592
22125
  * group goes from this device at once. An admin hands the role to a member first. Something must
22593
- * be connected to carry the request.
22126
+ * be connected to carry the request. Answers the member the role went to, when it went to one.
22594
22127
  */
22595
22128
  async leave(groupId) {
22596
22129
  const live = this.live.get(groupId);
22597
- if (!live) return this.forget(groupId);
22130
+ if (!live) {
22131
+ await this.forget(groupId);
22132
+ return;
22133
+ }
22598
22134
  const s = live.session;
22599
- if (s.status !== "active" || s.others.length === 0) return this.forget(groupId);
22135
+ if (s.status !== "active" || s.others.length === 0) {
22136
+ await this.forget(groupId);
22137
+ return;
22138
+ }
22600
22139
  const edges = [...this.host.edges(groupId).entries()].filter(([, id]) => this.host.linkReady(id, 2));
22601
22140
  if (!edges.length) throw new Error("Nobody in the group is connected right now to take your leave. Try again in a moment.");
22141
+ let next;
22602
22142
  if (s.isAdmin) {
22603
22143
  const connected = new Set(edges.map(([key]) => key));
22604
- const next = s.others.find((key) => connected.has(key)) ?? s.others[0];
22144
+ next = s.others.find((key) => connected.has(key)) ?? s.others[0];
22605
22145
  await s.transferAdmin(next);
22606
22146
  }
22607
22147
  await s.leave();
22608
22148
  await this.forget(groupId);
22149
+ return next;
22150
+ }
22151
+ /**
22152
+ * Delete from this device: left first, as Leave does. With nothing connected to carry the request, a member's leave
22153
+ * goes unsaid and the group goes anyway, as a private group's failed goodbye does: the others still list the device,
22154
+ * but members who never open the app again kept it here for good. An admin with members is still refused: gone with
22155
+ * the role, the group would have no admin.
22156
+ */
22157
+ async deleteHere(groupId) {
22158
+ try {
22159
+ await this.leave(groupId);
22160
+ } catch (error) {
22161
+ const s = this.live.get(groupId)?.session;
22162
+ if (!s || s.isAdmin && s.others.length) throw error;
22163
+ await this.forget(groupId);
22164
+ }
22609
22165
  }
22610
22166
  successor(groupId) {
22611
22167
  const s = this.live.get(groupId)?.session;
@@ -22677,6 +22233,7 @@ var Communities = class {
22677
22233
  this.lastMentionAt.delete(groupId);
22678
22234
  this.knockingWith.delete(groupId);
22679
22235
  this.knockRead.delete(groupId);
22236
+ this.lineOrder.forget(MESSAGE_LINK$1(groupId));
22680
22237
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
22681
22238
  await this.store.deleteGroup(groupId);
22682
22239
  await this.files?.drop(groupId);
@@ -23727,7 +23284,7 @@ var Communities = class {
23727
23284
  const live = this.live.get(groupId);
23728
23285
  if (!live || live.session.wasRemoved(peerKey)) return;
23729
23286
  if (live.hub && !this.peerIsHub(live, peerKey) && rosterHas(live.session.roster, peerKey)) live.members.set(peerKey, this.now());
23730
- this.sendTo(linkId, live.session.syncFrame());
23287
+ this.sendTo(linkId, live.session.syncFrame(peerKey));
23731
23288
  this.host.emit();
23732
23289
  }
23733
23290
  edgeNick(groupId, peerKey, nick) {
@@ -23763,6 +23320,11 @@ var Communities = class {
23763
23320
  sender: m.sender,
23764
23321
  file: m.file
23765
23322
  }, session.myKey) : void 0;
23323
+ const placing = this.lineOrder.placing(MESSAGE_LINK$1(id), {
23324
+ id: m.id,
23325
+ epoch: m.epoch,
23326
+ line: false
23327
+ }, this.now());
23766
23328
  const stored = await this.host.storeMessage({
23767
23329
  linkId: MESSAGE_LINK$1(id),
23768
23330
  id: m.id,
@@ -23777,6 +23339,7 @@ var Communities = class {
23777
23339
  ...m.card && { card: m.card },
23778
23340
  ...file && { file }
23779
23341
  });
23342
+ placing(stored !== false);
23780
23343
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
23781
23344
  this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
23782
23345
  if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
@@ -23873,7 +23436,16 @@ var Communities = class {
23873
23436
  else if (s.status === "active" && before !== after) {
23874
23437
  const at = receivedTimestamp(top.ts, when);
23875
23438
  for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, at, top.e, key);
23876
- for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
23439
+ const left = (key) => {
23440
+ const chain = s.state.chain;
23441
+ for (let i = chain.length - 1; i >= 0; i--) if ((chain[i].k === "remove" || chain[i].k === "leave") && chain[i].s === key) return chain[i].k === "leave";
23442
+ return false;
23443
+ };
23444
+ for (const [key] of before) {
23445
+ if (rosterHas(after, key)) continue;
23446
+ if (left(key)) await this.event(groupId, "left", `${name(key)} left`, at, top.e, key);
23447
+ else await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
23448
+ }
23877
23449
  if (top.k === "role" && before.find(([k]) => k === top.s)?.[1] !== "admin") await this.event(groupId, "admin", `${name(top.s)} ${top.s === s.myKey ? "are" : "is"} now the admin`, at, top.e, top.s);
23878
23450
  if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", at, top.e);
23879
23451
  }
@@ -23882,16 +23454,21 @@ var Communities = class {
23882
23454
  /** A line in the group's history. Its id carries its time: two lines at the same moment get distinct ones (`eventTime`). */
23883
23455
  async event(groupId, event, text, timestamp, epoch, member) {
23884
23456
  timestamp = eventTime(this.eventTimes, groupId, timestamp);
23885
- await this.host.storeMessage({
23457
+ const id = `event:${epoch}:${event}:${member ?? ""}:${timestamp}`;
23458
+ this.lineOrder.placing(MESSAGE_LINK$1(groupId), {
23459
+ id,
23460
+ epoch,
23461
+ line: true
23462
+ }, this.now())(await this.host.storeMessage({
23886
23463
  linkId: MESSAGE_LINK$1(groupId),
23887
- id: `event:${epoch}:${event}:${member ?? ""}:${timestamp}`,
23464
+ id,
23888
23465
  text,
23889
23466
  sender: "peer",
23890
23467
  event,
23891
23468
  member,
23892
23469
  timestamp,
23893
23470
  via: "datalink"
23894
- });
23471
+ }) !== false);
23895
23472
  }
23896
23473
  eventTimes = /* @__PURE__ */ new Map();
23897
23474
  };
@@ -24899,6 +24476,11 @@ var INVITED_KEPT_MS = LEFT_KEPT_MS;
24899
24476
  */
24900
24477
  var ACCEPT_AGAIN_MS = 6e4;
24901
24478
  var ACCEPT_AGAINS = 5;
24479
+ /**
24480
+ * Admin side: a member let in over its chat, its edge not up, is sent its welcome again at most this often. The welcome
24481
+ * is the whole chain and the sealed secrets (megabytes, tens of ms to build); its app says its accept once a minute.
24482
+ */
24483
+ var REWELCOME_MS = ACCEPT_AGAIN_MS / 2;
24902
24484
  /** Invitee side: the invitations one contact chat may have waiting here at a time. More are answered as declined. */
24903
24485
  var MAX_INVITATIONS_PER_CHAT = 4;
24904
24486
  /** What an invitation says of its group, kept as a claim within what a group can be: an epoch from 0, a roster of 1 to the most a group holds. */
@@ -24915,9 +24497,13 @@ var FAREWELL_ANSWERS = 3;
24915
24497
  var FAREWELL_COMMITS = 64;
24916
24498
  /**
24917
24499
  * A member whose messages were just asked of another (`Groups.askOf`) is told where I am only once that one's answer
24918
- * has come: no message from it for this long, or `MESH_SYNC_HOLD_MAX_MS` after it was asked. Told at once, its own
24500
+ * has come: no message of it for this long, or `MESH_SYNC_HOLD_MAX_MS` after it was asked. Told at once, its own
24919
24501
  * answer raced the other's, which holds the same messages among everyone's in the order they were written, and a
24920
24502
  * member back in a group of five read each member's lines apart, the newest above older ones (WISP 902 § Catch-up).
24503
+ * A message of the answer is one written before the asking: what the member writes meanwhile is no reason to wait, so
24504
+ * the longest wait only has to cover a whole answer (the 256 messages a member keeps for the others and its own last
24505
+ * 32) at the pace of a phone with a long history, 600 ms a frame. At ten seconds, an answer of 200 messages at four a
24506
+ * second was cut at the 40th, and the others' newest lines landed above what was still coming.
24921
24507
  */
24922
24508
  var MESH_SYNC_HOLD_MS = 1500;
24923
24509
  /** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
@@ -25031,6 +24617,8 @@ var Groups = class {
25031
24617
  syncHeld = /* @__PURE__ */ new Map();
25032
24618
  /** Per mesh group and member, when a message last came over its edge (its own, or one it handed on). */
25033
24619
  lastHanded = /* @__PURE__ */ new Map();
24620
+ /** Admin side, per group and member let in: when its welcome was last sent again, over its chat or the link (`REWELCOME_MS`). */
24621
+ rewelcomed = /* @__PURE__ */ new Map();
25034
24622
  lastGossip = /* @__PURE__ */ new Map();
25035
24623
  /**
25036
24624
  * Per mesh group, each reachable member some of whose messages never reached me (`GroupSession.lacking`): what was
@@ -25062,6 +24650,10 @@ var Groups = class {
25062
24650
  communities;
25063
24651
  /** My messages and edits an edge of a private group took. */
25064
24652
  frames = new FramesTaken();
24653
+ /** Each member's messages in the order that member wrote them, though catch-up answers race (WISP 400, requirement 10). */
24654
+ authorOrder;
24655
+ /** The lines of each history among the messages of their moment (`GroupLineOrder`). */
24656
+ lineOrder;
25065
24657
  /** Hubs of the private groups past 16 members (WISP 902 · Group Mesh § Hubs). */
25066
24658
  hubs;
25067
24659
  /** Groups whose edges are being reconciled because their hubs changed: one at a time. */
@@ -25088,6 +24680,16 @@ var Groups = class {
25088
24680
  }
25089
24681
  }, hubTimings);
25090
24682
  this.typings = new GroupTypings(host);
24683
+ this.lineOrder = new GroupLineOrder(host);
24684
+ const placeAgain = host.placeAgain && (async (linkId, ids) => {
24685
+ const done = host.placeAgain(linkId, ids);
24686
+ this.lineOrder.moved(linkId, ids);
24687
+ await done;
24688
+ });
24689
+ this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => {
24690
+ const session = this.sessions.get(groupId);
24691
+ return !!session && session.missing(sender, this.gapsGone.get(groupId)?.get(sender)?.[session.epoch]) > 0;
24692
+ });
25091
24693
  this.signals = new MeshSignals(host, {
25092
24694
  session: (id) => this.sessions.get(id),
25093
24695
  stored: (id) => this.stored.get(id),
@@ -25125,13 +24727,14 @@ var Groups = class {
25125
24727
  await this.communities.load(all.filter((g) => g.community || g.joining));
25126
24728
  for (const group of all.filter((g) => !g.community && !g.joining)) {
25127
24729
  this.stored.set(group.id, group);
25128
- if (group.state) this.attach(group.state);
25129
- if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
25130
24730
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
25131
- this.framesKept.set(group.id, history.filter((m) => m.frame).map((m) => ({
24731
+ const framed = history.filter((m) => m.frame);
24732
+ this.framesKept.set(group.id, framed.map((m) => ({
25132
24733
  id: m.id,
25133
24734
  bytes: frameBytes(m.frame)
25134
24735
  })));
24736
+ if (group.state) this.attach(group.state, framed.map((m) => m.frame));
24737
+ if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
25135
24738
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
25136
24739
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
25137
24740
  const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
@@ -25208,6 +24811,7 @@ var Groups = class {
25208
24811
  members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
25209
24812
  accepted: !!invitation.seedB64,
25210
24813
  ...this.acceptSaid.get(group.id)?.unanswered ? { unanswered: true } : {},
24814
+ ...invitation.takenBack ? { takenBack: true } : {},
25211
24815
  ...invitation.entry ? {
25212
24816
  viaLink: true,
25213
24817
  stage: this.joinStage(group)
@@ -25389,6 +24993,7 @@ var Groups = class {
25389
24993
  async accept(groupId) {
25390
24994
  const group = this.stored.get(groupId);
25391
24995
  if (!group?.invitation) throw new Error("No invitation to accept");
24996
+ if (group.invitation.takenBack) throw new Error("This invitation was taken back");
25392
24997
  if (!this.host.linkReady(group.invitation.linkId)) throw new Error("The contact who invited you is not connected. Try again when they are.");
25393
24998
  const seedB64 = group.invitation.seedB64 ?? createIdentity().seedB64;
25394
24999
  group.invitation = {
@@ -25412,6 +25017,7 @@ var Groups = class {
25412
25017
  if (this.isCommunity(groupId)) return this.communities.decline(groupId);
25413
25018
  const group = this.stored.get(groupId);
25414
25019
  if (!group?.invitation) throw new Error("No invitation to decline");
25020
+ if (group.invitation.takenBack) return this.forget(groupId, false);
25415
25021
  if (!group.previous) return this.forget(groupId);
25416
25022
  await this.dropInvitation(group, this.sayDecline(groupId, group.invitation.linkId));
25417
25023
  }
@@ -25427,6 +25033,25 @@ var Groups = class {
25427
25033
  await this.store.putGroup(previous);
25428
25034
  this.host.emit();
25429
25035
  }
25036
+ /**
25037
+ * Invitee side: who sent the invitation took it back. A group I kept under its id comes back as it was; otherwise the
25038
+ * invitation stays a week, marked, so the list says it was taken back where it was (an accept said for it stops).
25039
+ */
25040
+ async takeBackFrom(group) {
25041
+ if (group.previous) return this.dropInvitation(group);
25042
+ if (group.invitation.takenBack) return;
25043
+ const { seedB64: _unused, ...invitation } = group.invitation;
25044
+ group.invitation = {
25045
+ ...invitation,
25046
+ pieces: [],
25047
+ takenBack: this.now()
25048
+ };
25049
+ this.acceptSaid.delete(group.id);
25050
+ await this.store.putGroup(group);
25051
+ const kept = [...this.stored.values()].filter((x) => x.invitation?.takenBack && !x.state && x.invitation.linkId === invitation.linkId).reverse().sort((a, b) => b.invitation.takenBack - a.invitation.takenBack);
25052
+ for (const old of kept.slice(MAX_INVITATIONS_PER_CHAT)) await this.forget(old.id, false);
25053
+ this.host.emit();
25054
+ }
25430
25055
  /** Invitee side: says no to an invitation. With the inviter's chat down (its app is closed, say), returns the answer owed, said when that chat is next up. */
25431
25056
  sayDecline(groupId, linkId) {
25432
25057
  try {
@@ -25585,19 +25210,24 @@ var Groups = class {
25585
25210
  * Leaves, and the group is gone from this device at once: its row, its history, its files and every
25586
25211
  * edge but the one to the admin, which stays until the admin's commit removing me comes back (or a
25587
25212
  * week passes), so a leave said while the admin was away still reaches it. An admin with other
25588
- * members hands the role to one who is online first; alone, the group simply goes.
25213
+ * members hands the role to one who is online first; alone, the group simply goes. Answers the member the role went
25214
+ * to, when it went to one.
25589
25215
  */
25590
25216
  async leave(groupId) {
25591
25217
  if (this.isCommunity(groupId)) return this.communities.leave(groupId);
25592
25218
  const session = this.session(groupId);
25593
25219
  const group = this.stored.get(groupId);
25594
- if (session.status !== "active") return this.forget(groupId);
25220
+ if (session.status !== "active") {
25221
+ await this.forget(groupId);
25222
+ return;
25223
+ }
25595
25224
  if (session.others.length === 0) {
25596
25225
  await session.leave();
25597
- return this.forget(groupId);
25226
+ await this.forget(groupId);
25227
+ return;
25598
25228
  }
25229
+ const next = session.isAdmin ? this.successor(groupId) : void 0;
25599
25230
  if (session.isAdmin) {
25600
- const next = this.successor(groupId);
25601
25231
  if (!next) throw new Error("You are the admin and nobody else in the group is online to take over. Try again when a member is, or make someone the admin first.");
25602
25232
  await session.transferAdmin(next);
25603
25233
  }
@@ -25619,6 +25249,8 @@ var Groups = class {
25619
25249
  this.lastMessageAt.delete(groupId);
25620
25250
  this.lastPeerMessageAt.delete(groupId);
25621
25251
  this.lastMentionAt.delete(groupId);
25252
+ this.authorOrder.forget(groupId);
25253
+ this.lineOrder.forget(MESSAGE_LINK(groupId));
25622
25254
  await this.store.deleteGroup(groupId);
25623
25255
  await this.store.putGroup(group);
25624
25256
  await this.files?.drop(groupId);
@@ -25649,6 +25281,7 @@ var Groups = class {
25649
25281
  } catch {}
25650
25282
  this.reconcileEdges(groupId);
25651
25283
  this.host.emit();
25284
+ return next;
25652
25285
  }
25653
25286
  /** The admin heard me: the tombstone of a group I left can go. */
25654
25287
  async leaveConfirmed(groupId) {
@@ -25812,19 +25445,20 @@ var Groups = class {
25812
25445
  }
25813
25446
  /** `answer`: false when an invitation goes because its inviter took it back, so no decline is said for it. */
25814
25447
  async forget(groupId, answer = true) {
25815
- if (this.isCommunity(groupId)) return this.communities.leave(groupId);
25448
+ if (this.isCommunity(groupId)) return this.communities.deleteHere(groupId);
25816
25449
  const session = this.sessions.get(groupId);
25817
25450
  if (session?.status === "active") try {
25818
25451
  await this.leave(groupId);
25819
25452
  } catch (e) {
25820
25453
  if (session.isAdmin && session.others.length) throw e;
25821
25454
  }
25822
- const gone = this.stored.get(groupId), invitation = answer && gone?.invitation && !gone.invitation.entry ? gone.invitation : void 0;
25455
+ const gone = this.stored.get(groupId), invitation = answer && gone?.invitation && !gone.invitation.entry && !gone.invitation.takenBack ? gone.invitation : void 0;
25823
25456
  const owed = invitation && this.sayDecline(groupId, invitation.linkId);
25824
25457
  this.sessions.delete(groupId);
25825
25458
  this.stored.delete(groupId);
25826
25459
  this.takeBack(groupId);
25827
25460
  this.acceptSaid.delete(groupId);
25461
+ this.entryHosts.delete(groupId);
25828
25462
  this.lastRoster.delete(groupId);
25829
25463
  this.lastTold.delete(groupId);
25830
25464
  this.pendingEntries.delete(groupId);
@@ -25838,6 +25472,7 @@ var Groups = class {
25838
25472
  this.relayAsked.delete(groupId);
25839
25473
  this.syncHeld.delete(groupId);
25840
25474
  this.lastHanded.delete(groupId);
25475
+ this.rewelcomed.delete(groupId);
25841
25476
  this.hubs.forget(groupId);
25842
25477
  this.removedAt.delete(groupId);
25843
25478
  this.lastGossip.delete(groupId);
@@ -25850,6 +25485,8 @@ var Groups = class {
25850
25485
  this.hereHeard.delete(groupId);
25851
25486
  this.hereActed.delete(groupId);
25852
25487
  this.signals.forget(groupId);
25488
+ this.authorOrder.forget(groupId);
25489
+ this.lineOrder.forget(MESSAGE_LINK(groupId));
25853
25490
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
25854
25491
  await this.store.deleteGroup(groupId);
25855
25492
  if (owed && !this.stored.has(groupId)) {
@@ -25948,6 +25585,7 @@ var Groups = class {
25948
25585
  if (this.ticking) return;
25949
25586
  this.ticking = true;
25950
25587
  try {
25588
+ this.authorOrder.tick(now);
25951
25589
  for (const group of [...this.stored.values()]) {
25952
25590
  if (group.left) {
25953
25591
  if (now - group.left.at > LEFT_KEPT_MS) await this.forget(group.id);
@@ -25957,6 +25595,10 @@ var Groups = class {
25957
25595
  await this.declineOwed(group, now);
25958
25596
  if (!group.state) continue;
25959
25597
  }
25598
+ if (group.invitation?.takenBack && !group.state) {
25599
+ if (now - group.invitation.takenBack > LEFT_KEPT_MS) await this.forget(group.id, false);
25600
+ continue;
25601
+ }
25960
25602
  if (group.invitation?.entry && !group.state) {
25961
25603
  const every = now - group.createdAt > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
25962
25604
  if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && (now - (this.lastKnock.get(group.id) ?? 0) >= every || now >= (this.knockCheckAt.get(group.id) ?? Infinity))) await this.knock(group, now).catch(() => {});
@@ -26075,7 +25717,7 @@ var Groups = class {
26075
25717
  }
26076
25718
  const session = this.sessions.get(groupId), group = this.stored.get(groupId);
26077
25719
  if (!session?.isAdmin || !group || !this.pendingEntries.get(groupId)?.has(peer)) return;
26078
- if (this.growthBlocked(group, session, session.roster.length + 1, linkId)) {
25720
+ if (!rosterHas(session.roster, peer) && this.growthBlocked(group, session, session.roster.length + 1, linkId)) {
26079
25721
  this.pendingEntries.get(groupId)?.delete(peer);
26080
25722
  this.refused.set(peer, Date.now() + REFUSED_FOR_MS);
26081
25723
  this.host.closeEdge(linkId);
@@ -26122,14 +25764,22 @@ var Groups = class {
26122
25764
  return this.knocked.has(group.id) ? "knocked" : "knocking";
26123
25765
  }
26124
25766
  entryOf(group) {
26125
- return group.entry ? {
25767
+ if (!group.entry) return void 0;
25768
+ const { seedB64 } = group.entry;
25769
+ let host = this.entryHosts.get(group.id);
25770
+ if (host?.seedB64 !== seedB64) this.entryHosts.set(group.id, host = {
25771
+ seedB64,
25772
+ key: identityFromSeedB64(seedB64).pubKeyZ32
25773
+ });
25774
+ return {
26126
25775
  link: {
26127
25776
  g: group.id,
26128
- host: identityFromSeedB64(group.entry.seedB64).pubKeyZ32
25777
+ host: host.key
26129
25778
  },
26130
- seedB64: group.entry.seedB64
26131
- } : void 0;
25779
+ seedB64
25780
+ };
26132
25781
  }
25782
+ entryHosts = /* @__PURE__ */ new Map();
26133
25783
  /** Joiner side: the entry session kept after the welcome goes (the edge to the admin is up, or it was kept long enough). */
26134
25784
  async endLinger(groupId) {
26135
25785
  const kept = this.lingering.get(groupId);
@@ -26194,11 +25844,13 @@ var Groups = class {
26194
25844
  });
26195
25845
  let pending = this.pendingEntries.get(group.id);
26196
25846
  for (const { key, ts } of knocks) {
26197
- if (now - ts > 18e4 || rosterHas(session.roster, key) || this.refused.has(key) || pending?.has(key) || key === entry.link.host) continue;
25847
+ const again = this.unwelcomed(group, session, key);
25848
+ if (now - ts > 18e4 || rosterHas(session.roster, key) && !again || this.refused.has(key) || pending?.has(key) || key === entry.link.host) continue;
25849
+ if (again && this.rewelcomedLately(group.id, key)) continue;
26198
25850
  const entryIds = new Set(this.host.entries(group.id).values());
26199
25851
  const contactsInvited = [...this.invited.get(group.id) ?? []].filter((id) => !entryIds.has(id)).length;
26200
25852
  const size = session.roster.length + contactsInvited + (pending?.size ?? 0);
26201
- if ((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || size >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, size + 1)) break;
25853
+ if ((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || !again && (size >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, size + 1))) break;
26202
25854
  if (!pending) this.pendingEntries.set(group.id, pending = /* @__PURE__ */ new Map());
26203
25855
  pending.set(key, now);
26204
25856
  this.warmUntil.set(group.id, now + this.timings.warmMs);
@@ -26258,9 +25910,9 @@ var Groups = class {
26258
25910
  }
26259
25911
  if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
26260
25912
  if (existing?.left && (existing.left.admin !== frame.admin || existing.contacts?.[existing.left.admin] !== linkId)) return;
26261
- if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis) return;
26262
- if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
26263
- if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
25913
+ if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis && !existing.invitation.takenBack) return;
25914
+ if ([...this.stored.values()].filter((x) => x.invitation && !x.invitation.takenBack).length >= 32) return;
25915
+ if (!(existing?.invitation && !existing.invitation.takenBack && existing.invitation.linkId === linkId) && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId && !x.invitation.takenBack).length >= MAX_INVITATIONS_PER_CHAT) {
26264
25916
  try {
26265
25917
  this.host.sendOnLink(linkId, {
26266
25918
  t: "group-decline",
@@ -26312,6 +25964,8 @@ var Groups = class {
26312
25964
  } catch {}
26313
25965
  const edge = this.host.edges(g).get(frame.key);
26314
25966
  if (letIn && session.isAdmin && !(edge && this.host.linkReady(edge))) {
25967
+ if (this.rewelcomedLately(g, frame.key)) return;
25968
+ this.noteRewelcome(g, frame.key);
26315
25969
  const welcome = await session.rewelcome(frame.key).catch(() => []);
26316
25970
  try {
26317
25971
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
@@ -26320,18 +25974,35 @@ var Groups = class {
26320
25974
  return;
26321
25975
  }
26322
25976
  const group = this.stored.get(g);
26323
- if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
25977
+ if (frame.key === session.myKey) return;
26324
25978
  const entryPeer = this.hostEntry(g, linkId);
26325
25979
  if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
26326
- if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
25980
+ const again = !!entryPeer && this.unwelcomed(group, session, frame.key);
25981
+ if (rosterHas(session.roster, frame.key) && !again) return;
25982
+ if (again && this.rewelcomedLately(g, frame.key)) {
25983
+ this.pendingEntries.get(g)?.delete(frame.key);
25984
+ await this.host.closeEdge(linkId);
25985
+ return;
25986
+ }
25987
+ if (again) this.noteRewelcome(g, frame.key);
25988
+ if (!again && (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId))) return;
26327
25989
  if (!entryPeer) await session.setNick(frame.key, this.host.contactName(linkId));
26328
- this.meet(g, frame.key);
25990
+ if (!again) this.meet(g, frame.key);
26329
25991
  if (this.host.linkReady(linkId, 3)) this.markLarge(group, frame.key);
26330
- const welcome = await session.admit(frame.key);
25992
+ let welcome;
25993
+ try {
25994
+ welcome = again ? await session.rewelcome(frame.key) : await session.admit(frame.key);
25995
+ } catch (error) {
25996
+ if (!again) throw error;
25997
+ this.pendingEntries.get(g)?.delete(frame.key);
25998
+ await this.host.closeEdge(linkId);
25999
+ return;
26000
+ }
26331
26001
  if (!entryPeer) group.contacts = {
26332
26002
  ...group.contacts,
26333
26003
  [frame.key]: linkId
26334
26004
  };
26005
+ if (!again) group.unseen = [...(group.unseen ?? []).filter((key) => rosterHas(session.roster, key)), frame.key];
26335
26006
  this.answered(group, linkId);
26336
26007
  await this.store.putGroup(group);
26337
26008
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
@@ -26354,7 +26025,7 @@ var Groups = class {
26354
26025
  case "group-decline": {
26355
26026
  const mine = this.stored.get(g);
26356
26027
  if (mine?.invitation && !mine.invitation.entry && !mine.state && mine.invitation.linkId === linkId) {
26357
- await this.dropInvitation(mine);
26028
+ await this.takeBackFrom(mine);
26358
26029
  return;
26359
26030
  }
26360
26031
  const entryPeer = this.hostEntry(g, linkId);
@@ -26363,6 +26034,7 @@ var Groups = class {
26363
26034
  await this.host.closeEdge(linkId);
26364
26035
  }
26365
26036
  const group = this.stored.get(g);
26037
+ if (group && await this.declinedUnseen(g, group, linkId)) return;
26366
26038
  if (!group || !this.answered(group, linkId)) return;
26367
26039
  await this.store.putGroup(group);
26368
26040
  this.host.emit();
@@ -26526,7 +26198,8 @@ var Groups = class {
26526
26198
  return;
26527
26199
  }
26528
26200
  const taken = await session.handle(peerKey, frame);
26529
- if (taken.some((f) => f.t === "group-msg")) {
26201
+ const askedAt = this.askedAt(groupId, peerKey);
26202
+ if (taken.some((f) => f.t === "group-msg" && f.ts < askedAt)) {
26530
26203
  let handed = this.lastHanded.get(groupId);
26531
26204
  if (!handed) this.lastHanded.set(groupId, handed = /* @__PURE__ */ new Map());
26532
26205
  handed.set(peerKey, this.now());
@@ -26599,9 +26272,14 @@ var Groups = class {
26599
26272
  return;
26600
26273
  }
26601
26274
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
26275
+ const seen = !!group.unseen?.includes(peerKey);
26276
+ if (seen) {
26277
+ const unseen = group.unseen.filter((key) => key !== peerKey);
26278
+ group.unseen = unseen.length ? unseen : void 0;
26279
+ }
26602
26280
  const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
26603
26281
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
26604
- if (large || legacy) this.store.putGroup(group).catch(() => {});
26282
+ if (large || legacy || seen) this.store.putGroup(group).catch(() => {});
26605
26283
  if (!this.holdSync(groupId, peerKey)) try {
26606
26284
  this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey)));
26607
26285
  } catch {
@@ -26624,6 +26302,41 @@ var Groups = class {
26624
26302
  this.host.edgeUp?.(groupId, peerKey);
26625
26303
  this.host.emit();
26626
26304
  }
26305
+ /**
26306
+ * Admin side: a `group-decline` over the contact chat that let in a member never seen on an edge since. Its welcome
26307
+ * was lost and its app declined the invitation it still showed: it never had the group's keys, so it goes from the
26308
+ * roster (and may be invited again). True when one went.
26309
+ */
26310
+ async declinedUnseen(groupId, group, linkId) {
26311
+ const session = this.sessions.get(groupId);
26312
+ if (!session?.isAdmin || session.status !== "active" || !group.unseen) return false;
26313
+ const key = group.unseen.find((k) => group.contacts?.[k] === linkId && rosterHas(session.roster, k));
26314
+ if (!key) return false;
26315
+ const edge = this.host.edges(groupId).get(key);
26316
+ if (edge && this.host.linkReady(edge)) return false;
26317
+ const unseen = group.unseen.filter((k) => k !== key);
26318
+ group.unseen = unseen.length ? unseen : void 0;
26319
+ await this.remove(groupId, key).catch(() => {});
26320
+ await this.store.putGroup(group);
26321
+ this.host.emit();
26322
+ return true;
26323
+ }
26324
+ /** Admin side: a member in the roster never seen on an edge since it was let in, whose edge is not up now. */
26325
+ /** This member was sent its welcome again less than `REWELCOME_MS` ago, over its chat or through the link. */
26326
+ rewelcomedLately(groupId, key) {
26327
+ const last = this.rewelcomed.get(groupId)?.get(key);
26328
+ return last !== void 0 && this.now() - last < REWELCOME_MS;
26329
+ }
26330
+ noteRewelcome(groupId, key) {
26331
+ let sent = this.rewelcomed.get(groupId);
26332
+ if (!sent) this.rewelcomed.set(groupId, sent = /* @__PURE__ */ new Map());
26333
+ sent.set(key, this.now());
26334
+ }
26335
+ unwelcomed(group, session, key) {
26336
+ if (!session.isAdmin || !group.unseen?.includes(key) || !rosterHas(session.roster, key)) return false;
26337
+ const edge = this.host.edges(group.id).get(key);
26338
+ return !(edge && this.host.linkReady(edge));
26339
+ }
26627
26340
  /** Records that a member's app takes rosters past eight; true when that is news (the caller saves). */
26628
26341
  markLarge(group, key) {
26629
26342
  if (group.large?.includes(key)) return false;
@@ -26731,13 +26444,19 @@ var Groups = class {
26731
26444
  if (out.length) this.lastGossip.set(groupId, now);
26732
26445
  return out;
26733
26446
  }
26447
+ /** When `via` was last asked for another member's messages (`askOf`), or 0: what it sends written before that is its answer. */
26448
+ askedAt(groupId, via) {
26449
+ let at = 0;
26450
+ for (const asked of this.relayAsked.get(groupId)?.values() ?? []) if (asked.via === via && asked.at > at) at = asked.at;
26451
+ return at;
26452
+ }
26734
26453
  /**
26735
26454
  * Whether the answer of the member asked for someone's messages may still be coming: its edge up, asked under
26736
- * `MESH_SYNC_HOLD_MAX_MS` ago, and a message from it (or the asking) under `MESH_SYNC_HOLD_MS` ago.
26455
+ * `MESH_SYNC_HOLD_MAX_MS` ago, and a message of its answer (or the asking) under `MESH_SYNC_HOLD_MS` ago.
26737
26456
  */
26738
26457
  answering(groupId, { via, at }, now) {
26739
26458
  const edge = this.host.edges(groupId).get(via);
26740
- if (!edge || !this.host.linkReady(edge) || now - at >= 1e4) return false;
26459
+ if (!edge || !this.host.linkReady(edge) || now - at >= 18e4) return false;
26741
26460
  return now - Math.max(at, this.lastHanded.get(groupId)?.get(via) ?? 0) < MESH_SYNC_HOLD_MS;
26742
26461
  }
26743
26462
  /**
@@ -26757,21 +26476,41 @@ var Groups = class {
26757
26476
  held.set(peerKey, { ...asked });
26758
26477
  return true;
26759
26478
  }
26760
- /** The syncs that waited for another member's answer (`holdSync`), once it has come: for what it did not hand on. */
26479
+ /**
26480
+ * The syncs that waited for another member's answer (`holdSync`), once it has come: for what it did not hand on.
26481
+ * One member at a time: the first is told and asked for the others too, which wait for its answer in turn. Told
26482
+ * together, each answered with its own messages alone, and when the member asked first had been away too (it held
26483
+ * little of what was missed) a conversation came as one member's lines, then the next's.
26484
+ */
26761
26485
  heldSyncs(groupId, session, now) {
26762
26486
  const held = this.syncHeld.get(groupId);
26763
26487
  if (!held?.size) return;
26764
- const edges = this.host.edges(groupId);
26765
- for (const [key, asked] of held) {
26766
- if (this.answering(groupId, asked, now)) continue;
26488
+ const edges = this.host.edges(groupId), asked = this.relayAsked.get(groupId);
26489
+ const due = [];
26490
+ for (const [key, of] of held) {
26491
+ if (this.answering(groupId, of, now)) continue;
26767
26492
  held.delete(key);
26768
- if (this.relayAsked.get(groupId)?.get(key)?.via === asked.via) this.relayAsked.get(groupId).delete(key);
26493
+ if (asked?.get(key)?.via === of.via) asked.delete(key);
26769
26494
  const edge = edges.get(key);
26770
- if (!edge || !this.host.linkReady(edge) || !rosterHas(session.roster, key)) continue;
26771
- try {
26772
- this.host.sendOnLink(edge, session.syncFrame(this.askOf(groupId, session, key)));
26773
- } catch {}
26495
+ if (edge && this.host.linkReady(edge) && rosterHas(session.roster, key)) due.push(key);
26496
+ }
26497
+ const [next, ...rest] = due;
26498
+ if (!next) return;
26499
+ const ask = new Set(this.askOf(groupId, session, next));
26500
+ for (const key of rest) {
26501
+ ask.add(key);
26502
+ asked?.set(key, {
26503
+ via: next,
26504
+ at: now
26505
+ });
26506
+ held.set(key, {
26507
+ via: next,
26508
+ at: now
26509
+ });
26774
26510
  }
26511
+ try {
26512
+ this.host.sendOnLink(edges.get(next), session.syncFrame([...ask]));
26513
+ } catch {}
26775
26514
  }
26776
26515
  /**
26777
26516
  * Reachable members whose messages below the highest seen never reached me, for `via` to hand on: their own answer
@@ -27024,7 +26763,8 @@ var Groups = class {
27024
26763
  if (!session) throw new Error("Group not found");
27025
26764
  return session;
27026
26765
  }
27027
- attach(state) {
26766
+ /** `frames`: what the group's rows hold to hand on, oldest first (none yet for a group just made, joined or given back). */
26767
+ attach(state, frames = []) {
27028
26768
  const session = new GroupSession(state, {
27029
26769
  clock: () => this.now(),
27030
26770
  seqFloor: () => this.host.seqFloor?.() ?? 0,
@@ -27078,8 +26818,21 @@ var Groups = class {
27078
26818
  ...m.frame && { frame: m.frame }
27079
26819
  };
27080
26820
  const completed = !!m.completes && !!this.host.completeMessage;
26821
+ const placing = completed ? void 0 : this.lineOrder.placing(MESSAGE_LINK(state.id), {
26822
+ id: m.id,
26823
+ epoch: m.epoch,
26824
+ line: false
26825
+ }, this.now());
27081
26826
  const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
26827
+ placing?.(stored !== false);
27082
26828
  if (m.frame && (completed || stored !== false)) await this.frameKept(state.id, m.id, m.frame);
26829
+ if (!completed && stored !== false) this.authorOrder.placed(state.id, {
26830
+ sender: m.sender,
26831
+ epoch: m.epoch,
26832
+ seq: m.seq,
26833
+ id: m.id,
26834
+ at: this.now()
26835
+ });
27083
26836
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
27084
26837
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
27085
26838
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
@@ -27087,6 +26840,10 @@ var Groups = class {
27087
26840
  noteCame(this.stored.get(state.id), came, m.sender !== session.myKey, mentioned);
27088
26841
  this.typings.messageFrom(state.id, m.sender);
27089
26842
  },
26843
+ kept: async (id, frame) => {
26844
+ await this.host.keepFrame?.(MESSAGE_LINK(state.id), id, frame).catch(() => {});
26845
+ await this.frameKept(state.id, id, frame);
26846
+ },
27090
26847
  edit: (e) => this.host.groupEdit?.(state.id, e),
27091
26848
  changed: () => {
27092
26849
  if (session.status === "removed" && this.hubs.isHub(state.id) && !this.removedAt.has(state.id)) this.removedAt.set(state.id, this.now());
@@ -27099,7 +26856,7 @@ var Groups = class {
27099
26856
  const edge = this.host.edges(state.id).get(to);
27100
26857
  return edge && this.host.linkHandled ? this.host.linkHandled(edge) : Promise.resolve(false);
27101
26858
  } }
27102
- });
26859
+ }, frames);
27103
26860
  this.sessions.set(state.id, session);
27104
26861
  this.lastRoster.set(state.id, session.roster);
27105
26862
  this.lastTold.set(state.id, {
@@ -27128,7 +26885,13 @@ var Groups = class {
27128
26885
  for (const [key] of after) if (!rosterHas(before, key) && key !== session.myKey) await this.event(groupId, "joined", `${name(key)} joined`, when, top.e, key);
27129
26886
  const gone = before.filter(([key]) => !rosterHas(after, key)).map(([key]) => key);
27130
26887
  this.hubs.removed(groupId, gone, this.now());
27131
- for (const key of gone) await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
26888
+ const left = (key) => {
26889
+ const chain = session.state.chain;
26890
+ for (let e = chain.length - 1; e > 0; e--) if (chain[e].k === "remove" && chain[e].s === key) return !!session.state.leftMarks?.[e];
26891
+ return false;
26892
+ };
26893
+ for (const key of gone) if (left(key)) await this.event(groupId, "left", `${name(key)} left`, when, top.e, key);
26894
+ else await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
27132
26895
  if (top.k === "role") await this.event(groupId, "admin", `${name(top.s)} ${top.s === session.myKey ? "are" : "is"} now the admin`, when, top.e, top.s);
27133
26896
  if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", when, top.e);
27134
26897
  }
@@ -27183,16 +26946,21 @@ var Groups = class {
27183
26946
  /** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `eventTime`. */
27184
26947
  async event(groupId, event, text, timestamp, epoch, member) {
27185
26948
  timestamp = eventTime(this.eventTimes, groupId, timestamp);
27186
- await this.host.storeMessage({
26949
+ const id = `event:${epoch}:${event}:${timestamp}`;
26950
+ this.lineOrder.placing(MESSAGE_LINK(groupId), {
26951
+ id,
26952
+ epoch,
26953
+ line: true
26954
+ }, this.now())(await this.host.storeMessage({
27187
26955
  linkId: MESSAGE_LINK(groupId),
27188
- id: `event:${epoch}:${event}:${timestamp}`,
26956
+ id,
27189
26957
  text,
27190
26958
  sender: "peer",
27191
26959
  event,
27192
26960
  member,
27193
26961
  timestamp,
27194
26962
  via: "datalink"
27195
- });
26963
+ }) !== false);
27196
26964
  }
27197
26965
  eventTimes = /* @__PURE__ */ new Map();
27198
26966
  };
@@ -29146,8 +28914,6 @@ function resumeHere(log, runs, peer) {
29146
28914
  const shared = runs.find((transport) => peer?.includes(transport));
29147
28915
  return shared ? { resume: shared } : {};
29148
28916
  }
29149
- /** A round trip that moved less than this is not news in the history. */
29150
- var RTT_STEP_MS = 10;
29151
28917
  var sequence = 0;
29152
28918
  var lineId = (at) => `${at.toString(36)}-${(++sequence).toString(36)}`;
29153
28919
  /** A row that says the chat is live on its transport from then on. */
@@ -29551,7 +29317,7 @@ var TransportLog = class {
29551
29317
  changed = true;
29552
29318
  }
29553
29319
  const stretch = [...this.history].reverse().find((e) => e.kind === "live" || e.kind === "switched" || e.kind === "down");
29554
- if (stretch && stretch.kind !== "down" && stretch.transport === on && (stretch.rttMs === void 0 || Math.abs(stretch.rttMs - ms) >= RTT_STEP_MS)) {
29320
+ if (stretch && stretch.kind !== "down" && stretch.transport === on && (stretch.rttMs === void 0 || Math.abs(stretch.rttMs - ms) >= 10)) {
29555
29321
  stretch.rttMs = ms;
29556
29322
  changed = true;
29557
29323
  }
@@ -34483,24 +34249,6 @@ function messageAttention(type, message, { startedAt, newest, now }) {
34483
34249
  };
34484
34250
  }
34485
34251
  //#endregion
34486
- //#region ../browser/src/shared/hyperdhtRelay.ts
34487
- /** Only `wss://`, or `ws://` to this machine (a relay for tests or development). */
34488
- function hyperdhtRelayProblem(url) {
34489
- let parsed;
34490
- try {
34491
- parsed = new URL(url);
34492
- } catch {
34493
- return "Enter a relay address (wss://…)";
34494
- }
34495
- if (parsed.protocol === "wss:") return null;
34496
- if (parsed.protocol === "ws:" && [
34497
- "127.0.0.1",
34498
- "localhost",
34499
- "[::1]"
34500
- ].includes(parsed.hostname)) return null;
34501
- return "Use a wss:// relay address";
34502
- }
34503
- //#endregion
34504
34252
  //#region ../browser/src/shared/pushRelay.ts
34505
34253
  /**
34506
34254
  * The push relay a browser hands wake-ups to when it may not post to a push service itself (WISP 401 § Wake-up
@@ -34651,10 +34399,10 @@ var NATIVE_SLOTS = 8;
34651
34399
  var IROH_WEB_SLOTS = 16;
34652
34400
  /** Of a transport's listeners, the ones a group's links never hold: 1:1 chats keep that room. */
34653
34401
  var CHAT_NATIVE_SLOTS = 4;
34654
- /** How often a group link that found no free native slot tries again. */
34655
- var GROUP_NATIVE_RETRY_MS = 15e3;
34656
- /** The longest a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
34657
- var GROUP_NATIVE_FAILED_MAX_MS = 3e5;
34402
+ /** How often a group link that found no free native slot tries again, and the first wait of a listener that failed to start. */
34403
+ var NATIVE_RETRY_MS = 15e3;
34404
+ /** The longest a chat or a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
34405
+ var NATIVE_FAILED_MAX_MS = 3e5;
34658
34406
  /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
34659
34407
  var CLEAR_WITHDRAW_MS = 6e3;
34660
34408
  /**
@@ -34683,11 +34431,19 @@ var DEFAULT_SETTINGS = {
34683
34431
  */
34684
34432
  var CARD_RESTORE_MOST = 50;
34685
34433
  var CARD_RESTORE_MS = 6048e5;
34434
+ /**
34435
+ * A round trip that moved is shown at once, and written to disk by itself (relaxed, no disk sync of its own) at most
34436
+ * this often: a 15 s sample on an internet path moves by 10 ms most of the time. A power cut loses at most this much
34437
+ * of the round trip's drift, never a row; any other change to the story writes the latest round trip with it.
34438
+ */
34439
+ var RTT_SAVE_MS = 3e5;
34686
34440
  /** A reaction said on the live session and not confirmed is said again after this long. */
34687
34441
  var REACTION_RESEND_MS = 3e4;
34688
34442
  /** A file or a payment request of mine that waits for the chat to be live (a text that waits is its outbox's). */
34689
34443
  var waitsForLive = (m) => m.sender === "me" && m.delivery === "waiting" && (!!m.file || !!m.paymentId);
34690
34444
  /** This side's participation key in a chat: derived from its seed once, not at every state built. */
34445
+ /** A read of the contact's packet that found it as the last one did: online the same, the same packet, name and services. */
34446
+ var samePresence = (a, b) => a.online === b.online && a.lastPacketAt === b.lastPacketAt && a.seenAt === b.seenAt && a.nick === b.nick && JSON.stringify(a.services) === JSON.stringify(b.services);
34691
34447
  function participationKeyOf(live) {
34692
34448
  const seed = live.stored.participationSeed;
34693
34449
  if (!seed) return void 0;
@@ -34865,7 +34621,7 @@ var GhostlyNode = class GhostlyNode {
34865
34621
  * Everything that publishes, dials, polls or settles asks this, not the stored setting.
34866
34622
  */
34867
34623
  get networkOn() {
34868
- return !!this.settings.online && !this.limitedMode;
34624
+ return !!this.settings.online && !this.limitedMode && !this.options.offline;
34869
34625
  }
34870
34626
  transport;
34871
34627
  /** The same, for groups' requests: a relay budget keeps a chat's share from them (`CHAT_RESERVE`). */
@@ -34910,6 +34666,8 @@ var GhostlyNode = class GhostlyNode {
34910
34666
  });
34911
34667
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
34912
34668
  transfersRestored = false;
34669
+ /** `start` has loaded the groups (EngineState.groupsLoaded). */
34670
+ groupsLoaded = false;
34913
34671
  feedbackStartedAt = Date.now();
34914
34672
  feedbackIds = /* @__PURE__ */ new Set();
34915
34673
  walletFeedbackReady = false;
@@ -34993,9 +34751,15 @@ var GhostlyNode = class GhostlyNode {
34993
34751
  groupWakeReceived = /* @__PURE__ */ new Map();
34994
34752
  requestCounts = /* @__PURE__ */ new Map();
34995
34753
  transfers = /* @__PURE__ */ new Map();
34754
+ /**
34755
+ * Views that go in the next state sent, and then away (`GroupFilesHost.brief`): dropped once a state carried them,
34756
+ * unless another view took their place meanwhile.
34757
+ */
34758
+ briefViews = /* @__PURE__ */ new Map();
34996
34759
  /** files/3 in every chat: offers, resumable transfers, checked by digest (WISP 501 rev 0.3). */
34997
34760
  fileDesk = new FileDesk({
34998
34761
  send: (linkId, frame) => this.links.get(linkId)?.link?.sendFilesFrame(frame) ?? false,
34762
+ sendData: (linkId, id, offset, data) => this.links.get(linkId)?.link?.sendFileData(id, offset, data) ?? false,
34999
34763
  writable: (linkId) => this.links.get(linkId)?.link?.filesWritable(),
35000
34764
  receivedBytes: (linkId) => this.links.get(linkId)?.files.receivedBytes ?? 0,
35001
34765
  wireIds: (linkId) => this.links.get(linkId)?.files.wireIds,
@@ -35075,24 +34839,24 @@ var GhostlyNode = class GhostlyNode {
35075
34839
  * Every wallet twice, one per network (real money, test coins), both open at once. Nothing is parked: a payment
35076
34840
  * goes through the wallet of its own network, and a wallet of one network never pays for the other.
35077
34841
  */
35078
- usdtWallets = perNetwork((network) => new UsdtWallet(network, () => {
35079
- this.refreshWallet();
34842
+ usdtWallets = perNetwork((network) => new UsdtWallet(network, (viewChanged = true) => {
34843
+ if (viewChanged) this.refreshWallet();
35080
34844
  this.desk.reconcileUsdtReceipts().catch(() => {});
35081
34845
  }));
35082
- arkWallets = perNetwork((network) => new ArkWallet(network, () => {
35083
- this.refreshWallet();
34846
+ arkWallets = perNetwork((network) => new ArkWallet(network, (viewChanged = true) => {
34847
+ if (viewChanged) this.refreshWallet();
35084
34848
  this.desk.reconcileArkReceipts().catch(() => {});
35085
34849
  }));
35086
- barkWallets = perNetwork((network) => new BarkWallet(network, () => {
35087
- this.refreshWallet();
34850
+ barkWallets = perNetwork((network) => new BarkWallet(network, (viewChanged = true) => {
34851
+ if (viewChanged) this.refreshWallet();
35088
34852
  this.desk.reconcileBarkReceipts().catch(() => {});
35089
34853
  }));
35090
34854
  fedimintWallets = perNetwork((network) => new FedimintWallet(network, {
35091
34855
  changed: () => void this.refreshWallet(),
35092
34856
  received: (paymentId) => void this.desk.onLightningPaid({ paymentId })
35093
34857
  }, () => (this.options.fedimintSdk ?? loadFedimintSdk)()));
35094
- sparkWallets = perNetwork((network) => new SparkWallet(network, () => {
35095
- this.refreshWallet();
34858
+ sparkWallets = perNetwork((network) => new SparkWallet(network, (viewChanged = true) => {
34859
+ if (viewChanged) this.refreshWallet();
35096
34860
  this.desk.reconcileSparkReceipts().catch(() => {});
35097
34861
  }));
35098
34862
  /** What a call naming no network acts on (a caller from before wallets had their own): Mainnet. */
@@ -35419,29 +35183,38 @@ var GhostlyNode = class GhostlyNode {
35419
35183
  if (live.stored.deletedIds?.includes(`peer_${wire.wireId}`)) return null;
35420
35184
  live.files.wireIds.add(wire.wireId);
35421
35185
  live.files.receivedBytes += wire.size;
35422
- await fileStore.put({
35423
- id: file.id,
35424
- linkId,
35425
- blob: new Blob([bytes], { type: safeBlobType(file.mime) }),
35426
- createdAt: Date.now(),
35427
- direction: "in",
35428
- wireId: wire.wireId,
35429
- digest,
35430
- metadata: {
35431
- name: wire.name,
35432
- size: wire.size,
35433
- mime: wire.mime,
35434
- timestamp: wire.timestamp,
35435
- voice: wire.voice,
35436
- video: wire.video,
35437
- image: wire.image
35438
- },
35439
- transfer: {
35440
- state: "done",
35441
- transferred: wire.size,
35442
- size: wire.size
35443
- }
35444
- });
35186
+ const storage = await fileBytes();
35187
+ try {
35188
+ await storage.stage(file.id, new Blob([bytes]));
35189
+ await fileStore.put({
35190
+ id: file.id,
35191
+ linkId,
35192
+ bytes: storage.kind,
35193
+ createdAt: Date.now(),
35194
+ direction: "in",
35195
+ wireId: wire.wireId,
35196
+ digest,
35197
+ metadata: {
35198
+ name: wire.name,
35199
+ size: wire.size,
35200
+ mime: wire.mime,
35201
+ timestamp: wire.timestamp,
35202
+ voice: wire.voice,
35203
+ video: wire.video,
35204
+ image: wire.image
35205
+ },
35206
+ transfer: {
35207
+ state: "done",
35208
+ transferred: wire.size,
35209
+ size: wire.size
35210
+ }
35211
+ });
35212
+ } catch (error) {
35213
+ await storage.remove(file.id).catch(() => {});
35214
+ live.files.wireIds.delete(wire.wireId);
35215
+ live.files.receivedBytes -= wire.size;
35216
+ throw error;
35217
+ }
35445
35218
  this.transfers.set(file.id, {
35446
35219
  state: "done",
35447
35220
  transferred: wire.size,
@@ -35771,6 +35544,10 @@ var GhostlyNode = class GhostlyNode {
35771
35544
  forgetFrame: async (linkId, id) => {
35772
35545
  await db.patchMessage(linkId, id, (stored) => stored.frame ? { frame: void 0 } : null, true);
35773
35546
  },
35547
+ keepFrame: async (linkId, id, frame) => {
35548
+ await db.patchMessage(linkId, id, () => ({ frame }), true);
35549
+ },
35550
+ placeAgain: (linkId, ids) => this.placeAgain(linkId, ids),
35774
35551
  membersChanged: (groupId) => this.groupMembersChanged(groupId),
35775
35552
  historyGone: (groupId) => this.groupHistoryGone(groupId),
35776
35553
  emit: () => this.emitState(),
@@ -35791,11 +35568,16 @@ var GhostlyNode = class GhostlyNode {
35791
35568
  groupFiles: {
35792
35569
  store: storedGroupFiles,
35793
35570
  transfers: this.transfers,
35571
+ brief: (id) => {
35572
+ const view = this.transfers.get(id);
35573
+ if (view) this.briefViews.set(id, view);
35574
+ },
35794
35575
  settings: () => ({
35795
35576
  autoDownloads: this.settings.autoDownloads !== false,
35796
35577
  serveFiles: this.settings.serveFiles !== false
35797
35578
  }),
35798
35579
  sendFiles: (linkId, frame) => this.links.get(linkId)?.link?.sendFilesFrame(frame) ?? false,
35580
+ sendData: (linkId, id, offset, data) => this.links.get(linkId)?.link?.sendFileData(id, offset, data) ?? false,
35799
35581
  writable: (linkId) => this.links.get(linkId)?.link?.filesWritable()
35800
35582
  }
35801
35583
  });
@@ -36060,6 +35842,7 @@ var GhostlyNode = class GhostlyNode {
36060
35842
  const wallets = this.withHomes(walletInstances(networks));
36061
35843
  const intents = (await intentRepository.list()).map((saved) => saved.review);
36062
35844
  this.noteOpenReviews(intents);
35845
+ this.paymentCoordinator.notePending(intents);
36063
35846
  this.walletView = {
36064
35847
  ...networks.mainnet,
36065
35848
  networks,
@@ -36188,7 +35971,7 @@ var GhostlyNode = class GhostlyNode {
36188
35971
  const messages = stored.group ? [] : await db.getMessages(stored.id);
36189
35972
  const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
36190
35973
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
36191
- for (const file of storedFiles) if (file.transfer && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
35974
+ for (const file of storedFiles) if (file.transfer && file.transfer.state !== "done" && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
36192
35975
  ...file.transfer,
36193
35976
  state: "failed",
36194
35977
  error: "Transfer interrupted. Retry when connected."
@@ -36206,6 +35989,7 @@ var GhostlyNode = class GhostlyNode {
36206
35989
  this.transfersRestored = true;
36207
35990
  for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
36208
35991
  await this.groups.load(this.limitedMode);
35992
+ this.groupsLoaded = true;
36209
35993
  for (const group of this.groups.views()) {
36210
35994
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
36211
35995
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -36224,7 +36008,7 @@ var GhostlyNode = class GhostlyNode {
36224
36008
  this.prepareSpare(STARTUP_QUIET_MS);
36225
36009
  }
36226
36010
  if (!this.limitedMode) this.openStartedWallets(fresh);
36227
- if (this.appsOn) this.appStore.start().catch(() => {});
36011
+ if (this.appsOn && !this.appsWireOnly) this.appStore.start().catch(() => {});
36228
36012
  if (!this.options.singleDevice && this.networkOn && knownDeviceGate()?.state === "active") this.startDeviceSet().catch(() => {});
36229
36013
  if (!this.options.singleDevice && this.limitedMode && knownDeviceGate()?.state === "active") this.readTurnWhileLimited();
36230
36014
  if (this.restoreCheck === "checking" && this.limitedMode) this.checkRestoredTurn(0);
@@ -36689,7 +36473,7 @@ var GhostlyNode = class GhostlyNode {
36689
36473
  appShelf;
36690
36474
  /** The installed apps and stores, refused while the apps feature is off. */
36691
36475
  get appStore() {
36692
- if (!this.appsOn) throw this.appsOff();
36476
+ if (!this.appsOn || this.appsWireOnly) throw this.appsOff();
36693
36477
  return this.appShelf ??= new Apps({
36694
36478
  fetch: boundedAppFetch({
36695
36479
  fetcher: this.options.appFetch,
@@ -36700,12 +36484,16 @@ var GhostlyNode = class GhostlyNode {
36700
36484
  return !!stored && !stored.group && !!stored.profile && !!stored.pairedPeerKey;
36701
36485
  },
36702
36486
  online: () => this.networkOn && !this.shuttingDown,
36703
- checked: () => this.events.onAppsChecked?.()
36487
+ checked: () => this.events.onAppsChecked?.(),
36488
+ ...this.options.builtInApp && { readBuiltIn: this.options.builtInApp }
36704
36489
  });
36705
36490
  }
36706
36491
  appList() {
36707
36492
  return this.appStore.list();
36708
36493
  }
36494
+ appBuiltIns() {
36495
+ return this.appStore.builtIns();
36496
+ }
36709
36497
  appStoreList() {
36710
36498
  return this.appStore.listStores();
36711
36499
  }
@@ -36751,6 +36539,9 @@ var GhostlyNode = class GhostlyNode {
36751
36539
  appFile(params) {
36752
36540
  return this.appStore.file(params);
36753
36541
  }
36542
+ appFileAt(params) {
36543
+ return this.appStore.fileAt(params);
36544
+ }
36754
36545
  appStorageGet(params) {
36755
36546
  return this.appStore.storageGet(params);
36756
36547
  }
@@ -36766,6 +36557,9 @@ var GhostlyNode = class GhostlyNode {
36766
36557
  appDataExport(params) {
36767
36558
  return this.appStore.exportData(params);
36768
36559
  }
36560
+ appDataExportText(params) {
36561
+ return this.appStore.exportDataText(params);
36562
+ }
36769
36563
  getState() {
36770
36564
  const groups = this.groups.views();
36771
36565
  return {
@@ -36793,6 +36587,7 @@ var GhostlyNode = class GhostlyNode {
36793
36587
  })).sort((a, b) => a.createdAt - b.createdAt),
36794
36588
  transfers: Object.fromEntries(this.transfers),
36795
36589
  transfersRestored: this.transfersRestored,
36590
+ ...this.groupsLoaded && { groupsLoaded: true },
36796
36591
  wallet: this.walletView,
36797
36592
  payments: this.desk.views(),
36798
36593
  identityProofs: this.identities.views(),
@@ -37645,7 +37440,7 @@ var GhostlyNode = class GhostlyNode {
37645
37440
  this.transport.networkChanged?.();
37646
37441
  this.directPath.reset();
37647
37442
  }
37648
- if (params.network) for (const failed of this.groupNativeFailed.values()) {
37443
+ if (params.network) for (const failed of this.nativeFailed.values()) {
37649
37444
  failed.at = 0;
37650
37445
  failed.wait = 0;
37651
37446
  }
@@ -37770,6 +37565,10 @@ var GhostlyNode = class GhostlyNode {
37770
37565
  error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
37771
37566
  refused: true
37772
37567
  };
37568
+ if (!pairedMessageFits(timestamp, trimmed, reply && pairedWireReply(reply), forwarded, card)) return {
37569
+ error: PAIRED_FRAME_TOO_LARGE,
37570
+ refused: true
37571
+ };
37773
37572
  const stop = this.chatStopped(live);
37774
37573
  if (stop) return { error: stop };
37775
37574
  if (!link.isDataLinkOpen && link.dhtDelivery?.inviteTaken) return {
@@ -37859,14 +37658,23 @@ var GhostlyNode = class GhostlyNode {
37859
37658
  /**
37860
37659
  * The message of a chat a reply answers, by its id here or the id both sides know it by (`replyRef`): what the
37861
37660
  * reply keeps of it, or why there is nothing to reply to. A group's reply needs the author's member key too.
37661
+ * Read alone by its id, or a chat's `peer_`/`me_` row for a wire id, as `resolveReply` does: a group's message goes
37662
+ * by its row's id, so a miss there is not here; a chat's other rows (a file's, a payment's) are looked for in the
37663
+ * whole chat.
37862
37664
  */
37863
37665
  async replyFor(linkId, messageId) {
37864
37666
  if (typeof messageId !== "string" || !messageId) return "No message to reply to";
37865
- const messages = await db.getMessages(linkId);
37866
- const original = messages.find((m) => m.id === messageId) ?? messages.find((m) => replyRef(m) === messageId);
37667
+ const group = linkId.startsWith("group:");
37668
+ let original = await db.getMessage(linkId, messageId);
37669
+ for (const rowId of group ? [] : [`peer_${messageId}`, `me_${messageId}`]) {
37670
+ if (original) break;
37671
+ const row = await db.getMessage(linkId, rowId);
37672
+ if (row && replyRef(row) === messageId) original = row;
37673
+ }
37674
+ if (!original && !group) original = (await db.getMessages(linkId)).find((m) => replyRef(m) === messageId);
37867
37675
  const ref = original && replyRef(original);
37868
37676
  if (!original || !ref) return "That message is not in this chat, or cannot be replied to";
37869
- if (linkId.startsWith("group:") && !original.member) return "That message cannot be replied to";
37677
+ if (group && !original.member) return "That message cannot be replied to";
37870
37678
  return replyTo(original, ref);
37871
37679
  }
37872
37680
  /**
@@ -38204,6 +38012,7 @@ var GhostlyNode = class GhostlyNode {
38204
38012
  const chat = this.links.get(linkId)?.stored;
38205
38013
  outbox = new Outbox({
38206
38014
  read: () => db.getMessages(linkId),
38015
+ get: (id) => db.getMessage(linkId, id),
38207
38016
  update: async (id, delivery, error, extra) => {
38208
38017
  if (delivery === "delivered") await db.patchMessage(linkId, id, (message) => message.delivery === "delivered" ? null : {
38209
38018
  ...message.delivery && {
@@ -39285,19 +39094,40 @@ var GhostlyNode = class GhostlyNode {
39285
39094
  const cue = transportCue(mark, log.entries, log.history);
39286
39095
  if (cue) this.cueFeedback(cue, linkId);
39287
39096
  }
39288
- saveTransportLog(live, log) {
39097
+ /** `toDisk: false` shows the story and leaves the disk as it is; `relaxed`: written with no disk sync of its own. */
39098
+ saveTransportLog(live, log, { toDisk = true, relaxed = false } = {}) {
39289
39099
  const transportLog = log.entries.map((e) => ({ ...e })), transportHistory = log.history.map((e) => ({ ...e }));
39290
39100
  live.stored = {
39291
39101
  ...live.stored,
39292
39102
  transportLog,
39293
39103
  transportHistory
39294
39104
  };
39295
- db.patchLink(live.stored.id, {
39296
- transportLog,
39297
- transportHistory
39298
- }).catch(() => {});
39105
+ if (toDisk) {
39106
+ live.rttUnsaved = false;
39107
+ db.patchLink(live.stored.id, {
39108
+ transportLog,
39109
+ transportHistory
39110
+ }, relaxed).catch(() => {});
39111
+ }
39299
39112
  this.emitState();
39300
39113
  }
39114
+ /** A round trip measured on the chat's transport: in the view at once, on disk at most every `RTT_SAVE_MS`. */
39115
+ noteRtt(live, ms) {
39116
+ const moved = live.rttPosted === void 0 || Math.abs(live.rttPosted - ms) >= 10;
39117
+ const log = this.transportLogOf(live);
39118
+ if (!log) {
39119
+ if (moved) this.emitState();
39120
+ return;
39121
+ }
39122
+ const changed = log.rtt(ms);
39123
+ if (changed) live.rttUnsaved = true;
39124
+ const now = Date.now();
39125
+ if (live.rttUnsaved && (live.rttSavedAt === void 0 || now - live.rttSavedAt >= RTT_SAVE_MS)) {
39126
+ live.rttSavedAt = now;
39127
+ this.saveTransportLog(live, log, { relaxed: true });
39128
+ } else if (changed) this.saveTransportLog(live, log, { toDisk: false });
39129
+ else if (moved) this.emitState();
39130
+ }
39301
39131
  /** Which ways of paying one chat allows. */
39302
39132
  /** `networks`: for a way of paying, the networks this chat takes it on (the Accept side's cards); the others are kept. */
39303
39133
  async setChatPaymentMethods({ linkId, methods, networks }) {
@@ -39329,7 +39159,9 @@ var GhostlyNode = class GhostlyNode {
39329
39159
  async connect({ linkId }) {
39330
39160
  const link = this.links.get(linkId)?.link;
39331
39161
  if (!link) throw new Error("You are offline. Go online before reconnecting.");
39332
- await this.ensureNativeEndpoints(linkId);
39162
+ const live = this.links.get(linkId), native = this.ensureNativeEndpoints(linkId);
39163
+ const { preferredTransport: preferred, transportFallback: fallback } = live.stored;
39164
+ if (this.reachedOnlyNatively(live) || fallback === false && preferred && preferred !== "webrtc/1") await native;
39333
39165
  await link.connect();
39334
39166
  }
39335
39167
  disconnect({ linkId }) {
@@ -39839,11 +39671,22 @@ var GhostlyNode = class GhostlyNode {
39839
39671
  }
39840
39672
  /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
39841
39673
  get appsOn() {
39842
- return this.options.apps ?? true;
39674
+ return !!(this.options.apps ?? true);
39675
+ }
39676
+ /** `apps/1` only (`apps: "wire"`): this host runs no mini-app and keeps no store, so nothing is installed here to check. */
39677
+ get appsWireOnly() {
39678
+ return this.options.apps === "wire";
39843
39679
  }
39844
- /** Why an app call is refused: this client runs no mini-app (`apps: false`), or the build has them off. */
39680
+ /** Why an app call is refused: this client runs no mini-app (`apps: false`, or `"wire"` for the store's), or the build has them off. */
39845
39681
  appsOff() {
39846
- return /* @__PURE__ */ new Error(this.options.apps === false ? "Apps do not run on this client" : "Apps are unavailable in this release");
39682
+ return /* @__PURE__ */ new Error(this.options.apps === false || this.appsWireOnly ? "Apps do not run on this client" : "Apps are unavailable in this release");
39683
+ }
39684
+ /** Before an app's frames go to a contact: a version removed or revoked here says nothing more (WISP 1200 § Takedowns). */
39685
+ async appChatRunnable(ref, digest) {
39686
+ if (!this.appsWireOnly) await this.appStore.chatRunnable({
39687
+ ref,
39688
+ digest
39689
+ });
39847
39690
  }
39848
39691
  /** A paired 1:1 chat and the chat app id of `ref` in it, from the two pinned participation keys (WISP 1200 § In a chat). */
39849
39692
  appChat(linkId, ref) {
@@ -39869,10 +39712,7 @@ var GhostlyNode = class GhostlyNode {
39869
39712
  async appOpen({ linkId, ref, version, digest }) {
39870
39713
  const { live, app } = this.appChat(linkId, ref);
39871
39714
  if (!isAppVersion(version)) throw new Error("Not an app version");
39872
- await this.appStore.chatRunnable({
39873
- ref,
39874
- digest
39875
- });
39715
+ await this.appChatRunnable(ref, digest);
39876
39716
  const open = live.appsOpen ??= /* @__PURE__ */ new Map();
39877
39717
  if (live.link) live.link.openApp(app, version);
39878
39718
  else open.set(app, version);
@@ -39888,10 +39728,7 @@ var GhostlyNode = class GhostlyNode {
39888
39728
  async appSend({ linkId, ref, data, digest }) {
39889
39729
  const { live, app } = this.appChat(linkId, ref);
39890
39730
  if (!live.appsOpen?.has(app)) return { error: "not-open" };
39891
- await this.appStore.chatRunnable({
39892
- ref,
39893
- digest
39894
- });
39731
+ await this.appChatRunnable(ref, digest);
39895
39732
  return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
39896
39733
  }
39897
39734
  /** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
@@ -39998,8 +39835,10 @@ var GhostlyNode = class GhostlyNode {
39998
39835
  this.links.get(edge)?.link?.sendGroupFrame(frame);
39999
39836
  } catch {}
40000
39837
  }
40001
- leaveGroup({ groupId }) {
40002
- return this.groups.leave(groupId);
39838
+ /** `admin`: the member a leaving admin handed the role to. */
39839
+ async leaveGroup({ groupId }) {
39840
+ const admin = await this.groups.leave(groupId);
39841
+ return admin ? { admin } : {};
40003
39842
  }
40004
39843
  removeGroupMember({ groupId, key }) {
40005
39844
  return this.groups.remove(groupId, key);
@@ -41018,6 +40857,7 @@ var GhostlyNode = class GhostlyNode {
41018
40857
  const settings = rest;
41019
40858
  delete settings.backupReminders;
41020
40859
  const wasOnline = this.networkOn;
40860
+ if (Array.isArray(settings.iceServers)) settings.iceServers = settings.iceServers.map(iceServerSetting);
41021
40861
  for (const server of settings.iceServers ?? []) {
41022
40862
  const problem = iceServerProblem(server);
41023
40863
  if (problem) throw new Error(problem);
@@ -41986,7 +41826,7 @@ var GhostlyNode = class GhostlyNode {
41986
41826
  this.groupWakeReceived.delete(linkId);
41987
41827
  this.quietEdges.delete(linkId);
41988
41828
  this.groupNativeWaiting.delete(linkId);
41989
- this.groupNativeFailed.delete(linkId);
41829
+ this.nativeFailed.delete(linkId);
41990
41830
  this.groupNativeRetryAt = 0;
41991
41831
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
41992
41832
  const group = live.stored.group, peer = live.stored.groupPeer;
@@ -42102,7 +41942,7 @@ var GhostlyNode = class GhostlyNode {
42102
41942
  if (this.groupEntryTimer || this.shuttingDown || this.options.deferGroups) return;
42103
41943
  this.groupEntryTimer = setInterval(() => {
42104
41944
  this.groups.tick().catch(() => {});
42105
- this.retryGroupNative();
41945
+ this.retryNative();
42106
41946
  }, 1e3);
42107
41947
  }
42108
41948
  stopGroupEntries() {
@@ -42145,6 +41985,7 @@ var GhostlyNode = class GhostlyNode {
42145
41985
  usdtPaymentsSupport: !entry,
42146
41986
  barkPaymentsSupport: !entry,
42147
41987
  largeFilesSupport: !entry,
41988
+ binaryFilesSupport: !entry,
42148
41989
  params: stored,
42149
41990
  rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
42150
41991
  resume: !entry && stored.edgeLive ? resumeOn : void 0,
@@ -42250,10 +42091,11 @@ var GhostlyNode = class GhostlyNode {
42250
42091
  role,
42251
42092
  packetAt: presence.lastPacketAt
42252
42093
  });
42094
+ const same = samePresence(live.presence, presence);
42253
42095
  live.presence = presence;
42254
42096
  if (!entry && presence.nick && !live.link?.isDataLinkOpen) this.groups.edgeNick(group, peer, presence.nick);
42255
42097
  if (!entry) this.edgeRtcPresence(linkId, presence);
42256
- this.emitState();
42098
+ if (!same) this.emitState();
42257
42099
  },
42258
42100
  onPairingState: (state) => {
42259
42101
  live.pairing = state;
@@ -42340,7 +42182,7 @@ var GhostlyNode = class GhostlyNode {
42340
42182
  this.edgeRtcOff.delete(linkId);
42341
42183
  if (live) traceLink(live.myPubKeyZ32, "edge-rtc-back", {});
42342
42184
  this.groupNativeWaiting.delete(linkId);
42343
- this.groupNativeFailed.delete(linkId);
42185
+ this.nativeFailed.delete(linkId);
42344
42186
  this.groupNativeRetryAt = 0;
42345
42187
  this.restartEdge(linkId);
42346
42188
  }
@@ -42381,6 +42223,7 @@ var GhostlyNode = class GhostlyNode {
42381
42223
  callsSupport: this.options.callsSupport ?? typeof RTCPeerConnection !== "undefined",
42382
42224
  callsMissing: this.options.callsUnavailable,
42383
42225
  largeFilesSupport: true,
42226
+ binaryFilesSupport: true,
42384
42227
  typingSupport: true,
42385
42228
  reactionsSupport: this.options.reactions !== false,
42386
42229
  editSupport: !GhostlyNode.testNoEdit(),
@@ -42532,11 +42375,7 @@ var GhostlyNode = class GhostlyNode {
42532
42375
  this.emitState();
42533
42376
  },
42534
42377
  onLiveAttempt: () => this.emitState(),
42535
- onRtt: (ms) => {
42536
- const log = this.transportLogOf(live);
42537
- if (log?.rtt(ms)) this.saveTransportLog(live, log);
42538
- else this.emitState();
42539
- },
42378
+ onRtt: (ms) => this.noteRtt(live, ms),
42540
42379
  onDiscoveryError: (error) => {
42541
42380
  if ((error ?? void 0) !== live.discoveryError) {
42542
42381
  live.discoveryError = error ?? void 0;
@@ -42658,6 +42497,7 @@ var GhostlyNode = class GhostlyNode {
42658
42497
  this.emitState();
42659
42498
  },
42660
42499
  onPresence: (presence) => {
42500
+ let same = samePresence(live.presence, presence);
42661
42501
  live.presence = presence;
42662
42502
  if (!stored.profile && presence.nick && presence.nick !== live.stored.peerNick) {
42663
42503
  live.stored = {
@@ -42665,8 +42505,9 @@ var GhostlyNode = class GhostlyNode {
42665
42505
  peerNick: presence.nick
42666
42506
  };
42667
42507
  db.patchLink(linkId, { peerNick: presence.nick });
42508
+ same = false;
42668
42509
  }
42669
- this.emitState();
42510
+ if (!same) this.emitState();
42670
42511
  },
42671
42512
  onMessage: (message) => {
42672
42513
  if (live.stored.inviteCode) {
@@ -42737,6 +42578,7 @@ var GhostlyNode = class GhostlyNode {
42737
42578
  }
42738
42579
  });
42739
42580
  const link = live.link;
42581
+ if (linkId === this.activeLinkId) link.setChatActive(true);
42740
42582
  link.start();
42741
42583
  this.emitState();
42742
42584
  if (resumed.resume) {
@@ -42861,7 +42703,7 @@ var GhostlyNode = class GhostlyNode {
42861
42703
  get nativeFactories() {
42862
42704
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
42863
42705
  return {
42864
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-C1p0nkle.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
42706
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-CPYwSLLS.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
42865
42707
  ...this.options.nativeTransports,
42866
42708
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
42867
42709
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -42962,17 +42804,18 @@ var GhostlyNode = class GhostlyNode {
42962
42804
  return Promise.all(starts).then((full) => {
42963
42805
  const live = this.links.get(linkId), link = live?.link;
42964
42806
  if (this.shuttingDown || !live || !link || link !== expected) return;
42965
- if (live.stored.group && this.keepsGroupNative(live.stored)) {
42807
+ const group = !!live.stored.group;
42808
+ if (group && this.keepsGroupNative(live.stored)) {
42966
42809
  if (full.includes("no-slot") && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
42967
42810
  else this.groupNativeWaiting.delete(linkId);
42968
- if (full.includes("failed")) {
42969
- const wait = Math.min(Math.max(GROUP_NATIVE_RETRY_MS, (this.groupNativeFailed.get(linkId)?.wait ?? 0) * 2), GROUP_NATIVE_FAILED_MAX_MS);
42970
- this.groupNativeFailed.set(linkId, {
42971
- at: Date.now() + wait,
42972
- wait
42973
- });
42974
- } else this.groupNativeFailed.delete(linkId);
42975
- } else this.groupNativeFailed.delete(linkId);
42811
+ }
42812
+ if ((group ? this.keepsGroupNative(live.stored) : this.keepsNativeEndpoints(linkId, live.stored)) && full.includes("failed")) {
42813
+ const wait = Math.min(Math.max(NATIVE_RETRY_MS, (this.nativeFailed.get(linkId)?.wait ?? 0) * 2), NATIVE_FAILED_MAX_MS);
42814
+ this.nativeFailed.set(linkId, {
42815
+ at: Date.now() + wait,
42816
+ wait
42817
+ });
42818
+ } else this.nativeFailed.delete(linkId);
42976
42819
  this.emitState();
42977
42820
  });
42978
42821
  }
@@ -43130,25 +42973,25 @@ var GhostlyNode = class GhostlyNode {
43130
42973
  /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
43131
42974
  groupNativeWaiting = /* @__PURE__ */ new Set();
43132
42975
  groupNativeRetryAt = 0;
43133
- /** Group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
43134
- groupNativeFailed = /* @__PURE__ */ new Map();
42976
+ /** Chats and group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
42977
+ nativeFailed = /* @__PURE__ */ new Map();
43135
42978
  /**
43136
42979
  * Now and then, the group links that found no free native slot try again: a chat or another group may have let one go.
43137
- * Those whose listener failed to start try again once their wait is over.
42980
+ * Chats and group links whose listener failed to start try again once their wait is over.
43138
42981
  */
43139
- retryGroupNative() {
42982
+ retryNative() {
43140
42983
  const now = Date.now();
43141
- for (const [linkId, failed] of this.groupNativeFailed) {
42984
+ for (const [linkId, failed] of this.nativeFailed) {
43142
42985
  if (now < failed.at) continue;
43143
42986
  if (!this.links.get(linkId)?.link) {
43144
- this.groupNativeFailed.delete(linkId);
42987
+ this.nativeFailed.delete(linkId);
43145
42988
  continue;
43146
42989
  }
43147
42990
  failed.at = now + failed.wait;
43148
42991
  this.ensureNativeEndpoints(linkId);
43149
42992
  }
43150
42993
  if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
43151
- this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
42994
+ this.groupNativeRetryAt = Date.now() + NATIVE_RETRY_MS;
43152
42995
  for (const linkId of [...this.groupNativeWaiting]) {
43153
42996
  if (!this.links.get(linkId)?.link) {
43154
42997
  this.groupNativeWaiting.delete(linkId);
@@ -43253,6 +43096,24 @@ var GhostlyNode = class GhostlyNode {
43253
43096
  } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
43254
43097
  return message;
43255
43098
  }
43099
+ /**
43100
+ * These rows, in this order, placed again below everything in their history, as if they came now: a group member's
43101
+ * messages that came above an older one of theirs (WISP 400, requirement 10), and the lines that came after them. The
43102
+ * places are taken before anything is awaited, so a row that comes meanwhile goes below them. Each still shows the
43103
+ * time it showed. All of them in one write: one disk sync, not one a row.
43104
+ */
43105
+ async placeAgain(linkId, ids) {
43106
+ const places = new Map(ids.map((id) => {
43107
+ const timestamp = arrivalKey(Date.now(), this.placedAt.get(linkId));
43108
+ this.placedAt.set(linkId, timestamp);
43109
+ return [id, timestamp];
43110
+ }));
43111
+ const moved = await db.patchMessages(linkId, [...places.keys()], (m) => ({
43112
+ timestamp: places.get(m.id),
43113
+ sentAt: shownTime(m)
43114
+ }));
43115
+ if (moved.length) await this.messagesChanged(linkId, moved.map((m) => m.id));
43116
+ }
43256
43117
  async storeMessage(message) {
43257
43118
  await this.storeNewMessage(message);
43258
43119
  }
@@ -43372,7 +43233,7 @@ var GhostlyNode = class GhostlyNode {
43372
43233
  transportWait: live.link?.transportWait,
43373
43234
  liveAttempt: live.link?.liveAttempt,
43374
43235
  liveDialer: live.stored.profile && !live.stored.group ? live.link?.dialer : void 0,
43375
- transportRttMs: live.link?.rttMs,
43236
+ transportRttMs: live.rttPosted = live.link?.rttMs,
43376
43237
  transportRelayed: live.link?.relayedPath,
43377
43238
  transportLive: live.transportLog?.liveNow(),
43378
43239
  transportLog: stored.id === this.activeLinkId && stored.profile && !stored.group ? stored.transportLog ?? [] : void 0,
@@ -43430,6 +43291,7 @@ var GhostlyNode = class GhostlyNode {
43430
43291
  this.stateTimer = null;
43431
43292
  if (this.gatedOut) return;
43432
43293
  this.events.onState(this.getState());
43294
+ this.dropBriefViews();
43433
43295
  if (this.deviceLinks) this.syncDeviceTokens();
43434
43296
  }, delayMs);
43435
43297
  }
@@ -43441,11 +43303,16 @@ var GhostlyNode = class GhostlyNode {
43441
43303
  }
43442
43304
  if (this.gatedOut) return;
43443
43305
  this.events.onState(this.getState());
43306
+ this.dropBriefViews();
43307
+ }
43308
+ /** A state went with the brief views: they go, so the next states do not carry them (`briefViews`). */
43309
+ dropBriefViews() {
43310
+ for (const [id, view] of this.briefViews) if (this.transfers.get(id) === view) this.transfers.delete(id);
43311
+ this.briefViews.clear();
43444
43312
  }
43445
43313
  };
43446
43314
  //#endregion
43447
43315
  //#region ../browser/src/engine/server.ts
43448
- var server_exports = /* @__PURE__ */ __exportAll({ EngineServer: () => EngineServer });
43449
43316
  var EngineServer = class {
43450
43317
  /** The whole engine: never made on a device that is not the active one (`devices/peer.ts` decides, WISP 06 § The gate). */
43451
43318
  gated = false;
@@ -43517,6 +43384,10 @@ var EngineServer = class {
43517
43384
  get historyMark() {
43518
43385
  return this.changes;
43519
43386
  }
43387
+ /** Whether a chat's or group's history changed after `mark` (`historyMark`); with none, since this engine was made. */
43388
+ historyChanged(id, mark = 0) {
43389
+ return (this.changedAt.get(id) ?? 0) > mark;
43390
+ }
43520
43391
  /**
43521
43392
  * `read`: the chats and groups whose whole history the client read itself, after `mark` (`historyMark`). One that has
43522
43393
  * not changed since is not read and sent again: the client hears only what changes in it from now on.
@@ -43679,6 +43550,6 @@ function signalKind(signal) {
43679
43550
  }
43680
43551
  }
43681
43552
  //#endregion
43682
- export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
43553
+ export { EngineServer };
43683
43554
 
43684
- //# sourceMappingURL=server-0zfNpABQ.js.map
43555
+ //# sourceMappingURL=server-Ss0gJ-nw.js.map