@ghostlytools/cli 1.1.7 → 1.2.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 (35) hide show
  1. package/README.md +27 -19
  2. package/SKILL.md +15 -7
  3. package/dist/assets/arkade-CtCpFaF7.js +362 -0
  4. package/dist/assets/arkade-CtCpFaF7.js.map +1 -0
  5. package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
  6. package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
  7. package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
  8. package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
  9. package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
  10. package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
  11. package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
  12. package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
  13. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
  14. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
  15. package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-C0XRWySP.js} +3 -3
  16. package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-C0XRWySP.js.map} +1 -1
  17. package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
  18. package/dist/assets/idb-CTF6LF5B.js.map +1 -0
  19. package/dist/assets/{mainline-B7wHcLaW.js → mainline-DVYdrus6.js} +2 -2
  20. package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-DVYdrus6.js.map} +1 -1
  21. package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
  22. package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
  23. package/dist/assets/{server-GJW_5wGI.js → server-D5FBZYW8.js} +1514 -1088
  24. package/dist/assets/server-D5FBZYW8.js.map +1 -0
  25. package/dist/assets/{src-Cp3o_yHN.js → src-aKGjb_yz.js} +855 -262
  26. package/dist/assets/src-aKGjb_yz.js.map +1 -0
  27. package/dist/assets/usdt-CUfucwZf.js +410 -0
  28. package/dist/assets/usdt-CUfucwZf.js.map +1 -0
  29. package/dist/ghostly.mjs +1326 -262
  30. package/dist/ghostly.mjs.map +1 -1
  31. package/package.json +4 -4
  32. package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
  33. package/dist/assets/idb-BIdus0WX.js.map +0 -1
  34. package/dist/assets/server-GJW_5wGI.js.map +0 -1
  35. package/dist/assets/src-Cp3o_yHN.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
- import { $i as formatFileSize, $n as GROUP_FILE_LIMITS, $o as handoffEphemeral, $r as WAKE_CALL_INTERVAL_MS, $s as deviceKeyZ32, $t as SEPOLIA_TEST_USDT_FAUCET, A as entryParams, Aa as replySnippet, Ac as createRelayPayload, Ai as IdentityExchange, Ao as LEGACY_SERVICES, Ar as newerHead, As as enrollCancelReason, Ba as claimedTime, Bc as sanitizeAvatar, Bi as emptyProofLedger, Bn as engineText, Bo as HANDOFF_REQUEST, Br as transferStalled, Bs as deviceInviteJoinerParams, C as GROUP_READ_NOTE_COMMUNITY, Ca as reactionEmoji, Ci as DHT_TEXT_CAPABILITY, Cr as beaconRecords, Cs as signTombstone, Da as wireReaction, Dc as withRequestOptions, Dr as lobbyRecords, E as encodeCommunityLink, Ea as validReactionNumber, Ec as isDiscoveryBudgetError, Eo as parseCallSignal, Er as lobbyKeys, Fc as identityFromSeed, Fi as newIdentityBinding, Fo as HANDOFF_HAVE, Fr as readLobby, Fs as enrollProofFrame, G as GROUP_EDIT_TEXT_BYTES, Gc as toZ32, Gn as findEndpoint, Go as handoffAttemptFailed, Gr as typingActivity, Gs as encodeInviteCode, Gt as encodeDidDhtPacket, H as GroupSession, Ha as receivedTimestamp, Hc as fromBase64Url, Hi as TRANSPORTS, Hn as ENDPOINT, Ho as HANDOFF_VERIFIED, Hs as OwnInviteError, Ht as didDhtDocument, I as GroupWakeLimiter, Ia as checkStatusCard, Ic as identityFromSeedB64, Ii as receivedIdentityStatus, Io as HANDOFF_HELLO, Ir as shouldBeHub, Is as readEnrollGrant, J as mentionsMember, Ji as peekDhtMailbox, Jn as parseFedimintRequestPayload, Jo as handoffCancelFrame, Jr as mayPin, Js as DEVICES_CAPABILITY, Jt as ETHEREUM_USDT, K as carryMentions, Ka as appFingerprint, Kc as utf8Decode, Kn as isFederationId, Ko as handoffAttemptSucceeded, L as groupWakeFrame, La as sameButtonText, Lc as publicKeyFromZ32, Li as revokesIdentity, Lo as HANDOFF_OFFER, Lr as ChatFiles, Ls as readEnrollHello, M as knockRecords, Mi as emptyIdentityLedger, Mo as HANDOFF_ACK, Mr as rankHubs, Ms as enrollDoneFrame, N as mergeKnocks, Ni as identityRevocationValue, No as HANDOFF_CANCEL, Nr as readBeacon, Ns as enrollGrantFrame, O as decodeGroupEntryLink, Oa as pairedReplyAuthor, Oc as PacketTooLargeError, Oi as IDENTITY_REVOCATION_LABEL, Oo as emptyLinkRecords, Or as mergeBeacon, P as readKnocks, Pa as STATUS_CARD_LIMITS, Pc as createIdentity, Pi as identityStatement, Po as HANDOFF_DONE, Pr as readBeaconHead, Ps as enrollHelloFrame, Qn as pairedMessageFrame, Qo as handoffDigest, Qr as readPin, Qs as deviceEchoNonce, Qt as SEPOLIA_TEST_USDT, R as groupWakes, Ra as statusCardText, Rc as verify, Ri as lastSharedWithMe, Ro as HANDOFF_PAKE, Rr as FILE_LIMITS, Rs as verifyEnrollProof, S as CommunitySession, Sa as queueReaction, Sc as normalizeRelayUrl, Si as CapsExchange, Sr as beaconKeys, Ss as setUpdateFrame, T as decodeCommunityLink, Ta as readReaction, Tc as budgetRetryMs, Ti as dialDescriptors, Tr as freshHubs, Ua as shownTime, Uc as randomBytes, Ui as automaticTransport, Un as cashuRequestPayload, Uo as filePartName, Ur as TypingSender, Us as createChatInvite, Ut as didDhtFromPublicKey, V as GROUP_READ_NOTE, Va as heardTime, Vc as bytesEqual, Vi as validExternalKey, Vn as decodeBolt11, Vo as HANDOFF_TIMINGS, Vs as readDeviceInvite, Wc as toBase64Url, Wn as fedimintRequestPayload, Wo as handoffAttemptAllowed, Ws as decodeInviteCode, Wt as encodeDidDhtDeactivated, Xa as isAppRef, Xi as dhtFileId, Xn as INVITE_TAKEN, Xo as handoffContextProof, Xr as pinIsNewer, Xs as HANDOFF_CAPABILITY, Xt as ONCHAIN_PROVIDER, Y as groupName, Ya as isAppKey, Yi as DHT_FILE_CAPABILITY, Yn as GhostLink, Yo as handoffContext, Yr as nextPinNumber, Ys as ENROLL_CAPABILITY, Yt as EVM_TEST_CHAINS, Za as readAppRevocations, Zi as parseImageMeta, Zn as LIVENESS_PING_MS, Zo as handoffDataFrame, Zr as pinNumberHolds, Zs as deviceEchoFrame, Zt as PaymentPreflightError, _ as GroupTypingBoard, _a as COMMUNITY_REACTION_FRAME, _c as turnKeys, _i as HOLD_LIMITS, _o as decodeControl, _r as MESH_HUBS, _s as sealHandoffPiece, ai as wakeRequest, an as formatPaymentAmount, ao as rosterHas, as as handoffRetryAfter, b as sealGroupTyping, ba as ReactionWindow, bc as RelayTransport, bi as newMailbox, bo as RTC_CONFIG, br as pickMeshHubs, bs as readSetUpdate, c as APP_BUNDLE_LIMITS, cc as classifyTurnRead, ci as chatAppId, cn as SPARK_NETWORKS, co as DirectPathWatch, cs as newHandoffId, d as appViewOf, da as EDIT_RATE_WINDOW_MS, dc as TURN_MAX, di as BUTTONS_CAPABILITY, dn as sparkInvoiceDetails, do as presenceSeenAt, ds as readHandoffDone, ea as safeBlobType, ec as deviceLinkPairing, ei as WakeLimiter, en as TEST_USDT_FAUCET_AMOUNT, eo as canonicalJson, es as handoffHelloFrame, fa as RateWindow, fc as firstDeviceSetSecret, fi as EDIT_CAPABILITY, fn as isBitcoinAddress, fo as GhostlyHttpError, fs as readHandoffHave, ga as readForwarded, gc as signTurnRelease, gi as HOLD_DATED_BACK_MS, go as LIMITS, gr as readGroupSignal, gs as readHandoffVerified, gt as normalizeAtprotoHandle, h as readAppBundle, ha as forwardedAgain, hc as signTurnPacket, hi as HOLD_CAPABILITY, ho as webLocalFetch, hr as groupSignalFrame, hs as readHandoffTurnFrame, ht as isAtprotoDid, i as planAppUpdate, ic as webCryptoSigner, ii as relayRequest, in as assertWholeSats, io as MEMBER_KEY, ir as groupFileFallback, is as handoffReleaseFrame, j as knockIdentity, ja as BUTTON_ID, jo as serviceIdFromName, jr as pickHubs, js as enrollDigits, k as encodeGroupEntryLink, ka as readReply, ko as HTTP_SERVICE_PROTO, kr as mergeLobby, ks as enrollCancelFrame, l as APP_BUNDLE_MAGIC, la as parseVideoMeta, lc as turnPutSummary, li as isAppVersion, ln as SPARK_PROVIDER, ls as readHandoffAck, m as isAppUrl, ma as parseLinkPreview, mc as readTurnPacket, mi as UPGRADE_CAPABILITY, mn as isPubkyKey, mo as parseLocalTarget, ms as readHandoffPake, n as appUpdateDecision, nc as newDeviceSetSecret, ni as newWakeToken, nn as WALLET_NETWORKS, no as GROUP_MEMBER_CAP, ns as handoffOfferFrame, o as readAppStore, oa as fileMessageText, oc as TURN_RAISE_READ_TIMEOUT_MS, oi as checkPushEndpoint, on as validatePaymentTarget, oo as verifyCommitSignature, or as readGroupFileMeta, os as handoffStreamKey, pa as dhtEditId, pc as nextTurnPosition, pi as STATUS_CARD_CAPABILITY, po as formatLocalTarget, ps as readHandoffHello, q as parseCommunityEdit, qa as appRef, qc as utf8Encode, qi as DHT_MESSAGE_TTL, qn as parseCashuRequestPayload, qo as handoffBusyFrame, qr as PIN_LIMITS, qs as inviteOwnership, qt as signDidDhtPacket, r as checkAppBeforeRun, rc as seedSigner, ri as parseWakeFrame, rn as assertTokenUnits, rs as handoffPakeFrame, sa as parseVoiceMeta, sc as TURN_SETTLE_MS, si as vapidKeysMatch, sn as walletNetworkOf, so as edgeParams, ss as handoffVersions, t as appStoreDecision, tc as devicePingFrame, ti as deviceWakeRequest, tn as USDT_PUBLIC_RPC, ts as handoffManifestFrames, u as appDigest, ua as EDIT_BUFFER_MS, uc as TURN_LAST_SEQUENCE, ui as APPS_CAPABILITY, un as sparkAddressKind, uo as RELAY_POLL_INTERVALS, ur as CarriedTransport, us as readHandoffCancel, v as groupTypingEpoch, va as GROUP_REACTION_FRAME, vc as turnName, vi as HoldKeys, vo as encodeControl, vr as mayBeHub, w as communityHasFrame, wa as reactionIsNewer, wc as traceLink, wi as capsDescriptors, wr as doorHubs, ws as verifySetUpdate, x as COMMUNITY_LIMITS, xa as nextReactionNumber, xc as currentRelays, xi as readManifest, xr as COMMUNITY_TOPOLOGY, xs as setAckFrame, y as openGroupTyping, ya as REACTION_LIMITS, yc as DEFAULT_RELAYS, yi as HoldRefusedError, yr as meshHubs, z as parseGroupWakeFrame, za as arrivalKey, zi as PeerProofs, zn as engineError, zo as HANDOFF_PIECE_BYTES, zr as transferEnded, zs as createDeviceInvite } from "./src-Cp3o_yHN.js";
3
- import { a as clearProfileStores, c as fileStore, d as store, f as transact, i as STORES$1, l as openDb, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as wrap, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as profileOpenFailure } from "./idb-BIdus0WX.js";
4
- import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-3AQ2tJzW.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, h as rpcHost, 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, m as networkReason, 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";
2
+ import { $a as appRef, $c as randomBytes, $i as parseImageMeta, $n as engineError, $o as filePartName, $r as pinNumberHolds, $s as createChatInvite, $t as PaymentPreflightError, A as decodeGroupEntryLink, Aa as pairedReplyAuthor, Ac as RelayTransport, Ai as IDENTITY_REVOCATION_LABEL, Ao as RTC_CONFIG, Ar as mergeBeacon, As as readSetUpdate, B as groupWakes, Bc as createRelayPayload, Bi as lastSharedWithMe, Bo as LEGACY_SERVICES, Br as FILE_LIMITS, Bs as enrollCancelReason, C as COMMUNITY_LIMITS, Ca as nextReactionNumber, Cc as nextTurnPosition, Ci as readManifest, Co as formatLocalTarget, Cr as COMMUNITY_TOPOLOGY, Cs as readHandoffHello, D as decodeCommunityLink, Da as readReaction, Dc as turnKeys, Di as dialDescriptors, Do as decodeControl, Dr as freshHubs, Ds as sealHandoffPiece, E as communityHasFrame, Ea as reactionIsNewer, Ec as signTurnRelease, Ei as capsDescriptors, Eo as LIMITS, Er as doorHubs, Es as readHandoffVerified, F as mergeKnocks, Fc as budgetRetryMs, Fi as identityRevocationValue, Fr as readBeacon, Ga as arrivalKey, Gc as identityFromSeed, Gi as automaticTransport, Gn as findEndpoint, Go as HANDOFF_HAVE, Gr as TypingSender, Gs as enrollProofFrame, Gt as didDhtFromPublicKey, Ha as checkStatusCard, Hi as emptyProofLedger, Hn as ENDPOINT, Ho as HANDOFF_ACK, Hr as transferStalled, Hs as enrollDoneFrame, I as readKnocks, Ic as isDiscoveryBudgetError, Ii as identityStatement, Io as parseCallSignal, Ir as readBeaconHead, J as carryMentions, Ja as receivedTimestamp, Jc as verify, Jn as parseFedimintRequestPayload, Jo as HANDOFF_PAKE, Js as verifyEnrollProof, Ka as claimedTime, Kc as identityFromSeedB64, Kn as isFederationId, Ko as HANDOFF_HELLO, Ks as readEnrollGrant, Kt as encodeDidDhtDeactivated, Lc as withRequestOptions, Li as newIdentityBinding, Lr as readLobby, M as entryParams, Ma as replySnippet, Mc as normalizeRelayUrl, Mi as IdentityExchange, Mr as newerHead, Ms as setUpdateFrame, N as knockIdentity, Na as BUTTON_ID, Nr as pickHubs, Ns as signTombstone, O as encodeCommunityLink, Oa as validReactionNumber, Oc as turnName, Oo as encodeControl, Or as lobbyKeys, P as knockRecords, Pc as traceLink, Pi as emptyIdentityLedger, Pr as rankHubs, Ps as verifySetUpdate, Qa as appFingerprint, Qc as fromBase64Url, Qi as dhtFileId, Qn as pairedMessageFrame, Qo as HANDOFF_VERIFIED, Qr as pinIsNewer, Qs as OwnInviteError, Qt as ONCHAIN_PROVIDER, R as GroupWakeLimiter, Ra as STATUS_CARD_LIMITS, Rc as PacketTooLargeError, Ri as receivedIdentityStatus, Ro as emptyLinkRecords, Rr as shouldBeHub, S as sealGroupTyping, Sa as ReactionWindow, Sc as firstDeviceSetSecret, Si as newMailbox, So as GhostlyHttpError, Sr as pickMeshHubs, Ss as readHandoffHave, T as GROUP_READ_NOTE_COMMUNITY, Ta as reactionEmoji, Tc as signTurnPacket, Ti as DHT_TEXT_CAPABILITY, To as webLocalFetch, Tr as beaconRecords, Ts as readHandoffTurnFrame, U as GROUP_READ_NOTE, Ua as sameButtonText, Ui as validExternalKey, Un as cashuRequestPayload, Uo as HANDOFF_CANCEL, Us as enrollGrantFrame, V as parseGroupWakeFrame, Vi as PeerProofs, Vn as decodeBolt11, Vo as serviceIdFromName, Vr as transferEnded, Vs as enrollDigits, W as GroupSession, Wa as statusCardText, Wc as createIdentity, Wi as TRANSPORTS, Wn as fedimintRequestPayload, Wo as HANDOFF_DONE, Ws as enrollHelloFrame, Wt as didDhtDocument, X as mentionsMember, Xc as sanitizeAvatar, Xi as peekDhtMailbox, Xn as INVITE_TAKEN, Xo as HANDOFF_REQUEST, Xr as mayPin, Xs as deviceInviteJoinerParams, Xt as ETHEREUM_USDT, Y as parseCommunityEdit, Ya as shownTime, Yi as DHT_MESSAGE_TTL, Yn as GhostLink, Yo as HANDOFF_PIECE_BYTES, Yr as PIN_LIMITS, Ys as createDeviceInvite, Yt as signDidDhtPacket, Z as groupName, Za as APP_STATEMENT_LIMITS, Zc as bytesEqual, Zi as DHT_FILE_CAPABILITY, Zn as LIVENESS_PING_MS, Zo as HANDOFF_TIMINGS, Zr as nextPinNumber, Zs as readDeviceInvite, Zt as EVM_TEST_CHAINS, _ as readAppBundle, _a as forwardedAgain, _c as TURN_SETTLE_MS, _i as HOLD_CAPABILITY, _o as DirectPathWatch, _r as groupSignalFrame, _s as handoffVersions, _t as isAtprotoDid, a as planAppUpdate, ac as ENROLL_CAPABILITY, ai as parseWakeFrame, an as assertTokenUnits, as as handoffContext, b as groupTypingEpoch, ba as GROUP_REACTION_FRAME, bc as TURN_LAST_SEQUENCE, bi as HoldKeys, bo as RELAY_POLL_INTERVALS, br as mayBeHub, bs as readHandoffCancel, ca as fileMessageText, cc as deviceEchoNonce, ci as checkPushEndpoint, cn as validatePaymentTarget, cr as readGroupFileMeta, cs as handoffDigest, da as parseVideoMeta, dc as devicePingFrame, di as isAppVersion, dn as SPARK_PROVIDER, ds as handoffManifestFrames, ec as decodeInviteCode, ei as readPin, el as toBase64Url, en as SEPOLIA_TEST_USDT, er as engineText, es as handoffAttemptAllowed, f as appDigest, fa as EDIT_BUFFER_MS, fc as newDeviceSetSecret, fi as APPS_CAPABILITY, fn as sparkAddressKind, fo as MEMBER_KEY, fr as CarriedTransport, fs as handoffOfferFrame, g as isAppUrl, ga as parseLinkPreview, gc as TURN_RAISE_READ_TIMEOUT_MS, gi as UPGRADE_CAPABILITY, gn as isPubkyKey, go as edgeParams, gs as handoffStreamKey, ha as dhtEditId, hi as STATUS_CARD_CAPABILITY, ho as verifyCommitSignature, hs as handoffRetryAfter, i as checkAppBeforeRun, ia as sanitizeMime, ic as DEVICES_CAPABILITY, ii as newWakeToken, in as WALLET_NETWORKS, is as handoffCancelFrame, j as encodeGroupEntryLink, ja as readReply, jc as currentRelays, jr as mergeLobby, js as setAckFrame, ka as wireReaction, kc as DEFAULT_RELAYS, kr as lobbyRecords, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, lc as deviceKeyZ32, li as vapidKeysMatch, ln as walletNetworkOf, lo as readCanonicalJson, ls as handoffEphemeral, ma as RateWindow, mc as webCryptoSigner, mi as EDIT_CAPABILITY, mn as isBitcoinAddress, mo as rosterHas, ms as handoffReleaseFrame, n as appStoreDecision, na as safeBlobType, ni as WakeLimiter, nl as utf8Decode, nn as TEST_USDT_FAUCET_AMOUNT, no as isAppRef, ns as handoffAttemptSucceeded, oc as HANDOFF_CAPABILITY, oi as relayRequest, on as assertWholeSats, or as groupFileFallback, os as handoffContextProof, p as appViewOf, pa as EDIT_RATE_WINDOW_MS, pc as seedSigner, pi as BUTTONS_CAPABILITY, pn as sparkInvoiceDetails, po as commitHash, ps as handoffPakeFrame, q as GROUP_EDIT_TEXT_BYTES, qa as heardTime, qc as publicKeyFromZ32, qn as parseCashuRequestPayload, qo as HANDOFF_OFFER, qr as typingActivity, qs as readEnrollHello, qt as encodeDidDhtPacket, r as appUpdateDecision, ra as sanitizeFileName, rc as inviteOwnership, ri as deviceWakeRequest, rl as utf8Encode, rn as USDT_PUBLIC_RPC, ro as readAppRevocation, rs as handoffBusyFrame, s as readAppStore, sc as deviceEchoFrame, si as wakeRequest, sn as formatPaymentAmount, so as canonicalJson, ss as handoffDataFrame, t as appRevocationFor, ta as formatFileSize, tc as encodeInviteCode, ti as WAKE_CALL_INTERVAL_MS, tl as toZ32, to as isAppKey, tr as GROUP_FILE_LIMITS, ts as handoffAttemptFailed, u as APP_BUNDLE_MAGIC, uc as deviceLinkPairing, ui as chatAppId, un as SPARK_NETWORKS, uo as GROUP_MEMBER_CAP, us as handoffHelloFrame, va as readForwarded, vc as classifyTurnRead, vi as HOLD_DATED_BACK_MS, vo as DHT_POLL_INTERVALS, vr as readGroupSignal, vs as newHandoffId, vt as normalizeAtprotoHandle, w as CommunitySession, wa as queueReaction, wc as readTurnPacket, wi as CapsExchange, wo as parseLocalTarget, wr as beaconKeys, ws as readHandoffPake, x as openGroupTyping, xa as REACTION_LIMITS, xc as TURN_MAX, xi as HoldRefusedError, xo as presenceSeenAt, xr as meshHubs, xs as readHandoffDone, y as GroupTypingBoard, ya as COMMUNITY_REACTION_FRAME, yc as turnPutSummary, yi as HOLD_LIMITS, yr as MESH_HUBS, ys as readHandoffAck, z as groupWakeFrame, zi as revokesIdentity, zo as HTTP_SERVICE_PROTO, zr as ChatFiles, zs as enrollCancelFrame } from "./src-aKGjb_yz.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
6
  import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
7
7
  import { hkdf } from "@noble/hashes/hkdf.js";
8
8
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -14,12 +14,8 @@ import { wordlist } from "@scure/bip39/wordlists/english.js";
14
14
  import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
15
15
  import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
16
16
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
17
- import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
18
- import { Interface, Transaction, getAddress, keccak256 } from "ethers";
19
- import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
20
- import { tapLeafHash } from "@scure/btc-signer/payment.js";
21
- import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
22
- import { Transaction as Transaction$2 } from "@scure/btc-signer";
17
+ import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
18
+ import { Address, OutScript, Transaction } from "@scure/btc-signer";
23
19
  import * as nip04 from "nostr-tools/nip04";
24
20
  import * as nip44 from "nostr-tools/nip44";
25
21
  /** Evidence older than this says nothing about the clock now. */
@@ -182,409 +178,9 @@ async function migrateWalletNetworks(now = Date.now()) {
182
178
  return report;
183
179
  }
184
180
  //#endregion
