@ghostlytools/cli 1.2.3 → 1.3.0

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 (86) hide show
  1. package/README.md +18 -7
  2. package/dist/assets/api-BiaX6l6f.js +3 -0
  3. package/dist/assets/api-CrxVxtY1.js +4071 -0
  4. package/dist/assets/api-CrxVxtY1.js.map +1 -0
  5. package/dist/assets/apiKit-CajiDxdK.js +196 -0
  6. package/dist/assets/apiKit-CajiDxdK.js.map +1 -0
  7. package/dist/assets/apps-CbfiCSIw.js +787 -0
  8. package/dist/assets/apps-CbfiCSIw.js.map +1 -0
  9. package/dist/assets/apps-t23gkc28.js +2 -0
  10. package/dist/assets/{arkade-B-ALutmU.js → arkade-D4BVJFEQ.js} +2 -2
  11. package/dist/assets/{arkade-B-ALutmU.js.map → arkade-D4BVJFEQ.js.map} +1 -1
  12. package/dist/assets/backup-BAMi2lAq.js +731 -0
  13. package/dist/assets/backup-BAMi2lAq.js.map +1 -0
  14. package/dist/assets/backup-Cg52WeQi.js +2 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  17. package/dist/assets/buttons-CWl2yNJu.js +2 -0
  18. package/dist/assets/buttons-CX1pKB0j.js +497 -0
  19. package/dist/assets/buttons-CX1pKB0j.js.map +1 -0
  20. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-Dn7H8qE1.js} +14 -8
  21. package/dist/assets/endpoint-Dn7H8qE1.js.map +1 -0
  22. package/dist/assets/envelope-BvU0fLEt.js +90 -0
  23. package/dist/assets/envelope-BvU0fLEt.js.map +1 -0
  24. package/dist/assets/errors-my6VJR_0.js +77 -0
  25. package/dist/assets/errors-my6VJR_0.js.map +1 -0
  26. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  27. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  28. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  29. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  30. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  31. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  32. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  33. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  34. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  35. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  36. package/dist/assets/groupFiles-BNTSDaoq.js +7685 -0
  37. package/dist/assets/groupFiles-BNTSDaoq.js.map +1 -0
  38. package/dist/assets/groupFiles-DBbjOLuD.js +2088 -0
  39. package/dist/assets/groupFiles-DBbjOLuD.js.map +1 -0
  40. package/dist/assets/host-Cmd205Dc.js +4648 -0
  41. package/dist/assets/host-Cmd205Dc.js.map +1 -0
  42. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  43. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  44. package/dist/assets/{hyperdhtRelay-hKC9DpIH.js → hyperdhtRelay-CEmBpT0Q.js} +26 -10
  45. package/dist/assets/hyperdhtRelay-CEmBpT0Q.js.map +1 -0
  46. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  47. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  48. package/dist/assets/irohWeb--YyEA-hX.js +545 -0
  49. package/dist/assets/irohWeb--YyEA-hX.js.map +1 -0
  50. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  51. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  52. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  53. package/dist/assets/main-BzNN6bkx.js +4302 -0
  54. package/dist/assets/main-BzNN6bkx.js.map +1 -0
  55. package/dist/assets/{mainline-DriRu_hb.js → mainline-D5D5C-jT.js} +14 -6
  56. package/dist/assets/mainline-D5D5C-jT.js.map +1 -0
  57. package/dist/assets/mints-BrlY773j.js +74 -0
  58. package/dist/assets/mints-BrlY773j.js.map +1 -0
  59. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  60. package/dist/assets/networkReason-DxZWHw6v.js +154 -0
  61. package/dist/assets/networkReason-DxZWHw6v.js.map +1 -0
  62. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  63. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  64. package/dist/assets/profiles-B6F4oa9K.js +139 -0
  65. package/dist/assets/profiles-B6F4oa9K.js.map +1 -0
  66. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  67. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  68. package/dist/assets/{server-DHMXMwNn.js → server-CBCEYKMN.js} +1290 -277
  69. package/dist/assets/server-CBCEYKMN.js.map +1 -0
  70. package/dist/assets/{src-_FzJkeAJ.js → src-DhfGgAUu.js} +1068 -2266
  71. package/dist/assets/src-DhfGgAUu.js.map +1 -0
  72. package/dist/assets/turnRefs-ChEfgbSX.js +323 -0
  73. package/dist/assets/turnRefs-ChEfgbSX.js.map +1 -0
  74. package/dist/assets/{usdt-D0lO2pRR.js → usdt-C1Xao2xH.js} +3 -3
  75. package/dist/assets/{usdt-D0lO2pRR.js.map → usdt-C1Xao2xH.js.map} +1 -1
  76. package/dist/ghostly.mjs +4 -21992
  77. package/dist/ghostly.mjs.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  80. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  81. package/dist/assets/hyperdhtRelay-hKC9DpIH.js.map +0 -1
  82. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  83. package/dist/assets/mainline-DriRu_hb.js.map +0 -1
  84. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  85. package/dist/assets/server-DHMXMwNn.js.map +0 -1
  86. package/dist/assets/src-_FzJkeAJ.js.map +0 -1
@@ -1,23 +1,28 @@
1
- import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
- import { $ as parseCommunityEdit, $a as receivedTimestamp, $c as identityFromSeed, $o as HANDOFF_PAKE, $r as vapidKeysMatch, $s as verifyEnrollProof, $t as signDidDhtPacket, A as encodeCommunityLink, Aa as reactionEmoji, Ac as signTurnPacket, Ai as DHT_TEXT_CAPABILITY, Ao as webLocalFetch, Ar as FILE_LIMITS, As as readHandoffTurnFrame, B as GroupWakeLimiter, Bc as normalizeRelayUrl, Bi as identityRevocationValue, Bn as INVITE_TAKEN, Br as mayPin, C as openGroupTyping, Ca as readForwarded, Cc as classifyTurnRead, Co as DHT_POLL_INTERVALS, Cr as pickHubs, Cs as newHandoffId, D as GROUP_READ_NOTE_COMMUNITY, Da as ReactionWindow, Dc as firstDeviceSetSecret, Do as GhostlyHttpError, Dr as readLobby, Ds as readHandoffHave, E as CommunitySession, Ea as REACTION_LIMITS, Ec as TURN_MAX, Eo as presenceSeenAt, Er as readBeaconHead, Es as readHandoffDone, F as knockIdentity, Fa as pairedReplyAuthor, Fc as turnStatedVerifier, Fi as IDENTITY_REVOCATION_LABEL, Fn as findEndpoint, Fo as RTC_CONFIG, Fr as TypingSender, Fs as readSetUpdate, Gc as withRequestOptions, Gi as lastSharedWithMe, Gn as GROUP_FILE_LIMITS, Go as LEGACY_SERVICES, Gr as WAKE_CALL_INTERVAL_MS, Gs as enrollCancelReason, H as groupWakes, Hc as traceLink, Hi as newIdentityBinding, Hn as pairedMessageFrame, Hr as pinIsNewer, I as knockRecords, Ia as readReply, Ic as turnVerifierMark, In as isFederationId, Is as setAckFrame, J as frameBytes, Ja as sameButtonText, Jc as createRelayPayload, Ji as validExternalKey, Jo as HANDOFF_CANCEL, Jr as newWakeToken, Js as enrollGrantFrame, Jt as didDhtDocument, K as GROUP_READ_NOTE, Kc as PacketTooLargeError, Ki as PeerProofs, Ko as serviceIdFromName, Kr as WakeLimiter, Ks as enrollDigits, L as mergeKnocks, La as replySnippet, Lc as DEFAULT_RELAYS, Li as IdentityExchange, Ln as parseCashuRequestPayload, Lr as typingActivity, Ls as setUpdateFrame, M as decodeGroupEntryLink, Ma as readReaction, Mc as turnInstance, Mi as dialDescriptors, Mn as ENDPOINT, Mo as decodeControl, Mr as transferStalled, Ms as sealHandoffPiece, N as encodeGroupEntryLink, Na as validReactionNumber, Nc as turnKeys, Nn as cashuRequestPayload, No as encodeControl, O as communityHasFrame, Oa as nextReactionNumber, Oc as nextTurnPosition, Oo as formatLocalTarget, Or as shouldBeHub, Os as readHandoffHello, P as entryParams, Pa as wireReaction, Pc as turnName, Pn as fedimintRequestPayload, Q as carryMentions, Qa as heardTime, Qc as createIdentity, Qn as readGroupFileMeta, Qo as HANDOFF_OFFER, Qr as checkPushEndpoint, Qs as readEnrollHello, R as readKnocks, Ra as BUTTON_ID, Rc as RelayTransport, Rn as parseFedimintRequestPayload, Rs as signTombstone, S as groupTypingEpoch, Sa as forwardedAgain, Sc as TURN_SETTLE_MS, So as DirectPathWatch, Sr as newerHead, Ss as handoffVersions, St as normalizeAtprotoHandle, T as COMMUNITY_LIMITS, Ta as GROUP_REACTION_FRAME, Tc as TURN_LAST_SEQUENCE, To as RELAY_POLL_INTERVALS, Tr as readBeacon, Ts as readHandoffCancel, U as parseGroupWakeFrame, Ua as STATUS_CARD_LIMITS, Uc as budgetRetryMs, Ui as receivedIdentityStatus, Un as engineError, Uo as emptyLinkRecords, Ur as pinNumberHolds, V as groupWakeFrame, Vi as identityStatement, Vn as LIVENESS_PING_MS, Vo as parseCallSignal, Vr as nextPinNumber, W as GROUP_LIMITS, Wc as isDiscoveryBudgetError, Wi as revokesIdentity, Wn as engineText, Wo as HTTP_SERVICE_PROTO, Wr as readPin, Ws as enrollCancelFrame, Xa as arrivalKey, Xi as automaticTransport, Xn as groupFileFallback, Xo as HANDOFF_HAVE, Xr as relayRequest, Xs as enrollProofFrame, Xt as encodeDidDhtDeactivated, Ya as statusCardText, Yi as TRANSPORTS, Yo as HANDOFF_DONE, Yr as parseWakeFrame, Ys as enrollHelloFrame, Yt as didDhtFromPublicKey, Z as GROUP_EDIT_TEXT_BYTES, Za as claimedTime, Zo as HANDOFF_HELLO, Zr as wakeRequest, Zs as readEnrollGrant, Zt as encodeDidDhtPacket, _a as EDIT_BUFFER_MS, _c as newDeviceSetSecret, _n as sparkInvoiceDetails, _o as MEMBER_KEY, _r as freshHubs, _s as handoffOfferFrame, a as planAppUpdate, ac as decodeInviteCode, ai as STATUS_CARD_CAPABILITY, al as bytesEqual, as as handoffAttemptAllowed, ba as dhtEditId, bn as isPubkyKey, bo as verifyCommitSignature, br as mergeBeacon, bs as handoffRetryAfter, ca as sanitizeFileName, cc as inviteOwnership, ci as HOLD_DATED_BACK_MS, cl as toBase64Url, cn as WALLET_NETWORKS, co as readAppRevocation, cr as readGroupSignal, cs as handoffBusyFrame, d as APP_BUNDLE_LIMITS, dc as HANDOFF_CAPABILITY, di as HoldRefusedError, dl as utf8Encode, dn as formatPaymentAmount, dr as meshHubs, ds as handoffContextProof, ea as DHT_MESSAGE_TTL, ec as createDeviceInvite, ei as chatAppId, el as identityFromSeedB64, en as ETHEREUM_USDT, eo as shownTime, es as HANDOFF_PIECE_BYTES, et as mentionsMember, f as APP_BUNDLE_MAGIC, fc as deviceEchoFrame, fi as isHoldUrl, fn as validatePaymentTarget, fo as canonicalJson, fr as pickMeshHubs, fs as handoffDataFrame, ga as parseVideoMeta, gc as devicePingFrame, gn as sparkAddressKind, gr as doorHubs, gs as handoffManifestFrames, h as appViewOf, hc as deviceLinkPairing, hn as SPARK_PROVIDER, ho as GROUP_MEMBER_CAP, hr as beaconRecords, hs as handoffHelloFrame, i as checkAppBeforeRun, ia as parseImageMeta, ic as createChatInvite, ii as EDIT_CAPABILITY, il as sanitizeAvatar, in as SEPOLIA_TEST_USDT, io as appRef, is as filePartName, ja as reactionIsNewer, jc as signTurnRelease, ji as capsDescriptors, jn as decodeBolt11, jo as LIMITS, jr as transferEnded, js as readHandoffVerified, k as decodeCommunityLink, ka as queueReaction, kc as readTurnPacket, ki as CapsExchange, ko as parseLocalTarget, kr as ChatFiles, ks as readHandoffPake, l as readAppStoreSteps, la as sanitizeMime, lc as DEVICES_CAPABILITY, li as HOLD_LIMITS, ll as toZ32, ln as assertTokenUnits, lr as MESH_HUBS, ls as handoffCancelFrame, m as appDigest, ma as parseVoiceMeta, mc as deviceKeyZ32, mi as readManifest, mn as SPARK_NETWORKS, mo as readCanonicalJson, mr as beaconKeys, ms as handoffEphemeral, n as appStoreDecision, na as DHT_FILE_CAPABILITY, nc as readDeviceInvite, ni as APPS_CAPABILITY, nl as verify, nn as ONCHAIN_PROVIDER, no as APP_STATEMENT_LIMITS, nr as CarriedTransport, ns as HANDOFF_TIMINGS, o as APP_STORE_LIMITS, oa as formatFileSize, oc as encodeInviteCode, oi as UPGRADE_CAPABILITY, ol as fromBase64Url, on as TEST_USDT_FAUCET_AMOUNT, oo as isAppKey, os as handoffAttemptFailed, pa as fileMessageText, pc as deviceEchoNonce, pi as newMailbox, pn as walletNetworkOf, pr as COMMUNITY_TOPOLOGY, ps as handoffDigest, q as GroupSession, qa as checkStatusCard, qi as emptyProofLedger, qo as HANDOFF_ACK, qr as deviceWakeRequest, qs as enrollDoneFrame, r as appUpdateDecision, ra as dhtFileId, rc as OwnInviteError, ri as BUTTONS_CAPABILITY, rn as PaymentPreflightError, ro as appFingerprint, rs as HANDOFF_VERIFIED, sa as safeBlobType, si as HOLD_CAPABILITY, sl as randomBytes, sn as USDT_PUBLIC_RPC, so as isAppRef, sr as groupSignalFrame, ss as handoffAttemptSucceeded, t as appRevocationFor, ta as peekDhtMailbox, tc as deviceInviteJoinerParams, ti as isAppVersion, tl as publicKeyFromZ32, tn as EVM_TEST_CHAINS, ts as HANDOFF_REQUEST, tt as groupName, uc as ENROLL_CAPABILITY, ui as HoldKeys, ul as utf8Decode, un as assertWholeSats, ur as mayBeHub, us as handoffContext, v as isAppUrl, va as EDIT_RATE_WINDOW_MS, vc as seedSigner, vn as isBitcoinAddress, vo as commitHash, vr as lobbyKeys, vs as handoffPakeFrame, w as sealGroupTyping, wa as COMMUNITY_REACTION_FRAME, wc as turnPutSummary, wr as rankHubs, ws as readHandoffAck, x as GroupTypingBoard, xa as parseLinkPreview, xc as TURN_RAISE_READ_TIMEOUT_MS, xo as edgeParams, xr as mergeLobby, xs as handoffStreamKey, xt as isAtprotoDid, y as readAppBundle, ya as RateWindow, yc as webCryptoSigner, yo as rosterHas, yr as lobbyRecords, ys as handoffReleaseFrame, zc as currentRelays, zi as emptyIdentityLedger, zn as GhostLink, zr as PIN_LIMITS, zs as verifySetUpdate } from "./src-_FzJkeAJ.js";
3
- import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-CTF6LF5B.js";
4
- import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-BcYZhNgF.js";
5
- import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
6
- import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
1
+ import { n as traceJoin } from "./joinTrace-CjRDFy85.js";
2
+ import { $ as appRef, D as parseVideoMeta, Ft as toBase64Url, G as claimedTime, It as toZ32, J as shownTime, K as heardTime, Lt as utf8Decode, N as STATUS_CARD_LIMITS, Nt as randomBytes, O as BUTTON_ID, Ot as bytesEqual, Q as appFingerprint, Rt as utf8Encode, T as parseVoiceMeta, U as statusCardText, V as sameButtonText, W as arrivalKey, Z as APP_STATEMENT_LIMITS, _ as safeBlobType, _t as LIMITS, at as readAppRevocation, bt as encodeControl, c as readGroupFileMeta, g as formatFileSize, ht as readCanonicalJson, it as isAppRef, jt as fromBase64Url, nt as isAppHash, o as groupFileFallback, p as parseImageMeta, pt as canonicalJson, q as receivedTimestamp, rt as isAppKey, t as GROUP_FILE_LIMITS, v as sanitizeFileName, w as fileMessageText, y as sanitizeMime, yt as decodeControl, z as checkStatusCard } from "./groupFiles-DBbjOLuD.js";
3
+ import { $a as HANDOFF_HELLO, $i as EDIT_RATE_WINDOW_MS, $o as readEnrollGrant, $r as STATUS_CARD_CAPABILITY, $t as didDhtFromPublicKey, A as CommunitySession, Aa as RELAY_POLL_INTERVALS, Ao as readHandoffHello, Ar as typingActivity, As as nextTurnPosition, B as knockRecords, Bi as PeerProofs, Bn as isFederationId, Bo as signTombstone, Br as deviceWakeRequest, Bs as RelayTransport, C as sha256Paced, Ca as rosterHas, Ci as CapsExchange, Co as handoffStreamKey, Cr as FILE_LIMITS, Cs as TURN_RAISE_READ_TIMEOUT_MS, D as openGroupTyping, Do as readHandoffCancel, Ds as TURN_LAST_SEQUENCE, E as groupTypingEpoch, Ea as DirectPathWatch, Ei as dialDescriptors, Eo as readHandoffAck, Es as turnPutSummary, Et as normalizeAtprotoHandle, Fa as webLocalFetch, Fi as identityStatement, Fn as decodeBolt11, Fr as pinIsNewer, Fs as turnKeys, G as groupWakeFrame, Ga as emptyLinkRecords, Gn as LIVENESS_PING_MS, Gr as checkPushEndpoint, Gs as budgetRetryMs, H as readKnocks, Hi as validExternalKey, Hn as parseFedimintRequestPayload, Hr as parseWakeFrame, Hs as normalizeRelayUrl, I as decodeGroupEntryLink, Ii as newIdentityBinding, In as ENDPOINT, Ir as pinNumberHolds, Is as turnName, J as GROUP_LIMITS, Ja as serviceIdFromName, Ji as DHT_MESSAGE_TTL, Jn as engineText, Jo as enrollDigits, Jr as chatAppId, Js as PacketTooLargeError, K as groupWakes, Ka as HTTP_SERVICE_PROTO, Kn as pairedMessageFrame, Ko as enrollCancelFrame, Kr as vapidKeysMatch, Ks as isDiscoveryBudgetError, L as encodeGroupEntryLink, La as RTC_CONFIG, Li as receivedIdentityStatus, Ln as cashuRequestPayload, Lo as readSetUpdate, Lr as readPin, Ls as turnStatedVerifier, M as communityHasFrame, Ma as GhostlyHttpError, Mo as readHandoffTurnFrame, Mr as PIN_LIMITS, Ms as signTurnPacket, N as decodeCommunityLink, Na as formatLocalTarget, Ni as emptyIdentityLedger, No as readHandoffVerified, Nr as mayPin, Ns as signTurnRelease, O as sealGroupTyping, Oa as DHT_POLL_INTERVALS, Oo as readHandoffDone, Or as TypingSender, Os as TURN_MAX, P as encodeCommunityLink, Pa as parseLocalTarget, Pi as identityRevocationValue, Po as sealHandoffPiece, Pr as nextPinNumber, Ps as turnInstance, Q as frameBytes, Qa as HANDOFF_HAVE, Qi as EDIT_BUFFER_MS, Qo as enrollProofFrame, Qr as EDIT_CAPABILITY, Qt as didDhtDocument, R as entryParams, Ri as revokesIdentity, Rn as fedimintRequestPayload, Ro as setAckFrame, Rr as WAKE_CALL_INTERVAL_MS, Rs as turnVerifierMark, S as readAppBundleAsync, Sa as commitHash, Sn as isBitcoinAddress, So as handoffRetryAfter, Sr as ChatFiles, T as GroupTypingBoard, Ta as edgeParams, Ti as capsDescriptors, To as newHandoffId, Tr as transferStalled, Ts as classifyTurnRead, Tt as isAtprotoDid, Ua as parseCallSignal, Ui as TRANSPORTS, Un as GhostLink, Ur as relayRequest, V as mergeKnocks, Vi as emptyProofLedger, Vn as parseCashuRequestPayload, Vo as verifySetUpdate, Vr as newWakeToken, Vs as currentRelays, W as GroupWakeLimiter, Wi as automaticTransport, Wn as INVITE_TAKEN, Wr as wakeRequest, Ws as traceLink, X as GROUP_READ_NOTE, Xa as HANDOFF_CANCEL, Xi as DHT_FILE_CAPABILITY, Xo as enrollGrantFrame, Xr as APPS_CAPABILITY, Xs as createRelayPayload, Ya as HANDOFF_ACK, Yi as peekDhtMailbox, Yn as CarriedTransport, Yo as enrollDoneFrame, Yr as isAppVersion, Z as GroupSession, Za as HANDOFF_DONE, Zi as dhtFileId, Zo as enrollHelloFrame, Zr as BUTTONS_CAPABILITY, _a as readReply, _n as walletNetworkOf, _o as handoffHelloFrame, _r as rankHubs, _s as deviceLinkPairing, a as planAppUpdate, aa as COMMUNITY_REACTION_FRAME, ai as HoldRefusedError, an as EVM_TEST_CHAINS, ao as HANDOFF_VERIFIED, ar as pickMeshHubs, as as OwnInviteError, at as groupName, b as pacer, bn as sparkAddressKind, bo as handoffPakeFrame, br as readLobby, bs as seedSigner, ca as ReactionWindow, ci as readManifest, cn as SEPOLIA_TEST_USDT, co as handoffAttemptFailed, cr as beaconRecords, cs as encodeInviteCode, d as APP_BUNDLE_LIMITS, da as reactionEmoji, dn as USDT_PUBLIC_RPC, do as handoffCancelFrame, dr as lobbyKeys, ds as DEVICES_CAPABILITY, ea as RateWindow, ec as createIdentity, ei as UPGRADE_CAPABILITY, en as encodeDidDhtDeactivated, eo as HANDOFF_OFFER, er as groupSignalFrame, es as readEnrollHello, f as APP_BUNDLE_MAGIC, fa as reactionIsNewer, fn as WALLET_NETWORKS, fo as handoffContext, fr as lobbyRecords, fs as ENROLL_CAPABILITY, g as appViewOf, ga as pairedReplyAuthor, gn as validatePaymentTarget, go as handoffEphemeral, gr as pickHubs, gs as deviceKeyZ32, h as appDigest, ha as wireReaction, hn as formatPaymentAmount, ho as handoffDigest, hr as newerHead, hs as deviceEchoNonce, i as checkAppBeforeRun, ia as readForwarded, ic as verify, ii as HoldKeys, in as ETHEREUM_USDT, io as HANDOFF_TIMINGS, ir as meshHubs, is as readDeviceInvite, it as mentionsMember, j as GROUP_READ_NOTE_COMMUNITY, ja as presenceSeenAt, ji as IdentityExchange, jo as readHandoffPake, js as readTurnPacket, k as COMMUNITY_LIMITS, ki as IDENTITY_REVOCATION_LABEL, ko as readHandoffHave, ks as firstDeviceSetSecret, l as readAppStoreSteps, la as nextReactionNumber, lo as handoffAttemptSucceeded, lr as doorHubs, ma as validReactionNumber, mn as assertWholeSats, mo as handoffDataFrame, mr as mergeLobby, ms as deviceEchoFrame, n as appStoreDecision, na as parseLinkPreview, nc as identityFromSeedB64, ni as HOLD_DATED_BACK_MS, no as HANDOFF_PIECE_BYTES, nr as MESH_HUBS, ns as createDeviceInvite, nt as carryMentions, o as APP_STORE_LIMITS, oa as GROUP_REACTION_FRAME, oc as sanitizeAvatar, oi as isHoldUrl, on as ONCHAIN_PROVIDER, oo as filePartName, or as COMMUNITY_TOPOLOGY, os as createChatInvite, p as APP_ICON_PATH, pa as readReaction, pn as assertTokenUnits, po as handoffContextProof, pr as mergeBeacon, ps as HANDOFF_CAPABILITY, q as parseGroupWakeFrame, qa as LEGACY_SERVICES, qn as engineError, qo as enrollCancelReason, qs as withRequestOptions, r as appUpdateDecision, ra as forwardedAgain, rc as publicKeyFromZ32, ri as HOLD_LIMITS, rn as signDidDhtPacket, ro as HANDOFF_REQUEST, rr as mayBeHub, rs as deviceInviteJoinerParams, rt as parseCommunityEdit, sa as REACTION_LIMITS, si as newMailbox, sn as PaymentPreflightError, so as handoffAttemptAllowed, sr as beaconKeys, ss as decodeInviteCode, t as appRevocationFor, ta as dhtEditId, tc as identityFromSeed, ti as HOLD_CAPABILITY, tn as encodeDidDhtPacket, to as HANDOFF_PAKE, tr as readGroupSignal, ts as verifyEnrollProof, tt as GROUP_EDIT_TEXT_BYTES, ua as queueReaction, un as TEST_USDT_FAUCET_AMOUNT, uo as handoffBusyFrame, ur as freshHubs, us as inviteOwnership, va as replySnippet, vn as SPARK_NETWORKS, vo as handoffManifestFrames, vr as readBeacon, vs as devicePingFrame, wa as verifyCommitSignature, wi as DHT_TEXT_CAPABILITY, wn as isPubkyKey, wo as handoffVersions, wr as transferEnded, ws as TURN_SETTLE_MS, xa as MEMBER_KEY, xn as sparkInvoiceDetails, xo as handoffReleaseFrame, xr as shouldBeHub, xs as webCryptoSigner, y as isAppUrl, ya as GROUP_MEMBER_CAP, yn as SPARK_PROVIDER, yo as handoffOfferFrame, yr as readBeaconHead, ys as newDeviceSetSecret, z as knockIdentity, zi as lastSharedWithMe, zn as findEndpoint, zo as setUpdateFrame, zr as WakeLimiter, zs as DEFAULT_RELAYS } from "./src-DhfGgAUu.js";
4
+ import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-B5L8wuOm.js";
5
+ import { a as sealSeed, i as newDeviceKey, o as unsealSeed, r as intentRepository } from "./networkReason-DxZWHw6v.js";
6
+ import { $ as SourceConfigError, A as identityProviders, B as verifyIdentity, C as FileAppender, D as streamStored, E as storedSize, F as onAdaptersChanged, G as normalizePhrase, H as LightningCards, I as registeredLightningProviders, J as sourceKey, K as lightningPaying, L as registeredOnchainProviders, M as resolveAtprotoDid, N as setOwnDidSource, P as chosenResolver, Q as PROVIDER_PLATFORMS, R as reserveAdapterIds, S as WALLET_TYPES, T as removeStored, U as cardsKey, V as CASHU_CARD, W as isRecoveryPhrase, X as cashuMint, Y as CASHU_MINT_SOURCE, Z as NothingSpentError, _ as crossNetwork, _t as setAwayWallets, a as storedGroupFiles, at as breezDatabaseInUse, b as walletInstances, c as receivedPairedReply, ct as iceServerProblem, d as paymentAlias, dt as ModeGate, et as SourceUnreachableError, f as paymentWireId, ft as WrongNetworkError, g as createTiming, gt as requireTurn, h as createFailure, ht as refuseAway, i as groupOfFile, it as breezDatabase, j as assertPublicServer, l as replyRef, lt as iceServerSetting, m as assertConfirmedReal, mt as awayFrom, n as groupFileId, nt as networkMode, o as groupReply, ot as breezDatabasesFor, p as WALLET_NAMES, pt as networkLabel, q as ProviderSources, r as groupFileText, rt as redact, s as pairedWireReply, st as dropBreezDatabase, t as GroupFileDesk, tt as isNothingSpentError, u as replyTo, ut as ModeChanged, v as paymentNetwork, vt as setSingleWriterGate, w as readStored$1, y as paymentNetworksOf, z as boundedIdentityFetch } from "./groupFiles-BNTSDaoq.js";
7
+ import { i as fedimintDatabase, n as createIrohWebEndpoint, o as loadFedimintSdk, r as irohRelayProblem, t as DEFAULT_IROH_RELAYS } from "./irohWeb--YyEA-hX.js";
8
+ import { c as isTestMint, d as paysItsOwnInvoices, l as isWorthlessMint, n as DEFAULT_MINTS, o as fakesLightning, r as TEST_MINT, t as BITCOIN_INVOICE_ON_TESTNET, u as mintNetwork } from "./mints-BrlY773j.js";
9
+ import { c as fileSpace, f as FILE_BYTES_STEP, o as fileBytes, r as blobDigest, s as fileBytesOf, u as removeFileBytes } from "./fileBytes-Di7VZtmU.js";
10
+ import { n as MAX_BACKUP_BYTES, t as BACKUP_MEDIA_TYPE } from "./envelope-BvU0fLEt.js";
11
+ import { t as hyperdhtRelayProblem } from "./hyperdhtRelay-BT_wRxab.js";
7
12
  import { hkdf } from "@noble/hashes/hkdf.js";
