@ghostlytools/cli 1.1.4 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +7 -2
  2. package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
  3. package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
  4. package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
  5. package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
  6. package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
  7. package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
  8. package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
  9. package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
  10. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
  11. package/dist/assets/{hyperdhtRelay-BoxPHp7v.js.map → hyperdhtRelay-w9cwVqGZ.js.map} +1 -1
  12. package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
  13. package/dist/assets/idb-BIdus0WX.js.map +1 -0
  14. package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
  15. package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
  16. package/dist/assets/{mainline-dkA94eh2.js → mainline-BabMhm8L.js} +2 -2
  17. package/dist/assets/{mainline-dkA94eh2.js.map → mainline-BabMhm8L.js.map} +1 -1
  18. package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
  19. package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
  20. package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
  21. package/dist/assets/{server-CXFTDnFI.js → server-TamKsfmC.js} +1400 -235
  22. package/dist/assets/server-TamKsfmC.js.map +1 -0
  23. package/dist/assets/{src-K3NijMq-.js → src-C6qQYrAH.js} +1721 -106
  24. package/dist/assets/src-C6qQYrAH.js.map +1 -0
  25. package/dist/ghostly.mjs +882 -21
  26. package/dist/ghostly.mjs.map +1 -1
  27. package/package.json +1 -1
  28. package/dist/assets/idb-Co2lObpU.js.map +0 -1
  29. package/dist/assets/server-CXFTDnFI.js.map +0 -1
  30. package/dist/assets/src-K3NijMq-.js.map +0 -1
@@ -1,7 +1,9 @@
1
- import { a as fileBytesOf, c as removeFileBytes, d as __exportAll, i as fileBytes, n as blobDigest } from "./fileBytes-DxiJHmTk.js";
2
- import { $a as HANDOFF_VERIFIED, $i as replySnippet, $n as freshHubs, $o as OwnInviteError, $s as fromBase64Url, $t as isPubkyKey, A as GROUP_EDIT_TEXT_BYTES, Ai as parseVoiceMeta, An as findEndpoint, Ar as newWakeToken, As as DEFAULT_RELAYS, At as encodeDidDhtPacket, Ba as HTTP_SERVICE_PROTO, Bi as COMMUNITY_REACTION_FRAME, Bo as enrollCancelFrame, Br as HOLD_DATED_BACK_MS, Bt as USDT_PUBLIC_RPC, C as groupWakes, Ca as GhostlyHttpError, Ci as safeBlobType, Co as readHandoffHave, Cr as nextPinNumber, Cs as firstDeviceSetSecret, D as GroupSession, Da as LIMITS, Dn as ENDPOINT, Do as readHandoffVerified, Dr as WAKE_CALL_INTERVAL_MS, Ds as signTurnRelease, Dt as didDhtDocument, E as GROUP_READ_NOTE, Ea as webLocalFetch, En as decodeBolt11, Eo as readHandoffTurnFrame, Er as readPin, Es as signTurnPacket, Fi as RateWindow, Fn as INVITE_TAKEN, Fo as verifySetUpdate, Fr as vapidKeysMatch, Fs as traceLink, Ft as ONCHAIN_PROVIDER, Ga as HANDOFF_DONE, Gi as queueReaction, Gn as MESH_HUBS, Go as enrollHelloFrame, Gr as readManifest, Gs as createIdentity, Gt as validatePaymentTarget, Ha as serviceIdFromName, Hi as REACTION_LIMITS, Ho as enrollDigits, Hr as HoldKeys, Ht as assertTokenUnits, Ii as dhtEditId, In as LIVENESS_PING_MS, Is as budgetRetryMs, It as PaymentPreflightError, Ja as HANDOFF_OFFER, Ji as readReaction, Jn as pickMeshHubs, Jo as readEnrollHello, Jr as capsDescriptors, Js as publicKeyFromZ32, Jt as SPARK_PROVIDER, Ka as HANDOFF_HAVE, Ki as reactionEmoji, Kn as mayBeHub, Ko as enrollProofFrame, Kr as CapsExchange, Ks as identityFromSeed, Kt as walletNetworkOf, La as parseCallSignal, Li as parseLinkPreview, Ln as pairedMessageFrame, Lr as EDIT_CAPABILITY, Ls as isDiscoveryBudgetError, Lt as SEPOLIA_TEST_USDT, M as parseCommunityEdit, Mi as parseVideoMeta, Mn as parseCashuRequestPayload, Mo as setAckFrame, Mr as relayRequest, Ms as currentRelays, Mt as signDidDhtPacket, N as mentionsMember, Ni as EDIT_BUFFER_MS, Nn as parseFedimintRequestPayload, No as setUpdateFrame, Nr as wakeRequest, Ns as normalizeRelayUrl, Nt as ETHEREUM_USDT, Oa as decodeControl, On as cashuRequestPayload, Oo as sealHandoffPiece, Or as WakeLimiter, Os as turnKeys, Ot as didDhtFromPublicKey, P as groupName, Pi as EDIT_RATE_WINDOW_MS, Pn as GhostLink, Po as signTombstone, Pr as checkPushEndpoint, Pt as EVM_TEST_CHAINS, Qa as HANDOFF_TIMINGS, Qi as readReply, Qn as doorHubs, Qo as readDeviceInvite, Qr as IDENTITY_REVOCATION_LABEL, Qs as bytesEqual, Ri as forwardedAgain, Rn as CarriedTransport, Rr as UPGRADE_CAPABILITY, Rs as withRequestOptions, Rt as SEPOLIA_TEST_USDT_FAUCET, S as groupWakeFrame, Sa as presenceSeenAt, So as readHandoffDone, Sr as mayPin, Ss as TURN_MAX, Ta as parseLocalTarget, Tn as engineText, To as readHandoffPake, Tr as pinNumberHolds, Ts as readTurnPacket, Ua as HANDOFF_ACK, Ui as ReactionWindow, Un as groupSignalFrame, Uo as enrollDoneFrame, Ur as HoldRefusedError, Ut as assertWholeSats, Va as LEGACY_SERVICES, Vi as GROUP_REACTION_FRAME, Vo as enrollCancelReason, Vr as HOLD_LIMITS, Vs as createRelayPayload, Vt as WALLET_NETWORKS, Wa as HANDOFF_CANCEL, Wi as nextReactionNumber, Wn as readGroupSignal, Wo as enrollGrantFrame, Wr as newMailbox, Wt as formatPaymentAmount, Xa as HANDOFF_PIECE_BYTES, Xi as wireReaction, Xn as beaconKeys, Xo as createDeviceInvite, Xt as sparkInvoiceDetails, Ya as HANDOFF_PAKE, Yi as validReactionNumber, Yn as COMMUNITY_TOPOLOGY, Yo as verifyEnrollProof, Yr as dialDescriptors, Ys as verify, Yt as sparkAddressKind, Za as HANDOFF_REQUEST, Zi as pairedReplyAuthor, Zn as beaconRecords, Zo as deviceInviteJoinerParams, Zs as sanitizeAvatar, Zt as isBitcoinAddress, _ as knockRecords, _a as verifyCommitSignature, _i as DHT_MESSAGE_TTL, _o as handoffStreamKey, _r as TypingSender, _s as TURN_RAISE_READ_TIMEOUT_MS, a as sealGroupTyping, aa as checkStatusCard, ai as newIdentityBinding, ao as handoffCancelFrame, ar as pickHubs, as as DEVICES_CAPABILITY, bi as dhtFileId, bo as readHandoffAck, bs as turnPutSummary, c as GROUP_READ_NOTE_COMMUNITY, ca as arrivalKey, ci as lastSharedWithMe, co as handoffDataFrame, cr as readBeaconHead, cs as deviceEchoFrame, d as encodeCommunityLink, da as receivedTimestamp, di as validExternalKey, do as handoffHelloFrame, dr as ChatFiles, ds as deviceLinkPairing, ea as BUTTON_ID, ec as randomBytes, ei as IdentityExchange, eo as filePartName, er as lobbyKeys, es as createChatInvite, et as isAtprotoDid, fa as shownTime, fi as TRANSPORTS, fo as handoffManifestFrames, fr as FILE_LIMITS, fs as devicePingFrame, g as knockIdentity, ga as rosterHas, go as handoffRetryAfter, h as entryParams, ha as MEMBER_KEY, hi as irohRelayUrl, ho as handoffReleaseFrame, hs as webCryptoSigner, i as openGroupTyping, ic as utf8Encode, ii as identityStatement, io as handoffBusyFrame, ir as newerHead, is as inviteOwnership, j as carryMentions, ja as RTC_CONFIG, jn as isFederationId, jo as readSetUpdate, jr as parseWakeFrame, js as RelayTransport, ka as encodeControl, ki as fileMessageText, kn as fedimintRequestPayload, kr as deviceWakeRequest, ks as turnName, kt as encodeDidDhtDeactivated, l as communityHasFrame, la as claimedTime, li as PeerProofs, lo as handoffDigest, lr as readLobby, ls as deviceEchoNonce, m as encodeGroupEntryLink, mi as irohDescriptor, mo as handoffPakeFrame, mr as transferStalled, ms as seedSigner, n as GroupTypingBoard, nc as toZ32, ni as emptyIdentityLedger, no as handoffAttemptFailed, nr as mergeBeacon, ns as encodeInviteCode, o as COMMUNITY_LIMITS, oa as sameButtonText, oi as receivedIdentityStatus, oo as handoffContext, or as rankHubs, os as ENROLL_CAPABILITY, p as decodeGroupEntryLink, pa as GROUP_MEMBER_CAP, pi as automaticTransport, po as handoffOfferFrame, pr as transferEnded, ps as newDeviceSetSecret, qa as HANDOFF_HELLO, qi as reactionIsNewer, qn as meshHubs, qo as readEnrollGrant, qr as DHT_TEXT_CAPABILITY, qs as identityFromSeedB64, qt as SPARK_NETWORKS, r as groupTypingEpoch, ra as STATUS_CARD_LIMITS, rc as utf8Decode, ri as identityRevocationValue, ro as handoffAttemptSucceeded, rr as mergeLobby, s as CommunitySession, sa as statusCardText, si as revokesIdentity, so as handoffContextProof, sr as readBeacon, ss as HANDOFF_CAPABILITY, tc as toBase64Url, to as handoffAttemptAllowed, tr as lobbyRecords, ts as decodeInviteCode, tt as normalizeAtprotoHandle, u as decodeCommunityLink, ua as heardTime, ui as emptyProofLedger, uo as handoffEphemeral, ur as shouldBeHub, us as deviceKeyZ32, v as mergeKnocks, va as edgeParams, vi as peekDhtMailbox, vo as handoffVersions, vs as TURN_SETTLE_MS, w as parseGroupWakeFrame, wa as formatLocalTarget, wn as engineError, wo as readHandoffHello, wr as pinIsNewer, ws as nextTurnPosition, x as GroupWakeLimiter, xa as RELAY_POLL_INTERVALS, xi as parseImageMeta, xo as readHandoffCancel, xr as PIN_LIMITS, xs as TURN_LAST_SEQUENCE, y as readKnocks, ya as DirectPathWatch, yi as DHT_FILE_CAPABILITY, yo as newHandoffId, yr as typingActivity, ys as classifyTurnRead, za as emptyLinkRecords, zi as readForwarded, zr as HOLD_CAPABILITY, zs as PacketTooLargeError, zt as TEST_USDT_FAUCET_AMOUNT } from "./src-K3NijMq-.js";
3
- import { a as deleteDatabase, c as profileOpenFailure, d as wrap, i as databaseName, l as store, n as STORES$1, o as fileStore, r as clearProfileStores, s as openDb, t as CARD_INDEX, u as transact } from "./idb-Co2lObpU.js";
4
- import { $ as onAdaptersChanged, A as intentRepository, C as ProviderSources, Ct as FileAppender, D as requireTurn, Dt as streamStored, E as refuseAway, Et as storedSize, F as ModeGate, G as fakesLightning, H as BITCOIN_INVOICE_ON_TESTNET, I as WrongNetworkError, J as mintNetwork, K as isTestMint, L as networkLabel, M as sealSeed, N as unsealSeed, O as setAwayWallets, P as ModeChanged, Q as identityProviders, R as CASHU_MINT_SOURCE, S as lightningPaying, St as paymentWireId, T as awayFrom, Tt as removeStored, U as DEFAULT_MINTS, V as WALLET_TYPES, W as TEST_MINT, X as BACKUP_MEDIA_TYPE, Y as paysItsOwnInvoices, Z as MAX_BACKUP_BYTES, _ as normalizePhrase, _t as pairedWireReply, a as createFailure, at as SourceConfigError, b as breezDatabasesFor, bt as replyTo, c as paymentNetwork, ct as networkMode, d as networkReason, dt as resolveAtprotoDid, et as registeredLightningProviders, f as rpcHost, ft as setOwnDidSource, g as isRecoveryPhrase, gt as groupReply, h as cardsKey, ht as chosenResolver, i as assertConfirmedReal, it as PROVIDER_PLATFORMS, j as newDeviceKey, k as setSingleWriterGate, l as paymentNetworksOf, lt as redact, m as LightningCards, mt as verifyIdentity, n as loadFedimintSdk, nt as reserveAdapterIds, o as createTiming, ot as SourceUnreachableError, p as CASHU_CARD, pt as boundedIdentityFetch, q as isWorthlessMint, r as WALLET_NAMES, rt as NothingSpentError, s as crossNetwork, st as isNothingSpentError, t as fedimintDatabase, tt as registeredOnchainProviders, u as walletInstances, ut as assertPublicServer, v as breezDatabase, vt as receivedPairedReply, w as sourceKey, wt as readStored$1, x as dropBreezDatabase, xt as paymentAlias, y as breezDatabaseInUse, yt as replyRef, z as cashuMint } from "../ghostly.mjs";
1
+ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
+ import { $a as parseLocalTarget, $i as parseLinkPreview, $o as readHandoffPake, $r as EDIT_CAPABILITY, $s as readTurnPacket, $t as TEST_USDT_FAUCET_AMOUNT, A as knockIdentity, Ac as toZ32, Ao as handoffAttemptFailed, As as encodeInviteCode, B as GROUP_READ_NOTE, Bn as decodeBolt11, Bo as handoffManifestFrames, Br as readPin, Bs as devicePingFrame, C as communityHasFrame, Ca as statusCardText, Cc as verify, Ci as revokesIdentity, Co as HANDOFF_PAKE, Cr as readLobby, Cs as verifyEnrollProof, D as decodeGroupEntryLink, Da as receivedTimestamp, Dc as fromBase64Url, Di as validExternalKey, Do as HANDOFF_VERIFIED, Dr as transferEnded, Ds as OwnInviteError, Ea as heardTime, Ec as bytesEqual, Ei as emptyProofLedger, Eo as HANDOFF_TIMINGS, Er as FILE_LIMITS, Es as readDeviceInvite, F as GroupWakeLimiter, Fa as isAppRef, Fi as DHT_FILE_CAPABILITY, Fo as handoffContextProof, Fr as PIN_LIMITS, Fs as HANDOFF_CAPABILITY, G as carryMentions, Ga as verifyCommitSignature, Gi as fileMessageText, Gn as isFederationId, Go as handoffStreamKey, Gr as parseWakeFrame, Gs as TURN_RAISE_READ_TIMEOUT_MS, Hn as cashuRequestPayload, Ho as handoffPakeFrame, Hr as WakeLimiter, Hs as seedSigner, Ht as didDhtFromPublicKey, I as groupWakeFrame, Ia as readAppRevocations, Ii as dhtFileId, Io as handoffDataFrame, Ir as mayPin, Is as deviceEchoFrame, J as groupName, Ji as parseVideoMeta, Jn as GhostLink, Jo as readHandoffAck, Jr as checkPushEndpoint, Js as turnPutSummary, Jt as EVM_TEST_CHAINS, K as parseCommunityEdit, Ka as edgeParams, Ki as parseVoiceMeta, Kn as parseCashuRequestPayload, Ko as handoffVersions, Kr as relayRequest, Ks as TURN_SETTLE_MS, Kt as signDidDhtPacket, L as groupWakes, Li as parseImageMeta, Lo as handoffDigest, Lr as nextPinNumber, Ls as deviceEchoNonce, M as mergeKnocks, Ma as appRef, Mc as utf8Encode, Mo as handoffBusyFrame, Ms as inviteOwnership, N as readKnocks, Ni as DHT_MESSAGE_TTL, No as handoffCancelFrame, Nr as typingActivity, Ns as DEVICES_CAPABILITY, O as encodeGroupEntryLink, Oa as shownTime, Oc as randomBytes, Oi as TRANSPORTS, Oo as filePartName, Or as transferStalled, Os as createChatInvite, Pa as isAppKey, Pi as peekDhtMailbox, Po as handoffContext, Ps as ENROLL_CAPABILITY, Qa as formatLocalTarget, Qi as dhtEditId, Qn as CarriedTransport, Qo as readHandoffHello, Qs as nextTurnPosition, Qt as SEPOLIA_TEST_USDT_FAUCET, R as parseGroupWakeFrame, Rn as engineError, Ro as handoffEphemeral, Rr as pinIsNewer, Rs as deviceKeyZ32, S as GROUP_READ_NOTE_COMMUNITY, Sa as sameButtonText, Sc as publicKeyFromZ32, Si as receivedIdentityStatus, So as HANDOFF_OFFER, Sr as readBeaconHead, Ss as readEnrollHello, T as encodeCommunityLink, Ta as claimedTime, Tc as sanitizeAvatar, Ti as PeerProofs, To as HANDOFF_REQUEST, Tr as ChatFiles, Ts as deviceInviteJoinerParams, Ua as MEMBER_KEY, Un as fedimintRequestPayload, Uo as handoffReleaseFrame, Ur as deviceWakeRequest, Us as webCryptoSigner, Ut as encodeDidDhtDeactivated, V as GroupSession, Va as GROUP_MEMBER_CAP, Vn as ENDPOINT, Vo as handoffOfferFrame, Vr as WAKE_CALL_INTERVAL_MS, Vs as newDeviceSetSecret, Vt as didDhtDocument, W as GROUP_EDIT_TEXT_BYTES, Wa as rosterHas, Wn as findEndpoint, Wo as handoffRetryAfter, Wr as newWakeToken, Wt as encodeDidDhtPacket, Xa as presenceSeenAt, Xi as EDIT_RATE_WINDOW_MS, Xn as LIVENESS_PING_MS, Xo as readHandoffDone, Xr as chatAppId, Xs as TURN_MAX, Xt as PaymentPreflightError, Ya as RELAY_POLL_INTERVALS, Yi as EDIT_BUFFER_MS, Yn as INVITE_TAKEN, Yo as readHandoffCancel, Yr as vapidKeysMatch, Ys as TURN_LAST_SEQUENCE, Yt as ONCHAIN_PROVIDER, Za as GhostlyHttpError, Zi as RateWindow, Zn as pairedMessageFrame, Zo as readHandoffHave, Zr as isAppVersion, Zs as firstDeviceSetSecret, Zt as SEPOLIA_TEST_USDT, _ as groupTypingEpoch, _o as HANDOFF_ACK, _r as mergeLobby, _s as enrollDoneFrame, aa as ReactionWindow, ac as RelayTransport, ai as HoldRefusedError, an as validatePaymentTarget, ao as RTC_CONFIG, ar as MESH_HUBS, as as readSetUpdate, b as COMMUNITY_LIMITS, bc as identityFromSeed, bi as identityStatement, bo as HANDOFF_HAVE, br as rankHubs, bs as enrollProofFrame, c as APP_BUNDLE_LIMITS, ca as reactionEmoji, ci as CapsExchange, cn as SPARK_PROVIDER, cr as pickMeshHubs, cs as signTombstone, da as validReactionNumber, dc as isDiscoveryBudgetError, di as dialDescriptors, dn as isBitcoinAddress, do as parseCallSignal, dr as beaconRecords, ea as forwardedAgain, ec as signTurnPacket, ei as UPGRADE_CAPABILITY, en as USDT_PUBLIC_RPC, eo as webLocalFetch, es as readHandoffTurnFrame, fa as wireReaction, fc as withRequestOptions, fr as doorHubs, g as GroupTypingBoard, ga as BUTTON_ID, gi as IdentityExchange, go as serviceIdFromName, gr as mergeBeacon, gs as enrollDigits, ha as replySnippet, hc as createRelayPayload, ho as LEGACY_SERVICES, hr as lobbyRecords, hs as enrollCancelReason, ht as normalizeAtprotoHandle, i as planAppUpdate, ia as REACTION_LIMITS, ic as DEFAULT_RELAYS, ii as HoldKeys, in as formatPaymentAmount, ir as readGroupSignal, j as knockRecords, ja as appFingerprint, jc as utf8Decode, jo as handoffAttemptSucceeded, jr as TypingSender, k as entryParams, kc as toBase64Url, ki as automaticTransport, ko as handoffAttemptAllowed, ks as decodeInviteCode, l as APP_BUNDLE_MAGIC, la as reactionIsNewer, lc as traceLink, li as DHT_TEXT_CAPABILITY, ln as sparkAddressKind, lr as COMMUNITY_TOPOLOGY, ls as verifySetUpdate, m as readAppBundle, ma as readReply, mi as IDENTITY_REVOCATION_LABEL, mo as HTTP_SERVICE_PROTO, mr as lobbyKeys, ms as enrollCancelFrame, mt as isAtprotoDid, n as appUpdateDecision, na as COMMUNITY_REACTION_FRAME, nc as turnKeys, ni as HOLD_DATED_BACK_MS, nn as assertTokenUnits, no as decodeControl, ns as sealHandoffPiece, o as readAppStore, oa as nextReactionNumber, oc as currentRelays, oi as newMailbox, on as walletNetworkOf, or as mayBeHub, os as setAckFrame, p as isAppUrl, pa as pairedReplyAuthor, pc as PacketTooLargeError, pn as isPubkyKey, po as emptyLinkRecords, pr as freshHubs, q as mentionsMember, qa as DirectPathWatch, qn as parseFedimintRequestPayload, qo as newHandoffId, qr as wakeRequest, qs as classifyTurnRead, qt as ETHEREUM_USDT, r as checkAppBeforeRun, ra as GROUP_REACTION_FRAME, rc as turnName, ri as HOLD_LIMITS, rn as assertWholeSats, ro as encodeControl, rr as groupSignalFrame, sa as queueReaction, sc as normalizeRelayUrl, si as readManifest, sn as SPARK_NETWORKS, sr as meshHubs, ss as setUpdateFrame, t as appStoreDecision, ta as readForwarded, tc as signTurnRelease, ti as HOLD_CAPABILITY, tn as WALLET_NETWORKS, to as LIMITS, ts as readHandoffVerified, u as appDigest, ua as readReaction, uc as budgetRetryMs, ui as capsDescriptors, un as sparkInvoiceDetails, ur as beaconKeys, v as openGroupTyping, vi as emptyIdentityLedger, vo as HANDOFF_CANCEL, vr as newerHead, vs as enrollGrantFrame, w as decodeCommunityLink, wa as arrivalKey, wi as lastSharedWithMe, wo as HANDOFF_PIECE_BYTES, wr as shouldBeHub, ws as createDeviceInvite, x as CommunitySession, xa as checkStatusCard, xc as identityFromSeedB64, xi as newIdentityBinding, xo as HANDOFF_HELLO, xr as readBeacon, xs as readEnrollGrant, y as sealGroupTyping, ya as STATUS_CARD_LIMITS, yc as createIdentity, yi as identityRevocationValue, yo as HANDOFF_DONE, yr as pickHubs, ys as enrollHelloFrame, za as canonicalJson, zi as safeBlobType, zn as engineText, zo as handoffHelloFrame, zr as pinNumberHolds, zs as deviceLinkPairing } from "./src-C6qQYrAH.js";
3
+ import { a as clearProfileStores, c as fileStore, d as store, f as transact, i as STORES$1, l as openDb, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as wrap, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as profileOpenFailure } from "./idb-BIdus0WX.js";
4
+ import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-3AQ2tJzW.js";
5
+ import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as streamStored, B as networkLabel, C as breezDatabasesFor, Ct as replyTo, D as sourceKey, Dt as readStored$1, E as ProviderSources, Et as FileAppender, F as sealSeed, 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, N as intentRepository, O as awayFrom, Ot as removeStored, P as newDeviceKey, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as replyRef, T as lightningPaying, Tt as paymentWireId, 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 pairedWireReply, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, h as rpcHost, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, k as refuseAway, kt as storedSize, l as createTiming, lt as SourceUnreachableError, m as networkReason, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as paymentAlias, x as breezDatabase, xt as receivedPairedReply, y as isRecoveryPhrase, yt as groupReply, z as WrongNetworkError } from "../ghostly.mjs";
6
+ import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
5
7
  import { hkdf } from "@noble/hashes/hkdf.js";