185
- //#region ../browser/src/engine/paymentAdapters/usdt.ts
186
- var faucet = new Interface(["function mint(address token,address to,uint256 amount) returns (uint256)"]);
187
- var erc20 = new Interface([
188
- "function decimals() view returns(uint8)",
189
- "function balanceOf(address) view returns(uint256)",
190
- "function transfer(address,uint256) returns(bool)",
191
- "event Transfer(address indexed from,address indexed to,uint256 value)"
192
- ]);
193
- var same$1 = (a, b) => a.toLowerCase() === b.toLowerCase();
194
- var CONFIRMATIONS = 2;
195
- /** The JSON-RPC methods that only read: asked again once when they fail (see `UsdtAdapter.rpc`). */
196
- var RETRIED_READS = /* @__PURE__ */ new Set([
197
- "eth_chainId",
198
- "eth_getCode",
199
- "eth_call",
200
- "eth_getBalance",
201
- "eth_blockNumber",
202
- "eth_getLogs",
203
- "eth_getTransactionCount",
204
- "eth_estimateGas",
205
- "eth_getBlockByNumber",
206
- "eth_maxPriorityFeePerGas",
207
- "eth_gasPrice",
208
- "eth_feeHistory",
209
- "eth_getTransactionByHash",
210
- "eth_getTransactionReceipt"
211
- ]);
212
- var RETRY_AFTER_MS = 1e3;
213
- /** Failures that would fail the same way again at once: not retried. */
214
- var FINAL = /* @__PURE__ */ new WeakSet();
215
- var timedOut = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
216
- /**
217
- * The `provider` a request carries to the contact. Which RPC pays is the payer's own choice, so it names none of
218
- * this wallet's: a typed RPC URL may hold an API key (`/v3/<key>`), and every contact would read it. Older apps
219
- * require a URL here and pay only when it equals their own RPC, so it is the chain's public RPC (the default both
220
- * start with), and a local test chain's origin, without its path.
221
- */
222
- function wireProvider(config) {
223
- return config.network === "evm-local" ? new URL(config.provider).origin : USDT_PUBLIC_RPC[config.network];
224
- }
225
- /** WDK signs locally. Every network call uses the configured RPC, never a peer-supplied URL. */
226
- var UsdtAdapter = class UsdtAdapter {
227
- config;
228
- method = "usdt";
229
- manager;
230
- account;
231
- mnemonic = "";
232
- nextId = 0;
233
- queue = Promise.resolve();
234
- constructor(config) {
235
- this.config = config;
236
- }
237
- static async inspect(config) {
238
- if (config.network === "ethereum" ? config.chainId !== 1 || !same$1(config.token, "0xdAC17F958D2ee523a2206206994597C13D831ec7") : !(config.network in EVM_TEST_CHAINS) || config.chainId !== EVM_TEST_CHAINS[config.network]) throw new Error("Unsupported USDT network or contract");
239
- const token = getAddress(config.token);
240
- const url = new URL(config.provider);
241
- if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && [
242
- "localhost",
243
- "127.0.0.1",
244
- "[::1]"
245
- ].includes(url.hostname))) throw new Error("Use HTTPS or a loopback test RPC");
246
- const adapter = new UsdtAdapter({
247
- ...config,
248
- token,
249
- decimals: 6,
250
- codeHash: ""
251
- });
252
- await adapter.chain();
253
- const code = await adapter.rpc("eth_getCode", [token, "latest"]);
254
- if (code === "0x") throw new Error("No token contract exists on this chain");
255
- const result = await adapter.rpc("eth_call", [{
256
- to: token,
257
- data: erc20.encodeFunctionData("decimals")
258
- }, "latest"]);
259
- const decimals = Number(erc20.decodeFunctionResult("decimals", result)[0]);
260
- if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18 || config.chainId === 1 && decimals !== 6) throw new Error("Unexpected token decimals");
261
- return {
262
- ...config,
263
- token,
264
- decimals,
265
- codeHash: keccak256(code)
266
- };
267
- }
268
- static async connect(config, mnemonic) {
269
- const inspected = await UsdtAdapter.inspect(config);
270
- if (inspected.decimals !== config.decimals || inspected.codeHash !== config.codeHash) throw new Error("Token metadata changed; do not use this wallet configuration");
271
- const adapter = new UsdtAdapter(config);
272
- adapter.mnemonic = mnemonic;
273
- const seed = mnemonicToSeedSync(mnemonic);
274
- try {
275
- adapter.manager = new WalletManagerEvm(seed, {
276
- provider: { request: ({ method, params }) => adapter.rpc(method, params ?? []) },
277
- chainId: config.chainId
278
- });
279
- } finally {
280
- seed.fill(0);
281
- }
282
- adapter.account = await adapter.manager.getAccount(0);
283
- return adapter;
284
- }
285
- /**
286
- * A read that fails is asked again, once, a moment later: a public RPC drops a request now and then, and a chain that
287
- * just made a block can refuse a read of "latest" it no longer holds. Without it a network switch, a restore or New
288
- * gave up on the first such answer. Never a broadcast or anything else that writes, nor a request that did not answer
289
- * in time (the person would wait twice as long), nor one the server refused as such (4xx).
290
- */
291
- async rpc(method, params = []) {
292
- try {
293
- return await this.ask(method, params);
294
- } catch (error) {
295
- if (!RETRIED_READS.has(method) || FINAL.has(error)) throw error;
296
- await new Promise((resolve) => setTimeout(resolve, RETRY_AFTER_MS));
297
- return this.ask(method, params);
298
- }
299
- }
300
- async ask(method, params) {
301
- let response;
302
- try {
303
- response = await fetch(this.config.provider, {
304
- method: "POST",
305
- headers: { "Content-Type": "application/json" },
306
- body: JSON.stringify({
307
- jsonrpc: "2.0",
308
- id: ++this.nextId,
309
- method,
310
- params
311
- }),
312
- signal: AbortSignal.timeout(15e3)
313
- });
314
- } catch (error) {
315
- const reason = networkReason(error, rpcHost(this.config.provider));
316
- const thrown = reason ? Object.assign(new Error(reason), { cause: error }) : error;
317
- if (timedOut(error) && thrown && typeof thrown === "object") FINAL.add(thrown);
318
- throw thrown;
319
- }
320
- if (!response.ok) {
321
- const error = engineError("usdtRpcUnavailable");
322
- if (response.status !== 429 && response.status < 500) FINAL.add(error);
323
- throw error;
324
- }
325
- const result = await response.json();
326
- if (result.error || !Object.prototype.hasOwnProperty.call(result, "result")) throw new Error("USDT RPC rejected the operation");
327
- return result.result;
328
- }
329
- async chain() {
330
- if (BigInt(await this.rpc("eth_chainId")) !== BigInt(this.config.chainId)) throw new Error("RPC chain does not match this wallet");
331
- }
332
- async assertToken() {
333
- await this.chain();
334
- const code = await this.rpc("eth_getCode", [this.config.token, "latest"]);
335
- if (code === "0x" || keccak256(code) !== this.config.codeHash) throw new Error("Token contract changed");
336
- }
337
- /**
338
- * Sepolia only: asks Aave's public faucet for test USDT, to this wallet, paid with its own test ETH.
339
- * Everything is fixed here (chain, contract, token, amount, recipient): nothing comes from outside.
340
- */
341
- mintTestTokens() {
342
- return this.serial(async () => {
343
- if (this.config.chainId !== EVM_TEST_CHAINS.sepolia || !same$1(this.config.token, "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0")) throw new Error("Test USDT comes from a faucet on Sepolia only");
344
- await this.chain();
345
- const account = this.current(), from = await account.getAddress();
346
- const data = faucet.encodeFunctionData("mint", [
347
- SEPOLIA_TEST_USDT,
348
- from,
349
- BigInt(TEST_USDT_FAUCET_AMOUNT)
350
- ]);
351
- const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
352
- const estimate = BigInt(await this.rpc("eth_estimateGas", [{
353
- from,
354
- to: SEPOLIA_TEST_USDT_FAUCET,
355
- data,
356
- value: "0x0"
357
- }]));
358
- const block = await this.rpc("eth_getBlockByNumber", ["latest", false]);
359
- if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
360
- const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
361
- const maxFeePerGas = BigInt(block.baseFeePerGas) * 2n + priority, gasLimit = (estimate * 120n + 99n) / 100n;
362
- if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw engineError("usdtNeedsSepoliaGas");
363
- const raw = await account.signTransaction({
364
- type: 2,
365
- to: SEPOLIA_TEST_USDT_FAUCET,
366
- data,
367
- value: 0n,
368
- chainId: this.config.chainId,
369
- nonce,
370
- gasLimit,
371
- maxFeePerGas,
372
- maxPriorityFeePerGas: priority
373
- });
374
- await account.sendTransaction(raw);
375
- return Transaction.from(raw).hash;
376
- });
377
- }
378
- current() {
379
- if (!this.account) throw new Error("Unlock the USDT wallet first");
380
- return this.account;
381
- }
382
- address() {
383
- return this.current().getAddress();
384
- }
385
- async balances() {
386
- await this.assertToken();
387
- const address = await this.address();
388
- const [token, gas] = await Promise.all([this.rpc("eth_call", [{
389
- to: this.config.token,
390
- data: erc20.encodeFunctionData("balanceOf", [address])
391
- }, "latest"]), this.rpc("eth_getBalance", [address, "pending"])]);
392
- return {
393
- balance: erc20.decodeFunctionResult("balanceOf", token)[0].toString(),
394
- gasBalance: BigInt(gas).toString()
395
- };
396
- }
397
- async target() {
398
- const now = Date.now();
399
- return {
400
- method: "usdt",
401
- network: this.config.network,
402
- provider: wireProvider(this.config),
403
- asset: this.config.chainId === 1 ? "USDT" : "TEST-USDT",
404
- unit: "token-base",
405
- address: await this.address(),
406
- chainId: this.config.chainId,
407
- token: this.config.token,
408
- decimals: this.config.decimals,
409
- issuedAt: now,
410
- expiresAt: now + 9e5
411
- };
412
- }
413
- /** The request is for this wallet's token on its chain. Its `provider` is not compared: this wallet's own RPC pays. */
414
- matches(target) {
415
- validatePaymentTarget(target);
416
- if (target.method !== "usdt" || target.network !== this.config.network || target.chainId !== this.config.chainId || !same$1(target.token, this.config.token) || target.decimals !== this.config.decimals) throw new Error("Request does not match the configured token and network");
417
- }
418
- prepare(target, amount, feeCap) {
419
- return this.serial(async () => {
420
- this.matches(target);
421
- await this.assertToken();
422
- const from = await this.address(), to = this.config.token;
423
- if (same$1(target.address, from) || same$1(target.address, to)) throw new Error("Choose a different recipient address");
424
- const { balance, gasBalance } = await this.balances();
425
- if (BigInt(balance) < BigInt(amount)) throw engineError("usdtNotEnoughTokens");
426
- const data = erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(amount)]);
427
- const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
428
- if (!Number.isSafeInteger(nonce)) throw new Error("Invalid account nonce");
429
- const estimate = BigInt(await this.rpc("eth_estimateGas", [{
430
- from,
431
- to,
432
- data,
433
- value: "0x0"
434
- }]));
435
- const block = await this.rpc("eth_getBlockByNumber", ["latest", false]);
436
- if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
437
- const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
438
- const maxFee = BigInt(block.baseFeePerGas) * 2n + priority;
439
- const gasLimit = (estimate * 120n + 99n) / 100n;
440
- const maximum = gasLimit * maxFee;
441
- if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw engineError("usdtGasAboveLimit");
442
- if (BigInt(gasBalance) < maximum) throw engineError("usdtNotEnoughGas");
443
- const prepared = {
444
- from,
445
- to,
446
- data,
447
- value: "0",
448
- chainId: this.config.chainId,
449
- nonce,
450
- gasLimit: gasLimit.toString(),
451
- maxFeePerGas: maxFee.toString(),
452
- maxPriorityFeePerGas: priority.toString()
453
- };
454
- return {
455
- fee: Number(maximum),
456
- prepared,
457
- evm: {
458
- from,
459
- nonce,
460
- gasLimit: prepared.gasLimit,
461
- maxFeePerGas: prepared.maxFeePerGas,
462
- maxPriorityFeePerGas: prepared.maxPriorityFeePerGas,
463
- confirmations: CONFIRMATIONS
464
- }
465
- };
466
- });
467
- }
468
- verifyPrepared(review, tx) {
469
- if (tx.chainId !== this.config.chainId || !same$1(tx.to, this.config.token) || tx.value !== "0" || tx.data !== erc20.encodeFunctionData("transfer", [getAddress(review.address), BigInt(review.amount)]) || BigInt(tx.gasLimit) * BigInt(tx.maxFeePerGas) !== BigInt(review.fee) || review.fee > review.feeCap || tx.nonce !== review.evm?.nonce || !same$1(tx.from, review.evm?.from ?? "")) throw new Error("Transaction no longer matches the approved review");
470
- }
471
- verifySigned(raw, saved) {
472
- const tx = Transaction.from(raw);
473
- if (!tx.hash || !tx.from || !same$1(tx.from, saved.from) || tx.chainId !== BigInt(saved.chainId) || !tx.to || !same$1(tx.to, saved.to) || tx.data !== saved.data || tx.value !== 0n || tx.nonce !== saved.nonce || tx.gasLimit !== BigInt(saved.gasLimit) || tx.maxFeePerGas !== BigInt(saved.maxFeePerGas) || tx.maxPriorityFeePerGas !== BigInt(saved.maxPriorityFeePerGas)) throw new Error("Signature does not match the approved transaction");
474
- return tx.hash;
475
- }
476
- execute(review, prepared, persist) {
477
- return this.serial(async () => {
478
- let account;
479
- try {
480
- this.matches(review);
481
- this.verifyPrepared(review, prepared);
482
- await this.assertToken();
483
- account = this.current();
484
- if (!same$1(await account.getAddress(), prepared.from)) throw new Error("Wrong wallet");
485
- if (Number(BigInt(await this.rpc("eth_getTransactionCount", [prepared.from, "pending"]))) !== prepared.nonce) throw new Error("Account nonce changed. Create a new review");
486
- const balances = await this.balances();
487
- if (BigInt(balances.balance) < BigInt(review.amount) || BigInt(balances.gasBalance) < BigInt(review.fee)) throw new Error("Token or gas balance changed. Create a new review");
488
- if (!persist) throw new Error("Durable payment journal required");
489
- } catch (error) {
490
- throw new PaymentPreflightError(error instanceof Error ? error.message : "Could not validate payment");
491
- }
492
- const raw = await account.signTransaction({
493
- type: 2,
494
- to: prepared.to,
495
- data: prepared.data,
496
- value: 0n,
497
- chainId: prepared.chainId,
498
- nonce: prepared.nonce,
499
- gasLimit: BigInt(prepared.gasLimit),
500
- maxFeePerGas: BigInt(prepared.maxFeePerGas),
501
- maxPriorityFeePerGas: BigInt(prepared.maxPriorityFeePerGas)
502
- });
503
- prepared.hash = this.verifySigned(raw, prepared);
504
- prepared.signed = await sealSeed(raw, this.mnemonic);
505
- await persist();
506
- await account.sendTransaction(raw);
507
- return this.receipt(prepared.hash, review, prepared.from);
508
- });
509
- }
510
- reconcile(review, prepared) {
511
- return this.serial(async () => {
512
- await this.assertToken();
513
- this.verifyPrepared(review, prepared);
514
- if (!prepared.signed || !prepared.hash) return {
515
- settled: false,
516
- failed: true,
517
- error: "No signed transaction was submitted. Create a new review."
518
- };
519
- const result = await this.receipt(prepared.hash, review, prepared.from);
520
- if (result.settled || result.failed || result.pending) return result;
521
- if (Number(BigInt(await this.rpc("eth_getTransactionCount", [prepared.from, "pending"]))) > prepared.nonce) return result;
522
- const raw = await unsealSeed(prepared.signed, this.mnemonic);
523
- if (this.verifySigned(raw, prepared) !== prepared.hash || !same$1(await this.address(), prepared.from)) throw new Error("Recovery wallet mismatch");
524
- await this.current().sendTransaction(raw);
525
- return this.receipt(prepared.hash, review, prepared.from);
526
- });
527
- }
528
- async receipt(hash, target, expectedFrom) {
529
- if (!/^0x[0-9a-fA-F]{64}$/.test(hash) || target.chainId !== this.config.chainId || !same$1(target.token ?? "", this.config.token) || target.decimals !== this.config.decimals) throw new Error("Invalid receipt target");
530
- await this.assertToken();
531
- const tx = await this.rpc("eth_getTransactionByHash", [hash]);
532
- if (!tx) return {
533
- txid: hash,
534
- settled: false
535
- };
536
- if (!same$1(tx.to, this.config.token) || expectedFrom && !same$1(tx.from, expectedFrom) || BigInt(tx.value) !== 0n || tx.input !== erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(target.amount)])) throw new Error("Transaction is not the requested token transfer");
537
- const receipt = await this.rpc("eth_getTransactionReceipt", [hash]);
538
- if (!receipt) return {
539
- txid: hash,
540
- settled: false,
541
- pending: true
542
- };
543
- const block = await this.rpc("eth_getBlockByNumber", [receipt.blockNumber, false]);
544
- if (!block || block.hash !== receipt.blockHash || BigInt(block.timestamp) * 1000n < BigInt((target.issuedAt ?? 0) - 1e3)) return {
545
- txid: hash,
546
- settled: false
547
- };
548
- if (BigInt(await this.rpc("eth_blockNumber")) - BigInt(receipt.blockNumber) + 1n < BigInt(CONFIRMATIONS)) return {
549
- txid: hash,
550
- settled: false,
551
- pending: true
552
- };
553
- if (BigInt(receipt.status) !== 1n) return {
554
- txid: hash,
555
- settled: false,
556
- failed: true,
557
- error: "Transaction reverted. Tokens were not sent; gas was spent."
558
- };
559
- if (!receipt.logs.some((log) => {
560
- if (!same$1(log.address, this.config.token)) return false;
561
- try {
562
- const event = erc20.parseLog(log);
563
- return event?.name === "Transfer" && same$1(event.args[0], tx.from) && same$1(event.args[1], target.address) && event.args[2] === BigInt(target.amount);
564
- } catch {
565
- return false;
566
- }
567
- })) throw new Error("Receipt has no matching token Transfer");
568
- return {
569
- txid: hash,
570
- settled: true
571
- };
572
- }
573
- async dispose() {
574
- await this.queue.catch(() => {});
575
- this.account = void 0;
576
- this.manager?.dispose();
577
- this.manager = void 0;
578
- this.mnemonic = "";
579
- }
580
- serial(work) {
581
- const operation = this.queue.then(work, work);
582
- this.queue = operation.catch(() => {});
583
- return operation;
584
- }
585
- };
586
- //#endregion
587
181
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
182
+ /** 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-CUfucwZf.js");
588
184
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
589
185
  var DEFAULT_USDT = {
590
186
  network: "ethereum",
@@ -707,6 +303,7 @@ var UsdtWallet = class {
707
303
  if (!validateMnemonic(mnemonic, wordlist)) throw new Error("Invalid recovery phrase");
708
304
  const deviceKey = params.password ? void 0 : newDeviceKey();
709
305
  const seed = await sealSeed(mnemonic, params.password ?? deviceKey);
306
+ const { UsdtAdapter } = await loadUsdt();
710
307
  const config = await this.gate.within(UsdtAdapter.inspect({
711
308
  network: params.network,
712
309
  chainId: params.network === "ethereum" ? 1 : EVM_TEST_CHAINS[params.network],
@@ -755,6 +352,7 @@ var UsdtWallet = class {
755
352
  if (!key) throw new Error("Enter the USDT wallet password");
756
353
  const epoch = ++this.epoch;
757
354
  const mnemonic = await unsealSeed(this.saved.seed, key);
355
+ const { UsdtAdapter } = await loadUsdt();
758
356
  const adapter = await this.gate.within(UsdtAdapter.connect(this.saved.config, mnemonic), (a) => a.dispose());
759
357
  if (epoch !== this.epoch) {
760
358
  await adapter.dispose();
@@ -872,7 +470,7 @@ var UsdtWallet = class {
872
470
  const payload = JSON.parse(await unsealSeed(envelope.vault, password));
873
471
  if (payload.format !== "ghostly-usdt" || payload.version !== 1 || !validateMnemonic(payload.mnemonic, wordlist) || !Array.isArray(payload.intents)) throw new Error("Invalid USDT backup");
874
472
  if (usdtMode(payload.config.network) !== this.network) throw new WrongNetworkError(usdtMode(payload.config.network), `This backup is a ${networkLabel(usdtMode(payload.config.network))} USDT wallet`);
875
- const config = await UsdtAdapter.inspect(payload.config);
473
+ const config = await (await loadUsdt()).UsdtAdapter.inspect(payload.config);
876
474
  if (config.codeHash !== payload.config.codeHash || config.decimals !== payload.config.decimals || payload.intents.some((i) => i.review.method !== "usdt" || i.review.chainId !== config.chainId || i.review.token?.toLowerCase() !== config.token.toLowerCase())) throw new Error("Backup token or network mismatch");
877
475
  const deviceKey = newDeviceKey();
878
476
  const saved = {
@@ -946,362 +544,6 @@ function iceServerProblem(server) {
946
544
  return null;
947
545
  }
948
546
  //#endregion
949
- //#region ../browser/src/engine/paymentAdapters/arkade.ts
950
- var unbase64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
951
- var base64$1 = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));
952
- var hex$10 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
953
- var PreparedOnly = class extends Error {};
954
- var ARK_NETWORKS = [
955
- "bitcoin",
956
- "mutinynet",
957
- "signet",
958
- "regtest"
959
- ];
960
- /** Payments go through review and approval; only the SDK's own settlement (renewal, onboarding) reaches the provider directly. */
961
- var PreviewProvider = class extends RestArkProvider {
962
- authorizedFinalize;
963
- captured;
964
- take() {
965
- return this.captured;
966
- }
967
- async submitTx(signedTx, checkpoints) {
968
- this.captured = {
969
- signedTx,
970
- checkpoints
971
- };
972
- throw new PreparedOnly();
973
- }
974
- async finalizeTx(txid, checkpoints) {
975
- if (this.authorizedFinalize?.txid !== txid) throw new Error("Preview cannot finalize an unapproved payment");
976
- await this.authorizedFinalize.run(checkpoints);
977
- }
978
- };
979
- var ArkadeAdapter = class ArkadeAdapter {
980
- config;
981
- identity;
982
- wallet;
983
- preview;
984
- provider;
985
- indexer;
986
- repository;
987
- method = "arkade";
988
- pending = Promise.resolve();
989
- serial(run) {
990
- const next = this.pending.then(run, run);
991
- this.pending = next.catch(() => {});
992
- return next;
993
- }
994
- prepare(target, amount, feeCap) {
995
- return this.serial(() => this.prepareOperation(target, amount, feeCap));
996
- }
997
- execute(review, prepared, persist) {
998
- return this.serial(() => this.executeOperation(review, prepared, persist));
999
- }
1000
- reconcile(review, prepared, persist) {
1001
- return this.serial(() => this.reconcileOperation(review, prepared, persist));
1002
- }
1003
- constructor(config, identity, wallet, preview, provider, indexer, repository) {
1004
- this.config = config;
1005
- this.identity = identity;
1006
- this.wallet = wallet;
1007
- this.preview = preview;
1008
- this.provider = provider;
1009
- this.indexer = indexer;
1010
- this.repository = repository;
1011
- }
1012
- static async connect(config, mnemonic, storage) {
1013
- if (!ARK_NETWORKS.includes(config.network)) throw new Error("Unsupported Ark network");
1014
- validatePaymentTarget({
1015
- method: "arkade",
1016
- network: config.network,
1017
- provider: config.provider,
1018
- asset: "BTC",
1019
- unit: "sat",
1020
- address: "configuration",
1021
- expiresAt: Date.now() + 6e4
1022
- });
1023
- validatePaymentTarget({
1024
- method: "arkade",
1025
- network: config.network,
1026
- provider: config.explorer,
1027
- asset: "BTC",
1028
- unit: "sat",
1029
- address: "configuration",
1030
- expiresAt: Date.now() + 6e4
1031
- });
1032
- const provider = new RestArkProvider(config.provider);
1033
- const info = await provider.getInfo();
1034
- if (info.network !== config.network || info.signerPubkey !== config.serverKey) throw new Error("Ark provider network or signing key changed. Review the wallet configuration.");
1035
- const identity = MnemonicIdentity.fromMnemonic(mnemonic, { isMainnet: config.network === "bitcoin" });
1036
- const preview = new PreviewProvider(config.provider);
1037
- const indexer = new RestIndexerProvider(config.provider);
1038
- const repositories = {
1039
- ...storage,
1040
- walletRepository: storage?.walletRepository ?? new IndexedDBWalletRepository(`ghostly-ark-${config.walletId}`),
1041
- contractRepository: storage?.contractRepository ?? new IndexedDBContractRepository(`ghostly-ark-${config.walletId}`)
1042
- };
1043
- const wallet = await Wallet.create({
1044
- identity,
1045
- arkProvider: preview,
1046
- indexerProvider: indexer,
1047
- onchainProvider: new EsploraProvider(config.explorer),
1048
- settlementConfig: config.network === "regtest" ? false : { boardingUtxoSweep: true },
1049
- walletMode: "hd",
1050
- storage: repositories
1051
- });
1052
- return new ArkadeAdapter(config, identity, wallet, preview, provider, indexer, repositories.walletRepository);
1053
- }
1054
- async address() {
1055
- return this.wallet.getAddress();
1056
- }
1057
- requestAddress() {
1058
- return this.serial(async () => (await this.wallet.getNewAddresses({
1059
- types: ["default"],
1060
- forceNew: true
1061
- }))[0].address);
1062
- }
1063
- async balance() {
1064
- return (await this.wallet.getBalance()).available;
1065
- }
1066
- /**
1067
- * Sats whose batch expired before they were renewed, in three kinds, as a person can act on them:
1068
- * - `sweeping`: past their expiry, but the server has not swept their batch yet (with any swept ones: none can be
1069
- * recovered meanwhile). The SDK (0.4.76) counts them as
1070
- * recoverable already, but it settles an unswept coin without the forfeit the server still asks for (arkd wants
1071
- * one until the sweep): the batch fails ("missing forfeit transactions") and the server bans the coin for a while.
1072
- * The sweep waits for the chain's time to pass the expiry: an hour or more on a real network, the expiry's blocks
1073
- * on regtest.
1074
- * - `recoverable`: swept, and a recovery takes them now.
1075
- * - `small`: swept, but together under the server's dust limit: a recovery makes one new coin and the server makes
1076
- * none that small, so `recoverVtxos` answers "No recoverable VTXOs found". They wait for more coins to expire.
1077
- */
1078
- async expired() {
1079
- const now = { timestamp: /* @__PURE__ */ new Date() };
1080
- let swept = 0, sweeping = 0;
1081
- for (const vtxo of await this.wallet.getVtxos({
1082
- withRecoverable: true,
1083
- withUnrolled: false
1084
- })) if (isRecoverable(vtxo)) swept += vtxo.value;
1085
- else if (!vtxo.isSwept && canRecoverOnchain(vtxo, now)) sweeping += vtxo.value;
1086
- const takes = !sweeping && swept > 0 && (await (await this.wallet.getVtxoManager()).getRecoverableBalance()).recoverable >= this.wallet.dustAmount;
1087
- return sweeping ? {
1088
- recoverable: 0,
1089
- sweeping: sweeping + swept,
1090
- small: 0
1091
- } : {
1092
- recoverable: takes ? swept : 0,
1093
- sweeping: 0,
1094
- small: takes ? 0 : swept
1095
- };
1096
- }
1097
- /**
1098
- * When the first coin still spendable expires (ms), or undefined with none: a coin left alone past it is swept by the
1099
- * server and comes back only through a recovery. An expiry in blocks is read against the explorer's tip, at about ten
1100
- * minutes a block.
1101
- */
1102
- async expiresAt() {
1103
- let at, height;
1104
- for (const vtxo of await this.wallet.getVtxos({
1105
- withRecoverable: false,
1106
- withUnrolled: false
1107
- })) {
1108
- if (vtxo.isSwept || vtxo.isSpent) continue;
1109
- if (vtxo.expiresAt instanceof Date) {
1110
- const ms = vtxo.expiresAt.getTime();
1111
- if (Number.isFinite(ms)) at = Math.min(at ?? ms, ms);
1112
- } else if (typeof vtxo.expiresAtHeight === "number") height = Math.min(height ?? vtxo.expiresAtHeight, vtxo.expiresAtHeight);
1113
- }
1114
- if (height !== void 0) {
1115
- const tip = (await new EsploraProvider(this.config.explorer).getChainTip()).height;
1116
- const ms = Date.now() + Math.max(0, height - tip) * 6e5;
1117
- at = Math.min(at ?? ms, ms);
1118
- }
1119
- return at;
1120
- }
1121
- /**
1122
- * Moves swept outputs back into the balance, through the server's next batch. Refused while any expired coin is
1123
- * not swept yet (`recoverVtxos` would name it too, and that batch fails for all of them), and when what is swept
1124
- * is too little for a batch.
1125
- */
1126
- recover() {
1127
- return this.serial(async () => {
1128
- const { recoverable, sweeping, small } = await this.expired();
1129
- if (sweeping) throw new Error("Expired coins can be recovered once the Ark server has swept their batch. Try again later.");
1130
- if (small) throw new Error("Too few expired sats to recover on their own. They are recovered with the next coins that expire.");
1131
- if (!recoverable) throw new Error("Nothing to recover");
1132
- return (await this.wallet.getVtxoManager()).recoverVtxos();
1133
- });
1134
- }
1135
- /** On-chain deposits land here; the SDK's settlement moves them into Ark once confirmed. */
1136
- boardingAddress() {
1137
- return this.wallet.getBoardingAddress();
1138
- }
1139
- /** Sats sent on-chain to the boarding address that are not in Ark yet. */
1140
- async incoming() {
1141
- return (await this.wallet.getBalance()).boarding.total;
1142
- }
1143
- async dispose() {
1144
- await this.wallet.dispose();
1145
- }
1146
- async prepareOperation(target, amount, feeCap) {
1147
- validatePaymentTarget(target);
1148
- assertWholeSats(amount);
1149
- this.checkTarget(target);
1150
- const info = await this.provider.getInfo();
1151
- if (info.network !== this.config.network || info.signerPubkey !== this.config.serverKey) throw new Error("Ark provider configuration changed");
1152
- if (await this.balance() < amount) throw new Error("Insufficient Ark balance");
1153
- this.preview.captured = void 0;
1154
- try {
1155
- await this.wallet.send({
1156
- address: target.address,
1157
- amount
1158
- });
1159
- } catch (error) {
1160
- if (!(error instanceof PreparedOnly)) throw new Error("Could not prepare this Ark payment. Check funds, address and provider.");
1161
- }
1162
- const capture = this.preview.take();
1163
- if (!capture) throw new Error("The Ark adapter did not prepare a transaction");
1164
- const tx = Transaction$1.fromPSBT(unbase64(capture.signedTx));
1165
- const destination = ArkAddress.decode(target.address);
1166
- let received = 0n, input = 0n, output = 0n;
1167
- for (let i = 0; i < tx.inputsLength; i++) {
1168
- const prev = tx.getInput(i).witnessUtxo;
1169
- if (!prev) throw new Error("Missing Ark input amount");
1170
- input += prev.amount;
1171
- }
1172
- for (let i = 0; i < tx.outputsLength; i++) {
1173
- const out = tx.getOutput(i);
1174
- output += out.amount ?? 0n;
1175
- if (out.script && [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)].includes(hex$10(out.script))) received += out.amount ?? 0n;
1176
- }
1177
- const fee = Number(input - output);
1178
- if (received !== BigInt(amount) || !Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw new Error("Prepared amount or fee does not match your review");
1179
- return {
1180
- fee,
1181
- prepared: {
1182
- ...capture,
1183
- txid: tx.id,
1184
- address: target.address,
1185
- amount,
1186
- fee
1187
- }
1188
- };
1189
- }
1190
- async executeOperation(review, p, persist) {
1191
- this.checkPrepared(review, p);
1192
- const info = await this.provider.getInfo();
1193
- if (info.network !== this.config.network || info.signerPubkey !== this.config.serverKey) throw new Error("Ark provider configuration changed");
1194
- const tx = Transaction$1.fromPSBT(unbase64(p.signedTx));
1195
- const response = await this.provider.submitTx(p.signedTx, p.checkpoints);
1196
- assertSubmittedArkTxid(response, tx, "Ghostly Ark submission");
1197
- if (!response.finalArkTx) throw new Error("Provider returned no signed transaction");
1198
- this.verifyServer(Transaction$1.fromPSBT(unbase64(response.finalArkTx)), tx);
1199
- const matched = matchServerCheckpoints(response.signedCheckpointTxs, p.checkpoints.map((c) => Transaction$1.fromPSBT(unbase64(c))), "Ghostly Ark checkpoints");
1200
- const contracts = await (await this.wallet.getContractManager()).getContracts();
1201
- const final = await Promise.all(matched.map(async ({ server, local }) => {
1202
- assertAllowedSighashTypes(server);
1203
- this.verifyServer(server, local);
1204
- let signed = server;
1205
- for (let index = 0; index < local.inputsLength; index++) {
1206
- const script = local.getInput(index).witnessUtxo?.script;
1207
- const contract = script && contracts.find((c) => c.script === hex$10(script));
1208
- if (!contract || contract.type !== "default") throw new Error("Checkpoint is not owned by this wallet profile");
1209
- const descriptor = contract.metadata?.signingDescriptor;
1210
- const signer = typeof descriptor === "string" ? await this.wallet.signerForDescriptor(descriptor) : contract.params.pubKey === hex$10(await this.identity.xOnlyPublicKey()) ? this.identity : void 0;
1211
- if (!signer) throw new Error("Checkpoint signing descriptor missing");
1212
- signed = await signer.sign(signed, [index]);
1213
- }
1214
- return base64$1(signed.toPSBT());
1215
- }));
1216
- p.finalCheckpoints = final;
1217
- await persist?.();
1218
- await this.provider.finalizeTx(p.txid, final);
1219
- return {
1220
- txid: p.txid,
1221
- settled: true
1222
- };
1223
- }
1224
- async reconcileOperation(review, p, persist) {
1225
- this.checkPrepared(review, p);
1226
- if (await this.verifyReceipt(p.txid, p.address, p.amount)) return {
1227
- txid: p.txid,
1228
- settled: true
1229
- };
1230
- if (!p.finalCheckpoints) {
1231
- const state = await this.repository.getWalletState();
1232
- if (!state) throw new Error("Ark wallet state is unavailable");
1233
- await this.repository.saveWalletState({
1234
- ...state,
1235
- settings: {
1236
- ...state.settings,
1237
- hasPendingTx: true
1238
- }
1239
- });
1240
- this.preview.authorizedFinalize = {
1241
- txid: p.txid,
1242
- run: async (checkpoints) => {
1243
- const matched = matchServerCheckpoints(checkpoints, p.checkpoints.map((c) => Transaction$1.fromPSBT(unbase64(c))), "Ghostly pending recovery");
1244
- for (const { server, local } of matched) {
1245
- assertAllowedSighashTypes(server);
1246
- this.verifyServer(server, local);
1247
- }
1248
- p.finalCheckpoints = checkpoints;
1249
- await persist?.();
1250
- await this.provider.finalizeTx(p.txid, checkpoints);
1251
- }
1252
- };
1253
- try {
1254
- await this.wallet.finalizePendingTxs();
1255
- } finally {
1256
- this.preview.authorizedFinalize = void 0;
1257
- }
1258
- }
1259
- if (p.finalCheckpoints) try {
1260
- await this.provider.finalizeTx(p.txid, p.finalCheckpoints);
1261
- } catch {}
1262
- return {
1263
- txid: p.txid,
1264
- settled: await this.verifyReceipt(p.txid, p.address, p.amount)
1265
- };
1266
- }
1267
- /**
1268
- * A virtual output paying `address` at least `amount`, made after `since` and none of `claimed`: how a
1269
- * request paid from another Ark wallet is seen, with no receipt from the payer.
1270
- */
1271
- async received(address, amount, since, claimed) {
1272
- const destination = ArkAddress.decode(address);
1273
- const scripts = [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)];
1274
- const { vtxos } = await this.indexer.getVtxos({ scripts });
1275
- return vtxos.find((v) => /^[a-f0-9]{64}$/.test(v.txid) && v.value >= amount && scripts.includes(v.script) && !claimed.has(v.txid) && v.createdAt.getTime() >= since - 6e4)?.txid;
1276
- }
1277
- async verifyReceipt(txid, address, amount) {
1278
- if (!/^[a-f0-9]{64}$/.test(txid)) return false;
1279
- const destination = ArkAddress.decode(address);
1280
- return (await this.indexer.getVtxos({ outpoints: [{
1281
- txid,
1282
- vout: 0
1283
- }] })).vtxos.some((v) => v.txid === txid && v.value === amount && [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)].includes(v.script));
1284
- }
1285
- checkTarget(t) {
1286
- if (t.method !== this.method || t.provider !== this.config.provider || t.network !== this.config.network || t.asset !== "BTC" || t.unit !== "sat") throw new Error("Payment method, network or provider does not match this wallet");
1287
- const decoded = ArkAddress.decode(t.address);
1288
- const pinned = this.config.serverKey.replace(/^(02|03)(?=[a-f0-9]{64}$)/, "");
1289
- if (hex$10(decoded.serverPubKey) !== pinned || decoded.hrp !== (this.config.network === "bitcoin" ? "ark" : "tark")) throw new Error("Ark address belongs to another provider or network");
1290
- }
1291
- verifyServer(server, local) {
1292
- const key = this.config.serverKey.replace(/^(02|03)(?=[a-f0-9]{64}$)/, "");
1293
- for (let index = 0; index < local.inputsLength; index++) {
1294
- const leaf = local.getInput(index).tapLeafScript?.[0]?.[1];
1295
- if (!leaf) throw new Error("Missing reviewed spend leaf");
1296
- verifyTapscriptSignatures(server, index, [key], void 0, void 0, tapLeafHash(leaf.subarray(0, -1), leaf[leaf.length - 1]));
1297
- }
1298
- }
1299
- checkPrepared(r, p) {
1300
- this.checkTarget(r);
1301
- if (p.address !== r.address || p.amount !== r.amount || p.fee !== r.fee || p.fee > r.feeCap || Transaction$1.fromPSBT(unbase64(p.signedTx)).id !== p.txid) throw new Error("Prepared payment does not match the approved review");
1302
- }
1303
- };
1304
- //#endregion
1305
547
  //#region ../browser/src/engine/paymentAdapters/backup.ts
