@ghostlytools/cli 1.1.4 → 1.1.6
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 +8 -2
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/{endpoint-CaSbSFDw.js → endpoint-D_3bYafG.js} +76 -3
- package/dist/assets/endpoint-D_3bYafG.js.map +1 -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-BoxPHp7v.js → hyperdhtRelay-CnEkFl8R.js} +3 -3
- package/dist/assets/{hyperdhtRelay-BoxPHp7v.js.map → hyperdhtRelay-CnEkFl8R.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/{mainline-dkA94eh2.js → mainline-DQxDSWXm.js} +2 -2
- package/dist/assets/{mainline-dkA94eh2.js.map → mainline-DQxDSWXm.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-CXFTDnFI.js → server-HMzdsyn_.js} +1720 -284
- package/dist/assets/server-HMzdsyn_.js.map +1 -0
- package/dist/assets/{src-K3NijMq-.js → src-DtxVo2Rm.js} +2299 -164
- package/dist/assets/src-DtxVo2Rm.js.map +1 -0
- package/dist/ghostly.mjs +1944 -64
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/endpoint-CaSbSFDw.js.map +0 -1
- package/dist/assets/idb-Co2lObpU.js.map +0 -1
- package/dist/assets/server-CXFTDnFI.js.map +0 -1
- package/dist/assets/src-K3NijMq-.js.map +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { $
|
|
3
|
-
import { a as
|
|
4
|
-
import {
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { $i as formatFileSize, $n as GROUP_FILE_LIMITS, $o as handoffEphemeral, $r as WAKE_CALL_INTERVAL_MS, $s as deviceKeyZ32, $t as SEPOLIA_TEST_USDT_FAUCET, A as entryParams, Aa as replySnippet, Ac as createRelayPayload, Ai as IdentityExchange, Ao as LEGACY_SERVICES, Ar as newerHead, As as enrollCancelReason, Ba as claimedTime, Bc as sanitizeAvatar, Bi as emptyProofLedger, Bn as engineText, Bo as HANDOFF_REQUEST, Br as transferStalled, Bs as deviceInviteJoinerParams, C as GROUP_READ_NOTE_COMMUNITY, Ca as reactionEmoji, Ci as DHT_TEXT_CAPABILITY, Cr as beaconRecords, Cs as signTombstone, Da as wireReaction, Dc as withRequestOptions, Dr as lobbyRecords, E as encodeCommunityLink, Ea as validReactionNumber, Ec as isDiscoveryBudgetError, Eo as parseCallSignal, Er as lobbyKeys, Fc as identityFromSeed, Fi as newIdentityBinding, Fo as HANDOFF_HAVE, Fr as readLobby, Fs as enrollProofFrame, G as GROUP_EDIT_TEXT_BYTES, Gc as toZ32, Gn as findEndpoint, Go as handoffAttemptFailed, Gr as typingActivity, Gs as encodeInviteCode, Gt as encodeDidDhtPacket, H as GroupSession, Ha as receivedTimestamp, Hc as fromBase64Url, Hi as TRANSPORTS, Hn as ENDPOINT, Ho as HANDOFF_VERIFIED, Hs as OwnInviteError, Ht as didDhtDocument, I as GroupWakeLimiter, Ia as checkStatusCard, Ic as identityFromSeedB64, Ii as receivedIdentityStatus, Io as HANDOFF_HELLO, Ir as shouldBeHub, Is as readEnrollGrant, J as mentionsMember, Ji as peekDhtMailbox, Jn as parseFedimintRequestPayload, Jo as handoffCancelFrame, Jr as mayPin, Js as DEVICES_CAPABILITY, Jt as ETHEREUM_USDT, K as carryMentions, Ka as appFingerprint, Kc as utf8Decode, Kn as isFederationId, Ko as handoffAttemptSucceeded, L as groupWakeFrame, La as sameButtonText, Lc as publicKeyFromZ32, Li as revokesIdentity, Lo as HANDOFF_OFFER, Lr as ChatFiles, Ls as readEnrollHello, M as knockRecords, Mi as emptyIdentityLedger, Mo as HANDOFF_ACK, Mr as rankHubs, Ms as enrollDoneFrame, N as mergeKnocks, Ni as identityRevocationValue, No as HANDOFF_CANCEL, Nr as readBeacon, Ns as enrollGrantFrame, O as decodeGroupEntryLink, Oa as pairedReplyAuthor, Oc as PacketTooLargeError, Oi as IDENTITY_REVOCATION_LABEL, Oo as emptyLinkRecords, Or as mergeBeacon, P as readKnocks, Pa as STATUS_CARD_LIMITS, Pc as createIdentity, Pi as identityStatement, Po as HANDOFF_DONE, Pr as readBeaconHead, Ps as enrollHelloFrame, Qn as pairedMessageFrame, Qo as handoffDigest, Qr as readPin, Qs as deviceEchoNonce, Qt as SEPOLIA_TEST_USDT, R as groupWakes, Ra as statusCardText, Rc as verify, Ri as lastSharedWithMe, Ro as HANDOFF_PAKE, Rr as FILE_LIMITS, Rs as verifyEnrollProof, S as CommunitySession, Sa as queueReaction, Sc as normalizeRelayUrl, Si as CapsExchange, Sr as beaconKeys, Ss as setUpdateFrame, T as decodeCommunityLink, Ta as readReaction, Tc as budgetRetryMs, Ti as dialDescriptors, Tr as freshHubs, Ua as shownTime, Uc as randomBytes, Ui as automaticTransport, Un as cashuRequestPayload, Uo as filePartName, Ur as TypingSender, Us as createChatInvite, Ut as didDhtFromPublicKey, V as GROUP_READ_NOTE, Va as heardTime, Vc as bytesEqual, Vi as validExternalKey, Vn as decodeBolt11, Vo as HANDOFF_TIMINGS, Vs as readDeviceInvite, Wc as toBase64Url, Wn as fedimintRequestPayload, Wo as handoffAttemptAllowed, Ws as decodeInviteCode, Wt as encodeDidDhtDeactivated, Xa as isAppRef, Xi as dhtFileId, Xn as INVITE_TAKEN, Xo as handoffContextProof, Xr as pinIsNewer, Xs as HANDOFF_CAPABILITY, Xt as ONCHAIN_PROVIDER, Y as groupName, Ya as isAppKey, Yi as DHT_FILE_CAPABILITY, Yn as GhostLink, Yo as handoffContext, Yr as nextPinNumber, Ys as ENROLL_CAPABILITY, Yt as EVM_TEST_CHAINS, Za as readAppRevocations, Zi as parseImageMeta, Zn as LIVENESS_PING_MS, Zo as handoffDataFrame, Zr as pinNumberHolds, Zs as deviceEchoFrame, Zt as PaymentPreflightError, _ as GroupTypingBoard, _a as COMMUNITY_REACTION_FRAME, _c as turnKeys, _i as HOLD_LIMITS, _o as decodeControl, _r as MESH_HUBS, _s as sealHandoffPiece, ai as wakeRequest, an as formatPaymentAmount, ao as rosterHas, as as handoffRetryAfter, b as sealGroupTyping, ba as ReactionWindow, bc as RelayTransport, bi as newMailbox, bo as RTC_CONFIG, br as pickMeshHubs, bs as readSetUpdate, c as APP_BUNDLE_LIMITS, cc as classifyTurnRead, ci as chatAppId, cn as SPARK_NETWORKS, co as DirectPathWatch, cs as newHandoffId, d as appViewOf, da as EDIT_RATE_WINDOW_MS, dc as TURN_MAX, di as BUTTONS_CAPABILITY, dn as sparkInvoiceDetails, do as presenceSeenAt, ds as readHandoffDone, ea as safeBlobType, ec as deviceLinkPairing, ei as WakeLimiter, en as TEST_USDT_FAUCET_AMOUNT, eo as canonicalJson, es as handoffHelloFrame, fa as RateWindow, fc as firstDeviceSetSecret, fi as EDIT_CAPABILITY, fn as isBitcoinAddress, fo as GhostlyHttpError, fs as readHandoffHave, ga as readForwarded, gc as signTurnRelease, gi as HOLD_DATED_BACK_MS, go as LIMITS, gr as readGroupSignal, gs as readHandoffVerified, gt as normalizeAtprotoHandle, h as readAppBundle, ha as forwardedAgain, hc as signTurnPacket, hi as HOLD_CAPABILITY, ho as webLocalFetch, hr as groupSignalFrame, hs as readHandoffTurnFrame, ht as isAtprotoDid, i as planAppUpdate, ic as webCryptoSigner, ii as relayRequest, in as assertWholeSats, io as MEMBER_KEY, ir as groupFileFallback, is as handoffReleaseFrame, j as knockIdentity, ja as BUTTON_ID, jo as serviceIdFromName, jr as pickHubs, js as enrollDigits, k as encodeGroupEntryLink, ka as readReply, ko as HTTP_SERVICE_PROTO, kr as mergeLobby, ks as enrollCancelFrame, l as APP_BUNDLE_MAGIC, la as parseVideoMeta, lc as turnPutSummary, li as isAppVersion, ln as SPARK_PROVIDER, ls as readHandoffAck, m as isAppUrl, ma as parseLinkPreview, mc as readTurnPacket, mi as UPGRADE_CAPABILITY, mn as isPubkyKey, mo as parseLocalTarget, ms as readHandoffPake, n as appUpdateDecision, nc as newDeviceSetSecret, ni as newWakeToken, nn as WALLET_NETWORKS, no as GROUP_MEMBER_CAP, ns as handoffOfferFrame, o as readAppStore, oa as fileMessageText, oc as TURN_RAISE_READ_TIMEOUT_MS, oi as checkPushEndpoint, on as validatePaymentTarget, oo as verifyCommitSignature, or as readGroupFileMeta, os as handoffStreamKey, pa as dhtEditId, pc as nextTurnPosition, pi as STATUS_CARD_CAPABILITY, po as formatLocalTarget, ps as readHandoffHello, q as parseCommunityEdit, qa as appRef, qc as utf8Encode, qi as DHT_MESSAGE_TTL, qn as parseCashuRequestPayload, qo as handoffBusyFrame, qr as PIN_LIMITS, qs as inviteOwnership, qt as signDidDhtPacket, r as checkAppBeforeRun, rc as seedSigner, ri as parseWakeFrame, rn as assertTokenUnits, rs as handoffPakeFrame, sa as parseVoiceMeta, sc as TURN_SETTLE_MS, si as vapidKeysMatch, sn as walletNetworkOf, so as edgeParams, ss as handoffVersions, t as appStoreDecision, tc as devicePingFrame, ti as deviceWakeRequest, tn as USDT_PUBLIC_RPC, ts as handoffManifestFrames, u as appDigest, ua as EDIT_BUFFER_MS, uc as TURN_LAST_SEQUENCE, ui as APPS_CAPABILITY, un as sparkAddressKind, uo as RELAY_POLL_INTERVALS, ur as CarriedTransport, us as readHandoffCancel, v as groupTypingEpoch, va as GROUP_REACTION_FRAME, vc as turnName, vi as HoldKeys, vo as encodeControl, vr as mayBeHub, w as communityHasFrame, wa as reactionIsNewer, wc as traceLink, wi as capsDescriptors, wr as doorHubs, ws as verifySetUpdate, x as COMMUNITY_LIMITS, xa as nextReactionNumber, xc as currentRelays, xi as readManifest, xr as COMMUNITY_TOPOLOGY, xs as setAckFrame, y as openGroupTyping, ya as REACTION_LIMITS, yc as DEFAULT_RELAYS, yi as HoldRefusedError, yr as meshHubs, z as parseGroupWakeFrame, za as arrivalKey, zi as PeerProofs, zn as engineError, zo as HANDOFF_PIECE_BYTES, zr as transferEnded, zs as createDeviceInvite } from "./src-DtxVo2Rm.js";
|
|
3
|
+
import { a as clearProfileStores, c as fileStore, d as store, f as transact, i as STORES$1, l as openDb, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as wrap, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as profileOpenFailure } from "./idb-BIdus0WX.js";
|
|
4
|
+
import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-3AQ2tJzW.js";
|
|
5
|
+
import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, h as rpcHost, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, m as networkReason, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
|
|
6
|
+
import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
|
|
5
7
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
6
8
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
7
9
|
import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
|
|
@@ -6175,7 +6177,7 @@ var COMMANDO_NOT_RUN = {
|
|
|
6175
6177
|
};
|
|
6176
6178
|
var MAX_REPLY = 4194304;
|
|
6177
6179
|
/** One websocket frame may carry many Lightning messages; one this large is not an answer to anything. */
|
|
6178
|
-
var MAX_FRAME
|
|
6180
|
+
var MAX_FRAME = 4259840;
|
|
6179
6181
|
var text$1 = new TextDecoder();
|
|
6180
6182
|
/**
|
|
6181
6183
|
* One connection to a node, opened on first use and again after it drops. Calls run concurrently, matched
|
|
@@ -6279,7 +6281,7 @@ var CommandoClient = class {
|
|
|
6279
6281
|
ws.onmessage = ({ data }) => {
|
|
6280
6282
|
if (!(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) return fail("The node sent something that is not binary");
|
|
6281
6283
|
const chunk = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
6282
|
-
if (chunk.length > MAX_FRAME
|
|
6284
|
+
if (chunk.length > MAX_FRAME) return fail("The node sent more than it was asked for");
|
|
6283
6285
|
buffer = concatBytes(buffer, chunk);
|
|
6284
6286
|
try {
|
|
6285
6287
|
if (stage === "act2") {
|
|
@@ -11703,6 +11705,1161 @@ var eachNetwork = (value) => "mainnet" in value && "testnet" in value ? value :
|
|
|
11703
11705
|
testnet: value
|
|
11704
11706
|
};
|
|
11705
11707
|
//#endregion
|
|
11708
|
+
//#region ../browser/src/engine/appFetch.ts
|
|
11709
|
+
/** The hosts phase 1 reads from. A jsDelivr URL must also name a 40-character commit (`isAppUrl`). */
|
|
11710
|
+
var APP_FETCH_HOSTS = ["raw.githubusercontent.com", "cdn.jsdelivr.net"];
|
|
11711
|
+
var APP_FETCH_LIMITS = {
|
|
11712
|
+
/**
|
|
11713
|
+
* A store index as read (WISP 1200 § Stores, Privacy): 4 MiB in phase 1, under the format's 16 MiB. Counted on the body
|
|
11714
|
+
* as it arrives, after the browser undid any compression, since a page never sees the compressed size.
|
|
11715
|
+
*/
|
|
11716
|
+
storeIndexBytes: 4194304,
|
|
11717
|
+
/** A store's signature statement, `ghostly-store.sig` (canonical, at most 1 KiB). */
|
|
11718
|
+
sigBytes: 1024,
|
|
11719
|
+
/** `ghostly-revoke.json`: at most 4096 signed revocations. */
|
|
11720
|
+
revocationsBytes: 16777216,
|
|
11721
|
+
timeoutMs: 3e4
|
|
11722
|
+
};
|
|
11723
|
+
/** A URL this client may read an app, a store or a revocation list from. */
|
|
11724
|
+
function isAppFetchUrl(value) {
|
|
11725
|
+
if (!isAppUrl(value)) return false;
|
|
11726
|
+
const url = new URL(value);
|
|
11727
|
+
return url.port === "" && APP_FETCH_HOSTS.includes(url.hostname.toLowerCase());
|
|
11728
|
+
}
|
|
11729
|
+
var AppFetchError = class extends Error {
|
|
11730
|
+
code;
|
|
11731
|
+
status;
|
|
11732
|
+
constructor(code, message, status) {
|
|
11733
|
+
super(`${code}: ${message}`);
|
|
11734
|
+
this.code = code;
|
|
11735
|
+
this.status = status;
|
|
11736
|
+
this.name = "AppFetchError";
|
|
11737
|
+
}
|
|
11738
|
+
};
|
|
11739
|
+
function boundedAppFetch(options = {}) {
|
|
11740
|
+
return async (url, { maxBytes, peek = false }) => {
|
|
11741
|
+
if (!isAppFetchUrl(url)) throw new AppFetchError("host", "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
|
|
11742
|
+
if (options.online && !options.online()) throw new AppFetchError("offline", "Offline");
|
|
11743
|
+
const fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
|
|
11744
|
+
let response;
|
|
11745
|
+
try {
|
|
11746
|
+
response = await fetcher(url, {
|
|
11747
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? APP_FETCH_LIMITS.timeoutMs),
|
|
11748
|
+
credentials: "omit",
|
|
11749
|
+
referrerPolicy: "no-referrer",
|
|
11750
|
+
cache: "no-store",
|
|
11751
|
+
redirect: "error",
|
|
11752
|
+
...peek && { headers: { range: `bytes=0-${maxBytes - 1}` } }
|
|
11753
|
+
});
|
|
11754
|
+
} catch (error) {
|
|
11755
|
+
throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
|
|
11756
|
+
}
|
|
11757
|
+
if (response.url && response.url !== url && !isAppFetchUrl(response.url)) throw new AppFetchError("host", "The host sent the request elsewhere");
|
|
11758
|
+
if (!response.ok) {
|
|
11759
|
+
await response.body?.cancel().catch(() => {});
|
|
11760
|
+
throw new AppFetchError("status", `The host answered ${response.status}`, response.status);
|
|
11761
|
+
}
|
|
11762
|
+
if (!peek && Number(response.headers.get("content-length")) > maxBytes) {
|
|
11763
|
+
await response.body?.cancel().catch(() => {});
|
|
11764
|
+
throw new AppFetchError("too-large", "Too large");
|
|
11765
|
+
}
|
|
11766
|
+
const parts = [];
|
|
11767
|
+
let length = 0;
|
|
11768
|
+
const reader = response.body?.getReader();
|
|
11769
|
+
if (reader) try {
|
|
11770
|
+
for (;;) {
|
|
11771
|
+
const { done, value } = await reader.read();
|
|
11772
|
+
if (done) break;
|
|
11773
|
+
if (length + value.length > maxBytes) {
|
|
11774
|
+
if (!peek) throw new AppFetchError("too-large", "Too large");
|
|
11775
|
+
parts.push(value.subarray(0, maxBytes - length));
|
|
11776
|
+
length = maxBytes;
|
|
11777
|
+
break;
|
|
11778
|
+
}
|
|
11779
|
+
parts.push(value);
|
|
11780
|
+
length += value.length;
|
|
11781
|
+
}
|
|
11782
|
+
} catch (error) {
|
|
11783
|
+
if (error instanceof AppFetchError) throw error;
|
|
11784
|
+
throw new AppFetchError("network", error instanceof Error ? error.message : String(error));
|
|
11785
|
+
} finally {
|
|
11786
|
+
await reader.cancel().catch(() => {});
|
|
11787
|
+
}
|
|
11788
|
+
const bytes = new Uint8Array(length);
|
|
11789
|
+
let at = 0;
|
|
11790
|
+
for (const part of parts) {
|
|
11791
|
+
bytes.set(part, at);
|
|
11792
|
+
at += part.length;
|
|
11793
|
+
}
|
|
11794
|
+
return bytes;
|
|
11795
|
+
};
|
|
11796
|
+
}
|
|
11797
|
+
var NAME = /^[A-Za-z0-9_.-]+$/;
|
|
11798
|
+
/**
|
|
11799
|
+
* The URL a pasted address reads `file` from (WISP 1200 § Paste a URL), or null when it is not one this client reads:
|
|
11800
|
+
* - `https://github.com/<owner>/<repo>`, or `/tree/<ref>`: `raw.githubusercontent.com/<owner>/<repo>/<ref or HEAD>/<file>`,
|
|
11801
|
+
* with no request to `github.com` or `api.github.com`;
|
|
11802
|
+
* - a file as GitHub shows it, `https://github.com/<owner>/<repo>/blob/<ref>/<path>` (or `/raw/`), naming a `.ghostlyapp`
|
|
11803
|
+
* or `.json` file: `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`, again with no request to `github.com`;
|
|
11804
|
+
* - a URL of the list that names a file (`….ghostlyapp`, `….json`): itself;
|
|
11805
|
+
* - a URL of the list that names a folder: `<file>` in it.
|
|
11806
|
+
*/
|
|
11807
|
+
function appPasteUrl(input, file) {
|
|
11808
|
+
let url;
|
|
11809
|
+
try {
|
|
11810
|
+
url = new URL(input.trim());
|
|
11811
|
+
} catch {
|
|
11812
|
+
return null;
|
|
11813
|
+
}
|
|
11814
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
|
11815
|
+
const host = url.hostname.toLowerCase();
|
|
11816
|
+
if (host === "github.com" || host === "www.github.com") {
|
|
11817
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
11818
|
+
if (parts.length < 2) return null;
|
|
11819
|
+
const owner = parts[0], repo = parts[1].replace(/\.git$/, "");
|
|
11820
|
+
if (!NAME.test(owner) || !NAME.test(repo)) return null;
|
|
11821
|
+
let ref = "HEAD";
|
|
11822
|
+
if (parts.length > 2) {
|
|
11823
|
+
const rest = parts.slice(3);
|
|
11824
|
+
if (!rest.length || !rest.every((p) => NAME.test(p) && p !== "." && p !== "..")) return null;
|
|
11825
|
+
if (parts[2] === "blob" || parts[2] === "raw") {
|
|
11826
|
+
if (rest.length < 2 || !/\.(ghostlyapp|json)$/.test(rest[rest.length - 1])) return null;
|
|
11827
|
+
const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${rest.join("/")}`;
|
|
11828
|
+
return isAppFetchUrl(raw) ? raw : null;
|
|
11829
|
+
}
|
|
11830
|
+
if (parts[2] !== "tree") return null;
|
|
11831
|
+
ref = rest.join("/");
|
|
11832
|
+
}
|
|
11833
|
+
const raw = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${file}`;
|
|
11834
|
+
return isAppFetchUrl(raw) ? raw : null;
|
|
11835
|
+
}
|
|
11836
|
+
const last = url.pathname.split("/").pop() ?? "";
|
|
11837
|
+
const named = /\.(ghostlyapp|json)$/.test(last) ? url.href : new URL(`${url.pathname.replace(/\/?$/, "/")}${file}`, url).href;
|
|
11838
|
+
return isAppFetchUrl(named) ? named : null;
|
|
11839
|
+
}
|
|
11840
|
+
/** A page of github.com (any scheme): a link `appPasteUrl` could not read is still GitHub, and is answered as such. */
|
|
11841
|
+
function isGitHubPage(input) {
|
|
11842
|
+
try {
|
|
11843
|
+
const host = new URL(input.trim()).hostname.toLowerCase();
|
|
11844
|
+
return host === "github.com" || host === "www.github.com";
|
|
11845
|
+
} catch {
|
|
11846
|
+
return false;
|
|
11847
|
+
}
|
|
11848
|
+
}
|
|
11849
|
+
/** A file beside another (`ghostly-store.sig` beside the index, `ghostly-revoke.json` beside a bundle). */
|
|
11850
|
+
function besideUrl(url, name) {
|
|
11851
|
+
const target = new URL(url);
|
|
11852
|
+
target.search = "";
|
|
11853
|
+
target.hash = "";
|
|
11854
|
+
return new URL(name, target).href;
|
|
11855
|
+
}
|
|
11856
|
+
/**
|
|
11857
|
+
* The stores a new profile starts with (WISP 1200 § Stores: the default store, preloaded and removable), each a URL and
|
|
11858
|
+
* the store key it is pinned to. Preloading one makes no request; the first read comes when the person opens the Apps
|
|
11859
|
+
* page or once an app is installed.
|
|
11860
|
+
*/
|
|
11861
|
+
var DEFAULT_APP_STORES = [{
|
|
11862
|
+
url: "https://raw.githubusercontent.com/MiguelMedeiros/ghostly-store/HEAD/ghostly-store.json",
|
|
11863
|
+
key: "y379ia3t1urwudj8o4w1qwmuwp1mcxyf956b5r7pqup7stce6diy"
|
|
11864
|
+
}];
|
|
11865
|
+
var APP_STORAGE_LIMITS = {
|
|
11866
|
+
/** A key, in UTF-8 bytes. */
|
|
11867
|
+
keyBytes: 256,
|
|
11868
|
+
/** A value, as compact JSON in UTF-8 bytes. */
|
|
11869
|
+
valueBytes: 65536,
|
|
11870
|
+
/** Every key and value of one app in one scope. */
|
|
11871
|
+
scopeBytes: 5242880
|
|
11872
|
+
};
|
|
11873
|
+
var APP_CHECK_TIMINGS = {
|
|
11874
|
+
/** After the engine starts, so the chats come up first. */
|
|
11875
|
+
afterStartMs: 6e4,
|
|
11876
|
+
everyMs: 864e5,
|
|
11877
|
+
/** A check at start is skipped when every app was checked this recently (a page reloaded several times). */
|
|
11878
|
+
freshMs: 36e5
|
|
11879
|
+
};
|
|
11880
|
+
/** A fetched bundle kept for its install screen: at most this many, for this long. */
|
|
11881
|
+
var STAGED = {
|
|
11882
|
+
max: 4,
|
|
11883
|
+
ms: 6e5
|
|
11884
|
+
};
|
|
11885
|
+
/** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
|
|
11886
|
+
var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
|
|
11887
|
+
var bundleId = (digest) => `app-${digest}`;
|
|
11888
|
+
/** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
|
|
11889
|
+
function fail(code, words) {
|
|
11890
|
+
throw new Error(`${code}: ${words}`);
|
|
11891
|
+
}
|
|
11892
|
+
/**
|
|
11893
|
+
* Why a pasted link is not read: a github.com page that is neither a repository nor a file of one (`github-link`), so
|
|
11894
|
+
* the person is not told GitHub is off the list; any other host (`host`).
|
|
11895
|
+
*/
|
|
11896
|
+
function notReadable(url, words) {
|
|
11897
|
+
if (typeof url === "string" && isGitHubPage(url)) fail("github-link", "Paste the repository's link, or the link to its app file");
|
|
11898
|
+
fail("host", words);
|
|
11899
|
+
}
|
|
11900
|
+
var versionOf = (manifest, digest) => ({
|
|
11901
|
+
ref: appRef(manifest.publisher, manifest.name),
|
|
11902
|
+
sequence: manifest.sequence,
|
|
11903
|
+
digest
|
|
11904
|
+
});
|
|
11905
|
+
var byteLength = (text) => utf8Encode(text).length;
|
|
11906
|
+
/** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
|
|
11907
|
+
var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
|
|
11908
|
+
function done(tx) {
|
|
11909
|
+
return new Promise((resolve, reject) => {
|
|
11910
|
+
tx.oncomplete = () => resolve();
|
|
11911
|
+
tx.onerror = tx.onabort = () => reject(tx.error ?? /* @__PURE__ */ new Error("App storage failed"));
|
|
11912
|
+
});
|
|
11913
|
+
}
|
|
11914
|
+
/**
|
|
11915
|
+
* The manifest of a bundle's first bytes, unchecked: only to see whether a source holds something newer before reading
|
|
11916
|
+
* all of it. Whatever it says, the whole bundle is checked before anything is kept.
|
|
11917
|
+
*/
|
|
11918
|
+
function peekAppManifest(bytes) {
|
|
11919
|
+
const magic = utf8Encode(APP_BUNDLE_MAGIC);
|
|
11920
|
+
if (bytes.length < magic.length + 4 || !magic.every((b, i) => bytes[i] === b)) return null;
|
|
11921
|
+
const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(magic.length);
|
|
11922
|
+
if (length > APP_BUNDLE_LIMITS.manifestBytes || bytes.length < magic.length + 4 + length) return null;
|
|
11923
|
+
const manifestBytes = bytes.subarray(magic.length + 4, magic.length + 4 + length);
|
|
11924
|
+
try {
|
|
11925
|
+
const manifest = JSON.parse(utf8Decode(manifestBytes));
|
|
11926
|
+
const ref = typeof manifest.publisher === "string" && typeof manifest.name === "string" ? appRef(manifest.publisher, manifest.name) : "";
|
|
11927
|
+
if (!isAppRef(ref) || typeof manifest.sequence !== "number" || !Number.isSafeInteger(manifest.sequence)) return null;
|
|
11928
|
+
return {
|
|
11929
|
+
ref,
|
|
11930
|
+
sequence: manifest.sequence,
|
|
11931
|
+
digest: appDigest(manifestBytes)
|
|
11932
|
+
};
|
|
11933
|
+
} catch {
|
|
11934
|
+
return null;
|
|
11935
|
+
}
|
|
11936
|
+
}
|
|
11937
|
+
var Apps = class {
|
|
11938
|
+
host;
|
|
11939
|
+
staged = /* @__PURE__ */ new Map();
|
|
11940
|
+
/** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
|
|
11941
|
+
verified = /* @__PURE__ */ new Map();
|
|
11942
|
+
/** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
|
|
11943
|
+
usage = /* @__PURE__ */ new Map();
|
|
11944
|
+
queues = /* @__PURE__ */ new Map();
|
|
11945
|
+
timer = null;
|
|
11946
|
+
checking = null;
|
|
11947
|
+
/** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
|
|
11948
|
+
anyway = /* @__PURE__ */ new Set();
|
|
11949
|
+
stopped = false;
|
|
11950
|
+
constructor(host) {
|
|
11951
|
+
this.host = host;
|
|
11952
|
+
}
|
|
11953
|
+
now() {
|
|
11954
|
+
return (this.host.now ?? Date.now)();
|
|
11955
|
+
}
|
|
11956
|
+
nowS() {
|
|
11957
|
+
return Math.floor(this.now() / 1e3);
|
|
11958
|
+
}
|
|
11959
|
+
/** Preloads the default stores (no request), then checks for updates soon after start and every 24 hours. */
|
|
11960
|
+
async start() {
|
|
11961
|
+
this.stopped = false;
|
|
11962
|
+
await this.seedDefaults();
|
|
11963
|
+
this.schedule(APP_CHECK_TIMINGS.afterStartMs, true);
|
|
11964
|
+
}
|
|
11965
|
+
stop() {
|
|
11966
|
+
this.stopped = true;
|
|
11967
|
+
if (this.timer) clearTimeout(this.timer);
|
|
11968
|
+
this.timer = null;
|
|
11969
|
+
}
|
|
11970
|
+
schedule(ms, atStart = false) {
|
|
11971
|
+
if (this.timer) clearTimeout(this.timer);
|
|
11972
|
+
this.timer = setTimeout(() => {
|
|
11973
|
+
this.timer = null;
|
|
11974
|
+
(async () => {
|
|
11975
|
+
if (this.stopped) return;
|
|
11976
|
+
if (this.host.online()) {
|
|
11977
|
+
const apps = await this.installed();
|
|
11978
|
+
const fresh = atStart && apps.length > 0 && apps.every((a) => a.checkedAt !== void 0 && this.now() - a.checkedAt < APP_CHECK_TIMINGS.freshMs);
|
|
11979
|
+
if (apps.length && !fresh) await this.checkUpdates().catch(() => {});
|
|
11980
|
+
}
|
|
11981
|
+
if (!this.stopped) this.schedule(APP_CHECK_TIMINGS.everyMs);
|
|
11982
|
+
})();
|
|
11983
|
+
}, ms);
|
|
11984
|
+
}
|
|
11985
|
+
async seedDefaults() {
|
|
11986
|
+
for (const d of this.host.defaults ?? DEFAULT_APP_STORES) {
|
|
11987
|
+
if (!isAppKey(d.key) || !isAppFetchUrl(d.url)) continue;
|
|
11988
|
+
if (await this.storeRecord(d.key)) continue;
|
|
11989
|
+
await this.putStore({
|
|
11990
|
+
key: d.key,
|
|
11991
|
+
url: d.url,
|
|
11992
|
+
addedAt: this.now(),
|
|
11993
|
+
preloaded: true
|
|
11994
|
+
});
|
|
11995
|
+
}
|
|
11996
|
+
}
|
|
11997
|
+
async installed() {
|
|
11998
|
+
return wrap((await store(STORES$1.apps, "readonly")).getAll());
|
|
11999
|
+
}
|
|
12000
|
+
async app(ref) {
|
|
12001
|
+
return wrap((await store(STORES$1.apps, "readonly")).get(ref));
|
|
12002
|
+
}
|
|
12003
|
+
async installedOrFail(ref) {
|
|
12004
|
+
if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
|
|
12005
|
+
return await this.app(ref) ?? fail("not-installed", "This app is not installed");
|
|
12006
|
+
}
|
|
12007
|
+
/**
|
|
12008
|
+
* An installed app that may still run (WISP 1200 § Takedowns): a running app whose version its publisher revoked, or
|
|
12009
|
+
* a store of the person's removed (unless this run was started with Run anyway), is cut off with `stopped`, whether
|
|
12010
|
+
* or not the page stops its frame.
|
|
12011
|
+
*/
|
|
12012
|
+
async stillRunnable(app) {
|
|
12013
|
+
const run = this.runStatus(app, await this.stores(), app.revocations);
|
|
12014
|
+
if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
|
|
12015
|
+
if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12016
|
+
return app;
|
|
12017
|
+
}
|
|
12018
|
+
async runningOrFail(ref) {
|
|
12019
|
+
return this.stillRunnable(await this.installedOrFail(ref));
|
|
12020
|
+
}
|
|
12021
|
+
/**
|
|
12022
|
+
* Before an app's frames go to a contact (`appOpen`, `appSend`): refused with `stopped` as its storage is. A reference
|
|
12023
|
+
* not installed here (a bot that speaks an app's messages) is not this store's to refuse.
|
|
12024
|
+
*/
|
|
12025
|
+
async chatRunnable({ ref }) {
|
|
12026
|
+
const app = isAppRef(ref) ? await this.app(ref) : void 0;
|
|
12027
|
+
if (app) await this.stillRunnable(app);
|
|
12028
|
+
}
|
|
12029
|
+
async putApp(app) {
|
|
12030
|
+
await wrap((await store(STORES$1.apps, "readwrite")).put(app));
|
|
12031
|
+
}
|
|
12032
|
+
async storeRecord(key) {
|
|
12033
|
+
return wrap((await store(STORES$1.appStores, "readonly")).get(key));
|
|
12034
|
+
}
|
|
12035
|
+
async putStore(record) {
|
|
12036
|
+
await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
|
|
12037
|
+
}
|
|
12038
|
+
/** The stores the person has (a removed default is not one). */
|
|
12039
|
+
async stores() {
|
|
12040
|
+
return (await wrap((await store(STORES$1.appStores, "readonly")).getAll())).filter((s) => !s.removed);
|
|
12041
|
+
}
|
|
12042
|
+
storeViews(stores) {
|
|
12043
|
+
return stores.filter((s) => s.index).map((s) => ({
|
|
12044
|
+
key: s.key,
|
|
12045
|
+
name: s.index.name,
|
|
12046
|
+
kind: s.index.kind,
|
|
12047
|
+
removed: s.index.removed,
|
|
12048
|
+
revoked: s.index.revoked
|
|
12049
|
+
}));
|
|
12050
|
+
}
|
|
12051
|
+
listedBy(ref, stores) {
|
|
12052
|
+
return stores.filter((s) => s.index?.apps.some((a) => a.ref === ref)).map((s) => ({
|
|
12053
|
+
key: s.key,
|
|
12054
|
+
name: s.index.name,
|
|
12055
|
+
kind: s.index.kind
|
|
12056
|
+
}));
|
|
12057
|
+
}
|
|
12058
|
+
runStatus(version, stores, revocations = []) {
|
|
12059
|
+
const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
|
|
12060
|
+
if (check.status === "revoked") return {
|
|
12061
|
+
status: "revoked",
|
|
12062
|
+
...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
|
|
12063
|
+
};
|
|
12064
|
+
return check;
|
|
12065
|
+
}
|
|
12066
|
+
/**
|
|
12067
|
+
* Stores a bundle's bytes. A browser that keeps no files fails here: Safari's Private Browsing (and WebKit's in-memory
|
|
12068
|
+
* contexts) refuses a Blob in IndexedDB with "Error preparing Blob/File data to be stored in object store". Any such
|
|
12069
|
+
* failure is `storage`, with the browser's own words after it for the logs, and leaves no piece behind.
|
|
12070
|
+
*/
|
|
12071
|
+
async writeBundle(digest, bytes) {
|
|
12072
|
+
const id = bundleId(digest);
|
|
12073
|
+
const expected = toBase64Url(sha256(bytes));
|
|
12074
|
+
let files;
|
|
12075
|
+
let stored;
|
|
12076
|
+
try {
|
|
12077
|
+
files = await fileBytes();
|
|
12078
|
+
const size = await files.size(id);
|
|
12079
|
+
if (size === bytes.length && await files.digest(id) === expected) return files.kind;
|
|
12080
|
+
if (size !== null) await files.remove(id);
|
|
12081
|
+
for (let at = 0; at < bytes.length; at += FILE_BYTES_STEP) await files.append(id, at, bytes.subarray(at, at + FILE_BYTES_STEP));
|
|
12082
|
+
await files.flush(id);
|
|
12083
|
+
await files.close(id);
|
|
12084
|
+
stored = await files.digest(id);
|
|
12085
|
+
} catch (e) {
|
|
12086
|
+
await files?.close(id).catch(() => {});
|
|
12087
|
+
await files?.remove(id).catch(() => {});
|
|
12088
|
+
const words = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
12089
|
+
console.warn("[apps] storage:", words);
|
|
12090
|
+
fail("storage", `The app's files could not be stored on this device (${words})`);
|
|
12091
|
+
}
|
|
12092
|
+
if (stored !== expected) {
|
|
12093
|
+
await files.remove(id).catch(() => {});
|
|
12094
|
+
fail("storage", "The app's files could not be stored on this device");
|
|
12095
|
+
}
|
|
12096
|
+
return files.kind;
|
|
12097
|
+
}
|
|
12098
|
+
async readBundleBytes(kind, digest) {
|
|
12099
|
+
const files = await fileBytesOf(kind);
|
|
12100
|
+
const id = bundleId(digest);
|
|
12101
|
+
const size = files ? await files.size(id) : null;
|
|
12102
|
+
if (!files || size === null || size > APP_BUNDLE_LIMITS.bundleBytes) return null;
|
|
12103
|
+
const out = new Uint8Array(size);
|
|
12104
|
+
for (let at = 0; at < size;) {
|
|
12105
|
+
const part = await files.read(id, at, Math.min(FILE_BYTES_STEP, size - at));
|
|
12106
|
+
if (!part.length) return null;
|
|
12107
|
+
out.set(part, at);
|
|
12108
|
+
at += part.length;
|
|
12109
|
+
}
|
|
12110
|
+
return out;
|
|
12111
|
+
}
|
|
12112
|
+
async hasBundle(kind, digest) {
|
|
12113
|
+
const files = await fileBytesOf(kind);
|
|
12114
|
+
return !!files && await files.size(bundleId(digest)) !== null;
|
|
12115
|
+
}
|
|
12116
|
+
async removeBundle(kind, digest) {
|
|
12117
|
+
this.verified.delete(digest);
|
|
12118
|
+
await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
|
|
12119
|
+
}
|
|
12120
|
+
/** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
|
|
12121
|
+
async verifiedBundle(app) {
|
|
12122
|
+
const kept = this.verified.get(app.digest);
|
|
12123
|
+
if (kept) return kept;
|
|
12124
|
+
const bytes = await this.readBundleBytes(app.bytes, app.digest);
|
|
12125
|
+
if (!bytes) fail("needs-files", "This app's files are not on this device yet");
|
|
12126
|
+
const read = readAppBundle(bytes);
|
|
12127
|
+
if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
|
|
12128
|
+
if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
|
|
12129
|
+
this.verified.set(app.digest, read.bundle);
|
|
12130
|
+
return read.bundle;
|
|
12131
|
+
}
|
|
12132
|
+
async fetchBundle(url) {
|
|
12133
|
+
const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
|
|
12134
|
+
const read = readAppBundle(bytes);
|
|
12135
|
+
if (!read.ok) fail(read.reason, `This is not an app Ghostly can install${read.detail ? ` (${read.detail})` : ""}`);
|
|
12136
|
+
return {
|
|
12137
|
+
bundle: read.bundle,
|
|
12138
|
+
bytes
|
|
12139
|
+
};
|
|
12140
|
+
}
|
|
12141
|
+
async readStoreAt(url, heldKey) {
|
|
12142
|
+
const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
|
|
12143
|
+
return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
|
|
12144
|
+
}
|
|
12145
|
+
/** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
|
|
12146
|
+
async readRevocations(ref, urls) {
|
|
12147
|
+
const out = [];
|
|
12148
|
+
for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
|
|
12149
|
+
const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
|
|
12150
|
+
if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
|
|
12151
|
+
} catch {}
|
|
12152
|
+
return out;
|
|
12153
|
+
}
|
|
12154
|
+
summary(s) {
|
|
12155
|
+
const index = s.index;
|
|
12156
|
+
return {
|
|
12157
|
+
key: s.key,
|
|
12158
|
+
fingerprint: appFingerprint(s.key),
|
|
12159
|
+
url: s.url,
|
|
12160
|
+
preloaded: !!s.preloaded,
|
|
12161
|
+
...index && {
|
|
12162
|
+
name: index.name,
|
|
12163
|
+
kind: index.kind,
|
|
12164
|
+
sequence: index.sequence,
|
|
12165
|
+
expires: index.expires,
|
|
12166
|
+
...index.description !== void 0 && { description: index.description }
|
|
12167
|
+
},
|
|
12168
|
+
expired: !!index && index.expires < this.nowS(),
|
|
12169
|
+
...s.fetchedAt !== void 0 && { fetchedAt: s.fetchedAt },
|
|
12170
|
+
apps: index?.apps ?? [],
|
|
12171
|
+
removed: index?.removed ?? [],
|
|
12172
|
+
...s.equivocation && { equivocation: {
|
|
12173
|
+
sequence: s.equivocation.sequence,
|
|
12174
|
+
at: s.equivocation.at
|
|
12175
|
+
} },
|
|
12176
|
+
...s.problem && { problem: s.problem }
|
|
12177
|
+
};
|
|
12178
|
+
}
|
|
12179
|
+
async listStores() {
|
|
12180
|
+
return (await this.stores()).sort((a, b) => a.addedAt - b.addedAt).map((s) => this.summary(s));
|
|
12181
|
+
}
|
|
12182
|
+
storeUrl(url) {
|
|
12183
|
+
return typeof url === "string" && appPasteUrl(url, "ghostly-store.json") || notReadable(url, "Stores are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS");
|
|
12184
|
+
}
|
|
12185
|
+
/** Reads a store at a URL for the person to see before adding it. Nothing is kept. */
|
|
12186
|
+
async previewStore({ url }) {
|
|
12187
|
+
const at = this.storeUrl(url);
|
|
12188
|
+
const read = await this.readStoreAt(at);
|
|
12189
|
+
if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
|
|
12190
|
+
const { index } = read.store;
|
|
12191
|
+
return {
|
|
12192
|
+
url: at,
|
|
12193
|
+
key: index.key,
|
|
12194
|
+
fingerprint: appFingerprint(index.key),
|
|
12195
|
+
name: index.name,
|
|
12196
|
+
kind: index.kind,
|
|
12197
|
+
sequence: index.sequence,
|
|
12198
|
+
expires: index.expires,
|
|
12199
|
+
expired: read.store.expired,
|
|
12200
|
+
apps: index.apps.length,
|
|
12201
|
+
...index.description !== void 0 && { description: index.description }
|
|
12202
|
+
};
|
|
12203
|
+
}
|
|
12204
|
+
/**
|
|
12205
|
+
* Adds a store the person chose: read at its URL and pinned to its key (`key`, when given, is the key the person saw,
|
|
12206
|
+
* and another one is refused). A store already added under that key takes the URL and the newer index.
|
|
12207
|
+
*/
|
|
12208
|
+
async addStore({ url, key }) {
|
|
12209
|
+
if (key !== void 0 && !isAppKey(key)) fail("store-key", "Not a store key");
|
|
12210
|
+
const at = this.storeUrl(url);
|
|
12211
|
+
const read = await this.readStoreAt(at, key);
|
|
12212
|
+
if (!read.ok) fail(read.reason, "This is not a store Ghostly can read");
|
|
12213
|
+
const { index, digest } = read.store;
|
|
12214
|
+
const held = await this.storeRecord(index.key);
|
|
12215
|
+
const record = held && !held.removed ? {
|
|
12216
|
+
...held,
|
|
12217
|
+
url: at
|
|
12218
|
+
} : {
|
|
12219
|
+
key: index.key,
|
|
12220
|
+
url: at,
|
|
12221
|
+
addedAt: this.now(),
|
|
12222
|
+
...held?.preloaded && { preloaded: true }
|
|
12223
|
+
};
|
|
12224
|
+
await this.putStore(this.withIndex(record, index, digest));
|
|
12225
|
+
return this.summary(await this.storeRecord(index.key));
|
|
12226
|
+
}
|
|
12227
|
+
/** The update rule for indexes (WISP 1200 § Stores): a lower `sequence` or the same one with other bytes is refused, the held one kept. */
|
|
12228
|
+
withIndex(record, index, digest) {
|
|
12229
|
+
const held = record.index ? {
|
|
12230
|
+
key: record.key,
|
|
12231
|
+
sequence: record.index.sequence,
|
|
12232
|
+
digest: record.digest ?? ""
|
|
12233
|
+
} : null;
|
|
12234
|
+
const decision = appStoreDecision(held, {
|
|
12235
|
+
key: index.key,
|
|
12236
|
+
sequence: index.sequence,
|
|
12237
|
+
digest
|
|
12238
|
+
});
|
|
12239
|
+
const { problem: _problem, ...rest } = record;
|
|
12240
|
+
switch (decision) {
|
|
12241
|
+
case "new":
|
|
12242
|
+
case "update": {
|
|
12243
|
+
const { equivocation: _e, ...clean } = rest;
|
|
12244
|
+
return {
|
|
12245
|
+
...clean,
|
|
12246
|
+
index,
|
|
12247
|
+
digest,
|
|
12248
|
+
fetchedAt: this.now()
|
|
12249
|
+
};
|
|
12250
|
+
}
|
|
12251
|
+
case "same": return {
|
|
12252
|
+
...rest,
|
|
12253
|
+
fetchedAt: this.now()
|
|
12254
|
+
};
|
|
12255
|
+
case "rollback": return {
|
|
12256
|
+
...rest,
|
|
12257
|
+
problem: "rollback"
|
|
12258
|
+
};
|
|
12259
|
+
case "equivocation": return {
|
|
12260
|
+
...rest,
|
|
12261
|
+
equivocation: {
|
|
12262
|
+
sequence: index.sequence,
|
|
12263
|
+
digest,
|
|
12264
|
+
at: this.now()
|
|
12265
|
+
},
|
|
12266
|
+
problem: "equivocation"
|
|
12267
|
+
};
|
|
12268
|
+
default: return {
|
|
12269
|
+
...rest,
|
|
12270
|
+
problem: "store-key"
|
|
12271
|
+
};
|
|
12272
|
+
}
|
|
12273
|
+
}
|
|
12274
|
+
async removeStore({ key }) {
|
|
12275
|
+
const held = await this.storeRecord(key);
|
|
12276
|
+
if (!held) return;
|
|
12277
|
+
if (held.preloaded) await this.putStore({
|
|
12278
|
+
key: held.key,
|
|
12279
|
+
url: held.url,
|
|
12280
|
+
addedAt: held.addedAt,
|
|
12281
|
+
preloaded: true,
|
|
12282
|
+
removed: true
|
|
12283
|
+
});
|
|
12284
|
+
else await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
|
|
12285
|
+
}
|
|
12286
|
+
/** Reads one store again, or every store (the person opened the Apps page, or the update check). */
|
|
12287
|
+
async refreshStores({ key } = {}) {
|
|
12288
|
+
const stores = (await this.stores()).filter((s) => key === void 0 || s.key === key);
|
|
12289
|
+
if (key !== void 0 && !stores.length) fail("no-store", "No such store");
|
|
12290
|
+
const out = [];
|
|
12291
|
+
for (const s of stores) {
|
|
12292
|
+
let next;
|
|
12293
|
+
try {
|
|
12294
|
+
const read = await this.readStoreAt(s.url, s.key);
|
|
12295
|
+
next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
|
|
12296
|
+
...s,
|
|
12297
|
+
problem: read.reason
|
|
12298
|
+
};
|
|
12299
|
+
} catch (error) {
|
|
12300
|
+
next = {
|
|
12301
|
+
...s,
|
|
12302
|
+
problem: error instanceof AppFetchError ? error.code : "network"
|
|
12303
|
+
};
|
|
12304
|
+
}
|
|
12305
|
+
if (!await this.storeRecord(s.key) || (await this.storeRecord(s.key))?.removed) continue;
|
|
12306
|
+
await this.putStore(next);
|
|
12307
|
+
out.push(this.summary(next));
|
|
12308
|
+
}
|
|
12309
|
+
return out;
|
|
12310
|
+
}
|
|
12311
|
+
/**
|
|
12312
|
+
* Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
|
|
12313
|
+
* chat card's pointer. A store's or a card's `sequence` and digest must hold: the same app, and that version or a newer
|
|
12314
|
+
* one. Nothing is stored: the checked bundle waits in memory for `install`.
|
|
12315
|
+
*/
|
|
12316
|
+
async preview(source) {
|
|
12317
|
+
let urls;
|
|
12318
|
+
let expect = null;
|
|
12319
|
+
if ("url" in source) urls = [typeof source.url === "string" && appPasteUrl(source.url, "app.ghostlyapp") || notReadable(source.url, "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS")];
|
|
12320
|
+
else if ("store" in source) {
|
|
12321
|
+
const s = await this.storeRecord(source.store);
|
|
12322
|
+
const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
|
|
12323
|
+
if (!listing) fail("not-listed", "This store does not list this app");
|
|
12324
|
+
urls = listing.urls.filter(isAppFetchUrl);
|
|
12325
|
+
expect = {
|
|
12326
|
+
ref: listing.ref,
|
|
12327
|
+
sequence: listing.sequence,
|
|
12328
|
+
digest: listing.digest
|
|
12329
|
+
};
|
|
12330
|
+
} else if ("card" in source && source.card) {
|
|
12331
|
+
const { ref, url, sequence, digest } = source.card;
|
|
12332
|
+
if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
|
|
12333
|
+
if (!isAppFetchUrl(url)) fail("host", "A card's app is read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit");
|
|
12334
|
+
urls = [url];
|
|
12335
|
+
expect = {
|
|
12336
|
+
ref,
|
|
12337
|
+
...typeof sequence === "number" && { sequence },
|
|
12338
|
+
...typeof digest === "string" && { digest }
|
|
12339
|
+
};
|
|
12340
|
+
} else fail("bad-source", "No app to fetch");
|
|
12341
|
+
if (!urls.length) fail("host", "This listing has no URL Ghostly reads from");
|
|
12342
|
+
let last = null;
|
|
12343
|
+
for (const url of urls) try {
|
|
12344
|
+
const { bundle, bytes } = await this.fetchBundle(url);
|
|
12345
|
+
const version = versionOf(bundle.manifest, bundle.digest);
|
|
12346
|
+
if (expect) {
|
|
12347
|
+
if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
|
|
12348
|
+
if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
|
|
12349
|
+
if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
|
|
12350
|
+
}
|
|
12351
|
+
return this.stage(bundle, bytes, url);
|
|
12352
|
+
} catch (error) {
|
|
12353
|
+
last = error;
|
|
12354
|
+
}
|
|
12355
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
12356
|
+
}
|
|
12357
|
+
async stage(bundle, bytes, from) {
|
|
12358
|
+
for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
|
|
12359
|
+
this.staged.delete(bundle.digest);
|
|
12360
|
+
while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
|
|
12361
|
+
this.staged.set(bundle.digest, {
|
|
12362
|
+
bundle,
|
|
12363
|
+
bytes,
|
|
12364
|
+
from,
|
|
12365
|
+
at: this.now()
|
|
12366
|
+
});
|
|
12367
|
+
const { manifest, digest } = bundle;
|
|
12368
|
+
const version = versionOf(manifest, digest);
|
|
12369
|
+
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
12370
|
+
const decision = installed ? appUpdateDecision(installed, version) : "new";
|
|
12371
|
+
const install = decision === "other-app" ? "new" : decision;
|
|
12372
|
+
const asks = install === "new" ? [...manifest.permissions] : install === "update" ? manifest.permissions.filter((p) => !installed.permissions.includes(p)) : [];
|
|
12373
|
+
const listedBy = this.listedBy(version.ref, stores);
|
|
12374
|
+
return {
|
|
12375
|
+
digest,
|
|
12376
|
+
ref: version.ref,
|
|
12377
|
+
publisher: manifest.publisher,
|
|
12378
|
+
fingerprint: appFingerprint(manifest.publisher),
|
|
12379
|
+
manifest,
|
|
12380
|
+
from,
|
|
12381
|
+
icon: bundle.files.get("icon.png")?.slice() ?? null,
|
|
12382
|
+
install,
|
|
12383
|
+
asks,
|
|
12384
|
+
run: this.runStatus(version, stores, installed?.revocations),
|
|
12385
|
+
listedBy,
|
|
12386
|
+
unknownPublisher: !listedBy.some((s) => s.kind === "curated")
|
|
12387
|
+
};
|
|
12388
|
+
}
|
|
12389
|
+
/**
|
|
12390
|
+
* Installs the bundle `preview` fetched, once the person pressed Install: `grant` must hold every permission it asks
|
|
12391
|
+
* for (WISP 1200 § Permissions: none is granted by a store or a contact). A revoked or removed version is not
|
|
12392
|
+
* installed; neither is a rollback, nor another bundle under an installed `sequence` (marked on the app).
|
|
12393
|
+
*/
|
|
12394
|
+
async install({ digest, grant }) {
|
|
12395
|
+
const staged = this.staged.get(digest);
|
|
12396
|
+
if (!staged || this.now() - staged.at > STAGED.ms) {
|
|
12397
|
+
this.staged.delete(digest);
|
|
12398
|
+
fail("expired", "Fetch the app again");
|
|
12399
|
+
}
|
|
12400
|
+
const { bundle, bytes, from } = staged;
|
|
12401
|
+
const { manifest } = bundle;
|
|
12402
|
+
if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
|
|
12403
|
+
const version = versionOf(manifest, digest);
|
|
12404
|
+
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
12405
|
+
const run = this.runStatus(version, stores, installed?.revocations);
|
|
12406
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12407
|
+
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12408
|
+
const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
|
|
12409
|
+
const decision = installed ? pendingClash ? "equivocation" : appUpdateDecision(installed, version) : "new";
|
|
12410
|
+
if (decision === "rollback") fail("rollback", "A newer version of this app is installed");
|
|
12411
|
+
if (decision === "equivocation") {
|
|
12412
|
+
await this.putApp({
|
|
12413
|
+
...installed,
|
|
12414
|
+
equivocation: {
|
|
12415
|
+
sequence: version.sequence,
|
|
12416
|
+
digest,
|
|
12417
|
+
at: this.now()
|
|
12418
|
+
}
|
|
12419
|
+
});
|
|
12420
|
+
fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
|
|
12421
|
+
}
|
|
12422
|
+
if (decision === "same" && installed) {
|
|
12423
|
+
if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
|
|
12424
|
+
...installed,
|
|
12425
|
+
bytes: await this.writeBundle(digest, bytes)
|
|
12426
|
+
});
|
|
12427
|
+
this.staged.delete(digest);
|
|
12428
|
+
return this.view(await this.app(version.ref), stores);
|
|
12429
|
+
}
|
|
12430
|
+
const kind = await this.writeBundle(digest, bytes);
|
|
12431
|
+
const next = this.versionRecord(installed, bundle, kind, from);
|
|
12432
|
+
await this.putApp(next);
|
|
12433
|
+
if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
|
|
12434
|
+
if (installed?.pending && installed.pending.sequence <= version.sequence && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
|
|
12435
|
+
this.staged.delete(digest);
|
|
12436
|
+
return this.view(next, stores);
|
|
12437
|
+
}
|
|
12438
|
+
/** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
|
|
12439
|
+
versionRecord(installed, bundle, kind, from) {
|
|
12440
|
+
const { manifest, digest } = bundle;
|
|
12441
|
+
const now = this.now();
|
|
12442
|
+
const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
|
|
12443
|
+
return {
|
|
12444
|
+
ref: appRef(manifest.publisher, manifest.name),
|
|
12445
|
+
publisher: manifest.publisher,
|
|
12446
|
+
name: manifest.name,
|
|
12447
|
+
sequence: manifest.sequence,
|
|
12448
|
+
digest,
|
|
12449
|
+
permissions: [...manifest.permissions],
|
|
12450
|
+
manifest,
|
|
12451
|
+
bytes: kind,
|
|
12452
|
+
from,
|
|
12453
|
+
installedAt: installed?.installedAt ?? now,
|
|
12454
|
+
updatedAt: now,
|
|
12455
|
+
...installed?.revocations?.length && { revocations: installed.revocations },
|
|
12456
|
+
...installed?.equivocation && installed.equivocation.sequence >= manifest.sequence && { equivocation: installed.equivocation },
|
|
12457
|
+
...keepPending && { pending: keepPending },
|
|
12458
|
+
...installed?.checkedAt !== void 0 && { checkedAt: installed.checkedAt }
|
|
12459
|
+
};
|
|
12460
|
+
}
|
|
12461
|
+
/** The person accepted the permissions a waiting update adds: it is installed now. */
|
|
12462
|
+
async acceptUpdate({ ref }) {
|
|
12463
|
+
const installed = await this.installedOrFail(ref);
|
|
12464
|
+
const pending = installed.pending ?? fail("no-update", "No update waits for this app");
|
|
12465
|
+
const bytes = await this.readBundleBytes(pending.bytes, pending.digest);
|
|
12466
|
+
const read = bytes ? readAppBundle(bytes) : null;
|
|
12467
|
+
if (!read?.ok || read.bundle.digest !== pending.digest) {
|
|
12468
|
+
const { pending: _p, ...rest } = installed;
|
|
12469
|
+
await this.putApp(rest);
|
|
12470
|
+
fail("needs-files", "The update's files are gone; it is fetched again at the next check");
|
|
12471
|
+
}
|
|
12472
|
+
const stores = await this.stores();
|
|
12473
|
+
const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), stores, installed.revocations);
|
|
12474
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12475
|
+
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12476
|
+
const plan = planAppUpdate(installed, {
|
|
12477
|
+
...versionOf(read.bundle.manifest, pending.digest),
|
|
12478
|
+
permissions: read.bundle.manifest.permissions
|
|
12479
|
+
});
|
|
12480
|
+
if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
|
|
12481
|
+
const { pending: _p, ...without } = installed;
|
|
12482
|
+
const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
|
|
12483
|
+
await this.putApp(next);
|
|
12484
|
+
await this.removeBundle(installed.bytes, installed.digest);
|
|
12485
|
+
return this.view(next, stores);
|
|
12486
|
+
}
|
|
12487
|
+
/**
|
|
12488
|
+
* The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
|
|
12489
|
+
* app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
|
|
12490
|
+
* revocations read beside its sources. An update that keeps or drops permissions installs by itself; one that adds
|
|
12491
|
+
* some waits for the person. One check at a time.
|
|
12492
|
+
*/
|
|
12493
|
+
checkUpdates() {
|
|
12494
|
+
this.checking ??= this.checkNow().then((results) => {
|
|
12495
|
+
try {
|
|
12496
|
+
this.host.checked?.(results);
|
|
12497
|
+
} catch {}
|
|
12498
|
+
return results;
|
|
12499
|
+
}).finally(() => {
|
|
12500
|
+
this.checking = null;
|
|
12501
|
+
});
|
|
12502
|
+
return this.checking;
|
|
12503
|
+
}
|
|
12504
|
+
async checkNow() {
|
|
12505
|
+
const apps = await this.installed();
|
|
12506
|
+
if (!apps.length) return [];
|
|
12507
|
+
await this.refreshStores();
|
|
12508
|
+
const stores = await this.stores();
|
|
12509
|
+
const out = [];
|
|
12510
|
+
for (const listed of apps) {
|
|
12511
|
+
if (!await this.hasBundle(listed.bytes, listed.digest)) await this.fetchFiles({ ref: listed.ref }).catch(() => {});
|
|
12512
|
+
const first = await this.app(listed.ref);
|
|
12513
|
+
if (!first) continue;
|
|
12514
|
+
let app = first;
|
|
12515
|
+
let outcome = "none";
|
|
12516
|
+
const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
|
|
12517
|
+
const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
|
|
12518
|
+
const candidates = [];
|
|
12519
|
+
for (const s of stores) {
|
|
12520
|
+
const listing = s.index?.apps.find((a) => a.ref === app.ref);
|
|
12521
|
+
if (listing && (listing.sequence > highest || listing.sequence >= app.sequence && !known.has(listing.digest))) candidates.push(...listing.urls.filter(isAppFetchUrl));
|
|
12522
|
+
}
|
|
12523
|
+
for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
|
|
12524
|
+
const peek = peekAppManifest(await this.host.fetch(url, {
|
|
12525
|
+
maxBytes: PEEK_BYTES,
|
|
12526
|
+
peek: true
|
|
12527
|
+
}));
|
|
12528
|
+
if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
|
|
12529
|
+
} catch {}
|
|
12530
|
+
for (const url of new Set(candidates)) {
|
|
12531
|
+
let fetched;
|
|
12532
|
+
try {
|
|
12533
|
+
fetched = await this.fetchBundle(url);
|
|
12534
|
+
} catch {
|
|
12535
|
+
continue;
|
|
12536
|
+
}
|
|
12537
|
+
const { bundle, bytes } = fetched;
|
|
12538
|
+
const version = versionOf(bundle.manifest, bundle.digest);
|
|
12539
|
+
if (version.ref !== app.ref || known.has(version.digest)) continue;
|
|
12540
|
+
if (app.pending && version.sequence === app.pending.sequence) {
|
|
12541
|
+
app = {
|
|
12542
|
+
...app,
|
|
12543
|
+
equivocation: {
|
|
12544
|
+
sequence: version.sequence,
|
|
12545
|
+
digest: version.digest,
|
|
12546
|
+
at: this.now()
|
|
12547
|
+
}
|
|
12548
|
+
};
|
|
12549
|
+
outcome = "equivocation";
|
|
12550
|
+
continue;
|
|
12551
|
+
}
|
|
12552
|
+
const plan = planAppUpdate(app, {
|
|
12553
|
+
...version,
|
|
12554
|
+
permissions: bundle.manifest.permissions
|
|
12555
|
+
});
|
|
12556
|
+
if (plan.action === "refuse") {
|
|
12557
|
+
if (plan.decision === "equivocation") {
|
|
12558
|
+
app = {
|
|
12559
|
+
...app,
|
|
12560
|
+
equivocation: {
|
|
12561
|
+
sequence: version.sequence,
|
|
12562
|
+
digest: version.digest,
|
|
12563
|
+
at: this.now()
|
|
12564
|
+
}
|
|
12565
|
+
};
|
|
12566
|
+
outcome = "equivocation";
|
|
12567
|
+
}
|
|
12568
|
+
continue;
|
|
12569
|
+
}
|
|
12570
|
+
if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
|
|
12571
|
+
if (app.pending && version.sequence < app.pending.sequence) continue;
|
|
12572
|
+
const kind = await this.writeBundle(version.digest, bytes);
|
|
12573
|
+
if (plan.action === "install") {
|
|
12574
|
+
const old = app;
|
|
12575
|
+
app = this.versionRecord(app, bundle, kind, url);
|
|
12576
|
+
if (old.pending && old.pending.sequence <= version.sequence) {
|
|
12577
|
+
await this.removeBundle(old.pending.bytes, old.pending.digest);
|
|
12578
|
+
delete app.pending;
|
|
12579
|
+
}
|
|
12580
|
+
await this.putApp(app);
|
|
12581
|
+
await this.removeBundle(old.bytes, old.digest);
|
|
12582
|
+
outcome = "updated";
|
|
12583
|
+
} else {
|
|
12584
|
+
if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
|
|
12585
|
+
app = {
|
|
12586
|
+
...app,
|
|
12587
|
+
pending: {
|
|
12588
|
+
sequence: version.sequence,
|
|
12589
|
+
digest: version.digest,
|
|
12590
|
+
manifest: bundle.manifest,
|
|
12591
|
+
added: plan.added,
|
|
12592
|
+
bytes: kind,
|
|
12593
|
+
from: url,
|
|
12594
|
+
at: this.now()
|
|
12595
|
+
}
|
|
12596
|
+
};
|
|
12597
|
+
outcome = "ask";
|
|
12598
|
+
}
|
|
12599
|
+
break;
|
|
12600
|
+
}
|
|
12601
|
+
const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
|
|
12602
|
+
const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
|
|
12603
|
+
for (const r of revocations) merged.set(canonicalJson(r), r);
|
|
12604
|
+
if (!await this.app(app.ref)) continue;
|
|
12605
|
+
app = {
|
|
12606
|
+
...app,
|
|
12607
|
+
...merged.size && { revocations: [...merged.values()] },
|
|
12608
|
+
checkedAt: this.now()
|
|
12609
|
+
};
|
|
12610
|
+
await this.putApp(app);
|
|
12611
|
+
out.push({
|
|
12612
|
+
ref: app.ref,
|
|
12613
|
+
outcome,
|
|
12614
|
+
run: await this.runCheckOf(app, stores)
|
|
12615
|
+
});
|
|
12616
|
+
}
|
|
12617
|
+
return out;
|
|
12618
|
+
}
|
|
12619
|
+
/**
|
|
12620
|
+
* Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
|
|
12621
|
+
* carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
|
|
12622
|
+
* taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
|
|
12623
|
+
* then its `sources`. Nothing answers: it stays `needs-files`, and it never runs unchecked.
|
|
12624
|
+
*/
|
|
12625
|
+
async fetchFiles({ ref }) {
|
|
12626
|
+
const app = await this.installedOrFail(ref);
|
|
12627
|
+
const stores = await this.stores();
|
|
12628
|
+
if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
|
|
12629
|
+
const urls = [app.from];
|
|
12630
|
+
for (const s of stores) for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listing.urls);
|
|
12631
|
+
urls.push(...app.manifest.sources ?? []);
|
|
12632
|
+
for (const url of new Set(urls.filter(isAppFetchUrl))) {
|
|
12633
|
+
let fetched;
|
|
12634
|
+
try {
|
|
12635
|
+
fetched = await this.fetchBundle(url);
|
|
12636
|
+
} catch {
|
|
12637
|
+
continue;
|
|
12638
|
+
}
|
|
12639
|
+
if (fetched.bundle.digest !== app.digest) continue;
|
|
12640
|
+
const kind = await this.writeBundle(app.digest, fetched.bytes);
|
|
12641
|
+
const now = await this.app(app.ref);
|
|
12642
|
+
if (!now || now.digest !== app.digest) {
|
|
12643
|
+
await this.removeBundle(kind, app.digest);
|
|
12644
|
+
fail("not-installed", "This app is not installed");
|
|
12645
|
+
}
|
|
12646
|
+
if (now.bytes !== kind) await this.putApp({
|
|
12647
|
+
...now,
|
|
12648
|
+
bytes: kind
|
|
12649
|
+
});
|
|
12650
|
+
return this.runCheckOf({
|
|
12651
|
+
...now,
|
|
12652
|
+
bytes: kind
|
|
12653
|
+
}, stores);
|
|
12654
|
+
}
|
|
12655
|
+
return { status: "needs-files" };
|
|
12656
|
+
}
|
|
12657
|
+
async runCheckOf(app, stores) {
|
|
12658
|
+
if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
|
|
12659
|
+
return this.runStatus(app, stores, app.revocations);
|
|
12660
|
+
}
|
|
12661
|
+
/** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
|
|
12662
|
+
async runCheck({ ref }) {
|
|
12663
|
+
return this.runCheckOf(await this.installedOrFail(ref), await this.stores());
|
|
12664
|
+
}
|
|
12665
|
+
/**
|
|
12666
|
+
* What the runner writes into its frame: the entry, from the bundle read back and checked again. A revoked version
|
|
12667
|
+
* never runs; one a store removed runs only when the person chose "Run anyway" (`runAnyway`).
|
|
12668
|
+
*/
|
|
12669
|
+
async entry({ ref, runAnyway }) {
|
|
12670
|
+
let app = await this.installedOrFail(ref);
|
|
12671
|
+
let run = await this.runCheckOf(app, await this.stores());
|
|
12672
|
+
if (run.status === "needs-files") {
|
|
12673
|
+
run = await this.fetchFiles({ ref });
|
|
12674
|
+
app = await this.installedOrFail(ref);
|
|
12675
|
+
}
|
|
12676
|
+
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12677
|
+
if (run.status === "removed" && runAnyway !== true) fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12678
|
+
if (run.status === "needs-files") fail("needs-files", "This app's files are not on this device yet");
|
|
12679
|
+
if (run.status === "removed") this.anyway.add(`${app.ref} ${app.digest}`);
|
|
12680
|
+
const bundle = await this.verifiedBundle(app);
|
|
12681
|
+
const entry = bundle.files.get(bundle.manifest.entry) ?? fail("damaged", "The entry is missing");
|
|
12682
|
+
return {
|
|
12683
|
+
ref: app.ref,
|
|
12684
|
+
digest: app.digest,
|
|
12685
|
+
version: app.manifest.version,
|
|
12686
|
+
title: app.manifest.title,
|
|
12687
|
+
permissions: [...app.permissions],
|
|
12688
|
+
view: appViewOf(bundle.manifest),
|
|
12689
|
+
entry: utf8Decode(entry)
|
|
12690
|
+
};
|
|
12691
|
+
}
|
|
12692
|
+
/** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
|
|
12693
|
+
async file({ ref, path }) {
|
|
12694
|
+
const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
|
|
12695
|
+
const bundle = await this.verifiedBundle(app);
|
|
12696
|
+
if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
|
|
12697
|
+
return bundle.files.get(path).slice();
|
|
12698
|
+
}
|
|
12699
|
+
async view(app, stores) {
|
|
12700
|
+
const { manifest } = app;
|
|
12701
|
+
const listedBy = this.listedBy(app.ref, stores);
|
|
12702
|
+
return {
|
|
12703
|
+
ref: app.ref,
|
|
12704
|
+
name: app.name,
|
|
12705
|
+
publisher: app.publisher,
|
|
12706
|
+
fingerprint: appFingerprint(app.publisher),
|
|
12707
|
+
title: manifest.title,
|
|
12708
|
+
tagline: manifest.tagline,
|
|
12709
|
+
...manifest.description !== void 0 && { description: manifest.description },
|
|
12710
|
+
version: manifest.version,
|
|
12711
|
+
sequence: app.sequence,
|
|
12712
|
+
digest: app.digest,
|
|
12713
|
+
permissions: [...app.permissions],
|
|
12714
|
+
view: appViewOf(manifest),
|
|
12715
|
+
from: app.from,
|
|
12716
|
+
icon: manifest.files.some((f) => f.path === "icon.png"),
|
|
12717
|
+
installedAt: app.installedAt,
|
|
12718
|
+
updatedAt: app.updatedAt,
|
|
12719
|
+
run: await this.runCheckOf(app, stores),
|
|
12720
|
+
listedBy,
|
|
12721
|
+
unknownPublisher: !listedBy.some((s) => s.kind === "curated"),
|
|
12722
|
+
...app.pending && { pending: {
|
|
12723
|
+
sequence: app.pending.sequence,
|
|
12724
|
+
version: app.pending.manifest.version,
|
|
12725
|
+
added: [...app.pending.added]
|
|
12726
|
+
} },
|
|
12727
|
+
...app.equivocation && { equivocation: {
|
|
12728
|
+
sequence: app.equivocation.sequence,
|
|
12729
|
+
at: app.equivocation.at
|
|
12730
|
+
} },
|
|
12731
|
+
...app.checkedAt !== void 0 && { checkedAt: app.checkedAt }
|
|
12732
|
+
};
|
|
12733
|
+
}
|
|
12734
|
+
async list() {
|
|
12735
|
+
const [apps, stores] = await Promise.all([this.installed(), this.stores()]);
|
|
12736
|
+
return Promise.all(apps.sort((a, b) => a.installedAt - b.installedAt).map((a) => this.view(a, stores)));
|
|
12737
|
+
}
|
|
12738
|
+
/**
|
|
12739
|
+
* Uninstalls an app: its bundle (and a waiting update's), its record and its storage in every scope. The person chose
|
|
12740
|
+
* it, after the offer of an export (`exportData`).
|
|
12741
|
+
*/
|
|
12742
|
+
async uninstall({ ref }) {
|
|
12743
|
+
const app = await this.installedOrFail(ref);
|
|
12744
|
+
const tx = (await openDb()).transaction([STORES$1.apps, STORES$1.appStorage], "readwrite");
|
|
12745
|
+
tx.objectStore(STORES$1.apps).delete(app.ref);
|
|
12746
|
+
tx.objectStore(STORES$1.appStorage).delete(rowsOf(app.ref));
|
|
12747
|
+
await done(tx);
|
|
12748
|
+
for (const key of [...this.usage.keys()]) if (key.startsWith(`${app.ref}\0`)) this.usage.delete(key);
|
|
12749
|
+
await this.removeBundle(app.bytes, app.digest);
|
|
12750
|
+
if (app.pending) await this.removeBundle(app.pending.bytes, app.pending.digest);
|
|
12751
|
+
}
|
|
12752
|
+
scopeOf(scope) {
|
|
12753
|
+
if (scope === "alone" || typeof scope === "string" && this.host.isChat(scope)) return scope;
|
|
12754
|
+
fail("bad-scope", "Not a chat of this profile");
|
|
12755
|
+
}
|
|
12756
|
+
keyOf(key) {
|
|
12757
|
+
if (typeof key !== "string" || !key.length || byteLength(key) > APP_STORAGE_LIMITS.keyBytes) fail("bad-key", "A key is 1 to 256 bytes of text");
|
|
12758
|
+
return key;
|
|
12759
|
+
}
|
|
12760
|
+
/** One scope's writes, one after another, so the count of bytes used stays true. */
|
|
12761
|
+
serial(id, work) {
|
|
12762
|
+
const run = (this.queues.get(id) ?? Promise.resolve()).then(work, work);
|
|
12763
|
+
const settled = run.catch(() => {});
|
|
12764
|
+
this.queues.set(id, settled);
|
|
12765
|
+
settled.then(() => {
|
|
12766
|
+
if (this.queues.get(id) === settled) this.queues.delete(id);
|
|
12767
|
+
});
|
|
12768
|
+
return run;
|
|
12769
|
+
}
|
|
12770
|
+
async usageOf(ref, scope) {
|
|
12771
|
+
const id = `${ref}\0${scope}`;
|
|
12772
|
+
let used = this.usage.get(id);
|
|
12773
|
+
if (used === void 0) {
|
|
12774
|
+
used = (await wrap((await store(STORES$1.appStorage, "readonly")).index(APP_STORAGE_SCOPE_INDEX).getAll([ref, scope]))).reduce((sum, r) => sum + r.size, 0);
|
|
12775
|
+
this.usage.set(id, used);
|
|
12776
|
+
}
|
|
12777
|
+
return used;
|
|
12778
|
+
}
|
|
12779
|
+
async storageGet({ ref, scope, key }) {
|
|
12780
|
+
const app = await this.runningOrFail(ref);
|
|
12781
|
+
const row = await wrap((await store(STORES$1.appStorage, "readonly")).get([
|
|
12782
|
+
app.ref,
|
|
12783
|
+
this.scopeOf(scope),
|
|
12784
|
+
this.keyOf(key)
|
|
12785
|
+
]));
|
|
12786
|
+
return row ? { value: JSON.parse(row.value) } : null;
|
|
12787
|
+
}
|
|
12788
|
+
/** Keeps a JSON value under a key: at most 64 KiB, and 5 MiB for every key of this app in this scope. */
|
|
12789
|
+
async storageSet({ ref, scope, key, value }) {
|
|
12790
|
+
const app = await this.runningOrFail(ref);
|
|
12791
|
+
const where = this.scopeOf(scope), name = this.keyOf(key);
|
|
12792
|
+
let text;
|
|
12793
|
+
try {
|
|
12794
|
+
text = JSON.stringify(value);
|
|
12795
|
+
} catch {
|
|
12796
|
+
text = void 0;
|
|
12797
|
+
}
|
|
12798
|
+
if (typeof text !== "string") fail("bad-value", "A value is JSON");
|
|
12799
|
+
const valueBytes = byteLength(text);
|
|
12800
|
+
if (valueBytes > APP_STORAGE_LIMITS.valueBytes) fail("too-large", "A value is at most 64 KiB");
|
|
12801
|
+
const size = byteLength(name) + valueBytes;
|
|
12802
|
+
await this.serial(`${app.ref}\0${where}`, async () => {
|
|
12803
|
+
const used = await this.usageOf(app.ref, where);
|
|
12804
|
+
const rows = await store(STORES$1.appStorage, "readonly");
|
|
12805
|
+
const next = used - ((await wrap(rows.get([
|
|
12806
|
+
app.ref,
|
|
12807
|
+
where,
|
|
12808
|
+
name
|
|
12809
|
+
])))?.size ?? 0) + size;
|
|
12810
|
+
if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
|
|
12811
|
+
await wrap((await store(STORES$1.appStorage, "readwrite")).put({
|
|
12812
|
+
ref: app.ref,
|
|
12813
|
+
scope: where,
|
|
12814
|
+
key: name,
|
|
12815
|
+
value: text,
|
|
12816
|
+
size
|
|
12817
|
+
}));
|
|
12818
|
+
this.usage.set(`${app.ref}\0${where}`, next);
|
|
12819
|
+
});
|
|
12820
|
+
}
|
|
12821
|
+
async storageDelete({ ref, scope, key }) {
|
|
12822
|
+
const app = await this.runningOrFail(ref);
|
|
12823
|
+
const where = this.scopeOf(scope), name = this.keyOf(key);
|
|
12824
|
+
await this.serial(`${app.ref}\0${where}`, async () => {
|
|
12825
|
+
const used = await this.usageOf(app.ref, where);
|
|
12826
|
+
const old = await wrap((await store(STORES$1.appStorage, "readonly")).get([
|
|
12827
|
+
app.ref,
|
|
12828
|
+
where,
|
|
12829
|
+
name
|
|
12830
|
+
]));
|
|
12831
|
+
if (!old) return;
|
|
12832
|
+
await wrap((await store(STORES$1.appStorage, "readwrite")).delete([
|
|
12833
|
+
app.ref,
|
|
12834
|
+
where,
|
|
12835
|
+
name
|
|
12836
|
+
]));
|
|
12837
|
+
this.usage.set(`${app.ref}\0${where}`, used - old.size);
|
|
12838
|
+
});
|
|
12839
|
+
}
|
|
12840
|
+
async storageKeys({ ref, scope }) {
|
|
12841
|
+
const app = await this.runningOrFail(ref);
|
|
12842
|
+
return (await wrap((await store(STORES$1.appStorage, "readonly")).getAllKeys(rowsOf(app.ref, this.scopeOf(scope))))).map((k) => k[2]);
|
|
12843
|
+
}
|
|
12844
|
+
/** An app's storage in every scope, as export files (WISP 1200 § Where installed apps live), offered before an uninstall. */
|
|
12845
|
+
async exportData({ ref }) {
|
|
12846
|
+
const app = await this.installedOrFail(ref);
|
|
12847
|
+
const rows = await wrap((await store(STORES$1.appStorage, "readonly")).getAll(rowsOf(app.ref)));
|
|
12848
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
12849
|
+
for (const row of rows) {
|
|
12850
|
+
const out = scopes.get(row.scope) ?? {
|
|
12851
|
+
ghostlyAppData: 1,
|
|
12852
|
+
app: app.ref,
|
|
12853
|
+
scope: row.scope,
|
|
12854
|
+
entries: {}
|
|
12855
|
+
};
|
|
12856
|
+
out.entries[row.key] = JSON.parse(row.value);
|
|
12857
|
+
scopes.set(row.scope, out);
|
|
12858
|
+
}
|
|
12859
|
+
return [...scopes.values()];
|
|
12860
|
+
}
|
|
12861
|
+
};
|
|
12862
|
+
//#endregion
|
|
11706
12863
|
//#region ../browser/src/devices/state.ts
|
|
11707
12864
|
/** WISP 06 § States of a device. */
|
|
11708
12865
|
var DEVICE_STATES = [
|
|
@@ -17561,7 +18718,7 @@ async function room() {
|
|
|
17561
18718
|
async function handoffSelf(profileHost) {
|
|
17562
18719
|
return {
|
|
17563
18720
|
app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
|
|
17564
|
-
db:
|
|
18721
|
+
db: 14,
|
|
17565
18722
|
pins: { ...WALLET_SDK_PINS },
|
|
17566
18723
|
kind: profileHost.kind,
|
|
17567
18724
|
room: await room(),
|
|
@@ -20447,6 +21604,9 @@ var db = {
|
|
|
20447
21604
|
await fileStore.deleteForLink(linkId);
|
|
20448
21605
|
await removeFileBytes(`${linkId}-in-`);
|
|
20449
21606
|
await removeFileBytes(`${linkId}-out-`);
|
|
21607
|
+
const appStorage = await store(STORES$1.appStorage, "readwrite");
|
|
21608
|
+
const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
|
|
21609
|
+
await Promise.all(appKeys.map((key) => wrap(appStorage.delete(key))));
|
|
20450
21610
|
},
|
|
20451
21611
|
async getMessages(linkId) {
|
|
20452
21612
|
return (await wrap((await store(STORES$1.messages, "readonly")).index("byLink").getAll(linkId))).sort((a, b) => a.timestamp - b.timestamp);
|
|
@@ -20477,14 +21637,17 @@ var db = {
|
|
|
20477
21637
|
}
|
|
20478
21638
|
},
|
|
20479
21639
|
/**
|
|
20480
|
-
* Every message of this profile that carries a task or a
|
|
21640
|
+
* Every message of this profile that carries a task, a routine or a usage card, from every chat and group, oldest first (time,
|
|
20481
21641
|
* then id). Reads the card index only (`CARD_INDEX`): no chat's history is read, however long. The index follows the
|
|
20482
21642
|
* rows, so an edit's card, a deleted message and a deleted chat or group are in step with no bookkeeping.
|
|
20483
21643
|
*/
|
|
20484
21644
|
async getCardMessages() {
|
|
20485
21645
|
const index = (await store(STORES$1.messages, "readonly")).index(CARD_INDEX);
|
|
20486
|
-
|
|
20487
|
-
|
|
21646
|
+
return (await Promise.all([
|
|
21647
|
+
"task",
|
|
21648
|
+
"routine",
|
|
21649
|
+
"usage"
|
|
21650
|
+
].map((kind) => wrap(index.getAll(kind))))).flat().sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
20488
21651
|
},
|
|
20489
21652
|
/** Returns false when the message was already stored. */
|
|
20490
21653
|
async addMessage(message) {
|
|
@@ -20854,22 +22017,6 @@ async function myPin(host, chat, messageId, remove, current, now = Date.now()) {
|
|
|
20854
22017
|
};
|
|
20855
22018
|
}
|
|
20856
22019
|
//#endregion
|
|
20857
|
-
//#region ../browser/src/engine/joinTrace.ts
|
|
20858
|
-
/**
|
|
20859
|
-
* Timestamps along a join through a group's link, for measuring where the time goes. Silent unless
|
|
20860
|
-
* the page sets `globalThis.__ghostlyJoinTrace` (the e2e measurement does): each step is then one
|
|
20861
|
-
* `console.debug` line, `[ghostly:join] {"t":…,"g":…,"step":…}`, read back by the test.
|
|
20862
|
-
*/
|
|
20863
|
-
function traceJoin(group, step, detail) {
|
|
20864
|
-
if (!globalThis.__ghostlyJoinTrace) return;
|
|
20865
|
-
console.debug(`[ghostly:join] ${JSON.stringify({
|
|
20866
|
-
t: Date.now(),
|
|
20867
|
-
g: group,
|
|
20868
|
-
step,
|
|
20869
|
-
...detail
|
|
20870
|
-
})}`);
|
|
20871
|
-
}
|
|
20872
|
-
//#endregion
|
|
20873
22020
|
//#region ../browser/src/engine/hubClocks.ts
|
|
20874
22021
|
/**
|
|
20875
22022
|
* A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
|
|
@@ -21038,6 +22185,14 @@ var HUB_GRACE_MS = 18e4;
|
|
|
21038
22185
|
var CUT_OFF_STEP_UP_MS = 15e3;
|
|
21039
22186
|
/** A lobby request this recent is from a member still looking fast for my side of the edge: mine opens expecting it. */
|
|
21040
22187
|
var LOBBY_EXPECT_MS = 3e4;
|
|
22188
|
+
/**
|
|
22189
|
+
* A lobby is one record every member asking a hub writes, each reading it and writing it back with its own request: two
|
|
22190
|
+
* members writing at once (both cut off by the same leave, in the same second) each write what they read, and the later
|
|
22191
|
+
* 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
|
|
22192
|
+
* the members who wrote together do not read and write together again) and asks again at once if its request is gone.
|
|
22193
|
+
*/
|
|
22194
|
+
var LOBBY_CHECK_MS = 2e3;
|
|
22195
|
+
var LOBBY_CHECK_JITTER_MS = 3e3;
|
|
21041
22196
|
var REFUSED_FOR_MS$1 = 6e5;
|
|
21042
22197
|
var ENTRY_LINGER_MS$1 = 2e4;
|
|
21043
22198
|
var RELAYED_KEPT = 4096;
|
|
@@ -21096,6 +22251,8 @@ var Communities = class {
|
|
|
21096
22251
|
knockAt = /* @__PURE__ */ new Map();
|
|
21097
22252
|
/** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
|
|
21098
22253
|
knockingWith = /* @__PURE__ */ new Map();
|
|
22254
|
+
/** Joiners: the knock record a knock read whose write the relays refused, and when it was read (`knock`). */
|
|
22255
|
+
knockRead = /* @__PURE__ */ new Map();
|
|
21099
22256
|
/** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
|
|
21100
22257
|
frames = new FramesTaken();
|
|
21101
22258
|
carriers = /* @__PURE__ */ new Map();
|
|
@@ -21123,12 +22280,28 @@ var Communities = class {
|
|
|
21123
22280
|
now() {
|
|
21124
22281
|
return this.time || Date.now();
|
|
21125
22282
|
}
|
|
22283
|
+
/** Files and voice messages (WISP 503), the desk `Groups` keeps for every group. */
|
|
22284
|
+
files;
|
|
21126
22285
|
has(groupId) {
|
|
21127
22286
|
return this.stored.has(groupId);
|
|
21128
22287
|
}
|
|
22288
|
+
ids() {
|
|
22289
|
+
return [...this.stored.keys()];
|
|
22290
|
+
}
|
|
21129
22291
|
session(groupId) {
|
|
21130
22292
|
return this.live.get(groupId)?.session;
|
|
21131
22293
|
}
|
|
22294
|
+
/** Me in a community I am a member of, for its files: who is in it, and who could read one of its messages. */
|
|
22295
|
+
fileMembership(groupId) {
|
|
22296
|
+
const session = this.live.get(groupId)?.session;
|
|
22297
|
+
if (!session?.isMember) return void 0;
|
|
22298
|
+
return {
|
|
22299
|
+
me: session.myKey,
|
|
22300
|
+
community: true,
|
|
22301
|
+
inRoster: (key) => rosterHas(session.roster, key),
|
|
22302
|
+
couldRead: (key, messageId) => session.couldRead(key, messageId)
|
|
22303
|
+
};
|
|
22304
|
+
}
|
|
21132
22305
|
/** Is this device a hub of the group? (tests and the load harness) */
|
|
21133
22306
|
isHub(groupId) {
|
|
21134
22307
|
const live = this.live.get(groupId);
|
|
@@ -21281,6 +22454,7 @@ var Communities = class {
|
|
|
21281
22454
|
this.knocked.delete(link.g);
|
|
21282
22455
|
this.knockAt.delete(link.g);
|
|
21283
22456
|
this.knockingWith.delete(link.g);
|
|
22457
|
+
this.knockRead.delete(link.g);
|
|
21284
22458
|
const before = this.stored.get(link.g)?.joining;
|
|
21285
22459
|
const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
|
|
21286
22460
|
if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
|
|
@@ -21314,11 +22488,12 @@ var Communities = class {
|
|
|
21314
22488
|
this.knocked.delete(group.id);
|
|
21315
22489
|
this.knockAt.delete(group.id);
|
|
21316
22490
|
this.knockingWith.delete(group.id);
|
|
22491
|
+
this.knockRead.delete(group.id);
|
|
21317
22492
|
await this.host.closeEdge(joining.linkId);
|
|
21318
22493
|
await this.store.putGroup(group);
|
|
21319
22494
|
this.host.emit();
|
|
21320
22495
|
}
|
|
21321
|
-
async send(groupId, text, mentions = [], reply, forwarded, card) {
|
|
22496
|
+
async send(groupId, text, mentions = [], reply, forwarded, card, file) {
|
|
21322
22497
|
const live = this.live.get(groupId);
|
|
21323
22498
|
if (!live) return {
|
|
21324
22499
|
error: "You are not in this group yet",
|
|
@@ -21329,7 +22504,7 @@ var Communities = class {
|
|
|
21329
22504
|
refused: true
|
|
21330
22505
|
};
|
|
21331
22506
|
const now = this.now();
|
|
21332
|
-
const result = card ? await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded, card) : await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded);
|
|
22507
|
+
const result = card || file ? await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded, card, file) : await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded);
|
|
21333
22508
|
return "error" in result ? { error: result.error } : {
|
|
21334
22509
|
error: null,
|
|
21335
22510
|
messageId: result.id
|
|
@@ -21454,6 +22629,7 @@ var Communities = class {
|
|
|
21454
22629
|
this.lastPeerMessageAt.delete(groupId);
|
|
21455
22630
|
this.lastMentionAt.delete(groupId);
|
|
21456
22631
|
this.knockingWith.delete(groupId);
|
|
22632
|
+
this.knockRead.delete(groupId);
|
|
21457
22633
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
21458
22634
|
await this.store.deleteGroup(groupId);
|
|
21459
22635
|
this.host.historyGone?.(groupId);
|
|
@@ -21472,7 +22648,7 @@ var Communities = class {
|
|
|
21472
22648
|
if (group.joining && (this.knocking(group, live) || group.joining.check)) {
|
|
21473
22649
|
const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
|
|
21474
22650
|
if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
|
|
21475
|
-
else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
|
|
22651
|
+
else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= (this.knocked.has(group.id) ? every : Math.min(every, 3e3))) await this.knock(group, now).catch(() => {});
|
|
21476
22652
|
if (!live) continue;
|
|
21477
22653
|
}
|
|
21478
22654
|
if (!live) continue;
|
|
@@ -21648,7 +22824,15 @@ var Communities = class {
|
|
|
21648
22824
|
live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
|
|
21649
22825
|
for (const hub of kept) {
|
|
21650
22826
|
const id = edges.get(hub);
|
|
21651
|
-
if (id && this.host.linkReady(id, 2))
|
|
22827
|
+
if (id && this.host.linkReady(id, 2)) {
|
|
22828
|
+
live.lobbyChecks?.delete(hub);
|
|
22829
|
+
continue;
|
|
22830
|
+
}
|
|
22831
|
+
const check = live.lobbyChecks?.get(hub);
|
|
22832
|
+
if (check && now >= check.at) {
|
|
22833
|
+
live.lobbyChecks.delete(hub);
|
|
22834
|
+
if (await this.lobbyLost(groupId, live, hub, check.ts)) live.lobbyWrites.delete(hub);
|
|
22835
|
+
}
|
|
21652
22836
|
if (now - (live.lobbyWrites.get(hub) ?? 0) >= this.timings.lobbyWriteMs) {
|
|
21653
22837
|
live.lobbyWrites.set(hub, now);
|
|
21654
22838
|
await this.askHub(groupId, live, hub, now).catch(() => {
|
|
@@ -21921,8 +23105,26 @@ var Communities = class {
|
|
|
21921
23105
|
key: live.session.myKey,
|
|
21922
23106
|
ts: theirs
|
|
21923
23107
|
}, theirs)), true, true);
|
|
23108
|
+
(live.lobbyChecks ??= /* @__PURE__ */ new Map()).set(hub, {
|
|
23109
|
+
at: now + LOBBY_CHECK_MS + Math.floor(this.random() * LOBBY_CHECK_JITTER_MS),
|
|
23110
|
+
ts: Math.floor(theirs / 1e3) * 1e3
|
|
23111
|
+
});
|
|
21924
23112
|
traceJoin(groupId, "lobby.written");
|
|
21925
23113
|
}
|
|
23114
|
+
/**
|
|
23115
|
+
* 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
|
|
23116
|
+
* reading from before mine, dropped it (`LOBBY_CHECK_MS`). A full lobby that pushed it out is left as it is: asking
|
|
23117
|
+
* again there would push out someone else. A read that fails says nothing, and the usual refresh asks again.
|
|
23118
|
+
*/
|
|
23119
|
+
async lobbyLost(groupId, live, hub, ts) {
|
|
23120
|
+
const keys = lobbyKeys(live.session.state.rv, groupId, hub);
|
|
23121
|
+
const records = await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => void 0);
|
|
23122
|
+
if (records === void 0) return false;
|
|
23123
|
+
const entries = readLobby(keys, records ?? []);
|
|
23124
|
+
const lost = !entries.some((e) => e.key === live.session.myKey && e.ts >= ts) && entries.length < COMMUNITY_TOPOLOGY.lobbyEntries;
|
|
23125
|
+
if (lost) traceJoin(groupId, "lobby.lost");
|
|
23126
|
+
return lost;
|
|
23127
|
+
}
|
|
21926
23128
|
async pollLobby(groupId, live, now) {
|
|
21927
23129
|
const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
|
|
21928
23130
|
const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true, true).catch(() => null) ?? []);
|
|
@@ -22047,10 +23249,19 @@ var Communities = class {
|
|
|
22047
23249
|
}
|
|
22048
23250
|
const records = this.knockRecordsToRead(live, door === s.myKey, busy, now);
|
|
22049
23251
|
if (!records.length) return;
|
|
23252
|
+
let failed = 0;
|
|
22050
23253
|
const read = await Promise.all(records.map(async (n) => {
|
|
22051
23254
|
const record = knockRecord(link, n);
|
|
22052
|
-
return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() =>
|
|
23255
|
+
return readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true, door === s.myKey && n === KNOCK_BELL).catch(() => {
|
|
23256
|
+
failed++;
|
|
23257
|
+
return null;
|
|
23258
|
+
}) ?? []);
|
|
22053
23259
|
}));
|
|
23260
|
+
traceJoin(groupId, "knock.read", {
|
|
23261
|
+
records: records.length,
|
|
23262
|
+
knocks: read.flat().length,
|
|
23263
|
+
...failed ? { failed } : {}
|
|
23264
|
+
});
|
|
22054
23265
|
const bell = records.indexOf(KNOCK_BELL);
|
|
22055
23266
|
if (bell >= 0 && read[bell].filter((k) => now - k.ts < 18e4).length >= BELL_FULL) live.crowdUntil = now + CROWD_MS;
|
|
22056
23267
|
const newest = /* @__PURE__ */ new Map();
|
|
@@ -22109,10 +23320,16 @@ var Communities = class {
|
|
|
22109
23320
|
* Leaves (or refreshes) my knock: in the bell while it has room, in my own record once a crowd has
|
|
22110
23321
|
* filled it. The first one goes out at once; refreshes are background requests, and keep my side of
|
|
22111
23322
|
* the entry session looking fast while I still expect a member to answer soon.
|
|
23323
|
+
*
|
|
23324
|
+
* "The first" is the first of this run that the relays took (`knocked`), not the first tried: a knock the relays'
|
|
23325
|
+
* budget refused is tried again as one, every `UNKNOCKED_RETRY_MS`. Taken for a refresh, it went as a background
|
|
23326
|
+
* request, every `knockMs`, and background requests are held to a few a minute while a link of this app signals: its
|
|
23327
|
+
* own entry session, looking fast for the door's answer. A CLI that was the door of another community, its budget
|
|
23328
|
+
* spent, never knocked at all (groupCompat, with the budget scaled down to 12 requests a minute).
|
|
22112
23329
|
*/
|
|
22113
23330
|
async knock(group, now = this.now()) {
|
|
22114
23331
|
const joining = group.joining;
|
|
22115
|
-
const first = !this.
|
|
23332
|
+
const first = !this.knocked.has(group.id);
|
|
22116
23333
|
this.lastKnock.set(group.id, now);
|
|
22117
23334
|
if (first || now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
|
|
22118
23335
|
const me = identityFromSeedB64(joining.seedB64).pubKeyZ32, link = {
|
|
@@ -22127,16 +23344,37 @@ var Communities = class {
|
|
|
22127
23344
|
knocks: readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, !first) ?? [])
|
|
22128
23345
|
};
|
|
22129
23346
|
};
|
|
22130
|
-
|
|
22131
|
-
|
|
22132
|
-
|
|
22133
|
-
|
|
22134
|
-
|
|
22135
|
-
|
|
23347
|
+
const kept = this.knockRead.get(group.id);
|
|
23348
|
+
this.knockRead.delete(group.id);
|
|
23349
|
+
const reuse = kept && now - kept.at < this.timings.knockMs ? kept : void 0;
|
|
23350
|
+
let n, record, knocks;
|
|
23351
|
+
if (reuse) {
|
|
23352
|
+
n = reuse.n;
|
|
23353
|
+
knocks = reuse.knocks;
|
|
23354
|
+
record = knockRecord(link, n);
|
|
23355
|
+
} else {
|
|
23356
|
+
n = this.knockAt.get(group.id) ?? KNOCK_BELL;
|
|
23357
|
+
({record, knocks} = await read(n));
|
|
23358
|
+
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)));
|
|
23359
|
+
}
|
|
23360
|
+
try {
|
|
23361
|
+
await this.host.publish(knockIdentity(record), knockRecords(record, mergeKnocks(knocks, {
|
|
23362
|
+
key: me,
|
|
23363
|
+
ts: now
|
|
23364
|
+
}, now)), !first);
|
|
23365
|
+
} catch (error) {
|
|
23366
|
+
this.knockRead.set(group.id, {
|
|
23367
|
+
n,
|
|
23368
|
+
knocks,
|
|
23369
|
+
at: reuse?.at ?? now
|
|
23370
|
+
});
|
|
23371
|
+
throw error;
|
|
23372
|
+
}
|
|
22136
23373
|
this.knockAt.set(group.id, n);
|
|
22137
23374
|
traceJoin(group.id, "knock.published", {
|
|
22138
23375
|
ms: Date.now() - started,
|
|
22139
|
-
record: n
|
|
23376
|
+
record: n,
|
|
23377
|
+
...reuse && { kept: true }
|
|
22140
23378
|
});
|
|
22141
23379
|
const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
|
|
22142
23380
|
const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
|
|
@@ -22273,6 +23511,7 @@ var Communities = class {
|
|
|
22273
23511
|
this.knocked.delete(g);
|
|
22274
23512
|
this.knockAt.delete(g);
|
|
22275
23513
|
this.knockingWith.delete(g);
|
|
23514
|
+
this.knockRead.delete(g);
|
|
22276
23515
|
await this.host.closeEdge(linkId);
|
|
22277
23516
|
await this.reconcile(g, live, now);
|
|
22278
23517
|
this.host.emit();
|
|
@@ -22369,9 +23608,7 @@ var Communities = class {
|
|
|
22369
23608
|
await this.store.putGroup(group);
|
|
22370
23609
|
},
|
|
22371
23610
|
broadcast: (frame) => this.hearers(id, frame).filter(([, linkId]) => this.sendTo(linkId, frame)).length,
|
|
22372
|
-
direct: (to, frame) =>
|
|
22373
|
-
if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
|
|
22374
|
-
},
|
|
23611
|
+
direct: (to, frame) => this.mayHear(this.live.get(id), to, frame) ? this.sendTo(this.host.edges(id).get(to), frame) : void 0,
|
|
22375
23612
|
addressed: (to, frame) => {
|
|
22376
23613
|
const edges = this.host.edges(id), live = this.live.get(id);
|
|
22377
23614
|
const direct = edges.get(to);
|
|
@@ -22386,10 +23623,15 @@ var Communities = class {
|
|
|
22386
23623
|
message: async (m) => {
|
|
22387
23624
|
const timestamp = m.timestamp;
|
|
22388
23625
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
23626
|
+
const file = m.file && this.files ? await this.files.announced(id, {
|
|
23627
|
+
id: m.id,
|
|
23628
|
+
sender: m.sender,
|
|
23629
|
+
file: m.file
|
|
23630
|
+
}, session.myKey) : void 0;
|
|
22389
23631
|
const stored = await this.host.storeMessage({
|
|
22390
23632
|
linkId: MESSAGE_LINK$1(id),
|
|
22391
23633
|
id: m.id,
|
|
22392
|
-
text: m.text,
|
|
23634
|
+
text: file ? groupFileText(m.text, m.file) : m.text,
|
|
22393
23635
|
sender: m.sender === session.myKey ? "me" : "peer",
|
|
22394
23636
|
member: m.sender,
|
|
22395
23637
|
timestamp,
|
|
@@ -22397,7 +23639,8 @@ var Communities = class {
|
|
|
22397
23639
|
...mentionFields(m.mentions, mentioned),
|
|
22398
23640
|
...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
|
|
22399
23641
|
...m.forwarded && { forwarded: m.forwarded },
|
|
22400
|
-
...m.card && { card: m.card }
|
|
23642
|
+
...m.card && { card: m.card },
|
|
23643
|
+
...file && { file }
|
|
22401
23644
|
});
|
|
22402
23645
|
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
22403
23646
|
this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
|
|
@@ -22405,7 +23648,7 @@ var Communities = class {
|
|
|
22405
23648
|
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
|
|
22406
23649
|
noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
|
|
22407
23650
|
},
|
|
22408
|
-
app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
|
|
23651
|
+
app: (m) => this.deliver(id, () => m.frame.t === "group-have" ? this.files?.communityApp(id, m.sender, m.frame) : this.host.communityApp?.(id, m.sender, m.frame)),
|
|
22409
23652
|
pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
|
|
22410
23653
|
changed: () => {
|
|
22411
23654
|
this.membershipChanged(id);
|
|
@@ -22424,7 +23667,11 @@ var Communities = class {
|
|
|
22424
23667
|
adminTurn: () => this.host.adminTurn?.(id) ?? Promise.resolve(true),
|
|
22425
23668
|
relay: (frame) => {
|
|
22426
23669
|
if (this.live.get(id)?.hub) for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
|
|
22427
|
-
}
|
|
23670
|
+
},
|
|
23671
|
+
...this.host.linkHandled && { handled: (to) => {
|
|
23672
|
+
const edge = this.host.edges(id).get(to);
|
|
23673
|
+
return edge && this.host.linkHandled ? this.host.linkHandled(edge) : Promise.resolve(false);
|
|
23674
|
+
} }
|
|
22428
23675
|
});
|
|
22429
23676
|
this.live.set(id, {
|
|
22430
23677
|
session,
|
|
@@ -23611,6 +24858,8 @@ var Groups = class {
|
|
|
23611
24858
|
typings;
|
|
23612
24859
|
/** Edge signaling through members (WISP 902 · Group Mesh § Signaling through members). */
|
|
23613
24860
|
signals;
|
|
24861
|
+
/** Files and voice messages in every group, private or community (WISP 503 · Group Files). */
|
|
24862
|
+
files;
|
|
23614
24863
|
constructor(host, store = db, timings = ENTRY_TIMINGS, communityTimings = COMMUNITY_TIMINGS, random, hubTimings = MESH_HUB_TIMINGS) {
|
|
23615
24864
|
this.host = host;
|
|
23616
24865
|
this.store = store;
|
|
@@ -23628,6 +24877,27 @@ var Groups = class {
|
|
|
23628
24877
|
stored: (id) => this.stored.get(id),
|
|
23629
24878
|
now: () => this.now()
|
|
23630
24879
|
});
|
|
24880
|
+
const files = host.groupFiles;
|
|
24881
|
+
if (files) {
|
|
24882
|
+
this.files = new GroupFileDesk({
|
|
24883
|
+
...files,
|
|
24884
|
+
send: (linkId, frame) => {
|
|
24885
|
+
try {
|
|
24886
|
+
host.sendOnLink(linkId, frame);
|
|
24887
|
+
return true;
|
|
24888
|
+
} catch {
|
|
24889
|
+
return false;
|
|
24890
|
+
}
|
|
24891
|
+
},
|
|
24892
|
+
edges: (groupId) => host.edges(groupId),
|
|
24893
|
+
ready: (linkId) => host.linkReady(linkId, 6),
|
|
24894
|
+
membership: (groupId) => this.fileMembership(groupId),
|
|
24895
|
+
sendApp: (groupId, frame) => this.communities.sendApp(groupId, frame),
|
|
24896
|
+
changed: () => host.emit(),
|
|
24897
|
+
now: () => this.now()
|
|
24898
|
+
});
|
|
24899
|
+
this.communities.files = this.files;
|
|
24900
|
+
}
|
|
23631
24901
|
}
|
|
23632
24902
|
async load() {
|
|
23633
24903
|
const all = await this.store.getGroups();
|
|
@@ -23645,6 +24915,7 @@ var Groups = class {
|
|
|
23645
24915
|
if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
|
|
23646
24916
|
}
|
|
23647
24917
|
for (const id of this.sessions.keys()) this.reconcileEdges(id);
|
|
24918
|
+
await this.files?.load(all.map((g) => g.id));
|
|
23648
24919
|
for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
|
|
23649
24920
|
}
|
|
23650
24921
|
/** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
|
|
@@ -23889,6 +25160,58 @@ var Groups = class {
|
|
|
23889
25160
|
};
|
|
23890
25161
|
}
|
|
23891
25162
|
/**
|
|
25163
|
+
* Announces a file or voice message of mine (WISP 503): `text` is its caption, or the line older apps show
|
|
25164
|
+
* (`groupFileFallback`), `file` its description with the digest of the bytes kept under `fileId`, which this device
|
|
25165
|
+
* then holds and serves. `messageId` is the announcement's.
|
|
25166
|
+
*/
|
|
25167
|
+
async sendFile(groupId, text, file, fileId, reply, forwarded) {
|
|
25168
|
+
if (!this.files) return {
|
|
25169
|
+
error: "This app takes no group files",
|
|
25170
|
+
refused: true
|
|
25171
|
+
};
|
|
25172
|
+
const paced = this.files.mayAnnounce(groupId);
|
|
25173
|
+
if (paced) return { error: paced };
|
|
25174
|
+
this.files.outgoing(groupId, fileId, file);
|
|
25175
|
+
let result;
|
|
25176
|
+
if (this.isCommunity(groupId)) result = await this.communities.send(groupId, text, [], reply, forwarded, void 0, file);
|
|
25177
|
+
else {
|
|
25178
|
+
const session = this.sessions.get(groupId);
|
|
25179
|
+
const sent = session ? await session.sendText(text, Date.now(), [], reply, forwarded, void 0, file) : { error: "You are not in this group yet" };
|
|
25180
|
+
if (session) this.typings.say(session, false);
|
|
25181
|
+
result = "error" in sent ? { error: sent.error } : {
|
|
25182
|
+
error: null,
|
|
25183
|
+
messageId: sent.id
|
|
25184
|
+
};
|
|
25185
|
+
}
|
|
25186
|
+
if (result.error) this.files.notSent(groupId, file);
|
|
25187
|
+
return result;
|
|
25188
|
+
}
|
|
25189
|
+
/** The person asks for a group file this device did not fetch by itself, or asks again for one that stopped coming. */
|
|
25190
|
+
downloadFile(fileId) {
|
|
25191
|
+
if (!groupOfFile(fileId, [...this.stored.keys(), ...this.communities.ids()]) || !this.files) throw new Error("No such file");
|
|
25192
|
+
return this.files.download(fileId);
|
|
25193
|
+
}
|
|
25194
|
+
/** An edge's files/3 session opened or closed (WISP 503: the bytes of a group's files go over it). */
|
|
25195
|
+
fileSession(groupId, peerKey, linkId, open) {
|
|
25196
|
+
this.files?.session(groupId, peerKey, linkId, open);
|
|
25197
|
+
}
|
|
25198
|
+
/** A files/3 frame on an edge, from the member it is pinned to. */
|
|
25199
|
+
filesFrame(groupId, peerKey, linkId, frame) {
|
|
25200
|
+
return this.files?.filesFrame(groupId, peerKey, linkId, frame) ?? Promise.resolve();
|
|
25201
|
+
}
|
|
25202
|
+
/** Me in a group I am active in, for its files: who is in it, and who could read one of its messages. */
|
|
25203
|
+
fileMembership(groupId) {
|
|
25204
|
+
if (this.isCommunity(groupId)) return this.communities.fileMembership(groupId);
|
|
25205
|
+
const session = this.sessions.get(groupId);
|
|
25206
|
+
if (session?.status !== "active") return void 0;
|
|
25207
|
+
return {
|
|
25208
|
+
me: session.myKey,
|
|
25209
|
+
community: false,
|
|
25210
|
+
inRoster: (key) => rosterHas(session.roster, key),
|
|
25211
|
+
couldRead: (key, messageId) => session.couldRead(key, messageId)
|
|
25212
|
+
};
|
|
25213
|
+
}
|
|
25214
|
+
/**
|
|
23892
25215
|
* This side is typing in a private group (with what it is doing), or stopped (WISP 902 · Group Mesh § Typing).
|
|
23893
25216
|
* Nothing for a community: it does not carry typing yet.
|
|
23894
25217
|
*/
|
|
@@ -24129,7 +25452,11 @@ var Groups = class {
|
|
|
24129
25452
|
await this.session(groupId).rename(name);
|
|
24130
25453
|
}
|
|
24131
25454
|
async forget(groupId) {
|
|
24132
|
-
if (this.isCommunity(groupId))
|
|
25455
|
+
if (this.isCommunity(groupId)) {
|
|
25456
|
+
await this.communities.forget(groupId);
|
|
25457
|
+
await this.files?.drop(groupId);
|
|
25458
|
+
return;
|
|
25459
|
+
}
|
|
24133
25460
|
if (this.sessions.get(groupId)?.status === "active") try {
|
|
24134
25461
|
await this.leave(groupId);
|
|
24135
25462
|
} catch {}
|
|
@@ -24156,6 +25483,7 @@ var Groups = class {
|
|
|
24156
25483
|
this.signals.forget(groupId);
|
|
24157
25484
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
24158
25485
|
await this.store.deleteGroup(groupId);
|
|
25486
|
+
await this.files?.drop(groupId);
|
|
24159
25487
|
this.host.historyGone?.(groupId);
|
|
24160
25488
|
this.host.membersChanged?.(groupId);
|
|
24161
25489
|
this.host.emit();
|
|
@@ -24281,6 +25609,7 @@ var Groups = class {
|
|
|
24281
25609
|
this.meshTick(now);
|
|
24282
25610
|
await this.communities.tick(now);
|
|
24283
25611
|
await this.hubsTick(now);
|
|
25612
|
+
this.files?.tick();
|
|
24284
25613
|
} finally {
|
|
24285
25614
|
this.ticking = false;
|
|
24286
25615
|
}
|
|
@@ -24654,6 +25983,12 @@ var Groups = class {
|
|
|
24654
25983
|
}
|
|
24655
25984
|
/** A `group-*` frame on an edge: from the member the edge is pinned to. */
|
|
24656
25985
|
async handleEdgeFrame(groupId, peerKey, frame) {
|
|
25986
|
+
const t = frame && typeof frame === "object" ? frame.t : void 0;
|
|
25987
|
+
if (t === "group-want" || t === "group-want-no" || t === "group-have") {
|
|
25988
|
+
const linkId = this.host.edges(groupId).get(peerKey);
|
|
25989
|
+
if (linkId && !this.stored.get(groupId)?.left) await this.files?.frame(groupId, peerKey, linkId, frame);
|
|
25990
|
+
return;
|
|
25991
|
+
}
|
|
24657
25992
|
if (this.isCommunity(groupId)) return this.communities.handleEdgeFrame(groupId, peerKey, frame);
|
|
24658
25993
|
if (this.stored.get(groupId)?.left) {
|
|
24659
25994
|
if (this.removesMe(groupId, peerKey, frame)) await this.leaveConfirmed(groupId);
|
|
@@ -24682,7 +26017,6 @@ var Groups = class {
|
|
|
24682
26017
|
return;
|
|
24683
26018
|
}
|
|
24684
26019
|
if (!session) return;
|
|
24685
|
-
const t = frame && typeof frame === "object" ? frame.t : void 0;
|
|
24686
26020
|
if (t === "group-react" || t === "group-reacted") {
|
|
24687
26021
|
await this.reaction(groupId, session, peerKey, frame);
|
|
24688
26022
|
return;
|
|
@@ -24743,6 +26077,7 @@ var Groups = class {
|
|
|
24743
26077
|
edgeReady(groupId, peerKey, linkId) {
|
|
24744
26078
|
if (this.isCommunity(groupId)) {
|
|
24745
26079
|
this.communities.edgeReady(groupId, peerKey, linkId);
|
|
26080
|
+
this.files?.edgeReady(groupId, peerKey, linkId);
|
|
24746
26081
|
return;
|
|
24747
26082
|
}
|
|
24748
26083
|
const left = this.stored.get(groupId)?.left;
|
|
@@ -24782,6 +26117,7 @@ var Groups = class {
|
|
|
24782
26117
|
this.welcomed.get(groupId)?.delete(peerKey);
|
|
24783
26118
|
this.announceHere(groupId, session, peerKey);
|
|
24784
26119
|
this.signals.edgeReady(groupId, session, peerKey, linkId);
|
|
26120
|
+
this.files?.edgeReady(groupId, peerKey, linkId);
|
|
24785
26121
|
this.host.edgeUp?.(groupId, peerKey);
|
|
24786
26122
|
this.host.emit();
|
|
24787
26123
|
}
|
|
@@ -24983,10 +26319,15 @@ var Groups = class {
|
|
|
24983
26319
|
message: async (m) => {
|
|
24984
26320
|
const timestamp = m.timestamp;
|
|
24985
26321
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
26322
|
+
const file = m.file && this.files ? await this.files.announced(state.id, {
|
|
26323
|
+
id: m.id,
|
|
26324
|
+
sender: m.sender,
|
|
26325
|
+
file: m.file
|
|
26326
|
+
}, session.myKey) : void 0;
|
|
24986
26327
|
const message = {
|
|
24987
26328
|
linkId: MESSAGE_LINK(state.id),
|
|
24988
26329
|
id: m.id,
|
|
24989
|
-
text: m.text,
|
|
26330
|
+
text: file ? groupFileText(m.text, m.file) : m.text,
|
|
24990
26331
|
sender: m.sender === session.myKey ? "me" : "peer",
|
|
24991
26332
|
member: m.sender,
|
|
24992
26333
|
timestamp,
|
|
@@ -24994,7 +26335,8 @@ var Groups = class {
|
|
|
24994
26335
|
...mentionFields(m.mentions, mentioned),
|
|
24995
26336
|
...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
|
|
24996
26337
|
...m.forwarded && { forwarded: m.forwarded },
|
|
24997
|
-
...m.card && { card: m.card }
|
|
26338
|
+
...m.card && { card: m.card },
|
|
26339
|
+
...file && { file }
|
|
24998
26340
|
};
|
|
24999
26341
|
const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
|
|
25000
26342
|
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
@@ -25011,7 +26353,11 @@ var Groups = class {
|
|
|
25011
26353
|
},
|
|
25012
26354
|
metaChanged: (by, change, at) => {
|
|
25013
26355
|
this.metaChanged(state.id, session, by, change, at);
|
|
25014
|
-
}
|
|
26356
|
+
},
|
|
26357
|
+
...this.host.linkHandled && { handled: (to) => {
|
|
26358
|
+
const edge = this.host.edges(state.id).get(to);
|
|
26359
|
+
return edge && this.host.linkHandled ? this.host.linkHandled(edge) : Promise.resolve(false);
|
|
26360
|
+
} }
|
|
25015
26361
|
});
|
|
25016
26362
|
this.sessions.set(state.id, session);
|
|
25017
26363
|
this.lastRoster.set(state.id, session.roster);
|
|
@@ -25123,16 +26469,34 @@ function edgeView(live, now = Date.now(), noSlot = false) {
|
|
|
25123
26469
|
noSlot: true,
|
|
25124
26470
|
lastSeenAt: Math.max(presenceSeenAt(live.presence), live.lastSyncAt)
|
|
25125
26471
|
};
|
|
25126
|
-
const
|
|
26472
|
+
const pairingError = live.pairing?.status === "error" ? live.pairing.error : void 0;
|
|
26473
|
+
const error = open ? void 0 : pairingError ?? live.discoveryError;
|
|
25127
26474
|
const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
|
|
25128
26475
|
return {
|
|
25129
26476
|
linkId: live.stored.id,
|
|
25130
26477
|
state,
|
|
25131
26478
|
...open && live.pairing?.transport ? { transport: live.pairing.transport } : {},
|
|
25132
26479
|
lastSeenAt: open ? now : Math.max(presenceSeenAt(live.presence), live.lastSyncAt),
|
|
25133
|
-
...error ? {
|
|
26480
|
+
...error ? {
|
|
26481
|
+
error,
|
|
26482
|
+
cause: pairingError ? edgeErrorCause(pairingError) : "relays"
|
|
26483
|
+
} : {}
|
|
25134
26484
|
};
|
|
25135
26485
|
}
|
|
26486
|
+
/**
|
|
26487
|
+
* A pairing error the session ends with when nothing is wrong with the member: an attempt dialled again. Their
|
|
26488
|
+
* English is `PairedSession`'s and `GhostLink`'s (packages/core).
|
|
26489
|
+
*/
|
|
26490
|
+
var SESSION_DROPS = [
|
|
26491
|
+
/^Session receive queue full$/,
|
|
26492
|
+
/^The peer did not finish authentication\b/,
|
|
26493
|
+
/^Invalid session negotiation$/
|
|
26494
|
+
];
|
|
26495
|
+
/** What an edge's pairing error comes down to: the relays (its signal could not go out), a session dropped, or else. */
|
|
26496
|
+
function edgeErrorCause(error) {
|
|
26497
|
+
if (error.startsWith("Could not publish connection details:") || /^Could not (publish|read) discovery:/.test(error)) return "relays";
|
|
26498
|
+
return SESSION_DROPS.some((drop) => drop.test(error)) ? "session" : "other";
|
|
26499
|
+
}
|
|
25136
26500
|
//#endregion
|
|
25137
26501
|
//#region ../browser/src/engine/groupPayments.ts
|
|
25138
26502
|
/**
|
|
@@ -32194,203 +33558,6 @@ var WalletSetup = class {
|
|
|
32194
33558
|
}
|
|
32195
33559
|
};
|
|
32196
33560
|
//#endregion
|
|
32197
|
-
//#region ../browser/src/platform/irohWeb.ts
|
|
32198
|
-
/**
|
|
32199
|
-
* Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
|
|
32200
|
-
* Desktop's native Iroh, but relay only. A page cannot send UDP, so every
|
|
32201
|
-
* packet goes through an Iroh relay over a WebSocket; the relay sees who talks
|
|
32202
|
-
* to whom and when, never the frames.
|
|
32203
|
-
*
|
|
32204
|
-
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
32205
|
-
* never on the app's first load.
|
|
32206
|
-
*/
|
|
32207
|
-
/**
|
|
32208
|
-
* n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
|
|
32209
|
-
* dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
|
|
32210
|
-
* because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
|
|
32211
|
-
*/
|
|
32212
|
-
var DEFAULT_IROH_RELAYS = [
|
|
32213
|
-
"https://use1-1.relay.n0.iroh.link/",
|
|
32214
|
-
"https://euc1-1.relay.n0.iroh.link/",
|
|
32215
|
-
"https://aps1-1.relay.n0.iroh.link/",
|
|
32216
|
-
"https://usw1-1.relay.n0.iroh.link/"
|
|
32217
|
-
];
|
|
32218
|
-
/**
|
|
32219
|
-
* The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
|
|
32220
|
-
* relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
|
|
32221
|
-
* open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
|
|
32222
|
-
*/
|
|
32223
|
-
var BROWSER_SPELLING = "plain";
|
|
32224
|
-
/**
|
|
32225
|
-
* Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
|
|
32226
|
-
* may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
|
|
32227
|
-
*/
|
|
32228
|
-
function irohRelayProblem(value) {
|
|
32229
|
-
let url;
|
|
32230
|
-
try {
|
|
32231
|
-
url = new URL(value);
|
|
32232
|
-
} catch {
|
|
32233
|
-
return `Not a relay address: ${value}`;
|
|
32234
|
-
}
|
|
32235
|
-
const local = [
|
|
32236
|
-
"localhost",
|
|
32237
|
-
"127.0.0.1",
|
|
32238
|
-
"[::1]"
|
|
32239
|
-
].includes(url.hostname);
|
|
32240
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
|
|
32241
|
-
if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
|
|
32242
|
-
return null;
|
|
32243
|
-
}
|
|
32244
|
-
var loaded = null;
|
|
32245
|
-
/** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
|
|
32246
|
-
function loadIrohWasm() {
|
|
32247
|
-
loaded ??= import("./ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
|
|
32248
|
-
await module.default();
|
|
32249
|
-
return module;
|
|
32250
|
-
}).catch((error) => {
|
|
32251
|
-
loaded = null;
|
|
32252
|
-
throw error;
|
|
32253
|
-
});
|
|
32254
|
-
return loaded;
|
|
32255
|
-
}
|
|
32256
|
-
var SEND_BUDGET = 122880;
|
|
32257
|
-
var MAX_FRAME = 61440;
|
|
32258
|
-
/** One paired channel over one QUIC stream. Sends are queued in order. */
|
|
32259
|
-
var IrohWebChannel = class {
|
|
32260
|
-
conn;
|
|
32261
|
-
forget;
|
|
32262
|
-
reader = null;
|
|
32263
|
-
buffered = [];
|
|
32264
|
-
queue = Promise.resolve();
|
|
32265
|
-
closed = false;
|
|
32266
|
-
bufferedAmount = 0;
|
|
32267
|
-
sendBudget = SEND_BUDGET;
|
|
32268
|
-
onClose = null;
|
|
32269
|
-
constructor(conn, forget) {
|
|
32270
|
-
this.conn = conn;
|
|
32271
|
-
this.forget = forget;
|
|
32272
|
-
this.read();
|
|
32273
|
-
}
|
|
32274
|
-
get onMessage() {
|
|
32275
|
-
return this.reader;
|
|
32276
|
-
}
|
|
32277
|
-
set onMessage(reader) {
|
|
32278
|
-
this.reader = reader;
|
|
32279
|
-
if (reader) for (const text of this.buffered.splice(0)) reader(text);
|
|
32280
|
-
}
|
|
32281
|
-
async read() {
|
|
32282
|
-
try {
|
|
32283
|
-
for (;;) {
|
|
32284
|
-
const text = await this.conn.recv();
|
|
32285
|
-
if (text === void 0 || this.closed) break;
|
|
32286
|
-
if (this.reader) this.reader(text);
|
|
32287
|
-
else if (this.buffered.length < 64) this.buffered.push(text);
|
|
32288
|
-
else break;
|
|
32289
|
-
}
|
|
32290
|
-
} catch {}
|
|
32291
|
-
this.close();
|
|
32292
|
-
}
|
|
32293
|
-
send(data) {
|
|
32294
|
-
if (typeof data !== "string" || this.closed || !data.length || data.length > MAX_FRAME || this.bufferedAmount + data.length > SEND_BUDGET) throw new Error("Iroh channel is closed or its send budget is full");
|
|
32295
|
-
this.bufferedAmount += data.length;
|
|
32296
|
-
this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
|
|
32297
|
-
this.bufferedAmount -= data.length;
|
|
32298
|
-
});
|
|
32299
|
-
}
|
|
32300
|
-
drained() {
|
|
32301
|
-
return this.queue;
|
|
32302
|
-
}
|
|
32303
|
-
get rttMs() {
|
|
32304
|
-
const rtt = this.conn.rtt();
|
|
32305
|
-
return rtt >= 0 ? rtt : void 0;
|
|
32306
|
-
}
|
|
32307
|
-
close() {
|
|
32308
|
-
if (this.closed) return;
|
|
32309
|
-
this.closed = true;
|
|
32310
|
-
this.buffered = [];
|
|
32311
|
-
try {
|
|
32312
|
-
this.conn.close();
|
|
32313
|
-
} catch {}
|
|
32314
|
-
this.forget();
|
|
32315
|
-
this.onClose?.();
|
|
32316
|
-
}
|
|
32317
|
-
};
|
|
32318
|
-
/** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
|
|
32319
|
-
async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
32320
|
-
const seed = fromBase64Url(seedB64);
|
|
32321
|
-
if (seed.length !== 32) throw new Error("Invalid transport seed");
|
|
32322
|
-
const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
|
|
32323
|
-
const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
|
|
32324
|
-
const channels = /* @__PURE__ */ new Set();
|
|
32325
|
-
let stopped = false;
|
|
32326
|
-
let handler = null;
|
|
32327
|
-
const early = [];
|
|
32328
|
-
const bind = (conn) => {
|
|
32329
|
-
const channel = new IrohWebChannel(conn, () => channels.delete(channel));
|
|
32330
|
-
channels.add(channel);
|
|
32331
|
-
return {
|
|
32332
|
-
channel,
|
|
32333
|
-
binding: conn.binding()
|
|
32334
|
-
};
|
|
32335
|
-
};
|
|
32336
|
-
const endpoint = {
|
|
32337
|
-
transport: "iroh/1",
|
|
32338
|
-
descriptor: {
|
|
32339
|
-
...node.address(),
|
|
32340
|
-
relayed: true
|
|
32341
|
-
},
|
|
32342
|
-
onDescriptor: null,
|
|
32343
|
-
get onConnection() {
|
|
32344
|
-
return handler;
|
|
32345
|
-
},
|
|
32346
|
-
set onConnection(value) {
|
|
32347
|
-
handler = value;
|
|
32348
|
-
if (value) for (const bound of early.splice(0)) value(bound);
|
|
32349
|
-
},
|
|
32350
|
-
async connect(descriptor) {
|
|
32351
|
-
if (stopped) throw new Error("Iroh endpoint is stopped");
|
|
32352
|
-
const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
|
|
32353
|
-
if (stopped) {
|
|
32354
|
-
conn.close();
|
|
32355
|
-
throw new Error("Iroh endpoint is stopped");
|
|
32356
|
-
}
|
|
32357
|
-
return bind(conn);
|
|
32358
|
-
},
|
|
32359
|
-
async close() {
|
|
32360
|
-
if (stopped) return;
|
|
32361
|
-
stopped = true;
|
|
32362
|
-
for (const channel of [...channels]) channel.close();
|
|
32363
|
-
for (const bound of early.splice(0)) bound.channel.close();
|
|
32364
|
-
await node.close().catch(() => {});
|
|
32365
|
-
}
|
|
32366
|
-
};
|
|
32367
|
-
(async () => {
|
|
32368
|
-
while (!stopped) {
|
|
32369
|
-
let conn;
|
|
32370
|
-
try {
|
|
32371
|
-
conn = await node.accept();
|
|
32372
|
-
} catch {
|
|
32373
|
-
break;
|
|
32374
|
-
}
|
|
32375
|
-
if (!conn) break;
|
|
32376
|
-
if (stopped) {
|
|
32377
|
-
conn.close();
|
|
32378
|
-
break;
|
|
32379
|
-
}
|
|
32380
|
-
const bound = bind(conn);
|
|
32381
|
-
const accept = endpoint.onConnection;
|
|
32382
|
-
if (accept) accept(bound);
|
|
32383
|
-
else if (early.length < 4) early.push(bound);
|
|
32384
|
-
else bound.channel.close();
|
|
32385
|
-
}
|
|
32386
|
-
if (!stopped) {
|
|
32387
|
-
endpoint.onUnavailable?.();
|
|
32388
|
-
await endpoint.close();
|
|
32389
|
-
}
|
|
32390
|
-
})();
|
|
32391
|
-
return endpoint;
|
|
32392
|
-
}
|
|
32393
|
-
//#endregion
|
|
32394
33561
|
//#region ../browser/src/engine/node.ts
|
|
32395
33562
|
/** How long a chat waits before listening again after its HyperDHT relay went away. */
|
|
32396
33563
|
var RELAY_RETRY_MS = 3e4;
|
|
@@ -32420,6 +33587,12 @@ var DEFAULT_SETTINGS = {
|
|
|
32420
33587
|
mints: [],
|
|
32421
33588
|
mintsInitialized: false
|
|
32422
33589
|
};
|
|
33590
|
+
/**
|
|
33591
|
+
* A card that went on the DHT floor or into a hold goes again live (`restoreCards`) only for the newest this many
|
|
33592
|
+
* messages of a chat, sent within this long: an old chat's cards are not worth a burst of edits.
|
|
33593
|
+
*/
|
|
33594
|
+
var CARD_RESTORE_MOST = 50;
|
|
33595
|
+
var CARD_RESTORE_MS = 6048e5;
|
|
32423
33596
|
/** A reaction said on the live session and not confirmed is said again after this long. */
|
|
32424
33597
|
var REACTION_RESEND_MS = 3e4;
|
|
32425
33598
|
function emptyLinkFiles() {
|
|
@@ -33386,6 +34559,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
33386
34559
|
live.link.sendGroupFrame(frame);
|
|
33387
34560
|
},
|
|
33388
34561
|
linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
|
|
34562
|
+
linkHandled: (linkId) => this.links.get(linkId)?.link?.handled() ?? Promise.resolve(false),
|
|
33389
34563
|
linkOpen: (linkId) => !!this.links.get(linkId)?.link?.isDataLinkOpen,
|
|
33390
34564
|
myNick: () => this.sharedNick,
|
|
33391
34565
|
seqFloor: () => this.counterFloor,
|
|
@@ -33461,7 +34635,17 @@ var GhostlyNode = class GhostlyNode {
|
|
|
33461
34635
|
this.groupEdits.resend(groupId, peer).catch(() => {});
|
|
33462
34636
|
this.sendGroupPinFrame(groupId, peer);
|
|
33463
34637
|
},
|
|
33464
|
-
communityPair: (groupId, sender, payload) => this.communityPay.receivePair(groupId, sender, payload)
|
|
34638
|
+
communityPair: (groupId, sender, payload) => this.communityPay.receivePair(groupId, sender, payload),
|
|
34639
|
+
groupFiles: {
|
|
34640
|
+
store: storedGroupFiles,
|
|
34641
|
+
transfers: this.transfers,
|
|
34642
|
+
settings: () => ({
|
|
34643
|
+
autoDownloads: this.settings.autoDownloads !== false,
|
|
34644
|
+
serveFiles: this.settings.serveFiles !== false
|
|
34645
|
+
}),
|
|
34646
|
+
sendFiles: (linkId, frame) => this.links.get(linkId)?.link?.sendFilesFrame(frame) ?? false,
|
|
34647
|
+
writable: (linkId) => this.links.get(linkId)?.link?.filesWritable()
|
|
34648
|
+
}
|
|
33465
34649
|
});
|
|
33466
34650
|
/**
|
|
33467
34651
|
* Payments in community groups (WISP 903 · Group Community § Payments): the desk pays and requests over a link
|
|
@@ -33873,6 +35057,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
33873
35057
|
this.prepareSpare(STARTUP_QUIET_MS);
|
|
33874
35058
|
}
|
|
33875
35059
|
if (!this.limitedMode) this.openStartedWallets(fresh);
|
|
35060
|
+
if (this.appsOn) this.appStore.start().catch(() => {});
|
|
33876
35061
|
if (!this.options.singleDevice && this.networkOn && knownDeviceGate()?.state === "active") this.startDeviceSet().catch(() => {});
|
|
33877
35062
|
if (!this.options.singleDevice && this.limitedMode && knownDeviceGate()?.state === "active") this.readTurnWhileLimited();
|
|
33878
35063
|
if (this.restoreCheck === "checking" && this.limitedMode) this.checkRestoredTurn(0);
|
|
@@ -34257,6 +35442,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34257
35442
|
clearTimeout(this.limitedTimer);
|
|
34258
35443
|
this.limitedTimer = null;
|
|
34259
35444
|
}
|
|
35445
|
+
this.appShelf?.stop();
|
|
34260
35446
|
if (!quiet) this.depart();
|
|
34261
35447
|
this.directPath.close();
|
|
34262
35448
|
this.clock.close();
|
|
@@ -34329,6 +35515,83 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34329
35515
|
await this.shutdown();
|
|
34330
35516
|
await clearProfileStores();
|
|
34331
35517
|
}
|
|
35518
|
+
appShelf;
|
|
35519
|
+
/** The installed apps and stores, refused while the apps feature is off. */
|
|
35520
|
+
get appStore() {
|
|
35521
|
+
if (!this.appsOn) throw new Error("Apps are unavailable in this release");
|
|
35522
|
+
return this.appShelf ??= new Apps({
|
|
35523
|
+
fetch: boundedAppFetch({
|
|
35524
|
+
fetcher: this.options.appFetch,
|
|
35525
|
+
online: () => this.networkOn
|
|
35526
|
+
}),
|
|
35527
|
+
isChat: (scope) => {
|
|
35528
|
+
const stored = this.links.get(scope)?.stored;
|
|
35529
|
+
return !!stored && !stored.group && !!stored.profile && !!stored.pairedPeerKey;
|
|
35530
|
+
},
|
|
35531
|
+
online: () => this.networkOn && !this.shuttingDown,
|
|
35532
|
+
checked: () => this.events.onAppsChecked?.()
|
|
35533
|
+
});
|
|
35534
|
+
}
|
|
35535
|
+
appList() {
|
|
35536
|
+
return this.appStore.list();
|
|
35537
|
+
}
|
|
35538
|
+
appStoreList() {
|
|
35539
|
+
return this.appStore.listStores();
|
|
35540
|
+
}
|
|
35541
|
+
appStorePreview(params) {
|
|
35542
|
+
return this.appStore.previewStore(params);
|
|
35543
|
+
}
|
|
35544
|
+
appStoreAdd(params) {
|
|
35545
|
+
return this.appStore.addStore(params);
|
|
35546
|
+
}
|
|
35547
|
+
appStoreRemove(params) {
|
|
35548
|
+
return this.appStore.removeStore(params);
|
|
35549
|
+
}
|
|
35550
|
+
appStoreRefresh(params = {}) {
|
|
35551
|
+
return this.appStore.refreshStores(params);
|
|
35552
|
+
}
|
|
35553
|
+
appPreview(params) {
|
|
35554
|
+
return this.appStore.preview(params);
|
|
35555
|
+
}
|
|
35556
|
+
appInstall(params) {
|
|
35557
|
+
return this.appStore.install(params);
|
|
35558
|
+
}
|
|
35559
|
+
appUpdateAccept(params) {
|
|
35560
|
+
return this.appStore.acceptUpdate(params);
|
|
35561
|
+
}
|
|
35562
|
+
appCheckUpdates() {
|
|
35563
|
+
return this.appStore.checkUpdates();
|
|
35564
|
+
}
|
|
35565
|
+
appUninstall(params) {
|
|
35566
|
+
return this.appStore.uninstall(params);
|
|
35567
|
+
}
|
|
35568
|
+
appRunCheck(params) {
|
|
35569
|
+
return this.appStore.runCheck(params);
|
|
35570
|
+
}
|
|
35571
|
+
appFetchFiles(params) {
|
|
35572
|
+
return this.appStore.fetchFiles(params);
|
|
35573
|
+
}
|
|
35574
|
+
appEntry(params) {
|
|
35575
|
+
return this.appStore.entry(params);
|
|
35576
|
+
}
|
|
35577
|
+
appFile(params) {
|
|
35578
|
+
return this.appStore.file(params);
|
|
35579
|
+
}
|
|
35580
|
+
appStorageGet(params) {
|
|
35581
|
+
return this.appStore.storageGet(params);
|
|
35582
|
+
}
|
|
35583
|
+
appStorageSet(params) {
|
|
35584
|
+
return this.appStore.storageSet(params);
|
|
35585
|
+
}
|
|
35586
|
+
appStorageDelete(params) {
|
|
35587
|
+
return this.appStore.storageDelete(params);
|
|
35588
|
+
}
|
|
35589
|
+
appStorageKeys(params) {
|
|
35590
|
+
return this.appStore.storageKeys(params);
|
|
35591
|
+
}
|
|
35592
|
+
appDataExport(params) {
|
|
35593
|
+
return this.appStore.exportData(params);
|
|
35594
|
+
}
|
|
34332
35595
|
getState() {
|
|
34333
35596
|
const groups = this.groups.views();
|
|
34334
35597
|
return {
|
|
@@ -34431,7 +35694,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34431
35694
|
async statusCardIndex() {
|
|
34432
35695
|
const rows = [];
|
|
34433
35696
|
for (const m of await db.getCardMessages()) {
|
|
34434
|
-
if (m.card?.kind !== "task" && m.card?.kind !== "routine") continue;
|
|
35697
|
+
if (m.card?.kind !== "task" && m.card?.kind !== "routine" && m.card?.kind !== "usage") continue;
|
|
34435
35698
|
rows.push({
|
|
34436
35699
|
linkId: m.linkId,
|
|
34437
35700
|
id: m.id,
|
|
@@ -35348,7 +36611,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35348
36611
|
delivery: "sending",
|
|
35349
36612
|
...preview && { preview },
|
|
35350
36613
|
...answers,
|
|
35351
|
-
...card
|
|
36614
|
+
...card && { cardRestore: "due" }
|
|
35352
36615
|
});
|
|
35353
36616
|
this.hold.hold(linkId, {
|
|
35354
36617
|
kind: "text",
|
|
@@ -35560,34 +36823,49 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35560
36823
|
return !!live?.caps?.peer?.capabilities.includes(EDIT_CAPABILITY);
|
|
35561
36824
|
}
|
|
35562
36825
|
/**
|
|
35563
|
-
* A
|
|
35564
|
-
* Buttons):
|
|
36826
|
+
* A card of mine went on the DHT floor or into a hold, which carry its text alone (WISP 405 · Status Cards, WISP 406 ·
|
|
36827
|
+
* Message Buttons): the card is due to go again live. Once: a row that had it restored already stays so.
|
|
35565
36828
|
*/
|
|
35566
|
-
async
|
|
35567
|
-
if (message.card
|
|
35568
|
-
await db.patchMessage(linkId, message.id, (current) => current.sender === "me" && current.card
|
|
36829
|
+
async cardWentBare(linkId, message) {
|
|
36830
|
+
if (!message.card || message.cardRestore || message.buttonsRestore) return;
|
|
36831
|
+
await db.patchMessage(linkId, message.id, (current) => current.sender === "me" && current.card && !current.cardRestore && !current.buttonsRestore ? { cardRestore: "due" } : null);
|
|
36832
|
+
}
|
|
36833
|
+
/** The session capability a contact's app lists when it shows a card of this kind, so a restore edit reads as a card there. */
|
|
36834
|
+
static cardCapability(card) {
|
|
36835
|
+
return card.kind === "buttons" ? BUTTONS_CAPABILITY : card.kind === "app" ? APPS_CAPABILITY : STATUS_CARD_CAPABILITY;
|
|
35569
36836
|
}
|
|
35570
36837
|
/**
|
|
35571
|
-
*
|
|
35572
|
-
* hold reached the contact as text alone, and a copy under the same id that comes live later
|
|
35573
|
-
* already there.
|
|
35574
|
-
*
|
|
35575
|
-
* message
|
|
35576
|
-
*
|
|
35577
|
-
*
|
|
35578
|
-
*
|
|
36838
|
+
* Cards the contact may not have (WISP 405 · Status Cards, WISP 406 · Message Buttons): a message whose text went on
|
|
36839
|
+
* the DHT floor or into a hold reached the contact as text alone, and a copy under the same id that comes live later
|
|
36840
|
+
* is taken as the one already there. An app card or a finished task gets no later update to carry its card. So once
|
|
36841
|
+
* the chat is live with an app that shows the kind (`buttons/1`, `status-card/1`, `apps/1`) and takes edits, each such
|
|
36842
|
+
* message goes again as an edit of its card alone: the same text, so no version and no edit mark. Once per message (the
|
|
36843
|
+
* row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later edit of the
|
|
36844
|
+
* author's replaces it, the highest number winning). A contact whose app does not show the kind (it would drop the card
|
|
36845
|
+
* and mark the text edited) gets none: the row waits for one that does. Bounded: the newest `CARD_RESTORE_MOST` such
|
|
36846
|
+
* messages of the last `CARD_RESTORE_MS`; older ones are given up. The edit is marked `restore`: the bot's own event
|
|
36847
|
+
* stream says nothing of it, while its number counts, so a later edit of the bot's takes the next one.
|
|
35579
36848
|
*/
|
|
35580
|
-
async
|
|
36849
|
+
async restoreCards(linkId) {
|
|
35581
36850
|
const live = this.links.get(linkId), link = live?.link;
|
|
35582
|
-
|
|
35583
|
-
|
|
36851
|
+
const offers = link?.sessionOffers.peer;
|
|
36852
|
+
if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !offers) return;
|
|
36853
|
+
const due = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && (m.cardRestore ?? m.buttonsRestore) === "due").sort((a, b) => b.timestamp - a.timestamp);
|
|
36854
|
+
const since = Date.now() - CARD_RESTORE_MS;
|
|
35584
36855
|
const changed = [];
|
|
35585
|
-
for (const message of due) {
|
|
36856
|
+
for (const [index, message] of due.entries()) {
|
|
36857
|
+
const stale = index >= CARD_RESTORE_MOST || message.timestamp < since;
|
|
36858
|
+
if (!stale && message.card && !offers.includes(GhostlyNode.cardCapability(message.card))) continue;
|
|
35586
36859
|
const at = Date.now();
|
|
35587
36860
|
if ((await db.patchMessage(linkId, message.id, (current) => {
|
|
35588
|
-
if (current.sender !== "me" || current.buttonsRestore !== "due") return null;
|
|
36861
|
+
if (current.sender !== "me" || (current.cardRestore ?? current.buttonsRestore) !== "due") return null;
|
|
36862
|
+
const done = {
|
|
36863
|
+
cardRestore: "sent",
|
|
36864
|
+
buttonsRestore: void 0
|
|
36865
|
+
};
|
|
35589
36866
|
const seq = (current.edit?.seq ?? 0) + 1;
|
|
35590
|
-
if (current.card
|
|
36867
|
+
if (stale || !current.card || !current.wireId || seq > STATUS_CARD_LIMITS.edits) return done;
|
|
36868
|
+
if (current.edit?.pending) return done;
|
|
35591
36869
|
return {
|
|
35592
36870
|
edit: {
|
|
35593
36871
|
...withEdit(current, {
|
|
@@ -35600,7 +36878,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35600
36878
|
}).edit,
|
|
35601
36879
|
restore: true
|
|
35602
36880
|
},
|
|
35603
|
-
|
|
36881
|
+
...done
|
|
35604
36882
|
};
|
|
35605
36883
|
}))?.edit?.pending) changed.push(message.id);
|
|
35606
36884
|
}
|
|
@@ -35706,7 +36984,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35706
36984
|
const reply = GhostlyNode.wireReply(message);
|
|
35707
36985
|
const error = message.card ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded, message.card) : message.forwarded ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded) : reply ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply) : await link.sendMessage(message.text, message.timestamp, message.wireId, ...message.preview ? [message.preview] : []);
|
|
35708
36986
|
await this.noteTextSend(linkId, message, snapshot, at, error, link);
|
|
35709
|
-
if (!error && message.via === "pkarr") await this.
|
|
36987
|
+
if (!error && message.via === "pkarr") await this.cardWentBare(linkId, message);
|
|
35710
36988
|
return error;
|
|
35711
36989
|
}, (message) => message.via === "pkarr" ? DHT_MESSAGE_TTL : 2e4, (message) => {
|
|
35712
36990
|
const pending = this.links.get(linkId)?.stored.dhtDeliveryState?.pending;
|
|
@@ -35731,7 +37009,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35731
37009
|
via: "hold",
|
|
35732
37010
|
resendUntil: void 0
|
|
35733
37011
|
});
|
|
35734
|
-
await this.
|
|
37012
|
+
await this.cardWentBare(linkId, message);
|
|
35735
37013
|
await this.messagesChanged(linkId, [message.id]);
|
|
35736
37014
|
this.hold.hold(linkId, {
|
|
35737
37015
|
kind: "text",
|
|
@@ -36164,6 +37442,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36164
37442
|
return offered;
|
|
36165
37443
|
}
|
|
36166
37444
|
fileAction({ linkId, fileId, action }) {
|
|
37445
|
+
if (typeof linkId === "string" && linkId.startsWith("group:")) {
|
|
37446
|
+
if (action !== "accept" && action !== "request") throw new Error("A group's file is downloaded (accept) or asked for again (request)");
|
|
37447
|
+
if (!fileId.startsWith(`group-${linkId.slice(6)}-`)) throw new Error("No such file in this group");
|
|
37448
|
+
return this.groups.downloadFile(fileId);
|
|
37449
|
+
}
|
|
36167
37450
|
const live = this.links.get(linkId);
|
|
36168
37451
|
if (!live) throw new Error("No such chat");
|
|
36169
37452
|
if (![
|
|
@@ -36772,6 +38055,58 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36772
38055
|
if (!sent.error) this.wakeMentioned(groupId, text, named);
|
|
36773
38056
|
return sent;
|
|
36774
38057
|
}
|
|
38058
|
+
/**
|
|
38059
|
+
* Announces a file or voice message in a group (WISP 503 · Group Files): its bytes are in the files store under
|
|
38060
|
+
* `file.id` (`group-<group>-out-…`), put there by the caller as a chat's file is (`sendFile`). The message carries its
|
|
38061
|
+
* description and the SHA-256 of those bytes; its text is `caption`, or the line apps from before show. This device
|
|
38062
|
+
* is its first holder: members fetch it from here, then from each other.
|
|
38063
|
+
*/
|
|
38064
|
+
async sendGroupFile({ groupId, file, caption, replyTo, forwarded: hops }) {
|
|
38065
|
+
if (typeof groupId !== "string" || !file || typeof file.id !== "string" || !file.id.startsWith(`group-${groupId}-out-`)) return {
|
|
38066
|
+
error: "Invalid file id",
|
|
38067
|
+
refused: true
|
|
38068
|
+
};
|
|
38069
|
+
if (file.size > GROUP_FILE_LIMITS.maxBytes) return {
|
|
38070
|
+
error: `A group takes files of up to ${formatFileSize(GROUP_FILE_LIMITS.maxBytes)}`,
|
|
38071
|
+
refused: true
|
|
38072
|
+
};
|
|
38073
|
+
const stored = await fileStore.get(file.id);
|
|
38074
|
+
if (!stored?.metadata || stored.linkId !== `group:${groupId}` || stored.direction !== "out") return {
|
|
38075
|
+
error: "The file is gone",
|
|
38076
|
+
refused: true
|
|
38077
|
+
};
|
|
38078
|
+
if (storedSize(stored) !== file.size) return {
|
|
38079
|
+
error: "The file is not all here",
|
|
38080
|
+
refused: true
|
|
38081
|
+
};
|
|
38082
|
+
const digest = stored.digest ?? (stored.blob ? await blobDigest(stored.blob) : await (await fileBytesOf(stored.bytes)).digest(file.id));
|
|
38083
|
+
if (!stored.digest) await fileStore.patch(file.id, { digest });
|
|
38084
|
+
const meta = readGroupFileMeta({
|
|
38085
|
+
name: file.name,
|
|
38086
|
+
mime: file.mime,
|
|
38087
|
+
size: file.size,
|
|
38088
|
+
d: digest,
|
|
38089
|
+
...file.voice && { voice: file.voice },
|
|
38090
|
+
...file.video && { video: file.video },
|
|
38091
|
+
...file.image && { image: file.image }
|
|
38092
|
+
});
|
|
38093
|
+
if (!meta) return {
|
|
38094
|
+
error: "That file cannot go to a group",
|
|
38095
|
+
refused: true
|
|
38096
|
+
};
|
|
38097
|
+
if (file.voice && !meta.voice) return {
|
|
38098
|
+
error: "That recording cannot be sent as a voice message",
|
|
38099
|
+
refused: true
|
|
38100
|
+
};
|
|
38101
|
+
const reply = replyTo === void 0 ? void 0 : await this.replyFor(`group:${groupId}`, replyTo);
|
|
38102
|
+
if (typeof reply === "string") return { error: reply };
|
|
38103
|
+
const text = typeof caption === "string" && caption.trim() ? caption : groupFileFallback(meta);
|
|
38104
|
+
return this.groups.sendFile(groupId, text, meta, file.id, reply && {
|
|
38105
|
+
i: reply.id,
|
|
38106
|
+
s: reply.snippet,
|
|
38107
|
+
f: reply.member
|
|
38108
|
+
}, readForwarded(hops));
|
|
38109
|
+
}
|
|
36775
38110
|
groupMessages({ groupId }) {
|
|
36776
38111
|
return this.groups.messages(groupId);
|
|
36777
38112
|
}
|
|
@@ -36886,7 +38221,30 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36886
38221
|
continue;
|
|
36887
38222
|
}
|
|
36888
38223
|
if (groupId) {
|
|
36889
|
-
|
|
38224
|
+
const original = message.file, file = {
|
|
38225
|
+
id: groupFileId(groupId, "out"),
|
|
38226
|
+
name: original.name,
|
|
38227
|
+
size: original.size,
|
|
38228
|
+
mime: original.mime,
|
|
38229
|
+
...original.voice && { voice: original.voice },
|
|
38230
|
+
...original.video && { video: original.video },
|
|
38231
|
+
...original.image && { image: original.image }
|
|
38232
|
+
};
|
|
38233
|
+
await copyForForward(original.id, {
|
|
38234
|
+
linkId: to,
|
|
38235
|
+
wireId: file.id.slice(`group-${groupId}-out-`.length),
|
|
38236
|
+
timestamp: now(),
|
|
38237
|
+
file
|
|
38238
|
+
});
|
|
38239
|
+
const sent = await this.sendGroupFile({
|
|
38240
|
+
groupId,
|
|
38241
|
+
file,
|
|
38242
|
+
forwarded: hops
|
|
38243
|
+
});
|
|
38244
|
+
if (sent.error) {
|
|
38245
|
+
await removeStored(file.id).catch(() => {});
|
|
38246
|
+
note(sent.error);
|
|
38247
|
+
} else if (sent.messageId) result.messageIds.push(sent.messageId);
|
|
36890
38248
|
continue;
|
|
36891
38249
|
}
|
|
36892
38250
|
if (!live.link) {
|
|
@@ -37113,6 +38471,58 @@ var GhostlyNode = class GhostlyNode {
|
|
|
37113
38471
|
this.flushPin(linkId);
|
|
37114
38472
|
return { error: null };
|
|
37115
38473
|
}
|
|
38474
|
+
/** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
|
|
38475
|
+
get appsOn() {
|
|
38476
|
+
return this.options.apps ?? false;
|
|
38477
|
+
}
|
|
38478
|
+
/** 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). */
|
|
38479
|
+
appChat(linkId, ref) {
|
|
38480
|
+
if (!this.appsOn) throw new Error("Apps are unavailable in this release");
|
|
38481
|
+
const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
|
|
38482
|
+
if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
|
|
38483
|
+
if (!isAppRef(ref)) throw new Error("Not an app reference");
|
|
38484
|
+
const { participationSeed, pairedPeerKey } = live.stored;
|
|
38485
|
+
if (!participationSeed || !pairedPeerKey) throw new Error("This chat is not paired yet");
|
|
38486
|
+
return {
|
|
38487
|
+
live,
|
|
38488
|
+
app: chatAppId(identityFromSeedB64(participationSeed).publicKey, publicKeyFromZ32(pairedPeerKey), ref)
|
|
38489
|
+
};
|
|
38490
|
+
}
|
|
38491
|
+
/** The chat app id of an app in a paired chat: how the contact's frames name it. */
|
|
38492
|
+
appId({ linkId, ref }) {
|
|
38493
|
+
return { app: this.appChat(linkId, ref).app };
|
|
38494
|
+
}
|
|
38495
|
+
/**
|
|
38496
|
+
* This side opened an app in a paired chat, or updated it: `open` goes to the contact now when the session is live,
|
|
38497
|
+
* and again on every session that comes back while it stays open.
|
|
38498
|
+
*/
|
|
38499
|
+
async appOpen({ linkId, ref, version }) {
|
|
38500
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38501
|
+
if (!isAppVersion(version)) throw new Error("Not an app version");
|
|
38502
|
+
await this.appStore.chatRunnable({ ref });
|
|
38503
|
+
const open = live.appsOpen ??= /* @__PURE__ */ new Map();
|
|
38504
|
+
if (live.link) live.link.openApp(app, version);
|
|
38505
|
+
else open.set(app, version);
|
|
38506
|
+
return { app };
|
|
38507
|
+
}
|
|
38508
|
+
/** This side closed an app in a paired chat: `close` goes now when the session is live. */
|
|
38509
|
+
appClose({ linkId, ref }) {
|
|
38510
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38511
|
+
if (live.link) live.link.closeApp(app);
|
|
38512
|
+
else live.appsOpen?.delete(app);
|
|
38513
|
+
}
|
|
38514
|
+
/** One data frame of an app open on both sides, on the live session only; nothing is kept to send later. */
|
|
38515
|
+
async appSend({ linkId, ref, data }) {
|
|
38516
|
+
const { live, app } = this.appChat(linkId, ref);
|
|
38517
|
+
if (!live.appsOpen?.has(app)) return { error: "not-open" };
|
|
38518
|
+
await this.appStore.chatRunnable({ ref });
|
|
38519
|
+
return { error: live.link ? live.link.sendAppData(app, data) : "offline" };
|
|
38520
|
+
}
|
|
38521
|
+
/** Every page went away: no app runs here any more, so each open one is closed (`close` goes where it can). */
|
|
38522
|
+
appsCloseAll() {
|
|
38523
|
+
for (const live of this.links.values()) for (const app of [...live.appsOpen?.keys() ?? []]) if (live.link) live.link.closeApp(app);
|
|
38524
|
+
else live.appsOpen?.delete(app);
|
|
38525
|
+
}
|
|
37116
38526
|
/** Keeps a pin of a 1:1 chat when it is newer than the chat's; `out`: mine, to say until the contact confirms it. */
|
|
37117
38527
|
async keepLinkPin(linkId, pin, out) {
|
|
37118
38528
|
const live = this.links.get(linkId);
|
|
@@ -38268,7 +39678,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38268
39678
|
...settings
|
|
38269
39679
|
};
|
|
38270
39680
|
if (!this.settings.irohRelays?.length) delete this.settings.irohRelays;
|
|
38271
|
-
if (irohRelaysChanged) this.rehomeIroh();
|
|
39681
|
+
if (irohRelaysChanged && !this.options.irohRelays?.length) this.rehomeIroh();
|
|
38272
39682
|
if (settings.relays) {
|
|
38273
39683
|
this.relays?.setRelays(settings.relays);
|
|
38274
39684
|
if (this.relays) this.settings.relays = this.relays.describe().relays;
|
|
@@ -38296,6 +39706,12 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38296
39706
|
this.groups.stopTyping();
|
|
38297
39707
|
}
|
|
38298
39708
|
}
|
|
39709
|
+
for (const key of ["autoDownloads", "serveFiles"]) {
|
|
39710
|
+
if (settings[key] === void 0) continue;
|
|
39711
|
+
if (settings[key] !== false) delete this.settings[key];
|
|
39712
|
+
else this.settings[key] = false;
|
|
39713
|
+
await db.putSettings(this.settings);
|
|
39714
|
+
}
|
|
38299
39715
|
if (settings.publicProfiles !== void 0) {
|
|
38300
39716
|
if (settings.publicProfiles !== false) delete this.settings.publicProfiles;
|
|
38301
39717
|
else this.settings.publicProfiles = false;
|
|
@@ -39303,6 +40719,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39303
40719
|
arkPaymentsSupport: !entry,
|
|
39304
40720
|
usdtPaymentsSupport: !entry,
|
|
39305
40721
|
barkPaymentsSupport: !entry,
|
|
40722
|
+
largeFilesSupport: !entry,
|
|
39306
40723
|
params: stored,
|
|
39307
40724
|
rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
|
|
39308
40725
|
resume: !entry && stored.edgeLive ? resumeOn : void 0,
|
|
@@ -39379,7 +40796,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39379
40796
|
onPaymentAsk: (ask) => member() ? this.desk.onPaymentAsk(linkId, ask) : void 0,
|
|
39380
40797
|
onPayment: (payment) => member() ? this.desk.onPayment(linkId, payment) : void 0,
|
|
39381
40798
|
onPaymentResult: (result) => member() ? this.desk.onPaymentResult(linkId, result) : void 0,
|
|
39382
|
-
onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0)
|
|
40799
|
+
onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0),
|
|
40800
|
+
onFilesFrame: (frame) => member() ? this.groups.filesFrame(group, peer, linkId, frame) : void 0,
|
|
40801
|
+
onFilesSession: (open) => this.groups.fileSession(group, peer, linkId, open)
|
|
39383
40802
|
},
|
|
39384
40803
|
onPresence: (presence) => {
|
|
39385
40804
|
if (presence.online && !seen) {
|
|
@@ -39491,6 +40910,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39491
40910
|
wakeSupport: true,
|
|
39492
40911
|
upgradeSupport: true,
|
|
39493
40912
|
peerUpgrades: () => !!live.caps?.peer?.capabilities.includes(UPGRADE_CAPABILITY),
|
|
40913
|
+
appsSupport: this.appsOn,
|
|
40914
|
+
appsOpen: this.appsOn ? live.appsOpen ??= /* @__PURE__ */ new Map() : void 0,
|
|
39494
40915
|
servicesSupport: this.options.servicesSupport ?? (this.options.platform ?? "web") !== "web",
|
|
39495
40916
|
dht: stored.profile ? {
|
|
39496
40917
|
state: stored.dhtDeliveryState,
|
|
@@ -39679,7 +41100,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39679
41100
|
onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
|
|
39680
41101
|
onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
|
|
39681
41102
|
onEditSupport: (supported) => {
|
|
39682
|
-
if (supported && stored.profile && !stored.group) this.
|
|
41103
|
+
if (supported && stored.profile && !stored.group) this.restoreCards(linkId).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
|
|
39683
41104
|
},
|
|
39684
41105
|
onWakeSupport: (supported) => {
|
|
39685
41106
|
if (supported && stored.profile && !stored.group) this.shareWake(linkId);
|
|
@@ -39796,6 +41217,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39796
41217
|
onPinSupport: (supported) => {
|
|
39797
41218
|
if (supported) this.flushPin(linkId);
|
|
39798
41219
|
},
|
|
41220
|
+
onAppFrame: this.appsOn ? (event) => this.events.onAppFrame?.(linkId, event) : void 0,
|
|
39799
41221
|
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
39800
41222
|
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
39801
41223
|
onPayment: (payment) => this.desk.onPayment(linkId, payment),
|
|
@@ -39946,7 +41368,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39946
41368
|
get nativeFactories() {
|
|
39947
41369
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
39948
41370
|
return {
|
|
39949
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
41371
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-CnEkFl8R.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
39950
41372
|
...this.options.nativeTransports,
|
|
39951
41373
|
...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
|
|
39952
41374
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
@@ -39957,6 +41379,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39957
41379
|
return !!this.options.nativeIrohRelays && !!this.options.nativeTransports?.["iroh/1"];
|
|
39958
41380
|
}
|
|
39959
41381
|
get irohRelays() {
|
|
41382
|
+
if (this.options.irohRelays?.length) return [...this.options.irohRelays];
|
|
39960
41383
|
return this.settings.irohRelays?.length ? this.settings.irohRelays : [...DEFAULT_IROH_RELAYS];
|
|
39961
41384
|
}
|
|
39962
41385
|
/** New Iroh relays: idle endpoints move now; one carrying a chat keeps its relay until that session ends. */
|
|
@@ -40213,12 +41636,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40213
41636
|
...whole.replyTo && { replyTo: whole.replyTo },
|
|
40214
41637
|
...whole.forwarded && { forwarded: whole.forwarded },
|
|
40215
41638
|
...whole.card && { card: whole.card },
|
|
40216
|
-
...whole.press && { press: whole.press }
|
|
41639
|
+
...whole.press && { press: whole.press },
|
|
41640
|
+
...whole.file && {
|
|
41641
|
+
file: whole.file,
|
|
41642
|
+
text: whole.text
|
|
41643
|
+
}
|
|
40217
41644
|
};
|
|
40218
41645
|
if (!Object.keys(added).length) return;
|
|
40219
41646
|
if (!await db.patchMessage(message.linkId, message.id, (stored) => {
|
|
40220
41647
|
if (stored.member !== message.member || stored.sender !== message.sender) return null;
|
|
40221
|
-
const { card: _card, ...rest } = added;
|
|
41648
|
+
const { card: _card, text: _text, ...rest } = added;
|
|
40222
41649
|
return stored.edit ? rest : added;
|
|
40223
41650
|
})) return;
|
|
40224
41651
|
await this.messagesChanged(message.linkId, [message.id]);
|
|
@@ -40524,6 +41951,12 @@ var EngineServer = class {
|
|
|
40524
41951
|
signal
|
|
40525
41952
|
});
|
|
40526
41953
|
},
|
|
41954
|
+
onAppFrame: (linkId, event) => this.broadcast({
|
|
41955
|
+
kind: "app-frame",
|
|
41956
|
+
linkId,
|
|
41957
|
+
event
|
|
41958
|
+
}),
|
|
41959
|
+
onAppsChecked: () => this.broadcast({ kind: "apps-checked" }),
|
|
40527
41960
|
onDeviceGate: (gate) => this.broadcast({
|
|
40528
41961
|
kind: "device-gate",
|
|
40529
41962
|
gate
|
|
@@ -40571,7 +42004,10 @@ var EngineServer = class {
|
|
|
40571
42004
|
detach(client) {
|
|
40572
42005
|
this.clients.delete(client);
|
|
40573
42006
|
this.histories.delete(client);
|
|
40574
|
-
if (this.clients.size === 0)
|
|
42007
|
+
if (this.clients.size === 0) {
|
|
42008
|
+
this.node.setActiveLink({ linkId: null });
|
|
42009
|
+
this.node.appsCloseAll();
|
|
42010
|
+
}
|
|
40575
42011
|
if (this.onCall.delete(client)) this.node.setCallOn({ on: this.onCall.size > 0 });
|
|
40576
42012
|
}
|
|
40577
42013
|
async handle(client, request) {
|
|
@@ -40679,4 +42115,4 @@ function signalKind(signal) {
|
|
|
40679
42115
|
//#endregion
|
|
40680
42116
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
40681
42117
|
|
|
40682
|
-
//# sourceMappingURL=server-
|
|
42118
|
+
//# sourceMappingURL=server-HMzdsyn_.js.map
|