6
8
  import { sha256 } from "@noble/hashes/sha2.js";
7
9
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
@@ -6175,7 +6177,7 @@ var COMMANDO_NOT_RUN = {
6175
6177
  };
6176
6178
  var MAX_REPLY = 4194304;
6177
6179
  /** One websocket frame may carry many Lightning messages; one this large is not an answer to anything. */
6178
- var MAX_FRAME$1 = 4259840;
6180
+ var MAX_FRAME = 4259840;
6179
6181
  var text$1 = new TextDecoder();
6180
6182
  /**
6181
6183
  * One connection to a node, opened on first use and again after it drops. Calls run concurrently, matched
@@ -6279,7 +6281,7 @@ var CommandoClient = class {
6279
6281
  ws.onmessage = ({ data }) => {
6280
6282
  if (!(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) return fail("The node sent something that is not binary");
6281
6283
  const chunk = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
6282
- if (chunk.length > MAX_FRAME$1) return fail("The node sent more than it was asked for");
6284
+ if (chunk.length > MAX_FRAME) return fail("The node sent more than it was asked for");
6283
6285
  buffer = concatBytes(buffer, chunk);
6284
6286
  try {
6285
6287
  if (stage === "act2") {
@@ -11703,6 +11705,1159 @@ var eachNetwork = (value) => "mainnet" in value && "testnet" in value ? value :
11703
11705
  testnet: value
11704
11706
  };
11705
11707
  //#endregion
11708
+ //#region ../browser/src/engine/appFetch.ts
11709
+ /** The hosts phase 1 reads from. A jsDelivr URL must also name a 40-character commit (`isAppUrl`). */
11710
+ var APP_FETCH_HOSTS = ["raw.githubusercontent.com", "cdn.jsdelivr.net"];
11711
+ var APP_FETCH_LIMITS = {
11712
+ /**
11713
+ * A store index as read (WISP 1200 § Stores, Privacy): 4 MiB in phase 1, under the format's 16 MiB. Counted on the body
11714
+ * as it arrives, after the browser undid any compression, since a page never sees the compressed size.
11715
+ */
11716
+ storeIndexBytes: 4194304,
11717
+ /** A store's signature statement, `ghostly-store.sig` (canonical, at most 1 KiB). */
11718
+ sigBytes: 1024,
11719
+ /** `ghostly-revoke.json`: at most 4096 signed revocations. */
11720
+ revocationsBytes: 16777216,
11721
+ timeoutMs: 3e4
11722
+ };
11723
+ /** A URL this client may read an app, a store or a revocation list from. */
11724
+ function isAppFetchUrl(value) {
11725
+ if (!isAppUrl(value)) return false;
11726
+ const url = new URL(value);
11727
+ return url.port === "" && APP_FETCH_HOSTS.includes(url.hostname.toLowerCase());
11728
+ }
11729
+ var AppFetchError = class extends Error {
11730
+ code;
11731
+ status;
11732
+ constructor(code, message, status) {
11733
+ super(`${code}: ${message}`);
11734
+ this.code = code;
11735
+ this.status = status;
11736
+ this.name = "AppFetchError";
11737
+ }
11738
+ };
11739
+ function boundedAppFetch(options = {}) {
11740
+ return async (url, { maxBytes, peek = false }) => {
11741
+ if (!isAppFetchUrl(url)) throw new AppFetchError("host", "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
11742
+ if (options.online && !options.online()) throw new AppFetchError("offline", "Offline");
11743
+ const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
11744
+ let response;
11745
+ try {
11746
+ response = await fetcher(url, {
11747
+ signal: AbortSignal.timeout(options.timeoutMs ?? APP_FETCH_LIMITS.timeoutMs),
11748
+ credentials: "omit",
11749
+ referrerPolicy: "no-referrer",
11750
+ cache: "no-store",
11751
+ redirect: "error",
11752
+ ...peek && { headers: { range: `bytes=0-${maxBytes - 1}` } }
11753
+ });
11754
+ } catch (error) {
11755
+ throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
11756
+ }
11757
+ if (response.url && response.url !== url && !isAppFetchUrl(response.url)) throw new AppFetchError("host", "The host sent the request elsewhere");
11758
+ if (!response.ok) {
11759
+ await response.body?.cancel().catch(() => {});
11760
+ throw new AppFetchError("status", `The host answered ${response.status}`, response.status);
11761
+ }
11762
+ if (!peek && Number(response.headers.get("content-length")) > maxBytes) {
11763
+ await response.body?.cancel().catch(() => {});
11764
+ throw new AppFetchError("too-large", "Too large");
11765
+ }
11766
+ const parts = [];
11767
+ let length = 0;
11768
+ const reader = response.body?.getReader();
11769
+ if (reader) try {
11770
+ for (;;) {
11771
+ const { done, value } = await reader.read();
11772
+ if (done) break;
11773
+ if (length + value.length > maxBytes) {
11774
+ if (!peek) throw new AppFetchError("too-large", "Too large");
11775
+ parts.push(value.subarray(0, maxBytes - length));
11776
+ length = maxBytes;
11777
+ break;
11778
+ }
11779
+ parts.push(value);
11780
+ length += value.length;
11781
+ }
11782
+ } catch (error) {
11783
+ if (error instanceof AppFetchError) throw error;
11784
+ throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
11785
+ } finally {
11786
+ await reader.cancel().catch(() => {});
11787
+ }
11788
+ const bytes = new Uint8Array(length);
11789
+ let at = 0;
11790
+ for (const part of parts) {
11791
+ bytes.set(part, at);
11792
+ at += part.length;
11793
+ }
11794
+ return bytes;
11795
+ };
11796
+ }
11797
+ var NAME = /^[A-Za-z0-9_.-]+$/;
11798
+ /**
11799
+ * The URL a pasted address reads `file` from (WISP 1200 § Paste a URL), or null when it is not one this client reads:
11800
+ * - `https://github.com/<owner>/<repo>`, or `/tree/<ref>`: `raw.githubusercontent.com/<owner>/<repo>/<ref or HEAD>/<file>`,
11801
+ * with no request to `github.com` or `api.github.com`;
11802
+ * - a file as GitHub shows it, `https://github.com/<owner>/<repo>/blob/<ref>/<path>` (or `/raw/`), naming a `.ghostlyapp`
11803
+ * or `.json` file: `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`, again with no request to `github.com`;
11804
+ * - a URL of the list that names a file (`….ghostlyapp`, `….json`): itself;
11805
+ * - a URL of the list that names a folder: `<file>` in it.
11806
+ */
11807
+ function appPasteUrl(input, file) {
11808
+ let url;
11809
+ try {
11810
+ url = new URL(input.trim());
11811
+ } catch {
11812
+ return null;
11813
+ }
11814
+ if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
11815
+ const host = url.hostname.toLowerCase();
11816
+ if (host === "github.com" || host === "www.github.com") {
11817
+ const parts = url.pathname.split("/").filter(Boolean);
11818
+ if (parts.length < 2) return null;
11819
+ const owner = parts[0], repo = parts[1].replace(/\.git$/, "");
11820
+ if (!NAME.test(owner) || !NAME.test(repo)) return null;
11821
+ let ref = "HEAD";
11822
+ if (parts.length > 2) {
11823
+ const rest = parts.slice(3);
11824
+ if (!rest.length || !rest.every((p) => NAME.test(p) && p !== "." && p !== "..")) return null;
11825
+ if (parts[2] === "blob" || parts[2] === "raw") {
11826
+ if (rest.length < 2 || !/\.(ghostlyapp|json)$/.test(rest[rest.length - 1])) return null;
11827
+ const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${rest.join("/")}`;
11828
+ return isAppFetchUrl(raw) ? raw : null;
11829
+ }
11830
+ if (parts[2] !== "tree") return null;
11831
+ ref = rest.join("/");
11832
+ }
11833
+ const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${file}`;
11834
+ return isAppFetchUrl(raw) ? raw : null;
11835
+ }
11836
+ const last = url.pathname.split("/").pop() ?? "";
11837
+ const named = /\.(ghostlyapp|json)$/.test(last) ? url.href : new URL(`${url.pathname.replace(/\/?$/, "/")}${file}`, url).href;
11838
+ return isAppFetchUrl(named) ? named : null;
11839
+ }
11840
+ /** A page of github.com (any scheme): a link `appPasteUrl` could not read is still GitHub, and is answered as such. */
11841
+ function isGitHubPage(input) {
11842
+ try {
11843
+ const host = new URL(input.trim()).hostname.toLowerCase();
11844
+ return host === "github.com" || host === "www.github.com";
11845
+ } catch {
11846
+ return false;
11847
+ }
11848
+ }
11849
+ /** A file beside another (`ghostly-store.sig` beside the index, `ghostly-revoke.json` beside a bundle). */
11850
+ function besideUrl(url, name) {
11851
+ const target = new URL(url);
11852
+ target.search = "";
11853
+ target.hash = "";
11854
+ return new URL(name, target).href;
11855
+ }
11856
+ /**
11857
+ * The stores a new profile starts with (WISP 1200 § Stores: the default store, preloaded and removable), each a URL and
11858
+ * the store key it is pinned to. Preloading one makes no request; the first read comes when the person opens the Apps
11859
+ * page or once an app is installed.
11860
+ */
11861
+ var DEFAULT_APP_STORES = [{
11862
+ url: "https://raw.githubusercontent.com/MiguelMedeiros/ghostly-store/HEAD/ghostly-store.json",
11863
+ key: "y379ia3t1urwudj8o4w1qwmuwp1mcxyf956b5r7pqup7stce6diy"
11864
+ }];
11865
+ var APP_STORAGE_LIMITS = {
11866
+ /** A key, in UTF-8 bytes. */
11867
+ keyBytes: 256,
11868
+ /** A value, as compact JSON in UTF-8 bytes. */
11869
+ valueBytes: 65536,
11870
+ /** Every key and value of one app in one scope. */
11871
+ scopeBytes: 5242880
11872
+ };
11873
+ var APP_CHECK_TIMINGS = {
11874
+ /** After the engine starts, so the chats come up first. */
11875
+ afterStartMs: 6e4,
11876
+ everyMs: 864e5,
11877
+ /** A check at start is skipped when every app was checked this recently (a page reloaded several times). */
11878
+ freshMs: 36e5
11879
+ };
11880
+ /** A fetched bundle kept for its install screen: at most this many, for this long. */
11881
+ var STAGED = {
11882
+ max: 4,
11883
+ ms: 6e5
11884
+ };
11885
+ /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11886
+ var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11887
+ var bundleId = (digest) => `app-${digest}`;
11888
+ /** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
11889
+ function fail(code, words) {
11890
+ throw new Error(`${code}: ${words}`);
11891
+ }
11892
+ /**
11893
+ * Why a pasted link is not read: a github.com page that is neither a repository nor a file of one (`github-link`), so
11894
+ * the person is not told GitHub is off the list; any other host (`host`).
11895
+ */
11896
+ function notReadable(url, words) {
11897
+ if (typeof url === "string" && isGitHubPage(url)) fail("github-link", "Paste the repository's link, or the link to its app file");
11898
+ fail("host", words);
11899
+ }
11900
+ var versionOf = (manifest, digest) => ({
11901
+ ref: appRef(manifest.publisher, manifest.name),
11902
+ sequence: manifest.sequence,
11903
+ digest
11904
+ });
11905
+ var byteLength = (text) => utf8Encode(text).length;
11906
+ /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
11907
+ var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
11908
+ function done(tx) {
11909
+ return new Promise((resolve, reject) => {
11910
+ tx.oncomplete = () => resolve();
11911
+ tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("App storage failed"));
11912
+ });
11913
+ }
11914
+ /**
11915
+ * The manifest of a bundle's first bytes, unchecked: only to see whether a source holds something newer before reading
11916
+ * all of it. Whatever it says, the whole bundle is checked before anything is kept.
11917
+ */
11918
+ function peekAppManifest(bytes) {
11919
+ const magic = utf8Encode(APP_BUNDLE_MAGIC);
11920
+ if (bytes.length < magic.length + 4 || !magic.every((b, i) => bytes[i] === b)) return null;
11921
+ const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(magic.length);
11922
+ if (length > APP_BUNDLE_LIMITS.manifestBytes || bytes.length < magic.length + 4 + length) return null;
11923
+ const manifestBytes = bytes.subarray(magic.length + 4, magic.length + 4 + length);
11924
+ try {
11925
+ const manifest = JSON.parse(utf8Decode(manifestBytes));
11926
+ const ref = typeof manifest.publisher === "string" && typeof manifest.name === "string" ? appRef(manifest.publisher, manifest.name) : "";
11927
+ if (!isAppRef(ref) || typeof manifest.sequence !== "number" || !Number.isSafeInteger(manifest.sequence)) return null;
11928
+ return {
11929
+ ref,
11930
+ sequence: manifest.sequence,
11931
+ digest: appDigest(manifestBytes)
11932
+ };
11933
+ } catch {
11934
+ return null;
11935
+ }
11936
+ }
11937
+ var Apps = class {
11938
+ host;
11939
+ staged = /* @__PURE__ */ new Map();
11940
+ /** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
11941
+ verified = /* @__PURE__ */ new Map();
11942
+ /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
11943
+ usage = /* @__PURE__ */ new Map();
11944
+ queues = /* @__PURE__ */ new Map();
11945
+ timer = null;
11946
+ checking = null;
11947
+ /** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
11948
+ anyway = /* @__PURE__ */ new Set();
11949
+ stopped = false;
11950
+ constructor(host) {
11951
+ this.host = host;
11952
+ }
11953
+ now() {
11954
+ return (this.host.now ?? Date.now)();
11955
+ }
11956
+ nowS() {
11957
+ return Math.floor(this.now() / 1e3);
11958
+ }
11959
+ /** Preloads the default stores (no request), then checks for updates soon after start and every 24 hours. */
11960
+ async start() {
11961
+ this.stopped = false;
11962
+ await this.seedDefaults();
11963
+ this.schedule(APP_CHECK_TIMINGS.afterStartMs, true);
11964
+ }
11965
+ stop() {
11966
+ this.stopped = true;
11967
+ if (this.timer) clearTimeout(this.timer);
11968
+ this.timer = null;
11969
+ }
11970
+ schedule(ms, atStart = false) {
11971
+ if (this.timer) clearTimeout(this.timer);
11972
+ this.timer = setTimeout(() => {
11973
+ this.timer = null;
11974
+ (async () => {
11975
+ if (this.stopped) return;
11976
+ if (this.host.online()) {
11977
+ const apps = await this.installed();
11978
+ const fresh = atStart && apps.length > 0 && apps.every((a) => a.checkedAt !== void 0 && this.now() - a.checkedAt < APP_CHECK_TIMINGS.freshMs);
11979
+ if (apps.length && !fresh) await this.checkUpdates().catch(() => {});
11980
+ }
11981
+ if (!this.stopped) this.schedule(APP_CHECK_TIMINGS.everyMs);
11982
+ })();
11983
+ }, ms);
11984
+ }
11985
+ async seedDefaults() {
11986
+ for (const d of this.host.defaults ?? DEFAULT_APP_STORES) {
11987
+ if (!isAppKey(d.key) || !isAppFetchUrl(d.url)) continue;
11988
+ if (await this.storeRecord(d.key)) continue;
11989
+ await this.putStore({
11990
+ key: d.key,
11991
+ url: d.url,
11992
+ addedAt: this.now(),
11993
+ preloaded: true
11994
+ });
11995
+ }
11996
+ }
11997
+ async installed() {
11998
+ return wrap((await store(STORES$1.apps, "readonly")).getAll());
11999
+ }
12000
+ async app(ref) {
12001
+ return wrap((await store(STORES$1.apps, "readonly")).get(ref));
12002
+ }
12003
+ async installedOrFail(ref) {
12004
+ if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
12005
+ return await this.app(ref) ?? fail("not-installed", "This app is not installed");
12006
+ }
12007
+ /**
12008
+ * An installed app that may still run (WISP 1200 § Takedowns): a running app whose version its publisher revoked, or
12009
+ * a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
12010
+ * or not the page stops its frame.
12011
+ */
12012
+ async stillRunnable(app) {
12013
+ const run = this.runStatus(app, await this.stores(), app.revocations);
12014
+ if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
12015
+ if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12016
+ return app;
12017
+ }
12018
+ async runningOrFail(ref) {
12019
+ return this.stillRunnable(await this.installedOrFail(ref));
12020
+ }
12021
+ /**
12022
+ * Before an app's frames go to a contact (`appOpen`, `appSend`): refused with `stopped` as its storage is. A reference
12023
+ * not installed here (a bot that speaks an app's messages) is not this store's to refuse.
12024
+ */
12025
+ async chatRunnable({ ref }) {
12026
+ const app = isAppRef(ref) ? await this.app(ref) : void 0;
12027
+ if (app) await this.stillRunnable(app);
12028
+ }
12029
+ async putApp(app) {
12030
+ await wrap((await store(STORES$1.apps, "readwrite")).put(app));
12031
+ }
12032
+ async storeRecord(key) {
12033
+ return wrap((await store(STORES$1.appStores, "readonly")).get(key));
12034
+ }
12035
+ async putStore(record) {
12036
+ await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
12037
+ }
12038
+ /** The stores the person has (a removed default is not one). */
12039
+ async stores() {
12040
+ return (await wrap((await store(STORES$1.appStores, "readonly")).getAll())).filter((s) => !s.removed);
12041
+ }
12042
+ storeViews(stores) {
12043
+ return stores.filter((s) => s.index).map((s) => ({
12044
+ key: s.key,
12045
+ name: s.index.name,
12046
+ kind: s.index.kind,
12047
+ removed: s.index.removed,
12048
+ revoked: s.index.revoked
12049
+ }));
12050
+ }
12051
+ listedBy(ref, stores) {
12052
+ return stores.filter((s) => s.index?.apps.some((a) => a.ref === ref)).map((s) => ({
12053
+ key: s.key,
12054
+ name: s.index.name,
12055
+ kind: s.index.kind
12056
+ }));
12057
+ }
12058
+ runStatus(version, stores, revocations = []) {
12059
+ const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
12060
+ if (check.status === "revoked") return {
12061
+ status: "revoked",
12062
+ ...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
12063
+ };
12064
+ return check;
12065
+ }
12066
+ /**
12067
+ * Stores a bundle's bytes. A browser that keeps no files fails here: Safari's Private Browsing (and WebKit's in-memory
12068
+ * contexts) refuses a Blob in IndexedDB with "Error preparing Blob/File data to be stored in object store". Any such
12069
+ * failure is `storage`, with the browser's own words after it for the logs, and leaves no piece behind.
12070
+ */
12071
+ async writeBundle(digest, bytes) {
12072
+ const id = bundleId(digest);
12073
+ const expected = toBase64Url(sha256(bytes));
12074
+ let files;
12075
+ let stored;
12076
+ try {
12077
+ files = await fileBytes();
12078
+ const size = await files.size(id);
12079
+ if (size === bytes.length && await files.digest(id) === expected) return files.kind;
12080
+ if (size !== null) await files.remove(id);
12081
+ for (let at = 0; at < bytes.length; at += FILE_BYTES_STEP) await files.append(id, at, bytes.subarray(at, at + FILE_BYTES_STEP));
12082
+ await files.flush(id);
12083
+ await files.close(id);
12084
+ stored = await files.digest(id);
12085
+ } catch (e) {
12086
+ await files?.close(id).catch(() => {});
12087
+ await files?.remove(id).catch(() => {});
12088
+ const words = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
12089
+ console.warn("[apps] storage:", words);
12090
+ fail("storage", `The app's files could not be stored on this device (${words})`);
12091
+ }
12092
+ if (stored !== expected) {
12093
+ await files.remove(id).catch(() => {});
12094
+ fail("storage", "The app's files could not be stored on this device");
12095
+ }
12096
+ return files.kind;
12097
+ }
12098
+ async readBundleBytes(kind, digest) {
12099
+ const files = await fileBytesOf(kind);
12100
+ const id = bundleId(digest);
12101
+ const size = files ? await files.size(id) : null;
12102
+ if (!files || size === null || size > APP_BUNDLE_LIMITS.bundleBytes) return null;
12103
+ const out = new Uint8Array(size);
12104
+ for (let at = 0; at < size;) {
12105
+ const part = await files.read(id, at, Math.min(FILE_BYTES_STEP, size - at));
12106
+ if (!part.length) return null;
12107
+ out.set(part, at);
12108
+ at += part.length;
12109
+ }
12110
+ return out;
12111
+ }
12112
+ async hasBundle(kind, digest) {
12113
+ const files = await fileBytesOf(kind);
12114
+ return !!files && await files.size(bundleId(digest)) !== null;
12115
+ }
12116
+ async removeBundle(kind, digest) {
12117
+ this.verified.delete(digest);
12118
+ await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
12119
+ }
12120
+ /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
12121
+ async verifiedBundle(app) {
12122
+ const kept = this.verified.get(app.digest);
12123
+ if (kept) return kept;
12124
+ const bytes = await this.readBundleBytes(app.bytes, app.digest);
12125
+ if (!bytes) fail("needs-files", "This app's files are not on this device yet");
12126
+ const read = readAppBundle(bytes);
12127
+ if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
12128
+ if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
12129
+ this.verified.set(app.digest, read.bundle);
12130
+ return read.bundle;
12131
+ }
12132
+ async fetchBundle(url) {
12133
+ const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
12134
+ const read = readAppBundle(bytes);
12135
+ if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
12136
+ return {
12137
+ bundle: read.bundle,
12138
+ bytes
12139
+ };
12140
+ }
12141
+ async readStoreAt(url, heldKey) {
12142
+ 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 })]);
12143
+ return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
12144
+ }
12145
+ /** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
12146
+ async readRevocations(ref, urls) {
12147
+ const out = [];
12148
+ for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
12149
+ const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
12150
+ if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
12151
+ } catch {}
12152
+ return out;
12153
+ }
12154
+ summary(s) {
12155
+ const index = s.index;
12156
+ return {
12157
+ key: s.key,
12158
+ fingerprint: appFingerprint(s.key),
12159
+ url: s.url,
12160
+ preloaded: !!s.preloaded,
12161
+ ...index && {
12162
+ name: index.name,
12163
+ kind: index.kind,
12164
+ sequence: index.sequence,
12165
+ expires: index.expires,
12166
+ ...index.description !== void 0 && { description: index.description }
12167
+ },
12168
+ expired: !!index && index.expires < this.nowS(),
12169
+ ...s.fetchedAt !== void 0 && { fetchedAt: s.fetchedAt },
12170
+ apps: index?.apps ?? [],
12171
+ removed: index?.removed ?? [],
12172
+ ...s.equivocation && { equivocation: {
12173
+ sequence: s.equivocation.sequence,
12174
+ at: s.equivocation.at
12175
+ } },
12176
+ ...s.problem && { problem: s.problem }
12177
+ };
12178
+ }
12179
+ async listStores() {
12180
+ return (await this.stores()).sort((a, b) => a.addedAt - b.addedAt).map((s) => this.summary(s));
12181
+ }
12182
+ storeUrl(url) {
12183
+ return typeof url === "string" && appPasteUrl(url, "ghostly-store.json") || notReadable(url, "Stores are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
12184
+ }
12185
+ /** Reads a store at a URL for the person to see before adding it. Nothing is kept. */
12186
+ async previewStore({ url }) {
12187
+ const at = this.storeUrl(url);
12188
+ const read = await this.readStoreAt(at);
12189
+ if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
12190
+ const { index } = read.store;
12191
+ return {
12192
+ url: at,
12193
+ key: index.key,
12194
+ fingerprint: appFingerprint(index.key),
12195
+ name: index.name,
12196
+ kind: index.kind,
12197
+ sequence: index.sequence,
12198
+ expires: index.expires,
12199
+ expired: read.store.expired,
12200
+ apps: index.apps.length,
12201
+ ...index.description !== void 0 && { description: index.description }
12202
+ };
12203
+ }
12204
+ /**
12205
+ * Adds a store the person chose: read at its URL and pinned to its key (`key`, when given, is the key the person saw,
12206
+ * and another one is refused). A store already added under that key takes the URL and the newer index.
12207
+ */
12208
+ async addStore({ url, key }) {
12209
+ if (key !== void 0 && !isAppKey(key)) fail("store-key", "Not a store key");
12210
+ const at = this.storeUrl(url);
12211
+ const read = await this.readStoreAt(at, key);
12212
+ if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
12213
+ const { index, digest } = read.store;
12214
+ const held = await this.storeRecord(index.key);
12215
+ const record = held && !held.removed ? {
12216
+ ...held,
12217
+ url: at
12218
+ } : {
12219
+ key: index.key,
12220
+ url: at,
12221
+ addedAt: this.now(),
12222
+ ...held?.preloaded && { preloaded: true }
12223
+ };
12224
+ await this.putStore(this.withIndex(record, index, digest));
12225
+ return this.summary(await this.storeRecord(index.key));
12226
+ }
12227
+ /** The update rule for indexes (WISP 1200 § Stores): a lower `sequence` or the same one with other bytes is refused, the held one kept. */
12228
+ withIndex(record, index, digest) {
12229
+ const held = record.index ? {
12230
+ key: record.key,
12231
+ sequence: record.index.sequence,
12232
+ digest: record.digest ?? ""
12233
+ } : null;
12234
+ const decision = appStoreDecision(held, {
12235
+ key: index.key,
12236
+ sequence: index.sequence,
12237
+ digest
12238
+ });
12239
+ const { problem: _problem, ...rest } = record;
12240
+ switch (decision) {
12241
+ case "new":
12242
+ case "update": {
12243
+ const { equivocation: _e, ...clean } = rest;
12244
+ return {
12245
+ ...clean,
12246
+ index,
12247
+ digest,
12248
+ fetchedAt: this.now()
12249
+ };
12250
+ }
12251
+ case "same": return {
12252
+ ...rest,
12253
+ fetchedAt: this.now()
12254
+ };
12255
+ case "rollback": return {
12256
+ ...rest,
12257
+ problem: "rollback"
12258
+ };
12259
+ case "equivocation": return {
12260
+ ...rest,
12261
+ equivocation: {
12262
+ sequence: index.sequence,
12263
+ digest,
12264
+ at: this.now()
12265
+ },
12266
+ problem: "equivocation"
12267
+ };
12268
+ default: return {
12269
+ ...rest,
12270
+ problem: "store-key"
12271
+ };
12272
+ }
12273
+ }
12274
+ async removeStore({ key }) {
12275
+ const held = await this.storeRecord(key);
12276
+ if (!held) return;
12277
+ if (held.preloaded) await this.putStore({
12278
+ key: held.key,
12279
+ url: held.url,
12280
+ addedAt: held.addedAt,
12281
+ preloaded: true,
12282
+ removed: true
12283
+ });
12284
+ else await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
12285
+ }
12286
+ /** Reads one store again, or every store (the person opened the Apps page, or the update check). */
12287
+ async refreshStores({ key } = {}) {
12288
+ const stores = (await this.stores()).filter((s) => key === void 0 || s.key === key);
12289
+ if (key !== void 0 && !stores.length) fail("no-store", "No such store");
12290
+ const out = [];
12291
+ for (const s of stores) {
12292
+ let next;
12293
+ try {
12294
+ const read = await this.readStoreAt(s.url, s.key);
12295
+ next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
12296
+ ...s,
12297
+ problem: read.reason
12298
+ };
12299
+ } catch (error) {
12300
+ next = {
12301
+ ...s,
12302
+ problem: error instanceof AppFetchError ? error.code : "network"
12303
+ };
12304
+ }
12305
+ if (!await this.storeRecord(s.key) || (await this.storeRecord(s.key))?.removed) continue;
12306
+ await this.putStore(next);
12307
+ out.push(this.summary(next));
12308
+ }
12309
+ return out;
12310
+ }
12311
+ /**
12312
+ * Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
12313
+ * chat card's pointer. A store's or a card's `sequence` and digest must hold: the same app, and that version or a newer
12314
+ * one. Nothing is stored: the checked bundle waits in memory for `install`.
12315
+ */
12316
+ async preview(source) {
12317
+ let urls;
12318
+ let expect = null;
12319
+ 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")];
12320
+ else if ("store" in source) {
12321
+ const s = await this.storeRecord(source.store);
12322
+ const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
12323
+ if (!listing) fail("not-listed", "This store does not list this app");
12324
+ urls = listing.urls.filter(isAppFetchUrl);
12325
+ expect = {
12326
+ ref: listing.ref,
12327
+ sequence: listing.sequence,
12328
+ digest: listing.digest
12329
+ };
12330
+ } else if ("card" in source && source.card) {
12331
+ const { ref, url, sequence, digest } = source.card;
12332
+ if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
12333
+ if (!isAppFetchUrl(url)) fail("host", "A card's app is read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit");
12334
+ urls = [url];
12335
+ expect = {
12336
+ ref,
12337
+ ...typeof sequence === "number" && { sequence },
12338
+ ...typeof digest === "string" && { digest }
12339
+ };
12340
+ } else fail("bad-source", "No app to fetch");
12341
+ if (!urls.length) fail("host", "This listing has no URL Ghostly reads from");
12342
+ let last = null;
12343
+ for (const url of urls) try {
12344
+ const { bundle, bytes } = await this.fetchBundle(url);
12345
+ const version = versionOf(bundle.manifest, bundle.digest);
12346
+ if (expect) {
12347
+ if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
12348
+ if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
12349
+ if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
12350
+ }
12351
+ return this.stage(bundle, bytes, url);
12352
+ } catch (error) {
12353
+ last = error;
12354
+ }
12355
+ throw last instanceof Error ? last : new Error(String(last));
12356
+ }
12357
+ async stage(bundle, bytes, from) {
12358
+ for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
12359
+ this.staged.delete(bundle.digest);
12360
+ while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
12361
+ this.staged.set(bundle.digest, {
12362
+ bundle,
12363
+ bytes,
12364
+ from,
12365
+ at: this.now()
12366
+ });
12367
+ const { manifest, digest } = bundle;
12368
+ const version = versionOf(manifest, digest);
12369
+ const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
12370
+ const decision = installed ? appUpdateDecision(installed, version) : "new";
12371
+ const install = decision === "other-app" ? "new" : decision;
12372
+ const asks = install === "new" ? [...manifest.permissions] : install === "update" ? manifest.permissions.filter((p) => !installed.permissions.includes(p)) : [];
12373
+ const listedBy = this.listedBy(version.ref, stores);
12374
+ return {
12375
+ digest,
12376
+ ref: version.ref,
12377
+ publisher: manifest.publisher,
12378
+ fingerprint: appFingerprint(manifest.publisher),
12379
+ manifest,
12380
+ from,
12381
+ icon: bundle.files.get("icon.png")?.slice() ?? null,
12382
+ install,
12383
+ asks,
12384
+ run: this.runStatus(version, stores, installed?.revocations),
12385
+ listedBy,
12386
+ unknownPublisher: !listedBy.some((s) => s.kind === "curated")
12387
+ };
12388
+ }
12389
+ /**
12390
+ * Installs the bundle `preview` fetched, once the person pressed Install: `grant` must hold every permission it asks
12391
+ * for (WISP 1200 § Permissions: none is granted by a store or a contact). A revoked or removed version is not
12392
+ * installed; neither is a rollback, nor another bundle under an installed `sequence` (marked on the app).
12393
+ */
12394
+ async install({ digest, grant }) {
12395
+ const staged = this.staged.get(digest);
12396
+ if (!staged || this.now() - staged.at > STAGED.ms) {
12397
+ this.staged.delete(digest);
12398
+ fail("expired", "Fetch the app again");
12399
+ }
12400
+ const { bundle, bytes, from } = staged;
12401
+ const { manifest } = bundle;
12402
+ if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
12403
+ const version = versionOf(manifest, digest);
12404
+ const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
12405
+ const run = this.runStatus(version, stores, installed?.revocations);
12406
+ if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12407
+ if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12408
+ const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
12409
+ const decision = installed ? pendingClash ? "equivocation" : appUpdateDecision(installed, version) : "new";
12410
+ if (decision === "rollback") fail("rollback", "A newer version of this app is installed");
12411
+ if (decision === "equivocation") {
12412
+ await this.putApp({
12413
+ ...installed,
12414
+ equivocation: {
12415
+ sequence: version.sequence,
12416
+ digest,
12417
+ at: this.now()
12418
+ }
12419
+ });
12420
+ fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
12421
+ }
12422
+ if (decision === "same" && installed) {
12423
+ if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
12424
+ ...installed,
12425
+ bytes: await this.writeBundle(digest, bytes)
12426
+ });
12427
+ this.staged.delete(digest);
12428
+ return this.view(await this.app(version.ref), stores);
12429
+ }
12430
+ const kind = await this.writeBundle(digest, bytes);
12431
+ const next = this.versionRecord(installed, bundle, kind, from);
12432
+ await this.putApp(next);
12433
+ if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12434
+ if (installed?.pending && installed.pending.sequence <= version.sequence && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
12435
+ this.staged.delete(digest);
12436
+ return this.view(next, stores);
12437
+ }
12438
+ /** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
12439
+ versionRecord(installed, bundle, kind, from) {
12440
+ const { manifest, digest } = bundle;
12441
+ const now = this.now();
12442
+ const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
12443
+ return {
12444
+ ref: appRef(manifest.publisher, manifest.name),
12445
+ publisher: manifest.publisher,
12446
+ name: manifest.name,
12447
+ sequence: manifest.sequence,
12448
+ digest,
12449
+ permissions: [...manifest.permissions],
12450
+ manifest,
12451
+ bytes: kind,
12452
+ from,
12453
+ installedAt: installed?.installedAt ?? now,
12454
+ updatedAt: now,
12455
+ ...installed?.revocations?.length && { revocations: installed.revocations },
12456
+ ...installed?.equivocation && installed.equivocation.sequence >= manifest.sequence && { equivocation: installed.equivocation },
12457
+ ...keepPending && { pending: keepPending },
12458
+ ...installed?.checkedAt !== void 0 && { checkedAt: installed.checkedAt }
12459
+ };
12460
+ }
12461
+ /** The person accepted the permissions a waiting update adds: it is installed now. */
12462
+ async acceptUpdate({ ref }) {
12463
+ const installed = await this.installedOrFail(ref);
12464
+ const pending = installed.pending ?? fail("no-update", "No update waits for this app");
12465
+ const bytes = await this.readBundleBytes(pending.bytes, pending.digest);
12466
+ const read = bytes ? readAppBundle(bytes) : null;
12467
+ if (!read?.ok || read.bundle.digest !== pending.digest) {
12468
+ const { pending: _p, ...rest } = installed;
12469
+ await this.putApp(rest);
12470
+ fail("needs-files", "The update's files are gone; it is fetched again at the next check");
12471
+ }
12472
+ const stores = await this.stores();
12473
+ const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), stores, installed.revocations);
12474
+ if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12475
+ if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12476
+ const plan = planAppUpdate(installed, {
12477
+ ...versionOf(read.bundle.manifest, pending.digest),
12478
+ permissions: read.bundle.manifest.permissions
12479
+ });
12480
+ if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
12481
+ const { pending: _p, ...without } = installed;
12482
+ const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
12483
+ await this.putApp(next);
12484
+ await this.removeBundle(installed.bytes, installed.digest);
12485
+ return this.view(next, stores);
12486
+ }
12487
+ /**
12488
+ * The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
12489
+ * app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
12490
+ * revocations read beside its sources. An update that keeps or drops permissions installs by itself; one that adds
12491
+ * some waits for the person. One check at a time.
12492
+ */
12493
+ checkUpdates() {
12494
+ this.checking ??= this.checkNow().then((results) => {
12495
+ try {
12496
+ this.host.checked?.(results);
12497
+ } catch {}
12498
+ return results;
12499
+ }).finally(() => {
12500
+ this.checking = null;
12501
+ });
12502
+ return this.checking;
12503
+ }
12504
+ async checkNow() {
12505
+ const apps = await this.installed();
12506
+ if (!apps.length) return [];
12507
+ await this.refreshStores();
12508
+ const stores = await this.stores();
12509
+ const out = [];
12510
+ for (const listed of apps) {
12511
+ if (!await this.hasBundle(listed.bytes, listed.digest)) await this.fetchFiles({ ref: listed.ref }).catch(() => {});
12512
+ const first = await this.app(listed.ref);
12513
+ if (!first) continue;
12514
+ let app = first;
12515
+ let outcome = "none";
12516
+ const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
12517
+ const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
12518
+ const candidates = [];
12519
+ for (const s of stores) {
12520
+ const listing = s.index?.apps.find((a) => a.ref === app.ref);
12521
+ if (listing && (listing.sequence > highest || listing.sequence >= app.sequence && !known.has(listing.digest))) candidates.push(...listing.urls.filter(isAppFetchUrl));
12522
+ }
12523
+ for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
12524
+ const peek = peekAppManifest(await this.host.fetch(url, {
12525
+ maxBytes: PEEK_BYTES,
12526
+ peek: true
12527
+ }));
12528
+ if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
12529
+ } catch {}
12530
+ for (const url of new Set(candidates)) {
12531
+ let fetched;
12532
+ try {
12533
+ fetched = await this.fetchBundle(url);
12534
+ } catch {
12535
+ continue;
12536
+ }
12537
+ const { bundle, bytes } = fetched;
12538
+ const version = versionOf(bundle.manifest, bundle.digest);
12539
+ if (version.ref !== app.ref || known.has(version.digest)) continue;
12540
+ if (app.pending && version.sequence === app.pending.sequence) {
12541
+ app = {
12542
+ ...app,
12543
+ equivocation: {
12544
+ sequence: version.sequence,
12545
+ digest: version.digest,
12546
+ at: this.now()
12547
+ }
12548
+ };
12549
+ outcome = "equivocation";
12550
+ continue;
12551
+ }
12552
+ const plan = planAppUpdate(app, {
12553
+ ...version,
12554
+ permissions: bundle.manifest.permissions
12555
+ });
12556
+ if (plan.action === "refuse") {
12557
+ if (plan.decision === "equivocation") {
12558
+ app = {
12559
+ ...app,
12560
+ equivocation: {
12561
+ sequence: version.sequence,
12562
+ digest: version.digest,
12563
+ at: this.now()
12564
+ }
12565
+ };
12566
+ outcome = "equivocation";
12567
+ }
12568
+ continue;
12569
+ }
12570
+ if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
12571
+ if (app.pending && version.sequence < app.pending.sequence) continue;
12572
+ const kind = await this.writeBundle(version.digest, bytes);
12573
+ if (plan.action === "install") {
12574
+ const old = app;
12575
+ app = this.versionRecord(app, bundle, kind, url);
12576
+ if (old.pending && old.pending.sequence <= version.sequence) {
12577
+ await this.removeBundle(old.pending.bytes, old.pending.digest);
12578
+ delete app.pending;
12579
+ }
12580
+ await this.putApp(app);
12581
+ await this.removeBundle(old.bytes, old.digest);
12582
+ outcome = "updated";
12583
+ } else {
12584
+ if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
12585
+ app = {
12586
+ ...app,
12587
+ pending: {
12588
+ sequence: version.sequence,
12589
+ digest: version.digest,
12590
+ manifest: bundle.manifest,
12591
+ added: plan.added,
12592
+ bytes: kind,
12593
+ from: url,
12594
+ at: this.now()
12595
+ }
12596
+ };
12597
+ outcome = "ask";
12598
+ }
12599
+ break;
12600
+ }
12601
+ const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
12602
+ const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
12603
+ for (const r of revocations) merged.set(canonicalJson(r), r);
12604
+ if (!await this.app(app.ref)) continue;
12605
+ app = {
12606
+ ...app,
12607
+ ...merged.size && { revocations: [...merged.values()] },
12608
+ checkedAt: this.now()
12609
+ };
12610
+ await this.putApp(app);
12611
+ out.push({
12612
+ ref: app.ref,
12613
+ outcome,
12614
+ run: await this.runCheckOf(app, stores)
12615
+ });
12616
+ }
12617
+ return out;
12618
+ }
12619
+ /**
12620
+ * Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
12621
+ * carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
12622
+ * taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
12623
+ * then its `sources`. Nothing answers: it stays `needs-files`, and it never runs unchecked.
12624
+ */
12625
+ async fetchFiles({ ref }) {
12626
+ const app = await this.installedOrFail(ref);
12627
+ const stores = await this.stores();
12628
+ if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
12629
+ const urls = [app.from];
12630
+ for (const s of stores) for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listing.urls);
12631
+ urls.push(...app.manifest.sources ?? []);
12632
+ for (const url of new Set(urls.filter(isAppFetchUrl))) {
12633
+ let fetched;
12634
+ try {
12635
+ fetched = await this.fetchBundle(url);
12636
+ } catch {
12637
+ continue;
12638
+ }
12639
+ if (fetched.bundle.digest !== app.digest) continue;
12640
+ const kind = await this.writeBundle(app.digest, fetched.bytes);
12641
+ const now = await this.app(app.ref);
12642
+ if (!now || now.digest !== app.digest) {
12643
+ await this.removeBundle(kind, app.digest);
12644
+ fail("not-installed", "This app is not installed");
12645
+ }
12646
+ if (now.bytes !== kind) await this.putApp({
12647
+ ...now,
12648
+ bytes: kind
12649
+ });
12650
+ return this.runCheckOf({
12651
+ ...now,
12652
+ bytes: kind
12653
+ }, stores);
12654
+ }
12655
+ return { status: "needs-files" };
12656
+ }
12657
+ async runCheckOf(app, stores) {
12658
+ if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
12659
+ return this.runStatus(app, stores, app.revocations);
12660
+ }
12661
+ /** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
12662
+ async runCheck({ ref }) {
12663
+ return this.runCheckOf(await this.installedOrFail(ref), await this.stores());
12664
+ }
12665
+ /**
12666
+ * What the runner writes into its frame: the entry, from the bundle read back and checked again. A revoked version
12667
+ * never runs; one a store removed runs only when the person chose "Run anyway" (`runAnyway`).
12668
+ */
12669
+ async entry({ ref, runAnyway }) {
12670
+ let app = await this.installedOrFail(ref);
12671
+ let run = await this.runCheckOf(app, await this.stores());
12672
+ if (run.status === "needs-files") {
12673
+ run = await this.fetchFiles({ ref });
12674
+ app = await this.installedOrFail(ref);
12675
+ }
12676
+ if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12677
+ if (run.status === "removed" && runAnyway !== true) fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12678
+ if (run.status === "needs-files") fail("needs-files", "This app's files are not on this device yet");
12679
+ if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
12680
+ const bundle = await this.verifiedBundle(app);
12681
+ const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
12682
+ return {
12683
+ ref: app.ref,
12684
+ digest: app.digest,
12685
+ version: app.manifest.version,
12686
+ title: app.manifest.title,
12687
+ permissions: [...app.permissions],
12688
+ entry: utf8Decode(entry)
12689
+ };
12690
+ }
12691
+ /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
12692
+ async file({ ref, path }) {
12693
+ const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
12694
+ const bundle = await this.verifiedBundle(app);
12695
+ if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12696
+ return bundle.files.get(path).slice();
12697
+ }
12698
+ async view(app, stores) {
12699
+ const { manifest } = app;
12700
+ const listedBy = this.listedBy(app.ref, stores);
12701
+ return {
12702
+ ref: app.ref,
12703
+ name: app.name,
12704
+ publisher: app.publisher,
12705
+ fingerprint: appFingerprint(app.publisher),
12706
+ title: manifest.title,
12707
+ tagline: manifest.tagline,
12708
+ ...manifest.description !== void 0 && { description: manifest.description },
12709
+ version: manifest.version,
12710
+ sequence: app.sequence,
12711
+ digest: app.digest,
12712
+ permissions: [...app.permissions],
12713
+ from: app.from,
12714
+ icon: manifest.files.some((f) => f.path === "icon.png"),
12715
+ installedAt: app.installedAt,
12716
+ updatedAt: app.updatedAt,
12717
+ run: await this.runCheckOf(app, stores),
12718
+ listedBy,
12719
+ unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
12720
+ ...app.pending && { pending: {
12721
+ sequence: app.pending.sequence,
12722
+ version: app.pending.manifest.version,
12723
+ added: [...app.pending.added]
12724
+ } },
12725
+ ...app.equivocation && { equivocation: {
12726
+ sequence: app.equivocation.sequence,
12727
+ at: app.equivocation.at
12728
+ } },
12729
+ ...app.checkedAt !== void 0 && { checkedAt: app.checkedAt }
12730
+ };
12731
+ }
12732
+ async list() {
12733
+ const [apps, stores] = await Promise.all([this.installed(), this.stores()]);
12734
+ return Promise.all(apps.sort((a, b) => a.installedAt - b.installedAt).map((a) => this.view(a, stores)));
12735
+ }
12736
+ /**
12737
+ * Uninstalls an app: its bundle (and a waiting update's), its record and its storage in every scope. The person chose
12738
+ * it, after the offer of an export (`exportData`).
12739
+ */
12740
+ async uninstall({ ref }) {
12741
+ const app = await this.installedOrFail(ref);
12742
+ const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
12743
+ tx.objectStore(STORES$1.apps).delete(app.ref);
12744
+ tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
12745
+ await done(tx);
12746
+ for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
12747
+ await this.removeBundle(app.bytes, app.digest);
12748
+ if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
12749
+ }
12750
+ scopeOf(scope) {
12751
+ if (scope === "alone" || typeof scope === "string" && this.host.isChat(scope)) return scope;
12752
+ fail("bad-scope", "Not a chat of this profile");
12753
+ }
12754
+ keyOf(key) {
12755
+ if (typeof key !== "string" || !key.length || byteLength(key) > APP_STORAGE_LIMITS.keyBytes) fail("bad-key", "A key is 1 to 256 bytes of text");
12756
+ return key;
12757
+ }
12758
+ /** One scope's writes, one after another, so the count of bytes used stays true. */
12759
+ serial(id, work) {
12760
+ const run = (this.queues.get(id) ?? Promise.resolve()).then(work, work);
12761
+ const settled = run.catch(() => {});
12762
+ this.queues.set(id, settled);
12763
+ settled.then(() => {
12764
+ if (this.queues.get(id) === settled) this.queues.delete(id);
12765
+ });
12766
+ return run;
12767
+ }
12768
+ async usageOf(ref, scope) {
12769
+ const id = `${ref}\0${scope}`;
12770
+ let used = this.usage.get(id);
12771
+ if (used === void 0) {
12772
+ used = (await wrap((await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX).getAll([ref, scope]))).reduce((sum, r) => sum + r.size, 0);
12773
+ this.usage.set(id, used);
12774
+ }
12775
+ return used;
12776
+ }
12777
+ async storageGet({ ref, scope, key }) {
12778
+ const app = await this.runningOrFail(ref);
12779
+ const row = await wrap((await store(STORES$1.appStorage, "readonly")).get([
12780
+ app.ref,
12781
+ this.scopeOf(scope),
12782
+ this.keyOf(key)
12783
+ ]));
12784
+ return row ? { value: JSON.parse(row.value) } : null;
12785
+ }
12786
+ /** Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope. */
12787
+ async storageSet({ ref, scope, key, value }) {
12788
+ const app = await this.runningOrFail(ref);
12789
+ const where = this.scopeOf(scope), name = this.keyOf(key);
12790
+ let text;
12791
+ try {
12792
+ text = JSON.stringify(value);
12793
+ } catch {
12794
+ text = void 0;
12795
+ }
12796
+ if (typeof text !== "string") fail("bad-value", "A value is JSON");
12797
+ const valueBytes = byteLength(text);
12798
+ if (valueBytes > APP_STORAGE_LIMITS.valueBytes) fail("too-large", "A value is at most 64 KiB");
12799
+ const size = byteLength(name) + valueBytes;
12800
+ await this.serial(`${app.ref}\0${where}`, async () => {
12801
+ const used = await this.usageOf(app.ref, where);
12802
+ const rows = await store(STORES$1.appStorage, "readonly");
12803
+ const next = used - ((await wrap(rows.get([
12804
+ app.ref,
12805
+ where,
12806
+ name
12807
+ ])))?.size ?? 0) + size;
12808
+ if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
12809
+ await wrap((await store(STORES$1.appStorage, "readwrite")).put({
12810
+ ref: app.ref,
12811
+ scope: where,
12812
+ key: name,
12813
+ value: text,
12814
+ size
12815
+ }));
12816
+ this.usage.set(`${app.ref}\0${where}`, next);
12817
+ });
12818
+ }
12819
+ async storageDelete({ ref, scope, key }) {
12820
+ const app = await this.runningOrFail(ref);
12821
+ const where = this.scopeOf(scope), name = this.keyOf(key);
12822
+ await this.serial(`${app.ref}\0${where}`, async () => {
12823
+ const used = await this.usageOf(app.ref, where);
12824
+ const old = await wrap((await store(STORES$1.appStorage, "readonly")).get([
12825
+ app.ref,
12826
+ where,
12827
+ name
12828
+ ]));
12829
+ if (!old) return;
12830
+ await wrap((await store(STORES$1.appStorage, "readwrite")).delete([
12831
+ app.ref,
12832
+ where,
12833
+ name
12834
+ ]));
12835
+ this.usage.set(`${app.ref}\0${where}`, used - old.size);
12836
+ });
12837
+ }
12838
+ async storageKeys({ ref, scope }) {
12839
+ const app = await this.runningOrFail(ref);
12840
+ return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
12841
+ }
12842
+ /** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
12843
+ async exportData({ ref }) {
12844
+ const app = await this.installedOrFail(ref);
12845
+ const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(rowsOf(app.ref)));
12846
+ const scopes = /* @__PURE__ */ new Map();
12847
+ for (const row of rows) {
12848
+ const out = scopes.get(row.scope) ?? {
12849
+ ghostlyAppData: 1,
12850
+ app: app.ref,
12851
+ scope: row.scope,
12852
+ entries: {}
12853
+ };
12854
+ out.entries[row.key] = JSON.parse(row.value);
12855
+ scopes.set(row.scope, out);
12856
+ }
12857
+ return [...scopes.values()];
12858
+ }
12859
+ };
12860
+ //#endregion
11706
12861
  //#region ../browser/src/devices/state.ts