1306
548
  /**
1307
549
  * Wallet database versions a backup may carry. 3 is what SDK 0.4.74-0.4.76 made (Ghostly 1.1.3 and earlier);
@@ -1399,6 +641,8 @@ async function restoreArkDatabase(walletId, snapshot) {
1399
641
  }
1400
642
  //#endregion
1401
643
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
644
+ /** 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-CtCpFaF7.js");
1402
646
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
1403
647
  var DEFAULT_ARK = {
1404
648
  network: "bitcoin",
@@ -1530,6 +774,7 @@ var ArkWallet = class {
1530
774
  }
1531
775
  async createNow(params) {
1532
776
  refuseAway(`arkade:${this.network}`, "Ark");
777
+ const [{ ARK_NETWORKS, ArkadeAdapter }, { RestArkProvider }] = await Promise.all([loadArkade(), import("@arkade-os/sdk")]);
1533
778
  if (!ARK_NETWORKS.includes(params.network)) throw new Error("Unsupported Ark network");
1534
779
  if (arkMode(params.network) !== this.network) throw new WrongNetworkError(arkMode(params.network), `${params.network === "bitcoin" ? "Bitcoin" : params.network} is a ${networkLabel(arkMode(params.network))} network: this is the ${networkLabel(this.network)} Ark wallet`);
1535
780
  checkProviders(params.network, params.provider, params.explorer);
@@ -1581,6 +826,7 @@ var ArkWallet = class {
1581
826
  const key = this.saved.deviceKey ?? password;
1582
827
  if (!key) throw new Error("Enter the Ark wallet password");
1583
828
  const mnemonic = await unsealSeed(this.saved.seed, key);
829
+ const { ArkadeAdapter } = await loadArkade();
1584
830
  this.adapter = await this.gate.within(ArkadeAdapter.connect(this.saved.config, mnemonic), (a) => a.dispose());
1585
831
  await this.refresh();
1586
832
  }
@@ -1667,6 +913,7 @@ var ArkWallet = class {
1667
913
  ...payload.config,
1668
914
  walletId: crypto.randomUUID()
1669
915
  };
916
+ const { ARK_NETWORKS } = await loadArkade();
1670
917
  if (!ARK_NETWORKS.includes(config.network)) throw new Error("Unsupported Ark network in backup");
1671
918
  if (arkMode(config.network) !== this.network) throw new WrongNetworkError(arkMode(config.network), `This backup is a ${networkLabel(arkMode(config.network))} Ark wallet`);
1672
919
  checkProviders(config.network, config.provider, config.explorer);
@@ -5587,7 +4834,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5587
4834
  lockUnspents: true,
5588
4835
  replaceable: true
5589
4836
  }), "funded transaction");
5590
- const inputs = this.outpoints(Transaction$2.fromPSBT(base64.decode(text$2(funded.psbt, "funded transaction")), TX_OPTS));
4837
+ const inputs = this.outpoints(Transaction.fromPSBT(base64.decode(text$2(funded.psbt, "funded transaction")), TX_OPTS));
5591
4838
  try {
5592
4839
  const fee = btcToSats(funded.fee);
5593
4840
  if (fee > request.feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${request.feeCap}`);
@@ -5595,7 +4842,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5595
4842
  if (signed.complete !== true) throw new Error("The wallet could not sign this payment (is it locked, or watch-only?)");
5596
4843
  const final = object(await this.call("finalizepsbt", signed.psbt, false), "finalized transaction");
5597
4844
  if (final.complete !== true) throw new Error("The wallet could not finalize this payment");
5598
- return this.check(Transaction$2.fromPSBT(base64.decode(text$2(final.psbt, "finalized transaction")), TX_OPTS), request, inputs, feeRate, network);
4845
+ return this.check(Transaction.fromPSBT(base64.decode(text$2(final.psbt, "finalized transaction")), TX_OPTS), request, inputs, feeRate, network);
5599
4846
  } catch (error) {
5600
4847
  await this.unlock(inputs).catch(() => {});
5601
4848
  throw error;
@@ -5605,16 +4852,17 @@ var BitcoindOnchain = class BitcoindOnchain {
5605
4852
  check(tx, request, funded, feeRate, network) {
5606
4853
  const same = (a, b) => a.length === b.length && a.every((o, i) => o.txid === b[i].txid && o.vout === b[i].vout);
5607
4854
  if (!same(this.outpoints(tx), funded)) throw new Error("The node signed other coins than it funded the payment with");
4855
+ const script = (() => {
4856
+ try {
4857
+ return hex.encode(OutScript.encode(Address(BTC_NETWORKS[network]).decode(request.address)));
4858
+ } catch {
4859
+ return;
4860
+ }
4861
+ })();
5608
4862
  let paid = 0;
5609
4863
  for (let i = 0; i < tx.outputsLength; i++) {
5610
4864
  const output = tx.getOutput(i);
5611
- if ((() => {
5612
- try {
5613
- return tx.getOutputAddress(i, BTC_NETWORKS[network]);
5614
- } catch {
5615
- return;
5616
- }
5617
- })() === request.address) paid += Number(output.amount ?? 0n);
4865
+ if (script !== void 0 && output.script && hex.encode(output.script) === script) paid += Number(output.amount ?? 0n);
5618
4866
  }
5619
4867
  if (paid !== request.amount) throw new Error("The node built a transaction that does not pay the address and amount asked for");
5620
4868
  const fee = Number(tx.fee);
@@ -5647,7 +4895,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5647
4895
  }
5648
4896
  signedTx(prepared) {
5649
4897
  if (!/^[0-9a-f]+$/.test(prepared.signed)) throw new Error("This prepared payment has no signed transaction");
5650
- const tx = Transaction$2.fromRaw(hex.decode(prepared.signed), TX_OPTS);
4898
+ const tx = Transaction.fromRaw(hex.decode(prepared.signed), TX_OPTS);
5651
4899
  if (tx.id !== prepared.txid) throw new Error("This prepared payment's transaction is not the one reviewed");
5652
4900
  return tx;
5653
4901
  }
@@ -11895,6 +11143,10 @@ var APP_CHECK_TIMINGS = {
11895
11143
  /** A check at start is skipped when every app was checked this recently (a page reloaded several times). */
11896
11144
  freshMs: 36e5
11897
11145
  };
11146
+ /** Revocations are checked this long at a time before the page's thread is let go. */
11147
+ var REVOCATIONS_SLICE_MS = 10;
11148
+ /** `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first). */
11149
+ var REVOCATION_FILES_KEPT = 64;
11898
11150
  /** A fetched bundle kept for its install screen: at most this many, for this long. */
