@ghostlytools/cli 1.1.3 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/assets/argon2id-088uQfnH.js.map +1 -1
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
- package/dist/assets/{fileBytes-DxiJHmTk.js → fileBytes-3AQ2tJzW.js} +5 -17
- package/dist/assets/{fileBytes-DxiJHmTk.js.map → fileBytes-3AQ2tJzW.js.map} +1 -1
- package/dist/assets/{fileBytes-BQ84L9Fe.js → fileBytes-CAZDydIe.js} +2 -2
- package/dist/assets/{fileBytes-BQ84L9Fe.js.map → fileBytes-CAZDydIe.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js → fileBytesIdb-CmG00qE-.js} +3 -3
- package/dist/assets/{fileBytesIdb-DQkQ10PS.js.map → fileBytesIdb-CmG00qE-.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js → fileBytesOpfs-CTgqt-p3.js} +3 -3
- package/dist/assets/{fileBytesOpfs-BFfR_68b.js.map → fileBytesOpfs-CTgqt-p3.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-CHMS7_KO.js → hyperdhtRelay-w9cwVqGZ.js} +3 -3
- package/dist/assets/{hyperdhtRelay-CHMS7_KO.js.map → hyperdhtRelay-w9cwVqGZ.js.map} +1 -1
- package/dist/assets/{idb-Co2lObpU.js → idb-BIdus0WX.js} +32 -8
- package/dist/assets/idb-BIdus0WX.js.map +1 -0
- package/dist/assets/joinTrace-CO8mlhNO.js +29 -0
- package/dist/assets/joinTrace-CO8mlhNO.js.map +1 -0
- package/dist/assets/legacy_ciphers-JJOu0JXL.js.map +1 -1
- package/dist/assets/{mainline-B0UTU0-4.js → mainline-BabMhm8L.js} +2 -2
- package/dist/assets/{mainline-B0UTU0-4.js.map → mainline-BabMhm8L.js.map} +1 -1
- package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -1
- package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -1
- package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -1
- package/dist/assets/openpgp-BBFr-zHQ.js.map +1 -1
- package/dist/assets/{restoredRows-C1O9FfA4.js → restoredRows-lhTKGmCO.js} +2 -2
- package/dist/assets/{restoredRows-C1O9FfA4.js.map → restoredRows-lhTKGmCO.js.map} +1 -1
- package/dist/assets/rolldown-runtime-8H4AJuhK.js +14 -0
- package/dist/assets/{server-9AM0ta6q.js → server-TamKsfmC.js} +1601 -262
- package/dist/assets/server-TamKsfmC.js.map +1 -0
- package/dist/assets/sha512-BYId-G-T.js.map +1 -1
- package/dist/assets/{src-C_E99PGx.js → src-C6qQYrAH.js} +2190 -207
- package/dist/assets/src-C6qQYrAH.js.map +1 -0
- package/dist/assets/storage-Bya5b2Xt.js.map +1 -1
- package/dist/assets/tree-store-N362nuzZ.js.map +1 -1
- package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -1
- package/dist/assets/{web-OUQl4Q7L.js → web-DwgdhfcY.js} +16 -16
- package/dist/assets/{web-OUQl4Q7L.js.map → web-DwgdhfcY.js.map} +1 -1
- package/dist/ghostly.mjs +905 -29
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +7 -7
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-9AM0ta6q.js.map +0 -1
- package/dist/assets/src-C_E99PGx.js.map +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { $a as
|
|
3
|
-
import { a as
|
|
4
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { $a as parseLocalTarget, $i as parseLinkPreview, $o as readHandoffPake, $r as EDIT_CAPABILITY, $s as readTurnPacket, $t as TEST_USDT_FAUCET_AMOUNT, A as knockIdentity, Ac as toZ32, Ao as handoffAttemptFailed, As as encodeInviteCode, B as GROUP_READ_NOTE, Bn as decodeBolt11, Bo as handoffManifestFrames, Br as readPin, Bs as devicePingFrame, C as communityHasFrame, Ca as statusCardText, Cc as verify, Ci as revokesIdentity, Co as HANDOFF_PAKE, Cr as readLobby, Cs as verifyEnrollProof, D as decodeGroupEntryLink, Da as receivedTimestamp, Dc as fromBase64Url, Di as validExternalKey, Do as HANDOFF_VERIFIED, Dr as transferEnded, Ds as OwnInviteError, Ea as heardTime, Ec as bytesEqual, Ei as emptyProofLedger, Eo as HANDOFF_TIMINGS, Er as FILE_LIMITS, Es as readDeviceInvite, F as GroupWakeLimiter, Fa as isAppRef, Fi as DHT_FILE_CAPABILITY, Fo as handoffContextProof, Fr as PIN_LIMITS, Fs as HANDOFF_CAPABILITY, G as carryMentions, Ga as verifyCommitSignature, Gi as fileMessageText, Gn as isFederationId, Go as handoffStreamKey, Gr as parseWakeFrame, Gs as TURN_RAISE_READ_TIMEOUT_MS, Hn as cashuRequestPayload, Ho as handoffPakeFrame, Hr as WakeLimiter, Hs as seedSigner, Ht as didDhtFromPublicKey, I as groupWakeFrame, Ia as readAppRevocations, Ii as dhtFileId, Io as handoffDataFrame, Ir as mayPin, Is as deviceEchoFrame, J as groupName, Ji as parseVideoMeta, Jn as GhostLink, Jo as readHandoffAck, Jr as checkPushEndpoint, Js as turnPutSummary, Jt as EVM_TEST_CHAINS, K as parseCommunityEdit, Ka as edgeParams, Ki as parseVoiceMeta, Kn as parseCashuRequestPayload, Ko as handoffVersions, Kr as relayRequest, Ks as TURN_SETTLE_MS, Kt as signDidDhtPacket, L as groupWakes, Li as parseImageMeta, Lo as handoffDigest, Lr as nextPinNumber, Ls as deviceEchoNonce, M as mergeKnocks, Ma as appRef, Mc as utf8Encode, Mo as handoffBusyFrame, Ms as inviteOwnership, N as readKnocks, Ni as DHT_MESSAGE_TTL, No as handoffCancelFrame, Nr as typingActivity, Ns as DEVICES_CAPABILITY, O as encodeGroupEntryLink, Oa as shownTime, Oc as randomBytes, Oi as TRANSPORTS, Oo as filePartName, Or as transferStalled, Os as createChatInvite, Pa as isAppKey, Pi as peekDhtMailbox, Po as handoffContext, Ps as ENROLL_CAPABILITY, Qa as formatLocalTarget, Qi as dhtEditId, Qn as CarriedTransport, Qo as readHandoffHello, Qs as nextTurnPosition, Qt as SEPOLIA_TEST_USDT_FAUCET, R as parseGroupWakeFrame, Rn as engineError, Ro as handoffEphemeral, Rr as pinIsNewer, Rs as deviceKeyZ32, S as GROUP_READ_NOTE_COMMUNITY, Sa as sameButtonText, Sc as publicKeyFromZ32, Si as receivedIdentityStatus, So as HANDOFF_OFFER, Sr as readBeaconHead, Ss as readEnrollHello, T as encodeCommunityLink, Ta as claimedTime, Tc as sanitizeAvatar, Ti as PeerProofs, To as HANDOFF_REQUEST, Tr as ChatFiles, Ts as deviceInviteJoinerParams, Ua as MEMBER_KEY, Un as fedimintRequestPayload, Uo as handoffReleaseFrame, Ur as deviceWakeRequest, Us as webCryptoSigner, Ut as encodeDidDhtDeactivated, V as GroupSession, Va as GROUP_MEMBER_CAP, Vn as ENDPOINT, Vo as handoffOfferFrame, Vr as WAKE_CALL_INTERVAL_MS, Vs as newDeviceSetSecret, Vt as didDhtDocument, W as GROUP_EDIT_TEXT_BYTES, Wa as rosterHas, Wn as findEndpoint, Wo as handoffRetryAfter, Wr as newWakeToken, Wt as encodeDidDhtPacket, Xa as presenceSeenAt, Xi as EDIT_RATE_WINDOW_MS, Xn as LIVENESS_PING_MS, Xo as readHandoffDone, Xr as chatAppId, Xs as TURN_MAX, Xt as PaymentPreflightError, Ya as RELAY_POLL_INTERVALS, Yi as EDIT_BUFFER_MS, Yn as INVITE_TAKEN, Yo as readHandoffCancel, Yr as vapidKeysMatch, Ys as TURN_LAST_SEQUENCE, Yt as ONCHAIN_PROVIDER, Za as GhostlyHttpError, Zi as RateWindow, Zn as pairedMessageFrame, Zo as readHandoffHave, Zr as isAppVersion, Zs as firstDeviceSetSecret, Zt as SEPOLIA_TEST_USDT, _ as groupTypingEpoch, _o as HANDOFF_ACK, _r as mergeLobby, _s as enrollDoneFrame, aa as ReactionWindow, ac as RelayTransport, ai as HoldRefusedError, an as validatePaymentTarget, ao as RTC_CONFIG, ar as MESH_HUBS, as as readSetUpdate, b as COMMUNITY_LIMITS, bc as identityFromSeed, bi as identityStatement, bo as HANDOFF_HAVE, br as rankHubs, bs as enrollProofFrame, c as APP_BUNDLE_LIMITS, ca as reactionEmoji, ci as CapsExchange, cn as SPARK_PROVIDER, cr as pickMeshHubs, cs as signTombstone, da as validReactionNumber, dc as isDiscoveryBudgetError, di as dialDescriptors, dn as isBitcoinAddress, do as parseCallSignal, dr as beaconRecords, ea as forwardedAgain, ec as signTurnPacket, ei as UPGRADE_CAPABILITY, en as USDT_PUBLIC_RPC, eo as webLocalFetch, es as readHandoffTurnFrame, fa as wireReaction, fc as withRequestOptions, fr as doorHubs, g as GroupTypingBoard, ga as BUTTON_ID, gi as IdentityExchange, go as serviceIdFromName, gr as mergeBeacon, gs as enrollDigits, ha as replySnippet, hc as createRelayPayload, ho as LEGACY_SERVICES, hr as lobbyRecords, hs as enrollCancelReason, ht as normalizeAtprotoHandle, i as planAppUpdate, ia as REACTION_LIMITS, ic as DEFAULT_RELAYS, ii as HoldKeys, in as formatPaymentAmount, ir as readGroupSignal, j as knockRecords, ja as appFingerprint, jc as utf8Decode, jo as handoffAttemptSucceeded, jr as TypingSender, k as entryParams, kc as toBase64Url, ki as automaticTransport, ko as handoffAttemptAllowed, ks as decodeInviteCode, l as APP_BUNDLE_MAGIC, la as reactionIsNewer, lc as traceLink, li as DHT_TEXT_CAPABILITY, ln as sparkAddressKind, lr as COMMUNITY_TOPOLOGY, ls as verifySetUpdate, m as readAppBundle, ma as readReply, mi as IDENTITY_REVOCATION_LABEL, mo as HTTP_SERVICE_PROTO, mr as lobbyKeys, ms as enrollCancelFrame, mt as isAtprotoDid, n as appUpdateDecision, na as COMMUNITY_REACTION_FRAME, nc as turnKeys, ni as HOLD_DATED_BACK_MS, nn as assertTokenUnits, no as decodeControl, ns as sealHandoffPiece, o as readAppStore, oa as nextReactionNumber, oc as currentRelays, oi as newMailbox, on as walletNetworkOf, or as mayBeHub, os as setAckFrame, p as isAppUrl, pa as pairedReplyAuthor, pc as PacketTooLargeError, pn as isPubkyKey, po as emptyLinkRecords, pr as freshHubs, q as mentionsMember, qa as DirectPathWatch, qn as parseFedimintRequestPayload, qo as newHandoffId, qr as wakeRequest, qs as classifyTurnRead, qt as ETHEREUM_USDT, r as checkAppBeforeRun, ra as GROUP_REACTION_FRAME, rc as turnName, ri as HOLD_LIMITS, rn as assertWholeSats, ro as encodeControl, rr as groupSignalFrame, sa as queueReaction, sc as normalizeRelayUrl, si as readManifest, sn as SPARK_NETWORKS, sr as meshHubs, ss as setUpdateFrame, t as appStoreDecision, ta as readForwarded, tc as signTurnRelease, ti as HOLD_CAPABILITY, tn as WALLET_NETWORKS, to as LIMITS, ts as readHandoffVerified, u as appDigest, ua as readReaction, uc as budgetRetryMs, ui as capsDescriptors, un as sparkInvoiceDetails, ur as beaconKeys, v as openGroupTyping, vi as emptyIdentityLedger, vo as HANDOFF_CANCEL, vr as newerHead, vs as enrollGrantFrame, w as decodeCommunityLink, wa as arrivalKey, wi as lastSharedWithMe, wo as HANDOFF_PIECE_BYTES, wr as shouldBeHub, ws as createDeviceInvite, x as CommunitySession, xa as checkStatusCard, xc as identityFromSeedB64, xi as newIdentityBinding, xo as HANDOFF_HELLO, xr as readBeacon, xs as readEnrollGrant, y as sealGroupTyping, ya as STATUS_CARD_LIMITS, yc as createIdentity, yi as identityRevocationValue, yo as HANDOFF_DONE, yr as pickHubs, ys as enrollHelloFrame, za as canonicalJson, zi as safeBlobType, zn as engineText, zo as handoffHelloFrame, zr as pinNumberHolds, zs as deviceLinkPairing } from "./src-C6qQYrAH.js";
|
|
3
|
+
import { a as clearProfileStores, c as fileStore, d as store, f as transact, i as STORES$1, l as openDb, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as wrap, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as profileOpenFailure } from "./idb-BIdus0WX.js";
|
|
4
|
+
import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-3AQ2tJzW.js";
|
|
5
|
+
import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as streamStored, B as networkLabel, C as breezDatabasesFor, Ct as replyTo, D as sourceKey, Dt as readStored$1, E as ProviderSources, Et as FileAppender, F as sealSeed, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, N as intentRepository, O as awayFrom, Ot as removeStored, P as newDeviceKey, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as replyRef, T as lightningPaying, Tt as paymentWireId, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as pairedWireReply, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, h as rpcHost, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, k as refuseAway, kt as storedSize, l as createTiming, lt as SourceUnreachableError, m as networkReason, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as paymentAlias, x as breezDatabase, xt as receivedPairedReply, y as isRecoveryPhrase, yt as groupReply, z as WrongNetworkError } from "../ghostly.mjs";
|
|
6
|
+
import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
|
|
5
7
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
6
8
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
7
9
|
import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
|
|
@@ -190,6 +192,27 @@ var erc20 = new Interface([
|
|
|
190
192
|
]);
|
|
191
193
|
var same$1 = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
192
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");
|
|
193
216
|
/**
|
|
194
217
|
* The `provider` a request carries to the contact. Which RPC pays is the payer's own choice, so it names none of
|
|
195
218
|
* this wallet's: a typed RPC URL may hold an API key (`/v3/<key>`), and every contact would read it. Older apps
|
|
@@ -259,7 +282,22 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
259
282
|
adapter.account = await adapter.manager.getAccount(0);
|
|
260
283
|
return adapter;
|
|
261
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
|
+
*/
|
|
262
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) {
|
|
263
301
|
let response;
|
|
264
302
|
try {
|
|
265
303
|
response = await fetch(this.config.provider, {
|
|
@@ -275,9 +313,15 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
275
313
|
});
|
|
276
314
|
} catch (error) {
|
|
277
315
|
const reason = networkReason(error, rpcHost(this.config.provider));
|
|
278
|
-
|
|
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;
|
|
279
324
|
}
|
|
280
|
-
if (!response.ok) throw engineError("usdtRpcUnavailable");
|
|
281
325
|
const result = await response.json();
|
|
282
326
|
if (result.error || !Object.prototype.hasOwnProperty.call(result, "result")) throw new Error("USDT RPC rejected the operation");
|
|
283
327
|
return result.result;
|
|
@@ -1259,6 +1303,12 @@ var ArkadeAdapter = class ArkadeAdapter {
|
|
|
1259
1303
|
};
|
|
1260
1304
|
//#endregion
|
|
1261
1305
|
//#region ../browser/src/engine/paymentAdapters/backup.ts
|
|
1306
|
+
/**
|
|
1307
|
+
* Wallet database versions a backup may carry. 3 is what SDK 0.4.74-0.4.76 made (Ghostly 1.1.3 and earlier);
|
|
1308
|
+
* 4 is SDK 0.4.78's, which adds the vtxos `scriptUnspent` index. An older one is restored at its own version
|
|
1309
|
+
* and the SDK upgrades it the first time the wallet opens. arkBackupSchema.test.ts fails when the SDK moves on.
|
|
1310
|
+
*/
|
|
1311
|
+
var ARK_DB_VERSIONS = [3, 4];
|
|
1262
1312
|
var allowed = /* @__PURE__ */ new Set([
|
|
1263
1313
|
"vtxos",
|
|
1264
1314
|
"utxos",
|
|
@@ -1315,7 +1365,7 @@ async function snapshotArkDatabase(walletId) {
|
|
|
1315
1365
|
}
|
|
1316
1366
|
/** Only imports into a freshly allocated database; never replaces an existing wallet. */
|
|
1317
1367
|
async function restoreArkDatabase(walletId, snapshot) {
|
|
1318
|
-
if (snapshot.version
|
|
1368
|
+
if (!ARK_DB_VERSIONS.includes(snapshot.version) || !Array.isArray(snapshot.stores) || snapshot.stores.length > allowed.size || new Set(snapshot.stores.map((s) => s.name)).size !== snapshot.stores.length || snapshot.stores.some((s) => !allowed.has(s.name) || !Array.isArray(s.values) || !Array.isArray(s.keys) || s.values.length !== s.keys.length)) throw new Error("Unsupported Ark backup schema");
|
|
1319
1369
|
for (const name of [
|
|
1320
1370
|
"vtxos",
|
|
1321
1371
|
"utxos",
|
|
@@ -3330,7 +3380,7 @@ var loading$2;
|
|
|
3330
3380
|
/** Loads the WebAssembly once, on first use: profiles that never pick Breez never fetch its 12.8 MB. */
|
|
3331
3381
|
function loadBreezSdk() {
|
|
3332
3382
|
return loading$2 ??= (async () => {
|
|
3333
|
-
const breez = await import("./web-
|
|
3383
|
+
const breez = await import("./web-DwgdhfcY.js");
|
|
3334
3384
|
await breez.default();
|
|
3335
3385
|
return { async connect({ network, mnemonic, apiKey, storage }) {
|
|
3336
3386
|
const config = breez.defaultConfig(network);
|
|
@@ -6127,7 +6177,7 @@ var COMMANDO_NOT_RUN = {
|
|
|
6127
6177
|
};
|
|
6128
6178
|
var MAX_REPLY = 4194304;
|
|
6129
6179
|
/** One websocket frame may carry many Lightning messages; one this large is not an answer to anything. */
|
|
6130
|
-
var MAX_FRAME
|
|
6180
|
+
var MAX_FRAME = 4259840;
|
|
6131
6181
|
var text$1 = new TextDecoder();
|
|
6132
6182
|
/**
|
|
6133
6183
|
* One connection to a node, opened on first use and again after it drops. Calls run concurrently, matched
|
|
@@ -6231,7 +6281,7 @@ var CommandoClient = class {
|
|
|
6231
6281
|
ws.onmessage = ({ data }) => {
|
|
6232
6282
|
if (!(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) return fail("The node sent something that is not binary");
|
|
6233
6283
|
const chunk = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
6234
|
-
if (chunk.length > MAX_FRAME
|
|
6284
|
+
if (chunk.length > MAX_FRAME) return fail("The node sent more than it was asked for");
|
|
6235
6285
|
buffer = concatBytes(buffer, chunk);
|
|
6236
6286
|
try {
|
|
6237
6287
|
if (stage === "act2") {
|
|
@@ -8432,6 +8482,16 @@ var REVOCATIONS = "identityRevocations";
|
|
|
8432
8482
|
var REPUBLISH_MS$1 = 36e5;
|
|
8433
8483
|
/** How often the contacts' proofs are looked up for a revocation (and once shortly after start). */
|
|
8434
8484
|
var REVOCATION_CHECK_MS = 432e5;
|
|
8485
|
+
/**
|
|
8486
|
+
* After a contact withdraws a proof, how long to wait before each lookup for its revocation (WISP 300: removing a proof withdraws it from
|
|
8487
|
+
* every chat and revokes it). The notice goes out before the revocation is published, so not at once; each try backs
|
|
8488
|
+
* off, and the first that finds it stops the rest. The 12 h check covers it after that.
|
|
8489
|
+
*/
|
|
8490
|
+
var WITHDRAWN_REVOCATION_TRIES_MS = [
|
|
8491
|
+
2e4,
|
|
8492
|
+
6e4,
|
|
8493
|
+
18e4
|
|
8494
|
+
];
|
|
8435
8495
|
var DRAFT_TTL = 18e5;
|
|
8436
8496
|
var MAX_DRAFTS$1 = 4;
|
|
8437
8497
|
var MAX_PROOFS = 32;
|
|
@@ -8644,10 +8704,31 @@ var IdentityProofs = class {
|
|
|
8644
8704
|
}
|
|
8645
8705
|
for (const r of live) await this.publishRevocation(r).catch(() => {});
|
|
8646
8706
|
}
|
|
8647
|
-
/**
|
|
8707
|
+
/**
|
|
8708
|
+
* Looks up the contacts' proofs for a revocation (the Pkarr record only, not the provider): the ones standing, and the
|
|
8709
|
+
* ones withdrawn, which a removal also revokes. Not a proof already revoked, nor one past its expiry: its owner
|
|
8710
|
+
* publishes the revocation only until then.
|
|
8711
|
+
*/
|
|
8648
8712
|
async checkRevocations() {
|
|
8649
8713
|
if (!this.host.online()) return;
|
|
8650
|
-
for (const linkId of this.host.linkIds()) for (const r of this.host.ledger(linkId)?.received ?? []) if (
|
|
8714
|
+
for (const linkId of this.host.linkIds()) for (const r of this.host.ledger(linkId)?.received ?? []) if (this.revocable(r)) await this.exchange(linkId)?.recheck(r.id, { revocationOnly: true }).catch(() => {});
|
|
8715
|
+
}
|
|
8716
|
+
revocable(r) {
|
|
8717
|
+
return r.status !== "revoked" && Math.min(r.binding.expiresAt, r.verified.expiresAt ?? Infinity) > now();
|
|
8718
|
+
}
|
|
8719
|
+
/** A proof the contact just withdrew: looked up for its revocation a few times (`WITHDRAWN_REVOCATION_TRIES_MS`). */
|
|
8720
|
+
afterWithdrawal(linkId, id, tries = WITHDRAWN_REVOCATION_TRIES_MS) {
|
|
8721
|
+
const [wait, ...rest] = tries;
|
|
8722
|
+
if (wait === void 0) return;
|
|
8723
|
+
const timer = setTimeout(() => {
|
|
8724
|
+
this.timers = this.timers.filter((t) => t !== timer);
|
|
8725
|
+
const r = this.host.ledger(linkId)?.received.find((x) => x.id === id);
|
|
8726
|
+
if (!r || r.status !== "withdrawn" || !this.revocable(r)) return;
|
|
8727
|
+
(async () => {
|
|
8728
|
+
if ((this.host.online() ? await this.exchange(linkId)?.recheck(id, { revocationOnly: true }).catch(() => void 0) : void 0)?.status !== "revoked") this.afterWithdrawal(linkId, id, rest);
|
|
8729
|
+
})();
|
|
8730
|
+
}, wait);
|
|
8731
|
+
this.timers.push(timer);
|
|
8651
8732
|
}
|
|
8652
8733
|
views() {
|
|
8653
8734
|
return this.proofs.map((p) => this.viewOf(p));
|
|
@@ -8751,8 +8832,12 @@ var IdentityProofs = class {
|
|
|
8751
8832
|
}));
|
|
8752
8833
|
this.host.emit();
|
|
8753
8834
|
}
|
|
8754
|
-
frame(linkId, frame) {
|
|
8755
|
-
|
|
8835
|
+
async frame(linkId, frame) {
|
|
8836
|
+
const x = this.exchange(linkId);
|
|
8837
|
+
if (!x) return;
|
|
8838
|
+
const withdrawn = frame.t === "idp-withdraw" && typeof frame.id === "string" && this.host.ledger(linkId)?.received.some((r) => r.id === frame.id && r.status !== "withdrawn" && r.status !== "revoked");
|
|
8839
|
+
await x.receive(frame);
|
|
8840
|
+
if (withdrawn && this.host.ledger(linkId)?.received.some((r) => r.id === frame.id && r.status === "withdrawn")) this.afterWithdrawal(linkId, frame.id);
|
|
8756
8841
|
}
|
|
8757
8842
|
ready(linkId) {
|
|
8758
8843
|
if (this.greeted.has(linkId) || !this.host.channel(linkId)) return;
|
|
@@ -11620,6 +11705,1159 @@ var eachNetwork = (value) => "mainnet" in value && "testnet" in value ? value :
|
|
|
11620
11705
|
testnet: value
|
|
11621
11706
|
};
|
|
11622
11707
|
//#endregion
|
|
11708
|
+
//#region ../browser/src/engine/appFetch.ts
|
|
11709
|
+
/** The hosts phase 1 reads from. A jsDelivr URL must also name a 40-character commit (`isAppUrl`). */
|
|
11710
|
+
var APP_FETCH_HOSTS = ["raw.githubusercontent.com", "cdn.jsdelivr.net"];
|
|
11711
|
+
var APP_FETCH_LIMITS = {
|
|
11712
|
+
/**
|
|
11713
|
+
* A store index as read (WISP 1200 § Stores, Privacy): 4 MiB in phase 1, under the format's 16 MiB. Counted on the body
|
|
11714
|
+
* as it arrives, after the browser undid any compression, since a page never sees the compressed size.
|
|
11715
|
+
*/
|
|
11716
|
+
storeIndexBytes: 4194304,
|
|
11717
|
+
/** A store's signature statement, `ghostly-store.sig` (canonical, at most 1 KiB). */
|
|
11718
|
+
sigBytes: 1024,
|
|
11719
|
+
/** `ghostly-revoke.json`: at most 4096 signed revocations. */
|
|
11720
|
+
revocationsBytes: 16777216,
|
|
11721
|
+
timeoutMs: 3e4
|
|
11722
|
+
};
|
|
11723
|
+
/** A URL this client may read an app, a store or a revocation list from. */
|
|
11724
|
+
function isAppFetchUrl(value) {
|
|
11725
|
+
if (!isAppUrl(value)) return false;
|
|
11726
|
+
const url = new URL(value);
|
|
11727
|
+
return url.port === "" && APP_FETCH_HOSTS.includes(url.hostname.toLowerCase());
|
|
11728
|
+
}
|
|
11729
|
+
var AppFetchError = class extends Error {
|
|
11730
|
+
code;
|
|
11731
|
+
status;
|
|
11732
|
+
constructor(code, message, status) {
|
|
11733
|
+
super(`${code}: ${message}`);
|
|
11734
|
+
this.code = code;
|
|
11735
|
+
this.status = status;
|
|
11736
|
+
this.name = "AppFetchError";
|
|
11737
|
+
}
|
|
11738
|
+
};
|
|
11739
|
+
function boundedAppFetch(options = {}) {
|
|
11740
|
+
return async (url, { maxBytes, peek = false }) => {
|
|
11741
|
+
if (!isAppFetchUrl(url)) throw new AppFetchError("host", "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
|
|
11742
|
+
if (options.online && !options.online()) throw new AppFetchError("offline", "Offline");
|
|
11743
|
+
const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
|
|
11744
|
+
let response;
|
|
11745
|
+
try {
|
|
11746
|
+
response = await fetcher(url, {
|
|
11747
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? APP_FETCH_LIMITS.timeoutMs),
|
|
11748
|
+
credentials: "omit",
|
|
11749
|
+
referrerPolicy: "no-referrer",
|
|
11750
|
+
cache: "no-store",
|
|
11751
|
+
redirect: "error",
|
|
11752
|
+
...peek && { headers: { range: `bytes=0-${maxBytes - 1}` } }
|
|
11753
|
+
});
|
|
11754
|
+
} catch (error) {
|
|
11755
|
+
throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
|
|
11756
|
+
}
|
|
11757
|
+
if (response.url && response.url !== url && !isAppFetchUrl(response.url)) throw new AppFetchError("host", "The host sent the request elsewhere");
|
|
11758
|
+
if (!response.ok) {
|
|
11759
|
+
await response.body?.cancel().catch(() => {});
|
|
11760
|
+
throw new AppFetchError("status", `The host answered ${response.status}`, response.status);
|
|
11761
|
+
}
|
|
11762
|
+
if (!peek && Number(response.headers.get("content-length")) > maxBytes) {
|
|
11763
|
+
await response.body?.cancel().catch(() => {});
|
|
11764
|
+
throw new AppFetchError("too-large", "Too large");
|
|
11765
|
+
}
|
|
11766
|
+
const parts = [];
|
|
11767
|
+
let length = 0;
|
|
11768
|
+
const reader = response.body?.getReader();
|
|
11769
|
+
if (reader) try {
|
|
11770
|
+
for (;;) {
|
|
11771
|
+
const { done, value } = await reader.read();
|
|
11772
|
+
if (done) break;
|
|
11773
|
+
if (length + value.length > maxBytes) {
|
|
11774
|
+
if (!peek) throw new AppFetchError("too-large", "Too large");
|
|
11775
|
+
parts.push(value.subarray(0, maxBytes - length));
|
|
11776
|
+
length = maxBytes;
|
|
11777
|
+
break;
|
|
11778
|
+
}
|
|
11779
|
+
parts.push(value);
|
|
11780
|
+
length += value.length;
|
|
11781
|
+
}
|
|
11782
|
+
} catch (error) {
|
|
11783
|
+
if (error instanceof AppFetchError) throw error;
|
|
11784
|
+
throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
|
|
11785
|
+
} finally {
|
|
11786
|
+
await reader.cancel().catch(() => {});
|
|
11787
|
+
}
|
|
11788
|
+
const bytes = new Uint8Array(length);
|
|
11789
|
+
let at = 0;
|
|
11790
|
+
for (const part of parts) {
|
|
11791
|
+
bytes.set(part, at);
|
|
11792
|
+
at += part.length;
|
|
11793
|
+
}
|
|
11794
|
+
return bytes;
|
|
11795
|
+
};
|
|
11796
|
+
}
|
|
11797
|
+
var NAME = /^[A-Za-z0-9_.-]+$/;
|
|
11798
|
+
/**
|
|
11799
|
+
* The URL a pasted address reads `file` from (WISP 1200 § Paste a URL), or null when it is not one this client reads:
|
|
11800
|
+
* - `https://github.com/<owner>/<repo>`, or `/tree/<ref>`: `raw.githubusercontent.com/<owner>/<repo>/<ref or HEAD>/<file>`,
|
|
11801
|
+
* with no request to `github.com` or `api.github.com`;
|
|
11802
|
+
* - a file as GitHub shows it, `https://github.com/<owner>/<repo>/blob/<ref>/<path>` (or `/raw/`), naming a `.ghostlyapp`
|
|
11803
|
+
* or `.json` file: `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`, again with no request to `github.com`;
|
|
11804
|
+
* - a URL of the list that names a file (`….ghostlyapp`, `….json`): itself;
|
|
11805
|
+
* - a URL of the list that names a folder: `<file>` in it.
|
|
11806
|
+
*/
|
|
11807
|
+
function appPasteUrl(input, file) {
|
|
11808
|
+
let url;
|
|
11809
|
+
try {
|
|
11810
|
+
url = new URL(input.trim());
|
|
11811
|
+
} catch {
|
|
11812
|
+
return null;
|
|
11813
|
+
}
|
|
11814
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
|
11815
|
+
const host = url.hostname.toLowerCase();
|
|
11816
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
11817
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
11818
|
+
if (parts.length < 2) return null;
|
|
11819
|
+
const owner = parts[0], repo = parts[1].replace(/\.git$/, "");
|
|
11820
|
+
if (!NAME.test(owner) || !NAME.test(repo)) return null;
|
|
11821
|
+
let ref = "HEAD";
|
|
11822
|
+
if (parts.length > 2) {
|
|
11823
|
+
const rest = parts.slice(3);
|
|
11824
|
+
if (!rest.length || !rest.every((p) => NAME.test(p) && p !== "." && p !== "..")) return null;
|
|
11825
|
+
if (parts[2] === "blob" || parts[2] === "raw") {
|
|
11826
|
+
if (rest.length < 2 || !/\.(ghostlyapp|json)$/.test(rest[rest.length - 1])) return null;
|
|
11827
|
+
const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${rest.join("/")}`;
|
|
11828
|
+
return isAppFetchUrl(raw) ? raw : null;
|
|
11829
|
+
}
|
|
11830
|
+
if (parts[2] !== "tree") return null;
|
|
11831
|
+
ref = rest.join("/");
|
|
11832
|
+
}
|
|
11833
|
+
const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${file}`;
|
|
11834
|
+
return isAppFetchUrl(raw) ? raw : null;
|
|
11835
|
+
}
|
|
11836
|
+
const last = url.pathname.split("/").pop() ?? "";
|
|
11837
|
+
const named = /\.(ghostlyapp|json)$/.test(last) ? url.href : new URL(`${url.pathname.replace(/\/?$/, "/")}${file}`, url).href;
|
|
11838
|
+
return isAppFetchUrl(named) ? named : null;
|
|
11839
|
+
}
|
|
11840
|
+
/** A page of github.com (any scheme): a link `appPasteUrl` could not read is still GitHub, and is answered as such. */
|
|
11841
|
+
function isGitHubPage(input) {
|
|
11842
|
+
try {
|
|
11843
|
+
const host = new URL(input.trim()).hostname.toLowerCase();
|
|
11844
|
+
return host === "github.com" || host === "www.github.com";
|
|
11845
|
+
} catch {
|
|
11846
|
+
return false;
|
|
11847
|
+
}
|
|
11848
|
+
}
|
|
11849
|
+
/** A file beside another (`ghostly-store.sig` beside the index, `ghostly-revoke.json` beside a bundle). */
|
|
11850
|
+
function besideUrl(url, name) {
|
|
11851
|
+
const target = new URL(url);
|
|
11852
|
+
target.search = "";
|
|
11853
|
+
target.hash = "";
|
|
11854
|
+
return new URL(name, target).href;
|
|
11855
|
+
}
|
|
11856
|
+
/**
|
|
11857
|
+
* The stores a new profile starts with (WISP 1200 § Stores: the default store, preloaded and removable), each a URL and
|
|
11858
|
+
* the store key it is pinned to. Preloading one makes no request; the first read comes when the person opens the Apps
|
|
11859
|
+
* page or once an app is installed.
|
|
11860
|
+
*/
|
|
11861
|
+
var DEFAULT_APP_STORES = [{
|
|
11862
|
+
url: "https://raw.githubusercontent.com/MiguelMedeiros/ghostly-store/HEAD/ghostly-store.json",
|
|
11863
|
+
key: "y379ia3t1urwudj8o4w1qwmuwp1mcxyf956b5r7pqup7stce6diy"
|
|
11864
|
+
}];
|
|
11865
|
+
var APP_STORAGE_LIMITS = {
|
|
11866
|
+
/** A key, in UTF-8 bytes. */
|
|
11867
|
+
keyBytes: 256,
|
|
11868
|
+
/** A value, as compact JSON in UTF-8 bytes. */
|
|
11869
|
+
valueBytes: 65536,
|
|
11870
|
+
/** Every key and value of one app in one scope. */
|
|
11871
|
+
scopeBytes: 5242880
|
|
11872
|
+
};
|
|
11873
|
+
var APP_CHECK_TIMINGS = {
|
|
11874
|
+
/** After the engine starts, so the chats come up first. */
|
|
11875
|
+
afterStartMs: 6e4,
|
|
11876
|
+
everyMs: 864e5,
|
|
11877
|
+
/** A check at start is skipped when every app was checked this recently (a page reloaded several times). */
|
|
11878
|
+
freshMs: 36e5
|
|
11879
|
+
};
|
|
11880
|
+
/** A fetched bundle kept for its install screen: at most this many, for this long. */
|
|
11881
|
+
var STAGED = {
|
|
11882
|
+
max: 4,
|
|
11883
|
+
ms: 6e5
|
|
11884
|
+
};
|
|
11885
|
+
/** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
|
|
11886
|
+
var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
|
|
11887
|
+
var bundleId = (digest) => `app-${digest}`;
|
|
11888
|
+
/** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
|
|
11889
|
+
function fail(code, words) {
|
|
11890
|
+
throw new Error(`${code}: ${words}`);
|
|
11891
|
+
}
|
|
11892
|
+
/**
|
|
11893
|
+
* Why a pasted link is not read: a github.com page that is neither a repository nor a file of one (`github-link`), so
|
|
11894
|
+
* the person is not told GitHub is off the list; any other host (`host`).
|
|
11895
|
+
*/
|
|
11896
|
+
function notReadable(url, words) {
|
|
11897
|
+
if (typeof url === "string" && isGitHubPage(url)) fail("github-link", "Paste the repository's link, or the link to its app file");
|
|
11898
|
+
fail("host", words);
|
|
11899
|
+
}
|
|
11900
|
+
var versionOf = (manifest, digest) => ({
|
|
11901
|
+
ref: appRef(manifest.publisher, manifest.name),
|
|
11902
|
+
sequence: manifest.sequence,
|
|
11903
|
+
digest
|
|
11904
|
+
});
|
|
11905
|
+
var byteLength = (text) => utf8Encode(text).length;
|
|
11906
|
+
/** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
|
|
11907
|
+
var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
|
|
11908
|
+
function done(tx) {
|
|
11909
|
+
return new Promise((resolve, reject) => {
|
|
11910
|
+
tx.oncomplete = () => resolve();
|
|
11911
|
+
tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("App storage failed"));
|
|
11912
|
+
});
|
|
11913
|
+
}
|
|
11914
|
+
/**
|
|
11915
|
+
* The manifest of a bundle's first bytes, unchecked: only to see whether a source holds something newer before reading
|
|
11916
|
+
* all of it. Whatever it says, the whole bundle is checked before anything is kept.
|
|
11917
|
+
*/
|
|
11918
|
+
function peekAppManifest(bytes) {
|
|
11919
|
+
const magic = utf8Encode(APP_BUNDLE_MAGIC);
|
|
11920
|
+
if (bytes.length < magic.length + 4 || !magic.every((b, i) => bytes[i] === b)) return null;
|
|
11921
|
+
const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(magic.length);
|
|
11922
|
+
if (length > APP_BUNDLE_LIMITS.manifestBytes || bytes.length < magic.length + 4 + length) return null;
|
|
11923
|
+
const manifestBytes = bytes.subarray(magic.length + 4, magic.length + 4 + length);
|
|
11924
|
+
try {
|
|
11925
|
+
const manifest = JSON.parse(utf8Decode(manifestBytes));
|
|
11926
|
+
const ref = typeof manifest.publisher === "string" && typeof manifest.name === "string" ? appRef(manifest.publisher, manifest.name) : "";
|
|
11927
|
+
if (!isAppRef(ref) || typeof manifest.sequence !== "number" || !Number.isSafeInteger(manifest.sequence)) return null;
|
|
11928
|
+
return {
|
|
11929
|
+
ref,
|
|
11930
|
+
sequence: manifest.sequence,
|
|
11931
|
+
digest: appDigest(manifestBytes)
|
|
11932
|
+
};
|
|
11933
|
+
} catch {
|
|
11934
|
+
return null;
|
|
11935
|
+
}
|
|
11936
|
+
}
|
|
11937
|
+
var Apps = class {
|
|
11938
|
+
host;
|
|
11939
|
+
staged = /* @__PURE__ */ new Map();
|
|
11940
|
+
/** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
|
|
11941
|
+
verified = /* @__PURE__ */ new Map();
|
|
11942
|
+
/** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
|
|
11943
|
+
usage = /* @__PURE__ */ new Map();
|
|
11944
|
+
queues = /* @__PURE__ */ new Map();
|
|
11945
|
+
timer = null;
|
|
11946
|
+
checking = null;
|
|
11947
|
+
/** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
|
|
11948
|
+
anyway = /* @__PURE__ */ new Set();
|
|
11949
|
+
stopped = false;
|
|
11950
|
+
constructor(host) {
|
|
11951
|
+
this.host = host;
|
|
11952
|
+
}
|
|
11953
|
+
now() {
|
|
11954
|
+
return (this.host.now ?? Date.now)();
|
|
11955
|
+
}
|
|
11956
|
+
nowS() {
|
|
11957
|
+
return Math.floor(this.now() / 1e3);
|
|
11958
|
+
}
|
|
11959
|
+
/** Preloads the default stores (no request), then checks for updates soon after start and every 24 hours. */
|
|
11960
|
+
async start() {
|
|
11961
|
+
this.stopped = false;
|
|
11962
|
+
await this.seedDefaults();
|
|
11963
|
+
this.schedule(APP_CHECK_TIMINGS.afterStartMs, true);
|
|
11964
|
+
}
|
|
11965
|
+
stop() {
|
|
11966
|
+
this.stopped = true;
|
|
11967
|
+
if (this.timer) clearTimeout(this.timer);
|
|
11968
|
+
this.timer = null;
|
|
11969
|
+
}
|
|
11970
|
+
schedule(ms, atStart = false) {
|
|
11971
|
+
if (this.timer) clearTimeout(this.timer);
|
|
11972
|
+
this.timer = setTimeout(() => {
|
|
11973
|
+
this.timer = null;
|
|
11974
|
+
(async () => {
|
|
11975
|
+
if (this.stopped) return;
|
|
11976
|
+
if (this.host.online()) {
|
|
11977
|
+
const apps = await this.installed();
|
|
11978
|
+
const fresh = atStart && apps.length > 0 && apps.every((a) => a.checkedAt !== void 0 && this.now() - a.checkedAt < APP_CHECK_TIMINGS.freshMs);
|
|
11979
|
+
if (apps.length && !fresh) await this.checkUpdates().catch(() => {});
|
|
11980
|
+
}
|
|
11981
|
+
if (!this.stopped) this.schedule(APP_CHECK_TIMINGS.everyMs);
|
|
11982
|
+
})();
|
|
11983
|
+
}, ms);
|
|
11984
|
+
}
|
|
11985
|
+
async seedDefaults() {
|
|
11986
|
+
for (const d of this.host.defaults ?? DEFAULT_APP_STORES) {
|
|
11987
|
+
if (!isAppKey(d.key) || !isAppFetchUrl(d.url)) continue;
|
|
11988
|
+
if (await this.storeRecord(d.key)) continue;
|
|
11989
|
+
await this.putStore({
|
|
11990
|
+
key: d.key,
|
|
11991
|
+
url: d.url,
|
|
11992
|
+
addedAt: this.now(),
|
|
11993
|
+
preloaded: true
|
|
11994
|
+
});
|
|
11995
|
+
}
|
|
11996
|
+
}
|
|
11997
|
+
async installed() {
|
|
11998
|
+
return wrap((await store(STORES$1.apps, "readonly")).getAll());
|
|
11999
|
+
}
|
|
12000
|
+
async app(ref) {
|
|
12001
|
+
return wrap((await store(STORES$1.apps, "readonly")).get(ref));
|
|
12002
|
+
}
|
|
12003
|
+
async installedOrFail(ref) {
|
|
12004
|
+
if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
|
|
12005
|
+
return await this.app(ref) ?? fail("not-installed", "This app is not installed");
|
|
12006
|
+
}
|
|
12007
|
+
/**
|
|
12008
|
+
* An installed app that may still run (WISP 1200 § Takedowns): a running app whose version its publisher revoked, or
|
|
12009
|
+
* a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
|
|
12010
|
+
* or not the page stops its frame.
|
|
12011
|
+
*/
|
|
12012
|
+
async stillRunnable(app) {
|
|
12013
|
+
const run = this.runStatus(app, await this.stores(), app.revocations);
|
|
12014
|
+
if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
|
|
12015
|
+
if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12016
|
+
return app;
|
|
12017
|
+
}
|
|
12018
|
+
async runningOrFail(ref) {
|
|
12019
|
+
return this.stillRunnable(await this.installedOrFail(ref));
|
|
12020
|
+
}
|
|
12021
|
+
/**
|
|
12022
|
+
* Before an app's frames go to a contact (`appOpen`, `appSend`): refused with `stopped` as its storage is. A reference
|
|
12023
|
+
* not installed here (a bot that speaks an app's messages) is not this store's to refuse.
|
|
12024
|
+
*/
|
|
12025
|
+
async chatRunnable({ ref }) {
|
|
12026
|
+
const app = isAppRef(ref) ? await this.app(ref) : void 0;
|
|
12027
|
+
if (app) await this.stillRunnable(app);
|
|
12028
|
+
}
|
|
12029
|
+
async putApp(app) {
|
|
12030
|
+
await wrap((await store(STORES$1.apps, "readwrite")).put(app));
|
|
12031
|
+
}
|
|
12032
|
+
async storeRecord(key) {
|
|
12033
|
+
return wrap((await store(STORES$1.appStores, "readonly")).get(key));
|
|
12034
|
+
}
|
|
12035
|
+
async putStore(record) {
|
|
12036
|
+
await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
|
|
12037
|
+
}
|
|
12038
|
+
/** The stores the person has (a removed default is not one). */
|
|
12039
|
+
async stores() {
|
|
12040
|
+
return (await wrap((await store(STORES$1.appStores, "readonly")).getAll())).filter((s) => !s.removed);
|
|
12041
|
+
}
|
|
12042
|
+
storeViews(stores) {
|
|
12043
|
+
return stores.filter((s) => s.index).map((s) => ({
|
|
12044
|
+
key: s.key,
|
|
12045
|
+
name: s.index.name,
|
|
12046
|
+
kind: s.index.kind,
|
|
12047
|
+
removed: s.index.removed,
|
|
12048
|
+
revoked: s.index.revoked
|
|
12049
|
+
}));
|
|
12050
|
+
}
|
|
12051
|
+
listedBy(ref, stores) {
|
|
12052
|
+
return stores.filter((s) => s.index?.apps.some((a) => a.ref === ref)).map((s) => ({
|
|
12053
|
+
key: s.key,
|
|
12054
|
+
name: s.index.name,
|
|
12055
|
+
kind: s.index.kind
|
|
12056
|
+
}));
|
|
12057
|
+
}
|
|
12058
|
+
runStatus(version, stores, revocations = []) {
|
|
12059
|
+
const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
|
|
12060
|
+
if (check.status === "revoked") return {
|
|
12061
|
+
status: "revoked",
|
|
12062
|
+
...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
|
|
12063
|
+
};
|
|
12064
|
+
return check;
|
|
12065
|
+
}
|
|
12066
|
+
/**
|
|
12067
|
+
* Stores a bundle's bytes. A browser that keeps no files fails here: Safari's Private Browsing (and WebKit's in-memory
|
|
12068
|
+
* contexts) refuses a Blob in IndexedDB with "Error preparing Blob/File data to be stored in object store". Any such
|
|
12069
|
+
* failure is `storage`, with the browser's own words after it for the logs, and leaves no piece behind.
|
|
12070
|
+
*/
|
|
12071
|
+
async writeBundle(digest, bytes) {
|
|
12072
|
+
const id = bundleId(digest);
|
|
12073
|
+
const expected = toBase64Url(sha256(bytes));
|
|
12074
|
+
let files;
|
|
12075
|
+
let stored;
|
|
12076
|
+
try {
|
|
12077
|
+
files = await fileBytes();
|
|
12078
|
+
const size = await files.size(id);
|
|
12079
|
+
if (size === bytes.length && await files.digest(id) === expected) return files.kind;
|
|
12080
|
+
if (size !== null) await files.remove(id);
|
|
12081
|
+
for (let at = 0; at < bytes.length; at += FILE_BYTES_STEP) await files.append(id, at, bytes.subarray(at, at + FILE_BYTES_STEP));
|
|
12082
|
+
await files.flush(id);
|
|
12083
|
+
await files.close(id);
|
|
12084
|
+
stored = await files.digest(id);
|
|
12085
|
+
} catch (e) {
|
|
12086
|
+
await files?.close(id).catch(() => {});
|
|
12087
|
+
await files?.remove(id).catch(() => {});
|
|
12088
|
+
const words = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
12089
|
+
console.warn("[apps] storage:", words);
|
|
12090
|
+
fail("storage", `The app's files could not be stored on this device (${words})`);
|
|
12091
|
+
}
|
|
12092
|
+
if (stored !== expected) {
|
|
12093
|
+
await files.remove(id).catch(() => {});
|
|
12094
|
+
fail("storage", "The app's files could not be stored on this device");
|
|
12095
|
+
}
|
|
12096
|
+
return files.kind;
|
|
12097
|
+
}
|
|
12098
|
+
async readBundleBytes(kind, digest) {
|
|
12099
|
+
const files = await fileBytesOf(kind);
|
|
12100
|
+
const id = bundleId(digest);
|
|
12101
|
+
const size = files ? await files.size(id) : null;
|
|
12102
|
+
if (!files || size === null || size > APP_BUNDLE_LIMITS.bundleBytes) return null;
|
|
12103
|
+
const out = new Uint8Array(size);
|
|
12104
|
+
for (let at = 0; at < size;) {
|
|
12105
|
+
const part = await files.read(id, at, Math.min(FILE_BYTES_STEP, size - at));
|
|
12106
|
+
if (!part.length) return null;
|
|
12107
|
+
out.set(part, at);
|
|
12108
|
+
at += part.length;
|
|
12109
|
+
}
|
|
12110
|
+
return out;
|
|
12111
|
+
}
|
|
12112
|
+
async hasBundle(kind, digest) {
|
|
12113
|
+
const files = await fileBytesOf(kind);
|
|
12114
|
+
return !!files && await files.size(bundleId(digest)) !== null;
|
|
12115
|
+
}
|
|
12116
|
+
async removeBundle(kind, digest) {
|
|
12117
|
+
this.verified.delete(digest);
|
|
12118
|
+
await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
|
|
12119
|
+
}
|
|
12120
|
+
/** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
|
|
12121
|
+
async verifiedBundle(app) {
|
|
12122
|
+
const kept = this.verified.get(app.digest);
|
|
12123
|
+
if (kept) return kept;
|
|
12124
|
+
const bytes = await this.readBundleBytes(app.bytes, app.digest);
|
|
12125
|
+
if (!bytes) fail("needs-files", "This app's files are not on this device yet");
|
|
12126
|
+
const read = readAppBundle(bytes);
|
|
12127
|
+
if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
|
|
12128
|
+
if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
|
|
12129
|
+
this.verified.set(app.digest, read.bundle);
|
|
12130
|
+
return read.bundle;
|
|
12131
|
+
}
|
|
12132
|
+
async fetchBundle(url) {
|
|
12133
|
+
const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
|
|
12134
|
+
const read = readAppBundle(bytes);
|
|
12135
|
+
if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
|
|
12136
|
+
return {
|
|
12137
|
+
bundle: read.bundle,
|
|
12138
|
+
bytes
|
|
12139
|
+
};
|
|
12140
|
+
}
|
|
12141
|
+
async readStoreAt(url, heldKey) {
|
|
12142
|
+
const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
|
|
12143
|
+
return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
|
|
12144
|
+
}
|
|
12145
|
+
/** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
|
|
12146
|
+
async readRevocations(ref, urls) {
|
|
12147
|
+
const out = [];
|
|
12148
|
+
for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
|
|
12149
|
+
const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
|
|
12150
|
+
if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
|
|
12151
|
+
} catch {}
|
|
12152
|
+
return out;
|
|
12153
|
+
}
|
|
12154
|
+
summary(s) {
|
|
12155
|
+
const index = s.index;
|
|
12156
|
+
return {
|
|
12157
|
+
key: s.key,
|
|
12158
|
+
fingerprint: appFingerprint(s.key),
|
|
12159
|
+
url: s.url,
|
|
12160
|
+
preloaded: !!s.preloaded,
|
|
12161
|
+
...index && {
|
|
12162
|
+
name: index.name,
|
|
12163
|
+
kind: index.kind,
|
|
12164
|
+
sequence: index.sequence,
|
|
12165
|
+
expires: index.expires,
|
|
12166
|
+
...index.description !== void 0 && { description: index.description }
|
|
12167
|
+
},
|
|
12168
|
+
expired: !!index && index.expires < this.nowS(),
|
|
12169
|
+
...s.fetchedAt !== void 0 && { fetchedAt: s.fetchedAt },
|
|
12170
|
+
apps: index?.apps ?? [],
|
|
12171
|
+
removed: index?.removed ?? [],
|
|
12172
|
+
...s.equivocation && { equivocation: {
|
|
12173
|
+
sequence: s.equivocation.sequence,
|
|
12174
|
+
at: s.equivocation.at
|
|
12175
|
+
} },
|
|
12176
|
+
...s.problem && { problem: s.problem }
|
|
12177
|
+
};
|
|
12178
|
+
}
|
|
12179
|
+
async listStores() {
|
|
12180
|
+
return (await this.stores()).sort((a, b) => a.addedAt - b.addedAt).map((s) => this.summary(s));
|
|
12181
|
+
}
|
|
12182
|
+
storeUrl(url) {
|
|
12183
|
+
return typeof url === "string" && appPasteUrl(url, "ghostly-store.json") || notReadable(url, "Stores are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
|
|
12184
|
+
}
|
|
12185
|
+
/** Reads a store at a URL for the person to see before adding it. Nothing is kept. */
|
|
12186
|
+
async previewStore({ url }) {
|
|
12187
|
+
const at = this.storeUrl(url);
|
|
12188
|
+
const read = await this.readStoreAt(at);
|
|
12189
|
+
if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
|
|
12190
|
+
const { index } = read.store;
|
|
12191
|
+
return {
|
|
12192
|
+
url: at,
|
|
12193
|
+
key: index.key,
|
|
12194
|
+
fingerprint: appFingerprint(index.key),
|
|
12195
|
+
name: index.name,
|
|
12196
|
+
kind: index.kind,
|
|
12197
|
+
sequence: index.sequence,
|
|
12198
|
+
expires: index.expires,
|
|
12199
|
+
expired: read.store.expired,
|
|
12200
|
+
apps: index.apps.length,
|
|
12201
|
+
...index.description !== void 0 && { description: index.description }
|
|
12202
|
+
};
|
|
12203
|
+
}
|
|
12204
|
+
/**
|
|
12205
|
+
* Adds a store the person chose: read at its URL and pinned to its key (`key`, when given, is the key the person saw,
|
|
12206
|
+
* and another one is refused). A store already added under that key takes the URL and the newer index.
|
|
12207
|
+
*/
|
|
12208
|
+
async addStore({ url, key }) {
|
|
12209
|
+
if (key !== void 0 && !isAppKey(key)) fail("store-key", "Not a store key");
|
|
12210
|
+
const at = this.storeUrl(url);
|
|
12211
|
+
const read = await this.readStoreAt(at, key);
|
|
12212
|
+
if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
|
|
12213
|
+
const { index, digest } = read.store;
|
|
12214
|
+
const held = await this.storeRecord(index.key);
|
|
12215
|
+
const record = held && !held.removed ? {
|
|
12216
|
+
...held,
|
|
12217
|
+
url: at
|
|
12218
|
+
} : {
|
|
12219
|
+
key: index.key,
|
|
12220
|
+
url: at,
|
|
12221
|
+
addedAt: this.now(),
|
|
12222
|
+
...held?.preloaded && { preloaded: true }
|
|
12223
|
+
};
|
|
12224
|
+
await this.putStore(this.withIndex(record, index, digest));
|
|
12225
|
+
return this.summary(await this.storeRecord(index.key));
|
|
12226
|
+
}
|
|
12227
|
+
/** The update rule for indexes (WISP 1200 § Stores): a lower `sequence` or the same one with other bytes is refused, the held one kept. */
|
|
12228
|
+
withIndex(record, index, digest) {
|
|
12229
|
+
const held = record.index ? {
|
|
12230
|
+
key: record.key,
|
|
12231
|
+
sequence: record.index.sequence,
|
|
12232
|
+
digest: record.digest ?? ""
|
|
12233
|
+
} : null;
|
|
12234
|
+
const decision = appStoreDecision(held, {
|
|
12235
|
+
key: index.key,
|
|
12236
|
+
sequence: index.sequence,
|
|
12237
|
+
digest
|
|
12238
|
+
});
|
|
12239
|
+
const { problem: _problem, ...rest } = record;
|
|
12240
|
+
switch (decision) {
|
|
12241
|
+
case "new":
|
|
12242
|
+
case "update": {
|
|
12243
|
+
const { equivocation: _e, ...clean } = rest;
|
|
12244
|
+
return {
|
|
12245
|
+
...clean,
|
|
12246
|
+
index,
|
|
12247
|
+
digest,
|
|
12248
|
+
fetchedAt: this.now()
|
|
12249
|
+
};
|
|
12250
|
+
}
|
|
12251
|
+
case "same": return {
|
|
12252
|
+
...rest,
|
|
12253
|
+
fetchedAt: this.now()
|
|
12254
|
+
};
|
|
12255
|
+
case "rollback": return {
|
|
12256
|
+
...rest,
|
|
12257
|
+
problem: "rollback"
|
|
12258
|
+
};
|
|
12259
|
+
case "equivocation": return {
|
|
12260
|
+
...rest,
|
|
12261
|
+
equivocation: {
|
|
12262
|
+
sequence: index.sequence,
|
|
12263
|
+
digest,
|
|
12264
|
+
at: this.now()
|
|
12265
|
+
},
|
|
12266
|
+
problem: "equivocation"
|
|
12267
|
+
};
|
|
12268
|
+
default: return {
|
|
12269
|
+
...rest,
|
|
12270
|
+
problem: "store-key"
|
|
12271
|
+
};
|
|
12272
|
+
}
|
|
12273
|
+
}
|
|
12274
|
+
async removeStore({ key }) {
|
|
12275
|
+
const held = await this.storeRecord(key);
|
|
12276
|
+
if (!held) return;
|
|
12277
|
+
if (held.preloaded) await this.putStore({
|
|
12278
|
+
key: held.key,
|
|
12279
|
+
url: held.url,
|
|
12280
|
+
addedAt: held.addedAt,
|
|
12281
|
+
preloaded: true,
|
|
12282
|
+
removed: true
|
|
12283
|
+
});
|
|
12284
|
+
else await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
|
|
12285
|
+
}
|
|
12286
|
+
/** Reads one store again, or every store (the person opened the Apps page, or the update check). */
|
|
12287
|
+
async refreshStores({ key } = {}) {
|
|
12288
|
+
const stores = (await this.stores()).filter((s) => key === void 0 || s.key === key);
|
|
12289
|
+
if (key !== void 0 && !stores.length) fail("no-store", "No such store");
|
|
12290
|
+
const out = [];
|
|
12291
|
+
for (const s of stores) {
|
|
12292
|
+
let next;
|
|
12293
|
+
try {
|
|
12294
|
+
const read = await this.readStoreAt(s.url, s.key);
|
|
12295
|
+
next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
|
|
12296
|
+
...s,
|
|
12297
|
+
problem: read.reason
|
|
12298
|
+
};
|
|
12299
|
+
} catch (error) {
|
|
12300
|
+
next = {
|
|
12301
|
+
...s,
|
|
12302
|
+
problem: error instanceof AppFetchError ? error.code : "network"
|
|
12303
|
+
};
|
|
12304
|
+
}
|
|
12305
|
+
if (!await this.storeRecord(s.key) || (await this.storeRecord(s.key))?.removed) continue;
|
|
12306
|
+
await this.putStore(next);
|
|
12307
|
+
out.push(this.summary(next));
|
|
12308
|
+
}
|
|
12309
|
+
return out;
|
|
12310
|
+
}
|
|
12311
|
+
/**
|
|
12312
|
+
* Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
|
|
12313
|
+
* chat card's pointer. A store's or a card's `sequence` and digest must hold: the same app, and that version or a newer
|
|
12314
|
+
* one. Nothing is stored: the checked bundle waits in memory for `install`.
|
|
12315
|
+
*/
|
|
12316
|
+
async preview(source) {
|
|
12317
|
+
let urls;
|
|
12318
|
+
let expect = null;
|
|
12319
|
+
if ("url" in source) urls = [typeof source.url === "string" && appPasteUrl(source.url, "app.ghostlyapp") || notReadable(source.url, "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS")];
|
|
12320
|
+
else if ("store" in source) {
|
|
12321
|
+
const s = await this.storeRecord(source.store);
|
|
12322
|
+
const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
|
|
12323
|
+
if (!listing) fail("not-listed", "This store does not list this app");
|
|
12324
|
+
urls = listing.urls.filter(isAppFetchUrl);
|
|
12325
|
+
expect = {
|
|
12326
|
+
ref: listing.ref,
|
|
12327
|
+
sequence: listing.sequence,
|
|
12328
|
+
digest: listing.digest
|
|
12329
|
+
};
|
|
12330
|
+
} else if ("card" in source && source.card) {
|
|
12331
|
+
const { ref, url, sequence, digest } = source.card;
|
|
12332
|
+
if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
|
|
12333
|
+
if (!isAppFetchUrl(url)) fail("host", "A card's app is read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit");
|
|
12334
|
+
urls = [url];
|
|
12335
|
+
expect = {
|
|
12336
|
+
ref,
|
|
12337
|
+
...typeof sequence === "number" && { sequence },
|
|
12338
|
+
...typeof digest === "string" && { digest }
|
|
12339
|
+
};
|
|
12340
|
+
} else fail("bad-source", "No app to fetch");
|
|
12341
|
+
if (!urls.length) fail("host", "This listing has no URL Ghostly reads from");
|
|
12342
|
+
let last = null;
|
|
12343
|
+
for (const url of urls) try {
|
|
12344
|
+
const { bundle, bytes } = await this.fetchBundle(url);
|
|
12345
|
+
const version = versionOf(bundle.manifest, bundle.digest);
|
|
12346
|
+
if (expect) {
|
|
12347
|
+
if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
|
|
12348
|
+
if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
|
|
12349
|
+
if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
|
|
12350
|
+
}
|
|
12351
|
+
return this.stage(bundle, bytes, url);
|
|
12352
|
+
} catch (error) {
|
|
12353
|
+
last = error;
|
|
12354
|
+
}
|
|
12355
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
12356
|
+
}
|
|
12357
|
+
async stage(bundle, bytes, from) {
|
|
12358
|
+
for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
|
|
12359
|
+
this.staged.delete(bundle.digest);
|
|
12360
|
+
while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
|
|
12361
|
+
this.staged.set(bundle.digest, {
|
|
12362
|
+
bundle,
|
|
12363
|
+
bytes,
|
|
12364
|
+
from,
|
|
12365
|
+
at: this.now()
|
|
12366
|
+
});
|
|
12367
|
+
const { manifest, digest } = bundle;
|
|
12368
|
+
const version = versionOf(manifest, digest);
|
|
12369
|
+
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
12370
|
+
const decision = installed ? appUpdateDecision(installed, version) : "new";
|
|
12371
|
+
const install = decision === "other-app" ? "new" : decision;
|
|
12372
|
+
const asks = install === "new" ? [...manifest.permissions] : install === "update" ? manifest.permissions.filter((p) => !installed.permissions.includes(p)) : [];
|
|
12373
|
+
const listedBy = this.listedBy(version.ref, stores);
|
|
12374
|
+
return {
|
|
12375
|
+
digest,
|
|
12376
|
+
ref: version.ref,
|
|
12377
|
+
publisher: manifest.publisher,
|
|
12378
|
+
fingerprint: appFingerprint(manifest.publisher),
|
|
12379
|
+
manifest,
|
|
12380
|
+
from,
|
|
12381
|
+
icon: bundle.files.get("icon.png")?.slice() ?? null,
|
|
12382
|
+
install,
|
|
12383
|
+
asks,
|
|
12384
|
+
run: this.runStatus(version, stores, installed?.revocations),
|
|
12385
|
+
listedBy,
|
|
12386
|
+
unknownPublisher: !listedBy.some((s) => s.kind === "curated")
|
|
12387
|
+
};
|
|
12388
|
+
}
|
|
12389
|
+
/**
|
|
12390
|
+
* Installs the bundle `preview` fetched, once the person pressed Install: `grant` must hold every permission it asks
|
|
12391
|
+
* for (WISP 1200 § Permissions: none is granted by a store or a contact). A revoked or removed version is not
|
|
12392
|
+
* installed; neither is a rollback, nor another bundle under an installed `sequence` (marked on the app).
|
|
12393
|
+
*/
|
|
12394
|
+
async install({ digest, grant }) {
|
|
12395
|
+
const staged = this.staged.get(digest);
|
|
12396
|
+
if (!staged || this.now() - staged.at > STAGED.ms) {
|
|
12397
|
+
this.staged.delete(digest);
|
|
12398
|
+
fail("expired", "Fetch the app again");
|
|
12399
|
+
}
|
|
12400
|
+
const { bundle, bytes, from } = staged;
|
|
12401
|
+
const { manifest } = bundle;
|
|
12402
|
+
if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
|
|
12403
|
+
const version = versionOf(manifest, digest);
|
|
12404
|
+
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
12405
|
+
const run = this.runStatus(version, stores, installed?.revocations);
|
|
12406
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12407
|
+
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12408
|
+
const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
|
|
12409
|
+
const decision = installed ? pendingClash ? "equivocation" : appUpdateDecision(installed, version) : "new";
|
|
12410
|
+
if (decision === "rollback") fail("rollback", "A newer version of this app is installed");
|
|
12411
|
+
if (decision === "equivocation") {
|
|
12412
|
+
await this.putApp({
|
|
12413
|
+
...installed,
|
|
12414
|
+
equivocation: {
|
|
12415
|
+
sequence: version.sequence,
|
|
12416
|
+
digest,
|
|
12417
|
+
at: this.now()
|
|
12418
|
+
}
|
|
12419
|
+
});
|
|
12420
|
+
fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
|
|
12421
|
+
}
|
|
12422
|
+
if (decision === "same" && installed) {
|
|
12423
|
+
if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
|
|
12424
|
+
...installed,
|
|
12425
|
+
bytes: await this.writeBundle(digest, bytes)
|
|
12426
|
+
});
|
|
12427
|
+
this.staged.delete(digest);
|
|
12428
|
+
return this.view(await this.app(version.ref), stores);
|
|
12429
|
+
}
|
|
12430
|
+
const kind = await this.writeBundle(digest, bytes);
|
|
12431
|
+
const next = this.versionRecord(installed, bundle, kind, from);
|
|
12432
|
+
await this.putApp(next);
|
|
12433
|
+
if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
|
|
12434
|
+
if (installed?.pending && installed.pending.sequence <= version.sequence && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
|
|
12435
|
+
this.staged.delete(digest);
|
|
12436
|
+
return this.view(next, stores);
|
|
12437
|
+
}
|
|
12438
|
+
/** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
|
|
12439
|
+
versionRecord(installed, bundle, kind, from) {
|
|
12440
|
+
const { manifest, digest } = bundle;
|
|
12441
|
+
const now = this.now();
|
|
12442
|
+
const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
|
|
12443
|
+
return {
|
|
12444
|
+
ref: appRef(manifest.publisher, manifest.name),
|
|
12445
|
+
publisher: manifest.publisher,
|
|
12446
|
+
name: manifest.name,
|
|
12447
|
+
sequence: manifest.sequence,
|
|
12448
|
+
digest,
|
|
12449
|
+
permissions: [...manifest.permissions],
|
|
12450
|
+
manifest,
|
|
12451
|
+
bytes: kind,
|
|
12452
|
+
from,
|
|
12453
|
+
installedAt: installed?.installedAt ?? now,
|
|
12454
|
+
updatedAt: now,
|
|
12455
|
+
...installed?.revocations?.length && { revocations: installed.revocations },
|
|
12456
|
+
...installed?.equivocation && installed.equivocation.sequence >= manifest.sequence && { equivocation: installed.equivocation },
|
|
12457
|
+
...keepPending && { pending: keepPending },
|
|
12458
|
+
...installed?.checkedAt !== void 0 && { checkedAt: installed.checkedAt }
|
|
12459
|
+
};
|
|
12460
|
+
}
|
|
12461
|
+
/** The person accepted the permissions a waiting update adds: it is installed now. */
|
|
12462
|
+
async acceptUpdate({ ref }) {
|
|
12463
|
+
const installed = await this.installedOrFail(ref);
|
|
12464
|
+
const pending = installed.pending ?? fail("no-update", "No update waits for this app");
|
|
12465
|
+
const bytes = await this.readBundleBytes(pending.bytes, pending.digest);
|
|
12466
|
+
const read = bytes ? readAppBundle(bytes) : null;
|
|
12467
|
+
if (!read?.ok || read.bundle.digest !== pending.digest) {
|
|
12468
|
+
const { pending: _p, ...rest } = installed;
|
|
12469
|
+
await this.putApp(rest);
|
|
12470
|
+
fail("needs-files", "The update's files are gone; it is fetched again at the next check");
|
|
12471
|
+
}
|
|
12472
|
+
const stores = await this.stores();
|
|
12473
|
+
const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), stores, installed.revocations);
|
|
12474
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12475
|
+
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12476
|
+
const plan = planAppUpdate(installed, {
|
|
12477
|
+
...versionOf(read.bundle.manifest, pending.digest),
|
|
12478
|
+
permissions: read.bundle.manifest.permissions
|
|
12479
|
+
});
|
|
12480
|
+
if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
|
|
12481
|
+
const { pending: _p, ...without } = installed;
|
|
12482
|
+
const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
|
|
12483
|
+
await this.putApp(next);
|
|
12484
|
+
await this.removeBundle(installed.bytes, installed.digest);
|
|
12485
|
+
return this.view(next, stores);
|
|
12486
|
+
}
|
|
12487
|
+
/**
|
|
12488
|
+
* The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
|
|
12489
|
+
* app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
|
|
12490
|
+
* revocations read beside its sources. An update that keeps or drops permissions installs by itself; one that adds
|
|
12491
|
+
* some waits for the person. One check at a time.
|
|
12492
|
+
*/
|
|
12493
|
+
checkUpdates() {
|
|
12494
|
+
this.checking ??= this.checkNow().then((results) => {
|
|
12495
|
+
try {
|
|
12496
|
+
this.host.checked?.(results);
|
|
12497
|
+
} catch {}
|
|
12498
|
+
return results;
|
|
12499
|
+
}).finally(() => {
|
|
12500
|
+
this.checking = null;
|
|
12501
|
+
});
|
|
12502
|
+
return this.checking;
|
|
12503
|
+
}
|
|
12504
|
+
async checkNow() {
|
|
12505
|
+
const apps = await this.installed();
|
|
12506
|
+
if (!apps.length) return [];
|
|
12507
|
+
await this.refreshStores();
|
|
12508
|
+
const stores = await this.stores();
|
|
12509
|
+
const out = [];
|
|
12510
|
+
for (const listed of apps) {
|
|
12511
|
+
if (!await this.hasBundle(listed.bytes, listed.digest)) await this.fetchFiles({ ref: listed.ref }).catch(() => {});
|
|
12512
|
+
const first = await this.app(listed.ref);
|
|
12513
|
+
if (!first) continue;
|
|
12514
|
+
let app = first;
|
|
12515
|
+
let outcome = "none";
|
|
12516
|
+
const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
|
|
12517
|
+
const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
|
|
12518
|
+
const candidates = [];
|
|
12519
|
+
for (const s of stores) {
|
|
12520
|
+
const listing = s.index?.apps.find((a) => a.ref === app.ref);
|
|
12521
|
+
if (listing && (listing.sequence > highest || listing.sequence >= app.sequence && !known.has(listing.digest))) candidates.push(...listing.urls.filter(isAppFetchUrl));
|
|
12522
|
+
}
|
|
12523
|
+
for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
|
|
12524
|
+
const peek = peekAppManifest(await this.host.fetch(url, {
|
|
12525
|
+
maxBytes: PEEK_BYTES,
|
|
12526
|
+
peek: true
|
|
12527
|
+
}));
|
|
12528
|
+
if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
|
|
12529
|
+
} catch {}
|
|
12530
|
+
for (const url of new Set(candidates)) {
|
|
12531
|
+
let fetched;
|
|
12532
|
+
try {
|
|
12533
|
+
fetched = await this.fetchBundle(url);
|
|
12534
|
+
} catch {
|
|
12535
|
+
continue;
|
|
12536
|
+
}
|
|
12537
|
+
const { bundle, bytes } = fetched;
|
|
12538
|
+
const version = versionOf(bundle.manifest, bundle.digest);
|
|
12539
|
+
if (version.ref !== app.ref || known.has(version.digest)) continue;
|
|
12540
|
+
if (app.pending && version.sequence === app.pending.sequence) {
|
|
12541
|
+
app = {
|
|
12542
|
+
...app,
|
|
12543
|
+
equivocation: {
|
|
12544
|
+
sequence: version.sequence,
|
|
12545
|
+
digest: version.digest,
|
|
12546
|
+
at: this.now()
|
|
12547
|
+
}
|
|
12548
|
+
};
|
|
12549
|
+
outcome = "equivocation";
|
|
12550
|
+
continue;
|
|
12551
|
+
}
|
|
12552
|
+
const plan = planAppUpdate(app, {
|
|
12553
|
+
...version,
|
|
12554
|
+
permissions: bundle.manifest.permissions
|
|
12555
|
+
});
|
|
12556
|
+
if (plan.action === "refuse") {
|
|
12557
|
+
if (plan.decision === "equivocation") {
|
|
12558
|
+
app = {
|
|
12559
|
+
...app,
|
|
12560
|
+
equivocation: {
|
|
12561
|
+
sequence: version.sequence,
|
|
12562
|
+
digest: version.digest,
|
|
12563
|
+
at: this.now()
|
|
12564
|
+
}
|
|
12565
|
+
};
|
|
12566
|
+
outcome = "equivocation";
|
|
12567
|
+
}
|
|
12568
|
+
continue;
|
|
12569
|
+
}
|
|
12570
|
+
if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
|
|
12571
|
+
if (app.pending && version.sequence < app.pending.sequence) continue;
|
|
12572
|
+
const kind = await this.writeBundle(version.digest, bytes);
|
|
12573
|
+
if (plan.action === "install") {
|
|
12574
|
+
const old = app;
|
|
12575
|
+
app = this.versionRecord(app, bundle, kind, url);
|
|
12576
|
+
if (old.pending && old.pending.sequence <= version.sequence) {
|
|
12577
|
+
await this.removeBundle(old.pending.bytes, old.pending.digest);
|
|
12578
|
+
delete app.pending;
|
|
12579
|
+
}
|
|
12580
|
+
await this.putApp(app);
|
|
12581
|
+
await this.removeBundle(old.bytes, old.digest);
|
|
12582
|
+
outcome = "updated";
|
|
12583
|
+
} else {
|
|
12584
|
+
if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
|
|
12585
|
+
app = {
|
|
12586
|
+
...app,
|
|
12587
|
+
pending: {
|
|
12588
|
+
sequence: version.sequence,
|
|
12589
|
+
digest: version.digest,
|
|
12590
|
+
manifest: bundle.manifest,
|
|
12591
|
+
added: plan.added,
|
|
12592
|
+
bytes: kind,
|
|
12593
|
+
from: url,
|
|
12594
|
+
at: this.now()
|
|
12595
|
+
}
|
|
12596
|
+
};
|
|
12597
|
+
outcome = "ask";
|
|
12598
|
+
}
|
|
12599
|
+
break;
|
|
12600
|
+
}
|
|
12601
|
+
const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
|
|
12602
|
+
const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
|
|
12603
|
+
for (const r of revocations) merged.set(canonicalJson(r), r);
|
|
12604
|
+
if (!await this.app(app.ref)) continue;
|
|
12605
|
+
app = {
|
|
12606
|
+
...app,
|
|
12607
|
+
...merged.size && { revocations: [...merged.values()] },
|
|
12608
|
+
checkedAt: this.now()
|
|
12609
|
+
};
|
|
12610
|
+
await this.putApp(app);
|
|
12611
|
+
out.push({
|
|
12612
|
+
ref: app.ref,
|
|
12613
|
+
outcome,
|
|
12614
|
+
run: await this.runCheckOf(app, stores)
|
|
12615
|
+
});
|
|
12616
|
+
}
|
|
12617
|
+
return out;
|
|
12618
|
+
}
|
|
12619
|
+
/**
|
|
12620
|
+
* Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
|
|
12621
|
+
* carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
|
|
12622
|
+
* taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
|
|
12623
|
+
* then its `sources`. Nothing answers: it stays `needs-files`, and it never runs unchecked.
|
|
12624
|
+
*/
|
|
12625
|
+
async fetchFiles({ ref }) {
|
|
12626
|
+
const app = await this.installedOrFail(ref);
|
|
12627
|
+
const stores = await this.stores();
|
|
12628
|
+
if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
|
|
12629
|
+
const urls = [app.from];
|
|
12630
|
+
for (const s of stores) for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listing.urls);
|
|
12631
|
+
urls.push(...app.manifest.sources ?? []);
|
|
12632
|
+
for (const url of new Set(urls.filter(isAppFetchUrl))) {
|
|
12633
|
+
let fetched;
|
|
12634
|
+
try {
|
|
12635
|
+
fetched = await this.fetchBundle(url);
|
|
12636
|
+
} catch {
|
|
12637
|
+
continue;
|
|
12638
|
+
}
|
|
12639
|
+
if (fetched.bundle.digest !== app.digest) continue;
|
|
12640
|
+
const kind = await this.writeBundle(app.digest, fetched.bytes);
|
|
12641
|
+
const now = await this.app(app.ref);
|
|
12642
|
+
if (!now || now.digest !== app.digest) {
|
|
12643
|
+
await this.removeBundle(kind, app.digest);
|
|
12644
|
+
fail("not-installed", "This app is not installed");
|
|
12645
|
+
}
|
|
12646
|
+
if (now.bytes !== kind) await this.putApp({
|
|
12647
|
+
...now,
|
|
12648
|
+
bytes: kind
|
|
12649
|
+
});
|
|
12650
|
+
return this.runCheckOf({
|
|
12651
|
+
...now,
|
|
12652
|
+
bytes: kind
|
|
12653
|
+
}, stores);
|
|
12654
|
+
}
|
|
12655
|
+
return { status: "needs-files" };
|
|
12656
|
+
}
|
|
12657
|
+
async runCheckOf(app, stores) {
|
|
12658
|
+
if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
|
|
12659
|
+
return this.runStatus(app, stores, app.revocations);
|
|
12660
|
+
}
|
|
12661
|
+
/** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
|
|
12662
|
+
async runCheck({ ref }) {
|
|
12663
|
+
return this.runCheckOf(await this.installedOrFail(ref), await this.stores());
|
|
12664
|
+
}
|
|
12665
|
+
/**
|
|
12666
|
+
* What the runner writes into its frame: the entry, from the bundle read back and checked again. A revoked version
|
|
12667
|
+
* never runs; one a store removed runs only when the person chose "Run anyway" (`runAnyway`).
|
|
12668
|
+
*/
|
|
12669
|
+
async entry({ ref, runAnyway }) {
|
|
12670
|
+
let app = await this.installedOrFail(ref);
|
|
12671
|
+
let run = await this.runCheckOf(app, await this.stores());
|
|
12672
|
+
if (run.status === "needs-files") {
|
|
12673
|
+
run = await this.fetchFiles({ ref });
|
|
12674
|
+
app = await this.installedOrFail(ref);
|
|
12675
|
+
}
|
|
12676
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12677
|
+
if (run.status === "removed" && runAnyway !== true) fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12678
|
+
if (run.status === "needs-files") fail("needs-files", "This app's files are not on this device yet");
|
|
12679
|
+
if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
|
|
12680
|
+
const bundle = await this.verifiedBundle(app);
|
|
12681
|
+
const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
|
|
12682
|
+
return {
|
|
12683
|
+
ref: app.ref,
|
|
12684
|
+
digest: app.digest,
|
|
12685
|
+
version: app.manifest.version,
|
|
12686
|
+
title: app.manifest.title,
|
|
12687
|
+
permissions: [...app.permissions],
|
|
12688
|
+
entry: utf8Decode(entry)
|
|
12689
|
+
};
|
|
12690
|
+
}
|
|
12691
|
+
/** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
|
|
12692
|
+
async file({ ref, path }) {
|
|
12693
|
+
const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
|
|
12694
|
+
const bundle = await this.verifiedBundle(app);
|
|
12695
|
+
if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
|
|
12696
|
+
return bundle.files.get(path).slice();
|
|
12697
|
+
}
|
|
12698
|
+
async view(app, stores) {
|
|
12699
|
+
const { manifest } = app;
|
|
12700
|
+
const listedBy = this.listedBy(app.ref, stores);
|
|
12701
|
+
return {
|
|
12702
|
+
ref: app.ref,
|
|
12703
|
+
name: app.name,
|
|
12704
|
+
publisher: app.publisher,
|
|
12705
|
+
fingerprint: appFingerprint(app.publisher),
|
|
12706
|
+
title: manifest.title,
|
|
12707
|
+
tagline: manifest.tagline,
|
|
12708
|
+
...manifest.description !== void 0 && { description: manifest.description },
|
|
12709
|
+
version: manifest.version,
|
|
12710
|
+
sequence: app.sequence,
|
|
12711
|
+
digest: app.digest,
|
|
12712
|
+
permissions: [...app.permissions],
|
|
12713
|
+
from: app.from,
|
|
12714
|
+
icon: manifest.files.some((f) => f.path === "icon.png"),
|
|
12715
|
+
installedAt: app.installedAt,
|
|
12716
|
+
updatedAt: app.updatedAt,
|
|
12717
|
+
run: await this.runCheckOf(app, stores),
|
|
12718
|
+
listedBy,
|
|
12719
|
+
unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
|
|
12720
|
+
...app.pending && { pending: {
|
|
12721
|
+
sequence: app.pending.sequence,
|
|
12722
|
+
version: app.pending.manifest.version,
|
|
12723
|
+
added: [...app.pending.added]
|
|
12724
|
+
} },
|
|
12725
|
+
...app.equivocation && { equivocation: {
|
|
12726
|
+
sequence: app.equivocation.sequence,
|
|
12727
|
+
at: app.equivocation.at
|
|
12728
|
+
} },
|
|
12729
|
+
...app.checkedAt !== void 0 && { checkedAt: app.checkedAt }
|
|
12730
|
+
};
|
|
12731
|
+
}
|
|
12732
|
+
async list() {
|
|
12733
|
+
const [apps, stores] = await Promise.all([this.installed(), this.stores()]);
|
|
12734
|
+
return Promise.all(apps.sort((a, b) => a.installedAt - b.installedAt).map((a) => this.view(a, stores)));
|
|
12735
|
+
}
|
|
12736
|
+
/**
|
|
12737
|
+
* Uninstalls an app: its bundle (and a waiting update's), its record and its storage in every scope. The person chose
|
|
12738
|
+
* it, after the offer of an export (`exportData`).
|
|
12739
|
+
*/
|
|
12740
|
+
async uninstall({ ref }) {
|
|
12741
|
+
const app = await this.installedOrFail(ref);
|
|
12742
|
+
const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
|
|
12743
|
+
tx.objectStore(STORES$1.apps).delete(app.ref);
|
|
12744
|
+
tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
|
|
12745
|
+
await done(tx);
|
|
12746
|
+
for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
|
|
12747
|
+
await this.removeBundle(app.bytes, app.digest);
|
|
12748
|
+
if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
|
|
12749
|
+
}
|
|
12750
|
+
scopeOf(scope) {
|
|
12751
|
+
if (scope === "alone" || typeof scope === "string" && this.host.isChat(scope)) return scope;
|
|
12752
|
+
fail("bad-scope", "Not a chat of this profile");
|
|
12753
|
+
}
|
|
12754
|
+
keyOf(key) {
|
|
12755
|
+
if (typeof key !== "string" || !key.length || byteLength(key) > APP_STORAGE_LIMITS.keyBytes) fail("bad-key", "A key is 1 to 256 bytes of text");
|
|
12756
|
+
return key;
|
|
12757
|
+
}
|
|
12758
|
+
/** One scope's writes, one after another, so the count of bytes used stays true. */
|
|
12759
|
+
serial(id, work) {
|
|
12760
|
+
const run = (this.queues.get(id) ?? Promise.resolve()).then(work, work);
|
|
12761
|
+
const settled = run.catch(() => {});
|
|
12762
|
+
this.queues.set(id, settled);
|
|
12763
|
+
settled.then(() => {
|
|
12764
|
+
if (this.queues.get(id) === settled) this.queues.delete(id);
|
|
12765
|
+
});
|
|
12766
|
+
return run;
|
|
12767
|
+
}
|
|
12768
|
+
async usageOf(ref, scope) {
|
|
12769
|
+
const id = `${ref}\0${scope}`;
|
|
12770
|
+
let used = this.usage.get(id);
|
|
12771
|
+
if (used === void 0) {
|
|
12772
|
+
used = (await wrap((await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX).getAll([ref, scope]))).reduce((sum, r) => sum + r.size, 0);
|
|
12773
|
+
this.usage.set(id, used);
|
|
12774
|
+
}
|
|
12775
|
+
return used;
|
|
12776
|
+
}
|
|
12777
|
+
async storageGet({ ref, scope, key }) {
|
|
12778
|
+
const app = await this.runningOrFail(ref);
|
|
12779
|
+
const row = await wrap((await store(STORES$1.appStorage, "readonly")).get([
|
|
12780
|
+
app.ref,
|
|
12781
|
+
this.scopeOf(scope),
|
|
12782
|
+
this.keyOf(key)
|
|
12783
|
+
]));
|
|
12784
|
+
return row ? { value: JSON.parse(row.value) } : null;
|
|
12785
|
+
}
|
|
12786
|
+
/** Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope. */
|
|
12787
|
+
async storageSet({ ref, scope, key, value }) {
|
|
12788
|
+
const app = await this.runningOrFail(ref);
|
|
12789
|
+
const where = this.scopeOf(scope), name = this.keyOf(key);
|
|
12790
|
+
let text;
|
|
12791
|
+
try {
|
|
12792
|
+
text = JSON.stringify(value);
|
|
12793
|
+
} catch {
|
|
12794
|
+
text = void 0;
|
|
12795
|
+
}
|
|
12796
|
+
if (typeof text !== "string") fail("bad-value", "A value is JSON");
|
|
12797
|
+
const valueBytes = byteLength(text);
|
|
12798
|
+
if (valueBytes > APP_STORAGE_LIMITS.valueBytes) fail("too-large", "A value is at most 64 KiB");
|
|
12799
|
+
const size = byteLength(name) + valueBytes;
|
|
12800
|
+
await this.serial(`${app.ref}\0${where}`, async () => {
|
|
12801
|
+
const used = await this.usageOf(app.ref, where);
|
|
12802
|
+
const rows = await store(STORES$1.appStorage, "readonly");
|
|
12803
|
+
const next = used - ((await wrap(rows.get([
|
|
12804
|
+
app.ref,
|
|
12805
|
+
where,
|
|
12806
|
+
name
|
|
12807
|
+
])))?.size ?? 0) + size;
|
|
12808
|
+
if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
|
|
12809
|
+
await wrap((await store(STORES$1.appStorage, "readwrite")).put({
|
|
12810
|
+
ref: app.ref,
|
|
12811
|
+
scope: where,
|
|
12812
|
+
key: name,
|
|
12813
|
+
value: text,
|
|
12814
|
+
size
|
|
12815
|
+
}));
|
|
12816
|
+
this.usage.set(`${app.ref}\0${where}`, next);
|
|
12817
|
+
});
|
|
12818
|
+
}
|
|
12819
|
+
async storageDelete({ ref, scope, key }) {
|
|
12820
|
+
const app = await this.runningOrFail(ref);
|
|
12821
|
+
const where = this.scopeOf(scope), name = this.keyOf(key);
|
|
12822
|
+
await this.serial(`${app.ref}\0${where}`, async () => {
|
|
12823
|
+
const used = await this.usageOf(app.ref, where);
|
|
12824
|
+
const old = await wrap((await store(STORES$1.appStorage, "readonly")).get([
|
|
12825
|
+
app.ref,
|
|
12826
|
+
where,
|
|
12827
|
+
name
|
|
12828
|
+
]));
|
|
12829
|
+
if (!old) return;
|
|
12830
|
+
await wrap((await store(STORES$1.appStorage, "readwrite")).delete([
|
|
12831
|
+
app.ref,
|
|
12832
|
+
where,
|
|
12833
|
+
name
|
|
12834
|
+
]));
|
|
12835
|
+
this.usage.set(`${app.ref}\0${where}`, used - old.size);
|
|
12836
|
+
});
|
|
12837
|
+
}
|
|
12838
|
+
async storageKeys({ ref, scope }) {
|
|
12839
|
+
const app = await this.runningOrFail(ref);
|
|
12840
|
+
return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
|
|
12841
|
+
}
|
|
12842
|
+
/** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
|
|
12843
|
+
async exportData({ ref }) {
|
|
12844
|
+
const app = await this.installedOrFail(ref);
|
|
12845
|
+
const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(rowsOf(app.ref)));
|
|
12846
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
12847
|
+
for (const row of rows) {
|
|
12848
|
+
const out = scopes.get(row.scope) ?? {
|
|
12849
|
+
ghostlyAppData: 1,
|
|
12850
|
+
app: app.ref,
|
|
12851
|
+
scope: row.scope,
|
|
12852
|
+
entries: {}
|
|
12853
|
+
};
|
|
12854
|
+
out.entries[row.key] = JSON.parse(row.value);
|
|
12855
|
+
scopes.set(row.scope, out);
|
|
12856
|
+
}
|
|
12857
|
+
return [...scopes.values()];
|
|
12858
|
+
}
|
|
12859
|
+
};
|
|
12860
|
+
//#endregion
|
|
11623
12861
|
//#region ../browser/src/devices/state.ts
|
|
11624
12862
|
/** WISP 06 § States of a device. */
|
|
11625
12863
|
var DEVICE_STATES = [
|
|
@@ -13948,6 +15186,10 @@ var EnrollInviter = class extends Enrollment {
|
|
|
13948
15186
|
onPeerSeen: () => {
|
|
13949
15187
|
if (this.seen || this.over) return;
|
|
13950
15188
|
this.seen = true;
|
|
15189
|
+
if (this.view.role === "inviter" && this.view.step === "waiting") this.show({
|
|
15190
|
+
...this.view,
|
|
15191
|
+
seen: true
|
|
15192
|
+
});
|
|
13951
15193
|
this.timer(this.options.timing?.proofMs ?? 12e4, () => {
|
|
13952
15194
|
if (this.view.step === "waiting") this.fail("unreached");
|
|
13953
15195
|
});
|
|
@@ -16374,6 +17616,9 @@ var DeviceLinks = class {
|
|
|
16374
17616
|
const keeper = await this.turnKeeper();
|
|
16375
17617
|
if (!keeper) return null;
|
|
16376
17618
|
const outcome = await keeper.check(atStart);
|
|
17619
|
+
try {
|
|
17620
|
+
if (outcome) this.handoff?.turnRead?.(outcome);
|
|
17621
|
+
} catch {}
|
|
16377
17622
|
await this.refresh();
|
|
16378
17623
|
return outcome;
|
|
16379
17624
|
}
|
|
@@ -16570,6 +17815,11 @@ var SHOWN_AFTER_RELOAD = /* @__PURE__ */ new Set([
|
|
|
16570
17815
|
"cancelled"
|
|
16571
17816
|
]);
|
|
16572
17817
|
var reportOf = (busy) => busy === null ? null : typeof busy === "string" ? { why: busy } : busy;
|
|
17818
|
+
/**
|
|
17819
|
+
* What `handoff-busy` tells the taker of a local reason (WISP 06: the requesting device "Shows why"). A call is said as
|
|
17820
|
+
* itself, so the person waits for it to end; the rest stay "handoff": which wallet keeps the profile is said on this device.
|
|
17821
|
+
*/
|
|
17822
|
+
var wireWhy = (report) => report.why === "call" ? "call" : "handoff";
|
|
16573
17823
|
var name = (record, key) => record.deviceSet.find((slot) => slot?.key === key)?.name ?? "";
|
|
16574
17824
|
var slotOf = (record, key) => record.deviceSet.findIndex((slot) => slot?.key === key);
|
|
16575
17825
|
/** One session of a handoff with the other device: its own X25519 key, the other's hello, and the stream key. */
|
|
@@ -16929,6 +18179,25 @@ var HandoffGiver = class {
|
|
|
16929
18179
|
receive(from, frame) {
|
|
16930
18180
|
return this.exclusive(() => this.handle(from, frame)).catch(() => {});
|
|
16931
18181
|
}
|
|
18182
|
+
/**
|
|
18183
|
+
* A turn read on this standby found the record of `key` at `turn`: the device it released to took the turn, which is
|
|
18184
|
+
* what the `handoff-done` would have said. That frame goes once, as the taker reloads, and can be lost; without this
|
|
18185
|
+
* the screen said "Moving to …" for good, with no Use here, until that device offered the profile back.
|
|
18186
|
+
*/
|
|
18187
|
+
turnTaken(key, turn) {
|
|
18188
|
+
return this.exclusive(async () => {
|
|
18189
|
+
if (this.stopped || this.phase !== "released" || key !== this.peer || turn <= this.turn) return;
|
|
18190
|
+
await this.taken();
|
|
18191
|
+
}).catch(() => {});
|
|
18192
|
+
}
|
|
18193
|
+
/** The release was taken: the handoff note goes, and this device is a plain standby. */
|
|
18194
|
+
async taken() {
|
|
18195
|
+
await this.dropBreez();
|
|
18196
|
+
await this.ports.records.amend({ handoff: void 0 });
|
|
18197
|
+
this.phase = "idle";
|
|
18198
|
+
this.peer = null;
|
|
18199
|
+
this.changed();
|
|
18200
|
+
}
|
|
16932
18201
|
async handle(from, frame) {
|
|
16933
18202
|
if (this.stopped) return;
|
|
16934
18203
|
if (from === this.peer && frame.t.startsWith("handoff-")) this.heardAt = this.now();
|
|
@@ -16950,11 +18219,7 @@ var HandoffGiver = class {
|
|
|
16950
18219
|
case HANDOFF_DONE:
|
|
16951
18220
|
if (from !== this.peer || this.phase !== "released") return;
|
|
16952
18221
|
if (frame.t === "handoff-done" ? readHandoffDone(frame) === null : readHandoffTurnFrame(frame) === null) return;
|
|
16953
|
-
await this.
|
|
16954
|
-
await this.ports.records.amend({ handoff: void 0 });
|
|
16955
|
-
this.phase = "idle";
|
|
16956
|
-
this.peer = null;
|
|
16957
|
-
this.changed();
|
|
18222
|
+
await this.taken();
|
|
16958
18223
|
return;
|
|
16959
18224
|
case HANDOFF_CANCEL: {
|
|
16960
18225
|
const why = readHandoffCancel(frame);
|
|
@@ -17015,7 +18280,7 @@ var HandoffGiver = class {
|
|
|
17015
18280
|
this.failure = this.failWith(local);
|
|
17016
18281
|
this.changed();
|
|
17017
18282
|
}
|
|
17018
|
-
return busy(
|
|
18283
|
+
return busy(wireWhy(local));
|
|
17019
18284
|
}
|
|
17020
18285
|
const self = await this.ownSelf();
|
|
17021
18286
|
const versions = handoffVersions(self, session.peer);
|
|
@@ -17216,12 +18481,12 @@ var HandoffGiver = class {
|
|
|
17216
18481
|
while (this.now() < deadline && reportOf(await this.ports.busy(taker))?.why === "payment") await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
17217
18482
|
const still = reportOf(await this.ports.busy(taker));
|
|
17218
18483
|
if (still) {
|
|
17219
|
-
this.out(handoffBusyFrame(
|
|
18484
|
+
this.out(handoffBusyFrame(wireWhy(still), 30));
|
|
17220
18485
|
this.reset(still.why === "payment" ? "payment" : this.failWith(still));
|
|
17221
18486
|
return;
|
|
17222
18487
|
}
|
|
17223
18488
|
} else if (why) {
|
|
17224
|
-
this.out(handoffBusyFrame(
|
|
18489
|
+
this.out(handoffBusyFrame(wireWhy(why), 30));
|
|
17225
18490
|
this.reset(this.failWith(why));
|
|
17226
18491
|
return;
|
|
17227
18492
|
}
|
|
@@ -17451,7 +18716,7 @@ async function room() {
|
|
|
17451
18716
|
async function handoffSelf(profileHost) {
|
|
17452
18717
|
return {
|
|
17453
18718
|
app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
|
|
17454
|
-
db:
|
|
18719
|
+
db: 14,
|
|
17455
18720
|
pins: { ...WALLET_SDK_PINS },
|
|
17456
18721
|
kind: profileHost.kind,
|
|
17457
18722
|
room: await room(),
|
|
@@ -20337,6 +21602,9 @@ var db = {
|
|
|
20337
21602
|
await fileStore.deleteForLink(linkId);
|
|
20338
21603
|
await removeFileBytes(`${linkId}-in-`);
|
|
20339
21604
|
await removeFileBytes(`${linkId}-out-`);
|
|
21605
|
+
const appStorage = await store(STORES$1.appStorage, "readwrite");
|
|
21606
|
+
const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
|
|
21607
|
+
await Promise.all(appKeys.map((key) => wrap(appStorage.delete(key))));
|
|
20340
21608
|
},
|
|
20341
21609
|
async getMessages(linkId) {
|
|
20342
21610
|
return (await wrap((await store(STORES$1.messages, "readonly")).index("byLink").getAll(linkId))).sort((a, b) => a.timestamp - b.timestamp);
|
|
@@ -20744,22 +22012,6 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
|
|
|
20744
22012
|
};
|
|
20745
22013
|
}
|
|
20746
22014
|
//#endregion
|
|
20747
|
-
//#region ../browser/src/engine/joinTrace.ts
|
|
20748
|
-
/**
|
|
20749
|
-
* Timestamps along a join through a group's link, for measuring where the time goes. Silent unless
|
|
20750
|
-
* the page sets `globalThis.__ghostlyJoinTrace` (the e2e measurement does): each step is then one
|
|
20751
|
-
* `console.debug` line, `[ghostly:join] {"t":…,"g":…,"step":…}`, read back by the test.
|
|
20752
|
-
*/
|
|
20753
|
-
function traceJoin(group, step, detail) {
|
|
20754
|
-
if (!globalThis.__ghostlyJoinTrace) return;
|
|
20755
|
-
console.debug(`[ghostly:join] ${JSON.stringify({
|
|
20756
|
-
t: Date.now(),
|
|
20757
|
-
g: group,
|
|
20758
|
-
step,
|
|
20759
|
-
...detail
|
|
20760
|
-
})}`);
|
|
20761
|
-
}
|
|
20762
|
-
//#endregion
|
|
20763
22015
|
//#region ../browser/src/engine/hubClocks.ts
|
|
20764
22016
|
/**
|
|
20765
22017
|
* A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
|
|
@@ -20928,6 +22180,14 @@ var HUB_GRACE_MS = 18e4;
|
|
|
20928
22180
|
var CUT_OFF_STEP_UP_MS = 15e3;
|
|
20929
22181
|
/** A lobby request this recent is from a member still looking fast for my side of the edge: mine opens expecting it. */
|
|
20930
22182
|
var LOBBY_EXPECT_MS = 3e4;
|
|
22183
|
+
/**
|
|
22184
|
+
* A lobby is one record every member asking a hub writes, each reading it and writing it back with its own request: two
|
|
22185
|
+
* members writing at once (both cut off by the same leave, in the same second) each write what they read, and the later
|
|
22186
|
+
* write drops the earlier request. So a member reads the lobby back this long after its write (plus up to `LOBBY_CHECK_JITTER_MS`, so
|
|
22187
|
+
* the members who wrote together do not read and write together again) and asks again at once if its request is gone.
|
|
22188
|
+
*/
|
|
22189
|
+
var LOBBY_CHECK_MS = 2e3;
|
|
22190
|
+
var LOBBY_CHECK_JITTER_MS = 3e3;
|
|
20931
22191
|
var REFUSED_FOR_MS$1 = 6e5;
|
|
20932
22192
|
var ENTRY_LINGER_MS$1 = 2e4;
|
|
20933
22193
|
var RELAYED_KEPT = 4096;
|
|
@@ -20986,6 +22246,8 @@ var Communities = class {
|
|
|
20986
22246
|
knockAt = /* @__PURE__ */ new Map();
|
|
20987
22247
|
/** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
|
|
20988
22248
|
knockingWith = /* @__PURE__ */ new Map();
|
|
22249
|
+
/** Joiners: the knock record a knock read whose write the relays refused, and when it was read (`knock`). */
|
|
22250
|
+
knockRead = /* @__PURE__ */ new Map();
|
|
20989
22251
|
/** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
|
|
20990
22252
|
frames = new FramesTaken();
|
|
20991
22253
|
carriers = /* @__PURE__ */ new Map();
|
|
@@ -21082,9 +22344,11 @@ var Communities = class {
|
|
|
21082
22344
|
return !!id && this.host.linkReady(id, 2);
|
|
21083
22345
|
};
|
|
21084
22346
|
const hubs = freshHubs(this.hubs(live), this.now()).map((h) => h.key);
|
|
22347
|
+
const former = Object.entries(s.state.nicks).filter(([key]) => !rosterHas(s.roster, key));
|
|
21085
22348
|
return [{
|
|
21086
22349
|
...base,
|
|
21087
22350
|
name: s.name,
|
|
22351
|
+
...former.length ? { formerNames: Object.fromEntries(former) } : {},
|
|
21088
22352
|
status: s.status,
|
|
21089
22353
|
statusReason: s.state.statusReason,
|
|
21090
22354
|
epoch: s.epoch,
|
|
@@ -21169,6 +22433,7 @@ var Communities = class {
|
|
|
21169
22433
|
this.knocked.delete(link.g);
|
|
21170
22434
|
this.knockAt.delete(link.g);
|
|
21171
22435
|
this.knockingWith.delete(link.g);
|
|
22436
|
+
this.knockRead.delete(link.g);
|
|
21172
22437
|
const before = this.stored.get(link.g)?.joining;
|
|
21173
22438
|
const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
|
|
21174
22439
|
if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
|
|
@@ -21202,6 +22467,7 @@ var Communities = class {
|
|
|
21202
22467
|
this.knocked.delete(group.id);
|
|
21203
22468
|
this.knockAt.delete(group.id);
|
|
21204
22469
|
this.knockingWith.delete(group.id);
|
|
22470
|
+
this.knockRead.delete(group.id);
|
|
21205
22471
|
await this.host.closeEdge(joining.linkId);
|
|
21206
22472
|
await this.store.putGroup(group);
|
|
21207
22473
|
this.host.emit();
|
|
@@ -21342,6 +22608,7 @@ var Communities = class {
|
|
|
21342
22608
|
this.lastPeerMessageAt.delete(groupId);
|
|
21343
22609
|
this.lastMentionAt.delete(groupId);
|
|
21344
22610
|
this.knockingWith.delete(groupId);
|
|
22611
|
+
this.knockRead.delete(groupId);
|
|
21345
22612
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
21346
22613
|
await this.store.deleteGroup(groupId);
|
|
21347
22614
|
this.host.historyGone?.(groupId);
|
|
@@ -21360,7 +22627,7 @@ var Communities = class {
|
|
|
21360
22627
|
if (group.joining && (this.knocking(group, live) || group.joining.check)) {
|
|
21361
22628
|
const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
|
|
21362
22629
|
if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
|
|
21363
|
-
else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
|
|
22630
|
+
else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= (this.knocked.has(group.id) ? every : Math.min(every, 3e3))) await this.knock(group, now).catch(() => {});
|
|
21364
22631
|
if (!live) continue;
|
|
21365
22632
|
}
|
|
21366
22633
|
if (!live) continue;
|
|
@@ -21536,7 +22803,15 @@ var Communities = class {
|
|
|
21536
22803
|
live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
|
|
21537
22804
|
for (const hub of kept) {
|
|
21538
22805
|
const id = edges.get(hub);
|
|
21539
|
-
if (id && this.host.linkReady(id, 2))
|
|
22806
|
+
if (id && this.host.linkReady(id, 2)) {
|
|
22807
|
+
live.lobbyChecks?.delete(hub);
|
|
22808
|
+
continue;
|
|
22809
|
+
}
|
|
22810
|
+
const check = live.lobbyChecks?.get(hub);
|
|
22811
|
+
if (check && now >= check.at) {
|
|
22812
|
+
live.lobbyChecks.delete(hub);
|
|
22813
|
+
if (await this.lobbyLost(groupId, live, hub, check.ts)) live.lobbyWrites.delete(hub);
|
|
22814
|
+
}
|
|
21540
22815
|
if (now - (live.lobbyWrites.get(hub) ?? 0) >= this.timings.lobbyWriteMs) {
|
|
21541
22816
|
live.lobbyWrites.set(hub, now);
|
|
21542
22817
|
await this.askHub(groupId, live, hub, now).catch(() => {
|
|
@@ -21809,8 +23084,26 @@ var Communities = class {
|
|
|
21809
23084
|
key: live.session.myKey,
|
|
21810
23085
|
ts: theirs
|
|
21811
23086
|
}, theirs)), true, true);
|
|
23087
|
+
(live.lobbyChecks ??= /* @__PURE__ */ new Map()).set(hub, {
|
|
23088
|
+
at: now + LOBBY_CHECK_MS + Math.floor(this.random() * LOBBY_CHECK_JITTER_MS),
|
|
23089
|
+
ts: Math.floor(theirs / 1e3) * 1e3
|
|
23090
|
+
});
|
|
21812
23091
|
traceJoin(groupId, "lobby.written");
|
|
21813
23092
|
}
|
|
23093
|
+
/**
|
|
23094
|
+
* My request is not in the hub's lobby any more, and the lobby has room for it: another member's write, made from a
|
|
23095
|
+
* reading from before mine, dropped it (`LOBBY_CHECK_MS`). A full lobby that pushed it out is left as it is: asking
|
|
23096
|
+
* again there would push out someone else. A read that fails says nothing, and the usual refresh asks again.
|
|
23097
|
+
*/
|
|
23098
|
+
async lobbyLost(groupId, live, hub, ts) {
|
|
23099
|
+
const keys = lobbyKeys(live.session.state.rv, groupId, hub);
|
|
23100
|
+
const records = await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => void 0);
|
|
23101
|
+
if (records === void 0) return false;
|
|
23102
|
+
const entries = readLobby(keys, records ?? []);
|
|
23103
|
+
const lost = !entries.some((e) => e.key === live.session.myKey && e.ts >= ts) && entries.length < COMMUNITY_TOPOLOGY.lobbyEntries;
|
|
23104
|
+
if (lost) traceJoin(groupId, "lobby.lost");
|
|
23105
|
+
return lost;
|
|
23106
|
+
}
|
|
21814
23107
|
async pollLobby(groupId, live, now) {
|
|
21815
23108
|
const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
|
|
21816
23109
|
const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => null) ?? []);
|
|
@@ -21935,10 +23228,19 @@ var Communities = class {
|
|
|
21935
23228
|
}
|
|
21936
23229
|
const records = this.knockRecordsToRead(live, door === s.myKey, busy, now);
|
|
21937
23230
|
if (!records.length) return;
|
|
23231
|
+
let failed = 0;
|
|
21938
23232
|
const read = await Promise.all(records.map(async (n) => {
|
|
21939
23233
|
const record = knockRecord(link, n);
|
|
21940
|
-
return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() =>
|
|
23234
|
+
return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() => {
|
|
23235
|
+
failed++;
|
|
23236
|
+
return null;
|
|
23237
|
+
}) ?? []);
|
|
21941
23238
|
}));
|
|
23239
|
+
traceJoin(groupId, "knock.read", {
|
|
23240
|
+
records: records.length,
|
|
23241
|
+
knocks: read.flat().length,
|
|
23242
|
+
...failed ? { failed } : {}
|
|
23243
|
+
});
|
|
21942
23244
|
const bell = records.indexOf(KNOCK_BELL);
|
|
21943
23245
|
if (bell >= 0 && read[bell].filter((k) => now - k.ts < 18e4).length >= BELL_FULL) live.crowdUntil = now + CROWD_MS;
|
|
21944
23246
|
const newest = /* @__PURE__ */ new Map();
|
|
@@ -21997,10 +23299,16 @@ var Communities = class {
|
|
|
21997
23299
|
* Leaves (or refreshes) my knock: in the bell while it has room, in my own record once a crowd has
|
|
21998
23300
|
* filled it. The first one goes out at once; refreshes are background requests, and keep my side of
|
|
21999
23301
|
* the entry session looking fast while I still expect a member to answer soon.
|
|
23302
|
+
*
|
|
23303
|
+
* "The first" is the first of this run that the relays took (`knocked`), not the first tried: a knock the relays'
|
|
23304
|
+
* budget refused is tried again as one, every `UNKNOCKED_RETRY_MS`. Taken for a refresh, it went as a background
|
|
23305
|
+
* request, every `knockMs`, and background requests are held to a few a minute while a link of this app signals: its
|
|
23306
|
+
* own entry session, looking fast for the door's answer. A CLI that was the door of another community, its budget
|
|
23307
|
+
* spent, never knocked at all (groupCompat, with the budget scaled down to 12 requests a minute).
|
|
22000
23308
|
*/
|
|
22001
23309
|
async knock(group, now = this.now()) {
|
|
22002
23310
|
const joining = group.joining;
|
|
22003
|
-
const first = !this.
|
|
23311
|
+
const first = !this.knocked.has(group.id);
|
|
22004
23312
|
this.lastKnock.set(group.id, now);
|
|
22005
23313
|
if (first || now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
|
|
22006
23314
|
const me = identityFromSeedB64(joining.seedB64).pubKeyZ32, link = {
|
|
@@ -22015,16 +23323,37 @@ var Communities = class {
|
|
|
22015
23323
|
knocks: readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, !first) ?? [])
|
|
22016
23324
|
};
|
|
22017
23325
|
};
|
|
22018
|
-
|
|
22019
|
-
|
|
22020
|
-
|
|
22021
|
-
|
|
22022
|
-
|
|
22023
|
-
|
|
23326
|
+
const kept = this.knockRead.get(group.id);
|
|
23327
|
+
this.knockRead.delete(group.id);
|
|
23328
|
+
const reuse = kept && now - kept.at < this.timings.knockMs ? kept : void 0;
|
|
23329
|
+
let n, record, knocks;
|
|
23330
|
+
if (reuse) {
|
|
23331
|
+
n = reuse.n;
|
|
23332
|
+
knocks = reuse.knocks;
|
|
23333
|
+
record = knockRecord(link, n);
|
|
23334
|
+
} else {
|
|
23335
|
+
n = this.knockAt.get(group.id) ?? KNOCK_BELL;
|
|
23336
|
+
({record, knocks} = await read(n));
|
|
23337
|
+
if (n === KNOCK_BELL && !knocks.some((k) => k.key === me) && knocks.filter((k) => k.key !== me && now - k.ts < 18e4).length >= BELL_FULL) ({record, knocks} = await read(n = ownKnockRecord(me)));
|
|
23338
|
+
}
|
|
23339
|
+
try {
|
|
23340
|
+
await this.host.publish(knockIdentity(record), knockRecords(record, mergeKnocks(knocks, {
|
|
23341
|
+
key: me,
|
|
23342
|
+
ts: now
|
|
23343
|
+
}, now)), !first);
|
|
23344
|
+
} catch (error) {
|
|
23345
|
+
this.knockRead.set(group.id, {
|
|
23346
|
+
n,
|
|
23347
|
+
knocks,
|
|
23348
|
+
at: reuse?.at ?? now
|
|
23349
|
+
});
|
|
23350
|
+
throw error;
|
|
23351
|
+
}
|
|
22024
23352
|
this.knockAt.set(group.id, n);
|
|
22025
23353
|
traceJoin(group.id, "knock.published", {
|
|
22026
23354
|
ms: Date.now() - started,
|
|
22027
|
-
record: n
|
|
23355
|
+
record: n,
|
|
23356
|
+
...reuse && { kept: true }
|
|
22028
23357
|
});
|
|
22029
23358
|
const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
|
|
22030
23359
|
const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
|
|
@@ -22161,6 +23490,7 @@ var Communities = class {
|
|
|
22161
23490
|
this.knocked.delete(g);
|
|
22162
23491
|
this.knockAt.delete(g);
|
|
22163
23492
|
this.knockingWith.delete(g);
|
|
23493
|
+
this.knockRead.delete(g);
|
|
22164
23494
|
await this.host.closeEdge(linkId);
|
|
22165
23495
|
await this.reconcile(g, live, now);
|
|
22166
23496
|
this.host.emit();
|
|
@@ -23583,6 +24913,7 @@ var Groups = class {
|
|
|
23583
24913
|
isAdmin: false,
|
|
23584
24914
|
members: [],
|
|
23585
24915
|
canSend: false,
|
|
24916
|
+
...group.formerNames ? { formerNames: group.formerNames } : {},
|
|
23586
24917
|
invitation: {
|
|
23587
24918
|
linkId: invitation.linkId,
|
|
23588
24919
|
contact: this.host.contactName(invitation.linkId) ?? "",
|
|
@@ -24380,6 +25711,10 @@ var Groups = class {
|
|
|
24380
25711
|
if (existing?.state && existing.state.status === "active") return;
|
|
24381
25712
|
if (existing?.invitation?.seedB64) return;
|
|
24382
25713
|
if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
|
|
25714
|
+
const known = {
|
|
25715
|
+
...existing?.formerNames,
|
|
25716
|
+
...existing?.state?.nicks
|
|
25717
|
+
};
|
|
24383
25718
|
const invitation = {
|
|
24384
25719
|
id: g,
|
|
24385
25720
|
createdAt: Date.now(),
|
|
@@ -24390,7 +25725,8 @@ var Groups = class {
|
|
|
24390
25725
|
e: Number.isSafeInteger(frame.e) ? frame.e : 0,
|
|
24391
25726
|
n: Number.isSafeInteger(frame.n) ? frame.n : 1,
|
|
24392
25727
|
pieces: []
|
|
24393
|
-
}
|
|
25728
|
+
},
|
|
25729
|
+
...Object.keys(known).length ? { formerNames: known } : {}
|
|
24394
25730
|
};
|
|
24395
25731
|
if (existing) {
|
|
24396
25732
|
this.sessions.delete(g);
|
|
@@ -24483,6 +25819,8 @@ var Groups = class {
|
|
|
24483
25819
|
});
|
|
24484
25820
|
await this.store.putGroup(member);
|
|
24485
25821
|
if (!viaLink) await this.sessions.get(g).setNick(group.invitation.admin, this.host.contactName(linkId));
|
|
25822
|
+
const session = this.sessions.get(g);
|
|
25823
|
+
for (const [key, nick] of Object.entries(group.formerNames ?? {})) if (!session.state.nicks[key]) await session.setNick(key, nick);
|
|
24486
25824
|
await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE}`, Date.now(), joined.state.chain.length - 1);
|
|
24487
25825
|
if (viaLink) traceJoin(g, "welcome.received");
|
|
24488
25826
|
if (viaLink) {
|
|
@@ -28613,7 +29951,8 @@ var CashuWallet = class CashuWallet {
|
|
|
28613
29951
|
let swap = (await this.swapsAt(mint)).find((s) => s.kind === "receive" && s.token === token);
|
|
28614
29952
|
if (swap?.finished) return {
|
|
28615
29953
|
amount: swap.tx?.amount ?? 0,
|
|
28616
|
-
mint
|
|
29954
|
+
mint,
|
|
29955
|
+
fee: swap.tx?.fee ?? 0
|
|
28617
29956
|
};
|
|
28618
29957
|
if (!swap) {
|
|
28619
29958
|
const preview = await (await this.wallet(mint)).prepareSwapToReceive(token);
|
|
@@ -28643,7 +29982,8 @@ var CashuWallet = class CashuWallet {
|
|
|
28643
29982
|
this.events.onChange();
|
|
28644
29983
|
return {
|
|
28645
29984
|
amount: credited,
|
|
28646
|
-
mint
|
|
29985
|
+
mint,
|
|
29986
|
+
fee: Math.max(0, faceValue - credited)
|
|
28647
29987
|
};
|
|
28648
29988
|
});
|
|
28649
29989
|
}
|
|
@@ -29279,6 +30619,17 @@ var CashuWallet = class CashuWallet {
|
|
|
29279
30619
|
* taken back.
|
|
29280
30620
|
*/
|
|
29281
30621
|
var UNIT = "sat";
|
|
30622
|
+
var satsWord = (n) => `${n} sat${n === 1 ? "" : "s"}`;
|
|
30623
|
+
/**
|
|
30624
|
+
* A refused send's review, once its ecash came back: why, and exactly what came back. Taking ecash back is a swap at
|
|
30625
|
+
* the mint, which can keep a fee: "The sats came back" said nothing of it. Without `back` (Fedimint, or not known),
|
|
30626
|
+
* the plain sentence.
|
|
30627
|
+
*/
|
|
30628
|
+
var refusedLine = (reason, back) => {
|
|
30629
|
+
const why = `Refused: ${reason.replace(/\.+$/, "")}.`;
|
|
30630
|
+
if (!back) return `${why} The sats came back.`;
|
|
30631
|
+
return back.fee > 0 ? `${why} ${satsWord(back.amount)} came back; the mint kept ${back.fee} as its fee.` : `${why} All ${satsWord(back.amount)} came back.`;
|
|
30632
|
+
};
|
|
29282
30633
|
var mintLightning = (wallet, network) => ({
|
|
29283
30634
|
createInvoice: (amount, paymentId) => wallet.receiveLightning(amount, paymentId, network),
|
|
29284
30635
|
quote: (invoice) => wallet.quoteInvoice(invoice, network),
|
|
@@ -29914,7 +31265,10 @@ var PaymentDesk = class {
|
|
|
29914
31265
|
endpoint: [ENDPOINT.bolt11, request.invoice]
|
|
29915
31266
|
}).catch(() => {});
|
|
29916
31267
|
}
|
|
29917
|
-
/**
|
|
31268
|
+
/**
|
|
31269
|
+
* Takes back ecash the peer never redeemed. If they did redeem it, the mint says so and the payment is settled.
|
|
31270
|
+
* Resolves to what came back of Cashu ecash (and the mint's fee for it); undefined when nothing did, or not Cashu.
|
|
31271
|
+
*/
|
|
29918
31272
|
reclaim(paymentId) {
|
|
29919
31273
|
let running = this.reclaims.get(paymentId);
|
|
29920
31274
|
if (!running) {
|
|
@@ -29945,7 +31299,7 @@ var PaymentDesk = class {
|
|
|
29945
31299
|
}
|
|
29946
31300
|
if (!payment?.token || payment.direction !== "out") throw new Error("Nothing to reclaim");
|
|
29947
31301
|
try {
|
|
29948
|
-
await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo, { payment: () => ({
|
|
31302
|
+
const { amount, fee } = await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo, { payment: () => ({
|
|
29949
31303
|
...this.current(payment),
|
|
29950
31304
|
state: "reclaimed",
|
|
29951
31305
|
token: void 0
|
|
@@ -29955,6 +31309,10 @@ var PaymentDesk = class {
|
|
|
29955
31309
|
state: "reclaimed",
|
|
29956
31310
|
token: void 0
|
|
29957
31311
|
});
|
|
31312
|
+
return {
|
|
31313
|
+
amount,
|
|
31314
|
+
fee
|
|
31315
|
+
};
|
|
29958
31316
|
} catch (error) {
|
|
29959
31317
|
const message = error instanceof Error ? error.message : String(error);
|
|
29960
31318
|
if (!/spent/i.test(message)) throw error;
|
|
@@ -29964,6 +31322,7 @@ var PaymentDesk = class {
|
|
|
29964
31322
|
state: "settled",
|
|
29965
31323
|
token: void 0
|
|
29966
31324
|
});
|
|
31325
|
+
return;
|
|
29967
31326
|
}
|
|
29968
31327
|
}
|
|
29969
31328
|
/** Asks this side made to pay, until the contact answers with a request (or two minutes pass). */
|
|
@@ -30379,8 +31738,8 @@ var PaymentDesk = class {
|
|
|
30379
31738
|
...payment,
|
|
30380
31739
|
error: result.error ?? "The payment was refused"
|
|
30381
31740
|
});
|
|
30382
|
-
await this.reclaim(payment.id).catch(() =>
|
|
30383
|
-
if (this.current(payment).state === "reclaimed") await this.host.onReviewedPaymentRefused?.(payment.id, result.error ?? "The payment was refused");
|
|
31741
|
+
const back = await this.reclaim(payment.id).catch(() => void 0);
|
|
31742
|
+
if (this.current(payment).state === "reclaimed") await this.host.onReviewedPaymentRefused?.(payment.id, result.error ?? "The payment was refused", back);
|
|
30384
31743
|
return;
|
|
30385
31744
|
}
|
|
30386
31745
|
Promise.resolve(this.host.onReviewedPaymentResult?.(payment.id)).catch(() => {});
|
|
@@ -32053,203 +33412,6 @@ var WalletSetup = class {
|
|
|
32053
33412
|
}
|
|
32054
33413
|
};
|
|
32055
33414
|
//#endregion
|
|
32056
|
-
//#region ../browser/src/platform/irohWeb.ts
|
|
32057
|
-
/**
|
|
32058
|
-
* Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
|
|
32059
|
-
* Desktop's native Iroh, but relay only. A page cannot send UDP, so every
|
|
32060
|
-
* packet goes through an Iroh relay over a WebSocket; the relay sees who talks
|
|
32061
|
-
* to whom and when, never the frames.
|
|
32062
|
-
*
|
|
32063
|
-
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
32064
|
-
* never on the app's first load.
|
|
32065
|
-
*/
|
|
32066
|
-
/**
|
|
32067
|
-
* n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
|
|
32068
|
-
* dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
|
|
32069
|
-
* because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
|
|
32070
|
-
*/
|
|
32071
|
-
var DEFAULT_IROH_RELAYS = [
|
|
32072
|
-
"https://use1-1.relay.n0.iroh.link/",
|
|
32073
|
-
"https://euc1-1.relay.n0.iroh.link/",
|
|
32074
|
-
"https://aps1-1.relay.n0.iroh.link/",
|
|
32075
|
-
"https://usw1-1.relay.n0.iroh.link/"
|
|
32076
|
-
];
|
|
32077
|
-
/**
|
|
32078
|
-
* The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
|
|
32079
|
-
* relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
|
|
32080
|
-
* open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
|
|
32081
|
-
*/
|
|
32082
|
-
var BROWSER_SPELLING = "plain";
|
|
32083
|
-
/**
|
|
32084
|
-
* Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
|
|
32085
|
-
* may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
|
|
32086
|
-
*/
|
|
32087
|
-
function irohRelayProblem(value) {
|
|
32088
|
-
let url;
|
|
32089
|
-
try {
|
|
32090
|
-
url = new URL(value);
|
|
32091
|
-
} catch {
|
|
32092
|
-
return `Not a relay address: ${value}`;
|
|
32093
|
-
}
|
|
32094
|
-
const local = [
|
|
32095
|
-
"localhost",
|
|
32096
|
-
"127.0.0.1",
|
|
32097
|
-
"[::1]"
|
|
32098
|
-
].includes(url.hostname);
|
|
32099
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
|
|
32100
|
-
if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
|
|
32101
|
-
return null;
|
|
32102
|
-
}
|
|
32103
|
-
var loaded = null;
|
|
32104
|
-
/** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
|
|
32105
|
-
function loadIrohWasm() {
|
|
32106
|
-
loaded ??= import("./ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
|
|
32107
|
-
await module.default();
|
|
32108
|
-
return module;
|
|
32109
|
-
}).catch((error) => {
|
|
32110
|
-
loaded = null;
|
|
32111
|
-
throw error;
|
|
32112
|
-
});
|
|
32113
|
-
return loaded;
|
|
32114
|
-
}
|
|
32115
|
-
var SEND_BUDGET = 122880;
|
|
32116
|
-
var MAX_FRAME = 61440;
|
|
32117
|
-
/** One paired channel over one QUIC stream. Sends are queued in order. */
|
|
32118
|
-
var IrohWebChannel = class {
|
|
32119
|
-
conn;
|
|
32120
|
-
forget;
|
|
32121
|
-
reader = null;
|
|
32122
|
-
buffered = [];
|
|
32123
|
-
queue = Promise.resolve();
|
|
32124
|
-
closed = false;
|
|
32125
|
-
bufferedAmount = 0;
|
|
32126
|
-
sendBudget = SEND_BUDGET;
|
|
32127
|
-
onClose = null;
|
|
32128
|
-
constructor(conn, forget) {
|
|
32129
|
-
this.conn = conn;
|
|
32130
|
-
this.forget = forget;
|
|
32131
|
-
this.read();
|
|
32132
|
-
}
|
|
32133
|
-
get onMessage() {
|
|
32134
|
-
return this.reader;
|
|
32135
|
-
}
|
|
32136
|
-
set onMessage(reader) {
|
|
32137
|
-
this.reader = reader;
|
|
32138
|
-
if (reader) for (const text of this.buffered.splice(0)) reader(text);
|
|
32139
|
-
}
|
|
32140
|
-
async read() {
|
|
32141
|
-
try {
|
|
32142
|
-
for (;;) {
|
|
32143
|
-
const text = await this.conn.recv();
|
|
32144
|
-
if (text === void 0 || this.closed) break;
|
|
32145
|
-
if (this.reader) this.reader(text);
|
|
32146
|
-
else if (this.buffered.length < 64) this.buffered.push(text);
|
|
32147
|
-
else break;
|
|
32148
|
-
}
|
|
32149
|
-
} catch {}
|
|
32150
|
-
this.close();
|
|
32151
|
-
}
|
|
32152
|
-
send(data) {
|
|
32153
|
-
if (typeof data !== "string" || this.closed || !data.length || data.length > MAX_FRAME || this.bufferedAmount + data.length > SEND_BUDGET) throw new Error("Iroh channel is closed or its send budget is full");
|
|
32154
|
-
this.bufferedAmount += data.length;
|
|
32155
|
-
this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
|
|
32156
|
-
this.bufferedAmount -= data.length;
|
|
32157
|
-
});
|
|
32158
|
-
}
|
|
32159
|
-
drained() {
|
|
32160
|
-
return this.queue;
|
|
32161
|
-
}
|
|
32162
|
-
get rttMs() {
|
|
32163
|
-
const rtt = this.conn.rtt();
|
|
32164
|
-
return rtt >= 0 ? rtt : void 0;
|
|
32165
|
-
}
|
|
32166
|
-
close() {
|
|
32167
|
-
if (this.closed) return;
|
|
32168
|
-
this.closed = true;
|
|
32169
|
-
this.buffered = [];
|
|
32170
|
-
try {
|
|
32171
|
-
this.conn.close();
|
|
32172
|
-
} catch {}
|
|
32173
|
-
this.forget();
|
|
32174
|
-
this.onClose?.();
|
|
32175
|
-
}
|
|
32176
|
-
};
|
|
32177
|
-
/** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
|
|
32178
|
-
async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
32179
|
-
const seed = fromBase64Url(seedB64);
|
|
32180
|
-
if (seed.length !== 32) throw new Error("Invalid transport seed");
|
|
32181
|
-
const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
|
|
32182
|
-
const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
|
|
32183
|
-
const channels = /* @__PURE__ */ new Set();
|
|
32184
|
-
let stopped = false;
|
|
32185
|
-
let handler = null;
|
|
32186
|
-
const early = [];
|
|
32187
|
-
const bind = (conn) => {
|
|
32188
|
-
const channel = new IrohWebChannel(conn, () => channels.delete(channel));
|
|
32189
|
-
channels.add(channel);
|
|
32190
|
-
return {
|
|
32191
|
-
channel,
|
|
32192
|
-
binding: conn.binding()
|
|
32193
|
-
};
|
|
32194
|
-
};
|
|
32195
|
-
const endpoint = {
|
|
32196
|
-
transport: "iroh/1",
|
|
32197
|
-
descriptor: {
|
|
32198
|
-
...node.address(),
|
|
32199
|
-
relayed: true
|
|
32200
|
-
},
|
|
32201
|
-
onDescriptor: null,
|
|
32202
|
-
get onConnection() {
|
|
32203
|
-
return handler;
|
|
32204
|
-
},
|
|
32205
|
-
set onConnection(value) {
|
|
32206
|
-
handler = value;
|
|
32207
|
-
if (value) for (const bound of early.splice(0)) value(bound);
|
|
32208
|
-
},
|
|
32209
|
-
async connect(descriptor) {
|
|
32210
|
-
if (stopped) throw new Error("Iroh endpoint is stopped");
|
|
32211
|
-
const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
|
|
32212
|
-
if (stopped) {
|
|
32213
|
-
conn.close();
|
|
32214
|
-
throw new Error("Iroh endpoint is stopped");
|
|
32215
|
-
}
|
|
32216
|
-
return bind(conn);
|
|
32217
|
-
},
|
|
32218
|
-
async close() {
|
|
32219
|
-
if (stopped) return;
|
|
32220
|
-
stopped = true;
|
|
32221
|
-
for (const channel of [...channels]) channel.close();
|
|
32222
|
-
for (const bound of early.splice(0)) bound.channel.close();
|
|
32223
|
-
await node.close().catch(() => {});
|
|
32224
|
-
}
|
|
32225
|
-
};
|
|
32226
|
-
(async () => {
|
|
32227
|
-
while (!stopped) {
|
|
32228
|
-
let conn;
|
|
32229
|
-
try {
|
|
32230
|
-
conn = await node.accept();
|
|
32231
|
-
} catch {
|
|
32232
|
-
break;
|
|
32233
|
-
}
|
|
32234
|
-
if (!conn) break;
|
|
32235
|
-
if (stopped) {
|
|
32236
|
-
conn.close();
|
|
32237
|
-
break;
|
|
32238
|
-
}
|
|
32239
|
-
const bound = bind(conn);
|
|
32240
|
-
const accept = endpoint.onConnection;
|
|
32241
|
-
if (accept) accept(bound);
|
|
32242
|
-
else if (early.length < 4) early.push(bound);
|
|
32243
|
-
else bound.channel.close();
|
|
32244
|
-
}
|
|
32245
|
-
if (!stopped) {
|
|
32246
|
-
endpoint.onUnavailable?.();
|
|
32247
|
-
await endpoint.close();
|
|
32248
|
-
}
|
|
32249
|
-
})();
|
|
32250
|
-
return endpoint;
|
|
32251
|
-
}
|
|
32252
|
-
//#endregion
|
|
32253
33415
|
//#region ../browser/src/engine/node.ts
|
|
32254
33416
|
/** How long a chat waits before listening again after its HyperDHT relay went away. */
|
|
32255
33417
|
var RELAY_RETRY_MS = 3e4;
|
|
@@ -32793,14 +33955,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
32793
33955
|
onReviewedPaymentResult: async (id) => {
|
|
32794
33956
|
await this.reconcilePayment({ id });
|
|
32795
33957
|
},
|
|
32796
|
-
onReviewedPaymentRefused: async (id, reason) => {
|
|
33958
|
+
onReviewedPaymentRefused: async (id, reason, back) => {
|
|
32797
33959
|
const intent = await intentRepository.get(id);
|
|
32798
33960
|
if (intent && ["submitted", "unknown"].includes(intent.review.state)) await intentRepository.put({
|
|
32799
33961
|
...intent,
|
|
32800
33962
|
review: {
|
|
32801
33963
|
...intent.review,
|
|
32802
33964
|
state: "failed",
|
|
32803
|
-
error:
|
|
33965
|
+
error: refusedLine(reason, back),
|
|
33966
|
+
...back ? { returned: back } : {}
|
|
32804
33967
|
}
|
|
32805
33968
|
});
|
|
32806
33969
|
this.emitState();
|
|
@@ -33731,6 +34894,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
33731
34894
|
this.prepareSpare(STARTUP_QUIET_MS);
|
|
33732
34895
|
}
|
|
33733
34896
|
if (!this.limitedMode) this.openStartedWallets(fresh);
|
|
34897
|
+
if (this.appsOn) this.appStore.start().catch(() => {});
|
|
33734
34898
|
if (!this.options.singleDevice && this.networkOn && knownDeviceGate()?.state === "active") this.startDeviceSet().catch(() => {});
|
|
33735
34899
|
if (!this.options.singleDevice && this.limitedMode && knownDeviceGate()?.state === "active") this.readTurnWhileLimited();
|
|
33736
34900
|
if (this.restoreCheck === "checking" && this.limitedMode) this.checkRestoredTurn(0);
|
|
@@ -34115,6 +35279,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34115
35279
|
clearTimeout(this.limitedTimer);
|
|
34116
35280
|
this.limitedTimer = null;
|
|
34117
35281
|
}
|
|
35282
|
+
this.appShelf?.stop();
|
|
34118
35283
|
if (!quiet) this.depart();
|
|
34119
35284
|
this.directPath.close();
|
|
34120
35285
|
this.clock.close();
|
|
@@ -34187,6 +35352,83 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34187
35352
|
await this.shutdown();
|
|
34188
35353
|
await clearProfileStores();
|
|
34189
35354
|
}
|
|
35355
|
+
appShelf;
|
|
35356
|
+
/** The installed apps and stores, refused while the apps feature is off. */
|
|
35357
|
+
get appStore() {
|
|
35358
|
+
if (!this.appsOn) throw new Error("Apps are unavailable in this release");
|
|
35359
|
+
return this.appShelf ??= new Apps({
|
|
35360
|
+
fetch: boundedAppFetch({
|
|
35361
|
+
fetcher: this.options.appFetch,
|
|
35362
|
+
online: () => this.networkOn
|
|
35363
|
+
}),
|
|
35364
|
+
isChat: (scope) => {
|
|
35365
|
+
const stored = this.links.get(scope)?.stored;
|
|
35366
|
+
return !!stored && !stored.group && !!stored.profile && !!stored.pairedPeerKey;
|
|
35367
|
+
},
|
|
35368
|
+
online: () => this.networkOn && !this.shuttingDown,
|
|
35369
|
+
checked: () => this.events.onAppsChecked?.()
|
|
35370
|
+
});
|
|
35371
|
+
}
|
|
35372
|
+
appList() {
|
|
35373
|
+
return this.appStore.list();
|
|
35374
|
+
}
|
|
35375
|
+
appStoreList() {
|
|
35376
|
+
return this.appStore.listStores();
|
|
35377
|
+
}
|
|
35378
|
+
appStorePreview(params) {
|
|
35379
|
+
return this.appStore.previewStore(params);
|
|
35380
|
+
}
|
|
35381
|
+
appStoreAdd(params) {
|
|
35382
|
+
return this.appStore.addStore(params);
|
|
35383
|
+
}
|
|
35384
|
+
appStoreRemove(params) {
|
|
35385
|
+
return this.appStore.removeStore(params);
|
|
35386
|
+
}
|
|
35387
|
+
appStoreRefresh(params = {}) {
|
|
35388
|
+
return this.appStore.refreshStores(params);
|
|
35389
|
+
}
|
|
35390
|
+
appPreview(params) {
|
|
35391
|
+
return this.appStore.preview(params);
|
|
35392
|
+
}
|
|
35393
|
+
appInstall(params) {
|
|
35394
|
+
return this.appStore.install(params);
|
|
35395
|
+
}
|
|
35396
|
+
appUpdateAccept(params) {
|
|
35397
|
+
return this.appStore.acceptUpdate(params);
|
|
35398
|
+
}
|
|
35399
|
+
appCheckUpdates() {
|
|
35400
|
+
return this.appStore.checkUpdates();
|
|
35401
|
+
}
|
|
35402
|
+
appUninstall(params) {
|
|
35403
|
+
return this.appStore.uninstall(params);
|
|
35404
|
+
}
|
|
35405
|
+
appRunCheck(params) {
|
|
35406
|
+
return this.appStore.runCheck(params);
|
|
35407
|
+
}
|
|
35408
|
+
appFetchFiles(params) {
|
|
35409
|
+
return this.appStore.fetchFiles(params);
|
|
35410
|
+
}
|
|
35411
|
+
appEntry(params) {
|
|
35412
|
+
return this.appStore.entry(params);
|
|
35413
|
+
}
|
|
35414
|
+
appFile(params) {
|
|
35415
|
+
return this.appStore.file(params);
|
|
35416
|
+
}
|
|
35417
|
+
appStorageGet(params) {
|
|
35418
|
+
return this.appStore.storageGet(params);
|
|
35419
|
+
}
|
|
35420
|
+
appStorageSet(params) {
|
|
35421
|
+
return this.appStore.storageSet(params);
|
|
35422
|
+
}
|
|
35423
|
+
appStorageDelete(params) {
|
|
35424
|
+
return this.appStore.storageDelete(params);
|
|
35425
|
+
}
|
|
35426
|
+
appStorageKeys(params) {
|
|
35427
|
+
return this.appStore.storageKeys(params);
|
|
35428
|
+
}
|
|
35429
|
+
appDataExport(params) {
|
|
35430
|
+
return this.appStore.exportData(params);
|
|
35431
|
+
}
|
|
34190
35432
|
getState() {
|
|
34191
35433
|
const groups = this.groups.views();
|
|
34192
35434
|
return {
|
|
@@ -35033,7 +36275,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35033
36275
|
this.transport.networkChanged?.();
|
|
35034
36276
|
this.directPath.reset();
|
|
35035
36277
|
}
|
|
35036
|
-
for (const live of this.links.values()) live.link?.wake();
|
|
36278
|
+
for (const live of this.links.values()) live.link?.wake({ network: params.network });
|
|
35037
36279
|
this.hold.wake();
|
|
35038
36280
|
for (const network of WALLET_NETWORKS) {
|
|
35039
36281
|
this.lightnings[network].wake();
|
|
@@ -36971,6 +38213,58 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36971
38213
|
this.flushPin(linkId);
|
|
36972
38214
|
return { error: null };
|
|
36973
38215
|
}
|
|
38216
|
+
/** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
|
|
38217
|
+
get appsOn() {
|
|
38218
|
+
return this.options.apps ?? false;
|
|
38219
|
+
}
|
|
38220
|
+
/** A paired 1:1 chat and the chat app id of `ref` in it, from the two pinned participation keys (WISP 1200 § In a chat). */
|
|
38221
|
+
appChat(linkId, ref) {
|
|
38222
|
+
if (!this.appsOn) throw new Error("Apps are unavailable in this release");
|
|
38223
|
+
const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
|
|
38224
|
+
if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
|
|
38225
|
+
if (!isAppRef(ref)) throw new Error("Not an app reference");
|
|
38226
|
+
const { participationSeed, pairedPeerKey } = live.stored;
|
|
38227
|
+
if (!participationSeed || !pairedPeerKey) throw new Error("This chat is not paired yet");
|
|
38228
|
+
return {
|
|
38229
|
+
live,
|
|
38230
|
+
app: chatAppId(identityFromSeedB64(participationSeed).publicKey, publicKeyFromZ32(pairedPeerKey), ref)
|
|
38231
|
+
};
|
|
38232
|
+
}
|
|
38233
|
+
/** The chat app id of an app in a paired chat: how the contact's frames name it. */
|
|
38234
|
+
appId({ linkId, ref }) {
|
|
38235
|
+
return { app: this.appChat(linkId, ref).app };
|
|
38236
|
+
}
|
|
38237
|
+
/**
|
|
38238
|
+
* This side opened an app in a paired chat, or updated it: `open` goes to the contact now when the session is live,
|
|
38239
|
+
* and again on every session that comes back while it stays open.
|
|
38240
|
+
*/
|
|
38241
|
+
async appOpen({ linkId, ref, version }) {
|
|
38242
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38243
|
+
if (!isAppVersion(version)) throw new Error("Not an app version");
|
|
38244
|
+
await this.appStore.chatRunnable({ ref });
|
|
38245
|
+
const open = live.appsOpen ??= /* @__PURE__ */ new Map();
|
|
38246
|
+
if (live.link) live.link.openApp(app, version);
|
|
38247
|
+
else open.set(app, version);
|
|
38248
|
+
return { app };
|
|
38249
|
+
}
|
|
38250
|
+
/** This side closed an app in a paired chat: `close` goes now when the session is live. */
|
|
38251
|
+
appClose({ linkId, ref }) {
|
|
38252
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38253
|
+
if (live.link) live.link.closeApp(app);
|
|
38254
|
+
else live.appsOpen?.delete(app);
|
|
38255
|
+
}
|
|
38256
|
+
/** One data frame of an app open on both sides, on the live session only; nothing is kept to send later. */
|
|
38257
|
+
async appSend({ linkId, ref, data }) {
|
|
38258
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38259
|
+
if (!live.appsOpen?.has(app)) return { error: "not-open" };
|
|
38260
|
+
await this.appStore.chatRunnable({ ref });
|
|
38261
|
+
return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
|
|
38262
|
+
}
|
|
38263
|
+
/** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
|
|
38264
|
+
appsCloseAll() {
|
|
38265
|
+
for (const live of this.links.values()) for (const app of [...live.appsOpen?.keys() ?? []]) if (live.link) live.link.closeApp(app);
|
|
38266
|
+
else live.appsOpen?.delete(app);
|
|
38267
|
+
}
|
|
36974
38268
|
/** Keeps a pin of a 1:1 chat when it is newer than the chat's; `out`: mine, to say until the contact confirms it. */
|
|
36975
38269
|
async keepLinkPin(linkId, pin, out) {
|
|
36976
38270
|
const live = this.links.get(linkId);
|
|
@@ -38126,7 +39420,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38126
39420
|
...settings
|
|
38127
39421
|
};
|
|
38128
39422
|
if (!this.settings.irohRelays?.length) delete this.settings.irohRelays;
|
|
38129
|
-
if (irohRelaysChanged) this.rehomeIroh();
|
|
39423
|
+
if (irohRelaysChanged && !this.options.irohRelays?.length) this.rehomeIroh();
|
|
38130
39424
|
if (settings.relays) {
|
|
38131
39425
|
this.relays?.setRelays(settings.relays);
|
|
38132
39426
|
if (this.relays) this.settings.relays = this.relays.describe().relays;
|
|
@@ -38624,6 +39918,17 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38624
39918
|
}
|
|
38625
39919
|
}
|
|
38626
39920
|
/**
|
|
39921
|
+
* Whether a device code may be used in this profile now, asked before the person presses Add (WISP 06 § User
|
|
39922
|
+
* experience): `ready`, or why not: `set` (this profile is already on several devices), `in-use` (it holds something,
|
|
39923
|
+
* `inUse`), `loading` (what it holds is not known yet). The page says up front that a new profile is made for the
|
|
39924
|
+
* code; `deviceEnrollJoin` still decides, and refuses as before.
|
|
39925
|
+
*/
|
|
39926
|
+
async deviceEnrollReady() {
|
|
39927
|
+
if (this.starting) await this.starting;
|
|
39928
|
+
if (await readDeviceRecord(databaseName()).catch(() => null)) return "set";
|
|
39929
|
+
return this.inUse() ?? "ready";
|
|
39930
|
+
}
|
|
39931
|
+
/**
|
|
38627
39932
|
* Whether this profile holds anything a person would lose if it became a standby here: a chat, a group, an identity,
|
|
38628
39933
|
* money or a payment, or a wallet with keys of its own. The wallets a new profile gets by itself (Mainnet Cashu, with
|
|
38629
39934
|
* the Cashu mints' Lightning card, and USDT, `walletSetup.ts`) count only once they hold, wait for or have moved
|
|
@@ -38650,6 +39955,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38650
39955
|
}
|
|
38651
39956
|
/** The giver of this active device: answers a pull, makes a push, runs pass 1 while this engine goes on. */
|
|
38652
39957
|
handoffGiver = null;
|
|
39958
|
+
/** A call is on in one of the pages (`setCallOn`, told by the host for all of them): a handoff waits for it to end. */
|
|
39959
|
+
callOn = false;
|
|
39960
|
+
setCallOn({ on }) {
|
|
39961
|
+
this.callOn = on === true;
|
|
39962
|
+
}
|
|
38653
39963
|
async startHandoff() {
|
|
38654
39964
|
const host = handoffProfileHost(), links = this.deviceLinks;
|
|
38655
39965
|
if (!host || !links || this.handoffGiver || this.options.singleDevice) return;
|
|
@@ -38736,6 +40046,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38736
40046
|
* or anything this build cannot judge. Null: the wallets move or stay home by their plan.
|
|
38737
40047
|
*/
|
|
38738
40048
|
async handoffBusy(taker) {
|
|
40049
|
+
if (this.callOn) return { why: "call" };
|
|
38739
40050
|
if (this.limitedMode || !this.walletsStarted) return { why: "loading" };
|
|
38740
40051
|
await this.refreshWallet().catch(() => {});
|
|
38741
40052
|
const { refusal } = await this.handoffPlan(taker);
|
|
@@ -39330,6 +40641,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39330
40641
|
statusCardSupport: true,
|
|
39331
40642
|
buttonsSupport: true,
|
|
39332
40643
|
wakeSupport: true,
|
|
40644
|
+
upgradeSupport: true,
|
|
40645
|
+
peerUpgrades: () => !!live.caps?.peer?.capabilities.includes(UPGRADE_CAPABILITY),
|
|
40646
|
+
appsSupport: this.appsOn,
|
|
40647
|
+
appsOpen: this.appsOn ? live.appsOpen ??= /* @__PURE__ */ new Map() : void 0,
|
|
39333
40648
|
servicesSupport: this.options.servicesSupport ?? (this.options.platform ?? "web") !== "web",
|
|
39334
40649
|
dht: stored.profile ? {
|
|
39335
40650
|
state: stored.dhtDeliveryState,
|
|
@@ -39635,6 +40950,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39635
40950
|
onPinSupport: (supported) => {
|
|
39636
40951
|
if (supported) this.flushPin(linkId);
|
|
39637
40952
|
},
|
|
40953
|
+
onAppFrame: this.appsOn ? (event) => this.events.onAppFrame?.(linkId, event) : void 0,
|
|
39638
40954
|
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
39639
40955
|
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
39640
40956
|
onPayment: (payment) => this.desk.onPayment(linkId, payment),
|
|
@@ -39728,6 +41044,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39728
41044
|
DHT_FILE_CAPABILITY,
|
|
39729
41045
|
...live?.stored.hold?.enabled ? [HOLD_CAPABILITY] : [],
|
|
39730
41046
|
"files/2",
|
|
41047
|
+
UPGRADE_CAPABILITY,
|
|
39731
41048
|
...GhostlyNode.testNoEdit() ? [] : [EDIT_CAPABILITY],
|
|
39732
41049
|
...cashu || lightning ? ["payments/1"] : [],
|
|
39733
41050
|
...cashu ? ["payments-cashu/1"] : [],
|
|
@@ -39784,7 +41101,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39784
41101
|
get nativeFactories() {
|
|
39785
41102
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
39786
41103
|
return {
|
|
39787
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
41104
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-w9cwVqGZ.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
39788
41105
|
...this.options.nativeTransports,
|
|
39789
41106
|
...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
|
|
39790
41107
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
@@ -39795,6 +41112,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39795
41112
|
return !!this.options.nativeIrohRelays && !!this.options.nativeTransports?.["iroh/1"];
|
|
39796
41113
|
}
|
|
39797
41114
|
get irohRelays() {
|
|
41115
|
+
if (this.options.irohRelays?.length) return [...this.options.irohRelays];
|
|
39798
41116
|
return this.settings.irohRelays?.length ? this.settings.irohRelays : [...DEFAULT_IROH_RELAYS];
|
|
39799
41117
|
}
|
|
39800
41118
|
/** New Iroh relays: idle endpoints move now; one carrying a chat keeps its relay until that session ends. */
|
|
@@ -40336,6 +41654,11 @@ var EngineServer = class {
|
|
|
40336
41654
|
* it too: the extension's peer runs with no tab open, and a tab opened during the ring used to show no call.
|
|
40337
41655
|
*/
|
|
40338
41656
|
offers = /* @__PURE__ */ new Map();
|
|
41657
|
+
/**
|
|
41658
|
+
* The clients a call is on in (`setCallOn`): the engine hears whether any is, so a handoff waits for every page's
|
|
41659
|
+
* call (WISP 06 § States and events). A client that goes away (a tab closed mid-call) is taken out with it.
|
|
41660
|
+
*/
|
|
41661
|
+
onCall = /* @__PURE__ */ new Set();
|
|
40339
41662
|
constructor(options = {}) {
|
|
40340
41663
|
this.node = new GhostlyNode({
|
|
40341
41664
|
onAttention: (event) => this.broadcast({
|
|
@@ -40357,6 +41680,12 @@ var EngineServer = class {
|
|
|
40357
41680
|
signal
|
|
40358
41681
|
});
|
|
40359
41682
|
},
|
|
41683
|
+
onAppFrame: (linkId, event) => this.broadcast({
|
|
41684
|
+
kind: "app-frame",
|
|
41685
|
+
linkId,
|
|
41686
|
+
event
|
|
41687
|
+
}),
|
|
41688
|
+
onAppsChecked: () => this.broadcast({ kind: "apps-checked" }),
|
|
40360
41689
|
onDeviceGate: (gate) => this.broadcast({
|
|
40361
41690
|
kind: "device-gate",
|
|
40362
41691
|
gate
|
|
@@ -40404,7 +41733,11 @@ var EngineServer = class {
|
|
|
40404
41733
|
detach(client) {
|
|
40405
41734
|
this.clients.delete(client);
|
|
40406
41735
|
this.histories.delete(client);
|
|
40407
|
-
if (this.clients.size === 0)
|
|
41736
|
+
if (this.clients.size === 0) {
|
|
41737
|
+
this.node.setActiveLink({ linkId: null });
|
|
41738
|
+
this.node.appsCloseAll();
|
|
41739
|
+
}
|
|
41740
|
+
if (this.onCall.delete(client)) this.node.setCallOn({ on: this.onCall.size > 0 });
|
|
40408
41741
|
}
|
|
40409
41742
|
async handle(client, request) {
|
|
40410
41743
|
if (request?.kind !== "request") return;
|
|
@@ -40413,12 +41746,18 @@ var EngineServer = class {
|
|
|
40413
41746
|
id: request.id
|
|
40414
41747
|
};
|
|
40415
41748
|
if (request.method === "setCallSignal") this.offers.delete(request.params?.linkId ?? "");
|
|
41749
|
+
let params = request.params;
|
|
41750
|
+
if (request.method === "setCallOn") {
|
|
41751
|
+
if (request.params?.on === true) this.onCall.add(client);
|
|
41752
|
+
else this.onCall.delete(client);
|
|
41753
|
+
params = { on: this.onCall.size > 0 };
|
|
41754
|
+
}
|
|
40416
41755
|
try {
|
|
40417
41756
|
await this.ready;
|
|
40418
41757
|
const method = this.node[request.method];
|
|
40419
41758
|
if (typeof method !== "function") throw new Error(`Unknown method: ${request.method}`);
|
|
40420
41759
|
if (this.node.limited && !LIMITED_MODE_METHODS.has(String(request.method))) throw new Error(LIMITED_MODE_ERROR);
|
|
40421
|
-
response.result = await method.call(this.node,
|
|
41760
|
+
response.result = await method.call(this.node, params);
|
|
40422
41761
|
} catch (error) {
|
|
40423
41762
|
response.error = error instanceof Error ? error.message : String(error);
|
|
40424
41763
|
}
|
|
@@ -40505,4 +41844,4 @@ function signalKind(signal) {
|
|
|
40505
41844
|
//#endregion
|
|
40506
41845
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
40507
41846
|
|
|
40508
|
-
//# sourceMappingURL=server-
|
|
41847
|
+
//# sourceMappingURL=server-TamKsfmC.js.map
|