11707
12862
  /** WISP 06 § States of a device. */
11708
12863
  var DEVICE_STATES = [
@@ -17561,7 +18716,7 @@ async function room() {
17561
18716
  async function handoffSelf(profileHost) {
17562
18717
  return {
17563
18718
  app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
17564
- db: 13,
18719
+ db: 14,
17565
18720
  pins: { ...WALLET_SDK_PINS },
17566
18721
  kind: profileHost.kind,
17567
18722
  room: await room(),
@@ -20447,6 +21602,9 @@ var db = {
20447
21602
  await fileStore.deleteForLink(linkId);
20448
21603
  await removeFileBytes(`${linkId}-in-`);
20449
21604
  await removeFileBytes(`${linkId}-out-`);
21605
+ const appStorage = await store(STORES$1.appStorage, "readwrite");
21606
+ const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
21607
+ await Promise.all(appKeys.map((key) => wrap(appStorage.delete(key))));
20450
21608
  },
20451
21609
  async getMessages(linkId) {
20452
21610
  return (await wrap((await store(STORES$1.messages, "readonly")).index("byLink").getAll(linkId))).sort((a, b) => a.timestamp - b.timestamp);
@@ -20854,22 +22012,6 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
20854
22012
  };
20855
22013
  }
20856
22014
  //#endregion
20857
- //#region ../browser/src/engine/joinTrace.ts
20858
- /**
20859
- * Timestamps along a join through a group's link, for measuring where the time goes. Silent unless
20860
- * the page sets `globalThis.__ghostlyJoinTrace` (the e2e measurement does): each step is then one
20861
- * `console.debug` line, `[ghostly:join] {"t":…,"g":…,"step":…}`, read back by the test.
20862
- */
20863
- function traceJoin(group, step, detail) {
20864
- if (!globalThis.__ghostlyJoinTrace) return;
20865
- console.debug(`[ghostly:join] ${JSON.stringify({
20866
- t: Date.now(),
20867
- g: group,
20868
- step,
20869
- ...detail
20870
- })}`);
20871
- }
20872
- //#endregion
20873
22015
  //#region ../browser/src/engine/hubClocks.ts
20874
22016
  /**
20875
22017
  * 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
@@ -21038,6 +22180,14 @@ var HUB_GRACE_MS = 18e4;
21038
22180
  var CUT_OFF_STEP_UP_MS = 15e3;
21039
22181
  /** A lobby request this recent is from a member still looking fast for my side of the edge: mine opens expecting it. */
21040
22182
  var LOBBY_EXPECT_MS = 3e4;
22183
+ /**
22184
+ * A lobby is one record every member asking a hub writes, each reading it and writing it back with its own request: two
22185
+ * members writing at once (both cut off by the same leave, in the same second) each write what they read, and the later
22186
+ * write drops the earlier request. So a member reads the lobby back this long after its write (plus up to `LOBBY_CHECK_JITTER_MS`, so
22187
+ * the members who wrote together do not read and write together again) and asks again at once if its request is gone.
22188
+ */
22189
+ var LOBBY_CHECK_MS = 2e3;
22190
+ var LOBBY_CHECK_JITTER_MS = 3e3;
21041
22191
  var REFUSED_FOR_MS$1 = 6e5;
21042
22192
  var ENTRY_LINGER_MS$1 = 2e4;
21043
22193
  var RELAYED_KEPT = 4096;
@@ -21096,6 +22246,8 @@ var Communities = class {
21096
22246
  knockAt = /* @__PURE__ */ new Map();
21097
22247
  /** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
21098
22248
  knockingWith = /* @__PURE__ */ new Map();
22249
+ /** Joiners: the knock record a knock read whose write the relays refused, and when it was read (`knock`). */
22250
+ knockRead = /* @__PURE__ */ new Map();
21099
22251
  /** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
21100
22252
  frames = new FramesTaken();
21101
22253
  carriers = /* @__PURE__ */ new Map();
@@ -21281,6 +22433,7 @@ var Communities = class {
21281
22433
  this.knocked.delete(link.g);
21282
22434
  this.knockAt.delete(link.g);
21283
22435
  this.knockingWith.delete(link.g);
22436
+ this.knockRead.delete(link.g);
21284
22437
  const before = this.stored.get(link.g)?.joining;
21285
22438
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
21286
22439
  if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
@@ -21314,6 +22467,7 @@ var Communities = class {
21314
22467
  this.knocked.delete(group.id);
21315
22468
  this.knockAt.delete(group.id);
21316
22469
  this.knockingWith.delete(group.id);
22470
+ this.knockRead.delete(group.id);
21317
22471
  await this.host.closeEdge(joining.linkId);
21318
22472
  await this.store.putGroup(group);
21319
22473
  this.host.emit();
@@ -21454,6 +22608,7 @@ var Communities = class {
21454
22608
  this.lastPeerMessageAt.delete(groupId);
21455
22609
  this.lastMentionAt.delete(groupId);
21456
22610
  this.knockingWith.delete(groupId);
22611
+ this.knockRead.delete(groupId);
21457
22612
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
21458
22613
  await this.store.deleteGroup(groupId);
21459
22614
  this.host.historyGone?.(groupId);
@@ -21472,7 +22627,7 @@ var Communities = class {
21472
22627
  if (group.joining && (this.knocking(group, live) || group.joining.check)) {
21473
22628
  const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
21474
22629
  if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
21475
- else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
22630
+ else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= (this.knocked.has(group.id) ? every : Math.min(every, 3e3))) await this.knock(group, now).catch(() => {});
21476
22631
  if (!live) continue;
21477
22632
  }
21478
22633
  if (!live) continue;
@@ -21648,7 +22803,15 @@ var Communities = class {
21648
22803
  live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
21649
22804
  for (const hub of kept) {
21650
22805
  const id = edges.get(hub);
21651
- if (id && this.host.linkReady(id, 2)) continue;
22806
+ if (id && this.host.linkReady(id, 2)) {
22807
+ live.lobbyChecks?.delete(hub);
22808
+ continue;
22809
+ }
22810
+ const check = live.lobbyChecks?.get(hub);
22811
+ if (check && now >= check.at) {
22812
+ live.lobbyChecks.delete(hub);
22813
+ if (await this.lobbyLost(groupId, live, hub, check.ts)) live.lobbyWrites.delete(hub);
22814
+ }
21652
22815
  if (now - (live.lobbyWrites.get(hub) ?? 0) >= this.timings.lobbyWriteMs) {
21653
22816
  live.lobbyWrites.set(hub, now);
21654
22817
  await this.askHub(groupId, live, hub, now).catch(() => {
@@ -21921,8 +23084,26 @@ var Communities = class {
21921
23084
  key: live.session.myKey,
21922
23085
  ts: theirs
21923
23086
  }, theirs)), true, true);
23087
+ (live.lobbyChecks ??= /* @__PURE__ */ new Map()).set(hub, {
23088
+ at: now + LOBBY_CHECK_MS + Math.floor(this.random() * LOBBY_CHECK_JITTER_MS),
23089
+ ts: Math.floor(theirs / 1e3) * 1e3
23090
+ });
21924
23091
  traceJoin(groupId, "lobby.written");
21925
23092
  }
23093
+ /**
23094
+ * My request is not in the hub's lobby any more, and the lobby has room for it: another member's write, made from a
23095
+ * reading from before mine, dropped it (`LOBBY_CHECK_MS`). A full lobby that pushed it out is left as it is: asking
23096
+ * again there would push out someone else. A read that fails says nothing, and the usual refresh asks again.
23097
+ */
23098
+ async lobbyLost(groupId, live, hub, ts) {
23099
+ const keys = lobbyKeys(live.session.state.rv, groupId, hub);
23100
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => void 0);
23101
+ if (records === void 0) return false;
23102
+ const entries = readLobby(keys, records ?? []);
23103
+ const lost = !entries.some((e) => e.key === live.session.myKey && e.ts >= ts) && entries.length < COMMUNITY_TOPOLOGY.lobbyEntries;
23104
+ if (lost) traceJoin(groupId, "lobby.lost");
23105
+ return lost;
23106
+ }
21926
23107
  async pollLobby(groupId, live, now) {
21927
23108
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
21928
23109
  const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => null) ?? []);
@@ -22047,10 +23228,19 @@ var Communities = class {
22047
23228
  }
22048
23229
  const records = this.knockRecordsToRead(live, door === s.myKey, busy, now);
22049
23230
  if (!records.length) return;
23231
+ let failed = 0;
22050
23232
  const read = await Promise.all(records.map(async (n) => {
22051
23233
  const record = knockRecord(link, n);
22052
- return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() => null) ?? []);
23234
+ return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() => {
23235
+ failed++;
23236
+ return null;
23237
+ }) ?? []);
22053
23238
  }));
23239
+ traceJoin(groupId, "knock.read", {
23240
+ records: records.length,
23241
+ knocks: read.flat().length,
23242
+ ...failed ? { failed } : {}
23243
+ });
22054
23244
  const bell = records.indexOf(KNOCK_BELL);
22055
23245
  if (bell >= 0 && read[bell].filter((k) => now - k.ts < 18e4).length >= BELL_FULL) live.crowdUntil = now + CROWD_MS;
22056
23246
  const newest = /* @__PURE__ */ new Map();
@@ -22109,10 +23299,16 @@ var Communities = class {
22109
23299
  * Leaves (or refreshes) my knock: in the bell while it has room, in my own record once a crowd has
22110
23300
  * filled it. The first one goes out at once; refreshes are background requests, and keep my side of
22111
23301
  * the entry session looking fast while I still expect a member to answer soon.
23302
+ *
23303
+ * "The first" is the first of this run that the relays took (`knocked`), not the first tried: a knock the relays'
23304
+ * budget refused is tried again as one, every `UNKNOCKED_RETRY_MS`. Taken for a refresh, it went as a background
23305
+ * request, every `knockMs`, and background requests are held to a few a minute while a link of this app signals: its
23306
+ * own entry session, looking fast for the door's answer. A CLI that was the door of another community, its budget
23307
+ * spent, never knocked at all (groupCompat, with the budget scaled down to 12 requests a minute).
22112
23308
  */
22113
23309
  async knock(group, now = this.now()) {
22114
23310
  const joining = group.joining;
22115
- const first = !this.lastKnock.has(group.id);
23311
+ const first = !this.knocked.has(group.id);
22116
23312
  this.lastKnock.set(group.id, now);
22117
23313
  if (first || now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
22118
23314
  const me = identityFromSeedB64(joining.seedB64).pubKeyZ32, link = {
@@ -22127,16 +23323,37 @@ var Communities = class {
22127
23323
  knocks: readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, !first) ?? [])
22128
23324
  };
22129
23325
  };
22130
- let n = this.knockAt.get(group.id) ?? KNOCK_BELL, { record, knocks } = await read(n);
22131
- if (n === KNOCK_BELL && !knocks.some((k) => k.key === me) && knocks.filter((k) => k.key !== me && now - k.ts < 18e4).length >= BELL_FULL) ({record, knocks} = await read(n = ownKnockRecord(me)));
22132
- await this.host.publish(knockIdentity(record), knockRecords(record, mergeKnocks(knocks, {
22133
- key: me,
22134
- ts: now
22135
- }, now)), !first);
23326
+ const kept = this.knockRead.get(group.id);
23327
+ this.knockRead.delete(group.id);
23328
+ const reuse = kept && now - kept.at < this.timings.knockMs ? kept : void 0;
23329
+ let n, record, knocks;
23330
+ if (reuse) {
23331
+ n = reuse.n;
23332
+ knocks = reuse.knocks;
23333
+ record = knockRecord(link, n);
23334
+ } else {
23335
+ n = this.knockAt.get(group.id) ?? KNOCK_BELL;
23336
+ ({record, knocks} = await read(n));
23337
+ if (n === KNOCK_BELL && !knocks.some((k) => k.key === me) && knocks.filter((k) => k.key !== me && now - k.ts < 18e4).length >= BELL_FULL) ({record, knocks} = await read(n = ownKnockRecord(me)));
23338
+ }
23339
+ try {
23340
+ await this.host.publish(knockIdentity(record), knockRecords(record, mergeKnocks(knocks, {
23341
+ key: me,
23342
+ ts: now
23343
+ }, now)), !first);
23344
+ } catch (error) {
23345
+ this.knockRead.set(group.id, {
23346
+ n,
23347
+ knocks,
23348
+ at: reuse?.at ?? now
23349
+ });
23350
+ throw error;
23351
+ }
22136
23352
  this.knockAt.set(group.id, n);
22137
23353
  traceJoin(group.id, "knock.published", {
22138
23354
  ms: Date.now() - started,
22139
- record: n
23355
+ record: n,
23356
+ ...reuse && { kept: true }
22140
23357
  });
22141
23358
  const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
22142
23359
  const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
@@ -22273,6 +23490,7 @@ var Communities = class {
22273
23490
  this.knocked.delete(g);
22274
23491
  this.knockAt.delete(g);
22275
23492
  this.knockingWith.delete(g);
23493
+ this.knockRead.delete(g);
22276
23494
  await this.host.closeEdge(linkId);
22277
23495
  await this.reconcile(g, live, now);
22278
23496
  this.host.emit();
@@ -32194,203 +33412,6 @@ var WalletSetup = class {
32194
33412
  }
32195
33413
  };
32196
33414
  //#endregion
32197
- //#region ../browser/src/platform/irohWeb.ts
32198
- /**
32199
- * Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
32200
- * Desktop's native Iroh, but relay only. A page cannot send UDP, so every
32201
- * packet goes through an Iroh relay over a WebSocket; the relay sees who talks
32202
- * to whom and when, never the frames.
32203
- *
32204
- * The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
32205
- * never on the app's first load.
32206
- */
32207
- /**
32208
- * n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
32209
- * dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
32210
- * because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
32211
- */
32212
- var DEFAULT_IROH_RELAYS = [
32213
- "https://use1-1.relay.n0.iroh.link/",
32214
- "https://euc1-1.relay.n0.iroh.link/",
32215
- "https://aps1-1.relay.n0.iroh.link/",
32216
- "https://usw1-1.relay.n0.iroh.link/"
32217
- ];
32218
- /**
32219
- * The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
32220
- * relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
32221
- * open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
32222
- */
32223
- var BROWSER_SPELLING = "plain";
32224
- /**
32225
- * Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
32226
- * may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
32227
- */
32228
- function irohRelayProblem(value) {
32229
- let url;
32230
- try {
32231
- url = new URL(value);
32232
- } catch {
32233
- return `Not a relay address: ${value}`;
32234
- }
32235
- const local = [
32236
- "localhost",
32237
- "127.0.0.1",
32238
- "[::1]"
32239
- ].includes(url.hostname);
32240
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
32241
- if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
32242
- return null;
32243
- }
32244
- var loaded = null;
32245
- /** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
32246
- function loadIrohWasm() {
32247
- loaded ??= import("./ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
32248
- await module.default();
32249
- return module;
32250
- }).catch((error) => {
32251
- loaded = null;
32252
- throw error;
32253
- });
32254
- return loaded;
32255
- }
32256
- var SEND_BUDGET = 122880;
32257
- var MAX_FRAME = 61440;
32258
- /** One paired channel over one QUIC stream. Sends are queued in order. */
32259
- var IrohWebChannel = class {
32260
- conn;
32261
- forget;
32262
- reader = null;
32263
- buffered = [];
32264
- queue = Promise.resolve();
32265
- closed = false;
32266
- bufferedAmount = 0;
32267
- sendBudget = SEND_BUDGET;
32268
- onClose = null;
32269
- constructor(conn, forget) {
32270
- this.conn = conn;
32271
- this.forget = forget;
32272
- this.read();
32273
- }
32274
- get onMessage() {
32275
- return this.reader;
32276
- }
32277
- set onMessage(reader) {
32278
- this.reader = reader;
32279
- if (reader) for (const text of this.buffered.splice(0)) reader(text);
32280
- }
32281
- async read() {
32282
- try {
32283
- for (;;) {
32284
- const text = await this.conn.recv();
32285
- if (text === void 0 || this.closed) break;
32286
- if (this.reader) this.reader(text);
32287
- else if (this.buffered.length < 64) this.buffered.push(text);
32288
- else break;
32289
- }
32290
- } catch {}
32291
- this.close();
32292
- }
32293
- send(data) {
32294
- if (typeof data !== "string" || this.closed || !data.length || data.length > MAX_FRAME || this.bufferedAmount + data.length > SEND_BUDGET) throw new Error("Iroh channel is closed or its send budget is full");
32295
- this.bufferedAmount += data.length;
32296
- this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
32297
- this.bufferedAmount -= data.length;
32298
- });
32299
- }
32300
- drained() {
32301
- return this.queue;
32302
- }
32303
- get rttMs() {
32304
- const rtt = this.conn.rtt();
32305
- return rtt >= 0 ? rtt : void 0;
32306
- }
32307
- close() {
32308
- if (this.closed) return;
32309
- this.closed = true;
32310
- this.buffered = [];
32311
- try {
32312
- this.conn.close();
32313
- } catch {}
32314
- this.forget();
32315
- this.onClose?.();
32316
- }
32317
- };
32318
- /** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
32319
- async function createIrohWebEndpoint(seedB64, options = {}) {
32320
- const seed = fromBase64Url(seedB64);
32321
- if (seed.length !== 32) throw new Error("Invalid transport seed");
32322
- const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
32323
- const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
32324
- const channels = /* @__PURE__ */ new Set();
32325
- let stopped = false;
32326
- let handler = null;
32327
- const early = [];
32328
- const bind = (conn) => {
32329
- const channel = new IrohWebChannel(conn, () => channels.delete(channel));
32330
- channels.add(channel);
32331
- return {
32332
- channel,
32333
- binding: conn.binding()
32334
- };
32335
- };
32336
- const endpoint = {
32337
- transport: "iroh/1",
32338
- descriptor: {
32339
- ...node.address(),
32340
- relayed: true
32341
- },
32342
- onDescriptor: null,
32343
- get onConnection() {
32344
- return handler;
32345
- },
32346
- set onConnection(value) {
32347
- handler = value;
32348
- if (value) for (const bound of early.splice(0)) value(bound);
32349
- },
32350
- async connect(descriptor) {
32351
- if (stopped) throw new Error("Iroh endpoint is stopped");
32352
- const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
32353
- if (stopped) {
32354
- conn.close();
32355
- throw new Error("Iroh endpoint is stopped");
32356
- }
32357
- return bind(conn);
32358
- },
32359
- async close() {
32360
- if (stopped) return;
32361
- stopped = true;
32362
- for (const channel of [...channels]) channel.close();
32363
- for (const bound of early.splice(0)) bound.channel.close();
32364
- await node.close().catch(() => {});
32365
- }
32366
- };
32367
- (async () => {
32368
- while (!stopped) {
32369
- let conn;
32370
- try {
32371
- conn = await node.accept();
32372
- } catch {
32373
- break;
32374
- }
32375
- if (!conn) break;
32376
- if (stopped) {
32377
- conn.close();
32378
- break;
32379
- }
32380
- const bound = bind(conn);
32381
- const accept = endpoint.onConnection;
32382
- if (accept) accept(bound);
32383
- else if (early.length < 4) early.push(bound);
32384
- else bound.channel.close();
32385
- }
32386
- if (!stopped) {
32387
- endpoint.onUnavailable?.();
32388
- await endpoint.close();
32389
- }
32390
- })();
32391
- return endpoint;
32392
- }
32393
- //#endregion
32394
33415
  //#region ../browser/src/engine/node.ts