11899
11151
  var STAGED = {
11900
11152
  max: 4,
@@ -11903,6 +11155,16 @@ var STAGED = {
11903
11155
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11904
11156
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11905
11157
  var bundleId = (digest) => `app-${digest}`;
11158
+ /**
11159
+ * A listing's URLs this client reads, in order, a jsDelivr copy first: one this client reads names a full commit, so it
11160
+ * holds exactly the version the store reviewed, where a raw `HEAD` may have moved on to one it has not (WISP 1200 §
11161
+ * Stores), and a 16 MiB bundle is not downloaded only to be skipped.
11162
+ */
11163
+ function listingUrls(urls) {
11164
+ const readable = urls.filter(isAppFetchUrl);
11165
+ const pinned = (u) => new URL(u).hostname.toLowerCase() === "cdn.jsdelivr.net";
11166
+ return [...readable.filter(pinned), ...readable.filter((u) => !pinned(u))];
11167
+ }
11906
11168
  /** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
11907
11169
  function fail(code, words) {
11908
11170
  throw new Error(`${code}: ${words}`);
@@ -11921,6 +11183,15 @@ var versionOf = (manifest, digest) => ({
11921
11183
  digest
11922
11184
  });
11923
11185
  var byteLength = (text) => utf8Encode(text).length;
11186
+ /** The app with these revocations merged into the ones it holds, each kept once. */
11187
+ function withRevocations(app, read) {
11188
+ const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
11189
+ for (const r of read) merged.set(canonicalJson(r), r);
11190
+ return merged.size ? {
11191
+ ...app,
11192
+ revocations: [...merged.values()]
11193
+ } : app;
11194
+ }
11924
11195
  /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
11925
11196
  var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
11926
11197
  function done(tx) {
@@ -11957,13 +11228,32 @@ var Apps = class {
11957
11228
  staged = /* @__PURE__ */ new Map();
11958
11229
  /** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
11959
11230
  verified = /* @__PURE__ */ new Map();
11231
+ /** Reads of a bundle under way, by digest: icons drawn at once share one read. */
11232
+ verifying = /* @__PURE__ */ new Map();
11233
+ /**
11234
+ * The `icon.png` of each checked bundle, by digest (at most 256 KiB, or null for none): every icon shown on the Apps
11235
+ * page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
11236
+ */
11237
+ icons = /* @__PURE__ */ new Map();
11960
11238
  /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
11961
11239
  usage = /* @__PURE__ */ new Map();
11962
11240
  queues = /* @__PURE__ */ new Map();
11963
11241
  timer = null;
11242
+ /** Drops what `staged` holds past `STAGED.ms`, set while it holds anything: a closed install screen sends no call. */
11243
+ sweep = null;
11964
11244
  checking = null;
11965
11245
  /** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
11966
11246
  anyway = /* @__PURE__ */ new Set();
11247
+ views = null;
11248
+ held = /* @__PURE__ */ new Map();
11249
+ heldStores = null;
11250
+ /**
11251
+ * Listing URLs whose peek showed nothing to take (`<store> <ref> <url>` → `<index digest> <listed digest>`): the same
11252
+ * index listing the same is not peeked again at every check.
11253
+ */
11254
+ listingMisses = /* @__PURE__ */ new Map();
11255
+ /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused. */
11256
+ revocationFiles = /* @__PURE__ */ new Map();
11967
11257
  stopped = false;
11968
11258
  constructor(host) {
11969
11259
  this.host = host;
@@ -11984,6 +11274,12 @@ var Apps = class {
11984
11274
  this.stopped = true;
11985
11275
  if (this.timer) clearTimeout(this.timer);
11986
11276
  this.timer = null;
11277
+ if (this.sweep) clearTimeout(this.sweep);
11278
+ this.sweep = null;
11279
+ this.staged.clear();
11280
+ this.held.clear();
11281
+ this.heldStores = null;
11282
+ this.views = null;
11987
11283
  }
11988
11284
  schedule(ms, atStart = false) {
11989
11285
  if (this.timer) clearTimeout(this.timer);
@@ -12015,8 +11311,22 @@ var Apps = class {
12015
11311
  async installed() {
12016
11312
  return wrap((await store(STORES$1.apps, "readonly")).getAll());
12017
11313
  }
12018
- async app(ref) {
12019
- return wrap((await store(STORES$1.apps, "readonly")).get(ref));
11314
+ /**
11315
+ * An installed app's record, read once and again after it is written here (the only writer, as for `heldViews`). An
11316
+ * app's every storage call, file read and chat frame asks for it. A reference not installed is not kept.
11317
+ */
11318
+ app(ref) {
11319
+ const kept = this.held.get(ref);
11320
+ if (kept) return kept;
11321
+ const read = store(STORES$1.apps, "readonly").then((s) => wrap(s.get(ref)));
11322
+ this.held.set(ref, read);
11323
+ const drop = () => {
11324
+ if (this.held.get(ref) === read) this.held.delete(ref);
11325
+ };
11326
+ read.then((app) => {
11327
+ if (!app) drop();
11328
+ }, drop);
11329
+ return read;
12020
11330
  }
12021
11331
  async installedOrFail(ref) {
12022
11332
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
@@ -12028,7 +11338,7 @@ var Apps = class {
12028
11338
  * or not the page stops its frame.
12029
11339
  */
12030
11340
  async stillRunnable(app) {
12031
- const run = this.runStatus(app, await this.stores(), app.revocations);
11341
+ const run = this.runStatus(app, await this.heldViews(), app.revocations);
12032
11342
  if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
12033
11343
  if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12034
11344
  return app;
@@ -12045,17 +11355,77 @@ var Apps = class {
12045
11355
  if (app) await this.stillRunnable(app);
12046
11356
  }
12047
11357
  async putApp(app) {
12048
- await wrap((await store(STORES$1.apps, "readwrite")).put(app));
11358
+ try {
11359
+ await wrap((await store(STORES$1.apps, "readwrite")).put(app));
11360
+ } finally {
11361
+ this.held.delete(app.ref);
11362
+ }
11363
+ }
11364
+ /**
11365
+ * Writes an app's record back only while that install is still there (the same `installedAt`), read and written in
11366
+ * one transaction: an app uninstalled (or installed again) while the update check was reading is not put back.
11367
+ */
11368
+ async putAppIfInstalled(app, installedAt) {
11369
+ const tx = (await openDb()).transaction(STORES$1.apps, "readwrite");
11370
+ let put = false;
11371
+ const read = tx.objectStore(STORES$1.apps).get(app.ref);
11372
+ read.onsuccess = () => {
11373
+ if (read.result?.installedAt !== installedAt) return;
11374
+ tx.objectStore(STORES$1.apps).put(app);
11375
+ put = true;
11376
+ };
11377
+ try {
11378
+ await done(tx);
11379
+ } finally {
11380
+ this.held.delete(app.ref);
11381
+ }
11382
+ return put;
11383
+ }
11384
+ /**
11385
+ * The stores' records by key, read once and kept in step by the writes here (the only writer: pages in other tabs
11386
+ * share this engine). A record holds its store's whole index, up to 4 MiB, and IndexedDB reads and writes it whole:
11387
+ * the Apps page, the update check and an app's every storage call and chat frame ask for them.
11388
+ */
11389
+ records() {
11390
+ return this.heldStores ??= store(STORES$1.appStores, "readonly").then((s) => wrap(s.getAll())).then((all) => new Map(all.map((r) => [r.key, r])), (error) => {
11391
+ this.heldStores = null;
11392
+ throw error;
11393
+ });
12049
11394
  }
12050
11395
  async storeRecord(key) {
12051
- return wrap((await store(STORES$1.appStores, "readonly")).get(key));
11396
+ return (await this.records()).get(key);
12052
11397
  }
12053
- async putStore(record) {
12054
- await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
11398
+ /** A write of the stores' records: what is asked while it runs waits for it, as a read of IndexedDB would; one that fails leaves what was held. */
11399
+ async writeStores(write, apply) {
11400
+ const before = this.records();
11401
+ const written = before.then(async (held) => {
11402
+ await wrap(write(await store(STORES$1.appStores, "readwrite")));
11403
+ apply(held);
11404
+ this.views = null;
11405
+ });
11406
+ const after = written.then(() => before, () => before);
11407
+ this.heldStores = after;
11408
+ after.catch(() => {
11409
+ if (this.heldStores === after) this.heldStores = null;
11410
+ });
11411
+ await written;
12055
11412
  }
12056
- /** The stores the person has (a removed default is not one). */
11413
+ putStore(record) {
11414
+ return this.writeStores((records) => records.put(record), (held) => held.set(record.key, record));
11415
+ }
11416
+ deleteStore(key) {
11417
+ return this.writeStores((records) => records.delete(key), (held) => held.delete(key));
11418
+ }
11419
+ /** The stores' views, made once and again after a store is written here: an app's every storage call and chat frame checks them. */
11420
+ heldViews() {
11421
+ return this.views ??= this.stores().then((s) => this.storeViews(s), (error) => {
11422
+ this.views = null;
11423
+ throw error;
11424
+ });
11425
+ }
11426
+ /** The stores the person has (a removed default is not one), in the order of their keys, as IndexedDB lists them. */
12057
11427
  async stores() {
12058
- return (await wrap((await store(STORES$1.appStores, "readonly")).getAll())).filter((s) => !s.removed);
11428
+ return [...(await this.records()).values()].filter((s) => !s.removed).sort((a, b) => a.key < b.key ? -1 : 1);
12059
11429
  }
12060
11430
  storeViews(stores) {
12061
11431
  return stores.filter((s) => s.index).map((s) => ({
@@ -12073,8 +11443,8 @@ var Apps = class {
12073
11443
  kind: s.index.kind
12074
11444
  }));
12075
11445
  }
12076
- runStatus(version, stores, revocations = []) {
12077
- const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
11446
+ runStatus(version, views, revocations = []) {
11447
+ const check = checkAppBeforeRun(version, views, revocations);
12078
11448
  if (check.status === "revoked") return {
12079
11449
  status: "revoked",
12080
11450
  ...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
@@ -12133,19 +11503,34 @@ var Apps = class {
12133
11503
  }
12134
11504
  async removeBundle(kind, digest) {
12135
11505
  this.verified.delete(digest);
11506
+ this.icons.delete(digest);
12136
11507
  await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
12137
11508
  }
11509
+ /** A bundle's bytes removed unless the app's record (installed again meanwhile) holds that version, or waits for it. */
11510
+ async removeUnusedBundle(ref, kind, digest) {
11511
+ const app = await this.app(ref);
11512
+ if (app?.digest !== digest && app?.pending?.digest !== digest) await this.removeBundle(kind, digest);
11513
+ }
12138
11514
  /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
12139
- async verifiedBundle(app) {
11515
+ verifiedBundle(app) {
12140
11516
  const kept = this.verified.get(app.digest);
12141
- if (kept) return kept;
12142
- const bytes = await this.readBundleBytes(app.bytes, app.digest);
12143
- if (!bytes) fail("needs-files", "This app's files are not on this device yet");
12144
- const read = readAppBundle(bytes);
12145
- if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
12146
- if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
12147
- this.verified.set(app.digest, read.bundle);
12148
- return read.bundle;
11517
+ if (kept) return Promise.resolve(kept);
11518
+ const under = this.verifying.get(app.digest);
11519
+ if (under) return under;
11520
+ const reading = (async () => {
11521
+ const bytes = await this.readBundleBytes(app.bytes, app.digest);
11522
+ if (!bytes) fail("needs-files", "This app's files are not on this device yet");
11523
+ const read = readAppBundle(bytes);
11524
+ if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
11525
+ if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
11526
+ this.verified.set(app.digest, read.bundle);
11527
+ this.icons.set(app.digest, read.bundle.files.get("icon.png")?.slice() ?? null);
11528
+ return read.bundle;
11529
+ })().finally(() => {
11530
+ this.verifying.delete(app.digest);
11531
+ });
11532
+ this.verifying.set(app.digest, reading);
11533
+ return reading;
12149
11534
  }
12150
11535
  async fetchBundle(url) {
12151
11536
  const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
@@ -12156,17 +11541,67 @@ var Apps = class {
12156
11541
  bytes
12157
11542
  };
12158
11543
  }
12159
- async readStoreAt(url, heldKey) {
11544
+ /**
11545
+ * Reads the index at `url`. With the index held for this store, bytes equal to it (by digest) are the held index: its
11546
+ * signature and every revocation in it were checked when it was read, so only its expiry is taken again.
11547
+ */
11548
+ async readStoreAt(url, heldKey, held) {
12160
11549
  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 })]);
11550
+ if (held?.index && held.digest !== void 0 && toBase64Url(sha256(indexBytes)) === held.digest) return {
11551
+ ok: true,
11552
+ store: {
11553
+ index: held.index,
11554
+ digest: held.digest,
11555
+ expired: held.index.expires < this.nowS()
11556
+ }
11557
+ };
12161
11558
  return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
12162
11559
  }
12163
- /** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
12164
- async readRevocations(ref, urls) {
12165
- const out = [];
12166
- for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
12167
- const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
12168
- if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
12169
- } catch {}
11560
+ /**
11561
+ * The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error.
11562
+ * `read` holds the files already read, which are skipped, and gets the ones read now. Null when no file answered now.
11563
+ */
11564
+ async readRevocations(ref, urls, read = /* @__PURE__ */ new Set()) {
11565
+ let out = null;
11566
+ for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) {
11567
+ if (read.has(url)) continue;
11568
+ read.add(url);
11569
+ try {
11570
+ const file = await this.readRevocationsFile(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
11571
+ if (file) (out ??= []).push(...file.filter((r) => r.statement.app === ref));
11572
+ } catch {}
11573
+ }
11574
+ return out;
11575
+ }
11576
+ /**
11577
+ * `readAppRevocations` (one entry that does not verify refuses the file, null here), with a pause every few ms: the
11578
+ * engine shares the page's thread, and each entry is a signature check. The same bytes are not checked again.
11579
+ */
11580
+ async readRevocationsFile(bytes) {
11581
+ const id = toBase64Url(sha256(bytes));
11582
+ const known = this.revocationFiles.get(id);
11583
+ if (known !== void 0) return known;
11584
+ const read = readCanonicalJson(bytes);
11585
+ let out = null;
11586
+ if (read.ok && Array.isArray(read.value) && read.value.length <= APP_STATEMENT_LIMITS.revocations) {
11587
+ out = [];
11588
+ let since = performance.now();
11589
+ for (const entry of read.value) {
11590
+ if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11591
+ await new Promise((resolve) => setTimeout(resolve, 0));
11592
+ since = performance.now();
11593
+ }
11594
+ const one = readAppRevocation(entry);
11595
+ if (!one.ok) {
11596
+ out = null;
11597
+ break;
11598
+ }
11599
+ out.push(one.revocation);
11600
+ }
11601
+ }
11602
+ this.revocationFiles.delete(id);
11603
+ this.revocationFiles.set(id, out);
11604
+ for (const old of this.revocationFiles.keys()) if (this.revocationFiles.size > REVOCATION_FILES_KEPT) this.revocationFiles.delete(old);
12170
11605
  return out;
12171
11606
  }
12172
11607
  summary(s) {
@@ -12299,7 +11734,12 @@ var Apps = class {
12299
11734
  preloaded: true,
12300
11735
  removed: true
12301
11736
  });
12302
- else await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
11737
+ else await this.deleteStore(key);
11738
+ for (const app of await this.installed()) {
11739
+ if (app.store !== key) continue;
11740
+ const { store: _s, ...unpinned } = app;
11741
+ await this.putApp(unpinned);
11742
+ }
12303
11743
  }
12304
11744
  /** Reads one store again, or every store (the person opened the Apps page, or the update check). */
12305
11745
  async refreshStores({ key } = {}) {
@@ -12309,7 +11749,7 @@ var Apps = class {
12309
11749
  for (const s of stores) {
12310
11750
  let next;
12311
11751
  try {
12312
- const read = await this.readStoreAt(s.url, s.key);
11752
+ const read = await this.readStoreAt(s.url, s.key, s);
12313
11753
  next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
12314
11754
  ...s,
12315
11755
  problem: read.reason
@@ -12320,31 +11760,38 @@ var Apps = class {
12320
11760
  problem: error instanceof AppFetchError ? error.code : "network"
12321
11761
  };
12322
11762
  }
12323
- if (!await this.storeRecord(s.key) || (await this.storeRecord(s.key))?.removed) continue;
12324
- await this.putStore(next);
11763
+ const now = await this.storeRecord(s.key);
11764
+ if (!now || now.removed) continue;
11765
+ if (now === s && next.index === s.index && next.digest === s.digest && next.problem === s.problem && next.equivocation === s.equivocation) (await this.records()).set(s.key, next);
11766
+ else await this.putStore(next);
12325
11767
  out.push(this.summary(next));
12326
11768
  }
12327
11769
  return out;
12328
11770
  }
12329
11771
  /**
12330
11772
  * Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
12331
- * chat card's pointer. A store's or a card's `sequence` and digest must hold: the same app, and that version or a newer
12332
- * one. Nothing is stored: the checked bundle waits in memory for `install`.
11773
+ * chat card's pointer. A card's `sequence` and digest must hold: the same app, and that version or a newer one. A
11774
+ * store's listing must hold exactly: the version it lists, by digest, so a URL that already holds a newer version the
11775
+ * store has not reviewed is skipped for the next one (`unlisted` when none holds it). Nothing is stored: the checked
11776
+ * bundle waits in memory for `install`.
12333
11777
  */
12334
11778
  async preview(source) {
12335
11779
  let urls;
12336
11780
  let expect = null;
11781
+ let fromStore;
12337
11782
  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")];
12338
11783
  else if ("store" in source) {
12339
11784
  const s = await this.storeRecord(source.store);
12340
11785
  const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
12341
11786
  if (!listing) fail("not-listed", "This store does not list this app");
12342
- urls = listing.urls.filter(isAppFetchUrl);
11787
+ urls = listingUrls(listing.urls);
12343
11788
  expect = {
12344
11789
  ref: listing.ref,
12345
11790
  sequence: listing.sequence,
12346
- digest: listing.digest
11791
+ digest: listing.digest,
11792
+ exact: true
12347
11793
  };
11794
+ fromStore = s.key;
12348
11795
  } else if ("card" in source && source.card) {
12349
11796
  const { ref, url, sequence, digest } = source.card;
12350
11797
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
@@ -12365,14 +11812,22 @@ var Apps = class {
12365
11812
  if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
12366
11813
  if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
12367
11814
  if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
11815
+ if (expect.exact && version.digest !== expect.digest) fail("unlisted", "The URL holds a version the store does not list");
12368
11816
  }
12369
- return this.stage(bundle, bytes, url);
11817
+ return this.stage(bundle, bytes, url, fromStore);
12370
11818
  } catch (error) {
12371
11819
  last = error;
12372
11820
  }
12373
11821
  throw last instanceof Error ? last : new Error(String(last));
12374
11822
  }
12375
- async stage(bundle, bytes, from) {
11823
+ /** Drops the staged bundles past `STAGED.ms`, and comes back when the oldest left is due. */
11824
+ sweepStaged() {
11825
+ this.sweep = null;
11826
+ for (const [digest, s] of this.staged) if (this.now() - s.at >= STAGED.ms) this.staged.delete(digest);
11827
+ const oldest = this.staged.values().next().value;
11828
+ if (oldest) this.sweep = setTimeout(() => this.sweepStaged(), STAGED.ms - (this.now() - oldest.at));
11829
+ }
11830
+ async stage(bundle, bytes, from, fromStore) {
12376
11831
  for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
12377
11832
  this.staged.delete(bundle.digest);
12378
11833
  while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
@@ -12380,8 +11835,10 @@ var Apps = class {
12380
11835
  bundle,
12381
11836
  bytes,
12382
11837
  from,
11838
+ ...fromStore !== void 0 && { store: fromStore },
12383
11839
  at: this.now()
12384
11840
  });
11841
+ if (!this.sweep) this.sweepStaged();
12385
11842
  const { manifest, digest } = bundle;
12386
11843
  const version = versionOf(manifest, digest);
12387
11844
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
@@ -12399,7 +11856,7 @@ var Apps = class {
12399
11856
  icon: bundle.files.get("icon.png")?.slice() ?? null,
12400
11857
  install,
12401
11858
  asks,
12402
- run: this.runStatus(version, stores, installed?.revocations),
11859
+ run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
12403
11860
  listedBy,
12404
11861
  unknownPublisher: !listedBy.some((s) => s.kind === "curated")
12405
11862
  };
@@ -12415,12 +11872,12 @@ var Apps = class {
12415
11872
  this.staged.delete(digest);
12416
11873
  fail("expired", "Fetch the app again");
12417
11874
  }
12418
- const { bundle, bytes, from } = staged;
11875
+ const { bundle, bytes, from, store: fromStore } = staged;
12419
11876
  const { manifest } = bundle;
12420
11877
  if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
12421
11878
  const version = versionOf(manifest, digest);
12422
11879
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
12423
- const run = this.runStatus(version, stores, installed?.revocations);
11880
+ const run = this.runStatus(version, this.storeViews(stores), installed?.revocations);
12424
11881
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12425
11882
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12426
11883
  const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
@@ -12438,26 +11895,37 @@ var Apps = class {
12438
11895
  fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
12439
11896
  }
12440
11897
  if (decision === "same" && installed) {
12441
- if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
12442
- ...installed,
12443
- bytes: await this.writeBundle(digest, bytes)
12444
- });
11898
+ const files = await this.hasBundle(installed.bytes, digest) ? null : await this.writeBundle(digest, bytes);
11899
+ const pin = fromStore !== void 0 && installed.store !== fromStore;
11900
+ if (files || pin) {
11901
+ const { pending, ...rest } = installed;
11902
+ if (pin && pending) await this.removeBundle(pending.bytes, pending.digest);
11903
+ await this.putApp({
11904
+ ...pin ? rest : installed,
11905
+ ...files && { bytes: files },
11906
+ ...pin && { store: fromStore }
11907
+ });
11908
+ }
12445
11909
  this.staged.delete(digest);
12446
11910
  return this.view(await this.app(version.ref), stores);
12447
11911
  }
12448
11912
  const kind = await this.writeBundle(digest, bytes);
12449
- const next = this.versionRecord(installed, bundle, kind, from);
11913
+ const next = this.versionRecord(installed, bundle, kind, from, fromStore);
12450
11914
  await this.putApp(next);
12451
11915
  if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12452
- if (installed?.pending && installed.pending.sequence <= version.sequence && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
11916
+ if (installed?.pending && next.pending?.digest !== installed.pending.digest && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
12453
11917
  this.staged.delete(digest);
12454
11918
  return this.view(next, stores);
12455
11919
  }
12456
- /** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
12457
- versionRecord(installed, bundle, kind, from) {
11920
+ /**
11921
+ * The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). `store`
11922
+ * is the store its updates come from (`InstalledApp.store`); a newer version waiting is kept only while that is
11923
+ * unchanged, since it was found under the other rule.
11924
+ */
11925
+ versionRecord(installed, bundle, kind, from, store) {
12458
11926
  const { manifest, digest } = bundle;
12459
11927
  const now = this.now();
12460
- const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
11928
+ const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence && installed.store === store ? installed.pending : void 0;
12461
11929
  return {
12462
11930
  ref: appRef(manifest.publisher, manifest.name),
12463
11931
  publisher: manifest.publisher,
@@ -12468,6 +11936,7 @@ var Apps = class {
12468
11936
  manifest,
12469
11937
  bytes: kind,
12470
11938
  from,
11939
+ ...store !== void 0 && { store },
12471
11940
  installedAt: installed?.installedAt ?? now,
12472
11941
  updatedAt: now,
12473
11942
  ...installed?.revocations?.length && { revocations: installed.revocations },
@@ -12488,7 +11957,8 @@ var Apps = class {
12488
11957
  fail("needs-files", "The update's files are gone; it is fetched again at the next check");
12489
11958
  }
12490
11959
  const stores = await this.stores();
12491
- const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), stores, installed.revocations);
11960
+ if (installed.store !== void 0 && this.pinnedListing(installed, stores)?.digest !== pending.digest) fail("unlisted", "The store no longer lists this update");
11961
+ const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), this.storeViews(stores), installed.revocations);
12492
11962
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12493
11963
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12494
11964
  const plan = planAppUpdate(installed, {
@@ -12497,15 +11967,20 @@ var Apps = class {
12497
11967
  });
12498
11968
  if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
12499
11969
  const { pending: _p, ...without } = installed;
12500
- const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
11970
+ const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from, installed.store);
12501
11971
  await this.putApp(next);
12502
11972
  await this.removeBundle(installed.bytes, installed.digest);
12503
11973
  return this.view(next, stores);
12504
11974
  }
11975
+ /** An app's listing in the store its updates come from (`InstalledApp.store`), while that store is the person's. */
11976
+ pinnedListing(app, stores) {
11977
+ return app.store === void 0 ? void 0 : stores.find((s) => s.key === app.store)?.index?.apps.find((a) => a.ref === app.ref);
11978
+ }
12505
11979
  /**
12506
11980
  * The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
12507
- * app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
12508
- * revocations read beside its sources. An update that keeps or drops permissions installs by itself; one that adds
11981
+ * app's newer versions looked for in the stores' listings and its manifest's `sources` (for an app installed from a
11982
+ * store, only in that store's listing, at the digest it lists), and its publisher's revocations read beside its
11983
+ * sources. An update that keeps or drops permissions installs by itself; one that adds
12509
11984
  * some waits for the person. One check at a time.
12510
11985
  */
12511
11986
  checkUpdates() {
@@ -12531,21 +12006,65 @@ var Apps = class {
12531
12006
  if (!first) continue;
12532
12007
  let app = first;
12533
12008
  let outcome = "none";
12009
+ let wrote;
12010
+ if (app.store !== void 0 && !stores.some((s) => s.key === app.store)) {
12011
+ const { store: _s, ...unpinned } = app;
12012
+ app = unpinned;
12013
+ }
12014
+ if (app.store !== void 0 && app.pending && stores.find((s) => s.key === app.store)?.index && this.pinnedListing(app, stores)?.digest !== app.pending.digest) {
12015
+ await this.removeBundle(app.pending.bytes, app.pending.digest);
12016
+ const { pending: _p, ...rest } = app;
12017
+ app = rest;
12018
+ }
12534
12019
  const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
12535
12020
  const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
12536
- const candidates = [];
12021
+ const pinned = app.store;
12022
+ const candidates = /* @__PURE__ */ new Map();
12023
+ const newer = (v) => v.ref === app.ref && (v.sequence > highest || v.sequence >= app.sequence && !known.has(v.digest));
12537
12024
  for (const s of stores) {
12025
+ if (pinned !== void 0 && s.key !== pinned) continue;
12538
12026
  const listing = s.index?.apps.find((a) => a.ref === app.ref);
12539
- if (listing && (listing.sequence > highest || listing.sequence >= app.sequence && !known.has(listing.digest))) candidates.push(...listing.urls.filter(isAppFetchUrl));
12027
+ if (!listing || !newer(listing)) continue;
12028
+ const seen = `${s.digest} ${listing.digest}`;
12029
+ for (const url of listingUrls(listing.urls)) {
12030
+ const missed = `${s.key} ${app.ref} ${url}`;
12031
+ if (candidates.has(url) || s.digest !== void 0 && this.listingMisses.get(missed) === seen) continue;
12032
+ candidates.set(url, {
12033
+ ...pinned !== void 0 && { digest: listing.digest },
12034
+ peek: true,
12035
+ ...s.digest !== void 0 && { miss: [missed, seen] }
12036
+ });
12037
+ }
12540
12038
  }
12541
- for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
12542
- const peek = peekAppManifest(await this.host.fetch(url, {
12543
- maxBytes: PEEK_BYTES,
12544
- peek: true
12545
- }));
12546
- if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
12547
- } catch {}
12548
- for (const url of new Set(candidates)) {
12039
+ if (pinned === void 0) for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) {
12040
+ if (candidates.has(url)) continue;
12041
+ try {
12042
+ const peek = peekAppManifest(await this.host.fetch(url, {
12043
+ maxBytes: PEEK_BYTES,
12044
+ peek: true
12045
+ }));
12046
+ if (peek && newer(peek)) candidates.set(url, {});
12047
+ } catch {}
12048
+ }
12049
+ const revokeRead = /* @__PURE__ */ new Set();
12050
+ const before = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
12051
+ app = withRevocations(app, before ?? []);
12052
+ for (const [url, want] of candidates) {
12053
+ if (want.peek) {
12054
+ let peek;
12055
+ try {
12056
+ peek = peekAppManifest(await this.host.fetch(url, {
12057
+ maxBytes: PEEK_BYTES,
12058
+ peek: true
12059
+ }));
12060
+ } catch {
12061
+ continue;
12062
+ }
12063
+ if (!peek || !newer(peek) || want.digest !== void 0 && peek.digest !== want.digest) {
12064
+ if (want.miss) this.listingMisses.set(...want.miss);
12065
+ continue;
12066
+ }
12067
+ }
12549
12068
  let fetched;
12550
12069
  try {
12551
12070
  fetched = await this.fetchBundle(url);
@@ -12555,6 +12074,7 @@ var Apps = class {
12555
12074
  const { bundle, bytes } = fetched;
12556
12075
  const version = versionOf(bundle.manifest, bundle.digest);
12557
12076
  if (version.ref !== app.ref || known.has(version.digest)) continue;
12077
+ if (want.digest !== void 0 && version.digest !== want.digest) continue;
12558
12078
  if (app.pending && version.sequence === app.pending.sequence) {
12559
12079
  app = {
12560
12080
  ...app,
@@ -12585,17 +12105,21 @@ var Apps = class {
12585
12105
  }
12586
12106
  continue;
12587
12107
  }
12588
- if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
12108
+ if (this.runStatus(version, this.storeViews(stores), app.revocations).status !== "ok") continue;
12589
12109
  if (app.pending && version.sequence < app.pending.sequence) continue;
12590
12110
  const kind = await this.writeBundle(version.digest, bytes);
12111
+ wrote = {
12112
+ bytes: kind,
12113
+ digest: version.digest
12114
+ };
12591
12115
  if (plan.action === "install") {
12592
12116
  const old = app;
12593
- app = this.versionRecord(app, bundle, kind, url);
12594
- if (old.pending && old.pending.sequence <= version.sequence) {
12595
- await this.removeBundle(old.pending.bytes, old.pending.digest);
12596
- delete app.pending;
12597
- }
12598
- await this.putApp(app);
12117
+ const next = this.versionRecord(app, bundle, kind, url, app.store);
12118
+ const passed = old.pending && old.pending.sequence <= version.sequence ? old.pending : void 0;
12119
+ if (passed) delete next.pending;
12120
+ if (!await this.putAppIfInstalled(next, first.installedAt)) break;
12121
+ app = next;
12122
+ if (passed) await this.removeBundle(passed.bytes, passed.digest);
12599
12123
  await this.removeBundle(old.bytes, old.digest);
12600
12124
  outcome = "updated";
12601
12125
  } else {
@@ -12616,16 +12140,24 @@ var Apps = class {
12616
12140
  }
12617
12141
  break;
12618
12142
  }
12619
- const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
12620
- const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
12621
- for (const r of revocations) merged.set(canonicalJson(r), r);
12622
- if (!await this.app(app.ref)) continue;
12143
+ const after = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
12144
+ const revocations = before || after ? [...before ?? [], ...after ?? []] : null;
12145
+ const versions = [app, ...app.pending ? [versionOf(app.pending.manifest, app.pending.digest)] : []];
12146
+ const stays = (app.revocations ?? []).filter((r) => !revocations || versions.some((v) => appRevocationFor(v, [r])));
12147
+ const merged = new Map([...stays, ...revocations ?? []].map((r) => [canonicalJson(r), r]));
12148
+ if ((await this.app(app.ref))?.store === void 0 && app.store !== void 0) {
12149
+ const { store: _s, ...unpinned } = app;
12150
+ app = unpinned;
12151
+ }
12623
12152
  app = {
12624
12153
  ...app,
12625
12154
  ...merged.size && { revocations: [...merged.values()] },
12626
12155
  checkedAt: this.now()
12627
12156
  };
12628
- await this.putApp(app);
12157
+ if (!await this.putAppIfInstalled(app, first.installedAt)) {
12158
+ if (wrote) await this.removeUnusedBundle(app.ref, wrote.bytes, wrote.digest);
12159
+ continue;
12160
+ }
12629
12161
  out.push({
12630
12162
  ref: app.ref,
12631
12163
  outcome,
@@ -12638,15 +12170,19 @@ var Apps = class {
12638
12170
  * Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
12639
12171
  * carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
12640
12172
  * taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
12641
- * then its `sources`. Nothing answers: it stays `needs-files`, and it never runs unchecked.
12173
+ * then its `sources` (for an app installed from a store: only that store's listing, never its sources). Nothing
12174
+ * answers: it stays `needs-files`, and it never runs unchecked.
12642
12175
  */
12643
12176
  async fetchFiles({ ref }) {
12644
12177
  const app = await this.installedOrFail(ref);
12645
12178
  const stores = await this.stores();
12646
12179
  if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
12647
12180
  const urls = [app.from];
12648
- for (const s of stores) for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listing.urls);
12649
- urls.push(...app.manifest.sources ?? []);
12181
+ for (const s of stores) {
12182
+ if (app.store !== void 0 && s.key !== app.store) continue;
12183
+ for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listingUrls(listing.urls));
12184
+ }
12185
+ if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
12650
12186
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
12651
12187
  let fetched;
12652
12188
  try {
@@ -12674,7 +12210,7 @@ var Apps = class {
12674
12210
  }
12675
12211
  async runCheckOf(app, stores) {
12676
12212
  if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
12677
- return this.runStatus(app, stores, app.revocations);
12213
+ return this.runStatus(app, this.storeViews(stores), app.revocations);
12678
12214
  }
12679
12215
  /** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
12680
12216
  async runCheck({ ref }) {
@@ -12710,6 +12246,9 @@ var Apps = class {
12710
12246
  /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
12711
12247
  async file({ ref, path }) {
12712
12248
  const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
12249
+ const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
12250
+ if (icon) return icon.slice();
12251
+ if (icon === null) fail("no-file", "No such file in this app");
12713
12252
  const bundle = await this.verifiedBundle(app);
12714
12253
  if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12715
12254
  return bundle.files.get(path).slice();
@@ -12762,7 +12301,11 @@ var Apps = class {
12762
12301
  const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
12763
12302
  tx.objectStore(STORES$1.apps).delete(app.ref);
12764
12303
  tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
12765
- await done(tx);
12304
+ try {
12305
+ await done(tx);
12306
+ } finally {
12307
+ this.held.delete(app.ref);
12308
+ }
12766
12309
  for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
12767
12310
  await this.removeBundle(app.bytes, app.digest);
12768
12311
  if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
@@ -12826,7 +12369,12 @@ var Apps = class {
12826
12369
  name
12827
12370
  ])))?.size ?? 0) + size;
12828
12371
  if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
12829
- await wrap((await store(STORES$1.appStorage, "readwrite")).put({
12372
+ const writing = await store(STORES$1.appStorage, "readwrite");
12373
+ if (where !== "alone" && !this.host.isChat(where)) {
12374
+ this.usage.delete(`${app.ref}\0${where}`);
12375
+ fail("bad-scope", "Not a chat of this profile");
12376
+ }
12377
+ await wrap(writing.put({
12830
12378
  ref: app.ref,
12831
12379
  scope: where,
12832
12380
  key: name,
@@ -12871,7 +12419,12 @@ var Apps = class {
12871
12419
  scope: row.scope,
12872
12420
  entries: {}
12873
12421
  };
12874
- out.entries[row.key] = JSON.parse(row.value);
12422
+ Object.defineProperty(out.entries, row.key, {
12423
+ value: JSON.parse(row.value),
12424
+ enumerable: true,
12425
+ writable: true,
12426
+ configurable: true
12427
+ });
12875
12428
  scopes.set(row.scope, out);
12876
12429
  }
12877
12430
  return [...scopes.values()];
@@ -13197,7 +12750,26 @@ function parseDeviceRecord(value) {
13197
12750
  if (!o || typeof o !== "object" || !bytes(o.d) || !bytes(o.tombstone) || !bytes(o.packet) || !(o.at === null || count(o.at, Number.MAX_SAFE_INTEGER))) return bad("ownSet");
13198
12751
  if (!Array.isArray(o.sources) || o.sources.length > 16 || !o.sources.every(text)) return bad("ownSet");
13199
12752
  }
13200
- return value;
12753
+ return cleanNames(value);
12754
+ }
12755
+ /**
12756
+ * The record with every device name as `turnName` makes one: a name is another device's own word, and a build that did
12757
+ * not clean names may have stored it. The same record when there is nothing to clean.
12758
+ */
12759
+ function cleanNames(record) {
12760
+ const dirty = (entry) => !!entry && turnName(entry.name) !== entry.name;
12761
+ if (!record.deviceSet.some(dirty) && !record.unfinishedGrants?.some(dirty)) return record;
12762
+ return {
12763
+ ...record,
12764
+ deviceSet: record.deviceSet.map((slot) => slot && {
12765
+ ...slot,
12766
+ name: turnName(slot.name)
12767
+ }),
12768
+ ...record.unfinishedGrants ? { unfinishedGrants: record.unfinishedGrants.map((grant) => ({
12769
+ ...grant,
12770
+ name: turnName(grant.name)
12771
+ })) } : {}
12772
+ };
13201
12773
  }
13202
12774
  function checked(record) {
13203
12775
  return parseDeviceRecord(record);
@@ -14982,6 +14554,8 @@ async function openTurnKeeper(profile, network, options = {}) {
14982
14554
  signer: (bytes) => identity.key.sign(bytes)
14983
14555
  });
14984
14556
  }
14557
+ /** A joiner with no session yet puts its packet out again this often: another device on the same code may have replaced it. */
14558
+ var ENROLL_REPUT_MS = 5e3;
14985
14559
  /** How long, and how often, the joiner reads the turn record for the record that lists it. */
14986
14560
  var ENROLL_FINISH = {
14987
14561
  rounds: 20,
@@ -15146,6 +14720,8 @@ var EnrollInviter = class extends Enrollment {
15146
14720
  stopDoneWait = null;
15147
14721
  /** A joiner was seen on the link: from then it has as long as a joiner waits for the proof to open a session. */
15148
14722
  seen = false;
14723
+ /** The person answered the digits: one answer is taken, before the record is read or written for it. */
14724
+ answered = false;
15149
14725
  constructor(options) {
15150
14726
  super(options, {
15151
14727
  role: "inviter",
@@ -15260,7 +14836,8 @@ var EnrollInviter = class extends Enrollment {
15260
14836
  * and nothing was written.
15261
14837
  */
15262
14838
  async confirm(match) {
15263
- if (this.over || this.view.step !== "confirm" || !this.joiner) throw new Error("There are no digits to confirm");
14839
+ if (this.over || this.answered || this.view.step !== "confirm" || !this.joiner) throw new Error("There are no digits to confirm");
14840
+ this.answered = true;
15264
14841
  if (!match) {
15265
14842
  await this.fail("digits", "digits", this.joiner.name);
15266
14843
  return this.view;
@@ -15692,6 +15269,11 @@ function ghostLinkEnrollChannel(options) {
15692
15269
  };
15693
15270
  let link = null;
15694
15271
  const rtc = !!options.createPeerConnection;
15272
+ const intervals = options.pollIntervals ?? DHT_POLL_INTERVALS;
15273
+ let offered = false;
15274
+ const reput = pinned && rtc ? setInterval(() => {
15275
+ if (!opened) link?.republish();
15276
+ }, ENROLL_REPUT_MS) : null;
15695
15277
  const tryOpen = () => {
15696
15278
  if (opened || !link?.supportsDevice("enroll/1")) return;
15697
15279
  const hash = link.sessionTranscriptHash, key = credentials.peerKey;
@@ -15714,7 +15296,10 @@ function ghostLinkEnrollChannel(options) {
15714
15296
  native: { automatic: true },
15715
15297
  packetTransports: true,
15716
15298
  transport: options.transport,
15717
- pollIntervals: options.pollIntervals,
15299
+ pollIntervals: {
15300
+ ...intervals,
15301
+ connected: intervals.active
15302
+ },
15718
15303
  autoConnect: true,
15719
15304
  createPeerConnection: () => {
15720
15305
  if (!options.createPeerConnection) throw new ReferenceError("RTCPeerConnection is not defined");
@@ -15746,7 +15331,11 @@ function ghostLinkEnrollChannel(options) {
15746
15331
  },
15747
15332
  onPeerKeyRefused: () => events.onPeerRefused(),
15748
15333
  onPresence: (presence) => {
15749
- if (presence.online) events.onPeerSeen?.();
15334
+ if (!presence.online) return;
15335
+ events.onPeerSeen?.();
15336
+ if (!pinned || !rtc || offered || opened) return;
15337
+ offered = true;
15338
+ link?.connect().catch(() => {});
15750
15339
  },
15751
15340
  onPacketTransports: (transports) => {
15752
15341
  if (!transports.includes("webrtc/1")) startNative();
@@ -15778,6 +15367,7 @@ function ghostLinkEnrollChannel(options) {
15778
15367
  stop: async () => {
15779
15368
  const running = link;
15780
15369
  link = null;
15370
+ if (reput) clearInterval(reput);
15781
15371
  await running?.stop().catch(() => {});
15782
15372
  }
15783
15373
  };
@@ -18736,7 +18326,7 @@ async function room() {
18736
18326
  async function handoffSelf(profileHost) {
18737
18327
  return {
18738
18328
  app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
18739
- db: 14,
18329
+ db: 15,
18740
18330
  pins: { ...WALLET_SDK_PINS },
18741
18331
  kind: profileHost.kind,
18742
18332
  room: await room(),
@@ -20779,6 +20369,7 @@ var Outbox = class {
20779
20369
  return this.turn(async () => {});
20780
20370
  }
20781
20371
  async requeueUnconfirmed() {
20372
+ if (!this.receipts.size) return;
20782
20373
  const messages = await this.store.read();
20783
20374
  for (const id of [...this.receipts.keys()]) {
20784
20375
  if (messages.find((m) => m.id === id)?.via === "pkarr") {
@@ -20898,6 +20489,24 @@ var days = (ms) => {
20898
20489
  };
20899
20490
  //#endregion
20900
20491
  //#region ../browser/src/engine/edits.ts
20492
+ /**
20493
+ * My edits on their way to the contact (WISP 400 § Edits), one chat's worth. What goes is always the latest version of
20494
+ * a message, read from its row: an edit made while an earlier one waits replaces it, so a bot rewriting a status never
20495
+ * queues old versions. It keeps the outbox's resend policy (the window, the attempts, the backoff) in a queue of its
20496
+ * own, since the outbox sends rows and an edited row was delivered long ago.
20497
+ *
20498
+ * An edit goes on a live session where both sides offer edit/1, or on the DHT floor to a contact whose app takes edits
20499
+ * there, at most `EDIT_SEND_LIMIT` per window; the rest wait for the window, a confirmation, or the next session. A
20500
+ * message that never left yet (`waiting`) carries its latest text when it goes, and its edit follows it.
20501
+ */
20502
+ /**
20503
+ * A card that goes again live (`restore`, WISP 405 § Cards that went as text) goes at most once per this long: one a
20504
+ * reconnect owes many go in a trickle, not a burst. Two edit slots of the pace (10 in 10 s), so cards going again
20505
+ * take at most half of it and the author's live updates keep the rest; the CLI paces a card's own updates alike (2.5 s).
20506
+ */
20507
+ var CARD_RESTORE_GAP_MS = 2 * EDIT_RATE_WINDOW_MS / 10;
20508
+ /** What makes a card the same card across messages: a new message may reuse a card's id, and the newest stands for it. */
20509
+ var cardKey = (message) => message.card && `${message.card.kind}\n${message.card.id}`;
20901
20510
  var EditQueue = class {
20902
20511
  deps;
20903
20512
  pace;
@@ -20905,6 +20514,13 @@ var EditQueue = class {
20905
20514
  now;
20906
20515
  /** Per message: the edit number last sent, when, and how many times that number went. */
20907
20516
  sent = /* @__PURE__ */ new Map();
20517
+ /** When a card that goes again last went (`CARD_RESTORE_GAP_MS`). */
20518
+ restoredAt = -Infinity;
20519
+ /** The rows known to have an edit on its way, each with when it was last told (`edited`), by `tick`. */
20520
+ known = /* @__PURE__ */ new Map();
20521
+ tick = 0;
20522
+ /** The chat's history was read for the rows with an edit on its way: `known` has them all, until the next session. */
20523
+ learnt = false;
20908
20524
  timer;
20909
20525
  flushing = null;
20910
20526
  again = false;
@@ -20947,17 +20563,48 @@ var EditQueue = class {
20947
20563
  */
20948
20564
  reopened() {
20949
20565
  this.sent.clear();
20566
+ this.learnt = false;
20567
+ }
20568
+ /** This row has an edit to send (it was just written so): the next look reads it. */
20569
+ edited(id) {
20570
+ this.known.set(id, ++this.tick);
20571
+ }
20572
+ /**
20573
+ * My rows with an edit on its way, by time. `all`: the chat's whole history, when it was read for them (no `row` to
20574
+ * read one by, or nothing learnt yet in this session). Otherwise the known rows are read alone, and one that waits
20575
+ * no more is forgotten, unless it was told again while it was read.
20576
+ */
20577
+ async pending() {
20578
+ const waits = (m) => m.sender === "me" && !!m.edit?.pending;
20579
+ const row = this.deps.row;
20580
+ if (!row || !this.learnt) {
20581
+ const all = await this.deps.read(), rows = all.filter(waits);
20582
+ for (const m of rows) if (!this.known.has(m.id)) this.known.set(m.id, this.tick);
20583
+ this.learnt = true;
20584
+ return {
20585
+ rows,
20586
+ all
20587
+ };
20588
+ }
20589
+ const asOf = this.tick, ids = [...this.known.keys()];
20590
+ const read = await Promise.all(ids.map((id) => row(id)));
20591
+ const rows = [];
20592
+ read.forEach((m, i) => {
20593
+ if (m && waits(m)) rows.push(m);
20594
+ else if ((this.known.get(ids[i]) ?? 0) <= asOf) this.known.delete(ids[i]);
20595
+ });
20596
+ return { rows: rows.sort((a, b) => a.timestamp - b.timestamp) };
20950
20597
  }
20951
20598
  /** The contact confirmed edit `seq` of my message with this wire id. */
20952
20599
  async received(wireId, seq) {
20953
- const row = (await this.deps.read()).find((m) => m.sender === "me" && m.wireId === wireId);
20600
+ const row = (await this.pending()).rows.find((m) => m.wireId === wireId);
20954
20601
  if (!row?.edit?.pending || row.edit.seq > seq) return;
20955
20602
  this.sent.delete(row.id);
20956
20603
  await this.deps.settle(row.id, row.edit.seq);
20957
20604
  }
20958
20605
  /** The contact confirmed, on the DHT floor, the edit that went there under this id (`dhtEditId`). */
20959
20606
  async receivedOnDht(id) {
20960
- const row = (await this.deps.read()).find((m) => m.sender === "me" && m.edit?.pending && m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20607
+ const row = (await this.pending()).rows.find((m) => m.wireId && dhtEditId(m.wireId, m.edit.seq) === id);
20961
20608
  if (!row) return;
20962
20609
  this.sent.delete(row.id);
20963
20610
  await this.deps.settle(row.id, row.edit.seq);
@@ -20971,7 +20618,14 @@ var EditQueue = class {
20971
20618
  if (this.stopped) return;
20972
20619
  const now = this.now();
20973
20620
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20974
- const due = (await this.deps.read()).filter((m) => m.sender === "me" && m.edit?.pending && m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20621
+ const { rows, all } = await this.pending();
20622
+ const due = rows.filter((m) => m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20623
+ const newest = /* @__PURE__ */ new Map();
20624
+ if (due.some((m) => m.edit.restore)) for (const m of all ?? await this.deps.read()) {
20625
+ const key = m.sender === "me" && cardKey(m);
20626
+ if (key && m.timestamp > (newest.get(key) ?? -Infinity)) newest.set(key, m.timestamp);
20627
+ }
20628
+ const gap = this.deps.restoreGapMs ?? CARD_RESTORE_GAP_MS;
20975
20629
  let next = Infinity;
20976
20630
  for (const message of due) {
20977
20631
  const edit = message.edit;
@@ -20980,6 +20634,11 @@ var EditQueue = class {
20980
20634
  await this.deps.settle(message.id, edit.seq);
20981
20635
  continue;
20982
20636
  }
20637
+ if (edit.restore && message.timestamp < (newest.get(cardKey(message) || "") ?? -Infinity)) {
20638
+ this.sent.delete(message.id);
20639
+ await this.deps.settle(message.id, edit.seq);
20640
+ continue;
20641
+ }
20983
20642
  if (message.delivery === "waiting") continue;
20984
20643
  if (!this.deps.ready()) return;
20985
20644
  const last = this.sent.get(message.id);
@@ -20992,6 +20651,10 @@ var EditQueue = class {
20992
20651
  continue;
20993
20652
  }
20994
20653
  }
20654
+ if (edit.restore && this.restoredAt + gap > now) {
20655
+ next = Math.min(next, this.restoredAt + gap);
20656
+ continue;
20657
+ }
20995
20658
  const pace = this.pace.wait();
20996
20659
  if (pace > 0) {
20997
20660
  next = Math.min(next, now + pace);
@@ -21007,6 +20670,7 @@ var EditQueue = class {
21007
20670
  ...message.preview && { pv: message.preview },
21008
20671
  ...message.card && { sc: message.card }
21009
20672
  }, message)) continue;
20673
+ if (edit.restore) this.restoredAt = now;
21010
20674
  this.sent.set(message.id, {
21011
20675
  seq: edit.seq,
21012
20676
  at: now,
@@ -21616,18 +21280,35 @@ var db = {
21616
21280
  },
21617
21281
  async deleteLink(linkId) {
21618
21282
  await wrap((await store(STORES$1.links, "readwrite")).delete(linkId));
21619
- const messages = await store(STORES$1.messages, "readwrite");
21620
- const keys = await wrap(messages.index("byLink").getAllKeys(linkId));
21621
- await Promise.all(keys.map((key) => wrap(messages.delete(key))));
21283
+ await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([linkId], [linkId, []])));
21622
21284
  await fileStore.deleteForLink(linkId);
21623
21285
  await removeFileBytes(`${linkId}-in-`);
21624
21286
  await removeFileBytes(`${linkId}-out-`);
21625
21287
  const appStorage = await store(STORES$1.appStorage, "readwrite");
21626
- const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
21627
- await Promise.all(appKeys.map((key) => wrap(appStorage.delete(key))));
21288
+ await new Promise((resolve, reject) => {
21289
+ appStorage.transaction.oncomplete = () => resolve();
21290
+ appStorage.transaction.onerror = appStorage.transaction.onabort = () => reject(appStorage.transaction.error ?? /* @__PURE__ */ new Error("App storage failed"));
21291
+ const request = appStorage.index(APP_STORAGE_CHAT_INDEX).openKeyCursor(IDBKeyRange.only(linkId));
21292
+ request.onsuccess = () => {
21293
+ const cursor = request.result;
21294
+ if (!cursor) return;
21295
+ const [ref] = cursor.primaryKey;
21296
+ appStorage.delete(IDBKeyRange.bound([ref, linkId], [
21297
+ ref,
21298
+ linkId,
21299
+ []
21300
+ ]));
21301
+ cursor.continue();
21302
+ };
21303
+ });
21628
21304
  },
21305
+ /**
21306
+ * A chat's whole history, by time. Read by its key range (every key is `[linkId, id]`, ids are strings: from `[linkId]`
21307
+ * to `[linkId, []]`), the same rows in the same order as the `byLink` index has them, without looking each one up
21308
+ * again: in the CLI's store (fake-indexeddb) that lookup cost 4-9 times the read itself.
21309
+ */
21629
21310
  async getMessages(linkId) {
21630
- return (await wrap((await store(STORES$1.messages, "readonly")).index("byLink").getAll(linkId))).sort((a, b) => a.timestamp - b.timestamp);
21311
+ return (await wrap((await store(STORES$1.messages, "readonly")).getAll(IDBKeyRange.bound([linkId], [linkId, []])))).sort((a, b) => a.timestamp - b.timestamp);
21631
21312
  },
21632
21313
  /** Whether the chat has a message with this id. */
21633
21314
  async hasMessage(linkId, id) {
@@ -21746,9 +21427,8 @@ var db = {
21746
21427
  /** The group and its history. */
21747
21428
  async deleteGroup(groupId) {
21748
21429
  await wrap((await store(STORES$1.groups, "readwrite")).delete(groupId));
21749
- const messages = await store(STORES$1.messages, "readwrite");
21750
- const keys = await wrap(messages.index("byLink").getAllKeys(`group:${groupId}`));
21751
- await Promise.all(keys.map((key) => wrap(messages.delete(key))));
21430
+ const link = `group:${groupId}`;
21431
+ await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([link], [link, []])));
21752
21432
  },
21753
21433
  async getServices() {
21754
21434
  return wrap((await store(STORES$1.services, "readonly")).getAll());
@@ -21823,10 +21503,16 @@ function noteAfterChange(current, message) {
21823
21503
  snippet
21824
21504
  };
21825
21505
  }
21506
+ /** The note as its message's row has it now, when that reaction still stands there (not replaced, not taken back). */
21507
+ function noteIfStanding(current, message) {
21508
+ const r = message.reactions?.[current.by];
21509
+ return r?.e && r.e === current.emoji && r.at === current.at ? note(message, current.by, r.e, r.at) : void 0;
21510
+ }
21826
21511
  /**
21827
- * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`, of
21828
- * this membership only. One made before I was removed and invited again went out under my old member key; said again
21829
- * now it would go under the new one, and every member would show it twice.
21512
+ * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`,
21513
+ * newest first (what the member is likeliest to have missed lands before its pace runs out), of this membership only.
21514
+ * One made before I was removed and invited again went out under my old member key; said again now it would go under
21515
+ * the new one, and every member would show it twice.
21830
21516
  */
21831
21517
  function groupReactionsToResend(messages, limit) {
21832
21518
  let joinedAt = 0;
@@ -21838,23 +21524,27 @@ function groupReactionsToResend(messages, limit) {
21838
21524
  e: r.e,
21839
21525
  n: r.n
21840
21526
  }] : [];
21841
- }).sort((a, b) => b.n - a.n).slice(0, limit).reverse();
21527
+ }).sort((a, b) => b.n - a.n).slice(0, limit);
21842
21528
  }
21843
- /** This side's highest number in a chat: the next one goes past it (and past what still waits to be confirmed). */
21844
- function lastMine(messages, pending = []) {
21529
+ /** This side's highest number in a chat's history: the next one goes past it. */
21530
+ function lastMine(messages) {
21845
21531
  let last = 0;
21846
21532
  for (const m of messages) last = Math.max(last, m.reactions?.me?.n ?? 0);
21847
- for (const p of pending) last = Math.max(last, p.n);
21848
21533
  return last;
21849
21534
  }
21850
21535
  var Reactions = class {
21851
21536
  host;
21852
- /** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came. */
21537
+ /** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came (`buffer` per sender). */
21853
21538
  waiting = /* @__PURE__ */ new Map();
21854
21539
  /** One chat's reactions at a time: a burst for one message lands in order. */
21855
21540
  queue = Promise.resolve();
21856
21541
  /** A group member's reactions taken per window (chat and member key), as a paired chat's session limits them. */
21857
21542
  memberPace = /* @__PURE__ */ new Map();
21543
+ /**
21544
+ * This side's highest number per chat: read from its history the first time I react in it, then each number given
21545
+ * out. It only has to grow, so a row deleted since leaves it too high and nothing is wrong.
21546
+ */
21547
+ last = /* @__PURE__ */ new Map();
21858
21548
  constructor(host) {
21859
21549
  this.host = host;
21860
21550
  }
@@ -21876,17 +21566,19 @@ var Reactions = class {
21876
21566
  if (typeof messageId !== "string" || !messageId) return { error: "No message to react to" };
21877
21567
  const e = emoji === "" ? "" : reactionEmoji(emoji);
21878
21568
  if (e === null) return { error: "A reaction is one emoji" };
21879
- const messages = await this.host.messages(chat), group = isGroup$1(chat);
21880
- const target = messages.find((m) => m.id === messageId) ?? reactionTarget(messages, messageId, group);
21881
- const id = target && replyRef(target, group);
21569
+ const target = await this.named(chat, messageId);
21570
+ const id = target && replyRef(target, isGroup$1(chat));
21882
21571
  if (!target || !id) return { error: "That message is not in this chat, or takes no reaction" };
21883
21572
  const current = target.reactions?.me;
21884
21573
  if (!e && !current?.e) return { error: "No reaction of yours to take back" };
21574
+ let last = this.last.get(chat) ?? lastMine(await this.host.messages(chat));
21575
+ for (const p of pending) last = Math.max(last, p.n);
21885
21576
  const reaction = {
21886
21577
  id,
21887
21578
  e,
21888
- n: nextReactionNumber(lastMine(messages, pending), this.now())
21579
+ n: nextReactionNumber(last, this.now())
21889
21580
  };
21581
+ this.last.set(chat, reaction.n);
21890
21582
  return {
21891
21583
  reaction,
21892
21584
  message: await this.write(chat, target, "me", reaction) ?? target
@@ -21895,7 +21587,7 @@ var Reactions = class {
21895
21587
  }
21896
21588
  /**
21897
21589
  * A reaction that came from `by` (`peer`, or a member's key), already authenticated as theirs. A message not here
21898
- * yet keeps it for a minute; past the room for that, it is dropped (and, on a 1:1 session, not confirmed).
21590
+ * yet keeps it for five minutes; past the room for that sender, it is dropped (and, on a 1:1 session, not confirmed).
21899
21591
  */
21900
21592
  receive(chat, by, reaction) {
21901
21593
  if (isGroup$1(chat)) {
@@ -21922,27 +21614,35 @@ var Reactions = class {
21922
21614
  const now = this.now(), all = (this.waiting.get(message.linkId) ?? []).filter((w) => w.until > now);
21923
21615
  const mine = all.filter((w) => w.reaction.id === id).sort((a, b) => a.reaction.n - b.reaction.n);
21924
21616
  this.waiting.set(message.linkId, all.filter((w) => w.reaction.id !== id));
21925
- for (const { by, reaction } of mine) {
21926
- const target = reactionTarget(await this.host.messages(message.linkId), id, isGroup$1(message.linkId));
21927
- if (target && reactionIsNewer(target.reactions?.[by], reaction.n)) await this.write(message.linkId, target, by, reaction);
21928
- }
21617
+ for (const { by, reaction } of mine) await this.write(message.linkId, message, by, reaction);
21929
21618
  });
21930
21619
  }
21620
+ /** The row I react to: by its id here, as the pages name it, read alone; else by the id both sides know it by. */
21621
+ async named(chat, id) {
21622
+ if (this.host.message) return await this.host.message(chat, id) ?? this.find(chat, id);
21623
+ const messages = await this.host.messages(chat);
21624
+ return messages.find((m) => m.id === id) ?? reactionTarget(messages, id, isGroup$1(chat));
21625
+ }
21931
21626
  /**
21932
21627
  * The row a received reaction names. Most often the row of that id (a group's), or of `peer_` or `me_` and that id (a
21933
- * chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat.
21628
+ * chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat;
21629
+ * in a group there is no other, since a row that takes a reaction goes by its own id there.
21934
21630
  */
21935
21631
  async find(chat, id) {
21936
21632
  const group = isGroup$1(chat);
21937
- if (this.host.message) for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21938
- const row = await this.host.message(chat, rowId);
21939
- if (row && replyRef(row, group) === id) return row;
21633
+ if (this.host.message) {
21634
+ for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21635
+ const row = await this.host.message(chat, rowId);
21636
+ if (row && replyRef(row, group) === id) return row;
21637
+ }
21638
+ if (group) return void 0;
21940
21639
  }
21941
21640
  return reactionTarget(await this.host.messages(chat), id, group);
21942
21641
  }
21943
- /** A chat is gone: nothing waits for it any more. */
21642
+ /** A chat is gone: nothing waits for it any more, and my highest number in it is read again if it comes back. */
21944
21643
  forget(chat) {
21945
21644
  this.waiting.delete(chat);
21645
+ this.last.delete(chat);
21946
21646
  }
21947
21647
  wait(chat, by, reaction) {
21948
21648
  const now = this.now();
@@ -21951,7 +21651,7 @@ var Reactions = class {
21951
21651
  if (same && same.reaction.n >= reaction.n) return "stale";
21952
21652
  const kept = list.filter((w) => w !== same);
21953
21653
  this.waiting.set(chat, kept);
21954
- if (kept.length >= REACTION_LIMITS.buffer) return "dropped";
21654
+ if (kept.filter((w) => w.by === by).length >= REACTION_LIMITS.buffer) return "dropped";
21955
21655
  kept.push({
21956
21656
  by,
21957
21657
  reaction,
@@ -22117,11 +21817,14 @@ var HubClocks = class {
22117
21817
  };
22118
21818
  //#endregion
22119
21819
  //#region ../browser/src/engine/community.ts
22120
- /** The line a change of a group's picture leaves in its history (both profiles). */
22121
- var pictureText = (name, set) => `${name} ${set ? "changed" : "removed"} the group's picture`;
21820
+ /** The line a change of a group's picture leaves in its history (both profiles); without a name, nobody is credited. */
21821
+ var pictureText = (name, set) => name === void 0 ? `The group's picture was ${set ? "changed" : "removed"}` : `${name} ${set ? "changed" : "removed"} the group's picture`;
22122
21822
  /** The line a new name leaves (both profiles): `group` is the name the group has now. */
22123
- var renameText = (name, group) => `${name} renamed the group to “${group}”`;
22124
- /** The lines a statement that changed the group's name or picture leaves in its history, in that order. */
21823
+ var renameText = (name, group) => name === void 0 ? `The group is now called “${group}”` : `${name} renamed the group to “${group}”`;
21824
+ /**
21825
+ * The lines a statement that changed the group's name or picture leaves in its history, in that order. No `name` when
21826
+ * the core cannot say who made the change (a new admin signed again what the last one set).
21827
+ */
22125
21828
  function metaLines(name, change, group) {
22126
21829
  return [..."name" in change ? [{
22127
21830
  event: "renamed",
@@ -22511,7 +22214,8 @@ var Communities = class {
22511
22214
  await this.store.putGroup(group);
22512
22215
  this.host.emit();
22513
22216
  }
22514
- async send(groupId, text, mentions = [], reply, forwarded, card, file) {
22217
+ /** Whether a message of mine could go to the group now: the refusal `send` would answer for not being in it, or null. */
22218
+ sendCheck(groupId) {
22515
22219
  const live = this.live.get(groupId);
22516
22220
  if (!live) return {
22517
22221
  error: "You are not in this group yet",
@@ -22521,6 +22225,12 @@ var Communities = class {
22521
22225
  error: live.session.state.statusReason ?? "You are not in this group",
22522
22226
  refused: true
22523
22227
  };
22228
+ return { error: null };
22229
+ }
22230
+ async send(groupId, text, mentions = [], reply, forwarded, card, file) {
22231
+ const check = this.sendCheck(groupId);
22232
+ if (check.error) return check;
22233
+ const live = this.live.get(groupId);
22524
22234
  const now = this.now();
22525
22235
  const result = card || file ? await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded, card, file) : await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded);
22526
22236
  return "error" in result ? { error: result.error } : {
@@ -22650,6 +22360,7 @@ var Communities = class {
22650
22360
  this.knockRead.delete(groupId);
22651
22361
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
22652
22362
  await this.store.deleteGroup(groupId);
22363
+ await this.files?.drop(groupId);
22653
22364
  this.host.historyGone?.(groupId);
22654
22365
  this.host.emit();
22655
22366
  }
@@ -23403,6 +23114,16 @@ var Communities = class {
23403
23114
  }
23404
23115
  }
23405
23116
  /**
23117
+ * Let in: my knock leaves the record I knocked in. It stayed there until it aged out, and someone opening the link in
23118
+ * the next minute read it as a person still waiting to be let in. Once, in the background; if it fails, it ages out.
23119
+ */
23120
+ async unknock(link, n, seedB64) {
23121
+ const me = identityFromSeedB64(seedB64).pubKeyZ32, record = knockRecord(link, n);
23122
+ const knocks = readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true) ?? []);
23123
+ if (!knocks.some((k) => k.key === me)) return;
23124
+ await this.host.publish(knockIdentity(record), knockRecords(record, knocks.filter((k) => k.key !== me)), true);
23125
+ }
23126
+ /**
23406
23127
  * The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
23407
23128
  * link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
23408
23129
  * its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
@@ -23525,6 +23246,10 @@ var Communities = class {
23525
23246
  await this.store.putGroup(member);
23526
23247
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
23527
23248
  else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
23249
+ if (this.knocked.has(g)) this.unknock({
23250
+ g,
23251
+ host: joining.host
23252
+ }, this.knockAt.get(g) ?? KNOCK_BELL, joining.seedB64).catch(() => {});
23528
23253
  this.lastKnock.delete(g);
23529
23254
  this.knocked.delete(g);
23530
23255
  this.knockAt.delete(g);
@@ -23672,7 +23397,7 @@ var Communities = class {
23672
23397
  this.membershipChanged(id);
23673
23398
  },
23674
23399
  metaChanged: (by, change, at) => {
23675
- const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
23400
+ const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
23676
23401
  const when = receivedTimestamp(at, this.now());
23677
23402
  (async () => {
23678
23403
  for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
@@ -23869,7 +23594,8 @@ var MeshHubs = class {
23869
23594
  primed: false,
23870
23595
  gone: /* @__PURE__ */ new Map(),
23871
23596
  reactions: /* @__PURE__ */ new Map(),
23872
- need: 0
23597
+ need: 0,
23598
+ departed: /* @__PURE__ */ new Set()
23873
23599
  });
23874
23600
  }
23875
23601
  return live;
@@ -23921,10 +23647,36 @@ var MeshHubs = class {
23921
23647
  const live = this.live.get(groupId);
23922
23648
  if (!live || !this.large(session)) return [];
23923
23649
  const policy = session.hubPolicy, inRoster = (key) => rosterHas(session.roster, key);
23924
- const out = new Set(meshHubs(live.beacon, session.myKey, policy, inRoster, now).map((h) => h.key));
23925
- for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key)) out.add(key);
23650
+ const out = new Set(this.listed(live, session, now).map((h) => h.key));
23651
+ for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key) && !live.departed.has(key)) out.add(key);
23926
23652
  return [...out];
23927
23653
  }
23654
+ /** The beacon's hubs a member counts on (`meshHubs`), but those that said they leave. */
23655
+ listed(live, session, now) {
23656
+ return meshHubs(live.beacon, session.myKey, session.hubPolicy, (key) => rosterHas(session.roster, key), now).filter((h) => !live.departed.has(h.key));
23657
+ }
23658
+ /**
23659
+ * A member said on its own edge that it leaves: if it was a hub, it is none from now on, rather than until the beacon
23660
+ * forgets it and its grace runs out (the commit that takes it out may have no other way here). True when a hub went.
23661
+ */
23662
+ departs(groupId, key) {
23663
+ const live = this.live.get(groupId);
23664
+ if (!live || live.departed.has(key)) return false;
23665
+ live.departed.add(key);
23666
+ const was = live.seenHubs.delete(key) || live.myHubs.includes(key) || live.beacon.some((h) => h.key === key);
23667
+ live.reach.delete(key);
23668
+ live.hubsUp.delete(key);
23669
+ live.myHubs = live.myHubs.filter((k) => k !== key);
23670
+ return was;
23671
+ }
23672
+ /** As a hub that leaves the group: out of the beacon on my way, while the session can still write it. */
23673
+ async stepDown(groupId, session, now) {
23674
+ const live = this.live.get(groupId);
23675
+ if (!live?.hub) return;
23676
+ live.hub = false;
23677
+ live.members.clear();
23678
+ await this.publish(groupId, session, live, now, false).catch(() => {});
23679
+ }
23928
23680
  /** The group runs on hubs: it is large, and I am a hub or know of one. Else it is the full mesh. */
23929
23681
  active(groupId, session, now) {
23930
23682
  return this.large(session) && (this.isHub(groupId) || this.hubs(groupId, session, now).length > 0);
@@ -24021,7 +23773,7 @@ var MeshHubs = class {
24021
23773
  const may = mayBeHub(me, policy, inRoster, this.staysOnline);
24022
23774
  const readEvery = live.hub || may ? this.timings.beaconEveryMs : this.timings.beaconReadMs;
24023
23775
  if (!live.lastRead || now - live.lastRead >= readEvery) await this.read(groupId, session, live, now);
24024
- const others = meshHubs(live.beacon, me, policy, inRoster, now);
23776
+ const others = this.listed(live, session, now);
24025
23777
  const fits = this.fits(groupId, session);
24026
23778
  if (live.hub) live.need = session.others.length;
24027
23779
  if (!live.hub && may && fits && (policy.pin.includes(me) || others.length < MESH_HUBS.maxHubs)) {
@@ -24062,8 +23814,9 @@ var MeshHubs = class {
24062
23814
  this.noteHubs(live, session, now);
24063
23815
  }
24064
23816
  noteHubs(live, session, now) {
24065
- for (const h of meshHubs(live.beacon, session.myKey, session.hubPolicy, (key) => rosterHas(session.roster, key), now)) live.seenHubs.set(h.key, now);
23817
+ for (const h of this.listed(live, session, now)) live.seenHubs.set(h.key, now);
24066
23818
  for (const [key, at] of live.seenHubs) if (now - at > this.timings.graceMs || !rosterHas(session.roster, key)) live.seenHubs.delete(key);
23819
+ for (const key of live.departed) if (!rosterHas(session.roster, key)) live.departed.delete(key);
24067
23820
  }
24068
23821
  /** My entry in the beacon (or none, stepping down), merged with the other fresh hubs I do not drop. */
24069
23822
  async publish(groupId, session, live, now, listed) {
@@ -24148,7 +23901,7 @@ var MeshHubs = class {
24148
23901
  live.hubWaits.delete(key);
24149
23902
  }
24150
23903
  }
24151
- const fresh = meshHubs(live.beacon, me, session.hubPolicy, (key) => rosterHas(session.roster, key), now);
23904
+ const fresh = this.listed(live, session, now);
24152
23905
  const candidates = [...fresh, ...[...hubs].filter((key) => !fresh.some((h) => h.key === key)).map((key) => ({
24153
23906
  key,
24154
23907
  ts: 0,
@@ -24264,7 +24017,7 @@ var MeshHubs = class {
24264
24017
  if (!rosterHas(session.roster, from) || !Array.isArray(frame.k) || frame.k.length > session.roster.length) return false;
24265
24018
  const live = this.get(groupId, now);
24266
24019
  live.reach.set(from, new Set(frame.k.filter((key) => typeof key === "string" && rosterHas(session.roster, key))));
24267
- if (!session.hubPolicy.no.includes(from) && from !== session.myKey) live.seenHubs.set(from, now);
24020
+ if (!session.hubPolicy.no.includes(from) && from !== session.myKey && !live.departed.has(from)) live.seenHubs.set(from, now);
24268
24021
  if (!Array.isArray(frame.l) || frame.l.length > session.roster.length) return false;
24269
24022
  const legacy = new Set(group.legacy ?? []);
24270
24023
  let changed = false;
@@ -24744,6 +24497,22 @@ var ENTRY_RELEASE_MS = 2e3;
24744
24497
  var REMOVED_LINGER_MS = 15e3;
24745
24498
  /** How long the tombstone of a group I left waits for the admin to hear it. */
24746
24499
  var LEFT_KEPT_MS = 6048e5;
24500
+ /**
24501
+ * Invitee side: an accept the admin has not answered with a welcome is said again this often while the inviter's chat
24502
+ * is up, and at once when it comes up, at most `ACCEPT_AGAINS` times each time it does (its app may have restarted).
24503
+ */
24504
+ var ACCEPT_AGAIN_MS = 6e4;
24505
+ var ACCEPT_AGAINS = 5;
24506
+ /** Invitee side: the invitations one contact chat may have waiting here at a time. More are answered as declined. */
24507
+ var MAX_INVITATIONS_PER_CHAT = 4;
24508
+ /** 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. */
24509
+ function invitationClaims(frame) {
24510
+ const e = Number.isSafeInteger(frame.e) ? frame.e : 0, n = Number.isSafeInteger(frame.n) ? frame.n : 1;
24511
+ return {
24512
+ e: Math.max(e, 0),
24513
+ n: Math.min(Math.max(n, 1), GROUP_MEMBER_CAP.max)
24514
+ };
24515
+ }
24747
24516
  /** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
24748
24517
  var FAREWELL_ANSWERS = 3;
24749
24518
  /** Commits sent to a removed member that was behind when it left, at most. */
@@ -24805,8 +24574,10 @@ var Groups = class {
24805
24574
  timings;
24806
24575
  stored = /* @__PURE__ */ new Map();
24807
24576
  sessions = /* @__PURE__ */ new Map();
24808
- /** Contacts (chat ids) I invited to each group, until they answer or the app restarts. */
24577
+ /** Contacts (chat ids, kept in `StoredGroup.invited`) and entry sessions I invited to each group, until they answer. */
24809
24578
  invited = /* @__PURE__ */ new Map();
24579
+ /** Invitee side: when my accept of each invitation was last said, how many times since the inviter's chat came up, and whether it was down. */
24580
+ acceptSaid = /* @__PURE__ */ new Map();
24810
24581
  lastRoster = /* @__PURE__ */ new Map();
24811
24582
  /** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
24812
24583
  lastTold = /* @__PURE__ */ new Map();
@@ -24849,6 +24620,14 @@ var Groups = class {
24849
24620
  */
24850
24621
  relayAsked = /* @__PURE__ */ new Map();
24851
24622
  lastGossip = /* @__PURE__ */ new Map();
24623
+ /**
24624
+ * Per mesh group, each reachable member some of whose messages never reached me (`GroupSession.lacking`): what was
24625
+ * missing when it was asked for, and of whom since. Each connected member once until that changes, so messages nobody
24626
+ * holds any more are not asked for every gossip turn. `last`: the latest ask, its member and the numbers it named.
24627
+ */
24628
+ gapsAsked = /* @__PURE__ */ new Map();
24629
+ /** Per mesh group, when `gapsAgain` last looked: every half `MESH_GAP_AGAIN_MS`, not every tick. */
24630
+ gapsLooked = /* @__PURE__ */ new Map();
24852
24631
  /** Per mesh group: member key → since when its edge to me is down, and when someone last told me it is here. */
24853
24632
  downSince = /* @__PURE__ */ new Map();
24854
24633
  hereHeard = /* @__PURE__ */ new Map();
@@ -24917,12 +24696,14 @@ var Groups = class {
24917
24696
  this.communities.files = this.files;
24918
24697
  }
24919
24698
  }
24920
- async load() {
24699
+ /** `asItIs`: a copy that must stay as it was stored (limited mode, WISP 06), so nothing left over is cleared from it. */
24700
+ async load(asItIs = false) {
24921
24701
  const all = await this.store.getGroups();
24922
24702
  await this.communities.load(all.filter((g) => g.community || g.joining));
24923
24703
  for (const group of all.filter((g) => !g.community && !g.joining)) {
24924
24704
  this.stored.set(group.id, group);
24925
24705
  if (group.state) this.attach(group.state);
24706
+ if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
24926
24707
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
24927
24708
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
24928
24709
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
@@ -24934,6 +24715,7 @@ var Groups = class {
24934
24715
  }
24935
24716
  for (const id of this.sessions.keys()) this.reconcileEdges(id);
24936
24717
  await this.files?.load(all.map((g) => g.id));
24718
+ if (!asItIs) await this.files?.sweep(all.map((g) => g.id));
24937
24719
  for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
24938
24720
  }
24939
24721
  /** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
@@ -24989,7 +24771,7 @@ var Groups = class {
24989
24771
  linkId: invitation.linkId,
24990
24772
  contact: this.host.contactName(invitation.linkId) ?? "",
24991
24773
  admin: invitation.admin,
24992
- members: invitation.n,
24774
+ members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
24993
24775
  accepted: !!invitation.seedB64,
24994
24776
  ...invitation.entry ? {
24995
24777
  viaLink: true,
@@ -25130,8 +24912,21 @@ var Groups = class {
25130
24912
  let set = this.invited.get(groupId);
25131
24913
  if (!set) this.invited.set(groupId, set = /* @__PURE__ */ new Set());
25132
24914
  set.add(linkId);
24915
+ group.invited = {
24916
+ ...group.invited,
24917
+ [linkId]: Date.now()
24918
+ };
24919
+ await this.store.putGroup(group);
25133
24920
  this.host.emit();
25134
24921
  }
24922
+ /** Admin side: a contact chat answered its invitation (accepted or declined); false when it had none. */
24923
+ answered(group, linkId) {
24924
+ const had = !!this.invited.get(group.id)?.delete(linkId);
24925
+ if (!group.invited?.[linkId]) return had;
24926
+ const { [linkId]: _answered, ...rest } = group.invited;
24927
+ group.invited = Object.keys(rest).length ? rest : void 0;
24928
+ return true;
24929
+ }
25135
24930
  async accept(groupId) {
25136
24931
  const group = this.stored.get(groupId);
25137
24932
  if (!group?.invitation) throw new Error("No invitation to accept");
@@ -25148,6 +24943,10 @@ var Groups = class {
25148
24943
  g: groupId,
25149
24944
  key: identityFromSeedB64(seedB64).pubKeyZ32
25150
24945
  });
24946
+ this.acceptSaid.set(groupId, {
24947
+ at: this.now(),
24948
+ n: 0
24949
+ });
25151
24950
  this.host.emit();
25152
24951
  }
25153
24952
  async decline(groupId) {
@@ -25160,7 +24959,12 @@ var Groups = class {
25160
24959
  g: groupId
25161
24960
  });
25162
24961
  } catch {}
25163
- await this.forget(groupId);
24962
+ const previous = group.previous;
24963
+ if (!previous) return this.forget(groupId);
24964
+ this.stored.set(groupId, previous);
24965
+ if (previous.state) this.attach(previous.state);
24966
+ await this.store.putGroup(previous);
24967
+ this.host.emit();
25164
24968
  }
25165
24969
  /**
25166
24970
  * Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
@@ -25178,6 +24982,23 @@ var Groups = class {
25178
24982
  };
25179
24983
  }
25180
24984
  /**
24985
+ * Whether a file of mine could be announced in the group now, asked before its bytes are copied in: the error
24986
+ * `sendFile` would answer for the pace or for not being in the group, or null. `sendFile` checks again.
24987
+ */
24988
+ fileCheck(groupId) {
24989
+ if (!this.files) return {
24990
+ error: "This app takes no group files",
24991
+ refused: true
24992
+ };
24993
+ const paced = this.files.mayAnnounce(groupId);
24994
+ if (paced) return { error: paced };
24995
+ if (this.isCommunity(groupId)) return this.communities.sendCheck(groupId);
24996
+ const session = this.sessions.get(groupId);
24997
+ if (!session) return { error: "You are not in this group yet" };
24998
+ if (session.status !== "active") return { error: session.state.statusReason ?? "You are no longer in this group" };
24999
+ return { error: null };
25000
+ }
25001
+ /**
25181
25002
  * Announces a file or voice message of mine (WISP 503): `text` is its caption, or the line older apps show
25182
25003
  * (`groupFileFallback`), `file` its description with the digest of the bytes kept under `fileId`, which this device
25183
25004
  * then holds and serves. `messageId` is the announcement's.
@@ -25256,8 +25077,8 @@ var Groups = class {
25256
25077
  });
25257
25078
  }
25258
25079
  /**
25259
- * Leaves, and the group is gone from this device at once: its row, its history and every edge
25260
- * but the one to the admin, which stays until the admin's commit removing me comes back (or a
25080
+ * Leaves, and the group is gone from this device at once: its row, its history, its files and every
25081
+ * edge but the one to the admin, which stays until the admin's commit removing me comes back (or a
25261
25082
  * week passes), so a leave said while the admin was away still reaches it. An admin with other
25262
25083
  * members hands the role to one who is online first; alone, the group simply goes.
25263
25084
  */
@@ -25286,6 +25107,7 @@ var Groups = class {
25286
25107
  ...bye ? { bye } : {}
25287
25108
  };
25288
25109
  delete group.entry;
25110
+ delete group.invited;
25289
25111
  this.invited.delete(groupId);
25290
25112
  this.pendingEntries.delete(groupId);
25291
25113
  this.welcomed.delete(groupId);
@@ -25295,9 +25117,18 @@ var Groups = class {
25295
25117
  this.lastMentionAt.delete(groupId);
25296
25118
  await this.store.deleteGroup(groupId);
25297
25119
  await this.store.putGroup(group);
25120
+ await this.files?.drop(groupId);
25298
25121
  this.host.historyGone?.(groupId);
25299
25122
  for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
25300
25123
  this.host.emit();
25124
+ if (this.hubs.isHub(groupId)) {
25125
+ await this.hubs.stepDown(groupId, session, this.now());
25126
+ if (bye) {
25127
+ for (const [key, edge] of this.host.edges(groupId)) if (!hubs.includes(key) && this.host.linkReady(edge)) try {
25128
+ this.host.sendOnLink(edge, bye);
25129
+ } catch {}
25130
+ }
25131
+ }
25301
25132
  await session.leave();
25302
25133
  if (bye) for (const key of hubs) {
25303
25134
  const edge = this.host.edges(groupId).get(key);
@@ -25470,17 +25301,17 @@ var Groups = class {
25470
25301
  await this.session(groupId).rename(name);
25471
25302
  }
25472
25303
  async forget(groupId) {
25473
- if (this.isCommunity(groupId)) {
25474
- await this.communities.forget(groupId);
25475
- await this.files?.drop(groupId);
25476
- return;
25477
- }
25478
- if (this.sessions.get(groupId)?.status === "active") try {
25304
+ if (this.isCommunity(groupId)) return this.communities.leave(groupId);
25305
+ const session = this.sessions.get(groupId);
25306
+ if (session?.status === "active") try {
25479
25307
  await this.leave(groupId);
25480
- } catch {}
25308
+ } catch (e) {
25309
+ if (session.isAdmin && session.others.length) throw e;
25310
+ }
25481
25311
  this.sessions.delete(groupId);
25482
25312
  this.stored.delete(groupId);
25483
25313
  this.invited.delete(groupId);
25314
+ this.acceptSaid.delete(groupId);
25484
25315
  this.lastRoster.delete(groupId);
25485
25316
  this.lastTold.delete(groupId);
25486
25317
  this.pendingEntries.delete(groupId);
@@ -25494,6 +25325,8 @@ var Groups = class {
25494
25325
  this.hubs.forget(groupId);
25495
25326
  this.removedAt.delete(groupId);
25496
25327
  this.lastGossip.delete(groupId);
25328
+ this.gapsAsked.delete(groupId);
25329
+ this.gapsLooked.delete(groupId);
25497
25330
  this.typings.forget(groupId);
25498
25331
  this.downSince.delete(groupId);
25499
25332
  this.hereHeard.delete(groupId);
@@ -25557,6 +25390,8 @@ var Groups = class {
25557
25390
  const existing = this.stored.get(link.g);
25558
25391
  if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
25559
25392
  if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
25393
+ if (existing?.invitation && !existing.invitation.entry) throw new Error("You have an invitation to this group. Answer it first.");
25394
+ if (existing?.state && !existing.left) throw new Error("You are out of this group and its history is still here. Delete it from this device to join with a link.");
25560
25395
  if (existing) await this.forget(link.g);
25561
25396
  const seedB64 = dialedKey(link);
25562
25397
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
@@ -25596,6 +25431,10 @@ var Groups = class {
25596
25431
  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(() => {});
25597
25432
  continue;
25598
25433
  }
25434
+ if (group.invitation?.seedB64 && !group.state) {
25435
+ this.acceptAgain(group, now);
25436
+ continue;
25437
+ }
25599
25438
  const session = this.sessions.get(group.id);
25600
25439
  if (!group.entry || !session) continue;
25601
25440
  if (!session.isAdmin) {
@@ -25642,6 +25481,7 @@ var Groups = class {
25642
25481
  }
25643
25482
  for (const [groupId, session] of this.sessions) {
25644
25483
  const group = this.stored.get(groupId);
25484
+ if (session.status !== "active" && !this.removedAt.has(groupId) && this.hubs.isHub(groupId)) this.hubs.forget(groupId);
25645
25485
  if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
25646
25486
  await this.hubs.tick(groupId, session, group, now).catch(() => false);
25647
25487
  if (this.hubReconcile.has(groupId)) continue;
@@ -25653,6 +25493,39 @@ var Groups = class {
25653
25493
  }
25654
25494
  }
25655
25495
  }
25496
+ /**
25497
+ * Invitee side: my accept, not answered with a welcome yet, goes again over the inviter's chat (`ACCEPT_AGAIN_MS`):
25498
+ * the admin's app may have been closed or started again since, and an accept it never read waits for good otherwise.
25499
+ * An admin that let me in already, or never invited me, ignores it.
25500
+ */
25501
+ acceptAgain(group, now) {
25502
+ const invitation = group.invitation, said = this.acceptSaid.get(group.id) ?? {
25503
+ at: 0,
25504
+ n: 0
25505
+ };
25506
+ if (!this.host.linkReady(invitation.linkId)) {
25507
+ if (!said.down) this.acceptSaid.set(group.id, {
25508
+ ...said,
25509
+ n: 0,
25510
+ down: true
25511
+ });
25512
+ return;
25513
+ }
25514
+ if (said.n >= ACCEPT_AGAINS || !said.down && now - said.at < ACCEPT_AGAIN_MS) return;
25515
+ try {
25516
+ this.host.sendOnLink(invitation.linkId, {
25517
+ t: "group-accept",
25518
+ g: group.id,
25519
+ key: identityFromSeedB64(invitation.seedB64).pubKeyZ32
25520
+ });
25521
+ } catch {
25522
+ return;
25523
+ }
25524
+ this.acceptSaid.set(group.id, {
25525
+ at: now,
25526
+ n: said.n + 1
25527
+ });
25528
+ }
25656
25529
  /** An entry session came up with groups on both sides: the admin's side invites over it. */
25657
25530
  entryReady(groupId, linkId, peer) {
25658
25531
  if (this.isCommunity(groupId)) {
@@ -25823,8 +25696,7 @@ var Groups = class {
25823
25696
  name: groupName(frame.name) ?? "Group",
25824
25697
  admin: frame.admin,
25825
25698
  pieces: [],
25826
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25827
- n: Number.isSafeInteger(frame.n) ? frame.n : 1
25699
+ ...invitationClaims(frame)
25828
25700
  };
25829
25701
  await this.store.putGroup(existing);
25830
25702
  traceJoin(g, "invite.received");
@@ -25836,13 +25708,32 @@ var Groups = class {
25836
25708
  this.host.emit();
25837
25709
  return;
25838
25710
  }
25839
- if (existing?.state && existing.state.status === "active") return;
25840
- if (existing?.invitation?.seedB64) return;
25711
+ if (existing?.state && existing.state.status === "active") {
25712
+ if (this.sessions.get(g)?.admin === frame.admin) this.host.sendOnLink(linkId, {
25713
+ t: "group-decline",
25714
+ g
25715
+ });
25716
+ return;
25717
+ }
25718
+ if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
25719
+ if (existing?.left && (existing.left.admin !== frame.admin || existing.contacts?.[existing.left.admin] !== linkId)) return;
25720
+ if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis) return;
25841
25721
  if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
25722
+ if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
25723
+ try {
25724
+ this.host.sendOnLink(linkId, {
25725
+ t: "group-decline",
25726
+ g
25727
+ });
25728
+ } catch {}
25729
+ return;
25730
+ }
25842
25731
  const known = {
25843
25732
  ...existing?.formerNames,
25844
25733
  ...existing?.state?.nicks
25845
25734
  };
25735
+ const previous = existing?.state ? existing : existing?.previous;
25736
+ const genesis = existing?.state ? commitHash(existing.state.chain[0]) : existing?.invitation?.genesis;
25846
25737
  const invitation = {
25847
25738
  id: g,
25848
25739
  createdAt: Date.now(),
@@ -25850,17 +25741,19 @@ var Groups = class {
25850
25741
  name: groupName(frame.name) ?? "Group",
25851
25742
  admin: frame.admin,
25852
25743
  linkId,
25853
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25854
- n: Number.isSafeInteger(frame.n) ? frame.n : 1,
25855
- pieces: []
25744
+ ...invitationClaims(frame),
25745
+ pieces: [],
25746
+ ...genesis ? { genesis } : {}
25856
25747
  },
25857
- ...Object.keys(known).length ? { formerNames: known } : {}
25748
+ ...Object.keys(known).length ? { formerNames: known } : {},
25749
+ ...previous ? { previous } : {}
25858
25750
  };
25859
25751
  if (existing) {
25860
25752
  this.sessions.delete(g);
25861
25753
  for (const edge of this.host.edges(g).values()) await this.host.closeEdge(edge);
25862
25754
  }
25863
25755
  this.stored.set(g, invitation);
25756
+ this.acceptSaid.delete(g);
25864
25757
  await this.store.putGroup(invitation);
25865
25758
  this.host.emit();
25866
25759
  return;
@@ -25869,6 +25762,7 @@ var Groups = class {
25869
25762
  const session = this.sessions.get(g);
25870
25763
  if (!session?.isAdmin || !this.invited.get(g)?.has(linkId) || typeof frame.key !== "string" || !MEMBER_KEY.test(frame.key)) return;
25871
25764
  const group = this.stored.get(g);
25765
+ if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
25872
25766
  const entryPeer = this.hostEntry(g, linkId);
25873
25767
  if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
25874
25768
  if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
@@ -25880,8 +25774,8 @@ var Groups = class {
25880
25774
  ...group.contacts,
25881
25775
  [frame.key]: linkId
25882
25776
  };
25777
+ this.answered(group, linkId);
25883
25778
  await this.store.putGroup(group);
25884
- this.invited.get(g)?.delete(linkId);
25885
25779
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
25886
25780
  if (entryPeer) traceJoin(g, "welcome.sent");
25887
25781
  if (entryPeer) {
@@ -25905,7 +25799,10 @@ var Groups = class {
25905
25799
  this.pendingEntries.get(g)?.delete(entryPeer);
25906
25800
  await this.host.closeEdge(linkId);
25907
25801
  }
25908
- if (this.invited.get(g)?.delete(linkId)) this.host.emit();
25802
+ const group = this.stored.get(g);
25803
+ if (!group || !this.answered(group, linkId)) return;
25804
+ await this.store.putGroup(group);
25805
+ this.host.emit();
25909
25806
  return;
25910
25807
  }
25911
25808
  case "group-chain": {
@@ -25930,6 +25827,20 @@ var Groups = class {
25930
25827
  group.invitation.pieces = [];
25931
25828
  return;
25932
25829
  }
25830
+ if (group.invitation.genesis && commitHash(joined.state.chain[0]) !== group.invitation.genesis) {
25831
+ try {
25832
+ this.host.sendOnLink(linkId, {
25833
+ t: "group-decline",
25834
+ g
25835
+ });
25836
+ } catch {}
25837
+ delete group.invitation.seedB64;
25838
+ group.invitation.pieces = [];
25839
+ this.acceptSaid.delete(g);
25840
+ await this.store.putGroup(group);
25841
+ this.host.emit();
25842
+ return;
25843
+ }
25933
25844
  const viaLink = !!group.invitation.entry;
25934
25845
  this.meet(g, group.invitation.admin);
25935
25846
  const member = {
@@ -25939,6 +25850,7 @@ var Groups = class {
25939
25850
  contacts: viaLink ? {} : { [group.invitation.admin]: linkId }
25940
25851
  };
25941
25852
  this.stored.set(g, member);
25853
+ this.acceptSaid.delete(g);
25942
25854
  this.attach(joined.state);
25943
25855
  if (viaLink) this.lingering.set(g, {
25944
25856
  linkId,
@@ -26034,6 +25946,7 @@ var Groups = class {
26034
25946
  return;
26035
25947
  }
26036
25948
  if (!session) return;
25949
+ if (t === "group-bye" && session.status === "active" && frame.k === peerKey && this.hubs.departs(groupId, peerKey)) this.host.emit();
26037
25950
  if (t === "group-react" || t === "group-reacted") {
26038
25951
  await this.reaction(groupId, session, peerKey, frame);
26039
25952
  return;
@@ -26124,6 +26037,9 @@ var Groups = class {
26124
26037
  } catch {
26125
26038
  return;
26126
26039
  }
26040
+ if (peerKey === session.admin) try {
26041
+ for (const bye of session.byesHeld()) this.host.sendOnLink(linkId, bye);
26042
+ } catch {}
26127
26043
  const kept = this.lingering.get(groupId);
26128
26044
  if (kept?.admin === peerKey) {
26129
26045
  if (this.signals.keptEntry(groupId) === kept.linkId) setTimeout(() => {
@@ -26239,13 +26155,123 @@ var Groups = class {
26239
26155
  });
26240
26156
  out.push(key);
26241
26157
  }
26158
+ out.push(...this.gapsOf(groupId, session, via, session.lacking()));
26242
26159
  if (out.length) this.lastGossip.set(groupId, now);
26243
26160
  return out;
26244
26161
  }
26245
26162
  /**
26246
- * While some member is unreachable, one connected member (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it
26247
- * has from the unreachable ones: a message sent while the author could reach it and not me gets here within that,
26248
- * without either edge opening again. Edges the roster asks for and that do not exist are opened again.
26163
+ * Reachable members whose messages below the highest seen never reached me, for `via` to hand on: their own answer
26164
+ * comes from their last `GROUP_LIMITS.outlog` only (WISP 902 § Catch-up), the rest another member may hold. As
26165
+ * `askOf`, one member at a time, and each member once until what is missing changes.
26166
+ */
26167
+ gapsOf(groupId, session, via, lacking) {
26168
+ if (!lacking.size) return [];
26169
+ let asked = this.relayAsked.get(groupId), tried = this.gapsAsked.get(groupId);
26170
+ if (!asked) this.relayAsked.set(groupId, asked = /* @__PURE__ */ new Map());
26171
+ if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
26172
+ const edges = this.host.edges(groupId), now = this.now();
26173
+ const up = (key) => {
26174
+ const edge = edges.get(key);
26175
+ return !!edge && this.host.linkReady(edge);
26176
+ };
26177
+ for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26178
+ const out = [];
26179
+ for (const [key, mark] of lacking) {
26180
+ if (key === via || !up(key)) continue;
26181
+ const prior = asked.get(key);
26182
+ if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4) continue;
26183
+ let entry = tried.get(key);
26184
+ if (!entry || entry.mark !== mark) tried.set(key, entry = {
26185
+ mark,
26186
+ of: /* @__PURE__ */ new Set(),
26187
+ noticed: entry?.noticed,
26188
+ last: entry?.last
26189
+ });
26190
+ if (entry.of.has(via)) continue;
26191
+ entry.of.add(via);
26192
+ entry.last = {
26193
+ via,
26194
+ at: now,
26195
+ asked: session.missOf(key)
26196
+ };
26197
+ asked.set(key, {
26198
+ via,
26199
+ at: now
26200
+ });
26201
+ out.push(key);
26202
+ }
26203
+ return out;
26204
+ }
26205
+ /**
26206
+ * Gaps asked for sooner than the gossip turn: one noticed `MESH_GAP_AGAIN_MS` ago and not asked for yet (an author's
26207
+ * own answer, its last 32, showed what is below them) goes to a connected member now; and a member that handed on some
26208
+ * of what I last asked it for likely holds the rest, so it is asked for the next numbers `MESH_GAP_AGAIN_MS` after. A
26209
+ * member back after 200 messages of one author has them in about a minute, not 32 a gossip turn. One that handed
26210
+ * nothing on waits for the gossip turn, as before.
26211
+ */
26212
+ gapsAgain(groupId, session, now, connected) {
26213
+ if (now - (this.gapsLooked.get(groupId) ?? -Infinity) < 5e3) return;
26214
+ this.gapsLooked.set(groupId, now);
26215
+ const lacking = session.lacking();
26216
+ let tried = this.gapsAsked.get(groupId);
26217
+ if (!lacking.size && !tried?.size) return;
26218
+ if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
26219
+ for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26220
+ const edges = this.host.edges(groupId), again = /* @__PURE__ */ new Map(), fresh = /* @__PURE__ */ new Map();
26221
+ const up = (key) => {
26222
+ const edge = edges.get(key);
26223
+ return !!edge && this.host.linkReady(edge);
26224
+ };
26225
+ for (const [key, mark] of lacking) {
26226
+ let entry = tried.get(key);
26227
+ if (!entry) tried.set(key, entry = {
26228
+ mark,
26229
+ of: /* @__PURE__ */ new Set(),
26230
+ noticed: now
26231
+ });
26232
+ const { last } = entry;
26233
+ if (!up(key)) continue;
26234
+ if (!last) {
26235
+ if (now - (entry.noticed ?? now) >= 1e4) fresh.set(key, mark);
26236
+ continue;
26237
+ }
26238
+ if (now - last.at < 1e4 || !up(last.via)) continue;
26239
+ if (session.filledSince(key, last.asked)) again.set(last.via, [...again.get(last.via) ?? [], key]);
26240
+ }
26241
+ const asked = this.relayAsked.get(groupId);
26242
+ for (const [via, keys] of again) {
26243
+ for (const key of keys) {
26244
+ tried.set(key, {
26245
+ mark: lacking.get(key),
26246
+ of: /* @__PURE__ */ new Set([via]),
26247
+ last: {
26248
+ via,
26249
+ at: now,
26250
+ asked: session.missOf(key)
26251
+ }
26252
+ });
26253
+ asked?.set(key, {
26254
+ via,
26255
+ at: now
26256
+ });
26257
+ }
26258
+ try {
26259
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26260
+ } catch {}
26261
+ }
26262
+ const others = connected.filter((key) => !fresh.has(key)), pool = others.length ? others : connected;
26263
+ if (!fresh.size || !pool.length) return;
26264
+ const via = pool[(gossipStart(session.myKey) + this.gossipTurn++) % pool.length];
26265
+ const keys = this.gapsOf(groupId, session, via, fresh);
26266
+ if (keys.length) try {
26267
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26268
+ } catch {}
26269
+ }
26270
+ /**
26271
+ * While some member is unreachable, or some of a member's messages never reached me (`gapsOf`), one connected member
26272
+ * (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it has from them: a message sent while the author could reach
26273
+ * it and not me gets here within that, without either edge opening again. Edges the roster asks for and that do not
26274
+ * exist are opened again.
26249
26275
  */
26250
26276
  meshTick(now) {
26251
26277
  this.tickNow = now;
@@ -26261,10 +26287,13 @@ var Groups = class {
26261
26287
  }
26262
26288
  const away = this.unreachable(groupId, session);
26263
26289
  this.noteDown(groupId, session, away, now);
26290
+ const connected = session.others.filter((key) => !away.includes(key));
26291
+ this.gapsAgain(groupId, session, now, connected);
26264
26292
  if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
26265
26293
  const edges = this.host.edges(groupId);
26266
- const connected = session.others.filter((key) => !away.includes(key));
26267
- if (!away.length || !connected.length) continue;
26294
+ if (!connected.length) continue;
26295
+ const lacking = session.lacking();
26296
+ if (!away.length && !lacking.size) continue;
26268
26297
  this.lastGossip.set(groupId, now);
26269
26298
  const via = connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
26270
26299
  const asked = this.relayAsked.get(groupId) ?? /* @__PURE__ */ new Map();
@@ -26273,8 +26302,10 @@ var Groups = class {
26273
26302
  via,
26274
26303
  at: now
26275
26304
  });
26305
+ const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
26306
+ if (!ask.length) continue;
26276
26307
  try {
26277
- this.host.sendOnLink(edges.get(via), session.syncFrame(away));
26308
+ this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
26278
26309
  } catch {}
26279
26310
  }
26280
26311
  }
@@ -26419,7 +26450,7 @@ var Groups = class {
26419
26450
  }
26420
26451
  /** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
26421
26452
  async metaChanged(groupId, session, by, change, at) {
26422
- const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
26453
+ const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
26423
26454
  const when = at === void 0 ? Date.now() : receivedTimestamp(at);
26424
26455
  for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
26425
26456
  this.host.emit();
@@ -26842,6 +26873,14 @@ var GroupEdits = class {
26842
26873
  receivePace = /* @__PURE__ */ new Map();
26843
26874
  timers = /* @__PURE__ */ new Map();
26844
26875
  flushing = /* @__PURE__ */ new Map();
26876
+ /** Per group: my rows known to have an edit to say, each with when it was last told (`edit`), by `tick`. */
26877
+ known = /* @__PURE__ */ new Map();
26878
+ tick = 0;
26879
+ /**
26880
+ * The groups whose history was read for the rows with an edit to say: `known` has them all, and from then on those
26881
+ * rows alone are read (`host.message`). An edit costs what it does in a short group, however long this one is.
26882
+ */
26883
+ learnt = /* @__PURE__ */ new Set();
26845
26884
  stopped = false;
26846
26885
  constructor(host) {
26847
26886
  this.host = host;
@@ -26892,6 +26931,7 @@ var GroupEdits = class {
26892
26931
  mentions: mentions.length ? mentions : void 0
26893
26932
  };
26894
26933
  });
26934
+ if (this.host.message) this.told(groupId).set(messageId, ++this.tick);
26895
26935
  await this.host.changed(chat, messageId);
26896
26936
  await this.flush(groupId).catch(() => {});
26897
26937
  return {
@@ -26912,7 +26952,7 @@ var GroupEdits = class {
26912
26952
  this.timers.delete(groupId);
26913
26953
  if (this.stopped || !this.host.membership(groupId)) return;
26914
26954
  const chat = chatOf(groupId), now = this.now();
26915
- const due = (await this.host.messages(chat)).filter((m) => m.sender === "me" && m.edit?.pending).sort((a, b) => a.edit.at - b.edit.at);
26955
+ const due = (await this.pending(groupId)).sort((a, b) => a.edit.at - b.edit.at);
26916
26956
  let pace = this.sendPace.get(groupId);
26917
26957
  if (!pace) this.sendPace.set(groupId, pace = new RateWindow(10, EDIT_RATE_WINDOW_MS, this.now));
26918
26958
  let retry = Infinity;
@@ -26943,6 +26983,33 @@ var GroupEdits = class {
26943
26983
  this.flush(groupId).catch(() => {});
26944
26984
  }, Math.max(50, retry)));
26945
26985
  }
26986
+ /**
26987
+ * My rows of a group with an edit to say. The group's whole history is read for them once (always, with no
26988
+ * `host.message` to read one by); after that the known rows alone, and one that waits no more is forgotten, unless
26989
+ * it was told again while it was read.
26990
+ */
26991
+ async pending(groupId) {
26992
+ const waits = (m) => m.sender === "me" && !!m.edit?.pending;
26993
+ const chat = chatOf(groupId), read = this.host.message;
26994
+ if (!read || !this.learnt.has(groupId)) {
26995
+ const rows = (await this.host.messages(chat)).filter(waits);
26996
+ if (!read) return rows;
26997
+ for (const m of rows) if (!this.told(groupId).has(m.id)) this.told(groupId).set(m.id, this.tick);
26998
+ this.learnt.add(groupId);
26999
+ return rows;
27000
+ }
27001
+ const known = this.told(groupId), asOf = this.tick, ids = [...known.keys()];
27002
+ return (await Promise.all(ids.map((id) => read(chat, id)))).filter((m, i) => {
27003
+ if (m && waits(m)) return true;
27004
+ if ((known.get(ids[i]) ?? 0) <= asOf) known.delete(ids[i]);
27005
+ return false;
27006
+ });
27007
+ }
27008
+ told(groupId) {
27009
+ let known = this.known.get(groupId);
27010
+ if (!known) this.known.set(groupId, known = /* @__PURE__ */ new Map());
27011
+ return known;
27012
+ }
26946
27013
  async find(chat, id) {
26947
27014
  return this.host.message ? this.host.message(chat, id) : (await this.host.messages(chat)).find((m) => m.id === id);
26948
27015
  }
@@ -27014,7 +27081,8 @@ var GroupEdits = class {
27014
27081
  }
27015
27082
  /** An edge of a private group opened: the member at its other end hears my latest edits again, in case it missed them. */
27016
27083
  async resend(groupId, to) {
27017
- const mine = (await this.host.messages(chatOf(groupId))).filter((m) => m.sender === "me" && m.edit && !m.edit.pending && canEditInGroup(m)).sort((a, b) => b.edit.at - a.edit.at).slice(0, 16).reverse();
27084
+ const chat = chatOf(groupId);
27085
+ const mine = (await (this.host.history ? this.host.history(chat) : this.host.messages(chat))).filter((m) => m.sender === "me" && m.edit && !m.edit.pending && canEditInGroup(m)).sort((a, b) => b.edit.at - a.edit.at).slice(0, 16).reverse();
27018
27086
  for (const m of mine) await this.host.send(groupId, {
27019
27087
  id: m.id,
27020
27088
  e: m.edit.seq,
@@ -27027,6 +27095,8 @@ var GroupEdits = class {
27027
27095
  forget(groupId) {
27028
27096
  clearTimeout(this.timers.get(groupId));
27029
27097
  this.timers.delete(groupId);
27098
+ this.known.delete(groupId);
27099
+ this.learnt.delete(groupId);
27030
27100
  this.buffer.forget(chatOf(groupId));
27031
27101
  }
27032
27102
  stop() {
@@ -27154,7 +27224,8 @@ var CommunityPay = class {
27154
27224
  },
27155
27225
  memo: frame.memo,
27156
27226
  endpoints: frame.e,
27157
- ask: frame.a
27227
+ ask: frame.a,
27228
+ network: frame.n
27158
27229
  });
27159
27230
  else if (frame.t === "pay-ask") await this.host.onPaymentAsk(linkId, {
27160
27231
  id: frame.id,
@@ -27164,7 +27235,8 @@ var CommunityPay = class {
27164
27235
  asset: frame.u
27165
27236
  },
27166
27237
  method: frame.m,
27167
- memo: frame.memo
27238
+ memo: frame.memo,
27239
+ network: frame.n
27168
27240
  });
27169
27241
  else if (frame.t === "pay") await this.host.onPayment(linkId, {
27170
27242
  id: frame.id,
@@ -27208,7 +27280,8 @@ var CommunityPay = class {
27208
27280
  asset: frame.u
27209
27281
  },
27210
27282
  memo: frame.memo,
27211
- endpoints: frame.e
27283
+ endpoints: frame.e,
27284
+ network: frame.n
27212
27285
  });
27213
27286
  else if (frame.t === "pay-res" && frame.ok) await this.host.onPaymentResult(linkId, {
27214
27287
  id: frame.id,
@@ -27330,7 +27403,8 @@ var PairLink = class {
27330
27403
  u: r.amount.asset,
27331
27404
  memo: r.memo,
27332
27405
  e: r.endpoints,
27333
- a: r.ask
27406
+ a: r.ask,
27407
+ n: r.network
27334
27408
  });
27335
27409
  }
27336
27410
  async sendPaymentAsk(a) {
@@ -27342,7 +27416,8 @@ var PairLink = class {
27342
27416
  v: a.amount.value,
27343
27417
  u: a.amount.asset,
27344
27418
  m: a.method,
27345
- memo: a.memo
27419
+ memo: a.memo,
27420
+ n: a.network
27346
27421
  });
27347
27422
  }
27348
27423
  async sendPayment(p) {
@@ -27423,7 +27498,8 @@ var GroupLink = class {
27423
27498
  v: r.amount.value,
27424
27499
  u: r.amount.asset,
27425
27500
  memo: r.memo,
27426
- e: r.endpoints
27501
+ e: r.endpoints,
27502
+ n: r.network
27427
27503
  }));
27428
27504
  await this.host.sendApp(this.groupId, {
27429
27505
  ...frame,
@@ -28082,16 +28158,17 @@ var HoldEngine = class {
28082
28158
  }, held);
28083
28159
  } else if (header.kind === "file") {
28084
28160
  const meta = header.meta;
28085
- const voice = parseVoiceMeta(meta.voice, meta.mime);
28086
- const video = parseVideoMeta(meta.video, meta.mime);
28087
- const image = parseImageMeta(meta.image, meta.mime);
28161
+ const name = sanitizeFileName(meta.name), mime = sanitizeMime(meta.mime);
28162
+ const voice = parseVoiceMeta(meta.voice, mime);
28163
+ const video = parseVideoMeta(meta.video, mime);
28164
+ const image = parseImageMeta(meta.image, mime);
28088
28165
  const reply = readReply(meta.r, pairedReplyAuthor);
28089
28166
  const forwarded = readForwarded(meta.fw);
28090
28167
  const refused = await this.host.receiveFile(linkId, {
28091
28168
  wireId: header.id,
28092
- name: meta.name,
28169
+ name,
28093
28170
  size: meta.size,
28094
- mime: meta.mime,
28171
+ mime,
28095
28172
  timestamp: header.ts,
28096
28173
  ...voice && { voice },
28097
28174
  ...video && { video },
@@ -29491,7 +29568,10 @@ var CashuWallet = class CashuWallet {
29491
29568
  }
29492
29569
  /** How long `stop` waits for a swap in a per-mint lock. */
29493
29570
  static STOP_WAIT_MS = 3e4;
29494
- /** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
29571
+ /**
29572
+ * The Cashu wallet of one network: its mints, their balance, and the network's newest movements (every network's when
29573
+ * none is named) with the fees they paid. Only those movements are read, however long the history is.
29574
+ */
29495
29575
  async view(network) {
29496
29576
  const proofs = await this.allProofs();
29497
29577
  const mints = this.getMints(network).map((url) => ({
@@ -29500,8 +29580,7 @@ var CashuWallet = class CashuWallet {
29500
29580
  balance: total(proofs.filter((p) => p.mint === url && !p.reserved)),
29501
29581
  info: this.infos.get(url) ?? null
29502
29582
  }));
29503
- const all = await wrap((await store(STORES$1.walletTx, "readonly")).getAll());
29504
- all.sort((a, b) => b.timestamp - a.timestamp);
29583
+ const history = await newestWalletTx((mint) => !network || mintNetwork(mint) === network, HISTORY_SHOWN);
29505
29584
  const here = new Set(mints.map((m) => m.url));
29506
29585
  const swaps = (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
29507
29586
  const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
@@ -29513,8 +29592,8 @@ var CashuWallet = class CashuWallet {
29513
29592
  openSwaps: swaps.length,
29514
29593
  swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
29515
29594
  unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
29516
- history: all.slice(0, HISTORY_SHOWN),
29517
- feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
29595
+ history,
29596
+ feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0)
29518
29597
  };
29519
29598
  }
29520
29599
  /**
@@ -30078,7 +30157,7 @@ var CashuWallet = class CashuWallet {
30078
30157
  unit: metadata.unit,
30079
30158
  mint,
30080
30159
  memo: metadata.memo || void 0,
30081
- accepted: this.getKnownMints().includes(mint) || isTestMint(mint)
30160
+ accepted: metadata.unit === UNIT$1 && (this.getKnownMints().includes(mint) || isTestMint(mint))
30082
30161
  };
30083
30162
  }
30084
30163
  if (/^creq[AB]/i.test(value)) {
@@ -30101,14 +30180,16 @@ var CashuWallet = class CashuWallet {
30101
30180
  async receiveToken(token, kind = "ecash-in", note, { addTestMint = true, payment } = {}) {
30102
30181
  let mint;
30103
30182
  let faceValue;
30183
+ let unit;
30104
30184
  try {
30105
30185
  const metadata = getTokenMetadata(token);
30106
- if (metadata.unit !== UNIT$1) throw new Error("unit");
30186
+ unit = metadata.unit;
30107
30187
  mint = metadata.mint.replace(/\/+$/, "");
30108
30188
  faceValue = Number(metadata.amount);
30109
30189
  } catch {
30110
30190
  throw new Error("That is not a valid ecash token");
30111
30191
  }
30192
+ if (unit !== UNIT$1) throw engineError("ecashOtherUnit", { unit });
30112
30193
  if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
30113
30194
  if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
30114
30195
  return this.locked(mint, async () => {
@@ -30157,9 +30238,10 @@ var CashuWallet = class CashuWallet {
30157
30238
  }
30158
30239
  /**
30159
30240
  * Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
30160
- * no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
30161
- * stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
30162
- * Runs under the mint's lock.
30241
+ * no mint, as long as their token fits in a payment frame: a wallet of many small coins can add up exactly in
30242
+ * hundreds of them, a token the contact's app drops unread. Otherwise it is a swap, which sends a few coins: written
30243
+ * down, with its inputs reserved, before the mint is asked. The caller stores the result in one transaction: `inputs`
30244
+ * out, `keep` in, `send` where it goes, and `swap` deleted. Runs under the mint's lock.
30163
30245
  */
30164
30246
  async split(mint, amount, proofs) {
30165
30247
  const wallet = await this.wallet(mint);
@@ -30172,7 +30254,11 @@ var CashuWallet = class CashuWallet {
30172
30254
  includeFees: true,
30173
30255
  exactMatch: true
30174
30256
  });
30175
- if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()) return {
30257
+ if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber() && getEncodedToken({
30258
+ mint,
30259
+ proofs: send,
30260
+ unit: UNIT$1
30261
+ }).length <= LIMITS.maxPaymentEndpointChars) return {
30176
30262
  inputs: among(send),
30177
30263
  keep: [],
30178
30264
  send
@@ -30750,7 +30836,7 @@ var CashuWallet = class CashuWallet {
30750
30836
  let wallet = this.wallets.get(mint);
30751
30837
  if (!wallet) {
30752
30838
  wallet = (async () => {
30753
- const instance = new Wallet$1(mint, { unit: UNIT$1 });
30839
+ const instance = new Wallet(mint, { unit: UNIT$1 });
30754
30840
  await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
30755
30841
  return instance;
30756
30842
  })();
@@ -30799,6 +30885,9 @@ var CashuWallet = class CashuWallet {
30799
30885
  * taken back.
30800
30886
  */
30801
30887
  var UNIT = "sat";
30888
+ /** A contact's requests in one chat: the new ones taken per minute, and how many may wait to be paid. */
30889
+ var REQUESTS_PER_MINUTE = 20;
30890
+ var OPEN_REQUESTS = 50;
30802
30891
  var satsWord = (n) => `${n} sat${n === 1 ? "" : "s"}`;
30803
30892
  /**
30804
30893
  * A refused send's review, once its ecash came back: why, and exactly what came back. Taking ecash back is a swap at
@@ -30865,10 +30954,32 @@ var allows = (link, method) => method === "arkade" ? link.supportsArkPayments :
30865
30954
  var fedimintSats = (msats) => Math.floor(msats / 1e3);
30866
30955
  /** The distinct wallets of a per-network set (one object may stand for both). */
30867
30956
  var distinct = (each) => [...new Set(WALLET_NETWORKS.map((n) => each[n]))];
30957
+ /**
30958
+ * Every payment this device keeps, with the copy of them the pages get. The copy is made again only after a payment
30959
+ * changed: a state about anything else (typing, a file's progress) carries the one already made.
30960
+ */
30961
+ var PaymentBook = class extends Map {
30962
+ shown = null;
30963
+ set(id, payment) {
30964
+ this.shown = null;
30965
+ return super.set(id, payment);
30966
+ }
30967
+ delete(id) {
30968
+ this.shown = null;
30969
+ return super.delete(id);
30970
+ }
30971
+ clear() {
30972
+ this.shown = null;
30973
+ super.clear();
30974
+ }
30975
+ views() {
30976
+ return this.shown ??= Object.fromEntries([...this].map(([id, { token: _token, ...view }]) => [id, view]));
30977
+ }
30978
+ };
30868
30979
  var PaymentDesk = class {
30869
30980
  wallet;
30870
30981
  host;
30871
- payments = /* @__PURE__ */ new Map();
30982
+ payments = new PaymentBook();
30872
30983
  paying = /* @__PURE__ */ new Map();
30873
30984
  receiving = /* @__PURE__ */ new Map();
30874
30985
  reclaims = /* @__PURE__ */ new Map();
@@ -30930,8 +31041,9 @@ var PaymentDesk = class {
30930
31041
  records() {
30931
31042
  return [...this.payments.values()];
30932
31043
  }
31044
+ /** What the pages see of every payment. The same record until a payment changes: read it, never write to it. */
30933
31045
  views() {
30934
- return Object.fromEntries([...this.payments].map(([id, { token: _token, ...view }]) => [id, view]));
31046
+ return this.payments.views();
30935
31047
  }
30936
31048
  async forgetLink(linkId) {
30937
31049
  const paymentStore = await store(STORES$1.payments, "readwrite");
@@ -30940,6 +31052,7 @@ var PaymentDesk = class {
30940
31052
  this.payments.delete(payment.id);
30941
31053
  wrap(paymentStore.delete(payment.id));
30942
31054
  }
31055
+ this.requestsFrom.delete(linkId);
30943
31056
  }
30944
31057
  /** `confirmedReal`: the person confirmed a Mainnet send as real money; without it one is refused. */
30945
31058
  async send(params) {
@@ -31543,10 +31656,13 @@ var PaymentDesk = class {
31543
31656
  });
31544
31657
  return { askId };
31545
31658
  }
31546
- /** The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh. */
31547
- answering(linkId, request, method, amount) {
31659
+ /**
31660
+ * The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh, and the
31661
+ * network the request is of (what it says, else what it carries), never only what it says.
31662
+ */
31663
+ answering(linkId, request, method, amount, network) {
31548
31664
  const ask = request.ask ? this.asks.get(request.ask) : void 0;
31549
- if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() || request.network && request.network !== ask.network) return void 0;
31665
+ if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() || network !== ask.network) return void 0;
31550
31666
  this.asks.delete(request.ask);
31551
31667
  return request.ask;
31552
31668
  }
@@ -31591,9 +31707,24 @@ var PaymentDesk = class {
31591
31707
  endpoints: [...payment.invoice ? [[ENDPOINT.bolt11, payment.invoice]] : [], ...payment.mints?.length ? [[ENDPOINT.cashu, cashuRequestPayload(payment.mints)]] : []]
31592
31708
  };
31593
31709
  }
31710
+ /** New requests taken from each chat's contact lately. */
31711
+ requestsFrom = /* @__PURE__ */ new Map();
31712
+ /**
31713
+ * Whether this chat has room for one more request of its contact: twenty new ones a minute, and fifty waiting to be
31714
+ * paid at most. One left out is not lost: the contact's app sends its open requests again on every session.
31715
+ */
31716
+ roomForRequest(linkId) {
31717
+ let recent = this.requestsFrom.get(linkId);
31718
+ if (!recent) this.requestsFrom.set(linkId, recent = new RateWindow(REQUESTS_PER_MINUTE, 6e4));
31719
+ if (!recent.take()) return false;
31720
+ let open = 0;
31721
+ for (const p of this.payments.values()) if (p.linkId === linkId && p.kind === "request" && p.direction === "in" && p.state === "pending" && ++open >= OPEN_REQUESTS) return false;
31722
+ return true;
31723
+ }
31594
31724
  /** `held`: picked up from the contact's storage while it was away (WISP 404): only what this device allows counts, and only Cashu or Lightning. */
31595
31725
  async onPaymentRequest(linkId, request, held = false) {
31596
31726
  if (this.find(linkId, request.id)?.linkId === linkId) return;
31727
+ if (!this.roomForRequest(linkId)) return;
31597
31728
  if (held && (findEndpoint(request.endpoints, ENDPOINT.usdt) || findEndpoint(request.endpoints, ENDPOINT.arkade) || findEndpoint(request.endpoints, ENDPOINT.bark) || findEndpoint(request.endpoints, ENDPOINT.bitcoin) || findEndpoint(request.endpoints, ENDPOINT.fedimint) || findEndpoint(request.endpoints, ENDPOINT.spark))) return;
31598
31729
  if (findEndpoint(request.endpoints, ENDPOINT.usdt)) return this.receiveUsdtRequest(linkId, request);
31599
31730
  const amount = parseSats(request.amount.value, request.amount.asset);
@@ -31669,7 +31800,7 @@ var PaymentDesk = class {
31669
31800
  await this.save({
31670
31801
  target,
31671
31802
  federations,
31672
- ask: federations ? this.answering(linkId, request, "fedimint", amount) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount) : void 0,
31803
+ ask: federations ? this.answering(linkId, request, "fedimint", amount, network) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount, network) : void 0,
31673
31804
  id: this.keyFor(linkId, request.id),
31674
31805
  linkId,
31675
31806
  kind: "request",
@@ -32095,7 +32226,7 @@ var PaymentDesk = class {
32095
32226
  memo: request.memo,
32096
32227
  state: "pending",
32097
32228
  createdAt: request.timestamp,
32098
- ask: this.answering(linkId, request, "usdt", amount),
32229
+ ask: this.answering(linkId, request, "usdt", amount, network),
32099
32230
  network
32100
32231
  });
32101
32232
  await this.host.storeMessage({
@@ -32157,6 +32288,22 @@ var PaymentDesk = class {
32157
32288
  endpoint: [ENDPOINT.usdt, JSON.stringify({ txid: review.txid })]
32158
32289
  });
32159
32290
  }
32291
+ /**
32292
+ * One payment pays one request: a receipt for a request that already has one, under another payment id,
32293
+ * is not another payment. Nothing is stored or shown for it; a paid request is answered again.
32294
+ */
32295
+ repeatedReceipt(link, request, id) {
32296
+ let repeated = false;
32297
+ for (const p of this.payments.values()) if (p.id !== id && p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.linkId === request.linkId && p.state !== "failed") {
32298
+ repeated = true;
32299
+ break;
32300
+ }
32301
+ if (repeated && request.state === "settled") link?.sendPaymentResult({
32302
+ id: request.id,
32303
+ ok: true
32304
+ });
32305
+ return repeated;
32306
+ }
32160
32307
  async receiveUsdt(linkId, payment) {
32161
32308
  const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
32162
32309
  if (!this.host.getLink(linkId)?.supportsUsdtPayments || request?.target?.method !== "usdt" || request.linkId !== linkId || request.direction !== "out" || request.kind !== "request" || payment.amount.asset !== request.unit || payment.amount.value !== String(request.amount)) return;
@@ -32169,6 +32316,7 @@ var PaymentDesk = class {
32169
32316
  return;
32170
32317
  }
32171
32318
  if (typeof txid !== "string" || !/^0x[a-fA-F0-9]{64}$/.test(txid) || existing?.state === "settled") return;
32319
+ if (this.repeatedReceipt(this.host.getLink(linkId), request, payment.id)) return;
32172
32320
  txid = txid.toLowerCase();
32173
32321
  if ([...this.payments.values()].some((p) => p.txid?.toLowerCase() === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
32174
32322
  await this.save({
@@ -32336,6 +32484,7 @@ var PaymentDesk = class {
32336
32484
  }
32337
32485
  if ([...this.payments.values()].some((p) => p.txid === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
32338
32486
  if (typeof txid !== "string" || !/^[a-f0-9]{64}$/.test(txid) || existing?.state === "settled") return;
32487
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32339
32488
  await this.save({
32340
32489
  id: payment.id,
32341
32490
  linkId,
@@ -32453,6 +32602,7 @@ var PaymentDesk = class {
32453
32602
  const existing = this.payments.get(payment.id);
32454
32603
  if (existing && (existing.linkId !== linkId || existing.direction !== "in" || existing.requestId !== request.id)) return;
32455
32604
  if (existing || parseSats(payment.amount.value, payment.amount.asset) !== request.amount) return;
32605
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32456
32606
  await this.save({
32457
32607
  id: payment.id,
32458
32608
  linkId,
@@ -32589,6 +32739,7 @@ var PaymentDesk = class {
32589
32739
  return;
32590
32740
  }
32591
32741
  if (typeof hint !== "string" || !/^[0-9a-f]{64}$/.test(hint)) return;
32742
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32592
32743
  const settled = request.state === "settled";
32593
32744
  await this.save({
32594
32745
  id: payment.id,
@@ -32937,6 +33088,7 @@ var PaymentDesk = class {
32937
33088
  const existing = this.payments.get(payment.id);
32938
33089
  if (existing && (existing.linkId !== linkId || existing.direction !== "in" || existing.requestId !== request.id)) return;
32939
33090
  if (existing || parseSats(payment.amount.value, payment.amount.asset) !== request.amount) return;
33091
+ if (this.repeatedReceipt(link, request, payment.id)) return;
32940
33092
  const settled = request.state === "settled";
32941
33093
  await this.save({
32942
33094
  id: payment.id,
@@ -33060,6 +33212,20 @@ var PaymentDesk = class {
33060
33212
  });
33061
33213
  }
33062
33214
  /**
33215
+ * A contact's receipt that still waits for its money, dropped by hand (its chat line was deleted here): the
33216
+ * request it answers can take another receipt. Money that does arrive still pays the request.
33217
+ */
33218
+ async dropReceipt(id) {
33219
+ const payment = this.payments.get(id), request = payment?.requestId ? this.payments.get(payment.requestId) : void 0;
33220
+ if (payment?.kind !== "payment" || payment.direction !== "in" || payment.state !== "pending" || !payment.target) return;
33221
+ if (request?.kind !== "request" || request.direction !== "out" || request.linkId !== payment.linkId || request.target?.method !== payment.target.method) return;
33222
+ await this.save({
33223
+ ...payment,
33224
+ state: "failed",
33225
+ error: "Removed here"
33226
+ });
33227
+ }
33228
+ /**
33063
33229
  * A request of ours closed for good: the wallet it was paid through was removed. Nothing sends it again (replay,
33064
33230
  * a group edge opening), and the contact is told now when the chat is open (a request to a group: every member
33065
33231
  * reachable), so their app stops offering to pay it. `reason`: shown here; `told`: what the contact reads. Returns
@@ -33477,7 +33643,7 @@ function pushRelayProblem(url) {
33477
33643
  "localhost",
33478
33644
  "[::1]"
33479
33645
  ].includes(parsed.hostname)) return null;
33480
- return "Use an https:// relay address";
33646
+ return `Use an https:// relay address: ${url}`;
33481
33647
  }
33482
33648
  //#endregion
33483
33649
  //#region ../browser/src/engine/walletSetup.ts
@@ -33598,6 +33764,8 @@ var RELAY_RETRY_MS = 3e4;
33598
33764
  var NATIVE_SLOTS = 8;
33599
33765
  /** How often a group link that found no free native slot tries again. */
33600
33766
  var GROUP_NATIVE_RETRY_MS = 15e3;
33767
+ /** The longest a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
33768
+ var GROUP_NATIVE_FAILED_MAX_MS = 3e5;
33601
33769
  /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
33602
33770
  var CLEAR_WITHDRAW_MS = 6e3;
33603
33771
  /**
@@ -33628,6 +33796,16 @@ var CARD_RESTORE_MOST = 50;
33628
33796
  var CARD_RESTORE_MS = 6048e5;
33629
33797
  /** A reaction said on the live session and not confirmed is said again after this long. */
33630
33798
  var REACTION_RESEND_MS = 3e4;
33799
+ /** This side's participation key in a chat: derived from its seed once, not at every state built. */
33800
+ function participationKeyOf(live) {
33801
+ const seed = live.stored.participationSeed;
33802
+ if (!seed) return void 0;
33803
+ if (live.participationKey?.seed !== seed) live.participationKey = {
33804
+ seed,
33805
+ key: identityFromSeedB64(seed).pubKeyZ32
33806
+ };
33807
+ return live.participationKey.key;
33808
+ }
33631
33809
  function emptyLinkFiles() {
33632
33810
  return {
33633
33811
  receivedBytes: 0,
@@ -33833,6 +34011,11 @@ var GhostlyNode = class GhostlyNode {
33833
34011
  return Promise.all(this.nativeQueues.values()).then(() => {});
33834
34012
  }
33835
34013
  shuttingDown = false;
34014
+ /** Settles as `shutdown` begins: what is still starting is not waited for past it. */
34015
+ stopBegan;
34016
+ stopping = new Promise((resolve) => {
34017
+ this.stopBegan = () => resolve(null);
34018
+ });
33836
34019
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
33837
34020
  transfersRestored = false;
33838
34021
  feedbackStartedAt = Date.now();
@@ -33943,7 +34126,7 @@ var GhostlyNode = class GhostlyNode {
33943
34126
  cue: "downloaded",
33944
34127
  key: fileId
33945
34128
  }, this.chatOf(linkId));
33946
- this.noteFileEnd(linkId, fileId, record.state === "done" ? void 0 : record.error ?? record.state);
34129
+ this.noteFileEnd(linkId, fileId, record.state === "done" ? void 0 : record.error ?? record.state, !seen);
33947
34130
  }
33948
34131
  });
33949
34132
  stateTimer = null;
@@ -34125,6 +34308,7 @@ var GhostlyNode = class GhostlyNode {
34125
34308
  descriptors: () => this.providers().lightning,
34126
34309
  host: () => this.providerHost(network),
34127
34310
  hasMints: () => this.networkMints(network).length > 0,
34311
+ fetch: (...request) => (this.options.lnurlFetch ?? globalThis.fetch)(...request),
34128
34312
  events: {
34129
34313
  changed: () => void this.refreshWallet(),
34130
34314
  received: (op) => void this.desk.onLightningPaid(op),
@@ -34421,10 +34605,10 @@ var GhostlyNode = class GhostlyNode {
34421
34605
  } : void 0;
34422
34606
  },
34423
34607
  keys: (linkId) => {
34424
- const stored = this.links.get(linkId)?.stored;
34608
+ const live = this.links.get(linkId);
34425
34609
  return {
34426
- mine: stored?.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
34427
- theirs: stored?.pairedPeerKey
34610
+ mine: live && participationKeyOf(live),
34611
+ theirs: live?.stored.pairedPeerKey
34428
34612
  };
34429
34613
  },
34430
34614
  linkIds: () => [...this.links.keys()],
@@ -34463,9 +34647,9 @@ var GhostlyNode = class GhostlyNode {
34463
34647
  emit: () => this.emitState(),
34464
34648
  ownSubjects: () => this.identities.views().filter((p) => p.provider === "nostr").map((p) => p.subject),
34465
34649
  contactSubjects: (linkId) => {
34466
- const stored = this.links.get(linkId)?.stored;
34650
+ const live = this.links.get(linkId), stored = live?.stored;
34467
34651
  if (!stored?.profile || !stored.identities) return [];
34468
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34652
+ const mine = participationKeyOf(live);
34469
34653
  const t = Math.floor(Date.now() / 1e3);
34470
34654
  return [...new Set(stored.identities.received.filter((r) => r.binding.provider === "nostr" && receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified").map((r) => r.verified.subject))];
34471
34655
  },
@@ -34527,7 +34711,7 @@ var GhostlyNode = class GhostlyNode {
34527
34711
  for (const live of this.links.values()) {
34528
34712
  const stored = live.stored;
34529
34713
  if (!stored.profile || !stored.identities || stored.group) continue;
34530
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34714
+ const mine = participationKeyOf(live);
34531
34715
  for (const r of stored.identities.received) if (receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified") out.push({
34532
34716
  provider: r.binding.provider,
34533
34717
  subject: r.verified.subject,
@@ -34555,6 +34739,8 @@ var GhostlyNode = class GhostlyNode {
34555
34739
  * again those not confirmed after a while.
34556
34740
  */
34557
34741
  reactionNotes = /* @__PURE__ */ new Map();
34742
+ /** Each chat's notes shown before the one shown now, oldest first, the latest `REACTION_NOTES_KEPT` of this run. */
34743
+ reactionNotesBefore = /* @__PURE__ */ new Map();
34558
34744
  reactionsSent = /* @__PURE__ */ new Map();
34559
34745
  reactionPace = /* @__PURE__ */ new Map();
34560
34746
  reactionTimers = /* @__PURE__ */ new Map();
@@ -34576,12 +34762,33 @@ var GhostlyNode = class GhostlyNode {
34576
34762
  message: (chat, id) => db.getMessage(chat, id),
34577
34763
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34578
34764
  changed: async (chat, id, note) => {
34579
- if (note) this.reactionNotes.set(chat, note);
34765
+ const shown = this.reactionNotes.get(chat);
34766
+ if (note) {
34767
+ if (shown && (shown.message !== note.message || shown.by !== note.by)) this.reactionNotesBefore.set(chat, [...this.reactionNotesBefore.get(chat) ?? [], shown].slice(-8));
34768
+ this.reactionNotes.set(chat, note);
34769
+ } else if (shown?.message === id) {
34770
+ const before = await this.reactionNoteNow(chat, shown);
34771
+ if (before) this.reactionNotes.set(chat, before);
34772
+ else this.reactionNotes.delete(chat);
34773
+ }
34580
34774
  await this.messagesChanged(chat, [id]);
34581
34775
  this.emitState();
34582
34776
  },
34583
34777
  notify: (chat, key) => this.feedback("reaction", `${chat}:${key}`, chat)
34584
34778
  });
34779
+ /**
34780
+ * The chat list's reaction once one to its message was taken back: the same one if it still stands, else the latest
34781
+ * shown before it that does, each read from its own row. Only past those is the whole history read for an older one.
34782
+ */
34783
+ async reactionNoteNow(chat, shown) {
34784
+ const before = this.reactionNotesBefore.get(chat) ?? [];
34785
+ for (let note = shown; note; note = before.pop()) {
34786
+ const row = note.message ? await db.getMessage(chat, note.message) : void 0;
34787
+ const standing = row && noteIfStanding(note, row);
34788
+ if (standing) return standing;
34789
+ }
34790
+ return latestReaction(await db.getMessages(chat));
34791
+ }
34585
34792
  /** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
34586
34793
  groups = new Groups({
34587
34794
  sendOnLink: (linkId, frame) => {
@@ -34702,6 +34909,7 @@ var GhostlyNode = class GhostlyNode {
34702
34909
  */
34703
34910
  groupEdits = new GroupEdits({
34704
34911
  messages: (chat) => db.getMessages(chat),
34912
+ history: (chat) => this.edgeOpenHistory(chat),
34705
34913
  message: (chat, id) => db.getMessage(chat, id),
34706
34914
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34707
34915
  changed: (chat, id) => this.messagesChanged(chat, [id]),
@@ -34911,7 +35119,7 @@ var GhostlyNode = class GhostlyNode {
34911
35119
  const awaiting = await this.readAwaiting();
34912
35120
  for (const network of WALLET_NETWORKS) {
34913
35121
  const view = await this.wallet.view(network);
34914
- everything = view.history;
35122
+ everything = everything.concat(view.history);
34915
35123
  const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
34916
35124
  networks[network] = {
34917
35125
  mints: this.withWaits(view.mints, awaiting.mints),
@@ -35071,7 +35279,7 @@ var GhostlyNode = class GhostlyNode {
35071
35279
  }
35072
35280
  this.transfersRestored = true;
35073
35281
  for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
35074
- await this.groups.load();
35282
+ await this.groups.load(this.limitedMode);
35075
35283
  for (const group of this.groups.views()) {
35076
35284
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
35077
35285
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -35471,12 +35679,17 @@ var GhostlyNode = class GhostlyNode {
35471
35679
  async shutdown(options = {}) {
35472
35680
  const quiet = options.quiet === true || this.gatedOut;
35473
35681
  this.shuttingDown = true;
35682
+ this.stopBegan();
35474
35683
  if (this.limitedTimer) {
35475
35684
  clearTimeout(this.limitedTimer);
35476
35685
  this.limitedTimer = null;
35477
35686
  }
35478
35687
  this.appShelf?.stop();
35479
35688
  if (!quiet) this.depart();
35689
+ const linksStopped = Promise.allSettled([...this.links.values()].map(async (live) => {
35690
+ live.carried?.stop();
35691
+ await Promise.all([live.link?.stop(!quiet), live.caps?.stop()]);
35692
+ }));
35480
35693
  this.directPath.close();
35481
35694
  this.clock.close();
35482
35695
  this.clockOff?.();
@@ -35504,33 +35717,30 @@ var GhostlyNode = class GhostlyNode {
35504
35717
  this.activeTurnTimer = null;
35505
35718
  if (this.restoreTimer) clearTimeout(this.restoreTimer);
35506
35719
  this.restoreTimer = null;
35507
- await links?.stop().catch(() => {});
35508
35720
  this.identities.stop();
35509
35721
  this.did.stop();
35510
35722
  this.nostrSocial.stop();
35511
35723
  this.publicProfiles.stop();
35512
35724
  this.publicActivity.clear();
35513
- for (const network of WALLET_NETWORKS) {
35514
- await this.arkWallets[network].stop();
35515
- await this.barkWallets[network].stop();
35516
- await this.fedimintWallets[network].stop();
35517
- await this.sparkWallets[network].stop();
35518
- await this.usdtWallets[network].stop();
35519
- await this.lightnings[network].stop();
35520
- await this.bitcoins[network].stop();
35521
- }
35522
- await this.wallet.stop();
35523
- await this.nativeQueue;
35524
- await this.hold.stop();
35525
- await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
35526
35725
  for (const queue of this.editQueues.values()) queue.stop();
35527
35726
  this.groupEdits.stop();
35528
35727
  this.cardEdits.stop();
35529
- await Promise.allSettled([...this.links.values()].map(async (live) => {
35530
- live.carried?.stop();
35531
- await live.link?.stop(!quiet);
35532
- await live.caps?.stop();
35533
- }));
35728
+ await Promise.allSettled([
35729
+ linksStopped,
35730
+ links?.stop(),
35731
+ ...WALLET_NETWORKS.flatMap((network) => [
35732
+ this.arkWallets,
35733
+ this.barkWallets,
35734
+ this.fedimintWallets,
35735
+ this.sparkWallets,
35736
+ this.usdtWallets,
35737
+ this.lightnings,
35738
+ this.bitcoins
35739
+ ].map((wallets) => wallets[network].stop())),
35740
+ this.wallet.stop(),
35741
+ this.nativeQueue,
35742
+ Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop())).then(() => this.hold.stop())
35743
+ ]);
35534
35744
  }
35535
35745
  /**
35536
35746
  * "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
@@ -35551,7 +35761,7 @@ var GhostlyNode = class GhostlyNode {
35551
35761
  appShelf;
35552
35762
  /** The installed apps and stores, refused while the apps feature is off. */
35553
35763
  get appStore() {
35554
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
35764
+ if (!this.appsOn) throw this.appsOff();
35555
35765
  return this.appShelf ??= new Apps({
35556
35766
  fetch: boundedAppFetch({
35557
35767
  fetcher: this.options.appFetch,
@@ -35682,17 +35892,27 @@ var GhostlyNode = class GhostlyNode {
35682
35892
  ...this.settings.wakeMutedGroups?.includes(group.id) && { wakeMuted: true }
35683
35893
  };
35684
35894
  }
35895
+ /** Per community, the members a payment here names, as read from one copy of the payments (`PaymentDesk.views`). */
35896
+ communityPaid;
35685
35897
  /** Community members payments go to (or came from), as links the payment bubbles and the composer look up by key. */
35686
35898
  communityPayViews(groups) {
35687
- const withPayments = /* @__PURE__ */ new Map();
35688
- for (const p of Object.values(this.desk.views())) {
35689
- const at = parsePayLink(p.linkId);
35690
- if (at?.member) {
35691
- const set = withPayments.get(at.groupId) ?? /* @__PURE__ */ new Set();
35692
- set.add(at.member);
35693
- withPayments.set(at.groupId, set);
35899
+ const payments = this.desk.views();
35900
+ if (this.communityPaid?.of !== payments) {
35901
+ const members = /* @__PURE__ */ new Map();
35902
+ for (const p of Object.values(payments)) {
35903
+ const at = parsePayLink(p.linkId);
35904
+ if (at?.member) {
35905
+ const set = members.get(at.groupId) ?? /* @__PURE__ */ new Set();
35906
+ set.add(at.member);
35907
+ members.set(at.groupId, set);
35908
+ }
35694
35909
  }
35910
+ this.communityPaid = {
35911
+ of: payments,
35912
+ members
35913
+ };
35695
35914
  }
35915
+ const withPayments = this.communityPaid.members;
35696
35916
  return groups.filter((g) => g.profile === "community" && g.status === "active" && g.myKey).flatMap((g) => this.communityPay.views(g.id, g.myKey, g.members.map((m) => m.key), withPayments.get(g.id) ?? /* @__PURE__ */ new Set()));
35697
35917
  }
35698
35918
  /** The payment composer opened on a member of a community: ask what they take, if they did not say lately. */
@@ -35707,6 +35927,10 @@ var GhostlyNode = class GhostlyNode {
35707
35927
  getMessages(linkId) {
35708
35928
  return db.getMessages(linkId);
35709
35929
  }
35930
+ /** One message of a chat, read by its id: the whole history costs a read of every row. */
35931
+ getMessage(linkId, id) {
35932
+ return db.getMessage(linkId, id);
35933
+ }
35710
35934
  async messagePage({ linkId, limit = 50, before }) {
35711
35935
  if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit: a whole number of messages, at least 1");
35712
35936
  if (before === void 0) return db.getMessagePage(linkId, { limit });
@@ -36271,6 +36495,7 @@ var GhostlyNode = class GhostlyNode {
36271
36495
  this.newestAt.delete(linkId);
36272
36496
  this.placedAt.delete(linkId);
36273
36497
  this.reactionNotes.delete(linkId);
36498
+ this.reactionNotesBefore.delete(linkId);
36274
36499
  this.events.onMessages(linkId, []);
36275
36500
  }
36276
36501
  async setGroupWakeMuted(groupId, muted) {
@@ -36471,6 +36696,10 @@ var GhostlyNode = class GhostlyNode {
36471
36696
  this.transport.networkChanged?.();
36472
36697
  this.directPath.reset();
36473
36698
  }
36699
+ if (params.network) for (const failed of this.groupNativeFailed.values()) {
36700
+ failed.at = 0;
36701
+ failed.wait = 0;
36702
+ }
36474
36703
  for (const live of this.links.values()) live.link?.wake({ network: params.network });
36475
36704
  this.hold.wake();
36476
36705
  for (const network of WALLET_NETWORKS) {
@@ -36694,12 +36923,26 @@ var GhostlyNode = class GhostlyNode {
36694
36923
  /**
36695
36924
  * A received reply, or one sent before its original was here, against this chat's own history: the original found
36696
36925
  * here gives the line and the author (and it is then checked); one not found keeps what the wire said, unchecked.
36926
+ * The original is read alone where its id says its row (a group's message id, a chat's `peer_`/`me_` text): a reply
36927
+ * costs what a plain message does, however long the chat. A group's row goes by its own id only, so a miss there is
36928
+ * not here; a chat's other rows (a file's, a payment's) are looked for in the whole chat.
36697
36929
  */
36698
36930
  async resolveReply(message) {
36699
36931
  const reply = message.replyTo;
36700
36932
  if (!reply || reply.messageId) return message;
36701
- const history = await db.getMessages(message.linkId);
36702
- const original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
36933
+ const group = message.linkId.startsWith("group:");
36934
+ let original, history;
36935
+ for (const rowId of group ? [reply.id] : [`peer_${reply.id}`, `me_${reply.id}`]) {
36936
+ const row = rowId === message.id ? void 0 : await db.getMessage(message.linkId, rowId);
36937
+ if (row && replyRef(row, group) === reply.id) {
36938
+ original = row;
36939
+ break;
36940
+ }
36941
+ }
36942
+ if (!original && !group) {
36943
+ history = await db.getMessages(message.linkId);
36944
+ original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
36945
+ }
36703
36946
  if (!original) return message;
36704
36947
  const resolved = {
36705
36948
  ...message,
@@ -36709,7 +36952,7 @@ var GhostlyNode = class GhostlyNode {
36709
36952
  ...reply.button && { button: reply.button }
36710
36953
  }
36711
36954
  };
36712
- const press = buttonPress(resolved, original, history);
36955
+ const press = buttonPress(resolved, original, []) && buttonPress(resolved, original, history ?? await db.getMessages(message.linkId));
36713
36956
  return press ? {
36714
36957
  ...resolved,
36715
36958
  press
@@ -36800,7 +37043,9 @@ var GhostlyNode = class GhostlyNode {
36800
37043
  card: edited.card
36801
37044
  }));
36802
37045
  await this.messagesChanged(linkId, [message.id]);
36803
- this.editsFor(linkId).flush().catch(() => {});
37046
+ const edits = this.editsFor(linkId);
37047
+ edits.edited(message.id);
37048
+ edits.flush().catch(() => {});
36804
37049
  return {
36805
37050
  error: null,
36806
37051
  messageId: message.id
@@ -36875,19 +37120,27 @@ var GhostlyNode = class GhostlyNode {
36875
37120
  * message goes again as an edit of its card alone: the same text, so no version and no edit mark. Once per message (the
36876
37121
  * row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later edit of the
36877
37122
  * author's replaces it, the highest number winning). A contact whose app does not show the kind (it would drop the card
36878
- * and mark the text edited) gets none: the row waits for one that does. Bounded: the newest `CARD_RESTORE_MOST` such
36879
- * messages of the last `CARD_RESTORE_MS`; older ones are given up. The edit is marked `restore`: the bot's own event
36880
- * stream says nothing of it, while its number counts, so a later edit of the bot's takes the next one.
37123
+ * and mark the text edited) gets none: the row waits for one that does. Bounded: only the newest message of each card
37124
+ * (kind and id: a bot may send a task again as a new message), the newest `CARD_RESTORE_MOST` such cards of the last
37125
+ * `CARD_RESTORE_MS`; the others are given up. The edit queue spaces them (`CARD_RESTORE_GAP_MS`), so a reconnect does
37126
+ * not burst. The edit is marked `restore`: the bot's own event stream says nothing of it, while its number counts, so a
37127
+ * later edit of the bot's takes the next one.
36881
37128
  */
36882
37129
  async restoreCards(linkId) {
36883
37130
  const live = this.links.get(linkId), link = live?.link;
36884
37131
  const offers = link?.sessionOffers.peer;
36885
37132
  if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !offers) return;
36886
- const due = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && (m.cardRestore ?? m.buttonsRestore) === "due").sort((a, b) => b.timestamp - a.timestamp);
37133
+ const mine = (await db.getMessages(linkId)).filter((m) => m.sender === "me").sort((a, b) => b.timestamp - a.timestamp);
36887
37134
  const since = Date.now() - CARD_RESTORE_MS;
36888
37135
  const changed = [];
36889
- for (const [index, message] of due.entries()) {
36890
- const stale = index >= CARD_RESTORE_MOST || message.timestamp < since;
37136
+ const cards = /* @__PURE__ */ new Set();
37137
+ let kept = 0;
37138
+ for (const message of mine) {
37139
+ const key = message.card && `${message.card.kind}\n${message.card.id}`;
37140
+ const superseded = !!key && cards.has(key);
37141
+ if (key) cards.add(key);
37142
+ if ((message.cardRestore ?? message.buttonsRestore) !== "due") continue;
37143
+ const stale = superseded || kept++ >= CARD_RESTORE_MOST || message.timestamp < since;
36891
37144
  if (!stale && message.card && !offers.includes(GhostlyNode.cardCapability(message.card))) continue;
36892
37145
  const at = Date.now();
36893
37146
  if ((await db.patchMessage(linkId, message.id, (current) => {
@@ -36913,7 +37166,10 @@ var GhostlyNode = class GhostlyNode {
36913
37166
  },
36914
37167
  ...done
36915
37168
  };
36916
- }))?.edit?.pending) changed.push(message.id);
37169
+ }))?.edit?.pending) {
37170
+ changed.push(message.id);
37171
+ this.editsFor(linkId).edited(message.id);
37172
+ }
36917
37173
  }
36918
37174
  if (changed.length) await this.messagesChanged(linkId, changed);
36919
37175
  }
@@ -36923,6 +37179,7 @@ var GhostlyNode = class GhostlyNode {
36923
37179
  if (!queue) {
36924
37180
  queue = new EditQueue({
36925
37181
  read: () => db.getMessages(linkId),
37182
+ row: (id) => db.getMessage(linkId, id),
36926
37183
  ready: () => {
36927
37184
  const live = this.links.get(linkId), link = live?.link;
36928
37185
  return !!link && (link.supportsEdits || link.textDelivery === "dht" && this.takesDhtEdits(live));
@@ -37078,7 +37335,7 @@ var GhostlyNode = class GhostlyNode {
37078
37335
  return composeDetails(message, {
37079
37336
  link: live && stored && {
37080
37337
  stored,
37081
- myKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : live.myPubKeyZ32,
37338
+ myKey: participationKeyOf(live) ?? live.myPubKeyZ32,
37082
37339
  verified: !!stored.pairedPeerKey && (stored.peerTrust ? stored.peerTrust.verifiedKey === stored.pairedPeerKey : true),
37083
37340
  transportNow: live.link?.isDataLinkOpen ? live.pairing?.transport : void 0,
37084
37341
  relayedNow: !!live.link?.relayedPath,
@@ -37226,12 +37483,34 @@ var GhostlyNode = class GhostlyNode {
37226
37483
  }));
37227
37484
  return Promise.resolve();
37228
37485
  }
37229
- /** A file transfer ended: when, on the message that carries the file. */
37230
- async noteFileEnd(linkId, fileId, error) {
37231
- const message = (await db.getMessages(linkId)).find((m) => m.file?.id === fileId);
37486
+ /**
37487
+ * The message that carries a file, read by its id: a file of mine is `me_<its time>`, a received one is named by its
37488
+ * wire id (files/3, a held file) or by its sender's time (files/2). The chat's history is read only when none of
37489
+ * those rows carries it, so a file costs the same in a long chat as in a short one.
37490
+ */
37491
+ async fileMessage(linkId, fileId) {
37492
+ const stored = await fileStore.get(fileId).catch(() => void 0);
37493
+ const at = stored?.metadata?.timestamp, wireId = stored?.wireId;
37494
+ const ids = !stored ? [] : fileId.startsWith(`${linkId}-out-`) ? [`me_${at}`] : [
37495
+ `peer_${wireId}`,
37496
+ `peer_${at}`,
37497
+ `peer_${at}_${wireId}`
37498
+ ];
37499
+ for (const id of ids) {
37500
+ const message = await db.getMessage(linkId, id);
37501
+ if (message?.file?.id === fileId) return message;
37502
+ }
37503
+ return (await db.getMessages(linkId)).find((m) => m.file?.id === fileId);
37504
+ }
37505
+ /**
37506
+ * A file transfer ended: when, on the message that carries the file. `again`: it ended in an earlier run and is shown
37507
+ * again as the chat connects, so what its message says of its end stays (only a receipt it never got is given).
37508
+ */
37509
+ async noteFileEnd(linkId, fileId, error, again = false) {
37510
+ const message = await this.fileMessage(linkId, fileId);
37232
37511
  if (!message) return;
37233
37512
  const now = Date.now();
37234
- await this.noteDetails(linkId, message.id, (details) => error ? message.sender === "me" && details.sends?.length ? {
37513
+ if (!again) await this.noteDetails(linkId, message.id, (details) => error ? message.sender === "me" && details.sends?.length ? {
37235
37514
  ...details,
37236
37515
  sends: details.sends.map((s, i) => i === details.sends.length - 1 ? {
37237
37516
  ...s,
@@ -37281,6 +37560,7 @@ var GhostlyNode = class GhostlyNode {
37281
37560
  (async () => {
37282
37561
  const message = await db.getMessage(linkId, messageId);
37283
37562
  if (message?.paymentId && message.delivery === "waiting") await this.desk.withdraw(message.paymentId).catch(() => {});
37563
+ if (message?.paymentId && message.sender === "peer") await this.desk.dropReceipt(message.paymentId).catch(() => {});
37284
37564
  if (message?.sender === "me" && message.file && message.delivery === "waiting" && (message.fileOnFloor === "sent" || message.fileOnFloor === "seen")) {
37285
37565
  const wireId = message.file.id.slice(`${linkId}-out-`.length);
37286
37566
  live.link?.settleDht(dhtFileId(wireId)).catch(() => {});
@@ -37429,7 +37709,7 @@ var GhostlyNode = class GhostlyNode {
37429
37709
  });
37430
37710
  (async () => {
37431
37711
  if (!reply || !forwarded) {
37432
- const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
37712
+ const message = await db.getMessage(live.stored.id, `me_${timestamp}`);
37433
37713
  reply ??= message && GhostlyNode.wireReply(message);
37434
37714
  forwarded ??= message?.forwarded;
37435
37715
  }
@@ -37889,11 +38169,23 @@ var GhostlyNode = class GhostlyNode {
37889
38169
  /**
37890
38170
  * `chosen`: a choice from the chat's Connection menu, a row in its timeline even when it names the transport already
37891
38171
  * set. Left out (the RPC), only a change of transport is: the Fallback switch sends the same preference again, and
37892
- * is neither a row nor a switch intent.
38172
+ * is neither a row nor a switch intent. On Automatic it sends back the transport Automatic names: only the fallback
38173
+ * changes, and the chat stays on Automatic.
37893
38174
  */
37894
38175
  async setTransportPreference({ linkId, preferred, fallback }, chosen) {
37895
38176
  const live = this.links.get(linkId);
37896
38177
  if (!live?.stored.profile || !live.link?.availableTransports.includes(preferred) || typeof fallback !== "boolean") throw new Error("Transport unavailable");
38178
+ if (chosen === void 0 && live.stored.preferredTransport === void 0 && preferred === automaticTransport(live.link.availableTransports)) {
38179
+ await db.patchLink(linkId, { transportFallback: fallback });
38180
+ live.stored = {
38181
+ ...live.stored,
38182
+ transportFallback: fallback
38183
+ };
38184
+ this.emitState();
38185
+ await live.link.setTransportPreference(preferred, fallback, true, false);
38186
+ this.capsChanged(linkId);
38187
+ return;
38188
+ }
37897
38189
  chosen ??= live.stored.preferredTransport !== preferred;
37898
38190
  const patch = {
37899
38191
  preferredTransport: preferred,
@@ -38140,6 +38432,18 @@ var GhostlyNode = class GhostlyNode {
38140
38432
  f: reply.member
38141
38433
  }, readForwarded(hops));
38142
38434
  }
38435
+ /**
38436
+ * Whether `sendGroupFile` would take a file of mine for the group now, asked before its bytes are copied in: the
38437
+ * error it would answer for the group's pace of files or for not being in it, or null. Not a promise: it may still
38438
+ * refuse (the minute's files can fill in between).
38439
+ */
38440
+ groupFileCheck({ groupId }) {
38441
+ if (typeof groupId !== "string") return {
38442
+ error: "No such group",
38443
+ refused: true
38444
+ };
38445
+ return this.groups.fileCheck(groupId);
38446
+ }
38143
38447
  groupMessages({ groupId }) {
38144
38448
  return this.groups.messages(groupId);
38145
38449
  }
@@ -38155,8 +38459,7 @@ var GhostlyNode = class GhostlyNode {
38155
38459
  error: "No button to press",
38156
38460
  refused: true
38157
38461
  };
38158
- const history = await db.getMessages(linkId);
38159
- const message = history.find((m) => m.id === messageId);
38462
+ const message = await db.getMessage(linkId, messageId);
38160
38463
  if (!message) return {
38161
38464
  error: "That message is not in this chat",
38162
38465
  refused: true
@@ -38165,6 +38468,8 @@ var GhostlyNode = class GhostlyNode {
38165
38468
  error: "Buttons on your own message do nothing",
38166
38469
  refused: true
38167
38470
  };
38471
+ const card = messageButtons(message);
38472
+ const history = card && !card.closed && card.buttons.some((b) => b.once) ? await db.getMessages(linkId) : [];
38168
38473
  const state = buttonsState(message, replyRef(message), history);
38169
38474
  const button = state?.card.buttons.find((b) => b.id === buttonId);
38170
38475
  if (!state || !button) return {
@@ -38213,8 +38518,7 @@ var GhostlyNode = class GhostlyNode {
38213
38518
  if (!targets.length) throw new Error("No chat to forward to");
38214
38519
  if (targets.length > 5) throw new Error(`Forward to at most 5 chats at once`);
38215
38520
  if (linkId.startsWith("group:") ? !this.membership(linkId.slice(6)) : this.links.get(linkId)?.stored.group !== void 0 || !this.links.has(linkId)) throw new Error("Chat not found");
38216
- const history = await db.getMessages(linkId);
38217
- const chosen = ids.map((id) => history.find((m) => m.id === id));
38521
+ const chosen = await Promise.all(ids.map((id) => db.getMessage(linkId, id)));
38218
38522
  if (chosen.some((m) => !m)) throw new Error("That message is not in this chat");
38219
38523
  const messages = chosen.sort((a, b) => a.timestamp - b.timestamp);
38220
38524
  for (const message of messages) {
@@ -38427,19 +38731,47 @@ var GhostlyNode = class GhostlyNode {
38427
38731
  } catch {}
38428
38732
  return { error: null };
38429
38733
  }
38430
- /** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
38734
+ /** A group's history read in flight for edges that opened, by chat: its callers of the moment share it. */
38735
+ edgeOpenReads = /* @__PURE__ */ new Map();
38736
+ /**
38737
+ * A group's whole history, for what an edge that opens hears again (my latest edits and reactions): the edges that
38738
+ * open together, a member back in a group of 8 opening 7, read it once, not twice each. Ended, the next one reads anew.
38739
+ */
38740
+ edgeOpenHistory(chat) {
38741
+ let read = this.edgeOpenReads.get(chat);
38742
+ if (!read) {
38743
+ read = db.getMessages(chat).finally(() => this.edgeOpenReads.delete(chat));
38744
+ this.edgeOpenReads.set(chat, read);
38745
+ }
38746
+ return read;
38747
+ }
38748
+ /**
38749
+ * An edge of a private group opened: the member hears my latest reactions again, in case it missed them. The newest
38750
+ * `REACTION_LIMITS.send` go now and the rest a window later, while the edge is the same: the member takes 30 a window
38751
+ * from me, and 32 at once lost two of them.
38752
+ */
38431
38753
  async resendGroupReactions(groupId, linkId) {
38432
38754
  if (this.outsideEdge(linkId)) return;
38433
- const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
38434
- for (const reaction of mine) try {
38435
- this.links.get(linkId)?.link?.sendGroupFrame({
38436
- t: GROUP_REACTION_FRAME,
38437
- g: groupId,
38438
- ...reaction,
38439
- ...this.groups.signReaction(groupId, reaction)
38440
- });
38441
- } catch {
38442
- return;
38755
+ const mine = groupReactionsToResend(await this.edgeOpenHistory(`group:${groupId}`), REACTION_LIMITS.pending);
38756
+ const link = this.links.get(linkId)?.link;
38757
+ const say = (batch) => {
38758
+ for (const reaction of batch) try {
38759
+ link?.sendGroupFrame({
38760
+ t: GROUP_REACTION_FRAME,
38761
+ g: groupId,
38762
+ ...reaction,
38763
+ ...this.groups.signReaction(groupId, reaction)
38764
+ });
38765
+ } catch {
38766
+ return;
38767
+ }
38768
+ };
38769
+ say(mine.slice(0, REACTION_LIMITS.send));
38770
+ for (let start = REACTION_LIMITS.send, turn = 1; start < mine.length; start += REACTION_LIMITS.send, turn++) {
38771
+ const batch = mine.slice(start, start + REACTION_LIMITS.send);
38772
+ setTimeout(() => {
38773
+ if (link && this.links.get(linkId)?.link === link) say(batch);
38774
+ }, turn * (REACTION_LIMITS.windowMs + 1e3));
38443
38775
  }
38444
38776
  }
38445
38777
  /**
@@ -38506,19 +38838,23 @@ var GhostlyNode = class GhostlyNode {
38506
38838
  }
38507
38839
  /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
38508
38840
  get appsOn() {
38509
- return this.options.apps ?? false;
38841
+ return this.options.apps ?? true;
38842
+ }
38843
+ /** Why an app call is refused: this client runs no mini-app (`apps: false`), or the build has them off. */
38844
+ appsOff() {
38845
+ return /* @__PURE__ */ new Error(this.options.apps === false ? "Apps do not run on this client" : "Apps are unavailable in this release");
38510
38846
  }
38511
38847
  /** 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). */
38512
38848
  appChat(linkId, ref) {
38513
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
38849
+ if (!this.appsOn) throw this.appsOff();
38514
38850
  const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
38515
38851
  if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
38516
38852
  if (!isAppRef(ref)) throw new Error("Not an app reference");
38517
- const { participationSeed, pairedPeerKey } = live.stored;
38518
- if (!participationSeed || !pairedPeerKey) throw new Error("This chat is not paired yet");
38853
+ const mine = participationKeyOf(live), { pairedPeerKey } = live.stored;
38854
+ if (!mine || !pairedPeerKey) throw new Error("This chat is not paired yet");
38519
38855
  return {
38520
38856
  live,
38521
- app: chatAppId(identityFromSeedB64(participationSeed).publicKey, publicKeyFromZ32(pairedPeerKey), ref)
38857
+ app: chatAppId(publicKeyFromZ32(mine), publicKeyFromZ32(pairedPeerKey), ref)
38522
38858
  };
38523
38859
  }
38524
38860
  /** The chat app id of an app in a paired chat: how the contact's frames name it. */
@@ -40595,6 +40931,7 @@ var GhostlyNode = class GhostlyNode {
40595
40931
  this.groupWakeReceived.delete(linkId);
40596
40932
  this.quietEdges.delete(linkId);
40597
40933
  this.groupNativeWaiting.delete(linkId);
40934
+ this.groupNativeFailed.delete(linkId);
40598
40935
  this.groupNativeRetryAt = 0;
40599
40936
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
40600
40937
  const group = live.stored.group, peer = live.stored.groupPeer;
@@ -40763,6 +41100,7 @@ var GhostlyNode = class GhostlyNode {
40763
41100
  automatic: true
40764
41101
  },
40765
41102
  packetTransports: !this.options.webrtcGroupLinks,
41103
+ packetTransportsSaid: !!stored.transportSeeds && Object.keys(stored.transportSeeds).length > 0,
40766
41104
  resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
40767
41105
  pairing: {
40768
41106
  credentials: {
@@ -40823,6 +41161,21 @@ var GhostlyNode = class GhostlyNode {
40823
41161
  };
40824
41162
  db.patchLink(linkId, patch).catch(() => {});
40825
41163
  if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
41164
+ else this.groupNativeWaiting.delete(linkId);
41165
+ },
41166
+ onPacketTransportsGone: () => {
41167
+ const patch = {
41168
+ peerTransports: void 0,
41169
+ peerDescriptors: void 0,
41170
+ peerFallback: void 0
41171
+ };
41172
+ live.stored = {
41173
+ ...live.stored,
41174
+ ...patch
41175
+ };
41176
+ db.patchLink(linkId, patch).catch(() => {});
41177
+ if (!this.keepsGroupNative(live.stored)) this.groupNativeWaiting.delete(linkId);
41178
+ this.emitState();
40826
41179
  },
40827
41180
  ...entry ? {} : {
40828
41181
  onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
@@ -40865,12 +41218,16 @@ var GhostlyNode = class GhostlyNode {
40865
41218
  this.emitState();
40866
41219
  },
40867
41220
  onStatus: (status) => {
40868
- live.status = status;
40869
- this.emitState();
41221
+ if (status !== live.status) {
41222
+ live.status = status;
41223
+ this.emitState();
41224
+ }
40870
41225
  },
40871
41226
  onDiscoveryError: (error) => {
40872
- live.discoveryError = error ?? void 0;
40873
- this.emitState();
41227
+ if ((error ?? void 0) !== live.discoveryError) {
41228
+ live.discoveryError = error ?? void 0;
41229
+ this.emitState();
41230
+ }
40874
41231
  }
40875
41232
  }
40876
41233
  });
@@ -40922,6 +41279,7 @@ var GhostlyNode = class GhostlyNode {
40922
41279
  expectedPeerKey: stored.peerParticipationKeyZ32
40923
41280
  } : void 0;
40924
41281
  const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
41282
+ let dhtShown;
40925
41283
  live.link = new GhostLink({
40926
41284
  paymentMethods: stored.paymentMethods,
40927
41285
  paymentNetworks: this.chatNetworks(stored),
@@ -41045,10 +41403,14 @@ var GhostlyNode = class GhostlyNode {
41045
41403
  },
41046
41404
  onGroupFrame: stored.profile ? (frame) => this.groups.handleContactFrame(linkId, frame) : void 0,
41047
41405
  onGroupsSupport: () => this.emitState(),
41048
- onDhtDelivery: () => {
41406
+ onDhtDelivery: (view) => {
41049
41407
  if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
41050
41408
  this.observeTransport(linkId);
41051
- this.emitState();
41409
+ const shown = JSON.stringify(view);
41410
+ if (shown !== dhtShown) {
41411
+ dhtShown = shown;
41412
+ this.emitState();
41413
+ }
41052
41414
  },
41053
41415
  onHold: (state) => this.hold.peerSaid(linkId, state),
41054
41416
  onPeerProof: void 0,
@@ -41086,8 +41448,10 @@ var GhostlyNode = class GhostlyNode {
41086
41448
  else this.emitState();
41087
41449
  },
41088
41450
  onDiscoveryError: (error) => {
41089
- live.discoveryError = error ?? void 0;
41090
- this.emitState();
41451
+ if ((error ?? void 0) !== live.discoveryError) {
41452
+ live.discoveryError = error ?? void 0;
41453
+ this.emitState();
41454
+ }
41091
41455
  },
41092
41456
  onTransportDiscovery: async (peerDescriptors, peerTransports, peerFallback) => {
41093
41457
  const patch = {
@@ -41112,23 +41476,27 @@ var GhostlyNode = class GhostlyNode {
41112
41476
  this.emitState();
41113
41477
  },
41114
41478
  onStatus: (status) => {
41479
+ const was = live.status, synced = live.lastSyncAt;
41115
41480
  live.status = status;
41116
41481
  if (status === "online") live.lastSyncAt = Date.now();
41117
- this.emitState();
41482
+ if (status !== was || !synced || stored.group || linkId === this.activeLinkId) this.emitState();
41118
41483
  },
41119
41484
  onPoll: ({ polling, nextInMs }) => {
41485
+ const was = live.poll.polling;
41120
41486
  live.poll = {
41121
41487
  polling,
41122
41488
  nextAt: Date.now() + nextInMs,
41123
41489
  interval: nextInMs || live.poll.interval
41124
41490
  };
41125
- this.emitState();
41491
+ if (linkId === this.activeLinkId || !live.lastSyncAt && polling !== was) this.emitState();
41126
41492
  },
41127
41493
  onMessageReceipt: async (id) => {
41128
- if (stored.profile && !stored.group) await this.fileSeenOnFloor(linkId, id);
41494
+ const oneToOne = !!stored.profile && !stored.group;
41495
+ const mine = oneToOne ? await db.getMessage(linkId, `me_${id}`) : void 0;
41496
+ if (oneToOne && !mine) await this.fileSeenOnFloor(linkId, id);
41129
41497
  await this.outboxFor(linkId).received(id);
41130
- if (stored.profile && !stored.group) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
41131
- if (stored.profile && !stored.group) this.editsFor(linkId).flush().catch(() => {});
41498
+ if (oneToOne && !mine) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
41499
+ if (oneToOne && (!mine || mine.edit?.pending)) this.editsFor(linkId).flush().catch(() => {});
41132
41500
  },
41133
41501
  onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
41134
41502
  onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
@@ -41321,7 +41689,7 @@ var GhostlyNode = class GhostlyNode {
41321
41689
  }
41322
41690
  }
41323
41691
  nativeUp?.then(() => {
41324
- if (live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
41692
+ if (!this.shuttingDown && live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
41325
41693
  });
41326
41694
  }
41327
41695
  /**
@@ -41401,7 +41769,7 @@ var GhostlyNode = class GhostlyNode {
41401
41769
  get nativeFactories() {
41402
41770
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
41403
41771
  return {
41404
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DvXgebV7.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41772
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-C0XRWySP.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41405
41773
  ...this.options.nativeTransports,
41406
41774
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
41407
41775
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -41503,13 +41871,23 @@ var GhostlyNode = class GhostlyNode {
41503
41871
  const live = this.links.get(linkId), link = live?.link;
41504
41872
  if (this.shuttingDown || !live || !link || link !== expected) return;
41505
41873
  if (live.stored.group && this.keepsGroupNative(live.stored)) {
41506
- if (full.some(Boolean) && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
41874
+ if (full.includes("no-slot") && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
41507
41875
  else this.groupNativeWaiting.delete(linkId);
41508
- }
41876
+ if (full.includes("failed")) {
41877
+ const wait = Math.min(Math.max(GROUP_NATIVE_RETRY_MS, (this.groupNativeFailed.get(linkId)?.wait ?? 0) * 2), GROUP_NATIVE_FAILED_MAX_MS);
41878
+ this.groupNativeFailed.set(linkId, {
41879
+ at: Date.now() + wait,
41880
+ wait
41881
+ });
41882
+ } else this.groupNativeFailed.delete(linkId);
41883
+ } else this.groupNativeFailed.delete(linkId);
41509
41884
  this.emitState();
41510
41885
  });
41511
41886
  }
41512
- /** One transport's listener for one chat, in that transport's queue. True when a group's link found no slot free. */
41887
+ /**
41888
+ * One transport's listener for one chat, in that transport's queue. "no-slot" when a group's link found no slot free,
41889
+ * "failed" when the listener could not start.
41890
+ */
41513
41891
  async startNativeEndpoint(linkId, expected, key, factory, inUse) {
41514
41892
  const live = this.links.get(linkId), link = live?.link;
41515
41893
  if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
@@ -41519,7 +41897,7 @@ var GhostlyNode = class GhostlyNode {
41519
41897
  live.transportErrors ??= {};
41520
41898
  try {
41521
41899
  const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
41522
- if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return true;
41900
+ if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return "no-slot";
41523
41901
  if (owners.length >= NATIVE_SLOTS) {
41524
41902
  const now = Date.now();
41525
41903
  const taking = !group && (this.activeLinkId === linkId || inUse);
@@ -41563,7 +41941,12 @@ var GhostlyNode = class GhostlyNode {
41563
41941
  transportSeeds
41564
41942
  };
41565
41943
  await db.patchLink(linkId, { transportSeeds });
41566
- const endpoint = await factory(seed);
41944
+ const starting = factory(seed);
41945
+ const endpoint = await Promise.race([starting, this.stopping]);
41946
+ if (!endpoint) {
41947
+ starting.then((late) => late.close(), () => {});
41948
+ return false;
41949
+ }
41567
41950
  if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
41568
41951
  await endpoint.close();
41569
41952
  return false;
@@ -41574,6 +41957,7 @@ var GhostlyNode = class GhostlyNode {
41574
41957
  this.emitState();
41575
41958
  } catch (error) {
41576
41959
  live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
41960
+ return "failed";
41577
41961
  }
41578
41962
  return false;
41579
41963
  }
@@ -41644,8 +42028,23 @@ var GhostlyNode = class GhostlyNode {
41644
42028
  /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
41645
42029
  groupNativeWaiting = /* @__PURE__ */ new Set();
41646
42030
  groupNativeRetryAt = 0;
41647
- /** Now and then, the group links that found no free native slot try again: a chat or another group may have let one go. */
42031
+ /** Group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
42032
+ groupNativeFailed = /* @__PURE__ */ new Map();
42033
+ /**
42034
+ * Now and then, the group links that found no free native slot try again: a chat or another group may have let one go.
42035
+ * Those whose listener failed to start try again once their wait is over.
42036
+ */
41648
42037
  retryGroupNative() {
42038
+ const now = Date.now();
42039
+ for (const [linkId, failed] of this.groupNativeFailed) {
42040
+ if (now < failed.at) continue;
42041
+ if (!this.links.get(linkId)?.link) {
42042
+ this.groupNativeFailed.delete(linkId);
42043
+ continue;
42044
+ }
42045
+ failed.at = now + failed.wait;
42046
+ this.ensureNativeEndpoints(linkId);
42047
+ }
41649
42048
  if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
41650
42049
  this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
41651
42050
  for (const linkId of [...this.groupNativeWaiting]) {
@@ -41846,7 +42245,7 @@ var GhostlyNode = class GhostlyNode {
41846
42245
  },
41847
42246
  ...this.reactionNotes.has(stored.id) && { lastReaction: this.reactionNotes.get(stored.id) },
41848
42247
  ...stored.pin && { pin: pinView(stored.pin) },
41849
- participationKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
42248
+ participationKey: participationKeyOf(live),
41850
42249
  peerParticipationKey: stored.pairedPeerKey,
41851
42250
  publicProfiles: void 0,
41852
42251
  profileChoice: void 0,
@@ -41963,6 +42362,9 @@ var EngineServer = class {
41963
42362
  * call (WISP 06 § States and events). A client that goes away (a tab closed mid-call) is taken out with it.
41964
42363
  */
41965
42364
  onCall = /* @__PURE__ */ new Set();
42365
+ /** How many history changes the engine told; per chat or group, the count at its last one (`historyMark`). */
42366
+ changes = 0;
42367
+ changedAt = /* @__PURE__ */ new Map();
41966
42368
  constructor(options = {}) {
41967
42369
  this.node = new GhostlyNode({
41968
42370
  onAttention: (event) => this.broadcast({
@@ -41973,8 +42375,14 @@ var EngineServer = class {
41973
42375
  kind: "state",
41974
42376
  state
41975
42377
  }),
41976
- onMessages: (linkId, messages) => void this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {}),
41977
- onMessageChanges: (linkId, changes) => void this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {}),
42378
+ onMessages: (linkId, messages) => {
42379
+ this.changed(linkId);
42380
+ this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {});
42381
+ },
42382
+ onMessageChanges: (linkId, changes) => {
42383
+ this.changed(linkId);
42384
+ this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {});
42385
+ },
41978
42386
  onCallSignal: (linkId, signal) => {
41979
42387
  if (signalKind(signal) === "o") this.offers.set(linkId, signal);
41980
42388
  else this.offers.delete(linkId);
@@ -41998,9 +42406,21 @@ var EngineServer = class {
41998
42406
  this.ready = this.node.start();
41999
42407
  this.ready.catch(() => {});
42000
42408
  }
42001
- attach(client) {
42409
+ /** A point in the engine's history changes: a history read after it can be handed to `attach` as read. */
42410
+ get historyMark() {
42411
+ return this.changes;
42412
+ }
42413
+ /**
42414
+ * `read`: the chats and groups whose whole history the client read itself, after `mark` (`historyMark`). One that has
42415
+ * not changed since is not read and sent again: the client hears only what changes in it from now on.
42416
+ */
42417
+ attach(client, read) {
42002
42418
  this.clients.add(client);
42003
- this.histories.set(client, /* @__PURE__ */ new Set());
42419
+ const sent = /* @__PURE__ */ new Set();
42420
+ if (read) {
42421
+ for (const id of read.ids) if ((this.changedAt.get(id) ?? 0) <= read.mark) sent.add(id);
42422
+ }
42423
+ this.histories.set(client, sent);
42004
42424
  this.ready.then(async () => {
42005
42425
  if (!this.clients.has(client)) return;
42006
42426
  client.post({
@@ -42013,15 +42433,18 @@ var EngineServer = class {
42013
42433
  signal
42014
42434
  });
42015
42435
  else this.offers.delete(linkId);
42016
- for (const link of this.node.getState().links) await this.inOrder(link.id, async () => {
42017
- if (!this.clients.has(client)) return;
42018
- client.post({
42019
- kind: "messages",
42020
- linkId: link.id,
42021
- messages: await this.node.getMessages(link.id)
42436
+ for (const link of this.node.getState().links) {
42437
+ if (sent.has(link.id)) continue;
42438
+ await this.inOrder(link.id, async () => {
42439
+ if (!this.clients.has(client)) return;
42440
+ client.post({
42441
+ kind: "messages",
42442
+ linkId: link.id,
42443
+ messages: await this.node.getMessages(link.id)
42444
+ });
42445
+ this.histories.get(client)?.add(link.id);
42022
42446
  });
42023
- this.histories.get(client)?.add(link.id);
42024
- });
42447
+ }
42025
42448
  }, (error) => {
42026
42449
  this.post(client, {
42027
42450
  kind: "start-failed",
@@ -42083,6 +42506,9 @@ var EngineServer = class {
42083
42506
  return Promise.reject(error);
42084
42507
  }
42085
42508
  }
42509
+ changed(linkId) {
42510
+ this.changedAt.set(linkId, ++this.changes);
42511
+ }
42086
42512
  track(linkId, running) {
42087
42513
  const settled = running.catch(() => {});
42088
42514
  this.sending.set(linkId, settled);
@@ -42148,4 +42574,4 @@ function signalKind(signal) {
42148
42574
  //#endregion
42149
42575
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
42150
42576
 
42151
- //# sourceMappingURL=server-GJW_5wGI.js.map
42577
+ //# sourceMappingURL=server-D5FBZYW8.js.map