8
13
  import { sha256 } from "@noble/hashes/sha2.js";
9
14
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
10
15
  import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
11
16
  import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
12
17
  import { secp256k1 } from "@noble/curves/secp256k1.js";
13
- import { wordlist } from "@scure/bip39/wordlists/english.js";
14
- import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
15
- import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
16
18
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
19
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
17
20
  import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
18
21
  import { Address, OutScript, Transaction } from "@scure/btc-signer";
19
22
  import * as nip04 from "nostr-tools/nip04";
20
23
  import * as nip44 from "nostr-tools/nip44";
24
+ import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
25
+ import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
21
26
  /** Evidence older than this says nothing about the clock now. */
22
27
  var CLOCK_EVIDENCE_MS = 18e5;
23
28
  /** Sources kept of each kind: a profile has a few relays, and may have hundreds of contacts. */
@@ -180,7 +185,7 @@ async function migrateWalletNetworks(now = Date.now()) {
180
185
  //#endregion
181
186
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
182
187
  /** 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-D0lO2pRR.js");
188
+ var loadUsdt = () => import("./usdt-C1Xao2xH.js");
184
189
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
185
190
  var DEFAULT_USDT = {
186
191
  network: "ethereum",
@@ -529,21 +534,6 @@ var UsdtWallet = class {
529
534
  }
530
535
  };
531
536
  //#endregion
532
- //#region ../browser/src/shared/ice.ts
533
- /**
534
- * What is wrong with a TURN/STUN server, or null. Browsers refuse to create any WebRTC connection when
535
- * one server in the list is malformed, so a bad entry here would silently cut off calls and files.
536
- */
537
- function iceServerProblem(server) {
538
- const urls = server.urls.split(/[\s,]+/).filter(Boolean);
539
- if (!urls.length) return "Enter the TURN server address";
540
- for (const url of urls) {
541
- if (!/^(stun|turns?):[^\s/?#]+(\?transport=(udp|tcp))?$/i.test(url)) return `“${url}” is not a TURN or STUN address (turn:host:port)`;
542
- if (/^turns?:/i.test(url) && (!server.username?.trim() || !server.credential?.trim())) return "A TURN server needs its username and credential";
543
- }
544
- return null;
545
- }
546
- //#endregion
547
537
  //#region ../browser/src/engine/paymentAdapters/backup.ts
548
538
  /**
549
539
  * Wallet database versions a backup may carry. 3 is what SDK 0.4.74-0.4.76 made (Ghostly 1.1.3 and earlier);
@@ -642,7 +632,7 @@ async function restoreArkDatabase(walletId, snapshot) {
642
632
  //#endregion
643
633
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
644
634
  /** 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-B-ALutmU.js");
635
+ var loadArkade = () => import("./arkade-D4BVJFEQ.js");
646
636
  /** How long a Testnet wallet about to make way for another waits for its server to say what it holds. */
647
637
  var RETIRE_READ_MS = 1e4;
648
638
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
@@ -11154,18 +11144,39 @@ var DEFAULT_APP_STORES = [{
11154
11144
  url: "https://raw.githubusercontent.com/MiguelMedeiros/ghostly-store/HEAD/ghostly-store.json",
11155
11145
  key: "y379ia3t1urwudj8o4w1qwmuwp1mcxyf956b5r7pqup7stce6diy"
11156
11146
  }];
11147
+ /**
11148
+ * The apps that come with Ghostly. A short list kept here on purpose, never "everything in the official store": an
11149
+ * entry is added with the pin of its bundle in apps/ui/src/assets/apps/built-in.json, which the build reads from the
11150
+ * app's own repository (apps/ui/scripts/built-in-apps.mjs); no bundle is kept in this repository.
11151
+ *
11152
+ * Chess: Ghostly's own publisher key for it (fingerprint odcg w6wj w8dy nqop), version 2.3.0 (`sequence` 4), at the
11153
+ * commit of its repository that the official store lists.
11154
+ */
11155
+ var BUILT_IN_APPS = [{
11156
+ publisher: "odcgw6wjw8dynqop84r47jbjcdqossbgrfiejd367e14hgxmjcho",
11157
+ name: "chess",
11158
+ digest: "EEnFmP0grTkTe_9qgapQsEEZzWB9FcpoimWkHnfecVs",
11159
+ url: "https://cdn.jsdelivr.net/gh/MiguelMedeiros/ghostly-chess@c665ab194478145fd9b9a4777e6c0d1893969649/app.ghostlyapp",
11160
+ title: "Chess",
11161
+ tagline: "Play chess with a contact, live in your chat"
11162
+ }];
11157
11163
  var APP_STORAGE_LIMITS = {
11158
11164
  /** A key, in UTF-8 bytes. */
11159
11165
  keyBytes: 256,
11160
11166
  /** A value, as compact JSON in UTF-8 bytes. */
11161
11167
  valueBytes: 65536,
11162
11168
  /**
11163
- * Counted for each key beside its own bytes and its value's: what the row costs on the device (the app's reference,
11164
- * the scope and the key again, in the row's own key and in its two indexes). Without it a scope of one-byte values
11165
- * held 20 times its 5 MiB.
11169
+ * Counted for each key beside its own bytes and its value's: what the row costs on the device (the app's reference and
11170
+ * the scope, in the row's own key and in its two indexes). Without it a scope of one-byte values held 20 times its 5 MiB.
11166
11171
  */
11167
11172
  rowBytes: 128,
11168
- /** Every key and value of one app in one scope, with `rowBytes` for each key. */
11173
+ /**
11174
+ * How many times a key's bytes count. The device keeps a key four times (the row's own key, its value and both
11175
+ * indexes), three of them as UTF-16, about 10 bytes for each byte of the key: counted once, a scope of 256-byte keys
11176
+ * took 40 to 55 MB for its 5 MiB.
11177
+ */
11178
+ keyTimes: 8,
11179
+ /** Every key and value of one app in one scope, with `rowBytes` for each key and its bytes `keyTimes` over. */
11169
11180
  scopeBytes: 5242880
11170
11181
  };
11171
11182
  var APP_CHECK_TIMINGS = {
@@ -11178,22 +11189,74 @@ var APP_CHECK_TIMINGS = {
11178
11189
  /** Revocations are checked this long at a time before the page's thread is let go. */
11179
11190
  var REVOCATIONS_SLICE_MS = 10;
11180
11191
  /**
11192
+ * `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first):
11193
+ * at most this many, and of the ones whose list is held at most one file's limit of bytes in all. A publisher's file is
11194
+ * small; nine large ones beside one app's URLs, or one that changes at every check, would stay for the engine's life.
11195
+ */
11196
+ var REVOCATION_FILES_KEPT = {
11197
+ max: 64,
11198
+ bytes: APP_FETCH_LIMITS.revocationsBytes
11199
+ };
11200
+ /**
11181
11201
  * Revocations of store indexes whose signature was checked, kept by `appRevocationId` so an index that changes around
11182
11202
  * them does not have each checked again (the one read longest ago dropped first): four indexes' worth, about 1 MiB.
11183
11203
  */
11184
11204
  var STORE_REVOCATIONS_KEPT = 4 * APP_STORE_LIMITS.revoked;
11185
- /** `ghostly-revoke.json` files whose result is kept, so an unchanged one is not checked again (the oldest dropped first). */
11186
- var REVOCATION_FILES_KEPT = 64;
11187
11205
  /** A fetched bundle kept for its install screen: at most this many, for this long. */
11188
11206
  var STAGED = {
11189
11207
  max: 4,
11190
11208
  ms: 6e5
11191
11209
  };
11210
+ /**
11211
+ * Checked bundles kept in memory: the most recently used `min`, and beyond them each one a running app used (`entry`,
11212
+ * `file`, `fileAt`) within `ms`. The engine is not told when an app closes; one not used for that long goes, and is read
11213
+ * and checked again if it still runs.
11214
+ */
11215
+ var BUNDLES_KEPT = {
11216
+ min: 2,
11217
+ ms: 6e4
11218
+ };
11192
11219
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11193
11220
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11194
11221
  var bundleId = (digest) => `app-${digest}`;
11195
- /** What a row takes of its scope's 5 MiB: its key and value, and `rowBytes`. Not kept in the row, so rows from before count alike. */
11196
- var costOf = (row) => APP_STORAGE_LIMITS.rowBytes + row.size;
11222
+ /**
11223
+ * The key of a scope's counter row (`AppScopeCount` as JSON in `value`, `size` 0): an app's key is never empty
11224
+ * (`keyOf`), and the app's own rows of a scope are `keysOf`, which leaves it out.
11225
+ */
11226
+ var COUNT_KEY = "";
11227
+ var NO_ROWS = {
11228
+ rows: 0,
11229
+ keyBytes: 0,
11230
+ size: 0
11231
+ };
11232
+ /**
11233
+ * What rows take of their scope's 5 MiB: `rowBytes` each, their keys `keyTimes` over and their values. Not kept, so a
11234
+ * count from before counts alike.
11235
+ */
11236
+ var costOf = ({ rows, keyBytes, size }) => rows * APP_STORAGE_LIMITS.rowBytes + size + (APP_STORAGE_LIMITS.keyTimes - 1) * keyBytes;
11237
+ /** One row as a count, `sign` -1 to take it away. */
11238
+ var countOf = ({ key, size }, sign = 1) => ({
11239
+ rows: sign,
11240
+ keyBytes: sign * byteLength(key),
11241
+ size: sign * size
11242
+ });
11243
+ var plus = (a, b) => ({
11244
+ rows: a.rows + b.rows,
11245
+ keyBytes: a.keyBytes + b.keyBytes,
11246
+ size: a.size + b.size
11247
+ });
11248
+ function readCount(value) {
11249
+ try {
11250
+ const c = JSON.parse(value);
11251
+ return [
11252
+ c.rows,
11253
+ c.keyBytes,
11254
+ c.size
11255
+ ].every((n) => Number.isSafeInteger(n) && n >= 0) ? c : null;
11256
+ } catch {
11257
+ return null;
11258
+ }
11259
+ }
11197
11260
  /**
11198
11261
  * A listing's URLs this client reads, in order, a jsDelivr copy first: one this client reads names a full commit, so it
11199
11262
  * holds exactly the version the store reviewed, where a raw `HEAD` may have moved on to one it has not (WISP 1200 §
@@ -11209,6 +11272,46 @@ function fail(code, words) {
11209
11272
  throw new Error(`${code}: ${words}`);
11210
11273
  }
11211
11274
  /**
11275
+ * The key, in `STORES.settings`, of what the person did about the apps that come with Ghostly (one of
11276
+ * `PROFILE_SETTINGS_KEYS`: "Clear all data" drops it, and the profile is offered them as a new one is).
11277
+ */
11278
+ var BUILT_IN_APPS_KEY = "appBuiltIns";
11279
+ /** The marks as kept, read with nothing taken on trust: a record that is not one is no mark. */
11280
+ function builtInMarks(kept) {
11281
+ const removed = {};
11282
+ const held = kept?.removed;
11283
+ if (held && typeof held === "object") {
11284
+ for (const [ref, at] of Object.entries(held)) if (isAppRef(ref) && typeof at === "number") removed[ref] = at;
11285
+ }
11286
+ return { removed };
11287
+ }
11288
+ /** Queues, on the settings store of an open transaction, a change of the built-in apps' marks. */
11289
+ function markBuiltIns(settings, change) {
11290
+ const read = settings.get(BUILT_IN_APPS_KEY);
11291
+ read.onsuccess = () => {
11292
+ const marks = builtInMarks(read.result);
11293
+ change(marks);
11294
+ settings.put(marks, BUILT_IN_APPS_KEY);
11295
+ };
11296
+ }
11297
+ var builtInRef = (app) => appRef(app.publisher, app.name);
11298
+ /**
11299
+ * The entries of the built-in list this profile is offered (WISP 1200 § Built-in apps): each reference once, not
11300
+ * installed (`installed`, by reference) and not removed by the person. An entry that is not a whole one (its key, its
11301
+ * name, its digest, a URL off the list of `appFetch.ts`, no title or tagline) is skipped, as a build with no bundle
11302
+ * for it would be.
11303
+ */
11304
+ function builtInsDue(list, installed, marks) {
11305
+ const due = /* @__PURE__ */ new Map();
11306
+ for (const app of list) {
11307
+ if (!isAppKey(app.publisher) || !isAppHash(app.digest) || !isAppFetchUrl(app.url) || !app.title || !app.tagline) continue;
11308
+ const ref = builtInRef(app);
11309
+ if (!isAppRef(ref) || due.has(ref) || installed.has(ref) || marks.removed[ref] !== void 0) continue;
11310
+ due.set(ref, app);
11311
+ }
11312
+ return [...due.values()];
11313
+ }
11314
+ /**
11212
11315
  * Why a pasted link is not read: a github.com page that is neither a repository nor a file of one (`github-link`), so
11213
11316
  * the person is not told GitHub is off the list; any other host (`host`).
11214
11317
  */
@@ -11231,8 +11334,26 @@ function withRevocations(app, read) {
11231
11334
  revocations: [...merged.values()]
11232
11335
  } : app;
11233
11336
  }
11234
- /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
11337
+ /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`, counter rows included. */
11235
11338
  var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
11339
+ /** The app's own rows of one scope: its counter row left out. */
11340
+ var keysOf = (ref, scope) => IDBKeyRange.bound([
11341
+ ref,
11342
+ scope,
11343
+ COUNT_KEY
11344
+ ], [
11345
+ ref,
11346
+ scope,
11347
+ []
11348
+ ], true);
11349
+ /** A scope's counter row (`COUNT_KEY`). */
11350
+ var counterRow = (ref, scope, count) => ({
11351
+ ref,
11352
+ scope,
11353
+ key: COUNT_KEY,
11354
+ value: JSON.stringify(count),
11355
+ size: 0
11356
+ });
11236
11357
  function done(tx) {
11237
11358
  return new Promise((resolve, reject) => {
11238
11359
  tx.oncomplete = () => resolve();
@@ -11265,8 +11386,10 @@ function peekAppManifest(bytes) {
11265
11386
  var Apps = class {
11266
11387
  host;
11267
11388
  staged = /* @__PURE__ */ new Map();
11268
- /** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
11389
+ /** Bundles read back and checked, by digest, least recently used first (`BUNDLES_KEPT`): the ones running are read once, not on every `file`. */
11269
11390
  verified = /* @__PURE__ */ new Map();
11391
+ /** When a running app last used its bundle, by digest (`BUNDLES_KEPT`): an icon drawn is not a use. */
11392
+ runUsed = /* @__PURE__ */ new Map();
11270
11393
  /** Reads of a bundle under way, by digest: icons drawn at once share one read. */
11271
11394
  verifying = /* @__PURE__ */ new Map();
11272
11395
  /**
@@ -11274,7 +11397,7 @@ var Apps = class {
11274
11397
  * page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
11275
11398
  */
11276
11399
  icons = /* @__PURE__ */ new Map();
11277
- /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
11400
+ /** What each `ref\0scope` holds, read once from its counter row and kept in step by the writes here. */
11278
11401
  usage = /* @__PURE__ */ new Map();
11279
11402
  queues = /* @__PURE__ */ new Map();
11280
11403
  timer = null;
@@ -11298,7 +11421,7 @@ var Apps = class {
11298
11421
  * index listing the same is not peeked again at every check.
11299
11422
  */
11300
11423
  listingMisses = /* @__PURE__ */ new Map();
11301
- /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused. */
11424
+ /** `ghostly-revoke.json` files checked, by the SHA-256 of their bytes: their revocations, or null when refused, and the bytes the held list counts for. */
11302
11425
  revocationFiles = /* @__PURE__ */ new Map();
11303
11426
  /** Revocations read in a store's index and verified, by `appRevocationId`: at most `STORE_REVOCATIONS_KEPT`. */
11304
11427
  storeRevocations = /* @__PURE__ */ new Set();
@@ -11391,6 +11514,59 @@ var Apps = class {
11391
11514
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
11392
11515
  return await this.app(ref) ?? fail("not-installed", "This app is not installed");
11393
11516
  }
11517
+ /** The built-in list's entry of this reference, if it has one. */
11518
+ builtIn(ref) {
11519
+ return (this.host.builtIns ?? BUILT_IN_APPS).find((app) => builtInRef(app) === ref);
11520
+ }
11521
+ async builtInMarks() {
11522
+ return builtInMarks(await wrap((await store(STORES$1.settings, "readonly")).get(BUILT_IN_APPS_KEY)));
11523
+ }
11524
+ /**
11525
+ * The built-in apps this profile is offered (WISP 1200 § Built-in apps): shown with the installed ones, with nothing
11526
+ * stored and nothing granted. Opening one is an install like any other, from the bundle the build carries.
11527
+ */
11528
+ async builtIns() {
11529
+ const list = this.host.builtIns ?? BUILT_IN_APPS;
11530
+ if (!list.length || !this.host.readBuiltIn) return [];
11531
+ const [installed, marks] = await Promise.all([this.installed(), this.builtInMarks()]);
11532
+ return builtInsDue(list, new Set(installed.map((a) => a.ref)), marks).map((app) => ({
11533
+ ref: builtInRef(app),
11534
+ publisher: app.publisher,
11535
+ name: app.name,
11536
+ fingerprint: appFingerprint(app.publisher),
11537
+ digest: app.digest,
11538
+ url: app.url,
11539
+ title: app.title,
11540
+ tagline: app.tagline
11541
+ }));
11542
+ }
11543
+ /**
11544
+ * The bundle the build carries for a built-in app, checked as a fetched one is, and against its entry: the digest the
11545
+ * list pins and the entry's reference. Anything else in that file is not this app, and nothing of it is kept.
11546
+ */
11547
+ async bundled(app) {
11548
+ if (!this.host.readBuiltIn) fail("not-carried", "This build does not carry this app");
11549
+ let bytes;
11550
+ try {
11551
+ bytes = await this.host.readBuiltIn(app);
11552
+ } catch {
11553
+ fail("not-carried", "This build does not carry this app");
11554
+ }
11555
+ if (!(bytes instanceof Uint8Array) || bytes.length > APP_BUNDLE_LIMITS.bundleBytes) fail("not-carried", "This build does not carry this app");
11556
+ const read = await readAppBundleAsync(bytes);
11557
+ if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
11558
+ if (read.bundle.digest !== app.digest || versionOf(read.bundle.manifest, read.bundle.digest).ref !== builtInRef(app)) fail("unlisted", "The app this build carries is not the one it names");
11559
+ return {
11560
+ bundle: read.bundle,
11561
+ bytes
11562
+ };
11563
+ }
11564
+ /** The built-in entry of this reference while the profile is offered it (`builtIns`), else undefined. */
11565
+ async offered(ref) {
11566
+ const app = this.builtIn(ref);
11567
+ if (!app || !this.host.readBuiltIn) return void 0;
11568
+ return builtInsDue([app], await this.app(ref) ? /* @__PURE__ */ new Set([ref]) : /* @__PURE__ */ new Set(), await this.builtInMarks())[0];
11569
+ }
11394
11570
  /**
11395
11571
  * An installed app that may still run (WISP 1200 § Takedowns): a running app whose version its publisher revoked, or
11396
11572
  * a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
@@ -11543,7 +11719,7 @@ var Apps = class {
11543
11719
  */
11544
11720
  async writeBundle(digest, bytes) {
11545
11721
  const id = bundleId(digest);
11546
- const expected = toBase64Url(sha256(bytes));
11722
+ const expected = toBase64Url(await sha256Paced(bytes));
11547
11723
  let files;
11548
11724
  let stored;
11549
11725
  try {
@@ -11588,6 +11764,7 @@ var Apps = class {
11588
11764
  }
11589
11765
  async removeBundle(kind, digest) {
11590
11766
  this.verified.delete(digest);
11767
+ this.runUsed.delete(digest);
11591
11768
  this.icons.delete(digest);
11592
11769
  await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
11593
11770
  }
@@ -11597,18 +11774,24 @@ var Apps = class {
11597
11774
  if (app?.digest !== digest && app?.pending?.digest !== digest) await this.removeBundle(kind, digest);
11598
11775
  }
11599
11776
  /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
11600
- verifiedBundle(app) {
11777
+ verifiedBundle(app, running) {
11778
+ if (running) this.runUsed.set(app.digest, this.now());
11601
11779
  const kept = this.verified.get(app.digest);
11602
- if (kept) return Promise.resolve(kept);
11780
+ if (kept) {
11781
+ this.verified.delete(app.digest);
11782
+ this.verified.set(app.digest, kept);
11783
+ this.dropUnusedBundles();
11784
+ return Promise.resolve(kept);
11785
+ }
11603
11786
  const under = this.verifying.get(app.digest);
11604
11787
  if (under) return under;
11605
11788
  const reading = (async () => {
11606
11789
  const bytes = await this.readBundleBytes(app.bytes, app.digest);
11607
11790
  if (!bytes) fail("needs-files", "This app's files are not on this device yet");
11608
- const read = readAppBundle(bytes);
11791
+ const read = await readAppBundleAsync(bytes);
11609
11792
  if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
11610
- if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
11611
11793
  this.verified.set(app.digest, read.bundle);
11794
+ this.dropUnusedBundles();
11612
11795
  this.icons.set(app.digest, read.bundle.files.get("icon.png")?.slice() ?? null);
11613
11796
  return read.bundle;
11614
11797
  })().finally(() => {
@@ -11617,9 +11800,21 @@ var Apps = class {
11617
11800
  this.verifying.set(app.digest, reading);
11618
11801
  return reading;
11619
11802
  }
11803
+ /**
11804
+ * Apps running at once do not push each other's bundle out, which every `file` read would then read and check again
11805
+ * whole; one closed (or only drawn as an icon) goes once two newer ones are kept, at the next bundle read or hit.
11806
+ */
11807
+ dropUnusedBundles() {
11808
+ for (const digest of this.verified.keys()) {
11809
+ if (this.verified.size <= BUNDLES_KEPT.min) break;
11810
+ if (this.now() - (this.runUsed.get(digest) ?? -Infinity) < BUNDLES_KEPT.ms) continue;
11811
+ this.verified.delete(digest);
11812
+ this.runUsed.delete(digest);
11813
+ }
11814
+ }
11620
11815
  async fetchBundle(url) {
11621
11816
  const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
11622
- const read = readAppBundle(bytes);
11817
+ const read = await readAppBundleAsync(bytes);
11623
11818
  if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
11624
11819
  return {
11625
11820
  bundle: read.bundle,
@@ -11635,7 +11830,7 @@ var Apps = class {
11635
11830
  */
11636
11831
  async readStoreAt(url, heldKey, held) {
11637
11832
  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 })]);
11638
- if (held?.index && held.digest !== void 0 && toBase64Url(sha256(indexBytes)) === held.digest) return {
11833
+ if (held?.index && held.digest !== void 0 && toBase64Url(await sha256Paced(indexBytes)) === held.digest) return {
11639
11834
  ok: true,
11640
11835
  store: {
11641
11836
  index: held.index,
@@ -11644,14 +11839,11 @@ var Apps = class {
11644
11839
  }
11645
11840
  };
11646
11841
  const steps = readAppStoreSteps(indexBytes, sigBytes, this.nowS(), heldKey, this.checkedRevocations);
11647
- let since = performance.now();
11842
+ const pause = pacer(REVOCATIONS_SLICE_MS);
11648
11843
  for (;;) {
11649
11844
  const step = steps.next();
11650
11845
  if (step.done) return step.value;
11651
- if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11652
- await new Promise((resolve) => setTimeout(resolve, 0));
11653
- since = performance.now();
11654
- }
11846
+ await pause();
11655
11847
  }
11656
11848
  }
11657
11849
  /**
@@ -11675,19 +11867,16 @@ var Apps = class {
11675
11867
  * engine shares the page's thread, and each entry is a signature check. The same bytes are not checked again.
11676
11868
  */
11677
11869
  async readRevocationsFile(bytes) {
11678
- const id = toBase64Url(sha256(bytes));
11870
+ const id = toBase64Url(await sha256Paced(bytes));
11679
11871
  const known = this.revocationFiles.get(id);
11680
- if (known !== void 0) return known;
11872
+ if (known !== void 0) return known.revocations;
11681
11873
  const read = readCanonicalJson(bytes);
11682
11874
  let out = null;
11683
11875
  if (read.ok && Array.isArray(read.value) && read.value.length <= APP_STATEMENT_LIMITS.revocations) {
11684
11876
  out = [];
11685
- let since = performance.now();
11877
+ const pause = pacer(REVOCATIONS_SLICE_MS);
11686
11878
  for (const entry of read.value) {
11687
- if (performance.now() - since >= REVOCATIONS_SLICE_MS) {
11688
- await new Promise((resolve) => setTimeout(resolve, 0));
11689
- since = performance.now();
11690
- }
11879
+ await pause();
11691
11880
  const one = readAppRevocation(entry);
11692
11881
  if (!one.ok) {
11693
11882
  out = null;
@@ -11697,8 +11886,17 @@ var Apps = class {
11697
11886
  }
11698
11887
  }
11699
11888
  this.revocationFiles.delete(id);
11700
- this.revocationFiles.set(id, out);
11701
- for (const old of this.revocationFiles.keys()) if (this.revocationFiles.size > REVOCATION_FILES_KEPT) this.revocationFiles.delete(old);
11889
+ this.revocationFiles.set(id, {
11890
+ revocations: out,
11891
+ bytes: out ? bytes.length : 0
11892
+ });
11893
+ let held = 0;
11894
+ for (const file of this.revocationFiles.values()) held += file.bytes;
11895
+ for (const [old, file] of this.revocationFiles) {
11896
+ if (this.revocationFiles.size <= REVOCATION_FILES_KEPT.max && held <= REVOCATION_FILES_KEPT.bytes) break;
11897
+ this.revocationFiles.delete(old);
11898
+ held -= file.bytes;
11899
+ }
11702
11900
  return out;
11703
11901
  }
11704
11902
  summary(s) {
@@ -11876,6 +12074,12 @@ var Apps = class {
11876
12074
  let urls;
11877
12075
  let expect = null;
11878
12076
  let fromStore;
12077
+ if ("builtIn" in source) {
12078
+ if (!isAppRef(source.builtIn)) fail("bad-ref", "Not an app reference");
12079
+ const app = await this.offered(source.builtIn) ?? fail("not-offered", "This app does not come with Ghostly here");
12080
+ const { bundle, bytes } = await this.bundled(app);
12081
+ return this.stage(bundle, bytes, app.url, void 0, true);
12082
+ }
11879
12083
  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")];
11880
12084
  else if ("store" in source) {
11881
12085
  const s = await this.storeRecord(source.store);
@@ -11893,6 +12097,11 @@ var Apps = class {
11893
12097
  const { ref, url, sequence, digest } = source.card;
11894
12098
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
11895
12099
  if (!isAppFetchUrl(url)) fail("host", "A card's app is read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit");
12100
+ const carried = typeof digest === "string" ? await this.offered(ref) : void 0;
12101
+ if (carried && carried.digest === digest) {
12102
+ const held = await this.bundled(carried).catch(() => null);
12103
+ if (held) return this.stage(held.bundle, held.bytes, carried.url, void 0, true);
12104
+ }
11896
12105
  urls = [url];
11897
12106
  expect = {
11898
12107
  ref,
@@ -11924,7 +12133,7 @@ var Apps = class {
11924
12133
  const oldest = this.staged.values().next().value;
11925
12134
  if (oldest) this.sweep = setTimeout(() => this.sweepStaged(), STAGED.ms - (this.now() - oldest.at));
11926
12135
  }
11927
- async stage(bundle, bytes, from, fromStore) {
12136
+ async stage(bundle, bytes, from, fromStore, carried = false) {
11928
12137
  for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
11929
12138
  this.staged.delete(bundle.digest);
11930
12139
  while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
@@ -11955,7 +12164,8 @@ var Apps = class {
11955
12164
  asks,
11956
12165
  run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
11957
12166
  listedBy,
11958
- unknownPublisher: !listedBy.some((s) => s.kind === "curated")
12167
+ unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
12168
+ ...carried && { carried: true }
11959
12169
  };
11960
12170
  }
11961
12171
  /**
@@ -12009,6 +12219,7 @@ var Apps = class {
12009
12219
  const kind = await this.writeBundle(digest, bytes);
12010
12220
  const next = this.versionRecord(installed, bundle, kind, from, fromStore);
12011
12221
  await this.putApp(next);
12222
+ if (!installed && this.builtIn(version.ref)) await this.keepBuiltIn(version.ref);
12012
12223
  if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12013
12224
  if (installed?.pending && next.pending?.digest !== installed.pending.digest && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
12014
12225
  this.staged.delete(digest);
@@ -12047,7 +12258,7 @@ var Apps = class {
12047
12258
  const installed = await this.installedOrFail(ref);
12048
12259
  const pending = installed.pending ?? fail("no-update", "No update waits for this app");
12049
12260
  const bytes = await this.readBundleBytes(pending.bytes, pending.digest);
12050
- const read = bytes ? readAppBundle(bytes) : null;
12261
+ const read = bytes ? await readAppBundleAsync(bytes) : null;
12051
12262
  if (!read?.ok || read.bundle.digest !== pending.digest) {
12052
12263
  const { pending: _p, ...rest } = installed;
12053
12264
  await this.putApp(rest);
@@ -12282,6 +12493,26 @@ var Apps = class {
12282
12493
  const app = await this.installedOrFail(ref);
12283
12494
  const stores = await this.stores();
12284
12495
  if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
12496
+ const carried = this.builtIn(app.ref);
12497
+ if (carried && carried.digest === app.digest) {
12498
+ const held = await this.bundled(carried).catch(() => null);
12499
+ if (held) {
12500
+ const kind = await this.writeBundle(app.digest, held.bytes);
12501
+ const now = await this.app(app.ref);
12502
+ if (!now || now.digest !== app.digest) {
12503
+ await this.removeBundle(kind, app.digest);
12504
+ fail("not-installed", "This app is not installed");
12505
+ }
12506
+ if (now.bytes !== kind) await this.putApp({
12507
+ ...now,
12508
+ bytes: kind
12509
+ });
12510
+ return this.runCheckOf({
12511
+ ...now,
12512
+ bytes: kind
12513
+ }, stores);
12514
+ }
12515
+ }
12285
12516
  const urls = [app.from];
12286
12517
  for (const s of stores) {
12287
12518
  if (app.store !== void 0 && s.key !== app.store) continue;
@@ -12289,6 +12520,14 @@ var Apps = class {
12289
12520
  }
12290
12521
  if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
12291
12522
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
12523
+ try {
12524
+ if (peekAppManifest(await this.host.fetch(url, {
12525
+ maxBytes: PEEK_BYTES,
12526
+ peek: true
12527
+ }))?.digest !== app.digest) continue;
12528
+ } catch {
12529
+ continue;
12530
+ }
12292
12531
  let fetched;
12293
12532
  try {
12294
12533
  fetched = await this.fetchBundle(url);
@@ -12336,7 +12575,7 @@ var Apps = class {
12336
12575
  if (run.status === "removed" && runAnyway !== true) fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12337
12576
  if (run.status === "needs-files") fail("needs-files", "This app's files are not on this device yet");
12338
12577
  if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
12339
- const bundle = await this.verifiedBundle(app);
12578
+ const bundle = await this.verifiedBundle(app, true);
12340
12579
  const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
12341
12580
  this.ran.add(app.ref);
12342
12581
  const versions = this.started.get(app.ref) ?? /* @__PURE__ */ new Map();
@@ -12351,14 +12590,40 @@ var Apps = class {
12351
12590
  entry: utf8Decode(entry)
12352
12591
  };
12353
12592
  }
12354
- /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). `digest`: the version the frame runs. */
12593
+ /**
12594
+ * Where a file of the installed bundle lies in this device's file storage, for a client that hands the bytes to the
12595
+ * app itself (Desktop: Rust reads them, checks their SHA-256 and gives them to the app's window, so they never cross
12596
+ * the Ghostly window). The same checks as `file`; null where the bundle is not in such storage, and for the icon,
12597
+ * which `file` answers from memory.
12598
+ */
12599
+ async fileAt({ ref, path, digest }) {
12600
+ if (path === "icon.png") return null;
12601
+ const app = await this.runningOrFail(ref, digest);
12602
+ if (digest !== void 0 && digest !== app.digest) fail("no-file", "No such file in this app");
12603
+ if (app.bytes !== "native") return null;
12604
+ const bundle = await this.verifiedBundle(app, true);
12605
+ const file = typeof path === "string" ? bundle.files.get(path) : void 0;
12606
+ const listed = bundle.manifest.files.find((f) => f.path === path);
12607
+ if (!file || !listed) fail("no-file", "No such file in this app");
12608
+ return {
12609
+ space: fileSpace(),
12610
+ id: bundleId(app.digest),
12611
+ offset: file.byteOffset,
12612
+ length: file.length,
12613
+ sha256: listed.sha256
12614
+ };
12615
+ }
12616
+ /**
12617
+ * A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`): a copy, which the broker transfers to
12618
+ * the app as it is. `digest`: the version the frame runs.
12619
+ */
12355
12620
  async file({ ref, path, digest }) {
12356
12621
  const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref, digest);
12357
12622
  if (path !== "icon.png" && digest !== void 0 && digest !== app.digest) fail("no-file", "No such file in this app");
12358
12623
  const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
12359
12624
  if (icon) return icon.slice();
12360
12625
  if (icon === null) fail("no-file", "No such file in this app");
12361
- const bundle = await this.verifiedBundle(app);
12626
+ const bundle = await this.verifiedBundle(app, path !== APP_ICON_PATH);
12362
12627
  if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12363
12628
  return bundle.files.get(path).slice();
12364
12629
  }
@@ -12389,6 +12654,7 @@ var Apps = class {
12389
12654
  run: await this.runCheckOf(app, stores),
12390
12655
  listedBy,
12391
12656
  unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
12657
+ ...this.builtIn(app.ref)?.digest === app.digest && { builtIn: true },
12392
12658
  ...app.pending && { pending: {
12393
12659
  sequence: app.pending.sequence,
12394
12660
  version: app.pending.manifest.version,
@@ -12408,23 +12674,44 @@ var Apps = class {
12408
12674
  }
12409
12675
  /**
12410
12676
  * Uninstalls an app: its bundle (and a waiting update's), its record and its storage in every scope. The person chose
12411
- * it, after the offer of an export (`exportData`).
12677
+ * it, after the offer of an export (`exportData`). A built-in app is marked removed in the same transaction, and one
12678
+ * the person never opened is only marked: no start and no update offers it again (WISP 1200 § Built-in apps).
12412
12679
  */
12413
12680
  async uninstall({ ref }) {
12414
- const app = await this.installedOrFail(ref);
12415
- const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
12416
- tx.objectStore(STORES$1.apps).delete(app.ref);
12417
- tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
12681
+ if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
12682
+ const app = await this.app(ref);
12683
+ const builtIn = this.builtIn(ref) !== void 0;
12684
+ if (!app && !builtIn) fail("not-installed", "This app is not installed");
12685
+ const tx = (await openDb()).transaction([
12686
+ STORES$1.apps,
12687
+ STORES$1.appStorage,
12688
+ ...builtIn ? [STORES$1.settings] : []
12689
+ ], "readwrite");
12690
+ tx.objectStore(STORES$1.apps).delete(ref);
12691
+ tx.objectStore(STORES$1.appStorage).delete(rowsOf(ref));
12692
+ if (builtIn) markBuiltIns(tx.objectStore(STORES$1.settings), (marks) => {
12693
+ marks.removed[ref] ??= this.now();
12694
+ });
12418
12695
  try {
12419
12696
  await done(tx);
12420
12697
  } finally {
12421
- this.held.delete(app.ref);
12698
+ this.held.delete(ref);
12422
12699
  }
12423
- for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
12424
- this.started.delete(app.ref);
12700
+ for (const key of [...this.usage.keys()]) if (key.startsWith(`${ref}\0`)) this.usage.delete(key);
12701
+ this.started.delete(ref);
12702
+ if (!app) return;
12425
12703
  await this.removeBundle(app.bytes, app.digest);
12426
12704
  if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
12427
12705
  }
12706
+ /** Takes the "removed by the person" mark off a built-in app, if it has one. */
12707
+ async keepBuiltIn(ref) {
12708
+ if ((await this.builtInMarks()).removed[ref] === void 0) return;
12709
+ const tx = (await openDb()).transaction(STORES$1.settings, "readwrite");
12710
+ markBuiltIns(tx.objectStore(STORES$1.settings), (marks) => {
12711
+ delete marks.removed[ref];
12712
+ });
12713
+ await done(tx);
12714
+ }
12428
12715
  scopeOf(scope) {
12429
12716
  if (scope === "alone" || typeof scope === "string" && this.host.isChat(scope)) return scope;
12430
12717
  fail("bad-scope", "Not a chat of this profile");
@@ -12443,13 +12730,24 @@ var Apps = class {
12443
12730
  });
12444
12731
  return run;
12445
12732
  }
12733
+ /**
12734
+ * What a scope holds: its counter row, when the number of rows agrees with it (one key count, no value read). A
12735
+ * scope without one (written before it was kept) or where it disagrees (rows written by an older client) is
12736
+ * counted from its rows once.
12737
+ */
12446
12738
  async usageOf(ref, scope) {
12447
12739
  const id = `${ref}\0${scope}`;
12448
- let used = this.usage.get(id);
12449
- if (used === void 0) {
12450
- used = (await wrap((await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX).getAll([ref, scope]))).reduce((sum, r) => sum + costOf(r), 0);
12451
- this.usage.set(id, used);
12452
- }
12740
+ const cached = this.usage.get(id);
12741
+ if (cached) return cached;
12742
+ const rows = await store(STORES$1.appStorage, "readonly");
12743
+ const [counter, n] = await Promise.all([wrap(rows.get([
12744
+ ref,
12745
+ scope,
12746
+ COUNT_KEY
12747
+ ])), wrap(rows.count(keysOf(ref, scope)))]);
12748
+ let used = counter ? readCount(counter.value) : n ? null : NO_ROWS;
12749
+ if (!used || used.rows !== n) used = (await wrap((await store(STORES$1.appStorage, "readonly")).getAll(keysOf(ref, scope)))).reduce((sum, r) => plus(sum, countOf(r)), NO_ROWS);
12750
+ this.usage.set(id, used);
12453
12751
  return used;
12454
12752
  }
12455
12753
  async storageGet({ ref, scope, key, digest }) {
@@ -12463,8 +12761,8 @@ var Apps = class {
12463
12761
  }
12464
12762
  /**
12465
12763
  * Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope, each counted
12466
- * with `rowBytes`. A scope already past that (filled before a key counted `rowBytes`) still takes a write that does
12467
- * not grow it, so the app can make room.
12764
+ * as `costOf` says. A scope already past that (filled under an older count) still takes a write that does not grow
12765
+ * it, so the app can make room.
12468
12766
  */
12469
12767
  async storageSet({ ref, scope, key, value, digest }) {
12470
12768
  const app = await this.runningOrFail(ref, digest);
@@ -12487,20 +12785,24 @@ var Apps = class {
12487
12785
  where,
12488
12786
  name
12489
12787
  ]));
12490
- const next = used - (old ? costOf(old) : 0) + APP_STORAGE_LIMITS.rowBytes + size;
12491
- if (next > APP_STORAGE_LIMITS.scopeBytes && next > used) fail("full", "This app's storage in this chat is full (5 MiB)");
12788
+ const next = plus(old ? plus(used, countOf(old, -1)) : used, countOf({
12789
+ key: name,
12790
+ size
12791
+ }));
12792
+ if (costOf(next) > APP_STORAGE_LIMITS.scopeBytes && costOf(next) > costOf(used)) fail("full", "This app's storage in this chat is full (5 MiB)");
12492
12793
  const writing = await store(STORES$1.appStorage, "readwrite");
12493
12794
  if (where !== "alone" && !this.host.isChat(where)) {
12494
12795
  this.usage.delete(`${app.ref}\0${where}`);
12495
12796
  fail("bad-scope", "Not a chat of this profile");
12496
12797
  }
12497
- await wrap(writing.put({
12798
+ writing.put({
12498
12799
  ref: app.ref,
12499
12800
  scope: where,
12500
12801
  key: name,
12501
12802
  value: text,
12502
12803
  size
12503
- }));
12804
+ });
12805
+ await wrap(writing.put(counterRow(app.ref, where, next)));
12504
12806
  this.usage.set(`${app.ref}\0${where}`, next);
12505
12807
  });
12506
12808
  }
@@ -12515,39 +12817,73 @@ var Apps = class {
12515
12817
  name
12516
12818
  ]));