32395
33416
  /** How long a chat waits before listening again after its HyperDHT relay went away. */
32396
33417
  var RELAY_RETRY_MS = 3e4;
@@ -33873,6 +34894,7 @@ var GhostlyNode = class GhostlyNode {
33873
34894
  this.prepareSpare(STARTUP_QUIET_MS);
33874
34895
  }
33875
34896
  if (!this.limitedMode) this.openStartedWallets(fresh);
34897
+ if (this.appsOn) this.appStore.start().catch(() => {});
33876
34898
  if (!this.options.singleDevice && this.networkOn && knownDeviceGate()?.state === "active") this.startDeviceSet().catch(() => {});
33877
34899
  if (!this.options.singleDevice && this.limitedMode && knownDeviceGate()?.state === "active") this.readTurnWhileLimited();
33878
34900
  if (this.restoreCheck === "checking" && this.limitedMode) this.checkRestoredTurn(0);
@@ -34257,6 +35279,7 @@ var GhostlyNode = class GhostlyNode {
34257
35279
  clearTimeout(this.limitedTimer);
34258
35280
  this.limitedTimer = null;
34259
35281
  }
35282
+ this.appShelf?.stop();
34260
35283
  if (!quiet) this.depart();
34261
35284
  this.directPath.close();
34262
35285
  this.clock.close();
