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