12517
12819
  if (!old) return;
12518
- await wrap((await store(STORES$1.appStorage, "readwrite")).delete([
12820
+ const next = plus(used, countOf(old, -1));
12821
+ const writing = await store(STORES$1.appStorage, "readwrite");
12822
+ writing.delete([
12519
12823
  app.ref,
12520
12824
  where,
12521
12825
  name
12522
- ]));
12523
- this.usage.set(`${app.ref}\0${where}`, used - costOf(old));
12826
+ ]);
12827
+ await (next.rows ? wrap(writing.put(counterRow(app.ref, where, next))) : wrap(writing.delete([
12828
+ app.ref,
12829
+ where,
12830
+ COUNT_KEY
12831
+ ])));
12832
+ this.usage.set(`${app.ref}\0${where}`, next);
12524
12833
  });
12525
12834
  }
12526
12835
  async storageKeys({ ref, scope, digest }) {
12527
12836
  const app = await this.runningOrFail(ref, digest);
12528
- return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
12837
+ return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(keysOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
12529
12838
  }
12530
12839
  /** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
12531
12840
  async exportData({ ref }) {
12532
- const app = await this.installedOrFail(ref);
12533
- const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(rowsOf(app.ref)));
12534
- const scopes = /* @__PURE__ */ new Map();
12535
- for (const row of rows) {
12536
- const out = scopes.get(row.scope) ?? {
12841
+ return (await this.exportDataText({ ref })).map(({ app, scope, entries }) => {
12842
+ const out = {
12537
12843
  ghostlyAppData: 1,
12538
- app: app.ref,
12539
- scope: row.scope,
12844
+ app,
12845
+ scope,
12540
12846
  entries: {}
12541
12847
  };
12542
- Object.defineProperty(out.entries, row.key, {
12543
- value: JSON.parse(row.value),
12848
+ for (const [key, json] of entries) Object.defineProperty(out.entries, key, {
12849
+ value: JSON.parse(json),
12544
12850
  enumerable: true,
12545
12851
  writable: true,
12546
12852
  configurable: true
12547
12853
  });
12548
- scopes.set(row.scope, out);
12854
+ return out;
12855
+ });
12856
+ }
12857
+ /**
12858
+ * `exportData` with each value as the JSON text it is kept as, for a file written without parsing and writing every
12859
+ * value again. Read a scope at a time, the thread let go between them: ten chats of 5 MiB read at once held it for
12860
+ * the whole read.
12861
+ */
12862
+ async exportDataText({ ref }) {
12863
+ const app = await this.installedOrFail(ref);
12864
+ const scopes = [];
12865
+ const index = (await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX);
12866
+ await new Promise((resolve, reject) => {
12867
+ const request = index.openKeyCursor(rowsOf(app.ref), "nextunique");
12868
+ request.onerror = () => reject(request.error);
12869
+ request.onsuccess = () => {
12870
+ const cursor = request.result;
12871
+ if (!cursor) return resolve();
12872
+ scopes.push(cursor.key[1]);
12873
+ cursor.continue();
12874
+ };
12875
+ });
12876
+ const out = [];
12877
+ for (const [i, scope] of scopes.entries()) {
12878
+ if (i) await new Promise((resolve) => setTimeout(resolve, 0));
12879
+ const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(keysOf(app.ref, scope)));
12880
+ if (rows.length) out.push({
12881
+ app: app.ref,
12882
+ scope,
12883
+ entries: rows.map((row) => [row.key, row.value])
12884
+ });
12549
12885
  }
12550
- return [...scopes.values()];
12886
+ return out;
12551
12887
  }
12552
12888
  };
12553
12889
  //#endregion
@@ -16672,7 +17008,7 @@ function handoffNotices(was, handoff, session) {
16672
17008
  * device link must offer every transport both devices run: a Linux Desktop has no WebRTC, and an Iroh relay that
16673
17009
  * one side cannot reach leaves HyperDHT as the only way through.
16674
17010
  */
16675
- var NATIVE_RETRY_MS = 1e4;
17011
+ var NATIVE_RETRY_MS$1 = 1e4;
16676
17012
  var NATIVE_RETRY_MAX_MS = 12e4;
16677
17013
  /** How often a standby reads the turn while its screen shows (WISP 06 § When a device checks), and the jitter on it. */
16678
17014
  var STANDBY_TURN_EVERY_MS = 6e5;
@@ -16981,7 +17317,7 @@ var DeviceLinks = class {
16981
17317
  }
16982
17318
  retryNative(running) {
16983
17319
  if (running.nativeRetry || this.stopped || this.running.get(running.id) !== running) return;
16984
- const wait = Math.min(NATIVE_RETRY_MS * 2 ** running.nativeMisses, NATIVE_RETRY_MAX_MS);
17320
+ const wait = Math.min(NATIVE_RETRY_MS$1 * 2 ** running.nativeMisses, NATIVE_RETRY_MAX_MS);
16985
17321
  running.nativeMisses++;
16986
17322
  running.nativeRetry = setTimeout(() => {
16987
17323
  running.nativeRetry = void 0;
@@ -19355,6 +19691,7 @@ var FileDesk = class {
19355
19691
  openSource: (record) => this.openSource(linkId, record),
19356
19692
  changed: (record, transferred, progress) => this.changed(linkId, record, transferred, progress),
19357
19693
  room: async () => (await fileBytes()).room(),
19694
+ ...this.deps.sendData && { sendData: (id, offset, data) => this.deps.sendData(linkId, id, offset, data) },
19358
19695
  writable: () => this.deps.writable?.(linkId)
19359
19696
  });
19360
19697
  this.chats.set(linkId, made);
@@ -19374,6 +19711,7 @@ var FileDesk = class {
19374
19711
  records.push(file.wire3);
19375
19712
  if (file.wire3.direction === "in") chat.local.set(file.wire3.id, file.id);
19376
19713
  if (!transferEnded(file.wire3)) this.restored.add(file.id);
19714
+ if (file.wire3.state === "done") continue;
19377
19715
  this.deps.transfers.set(file.id, this.view(chat, file.wire3, file.wire3.confirmed, transferStalled(file.wire3, Date.now(), this.restored.has(file.id), Date.now())));
19378
19716
  }
19379
19717
  chat.files.restore(records);
@@ -19814,7 +20152,7 @@ var FileDesk = class {
19814
20152
  view.room = this.deps.transfers.get(id)?.room;
19815
20153
  this.room(id);
19816
20154
  }
19817
- this.deps.transfers.set(id, view);
20155
+ if (record.state !== "done" || this.underway.has(id) || this.deps.transfers.has(id)) this.deps.transfers.set(id, view);
19818
20156
  if (!transferEnded(record)) {
19819
20157
  this.underway.add(id);
19820
20158
  this.ended.delete(id);
@@ -20342,6 +20680,10 @@ var Outbox = class {
20342
20680
  get sameLink() {
20343
20681
  return !this.stopped && this.closed === this.began;
20344
20682
  }
20683
+ /** One message as stored now: by its id when the store can, else picked out of the chat's history. */
20684
+ async one(id) {
20685
+ return this.store.get ? this.store.get(id) : (await this.store.read()).find((m) => m.id === id);
20686
+ }
20345
20687
  /** A new message a turn could not send (the link closed under it, or the ones before it still wait): it waits too. */
20346
20688
  async waitsItsTurn(message) {
20347
20689
  await this.store.update(message.id, "waiting", "Waits for the messages before it to go.", {
@@ -20446,7 +20788,7 @@ var Outbox = class {
20446
20788
  this.busy.add(id);
20447
20789
  let again;
20448
20790
  try {
20449
- let message = read ?? (await this.store.read()).find((m) => m.id === id);
20791
+ let message = read ?? await this.one(id);
20450
20792
  if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
20451
20793
  const wait = this.waiting.get(id);
20452
20794
  if (wait?.timer) {
@@ -20487,7 +20829,7 @@ var Outbox = class {
20487
20829
  again = this.reopenedWhileBusy.delete(id);
20488
20830
  }
20489
20831
  if (again) {
20490
- const message = (await this.store.read()).find((m) => m.id === id);
20832
+ const message = await this.one(id);
20491
20833
  if (message) await this.again(message);
20492
20834
  }
20493
20835
  }
@@ -20497,7 +20839,7 @@ var Outbox = class {
20497
20839
  * nothing about it is unconfirmed; it counts no attempt until it goes.
20498
20840
  */
20499
20841
  async wait(id, reason) {
20500
- const message = (await this.store.read()).find((m) => m.id === id);
20842
+ const message = await this.one(id);
20501
20843
  if (!message || this.stopped) return;
20502
20844
  await this.store.update(id, "waiting", reason, { resendUntil: message.resendUntil ?? this.now() + this.policy.windowMs });
20503
20845
  this.schedule(id);
@@ -20587,7 +20929,7 @@ var Outbox = class {
20587
20929
  */
20588
20930
  async attempt(id, passed) {
20589
20931
  if (this.stopped || this.busy.has(id)) return true;
20590
- const message = (await this.store.read()).find((m) => m.id === id);
20932
+ const message = await this.one(id);
20591
20933
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
20592
20934
  this.forget(id);
20593
20935
  return true;
@@ -20614,7 +20956,7 @@ var Outbox = class {
20614
20956
  }
20615
20957
  /** After a failed attempt: queued again, unless its window or its attempts are spent. */
20616
20958
  async requeue(id, reason) {
20617
- const message = (await this.store.read()).find((m) => m.id === id);
20959
+ const message = await this.one(id);
20618
20960
  if (message && message.delivery !== "delivered") await this.queue(message, reason);
20619
20961
  }
20620
20962
  async queue(message, reason) {
@@ -20662,7 +21004,7 @@ var Outbox = class {
20662
21004
  this.turn(async () => {
20663
21005
  if (this.receipts.get(id) !== timer) return;
20664
21006
  this.receipts.delete(id);
20665
- const message = (await this.store.read()).find((m) => m.id === id);
21007
+ const message = await this.one(id);
20666
21008
  if (message?.via === "pkarr") await this.expired(message);
20667
21009
  else await this.requeue(id, "No receipt received yet.");
20668
21010
  }).catch(() => {});
@@ -21349,8 +21691,9 @@ var db = {
21349
21691
  tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("Link storage failed"));
21350
21692
  });
21351
21693
  },
21352
- async patchLink(linkId, patch) {
21353
- const tx = (await openDb()).transaction(STORES$1.links, "readwrite");
21694
+ /** `relaxed`: for what a power cut may cost (a round trip, a note made again later), no disk sync of its own. */
21695
+ async patchLink(linkId, patch, relaxed = false) {
21696
+ const tx = (await openDb()).transaction(STORES$1.links, "readwrite", relaxed ? RELAXED : void 0);
21354
21697
  const links = tx.objectStore(STORES$1.links);
21355
21698
  const request = links.get(linkId);
21356
21699
  request.onsuccess = () => {
@@ -21611,6 +21954,32 @@ var db = {
21611
21954
  });
21612
21955
  return result;
21613
21956
  },
21957
+ /**
21958
+ * `patchMessage` on these rows of one history, all in one transaction: one disk sync for all of them. Resolves to
21959
+ * the rows changed, in the order of `ids`.
21960
+ */
21961
+ async patchMessages(linkId, ids, change) {
21962
+ const tx = (await openDb()).transaction(STORES$1.messages, "readwrite");
21963
+ const messages = tx.objectStore(STORES$1.messages);
21964
+ const results = [];
21965
+ ids.forEach((id, i) => {
21966
+ const request = messages.get([linkId, id]);
21967
+ request.onsuccess = () => {
21968
+ const message = request.result;
21969
+ const patch = message && change(message);
21970
+ if (!patch) return;
21971
+ messages.put(results[i] = {
21972
+ ...message,
21973
+ ...patch
21974
+ });
21975
+ };
21976
+ });
21977
+ await new Promise((resolve, reject) => {
21978
+ tx.oncomplete = () => resolve();
21979
+ tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("Message storage failed"));
21980
+ });
21981
+ return results.filter((row) => !!row);
21982
+ },
21614
21983
  async deleteMessage(linkId, messageId) {
21615
21984
  await wrap((await store(STORES$1.messages, "readwrite")).delete([linkId, messageId]));
21616
21985
  },
@@ -22013,6 +22382,202 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
22013
22382
  };
22014
22383
  }
22015
22384
  //#endregion
22385
+ //#region ../browser/src/engine/groupAuthorOrder.ts
22386
+ /**
22387
+ * How long a member's message may still be placed again below that member's older ones (WISP 400, requirement 10):
22388
+ * about a catch-up, whose answers end within the three minutes the app waits for the first.
22389
+ */
22390
+ var AUTHOR_ORDER_WINDOW_MS = 18e4;
22391
+ AUTHOR_ORDER_WINDOW_MS / 2;
22392
+ /** Whether `a` was written after `b` by the same member: a later epoch, or a higher number in the same one. */
22393
+ function after(a, b) {
22394
+ return a.epoch > b.epoch || a.epoch === b.epoch && a.seq > b.seq;
22395
+ }
22396
+ /**
22397
+ * A member's messages in a private group in the order that member wrote them (WISP 400, requirement 10, and WISP 902
22398
+ * § Catch-up). A message is placed where it arrives. When one comes of a member whose later messages were placed here
22399
+ * moments ago (two catch-up answers raced: the author's own newest 32 and another member's hand-on of the older ones),
22400
+ * those later ones are placed again below it, in the order they were written, and the lines that came after them before
22401
+ * the older one (another member's, mine) go along below them, in their order: a reply to the newest stays below it. A
22402
+ * row only ever moves down, so nobody else's lines change order. While messages of that member are still missing
22403
+ * (`missing`), the rows wait for them, so each row moves about once in a whole catch-up.
22404
+ */
22405
+ var GroupAuthorOrder = class {
22406
+ host;
22407
+ linkOf;
22408
+ missing;
22409
+ /** Per group: the members' rows followed, in the order they are placed in the history. */
22410
+ rows = /* @__PURE__ */ new Map();
22411
+ /** Per group: the members with a row placed above an older one of theirs, since when, and when their last row came. */
22412
+ due = /* @__PURE__ */ new Map();
22413
+ running = /* @__PURE__ */ new Set();
22414
+ constructor(host, linkOf, missing = () => false) {
22415
+ this.host = host;
22416
+ this.linkOf = linkOf;
22417
+ this.missing = missing;
22418
+ }
22419
+ /** A member's message was stored in the group's history, new: `at` is when, on the engine's clock. */
22420
+ placed(groupId, row) {
22421
+ if (!this.host.placeAgain) return;
22422
+ const rows = (this.rows.get(groupId) ?? []).filter((r) => r.at > row.at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
22423
+ rows.push(row);
22424
+ this.rows.set(groupId, rows);
22425
+ let due = this.due.get(groupId);
22426
+ if (!due?.has(row.sender) && !rows.some((r) => r.sender === row.sender && after(r, row))) return;
22427
+ if (!due) this.due.set(groupId, due = /* @__PURE__ */ new Map());
22428
+ due.set(row.sender, {
22429
+ since: due.get(row.sender)?.since ?? row.at,
22430
+ last: row.at
22431
+ });
22432
+ if (!this.missing(groupId, row.sender)) this.run(groupId, row.at);
22433
+ }
22434
+ /** Time passed: rows nothing more is on its way for, or that waited long enough, are placed again. */
22435
+ tick(now) {
22436
+ for (const groupId of this.due.keys()) this.run(groupId, now);
22437
+ }
22438
+ /** The group's history is gone (left or forgotten). */
22439
+ forget(groupId) {
22440
+ this.rows.delete(groupId);
22441
+ this.due.delete(groupId);
22442
+ }
22443
+ /**
22444
+ * One group's rows put in order, a member at a time; what comes meanwhile is put in order by the next turn, so a
22445
+ * catch-up's frames that come together move each row once, not once per frame.
22446
+ */
22447
+ async run(groupId, now) {
22448
+ if (this.running.has(groupId)) return;
22449
+ this.running.add(groupId);
22450
+ try {
22451
+ for (;;) {
22452
+ const due = this.due.get(groupId);
22453
+ const ready = [...due ?? []].filter(([sender, { since, last }]) => now - last >= 15e3 || now - since >= 9e4 || !this.missing(groupId, sender)).map(([sender]) => sender);
22454
+ if (!ready.length) break;
22455
+ for (const sender of ready) due.delete(sender);
22456
+ if (!due.size) this.due.delete(groupId);
22457
+ for (const sender of ready) {
22458
+ const rows = this.rows.get(groupId) ?? [];
22459
+ const mine = rows.filter((r) => r.sender === sender);
22460
+ let from, below;
22461
+ for (const r of [...mine].reverse()) {
22462
+ if (below && after(r, below) && (!from || after(from, r))) from = r;
22463
+ if (!below || after(below, r)) below = r;
22464
+ }
22465
+ if (!from) continue;
22466
+ const newer = mine.filter((r) => !after(from, r)).sort((a, b) => after(a, b) ? 1 : after(b, a) ? -1 : 0);
22467
+ const ids = new Set(newer.map((r) => r.id));
22468
+ const first = rows.findIndex((r) => ids.has(r.id));
22469
+ let end = rows.length;
22470
+ for (let i = first + 1, top = rows[first]; i < rows.length; i++) {
22471
+ if (rows[i].sender !== sender) continue;
22472
+ if (after(top, rows[i])) {
22473
+ end = i;
22474
+ break;
22475
+ }
22476
+ top = rows[i];
22477
+ }
22478
+ const followers = rows.slice(first, end).filter((r) => r.sender !== sender);
22479
+ for (const r of followers) ids.add(r.id);
22480
+ const moved = [...newer, ...followers];
22481
+ this.rows.set(groupId, [...rows.filter((r) => !ids.has(r.id)), ...moved.map((r) => ({
22482
+ ...r,
22483
+ at: Math.max(r.at, now)
22484
+ }))]);
22485
+ await this.host.placeAgain(this.linkOf(groupId), moved.map((r) => r.id)).catch(() => {});
22486
+ }
22487
+ }
22488
+ } finally {
22489
+ this.running.delete(groupId);
22490
+ }
22491
+ }
22492
+ };
22493
+ //#endregion
22494
+ //#region ../browser/src/engine/groupLineOrder.ts
22495
+ /**
22496
+ * The lines of a group's history ("… joined", "… is no longer a member") among the messages of their moment, for a
22497
+ * member back from away (WISP 902 § Catch-up). A message sealed under an epoch was written before its author knew of
22498
+ * the next commit, so the line of that commit goes below it; a message under the commit's epoch or a later one was
22499
+ * written knowing it, and stays below the line. A catch-up brings the commits first (a commit opens the messages of
22500
+ * its epoch) and, in a private group, the answers of several members race, so the messages of the epoch before can come
22501
+ * after the line. Then the line, and the rows after it written knowing it, are placed again below them, in their order;
22502
+ * no clock is read. A row only ever moves down, and only rows placed here in the last few minutes, so a live
22503
+ * conversation read long ago never moves. Moves made meanwhile go in one turn: a row moves about once a catch-up page.
22504
+ */
22505
+ var GroupLineOrder = class {
22506
+ host;
22507
+ /** Per history: the rows followed, in the order they are placed in it. */
22508
+ rows = /* @__PURE__ */ new Map();
22509
+ due = /* @__PURE__ */ new Set();
22510
+ running = /* @__PURE__ */ new Set();
22511
+ constructor(host) {
22512
+ this.host = host;
22513
+ }
22514
+ /**
22515
+ * A row is being stored, a line of the history or a message sealed under this epoch, and takes its place now: `at` is
22516
+ * when, on the engine's clock. Call what this returns with whether it was new: a message below a line of a later
22517
+ * epoch takes the line (and the rows after it written knowing it) below it.
22518
+ */
22519
+ placing(linkId, row, at) {
22520
+ if (!this.host.placeAgain) return () => {};
22521
+ const followed = {
22522
+ ...row,
22523
+ at
22524
+ };
22525
+ const rows = (this.rows.get(linkId) ?? []).filter((r) => r.at > at - AUTHOR_ORDER_WINDOW_MS).slice(-575);
22526
+ rows.push(followed);
22527
+ this.rows.set(linkId, rows);
22528
+ return (stored) => {
22529
+ const now = this.rows.get(linkId);
22530
+ if (!stored) {
22531
+ if (now) this.rows.set(linkId, now.filter((r) => r !== followed));
22532
+ return;
22533
+ }
22534
+ if (!followed.line && now?.some((r) => r.line && r.epoch > followed.epoch && now.indexOf(r) < now.indexOf(followed))) this.run(linkId);
22535
+ };
22536
+ }
22537
+ /** These rows were placed again below everything (`GroupAuthorOrder`), in this order. */
22538
+ moved(linkId, ids) {
22539
+ const rows = this.rows.get(linkId);
22540
+ if (!rows) return;
22541
+ const moved = ids.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
22542
+ if (!moved.length) return;
22543
+ this.rows.set(linkId, [...rows.filter((r) => !moved.includes(r)), ...moved]);
22544
+ if (moved.some((r) => !r.line)) this.run(linkId);
22545
+ }
22546
+ /** The group's history is gone (left or forgotten). */
22547
+ forget(linkId) {
22548
+ this.rows.delete(linkId);
22549
+ this.due.delete(linkId);
22550
+ }
22551
+ /** The history's rows put in order; what comes while a move is written goes in the next turn. */
22552
+ run(linkId) {
22553
+ this.due.add(linkId);
22554
+ if (this.running.has(linkId)) return;
22555
+ this.running.add(linkId);
22556
+ (async () => {
22557
+ try {
22558
+ while (this.due.delete(linkId)) {
22559
+ const rows = this.rows.get(linkId) ?? [];
22560
+ const order = [];
22561
+ for (const row of rows) {
22562
+ const above = row.line ? -1 : order.findIndex((r) => r.line && r.epoch > row.epoch);
22563
+ if (above < 0) order.push(row);
22564
+ else order.splice(above, 0, row);
22565
+ }
22566
+ let from = 0;
22567
+ while (from < rows.length && rows[from] === order[from]) from++;
22568
+ if (from === rows.length) continue;
22569
+ const last = rows[rows.length - 1].at;
22570
+ for (const r of order.slice(from)) r.at = Math.max(r.at, last);
22571
+ this.rows.set(linkId, order);
22572
+ await this.host.placeAgain(linkId, order.slice(from).map((r) => r.id)).catch(() => {});
22573
+ }
22574
+ } finally {
22575
+ this.running.delete(linkId);
22576
+ }
22577
+ })();
22578
+ }
22579
+ };
22580
+ //#endregion
22016
22581
  //#region ../browser/src/engine/hubClocks.ts
22017
22582
  /**
22018
22583
  * A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
@@ -22266,11 +22831,14 @@ var Communities = class {
22266
22831
  /** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
22267
22832
  frames = new FramesTaken();
22268
22833
  carriers = /* @__PURE__ */ new Map();
22834
+ /** The lines of each history among the messages of their moment (`GroupLineOrder`). */
22835
+ lineOrder;
22269
22836
  constructor(host, store, timings = COMMUNITY_TIMINGS, random = Math.random) {
22270
22837
  this.host = host;
22271
22838
  this.store = store;
22272
22839
  this.timings = timings;
22273
22840
  this.random = random;
22841
+ this.lineOrder = new GroupLineOrder(host);
22274
22842
  }
22275
22843
  /** Application frames and pair payloads handed to the engine, one after the other per group. */
22276
22844
  deliveries = /* @__PURE__ */ new Map();
@@ -22287,6 +22855,16 @@ var Communities = class {
22287
22855
  }
22288
22856
  /** The clock: the last tick's time (the engine ticks every second), so every decision reads one clock. */
22289
22857
  time = 0;
22858
+ /**
22859
+ * The communities this app is in, and those it is a hub of, as of the last tick: their idle looks share one
22860
+ * community's pace (`beaconEvery`, `knockRecordsToRead`). Each community a hub held cost it about 10 requests a minute
22861
+ * on each relay with nobody coming in: 4 of them on one relay, or 8 on two, spent the relays' budget on those looks,
22862
+ * and the edges of some of them stayed down 10 minutes and more (CLI daemons, 2026-10-13).
22863
+ */
22864
+ sharing = {
22865
+ groups: 1,
22866
+ hubs: 1
22867
+ };
22290
22868
  now() {
22291
22869
  return this.time || Date.now();
22292
22870
  }
@@ -22359,6 +22937,7 @@ var Communities = class {
22359
22937
  accepted: true,
22360
22938
  viaLink: true,
22361
22939
  stage: this.joinStage(group),
22940
+ since: group.joining.since,
22362
22941
  ...this.knockingWith.get(group.id) ? { waiting: this.knockingWith.get(group.id) } : {}
22363
22942
  } : void 0;
22364
22943
  if (invitation && !this.keptWhileKnocking(live)) return [{
@@ -22589,6 +23168,21 @@ var Communities = class {
22589
23168
  await s.leave();
22590
23169
  await this.forget(groupId);
22591
23170
  }
23171
+ /**
23172
+ * Delete from this device: left first, as Leave does. With nothing connected to carry the request, a member's leave
23173
+ * goes unsaid and the group goes anyway, as a private group's failed goodbye does: the others still list the device,
23174
+ * but members who never open the app again kept it here for good. An admin with members is still refused: gone with
23175
+ * the role, the group would have no admin.
23176
+ */
23177
+ async deleteHere(groupId) {
23178
+ try {
23179
+ await this.leave(groupId);
23180
+ } catch (error) {
23181
+ const s = this.live.get(groupId)?.session;
23182
+ if (!s || s.isAdmin && s.others.length) throw error;
23183
+ await this.forget(groupId);
23184
+ }
23185
+ }
22592
23186
  successor(groupId) {
22593
23187
  const s = this.live.get(groupId)?.session;
22594
23188
  if (!s) return void 0;
@@ -22659,6 +23253,7 @@ var Communities = class {
22659
23253
  this.lastMentionAt.delete(groupId);
22660
23254
  this.knockingWith.delete(groupId);
22661
23255
  this.knockRead.delete(groupId);
23256
+ this.lineOrder.forget(MESSAGE_LINK$1(groupId));
22662
23257
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
22663
23258
  await this.store.deleteGroup(groupId);
22664
23259
  await this.files?.drop(groupId);
@@ -22673,6 +23268,11 @@ var Communities = class {
22673
23268
  /** Everything timed: beacon, hub election, lobbies, knocks, leaves, edges. The engine calls it every second or so. */
22674
23269
  async tick(now = Date.now()) {
22675
23270
  this.time = now;
23271
+ const active = [...this.live.values()].filter((l) => l.session.status === "active");
23272
+ this.sharing = {
23273
+ groups: Math.max(1, active.length),
23274
+ hubs: Math.max(1, active.filter((l) => l.hub).length)
23275
+ };
22676
23276
  for (const group of [...this.stored.values()]) {
22677
23277
  const live = this.live.get(group.id);
22678
23278
  if (group.joining && (this.knocking(group, live) || group.joining.check)) {
@@ -22719,7 +23319,7 @@ var Communities = class {
22719
23319
  if ([...this.host.entries(groupId).values()].includes(linkId)) await this.host.closeEdge(linkId);
22720
23320
  }
22721
23321
  const alone = live.hub && !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
22722
- if ((live.beaconFailedAt ? now - live.beaconFailedAt >= BEACON_RETRY_MS : now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs)) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
23322
+ if ((live.beaconFailedAt ? now - live.beaconFailedAt >= BEACON_RETRY_MS : now - live.lastBeaconRead >= this.beaconEvery(groupId, live, alone)) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
22723
23323
  if (!live.beaconKnown) {
22724
23324
  await this.keepLooking(groupId, live, now);
22725
23325
  await this.reconcile(groupId, live, now);
@@ -22981,11 +23581,22 @@ var Communities = class {
22981
23581
  const n = live.members.size;
22982
23582
  return n >= this.capacity(groupId, live, now, false) ? Math.max(n, COMMUNITY_TOPOLOGY.hubCapacity) : n;
22983
23583
  }
23584
+ /**
23585
+ * How often the beacon is read: alone, as often as I write my entry. A member with an edge up shares one community's
23586
+ * pace with the others it is in (`sharing`), up to that of a lone hub: an entry stays fresh three such readings. A
23587
+ * hub does not: the door is the hubs whose entries it read lately (`doorHubs`, an entry 45 s old at most), and read
23588
+ * slower, the door kept changing and read every knock record again each time. Nor a member with no edge up: who the
23589
+ * hubs are is what it needs to connect.
23590
+ */
23591
+ beaconEvery(groupId, live, alone) {
23592
+ if (alone) return COMMUNITY_TOPOLOGY.beaconEveryMs;
23593
+ return !live.hub && [...this.host.edges(groupId).values()].some((id) => this.host.linkReady(id, 2)) ? Math.min(this.timings.beaconReadMs * this.sharing.groups, COMMUNITY_TOPOLOGY.beaconEveryMs) : this.timings.beaconReadMs;
23594
+ }
22984
23595
  /** Reads the beacon; false when the reading did not happen (the relays held it back, or none answered). */
22985
23596
  async readBeacon(groupId, live, now) {
22986
23597
  live.lastBeaconRead = now;
22987
23598
  const keys = beaconKeys(live.session.state.rv, groupId);
22988
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true, live.hub).catch(() => void 0);
23599
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => void 0);
22989
23600
  if (records === void 0) {
22990
23601
  live.beaconFailedAt = now;
22991
23602
  live.beaconAskedAt ||= now;
@@ -23193,7 +23804,7 @@ var Communities = class {
23193
23804
  }
23194
23805
  async pollLobby(groupId, live, now) {
23195
23806
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
23196
- const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => null) ?? []);
23807
+ const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, this.doorBusy(groupId, live, now)).catch(() => null) ?? []);
23197
23808
  for (const { key, ts } of entries) {
23198
23809
  if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
23199
23810
  if (live.session.wasRemoved(key)) {
@@ -23283,6 +23894,17 @@ var Communities = class {
23283
23894
  return now - live.admittedAt < ADMITTED_QUIET_MS || [...live.pendingEntries.keys()].some((key) => down(entries.get(key))) || [...live.expect].some((key) => down(edges.get(key)));
23284
23895
  }
23285
23896
  /**
23897
+ * As a hub: someone is coming in, or about to. I am letting someone in, the link was just shown, a crowd fills the
23898
+ * bell, or members ask in my lobby (a hub just left, say). Then my reads of the bell, the beacon and my lobby go as
23899
+ * the door's (`PkarrRequestOptions.door`): not held back while links signal. Otherwise they give way to the links
23900
+ * that signal, my edges in my other communities first: an app that was a hub of 4 communities on one relay (8 on
23901
+ * two) spent its relays' background share on reads that each said nobody was there, and its edges, whose signaling
23902
+ * had what was left, stayed down 10 minutes and more in some of them (CLI daemons, 2026-10-13).
23903
+ */
23904
+ doorBusy(groupId, live, now) {
23905
+ return live.hub && (now < live.warmUntil || now < live.crowdUntil || now < live.lobbyBusyUntil || this.admitting(groupId, live, now));
23906
+ }
23907
+ /**
23286
23908
  * Which knock records to read now (none, most of the time): every one at once the first time
23287
23909
  * (a hub just elected, an app back, a change of door), since a knock left while nobody was at the
23288
23910
  * door may be in any; then the door the bell, and the others in turn; the other hubs one in turn, slowly.
@@ -23295,7 +23917,7 @@ var Communities = class {
23295
23917
  return Array.from({ length: 4 }, (_, n) => n);
23296
23918
  }
23297
23919
  const crowd = now < live.crowdUntil;
23298
- const every = !door ? this.timings.otherHubKnockPollMs : busy && !crowd ? this.timings.knockBusyPollMs : now < live.warmUntil ? this.timings.knockWarmPollMs : this.timings.knockPollMs;
23920
+ const every = !door ? this.timings.otherHubKnockPollMs : busy && !crowd ? this.timings.knockBusyPollMs : now < live.warmUntil ? this.timings.knockWarmPollMs : Math.min(this.timings.knockPollMs * this.sharing.hubs, this.timings.otherHubKnockPollMs);
23299
23921
  if (now - live.lastKnockPoll < every) return [];
23300
23922
  live.lastKnockPoll = now;
23301
23923
  if (!door) return [live.knockCursor = (live.knockCursor + 1) % 4];
@@ -23321,9 +23943,10 @@ var Communities = class {
23321
23943
  const records = this.knockRecordsToRead(live, door === s.myKey, busy, now);
23322
23944
  if (!records.length) return;
23323
23945
  let failed = 0;
23946
+ const open = door === s.myKey && this.doorBusy(groupId, live, now);
23324
23947
  const read = await Promise.all(records.map(async (n) => {
23325
23948
  const record = knockRecord(link, n);
23326
- return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() => {
23949
+ return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, open && n === KNOCK_BELL).catch(() => {
23327
23950
  failed++;
23328
23951
  return null;
23329
23952
  }) ?? []);
@@ -23717,6 +24340,11 @@ var Communities = class {
23717
24340
  sender: m.sender,
23718
24341
  file: m.file
23719
24342
  }, session.myKey) : void 0;
24343
+ const placing = this.lineOrder.placing(MESSAGE_LINK$1(id), {
24344
+ id: m.id,
24345
+ epoch: m.epoch,
24346
+ line: false
24347
+ }, this.now());
23720
24348
  const stored = await this.host.storeMessage({
23721
24349
  linkId: MESSAGE_LINK$1(id),
23722
24350
  id: m.id,
@@ -23731,6 +24359,7 @@ var Communities = class {
23731
24359
  ...m.card && { card: m.card },
23732
24360
  ...file && { file }
23733
24361
  });
24362
+ placing(stored !== false);
23734
24363
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
23735
24364
  this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
23736
24365
  if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
@@ -23827,7 +24456,16 @@ var Communities = class {
23827
24456
  else if (s.status === "active" && before !== after) {
23828
24457
  const at = receivedTimestamp(top.ts, when);
23829
24458
  for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, at, top.e, key);
23830
- for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
24459
+ const left = (key) => {
24460
+ const chain = s.state.chain;
24461
+ for (let i = chain.length - 1; i >= 0; i--) if ((chain[i].k === "remove" || chain[i].k === "leave") && chain[i].s === key) return chain[i].k === "leave";
24462
+ return false;
24463
+ };
24464
+ for (const [key] of before) {
24465
+ if (rosterHas(after, key)) continue;
24466
+ if (left(key)) await this.event(groupId, "left", `${name(key)} left`, at, top.e, key);
24467
+ else await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
24468
+ }
23831
24469
  if (top.k === "role" && before.find(([k]) => k === top.s)?.[1] !== "admin") await this.event(groupId, "admin", `${name(top.s)} ${top.s === s.myKey ? "are" : "is"} now the admin`, at, top.e, top.s);
23832
24470
  if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", at, top.e);
23833
24471
  }
@@ -23836,16 +24474,21 @@ var Communities = class {
23836
24474
  /** A line in the group's history. Its id carries its time: two lines at the same moment get distinct ones (`eventTime`). */
23837
24475
  async event(groupId, event, text, timestamp, epoch, member) {
23838
24476
  timestamp = eventTime(this.eventTimes, groupId, timestamp);
23839
- await this.host.storeMessage({
24477
+ const id = `event:${epoch}:${event}:${member ?? ""}:${timestamp}`;
24478
+ this.lineOrder.placing(MESSAGE_LINK$1(groupId), {
24479
+ id,
24480
+ epoch,
24481
+ line: true
24482
+ }, this.now())(await this.host.storeMessage({
23840
24483
  linkId: MESSAGE_LINK$1(groupId),
23841
- id: `event:${epoch}:${event}:${member ?? ""}:${timestamp}`,
24484
+ id,
23842
24485
  text,
23843
24486
  sender: "peer",
23844
24487
  event,
23845
24488
  member,
23846
24489
  timestamp,
23847
24490
  via: "datalink"
23848
- });
24491
+ }) !== false);
23849
24492
  }
23850
24493
  eventTimes = /* @__PURE__ */ new Map();
23851
24494
  };
@@ -24853,6 +25496,11 @@ var INVITED_KEPT_MS = LEFT_KEPT_MS;
24853
25496
  */
24854
25497
  var ACCEPT_AGAIN_MS = 6e4;
24855
25498
  var ACCEPT_AGAINS = 5;
25499
+ /**
25500
+ * Admin side: a member let in over its chat, its edge not up, is sent its welcome again at most this often. The welcome
25501
+ * is the whole chain and the sealed secrets (megabytes, tens of ms to build); its app says its accept once a minute.
25502
+ */
25503
+ var REWELCOME_MS = ACCEPT_AGAIN_MS / 2;
24856
25504
  /** Invitee side: the invitations one contact chat may have waiting here at a time. More are answered as declined. */
24857
25505
  var MAX_INVITATIONS_PER_CHAT = 4;
24858
25506
  /** 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. */
@@ -24869,9 +25517,13 @@ var FAREWELL_ANSWERS = 3;
24869
25517
  var FAREWELL_COMMITS = 64;
24870
25518
  /**
24871
25519
  * A member whose messages were just asked of another (`Groups.askOf`) is told where I am only once that one's answer
24872
- * has come: no message from it for this long, or `MESH_SYNC_HOLD_MAX_MS` after it was asked. Told at once, its own
25520
+ * has come: no message of it for this long, or `MESH_SYNC_HOLD_MAX_MS` after it was asked. Told at once, its own
24873
25521
  * answer raced the other's, which holds the same messages among everyone's in the order they were written, and a
24874
25522
  * member back in a group of five read each member's lines apart, the newest above older ones (WISP 902 § Catch-up).
25523
+ * A message of the answer is one written before the asking: what the member writes meanwhile is no reason to wait, so
25524
+ * the longest wait only has to cover a whole answer (the 256 messages a member keeps for the others and its own last
25525
+ * 32) at the pace of a phone with a long history, 600 ms a frame. At ten seconds, an answer of 200 messages at four a
25526
+ * second was cut at the 40th, and the others' newest lines landed above what was still coming.
24875
25527
  */
24876
25528
  var MESH_SYNC_HOLD_MS = 1500;
24877
25529
  /** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
@@ -24985,6 +25637,8 @@ var Groups = class {
24985
25637
  syncHeld = /* @__PURE__ */ new Map();
24986
25638
  /** Per mesh group and member, when a message last came over its edge (its own, or one it handed on). */
24987
25639
  lastHanded = /* @__PURE__ */ new Map();
25640
+ /** Admin side, per group and member let in: when its welcome was last sent again, over its chat or the link (`REWELCOME_MS`). */
25641
+ rewelcomed = /* @__PURE__ */ new Map();
24988
25642
  lastGossip = /* @__PURE__ */ new Map();
24989
25643
  /**
24990
25644
  * Per mesh group, each reachable member some of whose messages never reached me (`GroupSession.lacking`): what was
@@ -25016,6 +25670,10 @@ var Groups = class {
25016
25670
  communities;
25017
25671
  /** My messages and edits an edge of a private group took. */
25018
25672
  frames = new FramesTaken();
25673
+ /** Each member's messages in the order that member wrote them, though catch-up answers race (WISP 400, requirement 10). */
25674
+ authorOrder;
25675
+ /** The lines of each history among the messages of their moment (`GroupLineOrder`). */
25676
+ lineOrder;
25019
25677
  /** Hubs of the private groups past 16 members (WISP 902 · Group Mesh § Hubs). */
25020
25678
  hubs;
25021
25679
  /** Groups whose edges are being reconciled because their hubs changed: one at a time. */
@@ -25042,6 +25700,16 @@ var Groups = class {
25042
25700
  }
25043
25701
  }, hubTimings);
25044
25702
  this.typings = new GroupTypings(host);
25703
+ this.lineOrder = new GroupLineOrder(host);
25704
+ const placeAgain = host.placeAgain && (async (linkId, ids) => {
25705
+ const done = host.placeAgain(linkId, ids);
25706
+ this.lineOrder.moved(linkId, ids);
25707
+ await done;
25708
+ });
25709
+ this.authorOrder = new GroupAuthorOrder({ placeAgain }, MESSAGE_LINK, (groupId, sender) => {
25710
+ const session = this.sessions.get(groupId);
25711
+ return !!session && session.missing(sender, this.gapsGone.get(groupId)?.get(sender)?.[session.epoch]) > 0;
25712
+ });
25045
25713
  this.signals = new MeshSignals(host, {
25046
25714
  session: (id) => this.sessions.get(id),
25047
25715
  stored: (id) => this.stored.get(id),
@@ -25079,13 +25747,14 @@ var Groups = class {
25079
25747
  await this.communities.load(all.filter((g) => g.community || g.joining));
25080
25748
  for (const group of all.filter((g) => !g.community && !g.joining)) {
25081
25749
  this.stored.set(group.id, group);
25082
- if (group.state) this.attach(group.state);
25083
- if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
25084
25750
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
25085
- this.framesKept.set(group.id, history.filter((m) => m.frame).map((m) => ({
25751
+ const framed = history.filter((m) => m.frame);
25752
+ this.framesKept.set(group.id, framed.map((m) => ({
25086
25753
  id: m.id,
25087
25754
  bytes: frameBytes(m.frame)
25088
25755
  })));
25756
+ if (group.state) this.attach(group.state, framed.map((m) => m.frame));
25757
+ if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
25089
25758
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
25090
25759
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
25091
25760
  const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
@@ -25162,6 +25831,7 @@ var Groups = class {
25162
25831
  members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
25163
25832
  accepted: !!invitation.seedB64,
25164
25833
  ...this.acceptSaid.get(group.id)?.unanswered ? { unanswered: true } : {},
25834
+ ...invitation.takenBack ? { takenBack: true } : {},
25165
25835
  ...invitation.entry ? {
25166
25836
  viaLink: true,
25167
25837
  stage: this.joinStage(group)
@@ -25343,6 +26013,7 @@ var Groups = class {
25343
26013
  async accept(groupId) {
25344
26014
  const group = this.stored.get(groupId);
25345
26015
  if (!group?.invitation) throw new Error("No invitation to accept");
26016
+ if (group.invitation.takenBack) throw new Error("This invitation was taken back");
25346
26017
  if (!this.host.linkReady(group.invitation.linkId)) throw new Error("The contact who invited you is not connected. Try again when they are.");
25347
26018
  const seedB64 = group.invitation.seedB64 ?? createIdentity().seedB64;
25348
26019
  group.invitation = {
@@ -25366,6 +26037,7 @@ var Groups = class {
25366
26037
  if (this.isCommunity(groupId)) return this.communities.decline(groupId);
25367
26038
  const group = this.stored.get(groupId);
25368
26039
  if (!group?.invitation) throw new Error("No invitation to decline");
26040
+ if (group.invitation.takenBack) return this.forget(groupId, false);
25369
26041
  if (!group.previous) return this.forget(groupId);
25370
26042
  await this.dropInvitation(group, this.sayDecline(groupId, group.invitation.linkId));
25371
26043
  }
@@ -25381,6 +26053,25 @@ var Groups = class {
25381
26053
  await this.store.putGroup(previous);
25382
26054
  this.host.emit();
25383
26055
  }
26056
+ /**
26057
+ * Invitee side: who sent the invitation took it back. A group I kept under its id comes back as it was; otherwise the
26058
+ * invitation stays a week, marked, so the list says it was taken back where it was (an accept said for it stops).
26059
+ */
26060
+ async takeBackFrom(group) {
26061
+ if (group.previous) return this.dropInvitation(group);
26062
+ if (group.invitation.takenBack) return;
26063
+ const { seedB64: _unused, ...invitation } = group.invitation;
26064
+ group.invitation = {
26065
+ ...invitation,
26066
+ pieces: [],
26067
+ takenBack: this.now()
26068
+ };
26069
+ this.acceptSaid.delete(group.id);
26070
+ await this.store.putGroup(group);
26071
+ const kept = [...this.stored.values()].filter((x) => x.invitation?.takenBack && !x.state && x.invitation.linkId === invitation.linkId).reverse().sort((a, b) => b.invitation.takenBack - a.invitation.takenBack);
26072
+ for (const old of kept.slice(MAX_INVITATIONS_PER_CHAT)) await this.forget(old.id, false);
26073
+ this.host.emit();
26074
+ }
25384
26075
  /** Invitee side: says no to an invitation. With the inviter's chat down (its app is closed, say), returns the answer owed, said when that chat is next up. */
25385
26076
  sayDecline(groupId, linkId) {
25386
26077
  try {
@@ -25573,6 +26264,8 @@ var Groups = class {
25573
26264
  this.lastMessageAt.delete(groupId);
25574
26265
  this.lastPeerMessageAt.delete(groupId);
25575
26266
  this.lastMentionAt.delete(groupId);
26267
+ this.authorOrder.forget(groupId);
26268
+ this.lineOrder.forget(MESSAGE_LINK(groupId));
25576
26269
  await this.store.deleteGroup(groupId);
25577
26270
  await this.store.putGroup(group);
25578
26271
  await this.files?.drop(groupId);
@@ -25766,19 +26459,20 @@ var Groups = class {
25766
26459
  }
25767
26460
  /** `answer`: false when an invitation goes because its inviter took it back, so no decline is said for it. */
25768
26461
  async forget(groupId, answer = true) {
25769
- if (this.isCommunity(groupId)) return this.communities.leave(groupId);
26462
+ if (this.isCommunity(groupId)) return this.communities.deleteHere(groupId);
25770
26463
  const session = this.sessions.get(groupId);
25771
26464
  if (session?.status === "active") try {
25772
26465
  await this.leave(groupId);
25773
26466
  } catch (e) {
25774
26467
  if (session.isAdmin && session.others.length) throw e;
25775
26468
  }
25776
- const gone = this.stored.get(groupId), invitation = answer && gone?.invitation && !gone.invitation.entry ? gone.invitation : void 0;
26469
+ const gone = this.stored.get(groupId), invitation = answer && gone?.invitation && !gone.invitation.entry && !gone.invitation.takenBack ? gone.invitation : void 0;
25777
26470
  const owed = invitation && this.sayDecline(groupId, invitation.linkId);
25778
26471
  this.sessions.delete(groupId);
25779
26472
  this.stored.delete(groupId);
25780
26473
  this.takeBack(groupId);
25781
26474
  this.acceptSaid.delete(groupId);
26475
+ this.entryHosts.delete(groupId);
25782
26476
  this.lastRoster.delete(groupId);
25783
26477
  this.lastTold.delete(groupId);
25784
26478
  this.pendingEntries.delete(groupId);
@@ -25792,6 +26486,7 @@ var Groups = class {
25792
26486
  this.relayAsked.delete(groupId);
25793
26487
  this.syncHeld.delete(groupId);
25794
26488
  this.lastHanded.delete(groupId);
26489
+ this.rewelcomed.delete(groupId);
25795
26490
  this.hubs.forget(groupId);
25796
26491
  this.removedAt.delete(groupId);
25797
26492
  this.lastGossip.delete(groupId);
@@ -25804,6 +26499,8 @@ var Groups = class {
25804
26499
  this.hereHeard.delete(groupId);
25805
26500
  this.hereActed.delete(groupId);
25806
26501
  this.signals.forget(groupId);
26502
+ this.authorOrder.forget(groupId);
26503
+ this.lineOrder.forget(MESSAGE_LINK(groupId));
25807
26504
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
25808
26505
  await this.store.deleteGroup(groupId);
25809
26506
  if (owed && !this.stored.has(groupId)) {
@@ -25902,6 +26599,7 @@ var Groups = class {
25902
26599
  if (this.ticking) return;
25903
26600
  this.ticking = true;
25904
26601
  try {
26602
+ this.authorOrder.tick(now);
25905
26603
  for (const group of [...this.stored.values()]) {
25906
26604
  if (group.left) {
25907
26605
  if (now - group.left.at > LEFT_KEPT_MS) await this.forget(group.id);
@@ -25911,6 +26609,10 @@ var Groups = class {
25911
26609
  await this.declineOwed(group, now);
25912
26610
  if (!group.state) continue;
25913
26611
  }
26612
+ if (group.invitation?.takenBack && !group.state) {
26613
+ if (now - group.invitation.takenBack > LEFT_KEPT_MS) await this.forget(group.id, false);
26614
+ continue;
26615
+ }
25914
26616
  if (group.invitation?.entry && !group.state) {
25915
26617
  const every = now - group.createdAt > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
25916
26618
  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(() => {});
@@ -26005,6 +26707,7 @@ var Groups = class {
26005
26707
  this.host.emit();
26006
26708
  }
26007
26709
  if (said.n >= ACCEPT_AGAINS || !said.down && now - said.at < ACCEPT_AGAIN_MS) return;
26710
+ invitation.pieces = [];
26008
26711
  try {
26009
26712
  this.host.sendOnLink(invitation.linkId, {
26010
26713
  t: "group-accept",
@@ -26028,7 +26731,7 @@ var Groups = class {
26028
26731
  }
26029
26732
  const session = this.sessions.get(groupId), group = this.stored.get(groupId);
26030
26733
  if (!session?.isAdmin || !group || !this.pendingEntries.get(groupId)?.has(peer)) return;
26031
- if (this.growthBlocked(group, session, session.roster.length + 1, linkId)) {
26734
+ if (!rosterHas(session.roster, peer) && this.growthBlocked(group, session, session.roster.length + 1, linkId)) {
26032
26735
  this.pendingEntries.get(groupId)?.delete(peer);
26033
26736
  this.refused.set(peer, Date.now() + REFUSED_FOR_MS);
26034
26737
  this.host.closeEdge(linkId);
@@ -26075,14 +26778,22 @@ var Groups = class {
26075
26778
  return this.knocked.has(group.id) ? "knocked" : "knocking";
26076
26779
  }
26077
26780
  entryOf(group) {
26078
- return group.entry ? {
26781
+ if (!group.entry) return void 0;
26782
+ const { seedB64 } = group.entry;
26783
+ let host = this.entryHosts.get(group.id);
26784
+ if (host?.seedB64 !== seedB64) this.entryHosts.set(group.id, host = {
26785
+ seedB64,
26786
+ key: identityFromSeedB64(seedB64).pubKeyZ32
26787
+ });
26788
+ return {
26079
26789
  link: {
26080
26790
  g: group.id,
26081
- host: identityFromSeedB64(group.entry.seedB64).pubKeyZ32
26791
+ host: host.key
26082
26792
  },
26083
- seedB64: group.entry.seedB64
26084
- } : void 0;
26793
+ seedB64
26794
+ };
26085
26795
  }
26796
+ entryHosts = /* @__PURE__ */ new Map();
26086
26797
  /** Joiner side: the entry session kept after the welcome goes (the edge to the admin is up, or it was kept long enough). */
26087
26798
  async endLinger(groupId) {
26088
26799
  const kept = this.lingering.get(groupId);
@@ -26147,11 +26858,13 @@ var Groups = class {
26147
26858
  });
26148
26859
  let pending = this.pendingEntries.get(group.id);
26149
26860
  for (const { key, ts } of knocks) {
26150
- if (now - ts > 18e4 || rosterHas(session.roster, key) || this.refused.has(key) || pending?.has(key) || key === entry.link.host) continue;
26861
+ const again = this.unwelcomed(group, session, key);
26862
+ if (now - ts > 18e4 || rosterHas(session.roster, key) && !again || this.refused.has(key) || pending?.has(key) || key === entry.link.host) continue;
26863
+ if (again && this.rewelcomedLately(group.id, key)) continue;
26151
26864
  const entryIds = new Set(this.host.entries(group.id).values());
26152
26865
  const contactsInvited = [...this.invited.get(group.id) ?? []].filter((id) => !entryIds.has(id)).length;
26153
26866
  const size = session.roster.length + contactsInvited + (pending?.size ?? 0);
26154
- if ((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || size >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, size + 1)) break;
26867
+ if ((pending?.size ?? 0) >= MAX_PENDING_ENTRIES || !again && (size >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, size + 1))) break;
26155
26868
  if (!pending) this.pendingEntries.set(group.id, pending = /* @__PURE__ */ new Map());
26156
26869
  pending.set(key, now);
26157
26870
  this.warmUntil.set(group.id, now + this.timings.warmMs);
@@ -26211,9 +26924,9 @@ var Groups = class {
26211
26924
  }
26212
26925
  if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
26213
26926
  if (existing?.left && (existing.left.admin !== frame.admin || existing.contacts?.[existing.left.admin] !== linkId)) return;
26214
- if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis) return;
26215
- if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
26216
- if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
26927
+ if (existing?.invitation && !existing.state && existing.invitation.linkId !== linkId && !existing.invitation.genesis && !existing.invitation.takenBack) return;
26928
+ if ([...this.stored.values()].filter((x) => x.invitation && !x.invitation.takenBack).length >= 32) return;
26929
+ if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId && !x.invitation.takenBack).length >= MAX_INVITATIONS_PER_CHAT) {
26217
26930
  try {
26218
26931
  this.host.sendOnLink(linkId, {
26219
26932
  t: "group-decline",
@@ -26256,27 +26969,54 @@ var Groups = class {
26256
26969
  const session = this.sessions.get(g);
26257
26970
  if (typeof frame.key !== "string" || !MEMBER_KEY.test(frame.key)) return;
26258
26971
  if (!session?.isAdmin || !this.invited.get(g)?.has(linkId)) {
26259
- if (!(!!session && rosterHas(session.roster, frame.key) && this.stored.get(g)?.contacts?.[frame.key] === linkId) && ![...this.host.entries(g).values()].includes(linkId)) try {
26972
+ const letIn = !!session && rosterHas(session.roster, frame.key) && this.stored.get(g)?.contacts?.[frame.key] === linkId;
26973
+ if (!letIn && ![...this.host.entries(g).values()].includes(linkId)) try {
26260
26974
  this.host.sendOnLink(linkId, {
26261
26975
  t: "group-decline",
26262
26976
  g
26263
26977
  });
26264
26978
  } catch {}
26979
+ const edge = this.host.edges(g).get(frame.key);
26980
+ if (letIn && session.isAdmin && !(edge && this.host.linkReady(edge))) {
26981
+ if (this.rewelcomedLately(g, frame.key)) return;
26982
+ this.noteRewelcome(g, frame.key);
26983
+ const welcome = await session.rewelcome(frame.key).catch(() => []);
26984
+ try {
26985
+ for (const piece of welcome) this.host.sendOnLink(linkId, piece);
26986
+ } catch {}
26987
+ }
26265
26988
  return;
26266
26989
  }
26267
26990
  const group = this.stored.get(g);
26268
- if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
26991
+ if (frame.key === session.myKey) return;
26269
26992
  const entryPeer = this.hostEntry(g, linkId);
26270
26993
  if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
26271
- if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
26994
+ const again = !!entryPeer && this.unwelcomed(group, session, frame.key);
26995
+ if (rosterHas(session.roster, frame.key) && !again) return;
26996
+ if (again && this.rewelcomedLately(g, frame.key)) {
26997
+ this.pendingEntries.get(g)?.delete(frame.key);
26998
+ await this.host.closeEdge(linkId);
26999
+ return;
27000
+ }
27001
+ if (again) this.noteRewelcome(g, frame.key);
27002
+ if (!again && (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId))) return;
26272
27003
  if (!entryPeer) await session.setNick(frame.key, this.host.contactName(linkId));
26273
- this.meet(g, frame.key);
27004
+ if (!again) this.meet(g, frame.key);
26274
27005
  if (this.host.linkReady(linkId, 3)) this.markLarge(group, frame.key);
26275
- const welcome = await session.admit(frame.key);
27006
+ let welcome;
27007
+ try {
27008
+ welcome = again ? await session.rewelcome(frame.key) : await session.admit(frame.key);
27009
+ } catch (error) {
27010
+ if (!again) throw error;
27011
+ this.pendingEntries.get(g)?.delete(frame.key);
27012
+ await this.host.closeEdge(linkId);
27013
+ return;
27014
+ }
26276
27015
  if (!entryPeer) group.contacts = {
26277
27016
  ...group.contacts,
26278
27017
  [frame.key]: linkId
26279
27018
  };
27019
+ if (!again) group.unseen = [...(group.unseen ?? []).filter((key) => rosterHas(session.roster, key)), frame.key];
26280
27020
  this.answered(group, linkId);
26281
27021
  await this.store.putGroup(group);
26282
27022
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
@@ -26299,7 +27039,7 @@ var Groups = class {
26299
27039
  case "group-decline": {
26300
27040
  const mine = this.stored.get(g);
26301
27041
  if (mine?.invitation && !mine.invitation.entry && !mine.state && mine.invitation.linkId === linkId) {
26302
- await this.dropInvitation(mine);
27042
+ await this.takeBackFrom(mine);
26303
27043
  return;
26304
27044
  }
26305
27045
  const entryPeer = this.hostEntry(g, linkId);
@@ -26308,6 +27048,7 @@ var Groups = class {
26308
27048
  await this.host.closeEdge(linkId);
26309
27049
  }
26310
27050
  const group = this.stored.get(g);
27051
+ if (group && await this.declinedUnseen(g, group, linkId)) return;
26311
27052
  if (!group || !this.answered(group, linkId)) return;
26312
27053
  await this.store.putGroup(group);
26313
27054
  this.host.emit();
@@ -26403,7 +27144,7 @@ var Groups = class {
26403
27144
  const group = this.stored.get(g);
26404
27145
  const member = Object.entries(group?.contacts ?? {}).find(([, id]) => id === linkId)?.[0];
26405
27146
  if (!session?.isAdmin || !member || member === session.myKey) return;
26406
- if (rosterHas(session.roster, member)) await session.remove(member).catch(() => {});
27147
+ if (rosterHas(session.roster, member)) await session.remove(member, Date.now(), { left: true }).catch(() => {});
26407
27148
  try {
26408
27149
  this.host.sendOnLink(linkId, {
26409
27150
  t: "group-removed",
@@ -26471,7 +27212,8 @@ var Groups = class {
26471
27212
  return;
26472
27213
  }
26473
27214
  const taken = await session.handle(peerKey, frame);
26474
- if (taken.some((f) => f.t === "group-msg")) {
27215
+ const askedAt = this.askedAt(groupId, peerKey);
27216
+ if (taken.some((f) => f.t === "group-msg" && f.ts < askedAt)) {
26475
27217
  let handed = this.lastHanded.get(groupId);
26476
27218
  if (!handed) this.lastHanded.set(groupId, handed = /* @__PURE__ */ new Map());
26477
27219
  handed.set(peerKey, this.now());
@@ -26544,9 +27286,14 @@ var Groups = class {
26544
27286
  return;
26545
27287
  }
26546
27288
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
27289
+ const seen = !!group.unseen?.includes(peerKey);
27290
+ if (seen) {
27291
+ const unseen = group.unseen.filter((key) => key !== peerKey);
27292
+ group.unseen = unseen.length ? unseen : void 0;
27293
+ }
26547
27294
  const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
26548
27295
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
26549
- if (large || legacy) this.store.putGroup(group).catch(() => {});
27296
+ if (large || legacy || seen) this.store.putGroup(group).catch(() => {});
26550
27297
  if (!this.holdSync(groupId, peerKey)) try {
26551
27298
  this.host.sendOnLink(linkId, session.syncFrame(this.askOf(groupId, session, peerKey)));
26552
27299
  } catch {
@@ -26569,6 +27316,41 @@ var Groups = class {
26569
27316
  this.host.edgeUp?.(groupId, peerKey);
26570
27317
  this.host.emit();
26571
27318
  }
27319
+ /**
27320
+ * Admin side: a `group-decline` over the contact chat that let in a member never seen on an edge since. Its welcome
27321
+ * was lost and its app declined the invitation it still showed: it never had the group's keys, so it goes from the
27322
+ * roster (and may be invited again). True when one went.
27323
+ */
27324
+ async declinedUnseen(groupId, group, linkId) {
27325
+ const session = this.sessions.get(groupId);
27326
+ if (!session?.isAdmin || session.status !== "active" || !group.unseen) return false;
27327
+ const key = group.unseen.find((k) => group.contacts?.[k] === linkId && rosterHas(session.roster, k));
27328
+ if (!key) return false;
27329
+ const edge = this.host.edges(groupId).get(key);
27330
+ if (edge && this.host.linkReady(edge)) return false;
27331
+ const unseen = group.unseen.filter((k) => k !== key);
27332
+ group.unseen = unseen.length ? unseen : void 0;
27333
+ await this.remove(groupId, key).catch(() => {});
27334
+ await this.store.putGroup(group);
27335
+ this.host.emit();
27336
+ return true;
27337
+ }
27338
+ /** Admin side: a member in the roster never seen on an edge since it was let in, whose edge is not up now. */
27339
+ /** This member was sent its welcome again less than `REWELCOME_MS` ago, over its chat or through the link. */
27340
+ rewelcomedLately(groupId, key) {
27341
+ const last = this.rewelcomed.get(groupId)?.get(key);
27342
+ return last !== void 0 && this.now() - last < REWELCOME_MS;
27343
+ }
27344
+ noteRewelcome(groupId, key) {
27345
+ let sent = this.rewelcomed.get(groupId);
27346
+ if (!sent) this.rewelcomed.set(groupId, sent = /* @__PURE__ */ new Map());
27347
+ sent.set(key, this.now());
27348
+ }
27349
+ unwelcomed(group, session, key) {
27350
+ if (!session.isAdmin || !group.unseen?.includes(key) || !rosterHas(session.roster, key)) return false;
27351
+ const edge = this.host.edges(group.id).get(key);
27352
+ return !(edge && this.host.linkReady(edge));
27353
+ }
26572
27354
  /** Records that a member's app takes rosters past eight; true when that is news (the caller saves). */
26573
27355
  markLarge(group, key) {
26574
27356
  if (group.large?.includes(key)) return false;
@@ -26676,13 +27458,19 @@ var Groups = class {
26676
27458
  if (out.length) this.lastGossip.set(groupId, now);
26677
27459
  return out;
26678
27460
  }
27461
+ /** When `via` was last asked for another member's messages (`askOf`), or 0: what it sends written before that is its answer. */
27462
+ askedAt(groupId, via) {
27463
+ let at = 0;
27464
+ for (const asked of this.relayAsked.get(groupId)?.values() ?? []) if (asked.via === via && asked.at > at) at = asked.at;
27465
+ return at;
27466
+ }
26679
27467
  /**
26680
27468
  * Whether the answer of the member asked for someone's messages may still be coming: its edge up, asked under
26681
- * `MESH_SYNC_HOLD_MAX_MS` ago, and a message from it (or the asking) under `MESH_SYNC_HOLD_MS` ago.
27469
+ * `MESH_SYNC_HOLD_MAX_MS` ago, and a message of its answer (or the asking) under `MESH_SYNC_HOLD_MS` ago.
26682
27470
  */
26683
27471
  answering(groupId, { via, at }, now) {
26684
27472
  const edge = this.host.edges(groupId).get(via);
26685
- if (!edge || !this.host.linkReady(edge) || now - at >= 1e4) return false;
27473
+ if (!edge || !this.host.linkReady(edge) || now - at >= 18e4) return false;
26686
27474
  return now - Math.max(at, this.lastHanded.get(groupId)?.get(via) ?? 0) < MESH_SYNC_HOLD_MS;
26687
27475
  }
26688
27476
  /**
@@ -26702,21 +27490,41 @@ var Groups = class {
26702
27490
  held.set(peerKey, { ...asked });
26703
27491
  return true;
26704
27492
  }
26705
- /** The syncs that waited for another member's answer (`holdSync`), once it has come: for what it did not hand on. */
27493
+ /**
27494
+ * The syncs that waited for another member's answer (`holdSync`), once it has come: for what it did not hand on.
27495
+ * One member at a time: the first is told and asked for the others too, which wait for its answer in turn. Told
27496
+ * together, each answered with its own messages alone, and when the member asked first had been away too (it held
27497
+ * little of what was missed) a conversation came as one member's lines, then the next's.
27498
+ */
26706
27499
  heldSyncs(groupId, session, now) {
26707
27500
  const held = this.syncHeld.get(groupId);
26708
27501
  if (!held?.size) return;
26709
- const edges = this.host.edges(groupId);
26710
- for (const [key, asked] of held) {
26711
- if (this.answering(groupId, asked, now)) continue;
27502
+ const edges = this.host.edges(groupId), asked = this.relayAsked.get(groupId);
27503
+ const due = [];
27504
+ for (const [key, of] of held) {
27505
+ if (this.answering(groupId, of, now)) continue;
26712
27506
  held.delete(key);
26713
- if (this.relayAsked.get(groupId)?.get(key)?.via === asked.via) this.relayAsked.get(groupId).delete(key);
27507
+ if (asked?.get(key)?.via === of.via) asked.delete(key);
26714
27508
  const edge = edges.get(key);
26715
- if (!edge || !this.host.linkReady(edge) || !rosterHas(session.roster, key)) continue;
26716
- try {
26717
- this.host.sendOnLink(edge, session.syncFrame(this.askOf(groupId, session, key)));
26718
- } catch {}
27509
+ if (edge && this.host.linkReady(edge) && rosterHas(session.roster, key)) due.push(key);
27510
+ }
27511
+ const [next, ...rest] = due;
27512
+ if (!next) return;
27513
+ const ask = new Set(this.askOf(groupId, session, next));
27514
+ for (const key of rest) {
27515
+ ask.add(key);
27516
+ asked?.set(key, {
27517
+ via: next,
27518
+ at: now
27519
+ });
27520
+ held.set(key, {
27521
+ via: next,
27522
+ at: now
27523
+ });
26719
27524
  }
27525
+ try {
27526
+ this.host.sendOnLink(edges.get(next), session.syncFrame([...ask]));
27527
+ } catch {}
26720
27528
  }
26721
27529
  /**
26722
27530
  * Reachable members whose messages below the highest seen never reached me, for `via` to hand on: their own answer
@@ -26772,15 +27580,16 @@ var Groups = class {
26772
27580
  let tried = this.gapsAsked.get(groupId);
26773
27581
  if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
26774
27582
  let entry = tried.get(via);
27583
+ const now = this.now();
26775
27584
  if (!entry || entry.mark !== mark) tried.set(via, entry = {
26776
27585
  mark,
26777
27586
  of: /* @__PURE__ */ new Set(),
26778
- noticed: entry?.noticed,
27587
+ noticed: entry?.noticed ?? now,
26779
27588
  last: entry?.last
26780
27589
  });
26781
27590
  if (entry.of.has(via)) return false;
26782
27591
  entry.of.add(via);
26783
- entry.at = this.now();
27592
+ entry.at = now;
26784
27593
  return true;
26785
27594
  }
26786
27595
  /**
@@ -26870,11 +27679,14 @@ var Groups = class {
26870
27679
  this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26871
27680
  } catch {}
26872
27681
  }
26873
- const others = connected.filter((key) => !fresh.has(key)), pool = others.length ? others : connected;
26874
- if (!fresh.size || !pool.length) return;
26875
- const via = pool[(gossipStart(session.myKey) + this.gossipTurn++) % pool.length];
27682
+ if (!fresh.size || !connected.length) return;
27683
+ const at = () => connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
27684
+ let via = at();
27685
+ for (let i = 1; i < connected.length && fresh.has(via); i++) via = at();
26876
27686
  const keys = this.gapsOf(groupId, session, via, fresh);
26877
- if (keys.length) try {
27687
+ if (!keys.length) return;
27688
+ this.lastGossip.set(groupId, now);
27689
+ try {
26878
27690
  this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26879
27691
  } catch {}
26880
27692
  }
@@ -26965,7 +27777,8 @@ var Groups = class {
26965
27777
  if (!session) throw new Error("Group not found");
26966
27778
  return session;
26967
27779
  }
26968
- attach(state) {
27780
+ /** `frames`: what the group's rows hold to hand on, oldest first (none yet for a group just made, joined or given back). */
27781
+ attach(state, frames = []) {
26969
27782
  const session = new GroupSession(state, {
26970
27783
  clock: () => this.now(),
26971
27784
  seqFloor: () => this.host.seqFloor?.() ?? 0,
@@ -27019,8 +27832,21 @@ var Groups = class {
27019
27832
  ...m.frame && { frame: m.frame }
27020
27833
  };
27021
27834
  const completed = !!m.completes && !!this.host.completeMessage;
27835
+ const placing = completed ? void 0 : this.lineOrder.placing(MESSAGE_LINK(state.id), {
27836
+ id: m.id,
27837
+ epoch: m.epoch,
27838
+ line: false
27839
+ }, this.now());
27022
27840
  const stored = completed ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
27841
+ placing?.(stored !== false);
27023
27842
  if (m.frame && (completed || stored !== false)) await this.frameKept(state.id, m.id, m.frame);
27843
+ if (!completed && stored !== false) this.authorOrder.placed(state.id, {
27844
+ sender: m.sender,
27845
+ epoch: m.epoch,
27846
+ seq: m.seq,
27847
+ id: m.id,
27848
+ at: this.now()
27849
+ });
27024
27850
  const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
27025
27851
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
27026
27852
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
@@ -27028,6 +27854,10 @@ var Groups = class {
27028
27854
  noteCame(this.stored.get(state.id), came, m.sender !== session.myKey, mentioned);
27029
27855
  this.typings.messageFrom(state.id, m.sender);
27030
27856
  },
27857
+ kept: async (id, frame) => {
27858
+ await this.host.keepFrame?.(MESSAGE_LINK(state.id), id, frame).catch(() => {});
27859
+ await this.frameKept(state.id, id, frame);
27860
+ },
27031
27861
  edit: (e) => this.host.groupEdit?.(state.id, e),
27032
27862
  changed: () => {
27033
27863
  if (session.status === "removed" && this.hubs.isHub(state.id) && !this.removedAt.has(state.id)) this.removedAt.set(state.id, this.now());
@@ -27040,7 +27870,7 @@ var Groups = class {
27040
27870
  const edge = this.host.edges(state.id).get(to);
27041
27871
  return edge && this.host.linkHandled ? this.host.linkHandled(edge) : Promise.resolve(false);
27042
27872
  } }
27043
- });
27873
+ }, frames);
27044
27874
  this.sessions.set(state.id, session);
27045
27875
  this.lastRoster.set(state.id, session.roster);
27046
27876
  this.lastTold.set(state.id, {
@@ -27069,7 +27899,13 @@ var Groups = class {
27069
27899
  for (const [key] of after) if (!rosterHas(before, key) && key !== session.myKey) await this.event(groupId, "joined", `${name(key)} joined`, when, top.e, key);
27070
27900
  const gone = before.filter(([key]) => !rosterHas(after, key)).map(([key]) => key);
27071
27901
  this.hubs.removed(groupId, gone, this.now());
27072
- for (const key of gone) await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
27902
+ const left = (key) => {
27903
+ const chain = session.state.chain;
27904
+ for (let e = chain.length - 1; e > 0; e--) if (chain[e].k === "remove" && chain[e].s === key) return !!session.state.leftMarks?.[e];
27905
+ return false;
27906
+ };
27907
+ for (const key of gone) if (left(key)) await this.event(groupId, "left", `${name(key)} left`, when, top.e, key);
27908
+ else await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
27073
27909
  if (top.k === "role") await this.event(groupId, "admin", `${name(top.s)} ${top.s === session.myKey ? "are" : "is"} now the admin`, when, top.e, top.s);
27074
27910
  if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", when, top.e);
27075
27911
  }
@@ -27124,16 +27960,21 @@ var Groups = class {
27124
27960
  /** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `eventTime`. */
27125
27961
  async event(groupId, event, text, timestamp, epoch, member) {
27126
27962
  timestamp = eventTime(this.eventTimes, groupId, timestamp);
27127
- await this.host.storeMessage({
27963
+ const id = `event:${epoch}:${event}:${timestamp}`;
27964
+ this.lineOrder.placing(MESSAGE_LINK(groupId), {
27965
+ id,
27966
+ epoch,
27967
+ line: true
27968
+ }, this.now())(await this.host.storeMessage({
27128
27969
  linkId: MESSAGE_LINK(groupId),
27129
- id: `event:${epoch}:${event}:${timestamp}`,
27970
+ id,
27130
27971
  text,
27131
27972
  sender: "peer",
27132
27973
  event,
27133
27974
  member,
27134
27975
  timestamp,
27135
27976
  via: "datalink"
27136
- });
27977
+ }) !== false);
27137
27978
  }
27138
27979
  eventTimes = /* @__PURE__ */ new Map();
27139
27980
  };
@@ -29087,8 +29928,6 @@ function resumeHere(log, runs, peer) {
29087
29928
  const shared = runs.find((transport) => peer?.includes(transport));
29088
29929
  return shared ? { resume: shared } : {};
29089
29930
  }
29090
- /** A round trip that moved less than this is not news in the history. */
29091
- var RTT_STEP_MS = 10;
29092
29931
  var sequence = 0;
29093
29932
  var lineId = (at) => `${at.toString(36)}-${(++sequence).toString(36)}`;
29094
29933
  /** A row that says the chat is live on its transport from then on. */
@@ -29212,8 +30051,7 @@ function historyFromRows(rows) {
29212
30051
  });
29213
30052
  else if (e.kind === "flapping") events.push({
29214
30053
  ...base,
29215
- kind: e.live ? "live" : "down",
29216
- reason: `Dropped and came back ${e.count ?? 0} times`
30054
+ kind: e.live ? "live" : "down"
29217
30055
  });
29218
30056
  else if (e.kind === "connected" || e.kind === "back") events.push({
29219
30057
  ...base,
@@ -29493,7 +30331,7 @@ var TransportLog = class {
29493
30331
  changed = true;
29494
30332
  }
29495
30333
  const stretch = [...this.history].reverse().find((e) => e.kind === "live" || e.kind === "switched" || e.kind === "down");
29496
- if (stretch && stretch.kind !== "down" && stretch.transport === on && (stretch.rttMs === void 0 || Math.abs(stretch.rttMs - ms) >= RTT_STEP_MS)) {
30334
+ if (stretch && stretch.kind !== "down" && stretch.transport === on && (stretch.rttMs === void 0 || Math.abs(stretch.rttMs - ms) >= 10)) {
29497
30335
  stretch.rttMs = ms;
29498
30336
  changed = true;
29499
30337
  }
@@ -30975,7 +31813,8 @@ var CashuWallet = class CashuWallet {
30975
31813
  tx: walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note),
30976
31814
  ...payment ? { payment: payment(credited, mint) } : {}
30977
31815
  });
30978
- let swap = (await this.swapsAt(mint)).find((s) => s.kind === "receive" && s.token === token);
31816
+ const written = (await this.swapsAt(mint)).filter((s) => s.kind === "receive");
31817
+ let swap = written.find((s) => s.token === token);
30979
31818
  if (swap?.finished) return {
30980
31819
  amount: swap.tx?.amount ?? 0,
30981
31820
  mint,
@@ -30983,6 +31822,8 @@ var CashuWallet = class CashuWallet {
30983
31822
  };
30984
31823
  if (!swap) {
30985
31824
  const preview = await (await this.wallet(mint)).prepareSwapToReceive(token);
31825
+ const redeeming = new Set(written.flatMap((s) => (swapOf(s).inputs ?? []).map((p) => p.secret)));
31826
+ if (preview.inputs.some((p) => redeeming.has(p.secret))) throw engineError("ecashBeingReceived");
30986
31827
  swap = {
30987
31828
  id: swapId(),
30988
31829
  mint,
@@ -31119,7 +31960,17 @@ var CashuWallet = class CashuWallet {
31119
31960
  id: p.id
31120
31961
  })));
31121
31962
  if (states.length !== saved.inputs.length) throw new Error("The mint answered for another number of proofs");
31122
- if (states.some((s) => s.state === "PENDING")) return { state: "open" };
31963
+ if (states.some((s) => s.state === "PENDING")) {
31964
+ if (swap.kind === "receive" && refused) return {
31965
+ state: "never",
31966
+ gone: /* @__PURE__ */ new Set(),
31967
+ final: true
31968
+ };
31969
+ return {
31970
+ state: "open",
31971
+ pending: true
31972
+ };
31973
+ }
31123
31974
  const gone = new Set(saved.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
31124
31975
  if (gone.size > 0) {
31125
31976
  const second = await signed();
@@ -31241,7 +32092,7 @@ var CashuWallet = class CashuWallet {
31241
32092
  }
31242
32093
  /**
31243
32094
  * Swaps still written down: the app closed while the mint was being asked, or the mint did not answer. Each mint is
31244
- * asked where its own stand, under its lock. A swap it made is stored as its answer would have been; one it says it
32095
+ * asked where its own stand, one swap at a time under its lock. A swap it made is stored as its answer would have been; one it says it
31245
32096
  * never made is undone; one that is only overdue gives its inputs back and stays watched; one it cannot speak for
31246
32097
  * is asked about again. `mints`: only these mints', once, now.
31247
32098
  */
@@ -31260,16 +32111,18 @@ var CashuWallet = class CashuWallet {
31260
32111
  const later = (ms) => {
31261
32112
  again = again ? Math.min(again, ms) : ms;
31262
32113
  };
31263
- for (const mint of new Set(open.map((s) => s.mint))) await this.locked(mint, async () => {
31264
- for (let swap of await this.swapsAt(mint)) try {
32114
+ for (const { id, mint } of open) await this.locked(mint, async () => {
32115
+ let swap = await wrap((await store(STORES$1.swaps, "readonly")).get(id));
32116
+ if (!swap) return;
32117
+ try {
31265
32118
  if (swap.finished) {
31266
32119
  if (await this.tellSwap(swap, true)) await wrap((await store(STORES$1.swaps, "readwrite")).delete(swap.id));
31267
32120
  else later(SWAP_POLL_MS);
31268
- continue;
32121
+ return;
31269
32122
  }
31270
32123
  if (swap.askAt && Date.now() < swap.askAt && !mints) {
31271
32124
  later(swap.askAt - Date.now());
31272
- continue;
32125
+ return;
31273
32126
  }
31274
32127
  if (swap.attemptEndedAt === void 0) {
31275
32128
  swap = {
@@ -31282,7 +32135,7 @@ var CashuWallet = class CashuWallet {
31282
32135
  if (outcome.state === "open") {
31283
32136
  if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) {
31284
32137
  await this.dropSwap(swap, /* @__PURE__ */ new Set());
31285
- continue;
32138
+ return;
31286
32139
  }
31287
32140
  if (outcome.slow) {
31288
32141
  const stuck = (swap.stuck ?? 0) + 1;
@@ -31321,8 +32174,20 @@ var CashuWallet = class CashuWallet {
31321
32174
  this.events.onChange();
31322
32175
  } else later(SWAP_POLL_MS);
31323
32176
  }
31324
- else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
31325
- continue;
32177
+ else if (outcome.pending && swap.kind === "receive" && !swap.released && Date.now() >= swapOverdueAt(swap)) {
32178
+ if (Date.now() < swap.createdAt + SWAP_WATCH_MS) {
32179
+ await wrap((await store(STORES$1.swaps, "readwrite")).put({
32180
+ ...swap,
32181
+ askAt: Date.now() + SWAP_SLOW_POLL_MS
32182
+ }));
32183
+ later(SWAP_SLOW_POLL_MS);
32184
+ } else {
32185
+ await this.dropSwap(swap, /* @__PURE__ */ new Set());
32186
+ await this.tellSwap(swap, false);
32187
+ this.events.onChange();
32188
+ }
32189
+ } else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
32190
+ return;
31326
32191
  }
31327
32192
  if (outcome.state === "done") {
31328
32193
  if (await this.finishSwap(swap, outcome.keep, outcome.send, true)) {
@@ -31333,7 +32198,7 @@ var CashuWallet = class CashuWallet {
31333
32198
  } else if (swap.released) {
31334
32199
  if (outcome.final || Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, outcome.gone);
31335
32200
  else later(SWAP_SLOW_POLL_MS);
31336
- continue;
32201
+ return;
31337
32202
  } else {
31338
32203
  await this.dropSwap(swap, outcome.gone, !outcome.final);
31339
32204
  await this.tellSwap(swap, false);
@@ -32787,12 +33652,27 @@ var PaymentDesk = class {
32787
33652
  }
32788
33653
  return;
32789
33654
  }
32790
- if (done) await this.creditCashu(record, link);
32791
- else if (!this.payments.has(record.id)) link?.sendPaymentResult({
33655
+ if (done) {
33656
+ await this.creditCashu(record, link);
33657
+ return;
33658
+ }
33659
+ if (this.payments.has(record.id)) return;
33660
+ const reason = "The mint did not confirm the ecash";
33661
+ link?.sendPaymentResult({
32792
33662
  id: record.id,
32793
33663
  ok: false,
32794
- error: "The mint did not confirm the ecash"
33664
+ error: reason
32795
33665
  });
33666
+ try {
33667
+ await this.host.storeMessage({
33668
+ linkId: record.linkId,
33669
+ id: `peer_${record.createdAt}_refused`,
33670
+ text: `Could not receive ${record.amount.toLocaleString()} sats: ${reason}`,
33671
+ sender: "peer",
33672
+ timestamp: record.createdAt,
33673
+ via: "datalink"
33674
+ });
33675
+ } catch {}
32796
33676
  }
32797
33677
  async onPaymentResult(linkId, result) {
32798
33678
  const payment = this.find(linkId, result.id);
@@ -34383,24 +35263,6 @@ function messageAttention(type, message, { startedAt, newest, now }) {
34383
35263
  };
34384
35264
  }
34385
35265
  //#endregion
34386
- //#region ../browser/src/shared/hyperdhtRelay.ts
34387
- /** Only `wss://`, or `ws://` to this machine (a relay for tests or development). */
34388
- function hyperdhtRelayProblem(url) {
34389
- let parsed;
34390
- try {
34391
- parsed = new URL(url);
34392
- } catch {
34393
- return "Enter a relay address (wss://…)";
34394
- }
34395
- if (parsed.protocol === "wss:") return null;
34396
- if (parsed.protocol === "ws:" && [
34397
- "127.0.0.1",
34398
- "localhost",
34399
- "[::1]"
34400
- ].includes(parsed.hostname)) return null;
34401
- return "Use a wss:// relay address";
34402
- }
34403
- //#endregion
34404
35266
  //#region ../browser/src/shared/pushRelay.ts
34405
35267
  /**
34406
35268
  * The push relay a browser hands wake-ups to when it may not post to a push service itself (WISP 401 § Wake-up
@@ -34551,10 +35413,10 @@ var NATIVE_SLOTS = 8;
34551
35413
  var IROH_WEB_SLOTS = 16;
34552
35414
  /** Of a transport's listeners, the ones a group's links never hold: 1:1 chats keep that room. */
34553
35415
  var CHAT_NATIVE_SLOTS = 4;
34554
- /** How often a group link that found no free native slot tries again. */
34555
- var GROUP_NATIVE_RETRY_MS = 15e3;
34556
- /** The longest a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
34557
- var GROUP_NATIVE_FAILED_MAX_MS = 3e5;
35416
+ /** How often a group link that found no free native slot tries again, and the first wait of a listener that failed to start. */
35417
+ var NATIVE_RETRY_MS = 15e3;
35418
+ /** The longest a chat or a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
35419
+ var NATIVE_FAILED_MAX_MS = 3e5;
34558
35420
  /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
34559
35421
  var CLEAR_WITHDRAW_MS = 6e3;
34560
35422
  /**
@@ -34583,11 +35445,19 @@ var DEFAULT_SETTINGS = {
34583
35445
  */
34584
35446
  var CARD_RESTORE_MOST = 50;
34585
35447
  var CARD_RESTORE_MS = 6048e5;
35448
+ /**
35449
+ * A round trip that moved is shown at once, and written to disk by itself (relaxed, no disk sync of its own) at most
35450
+ * this often: a 15 s sample on an internet path moves by 10 ms most of the time. A power cut loses at most this much
35451
+ * of the round trip's drift, never a row; any other change to the story writes the latest round trip with it.
35452
+ */
35453
+ var RTT_SAVE_MS = 3e5;
34586
35454
  /** A reaction said on the live session and not confirmed is said again after this long. */
34587
35455
  var REACTION_RESEND_MS = 3e4;
34588
35456
  /** A file or a payment request of mine that waits for the chat to be live (a text that waits is its outbox's). */
34589
35457
  var waitsForLive = (m) => m.sender === "me" && m.delivery === "waiting" && (!!m.file || !!m.paymentId);
34590
35458
  /** This side's participation key in a chat: derived from its seed once, not at every state built. */
35459
+ /** A read of the contact's packet that found it as the last one did: online the same, the same packet, name and services. */
35460
+ var samePresence = (a, b) => a.online === b.online && a.lastPacketAt === b.lastPacketAt && a.seenAt === b.seenAt && a.nick === b.nick && JSON.stringify(a.services) === JSON.stringify(b.services);
34591
35461
  function participationKeyOf(live) {
34592
35462
  const seed = live.stored.participationSeed;
34593
35463
  if (!seed) return void 0;
@@ -34765,7 +35635,7 @@ var GhostlyNode = class GhostlyNode {
34765
35635
  * Everything that publishes, dials, polls or settles asks this, not the stored setting.
34766
35636
  */
34767
35637
  get networkOn() {
34768
- return !!this.settings.online && !this.limitedMode;
35638
+ return !!this.settings.online && !this.limitedMode && !this.options.offline;
34769
35639
  }
34770
35640
  transport;
34771
35641
  /** The same, for groups' requests: a relay budget keeps a chat's share from them (`CHAT_RESERVE`). */
@@ -34783,10 +35653,11 @@ var GhostlyNode = class GhostlyNode {
34783
35653
  });
34784
35654
  clockOff;
34785
35655
  /**
34786
- * Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
34787
- * no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
35656
+ * Edges of groups whose WebRTC did not connect (`edgeWithoutRtc`): they go on as an app with no WebRTC does, over a
35657
+ * native transport, until the member is seen online again after a packet said it was gone (`away`, `edgeRtcPresence`).
35658
+ * Not kept: the next start tries WebRTC first again.
34788
35659
  */
34789
- edgeRtcOff = /* @__PURE__ */ new Set();
35660
+ edgeRtcOff = /* @__PURE__ */ new Map();
34790
35661
  pollIntervals;
34791
35662
  localFetch;
34792
35663
  links = /* @__PURE__ */ new Map();
@@ -34809,6 +35680,8 @@ var GhostlyNode = class GhostlyNode {
34809
35680
  });
34810
35681
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
34811
35682
  transfersRestored = false;
35683
+ /** `start` has loaded the groups (EngineState.groupsLoaded). */
35684
+ groupsLoaded = false;
34812
35685
  feedbackStartedAt = Date.now();
34813
35686
  feedbackIds = /* @__PURE__ */ new Set();
34814
35687
  walletFeedbackReady = false;
@@ -34892,9 +35765,15 @@ var GhostlyNode = class GhostlyNode {
34892
35765
  groupWakeReceived = /* @__PURE__ */ new Map();
34893
35766
  requestCounts = /* @__PURE__ */ new Map();
34894
35767
  transfers = /* @__PURE__ */ new Map();
35768
+ /**
35769
+ * Views that go in the next state sent, and then away (`GroupFilesHost.brief`): dropped once a state carried them,
35770
+ * unless another view took their place meanwhile.
35771
+ */
35772
+ briefViews = /* @__PURE__ */ new Map();
34895
35773
  /** files/3 in every chat: offers, resumable transfers, checked by digest (WISP 501 rev 0.3). */
34896
35774
  fileDesk = new FileDesk({
34897
35775
  send: (linkId, frame) => this.links.get(linkId)?.link?.sendFilesFrame(frame) ?? false,
35776
+ sendData: (linkId, id, offset, data) => this.links.get(linkId)?.link?.sendFileData(id, offset, data) ?? false,
34898
35777
  writable: (linkId) => this.links.get(linkId)?.link?.filesWritable(),
34899
35778
  receivedBytes: (linkId) => this.links.get(linkId)?.files.receivedBytes ?? 0,
34900
35779
  wireIds: (linkId) => this.links.get(linkId)?.files.wireIds,
@@ -35670,6 +36549,10 @@ var GhostlyNode = class GhostlyNode {
35670
36549
  forgetFrame: async (linkId, id) => {
35671
36550
  await db.patchMessage(linkId, id, (stored) => stored.frame ? { frame: void 0 } : null, true);
35672
36551
  },
36552
+ keepFrame: async (linkId, id, frame) => {
36553
+ await db.patchMessage(linkId, id, () => ({ frame }), true);
36554
+ },
36555
+ placeAgain: (linkId, ids) => this.placeAgain(linkId, ids),
35673
36556
  membersChanged: (groupId) => this.groupMembersChanged(groupId),
35674
36557
  historyGone: (groupId) => this.groupHistoryGone(groupId),
35675
36558
  emit: () => this.emitState(),
@@ -35690,11 +36573,16 @@ var GhostlyNode = class GhostlyNode {
35690
36573
  groupFiles: {
35691
36574
  store: storedGroupFiles,
35692
36575
  transfers: this.transfers,
36576
+ brief: (id) => {
36577
+ const view = this.transfers.get(id);
36578
+ if (view) this.briefViews.set(id, view);
36579
+ },
35693
36580
  settings: () => ({
35694
36581
  autoDownloads: this.settings.autoDownloads !== false,
35695
36582
  serveFiles: this.settings.serveFiles !== false
35696
36583
  }),
35697
36584
  sendFiles: (linkId, frame) => this.links.get(linkId)?.link?.sendFilesFrame(frame) ?? false,
36585
+ sendData: (linkId, id, offset, data) => this.links.get(linkId)?.link?.sendFileData(id, offset, data) ?? false,
35698
36586
  writable: (linkId) => this.links.get(linkId)?.link?.filesWritable()
35699
36587
  }
35700
36588
  });
@@ -36087,7 +36975,7 @@ var GhostlyNode = class GhostlyNode {
36087
36975
  const messages = stored.group ? [] : await db.getMessages(stored.id);
36088
36976
  const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
36089
36977
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
36090
- for (const file of storedFiles) if (file.transfer && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
36978
+ for (const file of storedFiles) if (file.transfer && file.transfer.state !== "done" && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
36091
36979
  ...file.transfer,
36092
36980
  state: "failed",
36093
36981
  error: "Transfer interrupted. Retry when connected."
@@ -36105,6 +36993,7 @@ var GhostlyNode = class GhostlyNode {
36105
36993
  this.transfersRestored = true;
36106
36994
  for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
36107
36995
  await this.groups.load(this.limitedMode);
36996
+ this.groupsLoaded = true;
36108
36997
  for (const group of this.groups.views()) {
36109
36998
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
36110
36999
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -36123,7 +37012,7 @@ var GhostlyNode = class GhostlyNode {
36123
37012
  this.prepareSpare(STARTUP_QUIET_MS);
36124
37013
  }
36125
37014
  if (!this.limitedMode) this.openStartedWallets(fresh);
36126
- if (this.appsOn) this.appStore.start().catch(() => {});
37015
+ if (this.appsOn && !this.appsWireOnly) this.appStore.start().catch(() => {});
36127
37016
  if (!this.options.singleDevice && this.networkOn && knownDeviceGate()?.state === "active") this.startDeviceSet().catch(() => {});
36128
37017
  if (!this.options.singleDevice && this.limitedMode && knownDeviceGate()?.state === "active") this.readTurnWhileLimited();
36129
37018
  if (this.restoreCheck === "checking" && this.limitedMode) this.checkRestoredTurn(0);
@@ -36588,7 +37477,7 @@ var GhostlyNode = class GhostlyNode {
36588
37477
  appShelf;
36589
37478
  /** The installed apps and stores, refused while the apps feature is off. */
36590
37479
  get appStore() {
36591
- if (!this.appsOn) throw this.appsOff();
37480
+ if (!this.appsOn || this.appsWireOnly) throw this.appsOff();
36592
37481
  return this.appShelf ??= new Apps({
36593
37482
  fetch: boundedAppFetch({
36594
37483
  fetcher: this.options.appFetch,
@@ -36599,12 +37488,16 @@ var GhostlyNode = class GhostlyNode {
36599
37488
  return !!stored && !stored.group && !!stored.profile && !!stored.pairedPeerKey;
36600
37489
  },
36601
37490
  online: () => this.networkOn && !this.shuttingDown,
36602
- checked: () => this.events.onAppsChecked?.()
37491
+ checked: () => this.events.onAppsChecked?.(),
37492
+ ...this.options.builtInApp && { readBuiltIn: this.options.builtInApp }
36603
37493
  });
36604
37494
  }
36605
37495
  appList() {
36606
37496
  return this.appStore.list();
36607
37497
  }
37498
+ appBuiltIns() {
37499
+ return this.appStore.builtIns();
37500
+ }
36608
37501
  appStoreList() {
36609
37502
  return this.appStore.listStores();
36610
37503
  }
@@ -36650,6 +37543,9 @@ var GhostlyNode = class GhostlyNode {
36650
37543
  appFile(params) {
36651
37544
  return this.appStore.file(params);
36652
37545
  }
37546
+ appFileAt(params) {
37547
+ return this.appStore.fileAt(params);
37548
+ }
36653
37549
  appStorageGet(params) {
36654
37550
  return this.appStore.storageGet(params);
36655
37551
  }
@@ -36665,6 +37561,9 @@ var GhostlyNode = class GhostlyNode {
36665
37561
  appDataExport(params) {
36666
37562
  return this.appStore.exportData(params);
36667
37563
  }
37564
+ appDataExportText(params) {
37565
+ return this.appStore.exportDataText(params);
37566
+ }
36668
37567
  getState() {
36669
37568
  const groups = this.groups.views();
36670
37569
  return {
@@ -36692,6 +37591,7 @@ var GhostlyNode = class GhostlyNode {
36692
37591
  })).sort((a, b) => a.createdAt - b.createdAt),
36693
37592
  transfers: Object.fromEntries(this.transfers),
36694
37593
  transfersRestored: this.transfersRestored,
37594
+ ...this.groupsLoaded && { groupsLoaded: true },
36695
37595
  wallet: this.walletView,
36696
37596
  payments: this.desk.views(),
36697
37597
  identityProofs: this.identities.views(),
@@ -36849,9 +37749,17 @@ var GhostlyNode = class GhostlyNode {
36849
37749
  madeAt: Date.now()
36850
37750
  };
36851
37751
  }
36852
- /** One invite warmed and waiting (`after` ms from now), warmed again every so often while it waits (the relays forget). */
37752
+ /**
37753
+ * One invite warmed and waiting (`after` ms from now), warmed again every so often while it waits (the relays forget).
37754
+ * Once too old for `takeInvite` to hand out, it is replaced by a new one, warmed at once: warming it on would spend
37755
+ * two puts every few minutes on keys nobody will use, and leave the next invite on cold keys.
37756
+ */
36853
37757
  prepareSpare(after = 0) {
36854
37758
  if (this.shuttingDown || !this.networkOn) return;
37759
+ if (this.spare && after === 0 && Date.now() - this.spare.madeAt >= SPARE_INVITE_MAX_AGE_MS) {
37760
+ for (const identity of [identityFromSeedB64(this.spare.mine.seedB64), this.spare.inviteKey]) this.warmedKeys.delete(identity.pubKeyZ32);
37761
+ this.spare = null;
37762
+ }
36855
37763
  if (!this.spare) this.spare = this.makeSpare();
36856
37764
  const spare = this.spare;
36857
37765
  if (this.spareTimer) clearTimeout(this.spareTimer);
@@ -36867,10 +37775,11 @@ var GhostlyNode = class GhostlyNode {
36867
37775
  this.warmedKeys.set(identity.pubKeyZ32, Date.now());
36868
37776
  this.warmKey(identity);
36869
37777
  }
37778
+ const untilAged = spare.madeAt + SPARE_INVITE_MAX_AGE_MS - Date.now();
36870
37779
  this.spareTimer = setTimeout(() => {
36871
37780
  this.spareTimer = null;
36872
37781
  if (this.spare === spare) this.prepareSpare();
36873
- }, SPARE_INVITE_WARM_EVERY_MS);
37782
+ }, Math.min(SPARE_INVITE_WARM_EVERY_MS, untilAged));
36874
37783
  }
36875
37784
  async joinLink({ inviteCode }) {
36876
37785
  const decoded = decodeInviteCode(inviteCode);
@@ -37535,7 +38444,7 @@ var GhostlyNode = class GhostlyNode {
37535
38444
  this.transport.networkChanged?.();
37536
38445
  this.directPath.reset();
37537
38446
  }
37538
- if (params.network) for (const failed of this.groupNativeFailed.values()) {
38447
+ if (params.network) for (const failed of this.nativeFailed.values()) {
37539
38448
  failed.at = 0;
37540
38449
  failed.wait = 0;
37541
38450
  }
@@ -37749,14 +38658,23 @@ var GhostlyNode = class GhostlyNode {
37749
38658
  /**
37750
38659
  * The message of a chat a reply answers, by its id here or the id both sides know it by (`replyRef`): what the
37751
38660
  * reply keeps of it, or why there is nothing to reply to. A group's reply needs the author's member key too.
38661
+ * Read alone by its id, or a chat's `peer_`/`me_` row for a wire id, as `resolveReply` does: a group's message goes
38662
+ * by its row's id, so a miss there is not here; a chat's other rows (a file's, a payment's) are looked for in the
38663
+ * whole chat.
37752
38664
  */
37753
38665
  async replyFor(linkId, messageId) {
37754
38666
  if (typeof messageId !== "string" || !messageId) return "No message to reply to";
37755
- const messages = await db.getMessages(linkId);
37756
- const original = messages.find((m) => m.id === messageId) ?? messages.find((m) => replyRef(m) === messageId);
38667
+ const group = linkId.startsWith("group:");
38668
+ let original = await db.getMessage(linkId, messageId);
38669
+ for (const rowId of group ? [] : [`peer_${messageId}`, `me_${messageId}`]) {
38670
+ if (original) break;
38671
+ const row = await db.getMessage(linkId, rowId);
38672
+ if (row && replyRef(row) === messageId) original = row;
38673
+ }
38674
+ if (!original && !group) original = (await db.getMessages(linkId)).find((m) => replyRef(m) === messageId);
37757
38675
  const ref = original && replyRef(original);
37758
38676
  if (!original || !ref) return "That message is not in this chat, or cannot be replied to";
37759
- if (linkId.startsWith("group:") && !original.member) return "That message cannot be replied to";
38677
+ if (group && !original.member) return "That message cannot be replied to";
37760
38678
  return replyTo(original, ref);
37761
38679
  }
37762
38680
  /**
@@ -38094,6 +39012,7 @@ var GhostlyNode = class GhostlyNode {
38094
39012
  const chat = this.links.get(linkId)?.stored;
38095
39013
  outbox = new Outbox({
38096
39014
  read: () => db.getMessages(linkId),
39015
+ get: (id) => db.getMessage(linkId, id),
38097
39016
  update: async (id, delivery, error, extra) => {
38098
39017
  if (delivery === "delivered") await db.patchMessage(linkId, id, (message) => message.delivery === "delivered" ? null : {
38099
39018
  ...message.delivery && {
@@ -39175,19 +40094,40 @@ var GhostlyNode = class GhostlyNode {
39175
40094
  const cue = transportCue(mark, log.entries, log.history);
39176
40095
  if (cue) this.cueFeedback(cue, linkId);
39177
40096
  }
39178
- saveTransportLog(live, log) {
40097
+ /** `toDisk: false` shows the story and leaves the disk as it is; `relaxed`: written with no disk sync of its own. */
40098
+ saveTransportLog(live, log, { toDisk = true, relaxed = false } = {}) {
39179
40099
  const transportLog = log.entries.map((e) => ({ ...e })), transportHistory = log.history.map((e) => ({ ...e }));
39180
40100
  live.stored = {
39181
40101
  ...live.stored,
39182
40102
  transportLog,
39183
40103
  transportHistory
39184
40104
  };
39185
- db.patchLink(live.stored.id, {
39186
- transportLog,
39187
- transportHistory
39188
- }).catch(() => {});
40105
+ if (toDisk) {
40106
+ live.rttUnsaved = false;
40107
+ db.patchLink(live.stored.id, {
40108
+ transportLog,
40109
+ transportHistory
40110
+ }, relaxed).catch(() => {});
40111
+ }
39189
40112
  this.emitState();
39190
40113
  }
40114
+ /** A round trip measured on the chat's transport: in the view at once, on disk at most every `RTT_SAVE_MS`. */
40115
+ noteRtt(live, ms) {
40116
+ const moved = live.rttPosted === void 0 || Math.abs(live.rttPosted - ms) >= 10;
40117
+ const log = this.transportLogOf(live);
40118
+ if (!log) {
40119
+ if (moved) this.emitState();
40120
+ return;
40121
+ }
40122
+ const changed = log.rtt(ms);
40123
+ if (changed) live.rttUnsaved = true;
40124
+ const now = Date.now();
40125
+ if (live.rttUnsaved && (live.rttSavedAt === void 0 || now - live.rttSavedAt >= RTT_SAVE_MS)) {
40126
+ live.rttSavedAt = now;
40127
+ this.saveTransportLog(live, log, { relaxed: true });
40128
+ } else if (changed) this.saveTransportLog(live, log, { toDisk: false });
40129
+ else if (moved) this.emitState();
40130
+ }
39191
40131
  /** Which ways of paying one chat allows. */
39192
40132
  /** `networks`: for a way of paying, the networks this chat takes it on (the Accept side's cards); the others are kept. */
39193
40133
  async setChatPaymentMethods({ linkId, methods, networks }) {
@@ -39219,7 +40159,9 @@ var GhostlyNode = class GhostlyNode {
39219
40159
  async connect({ linkId }) {
39220
40160
  const link = this.links.get(linkId)?.link;
39221
40161
  if (!link) throw new Error("You are offline. Go online before reconnecting.");
39222
- await this.ensureNativeEndpoints(linkId);
40162
+ const live = this.links.get(linkId), native = this.ensureNativeEndpoints(linkId);
40163
+ const { preferredTransport: preferred, transportFallback: fallback } = live.stored;
40164
+ if (this.reachedOnlyNatively(live) || fallback === false && preferred && preferred !== "webrtc/1") await native;
39223
40165
  await link.connect();
39224
40166
  }
39225
40167
  disconnect({ linkId }) {
@@ -39729,11 +40671,22 @@ var GhostlyNode = class GhostlyNode {
39729
40671
  }
39730
40672
  /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
39731
40673
  get appsOn() {
39732
- return this.options.apps ?? true;
40674
+ return !!(this.options.apps ?? true);
39733
40675
  }
39734
- /** Why an app call is refused: this client runs no mini-app (`apps: false`), or the build has them off. */
40676
+ /** `apps/1` only (`apps: "wire"`): this host runs no mini-app and keeps no store, so nothing is installed here to check. */
40677
+ get appsWireOnly() {
40678
+ return this.options.apps === "wire";
40679
+ }
40680
+ /** Why an app call is refused: this client runs no mini-app (`apps: false`, or `"wire"` for the store's), or the build has them off. */
39735
40681
  appsOff() {
39736
- return /* @__PURE__ */ new Error(this.options.apps === false ? "Apps do not run on this client" : "Apps are unavailable in this release");
40682
+ return /* @__PURE__ */ new Error(this.options.apps === false || this.appsWireOnly ? "Apps do not run on this client" : "Apps are unavailable in this release");
40683
+ }
40684
+ /** Before an app's frames go to a contact: a version removed or revoked here says nothing more (WISP 1200 § Takedowns). */
40685
+ async appChatRunnable(ref, digest) {
40686
+ if (!this.appsWireOnly) await this.appStore.chatRunnable({
40687
+ ref,
40688
+ digest
40689
+ });
39737
40690
  }
39738
40691
  /** 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). */
39739
40692
  appChat(linkId, ref) {
@@ -39759,10 +40712,7 @@ var GhostlyNode = class GhostlyNode {
39759
40712
  async appOpen({ linkId, ref, version, digest }) {
39760
40713
  const { live, app } = this.appChat(linkId, ref);
39761
40714
  if (!isAppVersion(version)) throw new Error("Not an app version");
39762
- await this.appStore.chatRunnable({
39763
- ref,
39764
- digest
39765
- });
40715
+ await this.appChatRunnable(ref, digest);
39766
40716
  const open = live.appsOpen ??= /* @__PURE__ */ new Map();
39767
40717
  if (live.link) live.link.openApp(app, version);
39768
40718
  else open.set(app, version);
@@ -39778,10 +40728,7 @@ var GhostlyNode = class GhostlyNode {
39778
40728
  async appSend({ linkId, ref, data, digest }) {
39779
40729
  const { live, app } = this.appChat(linkId, ref);
39780
40730
  if (!live.appsOpen?.has(app)) return { error: "not-open" };
39781
- await this.appStore.chatRunnable({
39782
- ref,
39783
- digest
39784
- });
40731
+ await this.appChatRunnable(ref, digest);
39785
40732
  return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
39786
40733
  }
39787
40734
  /** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
@@ -40908,6 +41855,7 @@ var GhostlyNode = class GhostlyNode {
40908
41855
  const settings = rest;
40909
41856
  delete settings.backupReminders;
40910
41857
  const wasOnline = this.networkOn;
41858
+ if (Array.isArray(settings.iceServers)) settings.iceServers = settings.iceServers.map(iceServerSetting);
40911
41859
  for (const server of settings.iceServers ?? []) {
40912
41860
  const problem = iceServerProblem(server);
40913
41861
  if (problem) throw new Error(problem);
@@ -41876,7 +42824,7 @@ var GhostlyNode = class GhostlyNode {
41876
42824
  this.groupWakeReceived.delete(linkId);
41877
42825
  this.quietEdges.delete(linkId);
41878
42826
  this.groupNativeWaiting.delete(linkId);
41879
- this.groupNativeFailed.delete(linkId);
42827
+ this.nativeFailed.delete(linkId);
41880
42828
  this.groupNativeRetryAt = 0;
41881
42829
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
41882
42830
  const group = live.stored.group, peer = live.stored.groupPeer;
@@ -41992,7 +42940,7 @@ var GhostlyNode = class GhostlyNode {
41992
42940
  if (this.groupEntryTimer || this.shuttingDown || this.options.deferGroups) return;
41993
42941
  this.groupEntryTimer = setInterval(() => {
41994
42942
  this.groups.tick().catch(() => {});
41995
- this.retryGroupNative();
42943
+ this.retryNative();
41996
42944
  }, 1e3);
41997
42945
  }
41998
42946
  stopGroupEntries() {
@@ -42035,6 +42983,7 @@ var GhostlyNode = class GhostlyNode {
42035
42983
  usdtPaymentsSupport: !entry,
42036
42984
  barkPaymentsSupport: !entry,
42037
42985
  largeFilesSupport: !entry,
42986
+ binaryFilesSupport: !entry,
42038
42987
  params: stored,
42039
42988
  rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
42040
42989
  resume: !entry && stored.edgeLive ? resumeOn : void 0,
@@ -42140,9 +43089,11 @@ var GhostlyNode = class GhostlyNode {
42140
43089
  role,
42141
43090
  packetAt: presence.lastPacketAt
42142
43091
  });
43092
+ const same = samePresence(live.presence, presence);
42143
43093
  live.presence = presence;
42144
43094
  if (!entry && presence.nick && !live.link?.isDataLinkOpen) this.groups.edgeNick(group, peer, presence.nick);
42145
- this.emitState();
43095
+ if (!entry) this.edgeRtcPresence(linkId, presence);
43096
+ if (!same) this.emitState();
42146
43097
  },
42147
43098
  onPairingState: (state) => {
42148
43099
  live.pairing = state;
@@ -42199,14 +43150,44 @@ var GhostlyNode = class GhostlyNode {
42199
43150
  * never went live, and the member stayed unreachable for as long as that network lasted. The edge starts again as on an
42200
43151
  * app with no WebRTC (WISP 902 § Transports): it runs its native endpoints and says so in its packet (`_tr`, with no
42201
43152
  * `webrtc/1`), the member's app starts its own for it as it does for a Linux Desktop, and the two meet over Iroh
42202
- * through its relay. For this run of the app only.
43153
+ * through its relay. Until the member comes back (`edgeRtcPresence`), or for this run of the app.
42203
43154
  */
42204
43155
  async edgeWithoutRtc(linkId) {
42205
- const live = this.links.get(linkId), link = live?.link;
42206
- if (!live || !link || !live.stored.group || live.stored.groupEntry || this.shuttingDown || this.options.webrtcGroupLinks) return;
43156
+ const live = this.links.get(linkId);
43157
+ if (!live?.link || !live.stored.group || live.stored.groupEntry || this.shuttingDown || this.options.webrtcGroupLinks) return;
42207
43158
  if (this.edgeRtcOff.has(linkId) || this.keepsGroupNative(live.stored) || !Object.keys(this.nativeFactories).length) return;
42208
- this.edgeRtcOff.add(linkId);
43159
+ this.edgeRtcOff.set(linkId, { away: false });
42209
43160
  traceLink(live.myPubKeyZ32, "edge-rtc-off", {});
43161
+ await this.restartEdge(linkId);
43162
+ }
43163
+ /**
43164
+ * A member whose edge went native here (`edgeWithoutRtc`) is seen online again after a packet said it was gone: the
43165
+ * attempt that failed may have been its app quitting in the middle of it (a one-shot CLI command, a quick restart),
43166
+ * not a network that blocks direct connections. Held native for this whole run, the edge said so in its packet, the
43167
+ * member's app followed on its next starts, and past the group links' native slots members stayed unreachable for as
43168
+ * long as this app ran (2026-10-09). The edge tries WebRTC again, its `_tr` with `webrtc/1` again (WISP 902 §
43169
+ * Transports); a network that really blocks it costs one more failed attempt per return.
43170
+ */
43171
+ edgeRtcPresence(linkId, presence) {
43172
+ const off = this.edgeRtcOff.get(linkId);
43173
+ if (!off) return;
43174
+ if (!presence.online) {
43175
+ off.away = true;
43176
+ return;
43177
+ }
43178
+ if (!off.away) return;
43179
+ const live = this.links.get(linkId);
43180
+ this.edgeRtcOff.delete(linkId);
43181
+ if (live) traceLink(live.myPubKeyZ32, "edge-rtc-back", {});
43182
+ this.groupNativeWaiting.delete(linkId);
43183
+ this.nativeFailed.delete(linkId);
43184
+ this.groupNativeRetryAt = 0;
43185
+ this.restartEdge(linkId);
43186
+ }
43187
+ /** An edge starts again, on what `keepsGroupNative` says now. */
43188
+ async restartEdge(linkId) {
43189
+ const live = this.links.get(linkId), link = live?.link;
43190
+ if (!live || !link) return;
42210
43191
  live.link = null;
42211
43192
  await link.stop(false).catch(() => {});
42212
43193
  if (this.links.get(linkId) !== live || live.link || this.shuttingDown || !this.networkOn) return;
@@ -42240,6 +43221,7 @@ var GhostlyNode = class GhostlyNode {
42240
43221
  callsSupport: this.options.callsSupport ?? typeof RTCPeerConnection !== "undefined",
42241
43222
  callsMissing: this.options.callsUnavailable,
42242
43223
  largeFilesSupport: true,
43224
+ binaryFilesSupport: true,
42243
43225
  typingSupport: true,
42244
43226
  reactionsSupport: this.options.reactions !== false,
42245
43227
  editSupport: !GhostlyNode.testNoEdit(),
@@ -42391,11 +43373,7 @@ var GhostlyNode = class GhostlyNode {
42391
43373
  this.emitState();
42392
43374
  },
42393
43375
  onLiveAttempt: () => this.emitState(),
42394
- onRtt: (ms) => {
42395
- const log = this.transportLogOf(live);
42396
- if (log?.rtt(ms)) this.saveTransportLog(live, log);
42397
- else this.emitState();
42398
- },
43376
+ onRtt: (ms) => this.noteRtt(live, ms),
42399
43377
  onDiscoveryError: (error) => {
42400
43378
  if ((error ?? void 0) !== live.discoveryError) {
42401
43379
  live.discoveryError = error ?? void 0;
@@ -42428,16 +43406,17 @@ var GhostlyNode = class GhostlyNode {
42428
43406
  const was = live.status, synced = live.lastSyncAt;
42429
43407
  live.status = status;
42430
43408
  if (status === "online") live.lastSyncAt = Date.now();
42431
- if (status !== was || !synced || stored.group || linkId === this.activeLinkId) this.emitState();
43409
+ if (status !== was || !synced && status === "online" || stored.group || linkId === this.activeLinkId) this.emitState();
42432
43410
  },
42433
43411
  onPoll: ({ polling, nextInMs }) => {
42434
- const was = live.poll.polling;
43412
+ const was = live.poll.polling, first = !live.lastSyncAt && !live.firstReadDone;
42435
43413
  live.poll = {
42436
43414
  polling,
42437
43415
  nextAt: Date.now() + nextInMs,
42438
43416
  interval: nextInMs || live.poll.interval
42439
43417
  };
42440
- if (linkId === this.activeLinkId || !live.lastSyncAt && polling !== was) this.emitState();
43418
+ if (was && !polling) live.firstReadDone = true;
43419
+ if (linkId === this.activeLinkId || first && polling !== was) this.emitState();
42441
43420
  },
42442
43421
  onMessageReceipt: async (id) => {
42443
43422
  const oneToOne = !!stored.profile && !stored.group;
@@ -42516,6 +43495,7 @@ var GhostlyNode = class GhostlyNode {
42516
43495
  this.emitState();
42517
43496
  },
42518
43497
  onPresence: (presence) => {
43498
+ let same = samePresence(live.presence, presence);
42519
43499
  live.presence = presence;
42520
43500
  if (!stored.profile && presence.nick && presence.nick !== live.stored.peerNick) {
42521
43501
  live.stored = {
@@ -42523,8 +43503,9 @@ var GhostlyNode = class GhostlyNode {
42523
43503
  peerNick: presence.nick
42524
43504
  };
42525
43505
  db.patchLink(linkId, { peerNick: presence.nick });
43506
+ same = false;
42526
43507
  }
42527
- this.emitState();
43508
+ if (!same) this.emitState();
42528
43509
  },
42529
43510
  onMessage: (message) => {
42530
43511
  if (live.stored.inviteCode) {
@@ -42719,7 +43700,7 @@ var GhostlyNode = class GhostlyNode {
42719
43700
  get nativeFactories() {
42720
43701
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
42721
43702
  return {
42722
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-hKC9DpIH.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
43703
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-CEmBpT0Q.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
42723
43704
  ...this.options.nativeTransports,
42724
43705
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
42725
43706
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -42820,17 +43801,18 @@ var GhostlyNode = class GhostlyNode {
42820
43801
  return Promise.all(starts).then((full) => {
42821
43802
  const live = this.links.get(linkId), link = live?.link;
42822
43803
  if (this.shuttingDown || !live || !link || link !== expected) return;
42823
- if (live.stored.group && this.keepsGroupNative(live.stored)) {
43804
+ const group = !!live.stored.group;
43805
+ if (group && this.keepsGroupNative(live.stored)) {
42824
43806
  if (full.includes("no-slot") && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
42825
43807
  else this.groupNativeWaiting.delete(linkId);
42826
- if (full.includes("failed")) {
42827
- const wait = Math.min(Math.max(GROUP_NATIVE_RETRY_MS, (this.groupNativeFailed.get(linkId)?.wait ?? 0) * 2), GROUP_NATIVE_FAILED_MAX_MS);
42828
- this.groupNativeFailed.set(linkId, {
42829
- at: Date.now() + wait,
42830
- wait
42831
- });
42832
- } else this.groupNativeFailed.delete(linkId);
42833
- } else this.groupNativeFailed.delete(linkId);
43808
+ }
43809
+ if ((group ? this.keepsGroupNative(live.stored) : this.keepsNativeEndpoints(linkId, live.stored)) && full.includes("failed")) {
43810
+ const wait = Math.min(Math.max(NATIVE_RETRY_MS, (this.nativeFailed.get(linkId)?.wait ?? 0) * 2), NATIVE_FAILED_MAX_MS);
43811
+ this.nativeFailed.set(linkId, {
43812
+ at: Date.now() + wait,
43813
+ wait
43814
+ });
43815
+ } else this.nativeFailed.delete(linkId);
42834
43816
  this.emitState();
42835
43817
  });
42836
43818
  }
@@ -42988,25 +43970,25 @@ var GhostlyNode = class GhostlyNode {
42988
43970
  /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
42989
43971
  groupNativeWaiting = /* @__PURE__ */ new Set();
42990
43972
  groupNativeRetryAt = 0;
42991
- /** Group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
42992
- groupNativeFailed = /* @__PURE__ */ new Map();
43973
+ /** Chats and group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
43974
+ nativeFailed = /* @__PURE__ */ new Map();
42993
43975
  /**
42994
43976
  * Now and then, the group links that found no free native slot try again: a chat or another group may have let one go.
42995
- * Those whose listener failed to start try again once their wait is over.
43977
+ * Chats and group links whose listener failed to start try again once their wait is over.
42996
43978
  */
42997
- retryGroupNative() {
43979
+ retryNative() {
42998
43980
  const now = Date.now();
42999
- for (const [linkId, failed] of this.groupNativeFailed) {
43981
+ for (const [linkId, failed] of this.nativeFailed) {
43000
43982
  if (now < failed.at) continue;
43001
43983
  if (!this.links.get(linkId)?.link) {
43002
- this.groupNativeFailed.delete(linkId);
43984
+ this.nativeFailed.delete(linkId);
43003
43985
  continue;
43004
43986
  }
43005
43987
  failed.at = now + failed.wait;
43006
43988
  this.ensureNativeEndpoints(linkId);
43007
43989
  }
43008
43990
  if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
43009
- this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
43991
+ this.groupNativeRetryAt = Date.now() + NATIVE_RETRY_MS;
43010
43992
  for (const linkId of [...this.groupNativeWaiting]) {
43011
43993
  if (!this.links.get(linkId)?.link) {
43012
43994
  this.groupNativeWaiting.delete(linkId);
@@ -43111,6 +44093,24 @@ var GhostlyNode = class GhostlyNode {
43111
44093
  } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
43112
44094
  return message;
43113
44095
  }
44096
+ /**
44097
+ * These rows, in this order, placed again below everything in their history, as if they came now: a group member's
44098
+ * messages that came above an older one of theirs (WISP 400, requirement 10), and the lines that came after them. The
44099
+ * places are taken before anything is awaited, so a row that comes meanwhile goes below them. Each still shows the
44100
+ * time it showed. All of them in one write: one disk sync, not one a row.
44101
+ */
44102
+ async placeAgain(linkId, ids) {
44103
+ const places = new Map(ids.map((id) => {
44104
+ const timestamp = arrivalKey(Date.now(), this.placedAt.get(linkId));
44105
+ this.placedAt.set(linkId, timestamp);
44106
+ return [id, timestamp];
44107
+ }));
44108
+ const moved = await db.patchMessages(linkId, [...places.keys()], (m) => ({
44109
+ timestamp: places.get(m.id),
44110
+ sentAt: shownTime(m)
44111
+ }));
44112
+ if (moved.length) await this.messagesChanged(linkId, moved.map((m) => m.id));
44113
+ }
43114
44114
  async storeMessage(message) {
43115
44115
  await this.storeNewMessage(message);
43116
44116
  }
@@ -43230,7 +44230,7 @@ var GhostlyNode = class GhostlyNode {
43230
44230
  transportWait: live.link?.transportWait,
43231
44231
  liveAttempt: live.link?.liveAttempt,
43232
44232
  liveDialer: live.stored.profile && !live.stored.group ? live.link?.dialer : void 0,
43233
- transportRttMs: live.link?.rttMs,
44233
+ transportRttMs: live.rttPosted = live.link?.rttMs,
43234
44234
  transportRelayed: live.link?.relayedPath,
43235
44235
  transportLive: live.transportLog?.liveNow(),
43236
44236
  transportLog: stored.id === this.activeLinkId && stored.profile && !stored.group ? stored.transportLog ?? [] : void 0,
@@ -43252,7 +44252,10 @@ var GhostlyNode = class GhostlyNode {
43252
44252
  lastMessageAt: live.lastMessageAt,
43253
44253
  peerAck: live.peerAck,
43254
44254
  lastSyncAt: live.lastSyncAt,
43255
- poll: live.poll
44255
+ poll: live.firstReadDone && !live.lastSyncAt && stored.id !== this.activeLinkId ? {
44256
+ ...live.poll,
44257
+ polling: false
44258
+ } : live.poll
43256
44259
  };
43257
44260
  }
43258
44261
  /** What this chat can do right now: on the open session, or held for an away contact (text, files and requests only). */
@@ -43285,6 +44288,7 @@ var GhostlyNode = class GhostlyNode {
43285
44288
  this.stateTimer = null;
43286
44289
  if (this.gatedOut) return;
43287
44290
  this.events.onState(this.getState());
44291
+ this.dropBriefViews();
43288
44292
  if (this.deviceLinks) this.syncDeviceTokens();
43289
44293
  }, delayMs);
43290
44294
  }
@@ -43296,11 +44300,16 @@ var GhostlyNode = class GhostlyNode {
43296
44300
  }
43297
44301
  if (this.gatedOut) return;
43298
44302
  this.events.onState(this.getState());
44303
+ this.dropBriefViews();
44304
+ }
44305
+ /** A state went with the brief views: they go, so the next states do not carry them (`briefViews`). */
44306
+ dropBriefViews() {
44307
+ for (const [id, view] of this.briefViews) if (this.transfers.get(id) === view) this.transfers.delete(id);
44308
+ this.briefViews.clear();
43299
44309
  }
43300
44310
  };
43301
44311
  //#endregion
43302
44312
  //#region ../browser/src/engine/server.ts
43303
- var server_exports = /* @__PURE__ */ __exportAll({ EngineServer: () => EngineServer });
43304
44313
  var EngineServer = class {
43305
44314
  /** The whole engine: never made on a device that is not the active one (`devices/peer.ts` decides, WISP 06 § The gate). */
43306
44315
  gated = false;
@@ -43372,6 +44381,10 @@ var EngineServer = class {
43372
44381
  get historyMark() {
43373
44382
  return this.changes;
43374
44383
  }
44384
+ /** Whether a chat's or group's history changed after `mark` (`historyMark`); with none, since this engine was made. */
44385
+ historyChanged(id, mark = 0) {
44386
+ return (this.changedAt.get(id) ?? 0) > mark;
44387
+ }
43375
44388
  /**
43376
44389
  * `read`: the chats and groups whose whole history the client read itself, after `mark` (`historyMark`). One that has
43377
44390
  * not changed since is not read and sent again: the client hears only what changes in it from now on.
@@ -43534,6 +44547,6 @@ function signalKind(signal) {
43534
44547
  }
43535
44548
  }
43536
44549
  //#endregion
43537
- export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
44550
+ export { EngineServer };
43538
44551
 
43539
- //# sourceMappingURL=server-DHMXMwNn.js.map
44552
+ //# sourceMappingURL=server-CBCEYKMN.js.map