@@ -34329,6 +35352,83 @@ var GhostlyNode = class GhostlyNode {
34329
35352
  await this.shutdown();
34330
35353
  await clearProfileStores();
34331
35354
  }
35355
+ appShelf;
35356
+ /** The installed apps and stores, refused while the apps feature is off. */
35357
+ get appStore() {
35358
+ if (!this.appsOn) throw new Error("Apps are unavailable in this release");
35359
+ return this.appShelf ??= new Apps({
35360
+ fetch: boundedAppFetch({
35361
+ fetcher: this.options.appFetch,
35362
+ online: () => this.networkOn
35363
+ }),
35364
+ isChat: (scope) => {
35365
+ const stored = this.links.get(scope)?.stored;
35366
+ return !!stored && !stored.group && !!stored.profile && !!stored.pairedPeerKey;
35367
+ },
35368
+ online: () => this.networkOn && !this.shuttingDown,
35369
+ checked: () => this.events.onAppsChecked?.()
35370
+ });
35371
+ }
35372
+ appList() {
35373
+ return this.appStore.list();
35374
+ }
35375
+ appStoreList() {
35376
+ return this.appStore.listStores();
35377
+ }
35378
+ appStorePreview(params) {
35379
+ return this.appStore.previewStore(params);
35380
+ }
35381
+ appStoreAdd(params) {
35382
+ return this.appStore.addStore(params);
35383
+ }
35384
+ appStoreRemove(params) {
35385
+ return this.appStore.removeStore(params);
35386
+ }
35387
+ appStoreRefresh(params = {}) {
35388
+ return this.appStore.refreshStores(params);
35389
+ }
35390
+ appPreview(params) {
35391
+ return this.appStore.preview(params);
35392
+ }
35393
+ appInstall(params) {
35394
+ return this.appStore.install(params);
35395
+ }
35396
+ appUpdateAccept(params) {
35397
+ return this.appStore.acceptUpdate(params);
35398
+ }
35399
+ appCheckUpdates() {
35400
+ return this.appStore.checkUpdates();
35401
+ }
35402
+ appUninstall(params) {
35403
+ return this.appStore.uninstall(params);
35404
+ }
35405
+ appRunCheck(params) {
35406
+ return this.appStore.runCheck(params);
35407
+ }
35408
+ appFetchFiles(params) {
35409
+ return this.appStore.fetchFiles(params);
35410
+ }
35411
+ appEntry(params) {
35412
+ return this.appStore.entry(params);
35413
+ }
35414
+ appFile(params) {
35415
+ return this.appStore.file(params);
35416
+ }
35417
+ appStorageGet(params) {
35418
+ return this.appStore.storageGet(params);
35419
+ }
35420
+ appStorageSet(params) {
35421
+ return this.appStore.storageSet(params);
35422
+ }
35423
+ appStorageDelete(params) {
35424
+ return this.appStore.storageDelete(params);
35425
+ }
35426
+ appStorageKeys(params) {
35427
+ return this.appStore.storageKeys(params);
35428
+ }
35429
+ appDataExport(params) {
35430
+ return this.appStore.exportData(params);
35431
+ }
34332
35432
  getState() {
34333
35433
  const groups = this.groups.views();
34334
35434
  return {
@@ -37113,6 +38213,58 @@ var GhostlyNode = class GhostlyNode {
37113
38213
  this.flushPin(linkId);
37114
38214
  return { error: null };
37115
38215
  }
38216
+ /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
38217
+ get appsOn() {
38218
+ return this.options.apps ?? false;
38219
+ }
38220
+ /** 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). */
38221
+ appChat(linkId, ref) {
38222
+ if (!this.appsOn) throw new Error("Apps are unavailable in this release");
38223
+ const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
38224
+ if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
38225
+ if (!isAppRef(ref)) throw new Error("Not an app reference");
38226
+ const { participationSeed, pairedPeerKey } = live.stored;
38227
+ if (!participationSeed || !pairedPeerKey) throw new Error("This chat is not paired yet");
38228
+ return {
38229
+ live,
38230
+ app: chatAppId(identityFromSeedB64(participationSeed).publicKey, publicKeyFromZ32(pairedPeerKey), ref)
38231
+ };
38232
+ }
38233
+ /** The chat app id of an app in a paired chat: how the contact's frames name it. */
38234
+ appId({ linkId, ref }) {
38235
+ return { app: this.appChat(linkId, ref).app };
38236
+ }
38237
+ /**
38238
+ * This side opened an app in a paired chat, or updated it: `open` goes to the contact now when the session is live,
38239
+ * and again on every session that comes back while it stays open.
38240
+ */
38241
+ async appOpen({ linkId, ref, version }) {
38242
+ const { live, app } = this.appChat(linkId, ref);
38243
+ if (!isAppVersion(version)) throw new Error("Not an app version");
38244
+ await this.appStore.chatRunnable({ ref });
38245
+ const open = live.appsOpen ??= /* @__PURE__ */ new Map();
38246
+ if (live.link) live.link.openApp(app, version);
38247
+ else open.set(app, version);
38248
+ return { app };
38249
+ }
38250
+ /** This side closed an app in a paired chat: `close` goes now when the session is live. */
38251
+ appClose({ linkId, ref }) {
38252
+ const { live, app } = this.appChat(linkId, ref);
38253
+ if (live.link) live.link.closeApp(app);
38254
+ else live.appsOpen?.delete(app);
38255
+ }
38256
+ /** One data frame of an app open on both sides, on the live session only; nothing is kept to send later. */
38257
+ async appSend({ linkId, ref, data }) {
38258
+ const { live, app } = this.appChat(linkId, ref);
38259
+ if (!live.appsOpen?.has(app)) return { error: "not-open" };
38260
+ await this.appStore.chatRunnable({ ref });
38261
+ return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
38262
+ }
38263
+ /** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
38264
+ appsCloseAll() {
38265
+ for (const live of this.links.values()) for (const app of [...live.appsOpen?.keys() ?? []]) if (live.link) live.link.closeApp(app);
38266
+ else live.appsOpen?.delete(app);
38267
+ }
37116
38268
  /** Keeps a pin of a 1:1 chat when it is newer than the chat's; `out`: mine, to say until the contact confirms it. */
37117
38269
  async keepLinkPin(linkId, pin, out) {
37118
38270
  const live = this.links.get(linkId);
@@ -38268,7 +39420,7 @@ var GhostlyNode = class GhostlyNode {
38268
39420
  ...settings
38269
39421
  };
38270
39422
  if (!this.settings.irohRelays?.length) delete this.settings.irohRelays;
38271
- if (irohRelaysChanged) this.rehomeIroh();
39423
+ if (irohRelaysChanged && !this.options.irohRelays?.length) this.rehomeIroh();
38272
39424
  if (settings.relays) {
38273
39425
  this.relays?.setRelays(settings.relays);
38274
39426
  if (this.relays) this.settings.relays = this.relays.describe().relays;
@@ -39491,6 +40643,8 @@ var GhostlyNode = class GhostlyNode {
39491
40643
  wakeSupport: true,
39492
40644
  upgradeSupport: true,
39493
40645
  peerUpgrades: () => !!live.caps?.peer?.capabilities.includes(UPGRADE_CAPABILITY),
40646
+ appsSupport: this.appsOn,
40647
+ appsOpen: this.appsOn ? live.appsOpen ??= /* @__PURE__ */ new Map() : void 0,
39494
40648
  servicesSupport: this.options.servicesSupport ?? (this.options.platform ?? "web") !== "web",
39495
40649
  dht: stored.profile ? {
39496
40650
  state: stored.dhtDeliveryState,
@@ -39796,6 +40950,7 @@ var GhostlyNode = class GhostlyNode {
39796
40950
  onPinSupport: (supported) => {
39797
40951
  if (supported) this.flushPin(linkId);
39798
40952
  },
40953
+ onAppFrame: this.appsOn ? (event) => this.events.onAppFrame?.(linkId, event) : void 0,
39799
40954
  onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
39800
40955
  onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
39801
40956
  onPayment: (payment) => this.desk.onPayment(linkId, payment),
@@ -39946,7 +41101,7 @@ var GhostlyNode = class GhostlyNode {
39946
41101
  get nativeFactories() {
39947
41102
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
39948
41103
  return {
39949
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-BoxPHp7v.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41104
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-w9cwVqGZ.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
39950
41105
  ...this.options.nativeTransports,
39951
41106
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
39952
41107
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -39957,6 +41112,7 @@ var GhostlyNode = class GhostlyNode {
39957
41112
  return !!this.options.nativeIrohRelays && !!this.options.nativeTransports?.["iroh/1"];
39958
41113
  }
39959
41114
  get irohRelays() {
41115
+ if (this.options.irohRelays?.length) return [...this.options.irohRelays];
39960
41116
  return this.settings.irohRelays?.length ? this.settings.irohRelays : [...DEFAULT_IROH_RELAYS];
39961
41117
  }
39962
41118
  /** New Iroh relays: idle endpoints move now; one carrying a chat keeps its relay until that session ends. */
@@ -40524,6 +41680,12 @@ var EngineServer = class {
40524
41680
  signal
40525
41681
  });
40526
41682
  },
41683
+ onAppFrame: (linkId, event) => this.broadcast({
41684
+ kind: "app-frame",
41685
+ linkId,
41686
+ event
41687
+ }),
41688
+ onAppsChecked: () => this.broadcast({ kind: "apps-checked" }),
40527
41689
  onDeviceGate: (gate) => this.broadcast({
40528
41690
  kind: "device-gate",
40529
41691
  gate
@@ -40571,7 +41733,10 @@ var EngineServer = class {
40571
41733
  detach(client) {
40572
41734
  this.clients.delete(client);
40573
41735
  this.histories.delete(client);
40574
- if (this.clients.size === 0) this.node.setActiveLink({ linkId: null });
41736
+ if (this.clients.size === 0) {
41737
+ this.node.setActiveLink({ linkId: null });
41738
+ this.node.appsCloseAll();
41739
+ }
40575
41740
  if (this.onCall.delete(client)) this.node.setCallOn({ on: this.onCall.size > 0 });
40576
41741
  }
40577
41742
  async handle(client, request) {
@@ -40679,4 +41844,4 @@ function signalKind(signal) {
40679
41844
  //#endregion
40680
41845
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
40681
41846
 
40682
- //# sourceMappingURL=server-CXFTDnFI.js.map
41847
+ //# sourceMappingURL=server-TamKsfmC.js.map