@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
package/dist/ghostly.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { $ as
|
|
4
|
-
import {
|
|
2
|
+
import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
|
|
3
|
+
import { $ as parseSshPublicKey, $a as signAppRevocation, $n as GROUP_FILE_LIMITS, Aa as replySnippet, An as nip05RootKey, At as didWebDocumentUrl, Bc as sanitizeAvatar, Bn as engineText, Bt as staticDidDocument, Cc as setLinkTraceSink, Cn as DOMAIN_FILE_MAX_BYTES, Co as callRtcConfig, Dn as domainWellKnownFile, Do as signalHasVideo, Dt as didGhostlyServiceEntry, En as domainTxtRecord, Eo as parseCallSignal, Et as checkDidSignature, Fa as buttonLabelClash, Fn as txtValue, Ft as normalizeDid, Ga as APP_STATEMENT_LIMITS, Gc as toZ32, Gi as irohRelayUrl, Hc as fromBase64Url$1, Hr as TYPING_REFRESH_MS, Ia as checkStatusCard, In as wellKnownUrl, It as parseDid, Ja as checkAppRevokeStatement, Ka as appFingerprint, Kc as utf8Decode, Ks as inviteLink, Kt as resolveDidDht, Ln as requestLnurlInvoice, Lr as ChatFiles, Lt as parseDidDocument, Ma as ITEM_STATES, Mc as openRelayPayload, Mn as recordsFromTxt, Mt as didWebFileNames, Na as RUN_RESULTS, Nn as recordsFromWellKnown, Nt as didWebFileUrl, On as encodeDnsQuery, Ot as didJwsAlg, Pa as STATUS_CARD_LIMITS, Pi as identityStatement, Pn as recordsName, Pt as didWebHost, Q as SshSigError, Qa as signAppObject, Qi as readImageMeta, Rn as resolveLightningDestination, Rt as parseDidSignaturePaste, Sn as pubkyHomeserverOf, So as buildSdpFromSignal, St as DID_FILE_MAX_BYTES, Tn as dnsProofName, To as extractParamsFromSdp, Tt as checkDidJws, Uc as randomBytes$1, Vn as decodeBolt11, Vr as TYPING_KINDS, Vt as strictBase64Url, Wa as APP_PREFIXES, Wc as toBase64Url$1, Wi as irohDescriptor, Wr as sanitizeTypingStatus, Y as groupName, Z as SSHSIG_NAMESPACE, Za as readAppRevocations, _n as normalizePubkyKey, _t as parseAtprotoDidDocument, a as checkAppStoreIndex, aa as baseMime, ar as isGroupMessageId, at as BitcoinAddressError, bn as pubkyProofPath, bo as RTC_CONFIG, bt as verifyAtprotoRecordProof, c as APP_BUNDLE_LIMITS, ca as voicePeaksOf, cr as readGroupWant, ct as ATPROTO_FULL_SCOPE, di as BUTTONS_CAPABILITY, dt as atprotoDidMethod, eo as canonicalJson, er as GROUP_HAVE_FRAME, et as parseSshSignature, f as assembleAppBundle, fa as RateWindow, fo as GhostlyHttpError, ft as atprotoProofRecord, gn as newPubkyProofFolder, go as LIMITS, gt as normalizeAtprotoHandle, h as readAppBundle, hn as isPubkyProofFolder, ht as isAtprotoDid, ia as VOICE_LIMITS, ir as groupFileFallback, it as verifyBitcoinMessage, ji as boundVerifiedIdentity, jn as normalizeDomain, jt as didWebFile, kc as RELAY_PAYLOAD_MAX_BYTES, ki as IdentityCheckUnavailable, kn as isPublicAddress, kt as didSignedMessages, lr as readGroupWantNo, lt as ATPROTO_PROOF_COLLECTION, m as isAppUrl, mn as isPubkyKey, mt as checkAtprotoProofRecord, na as sanitizeMime, nn as WALLET_NETWORKS, nr as GROUP_WANT_NO_FRAME, nt as sshSignCommand, o as readAppStore, oa as fileMessageText, oi as checkPushEndpoint, ot as decodeAnyBitcoinAddress, p as checkAppManifest, pn as PUBKY_PROOF_MAX_BYTES, pt as atprotoProofRkey, qa as appRef, qc as utf8Encode, ra as PLAYABLE_AUDIO, rc as seedSigner, rr as fetchesByItself, rt as verifySshSignature, s as signAppStore, sa as parseVoiceMeta, sn as walletNetworkOf, sr as readGroupHave, st as ATPROTO_DECLARED_SCOPE, t as appStoreDecision, ta as sanitizeFileName, to as canonicalJsonBytes, tt as sameSshKey, ut as ATPROTO_PROOF_SCOPE, vn as pubkyProofCapability, vt as shortAtprotoDid, wn as decodeDnsResponse, wo as callSignalHeardAt, wt as DidError, xn as homeserverWebEndpoint, xo as answersOffer, xt as DID_DOCUMENT_MAX_BYTES$1, yn as pubkyProofFile, yo as CALL_SIGNAL_MAX_AGE_MS, yt as AtprotoProofError, zc as MAX_AVATAR_LENGTH, zn as engineError, zr as transferEnded, zt as signingKeys } from "./assets/src-DtxVo2Rm.js";
|
|
4
|
+
import { c as fileStore, d as store$1, f as transact, i as STORES, l as openDb, o as databaseName, p as wrap } from "./assets/idb-BIdus0WX.js";
|
|
5
|
+
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-3AQ2tJzW.js";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { spawn } from "node:child_process";
|
|
7
8
|
import { connect, createServer, isIP } from "node:net";
|
|
@@ -17,7 +18,7 @@ import { decode, npubEncode } from "nostr-tools/nip19";
|
|
|
17
18
|
import { BunkerSigner } from "nostr-tools/nip46";
|
|
18
19
|
import { SimplePool } from "nostr-tools/pool";
|
|
19
20
|
import { generateSecretKey } from "nostr-tools/pure";
|
|
20
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
21
22
|
import { createServer as createServer$1 } from "node:http";
|
|
22
23
|
import { Readable } from "node:stream";
|
|
23
24
|
import { pipeline } from "node:stream/promises";
|
|
@@ -34,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
|
|
|
34
35
|
import { format } from "node:util";
|
|
35
36
|
var package_default = {
|
|
36
37
|
name: "@ghostlytools/cli",
|
|
37
|
-
version: "1.1.
|
|
38
|
+
version: "1.1.6",
|
|
38
39
|
type: "module",
|
|
39
40
|
description: "Ghostly for bots and terminals: the app's own engine, headless on Node, behind a daemon and a `ghostly` command with JSON output.",
|
|
40
41
|
license: "MIT",
|
|
@@ -595,6 +596,10 @@ var ENGINE_METHODS = [
|
|
|
595
596
|
"retryMessage",
|
|
596
597
|
"react",
|
|
597
598
|
"pinMessage",
|
|
599
|
+
"appId",
|
|
600
|
+
"appOpen",
|
|
601
|
+
"appClose",
|
|
602
|
+
"appSend",
|
|
598
603
|
"messageDetails",
|
|
599
604
|
"deleteMessage",
|
|
600
605
|
"exportLinks",
|
|
@@ -669,6 +674,7 @@ var ENGINE_METHODS = [
|
|
|
669
674
|
"disableGroupLink",
|
|
670
675
|
"joinGroupByLink",
|
|
671
676
|
"sendGroupMessage",
|
|
677
|
+
"sendGroupFile",
|
|
672
678
|
"pressButton",
|
|
673
679
|
"groupMessages",
|
|
674
680
|
"messagePage",
|
|
@@ -713,7 +719,27 @@ var ENGINE_METHODS = [
|
|
|
713
719
|
"deviceSetNoticeSeen",
|
|
714
720
|
"deviceTurnCheck",
|
|
715
721
|
"devicePushState",
|
|
716
|
-
"devicePushSet"
|
|
722
|
+
"devicePushSet",
|
|
723
|
+
"appList",
|
|
724
|
+
"appStoreList",
|
|
725
|
+
"appStorePreview",
|
|
726
|
+
"appStoreAdd",
|
|
727
|
+
"appStoreRemove",
|
|
728
|
+
"appStoreRefresh",
|
|
729
|
+
"appPreview",
|
|
730
|
+
"appInstall",
|
|
731
|
+
"appUpdateAccept",
|
|
732
|
+
"appCheckUpdates",
|
|
733
|
+
"appUninstall",
|
|
734
|
+
"appRunCheck",
|
|
735
|
+
"appFetchFiles",
|
|
736
|
+
"appEntry",
|
|
737
|
+
"appFile",
|
|
738
|
+
"appStorageGet",
|
|
739
|
+
"appStorageSet",
|
|
740
|
+
"appStorageDelete",
|
|
741
|
+
"appStorageKeys",
|
|
742
|
+
"appDataExport"
|
|
717
743
|
];
|
|
718
744
|
/** Reads the hosts use outside `EngineApi`: the whole state, and one chat's stored messages. */
|
|
719
745
|
var ENGINE_READS = ["getState", "getMessages"];
|
|
@@ -1015,6 +1041,707 @@ function groupReply(reply, myKey) {
|
|
|
1015
1041
|
};
|
|
1016
1042
|
}
|
|
1017
1043
|
//#endregion
|
|
1044
|
+
//#region ../browser/src/engine/groupFiles.ts
|
|
1045
|
+
/** A group file's local id: its group's and a random part, never anything a member sends. */
|
|
1046
|
+
var groupFileId = (groupId, direction, random = toBase64Url$1(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
|
|
1047
|
+
/** The group a local file id names, among `groupIds`. */
|
|
1048
|
+
var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
|
|
1049
|
+
/** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
|
|
1050
|
+
var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessageText(meta) : text;
|
|
1051
|
+
/** The profile's files store and file storage, as a 1:1 chat's files use them. */
|
|
1052
|
+
var storedGroupFiles = {
|
|
1053
|
+
list: (groupId) => fileStore.listForLink(`group:${groupId}`),
|
|
1054
|
+
get: (id) => fileStore.get(id),
|
|
1055
|
+
put: (file) => fileStore.put(file),
|
|
1056
|
+
patch: (id, fields) => fileStore.patch(id, fields),
|
|
1057
|
+
remove: (id) => removeStored(id),
|
|
1058
|
+
async write(id) {
|
|
1059
|
+
const stored = await fileStore.get(id);
|
|
1060
|
+
const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
|
|
1061
|
+
if (await bytes.size(id) !== null) await bytes.truncate(id, 0);
|
|
1062
|
+
if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
|
|
1063
|
+
const appender = new FileAppender(bytes, id, 0);
|
|
1064
|
+
return {
|
|
1065
|
+
append: (chunk) => appender.append(chunk),
|
|
1066
|
+
flush: () => appender.flush(),
|
|
1067
|
+
digest: async () => {
|
|
1068
|
+
await appender.close();
|
|
1069
|
+
return bytes.digest(id);
|
|
1070
|
+
},
|
|
1071
|
+
discard: async () => {
|
|
1072
|
+
await appender.close().catch(() => {});
|
|
1073
|
+
await bytes.remove(id);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
},
|
|
1077
|
+
read: (file, offset, length) => readStored(file, offset, length),
|
|
1078
|
+
room: async () => (await fileBytes()).room()
|
|
1079
|
+
};
|
|
1080
|
+
/** Why a want waits, as the person reads it. */
|
|
1081
|
+
var NOBODY_CONNECTED = "Nobody you are connected to has this file yet";
|
|
1082
|
+
var GroupFileDesk = class {
|
|
1083
|
+
deps;
|
|
1084
|
+
kept = /* @__PURE__ */ new Map();
|
|
1085
|
+
/** `<group>\n<message id>` → local id. */
|
|
1086
|
+
byMessage = /* @__PURE__ */ new Map();
|
|
1087
|
+
wants = /* @__PURE__ */ new Map();
|
|
1088
|
+
edgesById = /* @__PURE__ */ new Map();
|
|
1089
|
+
/** My file about to be announced, by `<group>\n<digest>`: the message that announces it takes this local id. */
|
|
1090
|
+
own = /* @__PURE__ */ new Map();
|
|
1091
|
+
/** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
|
|
1092
|
+
sentPace = /* @__PURE__ */ new Map();
|
|
1093
|
+
takenPace = /* @__PURE__ */ new Map();
|
|
1094
|
+
/** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
|
|
1095
|
+
ahead = /* @__PURE__ */ new Map();
|
|
1096
|
+
/** A community's ids for its next `group-have`, and when one last went. */
|
|
1097
|
+
communityHave = /* @__PURE__ */ new Map();
|
|
1098
|
+
/** Bytes served today, and the day (UTC days since the epoch). */
|
|
1099
|
+
served = {
|
|
1100
|
+
day: 0,
|
|
1101
|
+
bytes: 0
|
|
1102
|
+
};
|
|
1103
|
+
constructor(deps) {
|
|
1104
|
+
this.deps = deps;
|
|
1105
|
+
}
|
|
1106
|
+
/** At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for again. */
|
|
1107
|
+
async load(groupIds) {
|
|
1108
|
+
for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
|
|
1109
|
+
if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
|
|
1110
|
+
const { name, size, mime, voice, video, image } = file.metadata;
|
|
1111
|
+
const kept = {
|
|
1112
|
+
id: file.id,
|
|
1113
|
+
groupId,
|
|
1114
|
+
message: file.group.message,
|
|
1115
|
+
author: file.group.author,
|
|
1116
|
+
meta: {
|
|
1117
|
+
name,
|
|
1118
|
+
size,
|
|
1119
|
+
mime,
|
|
1120
|
+
d: file.digest,
|
|
1121
|
+
...voice && { voice },
|
|
1122
|
+
...video && { video },
|
|
1123
|
+
...image && { image }
|
|
1124
|
+
},
|
|
1125
|
+
mine: file.direction === "out",
|
|
1126
|
+
auto: !!file.group.auto,
|
|
1127
|
+
asked: !!file.group.asked,
|
|
1128
|
+
held: file.transfer?.state === "done",
|
|
1129
|
+
holders: []
|
|
1130
|
+
};
|
|
1131
|
+
this.add(kept);
|
|
1132
|
+
if (kept.held) this.show(kept, {
|
|
1133
|
+
state: "done",
|
|
1134
|
+
transferred: size,
|
|
1135
|
+
size,
|
|
1136
|
+
direction: kept.mine ? "out" : "in"
|
|
1137
|
+
});
|
|
1138
|
+
else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
|
|
1139
|
+
else this.show(kept, {
|
|
1140
|
+
state: "transferring",
|
|
1141
|
+
stage: "asking",
|
|
1142
|
+
transferred: 0,
|
|
1143
|
+
size,
|
|
1144
|
+
direction: "in"
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
for (const [id] of this.wants) this.ask(this.kept.get(id));
|
|
1148
|
+
this.deps.changed();
|
|
1149
|
+
}
|
|
1150
|
+
/** An error when this group took as many announcements of mine as it takes in a minute; null when one may go now. */
|
|
1151
|
+
mayAnnounce(groupId) {
|
|
1152
|
+
return this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).wait() ? "You sent many files to this group just now. Wait a minute." : null;
|
|
1153
|
+
}
|
|
1154
|
+
/** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
|
|
1155
|
+
outgoing(groupId, fileId, meta) {
|
|
1156
|
+
this.own.set(`${groupId}\n${meta.d}`, fileId);
|
|
1157
|
+
}
|
|
1158
|
+
/** The announcement did not go: the file is not this group's after all. */
|
|
1159
|
+
notSent(groupId, meta) {
|
|
1160
|
+
this.own.delete(`${groupId}\n${meta.d}`);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
|
|
1164
|
+
* announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
|
|
1165
|
+
* one this device takes by itself. The same message again (a catch-up, a whole copy) is the same file.
|
|
1166
|
+
*/
|
|
1167
|
+
async announced(groupId, m, me) {
|
|
1168
|
+
const known = this.byMessage.get(`${groupId}\n${m.id}`);
|
|
1169
|
+
if (known) return messageFile(this.kept.get(known));
|
|
1170
|
+
const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
|
|
1171
|
+
if (ownId) {
|
|
1172
|
+
this.own.delete(`${groupId}\n${m.file.d}`);
|
|
1173
|
+
this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).take();
|
|
1174
|
+
const kept = {
|
|
1175
|
+
id: ownId,
|
|
1176
|
+
groupId,
|
|
1177
|
+
message: m.id,
|
|
1178
|
+
author: me,
|
|
1179
|
+
meta: m.file,
|
|
1180
|
+
mine: true,
|
|
1181
|
+
auto: false,
|
|
1182
|
+
asked: false,
|
|
1183
|
+
held: true,
|
|
1184
|
+
holders: []
|
|
1185
|
+
};
|
|
1186
|
+
this.add(kept);
|
|
1187
|
+
const transfer = {
|
|
1188
|
+
state: "done",
|
|
1189
|
+
transferred: m.file.size,
|
|
1190
|
+
size: m.file.size
|
|
1191
|
+
};
|
|
1192
|
+
await this.deps.store.patch(ownId, {
|
|
1193
|
+
group: {
|
|
1194
|
+
message: m.id,
|
|
1195
|
+
author: me
|
|
1196
|
+
},
|
|
1197
|
+
transfer
|
|
1198
|
+
}).catch(() => {});
|
|
1199
|
+
this.show(kept, {
|
|
1200
|
+
...transfer,
|
|
1201
|
+
direction: "out"
|
|
1202
|
+
});
|
|
1203
|
+
this.queueHave(kept);
|
|
1204
|
+
return messageFile(kept);
|
|
1205
|
+
}
|
|
1206
|
+
if (!this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take()) return void 0;
|
|
1207
|
+
const id = groupFileId(groupId, "in");
|
|
1208
|
+
const auto = this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
|
|
1209
|
+
const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
|
|
1210
|
+
this.ahead.delete(`${groupId}\n${m.id}`);
|
|
1211
|
+
const kept = {
|
|
1212
|
+
id,
|
|
1213
|
+
groupId,
|
|
1214
|
+
message: m.id,
|
|
1215
|
+
author: m.sender,
|
|
1216
|
+
meta: m.file,
|
|
1217
|
+
mine: false,
|
|
1218
|
+
auto,
|
|
1219
|
+
asked: false,
|
|
1220
|
+
held: false,
|
|
1221
|
+
holders
|
|
1222
|
+
};
|
|
1223
|
+
const { name, size, mime, voice, video, image } = m.file;
|
|
1224
|
+
this.add(kept);
|
|
1225
|
+
await this.deps.store.put({
|
|
1226
|
+
id,
|
|
1227
|
+
linkId: `group:${groupId}`,
|
|
1228
|
+
direction: "in",
|
|
1229
|
+
digest: m.file.d,
|
|
1230
|
+
createdAt: this.deps.now(),
|
|
1231
|
+
metadata: {
|
|
1232
|
+
name,
|
|
1233
|
+
size,
|
|
1234
|
+
mime,
|
|
1235
|
+
timestamp: this.deps.now(),
|
|
1236
|
+
voice,
|
|
1237
|
+
video,
|
|
1238
|
+
image
|
|
1239
|
+
},
|
|
1240
|
+
transfer: {
|
|
1241
|
+
state: "transferring",
|
|
1242
|
+
transferred: 0,
|
|
1243
|
+
size
|
|
1244
|
+
},
|
|
1245
|
+
group: {
|
|
1246
|
+
message: m.id,
|
|
1247
|
+
author: m.sender,
|
|
1248
|
+
...auto && { auto: true }
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
if (auto) {
|
|
1252
|
+
this.wants.set(id, this.newWant());
|
|
1253
|
+
this.ask(kept);
|
|
1254
|
+
} else this.show(kept, {
|
|
1255
|
+
state: "transferring",
|
|
1256
|
+
stage: "asking",
|
|
1257
|
+
transferred: 0,
|
|
1258
|
+
size,
|
|
1259
|
+
direction: "in"
|
|
1260
|
+
});
|
|
1261
|
+
return messageFile(kept);
|
|
1262
|
+
}
|
|
1263
|
+
/** The person asks for a file this device did not fetch by itself (Download), or for one that stopped coming. */
|
|
1264
|
+
async download(fileId) {
|
|
1265
|
+
const kept = this.kept.get(fileId);
|
|
1266
|
+
if (!kept) throw new Error("No such file");
|
|
1267
|
+
if (kept.held) return;
|
|
1268
|
+
const room = await this.deps.store.room().catch(() => null);
|
|
1269
|
+
if (room !== null && kept.meta.size > room) throw new Error("Not enough space on this device for this file");
|
|
1270
|
+
if (!kept.asked) {
|
|
1271
|
+
kept.asked = true;
|
|
1272
|
+
await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
|
|
1273
|
+
}
|
|
1274
|
+
const want = this.wants.get(kept.id);
|
|
1275
|
+
if (!want) this.wants.set(kept.id, this.newWant());
|
|
1276
|
+
else if (!want.wire) want.tried.clear();
|
|
1277
|
+
this.ask(kept);
|
|
1278
|
+
}
|
|
1279
|
+
/** The group is gone from this device: its files go too. */
|
|
1280
|
+
async drop(groupId) {
|
|
1281
|
+
for (const kept of [...this.kept.values()].filter((k) => k.groupId === groupId)) {
|
|
1282
|
+
this.kept.delete(kept.id);
|
|
1283
|
+
this.byMessage.delete(`${groupId}\n${kept.message}`);
|
|
1284
|
+
this.wants.delete(kept.id);
|
|
1285
|
+
this.deps.transfers.delete(kept.id);
|
|
1286
|
+
await this.deps.store.remove(kept.id).catch(() => {});
|
|
1287
|
+
}
|
|
1288
|
+
for (const [linkId, edge] of this.edgesById) if (edge.groupId === groupId) {
|
|
1289
|
+
edge.files.detach();
|
|
1290
|
+
this.edgesById.delete(linkId);
|
|
1291
|
+
}
|
|
1292
|
+
for (const key of [...this.ahead.keys()]) if (key.startsWith(`${groupId}\n`)) this.ahead.delete(key);
|
|
1293
|
+
this.communityHave.delete(groupId);
|
|
1294
|
+
this.deps.changed();
|
|
1295
|
+
}
|
|
1296
|
+
/** An edge's files/3 session opened (both sides agreed it) or closed. */
|
|
1297
|
+
session(groupId, peer, linkId, open) {
|
|
1298
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1299
|
+
if (open) edge.files.attach();
|
|
1300
|
+
else edge.files.detach();
|
|
1301
|
+
if (open) this.retry(groupId);
|
|
1302
|
+
}
|
|
1303
|
+
/** An edge came up and both sides said where they are: what this device holds goes to it, and wants waiting for a holder go. */
|
|
1304
|
+
edgeReady(groupId, peer, linkId) {
|
|
1305
|
+
if (!this.deps.ready(linkId)) return;
|
|
1306
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1307
|
+
if (this.deps.settings().serveFiles) {
|
|
1308
|
+
const held = [...this.kept.values()].filter((k) => k.groupId === groupId && k.held).map((k) => k.message).slice(-GROUP_FILE_LIMITS.haveIds);
|
|
1309
|
+
if (held.length) this.sendHave(edge, held);
|
|
1310
|
+
}
|
|
1311
|
+
this.retry(groupId);
|
|
1312
|
+
}
|
|
1313
|
+
/** A `group-want`, `group-want-no` or `group-have` on an edge, from the member it is pinned to. */
|
|
1314
|
+
async frame(groupId, peer, linkId, frame) {
|
|
1315
|
+
if (frame.t === "group-want") {
|
|
1316
|
+
const want = readGroupWant(frame, groupId);
|
|
1317
|
+
if (want) await this.serve(this.edge(groupId, peer, linkId), want.id);
|
|
1318
|
+
} else if (frame.t === "group-want-no") {
|
|
1319
|
+
const no = readGroupWantNo(frame, groupId);
|
|
1320
|
+
if (no) this.refused(groupId, peer, linkId, no.id, no.why);
|
|
1321
|
+
} else if (frame.t === "group-have") {
|
|
1322
|
+
const ids = readGroupHave(frame, groupId);
|
|
1323
|
+
if (ids) this.heard(groupId, peer, ids);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** A community's `group-have`, an application frame its signer sent the group. */
|
|
1327
|
+
communityApp(groupId, sender, frame) {
|
|
1328
|
+
const ids = readGroupHave(frame);
|
|
1329
|
+
if (ids) this.heard(groupId, sender, ids);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* A files/3 frame on an edge. An offer is taken only for a file this device asked that member for (`gm` naming the
|
|
1333
|
+
* message): nobody can push a file into another's storage. What it brings belongs to the group's message, not to the
|
|
1334
|
+
* edge as a chat.
|
|
1335
|
+
*/
|
|
1336
|
+
filesFrame(groupId, peer, linkId, frame) {
|
|
1337
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1338
|
+
if (frame.t === "pf-offer" && typeof frame.id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(frame.id) && !edge.taking.has(frame.id)) {
|
|
1339
|
+
const kept = isGroupMessageId(frame.gm) ? this.kept.get(this.byMessage.get(`${groupId}\n${frame.gm}`) ?? "") : void 0;
|
|
1340
|
+
const want = kept && this.wants.get(kept.id);
|
|
1341
|
+
if (!kept || kept.held || !want || want.linkId !== linkId || want.wire) {
|
|
1342
|
+
this.deps.sendFiles(linkId, {
|
|
1343
|
+
t: "pf-refuse",
|
|
1344
|
+
id: frame.id,
|
|
1345
|
+
why: "invalid"
|
|
1346
|
+
});
|
|
1347
|
+
return Promise.resolve();
|
|
1348
|
+
}
|
|
1349
|
+
edge.taking.set(frame.id, kept.id);
|
|
1350
|
+
want.wire = frame.id;
|
|
1351
|
+
want.movedAt = this.deps.now();
|
|
1352
|
+
}
|
|
1353
|
+
return edge.files.handle(frame);
|
|
1354
|
+
}
|
|
1355
|
+
/** Wants that waited too long for an offer, or for bytes, go to the next holder; `group-have`s gathered go. */
|
|
1356
|
+
tick() {
|
|
1357
|
+
const now = this.deps.now();
|
|
1358
|
+
for (const [id, want] of this.wants) {
|
|
1359
|
+
const kept = this.kept.get(id);
|
|
1360
|
+
if (!kept) {
|
|
1361
|
+
this.wants.delete(id);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
if (now - (want.wire ? want.movedAt : want.at) < GROUP_FILE_LIMITS.wantWaitMs) continue;
|
|
1365
|
+
if (want.wire) {
|
|
1366
|
+
const edge = want.linkId ? this.edgesById.get(want.linkId) : void 0;
|
|
1367
|
+
if (edge?.files.get("in", want.wire) && !transferEnded(edge.files.get("in", want.wire))) {
|
|
1368
|
+
edge.files.cancel("in", want.wire);
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
want.wire = void 0;
|
|
1372
|
+
}
|
|
1373
|
+
if (want.holder) want.tried.add(want.holder);
|
|
1374
|
+
else want.tried.clear();
|
|
1375
|
+
this.ask(kept);
|
|
1376
|
+
}
|
|
1377
|
+
for (const edge of this.edgesById.values()) {
|
|
1378
|
+
if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
|
|
1379
|
+
this.sendHave(edge, [...edge.have]);
|
|
1380
|
+
}
|
|
1381
|
+
for (const [groupId, have] of this.communityHave) {
|
|
1382
|
+
if (!have.ids.size || now - have.at < GROUP_FILE_LIMITS.communityHaveEveryMs) continue;
|
|
1383
|
+
const ids = [...have.ids].slice(-GROUP_FILE_LIMITS.haveIds);
|
|
1384
|
+
have.ids.clear();
|
|
1385
|
+
have.at = now;
|
|
1386
|
+
this.deps.sendApp(groupId, {
|
|
1387
|
+
t: GROUP_HAVE_FRAME,
|
|
1388
|
+
ids
|
|
1389
|
+
}).catch(() => {});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
newWant() {
|
|
1393
|
+
return {
|
|
1394
|
+
at: this.deps.now(),
|
|
1395
|
+
tried: /* @__PURE__ */ new Set(),
|
|
1396
|
+
moved: 0,
|
|
1397
|
+
movedAt: 0
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/** Wants of this group that wait for a holder: asked now (an edge came up, or a member said it holds something). */
|
|
1401
|
+
retry(groupId) {
|
|
1402
|
+
for (const [id, want] of this.wants) {
|
|
1403
|
+
const kept = this.kept.get(id);
|
|
1404
|
+
if (kept?.groupId === groupId && !want.holder) this.ask(kept);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Asks the next holder for a file: the author first while its edge is up, then the holders whose edge is up. Only over
|
|
1409
|
+
* an edge this device has. (A private group past 16 members, or a community, reaches a holder it has no edge with
|
|
1410
|
+
* over a file session of the two members, WISP 503 § The file session; that session is not built yet, so such a
|
|
1411
|
+
* holder is not asked, and the file waits for one that is reachable.)
|
|
1412
|
+
*/
|
|
1413
|
+
ask(kept) {
|
|
1414
|
+
const want = this.wants.get(kept.id);
|
|
1415
|
+
if (!want || kept.held || want.wire) return;
|
|
1416
|
+
const membership = this.deps.membership(kept.groupId);
|
|
1417
|
+
const edges = this.deps.edges(kept.groupId);
|
|
1418
|
+
const order = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key) && !want.tried.has(key));
|
|
1419
|
+
for (const key of order) {
|
|
1420
|
+
const linkId = edges.get(key);
|
|
1421
|
+
if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
|
|
1422
|
+
if (!this.deps.send(linkId, {
|
|
1423
|
+
t: "group-want",
|
|
1424
|
+
g: kept.groupId,
|
|
1425
|
+
id: kept.message
|
|
1426
|
+
})) {
|
|
1427
|
+
want.tried.add(key);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
Object.assign(want, {
|
|
1431
|
+
holder: key,
|
|
1432
|
+
linkId,
|
|
1433
|
+
at: this.deps.now()
|
|
1434
|
+
});
|
|
1435
|
+
this.show(kept, {
|
|
1436
|
+
state: "transferring",
|
|
1437
|
+
stage: "queued",
|
|
1438
|
+
transferred: 0,
|
|
1439
|
+
size: kept.meta.size,
|
|
1440
|
+
direction: "in"
|
|
1441
|
+
});
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
Object.assign(want, {
|
|
1445
|
+
holder: void 0,
|
|
1446
|
+
linkId: void 0,
|
|
1447
|
+
at: this.deps.now()
|
|
1448
|
+
});
|
|
1449
|
+
this.show(kept, {
|
|
1450
|
+
state: "transferring",
|
|
1451
|
+
stage: "waiting",
|
|
1452
|
+
transferred: 0,
|
|
1453
|
+
size: kept.meta.size,
|
|
1454
|
+
direction: "in",
|
|
1455
|
+
note: NOBODY_CONNECTED
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
/** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
|
|
1459
|
+
refused(groupId, peer, linkId, messageId, why) {
|
|
1460
|
+
const kept = this.kept.get(this.byMessage.get(`${groupId}\n${messageId}`) ?? "");
|
|
1461
|
+
const want = kept && this.wants.get(kept.id);
|
|
1462
|
+
if (!kept || !want || want.linkId !== linkId || want.wire) return;
|
|
1463
|
+
if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
|
|
1464
|
+
want.tried.add(peer);
|
|
1465
|
+
want.holder = want.linkId = void 0;
|
|
1466
|
+
this.ask(kept);
|
|
1467
|
+
}
|
|
1468
|
+
/** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
|
|
1469
|
+
heard(groupId, from, ids) {
|
|
1470
|
+
const membership = this.deps.membership(groupId);
|
|
1471
|
+
if (!membership || from === membership.me || !membership.inRoster(from)) return;
|
|
1472
|
+
const newest = (holders) => [from, ...holders.filter((key) => key !== from)].slice(0, GROUP_FILE_LIMITS.holders);
|
|
1473
|
+
for (const message of ids) {
|
|
1474
|
+
const key = `${groupId}\n${message}`, kept = this.kept.get(this.byMessage.get(key) ?? "");
|
|
1475
|
+
if (!kept) {
|
|
1476
|
+
this.ahead.set(key, newest(this.ahead.get(key) ?? []));
|
|
1477
|
+
if (this.ahead.size > GROUP_FILE_LIMITS.heardAhead) this.ahead.delete(this.ahead.keys().next().value);
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (kept.mine) continue;
|
|
1481
|
+
kept.holders = newest(kept.holders);
|
|
1482
|
+
const want = this.wants.get(kept.id);
|
|
1483
|
+
if (!want) continue;
|
|
1484
|
+
want.tried.delete(from);
|
|
1485
|
+
if (!want.holder) this.ask(kept);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/** A member asks for a file: offered over files/3 on its edge, or a `group-want-no` saying why not. */
|
|
1489
|
+
async serve(edge, messageId) {
|
|
1490
|
+
const kept = this.kept.get(this.byMessage.get(`${edge.groupId}\n${messageId}`) ?? "");
|
|
1491
|
+
const here = !!kept?.held && !!await this.deps.store.get(kept.id).catch(() => void 0);
|
|
1492
|
+
const why = this.mayServe(edge, messageId, here ? kept : void 0);
|
|
1493
|
+
if (why || !kept) {
|
|
1494
|
+
this.deps.send(edge.linkId, {
|
|
1495
|
+
t: GROUP_WANT_NO_FRAME,
|
|
1496
|
+
g: edge.groupId,
|
|
1497
|
+
id: messageId,
|
|
1498
|
+
why: why ?? "gone"
|
|
1499
|
+
});
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const wire = toBase64Url$1(randomBytes$1(12));
|
|
1503
|
+
edge.serving.set(wire, kept.id);
|
|
1504
|
+
const { name, mime, size, voice, video, image } = kept.meta;
|
|
1505
|
+
const file = {
|
|
1506
|
+
id: wire,
|
|
1507
|
+
name,
|
|
1508
|
+
mime,
|
|
1509
|
+
size,
|
|
1510
|
+
timestamp: this.deps.now(),
|
|
1511
|
+
...voice && { voice },
|
|
1512
|
+
...video && { video },
|
|
1513
|
+
...image && { image }
|
|
1514
|
+
};
|
|
1515
|
+
edge.files.offer(file, kept.meta.d);
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Why this device does not serve a file now, or null when it does (WISP 503 § Asking, Serving limits): `refused` to
|
|
1519
|
+
* someone who could not read the announcement, `gone` when it is not here (`kept` undefined), `busy` when serving is
|
|
1520
|
+
* off or full.
|
|
1521
|
+
*/
|
|
1522
|
+
mayServe(edge, messageId, kept) {
|
|
1523
|
+
const membership = this.deps.membership(edge.groupId);
|
|
1524
|
+
if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
|
|
1525
|
+
if (!kept) return "gone";
|
|
1526
|
+
if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
|
|
1527
|
+
const active = this.servingNow();
|
|
1528
|
+
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce || active.some((a) => a.peer === edge.peer)) return "busy";
|
|
1529
|
+
const today = Math.floor(this.deps.now() / 864e5);
|
|
1530
|
+
if (this.served.day !== today) this.served = {
|
|
1531
|
+
day: today,
|
|
1532
|
+
bytes: 0
|
|
1533
|
+
};
|
|
1534
|
+
const reserved = active.reduce((sum, a) => sum + a.size, 0);
|
|
1535
|
+
return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
|
|
1536
|
+
}
|
|
1537
|
+
/** Transfers served now, all groups together: to whom, and their size. */
|
|
1538
|
+
servingNow() {
|
|
1539
|
+
const active = [];
|
|
1540
|
+
for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
|
|
1541
|
+
const record = edge.files.get("out", wire);
|
|
1542
|
+
if (record && !transferEnded(record)) active.push({
|
|
1543
|
+
peer: edge.peer,
|
|
1544
|
+
size: record.file.size
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
return active;
|
|
1548
|
+
}
|
|
1549
|
+
/** Says on an edge which files this device holds. */
|
|
1550
|
+
sendHave(edge, ids) {
|
|
1551
|
+
for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
|
|
1552
|
+
t: "group-have",
|
|
1553
|
+
g: edge.groupId,
|
|
1554
|
+
ids: ids.slice(i, i + GROUP_FILE_LIMITS.haveIds)
|
|
1555
|
+
})) return;
|
|
1556
|
+
edge.have.clear();
|
|
1557
|
+
edge.haveAt = this.deps.now();
|
|
1558
|
+
}
|
|
1559
|
+
/** This device holds a new file: the group hears it with the next `group-have` (batched, `haveEveryMs` at most on an edge). */
|
|
1560
|
+
queueHave(kept) {
|
|
1561
|
+
if (!this.deps.settings().serveFiles) return;
|
|
1562
|
+
if (this.deps.membership(kept.groupId)?.community) {
|
|
1563
|
+
let have = this.communityHave.get(kept.groupId);
|
|
1564
|
+
if (!have) this.communityHave.set(kept.groupId, have = {
|
|
1565
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1566
|
+
at: 0
|
|
1567
|
+
});
|
|
1568
|
+
have.ids.add(kept.message);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
for (const [key, linkId] of this.deps.edges(kept.groupId)) this.edge(kept.groupId, key, linkId).have.add(kept.message);
|
|
1572
|
+
}
|
|
1573
|
+
edge(groupId, peer, linkId) {
|
|
1574
|
+
const known = this.edgesById.get(linkId);
|
|
1575
|
+
if (known) return known;
|
|
1576
|
+
const edge = {
|
|
1577
|
+
linkId,
|
|
1578
|
+
groupId,
|
|
1579
|
+
peer,
|
|
1580
|
+
files: void 0,
|
|
1581
|
+
serving: /* @__PURE__ */ new Map(),
|
|
1582
|
+
taking: /* @__PURE__ */ new Map(),
|
|
1583
|
+
have: /* @__PURE__ */ new Set(),
|
|
1584
|
+
haveAt: 0
|
|
1585
|
+
};
|
|
1586
|
+
edge.files = new ChatFiles({
|
|
1587
|
+
send: (frame) => {
|
|
1588
|
+
const kept = frame.t === "pf-offer" ? this.kept.get(edge.serving.get(frame.id) ?? "") : void 0;
|
|
1589
|
+
return this.deps.sendFiles(linkId, kept ? {
|
|
1590
|
+
...frame,
|
|
1591
|
+
gm: kept.message
|
|
1592
|
+
} : frame);
|
|
1593
|
+
},
|
|
1594
|
+
decide: (file) => this.decide(edge, file),
|
|
1595
|
+
openTarget: (record) => this.openTarget(edge, record),
|
|
1596
|
+
openSource: (record) => this.openSource(edge, record),
|
|
1597
|
+
changed: (record, transferred) => this.transferChanged(edge, record, transferred),
|
|
1598
|
+
room: () => this.deps.store.room(),
|
|
1599
|
+
writable: () => this.deps.writable?.(linkId)
|
|
1600
|
+
});
|
|
1601
|
+
this.edgesById.set(linkId, edge);
|
|
1602
|
+
return edge;
|
|
1603
|
+
}
|
|
1604
|
+
/** An offer that answers my want (`filesFrame` checked that): taken, unless it is not the file announced or there is no room. */
|
|
1605
|
+
async decide(edge, file) {
|
|
1606
|
+
const kept = this.kept.get(edge.taking.get(file.id) ?? ""), want = kept && this.wants.get(kept.id);
|
|
1607
|
+
if (!kept || kept.held || want?.linkId !== edge.linkId || want.wire !== file.id || file.size !== kept.meta.size) return { refuse: "invalid" };
|
|
1608
|
+
const room = await this.deps.store.room().catch(() => null);
|
|
1609
|
+
return room !== null && file.size > room ? {
|
|
1610
|
+
refuse: "no-room",
|
|
1611
|
+
room
|
|
1612
|
+
} : "accept";
|
|
1613
|
+
}
|
|
1614
|
+
async openTarget(edge, record) {
|
|
1615
|
+
const kept = this.kept.get(edge.taking.get(record.id) ?? "");
|
|
1616
|
+
if (!kept) throw new Error("No such file");
|
|
1617
|
+
await kept.idle;
|
|
1618
|
+
const writer = await this.deps.store.write(kept.id);
|
|
1619
|
+
return {
|
|
1620
|
+
offset: 0,
|
|
1621
|
+
append: (bytes) => writer.append(bytes),
|
|
1622
|
+
flush: () => writer.flush(),
|
|
1623
|
+
verify: async () => await writer.digest() === kept.meta.d,
|
|
1624
|
+
discard: () => kept.idle = writer.discard().catch(() => {})
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
async openSource(edge, record) {
|
|
1628
|
+
const kept = this.kept.get(edge.serving.get(record.id) ?? "");
|
|
1629
|
+
const stored = kept && await this.deps.store.get(kept.id);
|
|
1630
|
+
if (!kept || !stored) throw new Error("The file is gone");
|
|
1631
|
+
return {
|
|
1632
|
+
read: (offset, length) => this.deps.store.read(stored, offset, length),
|
|
1633
|
+
digest: async () => kept.meta.d
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
transferChanged(edge, record, transferred) {
|
|
1637
|
+
if (record.direction === "out") {
|
|
1638
|
+
if (transferEnded(record) && edge.serving.has(record.id)) {
|
|
1639
|
+
const today = Math.floor(this.deps.now() / 864e5);
|
|
1640
|
+
if (this.served.day !== today) this.served = {
|
|
1641
|
+
day: today,
|
|
1642
|
+
bytes: 0
|
|
1643
|
+
};
|
|
1644
|
+
this.served.bytes += record.confirmed;
|
|
1645
|
+
edge.serving.delete(record.id);
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const kept = this.kept.get(edge.taking.get(record.id) ?? ""), want = kept && this.wants.get(kept.id);
|
|
1650
|
+
if (!kept || !want || want.wire !== record.id || want.linkId !== edge.linkId) return;
|
|
1651
|
+
if (transferred !== want.moved) {
|
|
1652
|
+
want.moved = transferred;
|
|
1653
|
+
want.movedAt = this.deps.now();
|
|
1654
|
+
}
|
|
1655
|
+
const { size } = kept.meta;
|
|
1656
|
+
switch (record.state) {
|
|
1657
|
+
case "done":
|
|
1658
|
+
kept.held = true;
|
|
1659
|
+
this.wants.delete(kept.id);
|
|
1660
|
+
edge.taking.delete(record.id);
|
|
1661
|
+
this.deps.store.patch(kept.id, { transfer: {
|
|
1662
|
+
state: "done",
|
|
1663
|
+
transferred: size,
|
|
1664
|
+
size
|
|
1665
|
+
} }).catch(() => {});
|
|
1666
|
+
this.show(kept, {
|
|
1667
|
+
state: "done",
|
|
1668
|
+
transferred: size,
|
|
1669
|
+
size,
|
|
1670
|
+
direction: "in"
|
|
1671
|
+
});
|
|
1672
|
+
this.queueHave(kept);
|
|
1673
|
+
return;
|
|
1674
|
+
case "failed":
|
|
1675
|
+
case "declined":
|
|
1676
|
+
case "cancelled":
|
|
1677
|
+
edge.taking.delete(record.id);
|
|
1678
|
+
if (want.holder) want.tried.add(want.holder);
|
|
1679
|
+
Object.assign(want, {
|
|
1680
|
+
wire: void 0,
|
|
1681
|
+
holder: void 0,
|
|
1682
|
+
linkId: void 0,
|
|
1683
|
+
moved: 0
|
|
1684
|
+
});
|
|
1685
|
+
this.ask(kept);
|
|
1686
|
+
return;
|
|
1687
|
+
case "verifying":
|
|
1688
|
+
this.show(kept, {
|
|
1689
|
+
state: "transferring",
|
|
1690
|
+
stage: "verifying",
|
|
1691
|
+
transferred,
|
|
1692
|
+
size,
|
|
1693
|
+
direction: "in"
|
|
1694
|
+
});
|
|
1695
|
+
return;
|
|
1696
|
+
default: this.show(kept, {
|
|
1697
|
+
state: "transferring",
|
|
1698
|
+
transferred,
|
|
1699
|
+
size,
|
|
1700
|
+
direction: "in"
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
add(kept) {
|
|
1705
|
+
this.kept.set(kept.id, kept);
|
|
1706
|
+
this.byMessage.set(`${kept.groupId}\n${kept.message}`, kept.id);
|
|
1707
|
+
}
|
|
1708
|
+
/** Bytes this device fetched by itself from the group and keeps (or is fetching): what `autoBytesPerGroup` bounds. */
|
|
1709
|
+
keptAutomatically(groupId) {
|
|
1710
|
+
let sum = 0;
|
|
1711
|
+
for (const kept of this.kept.values()) if (kept.groupId === groupId && kept.auto && !kept.mine) sum += kept.meta.size;
|
|
1712
|
+
return sum;
|
|
1713
|
+
}
|
|
1714
|
+
groupField(kept) {
|
|
1715
|
+
return {
|
|
1716
|
+
message: kept.message,
|
|
1717
|
+
author: kept.author,
|
|
1718
|
+
...kept.auto && { auto: true },
|
|
1719
|
+
...kept.asked && { asked: true }
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
pace(windows, key, limit, ms) {
|
|
1723
|
+
let window = windows.get(key);
|
|
1724
|
+
if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
|
|
1725
|
+
return window;
|
|
1726
|
+
}
|
|
1727
|
+
show(kept, view) {
|
|
1728
|
+
this.deps.transfers.set(kept.id, view);
|
|
1729
|
+
this.deps.changed();
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
function messageFile(kept) {
|
|
1733
|
+
const { name, size, mime, voice, video, image } = kept.meta;
|
|
1734
|
+
return {
|
|
1735
|
+
id: kept.id,
|
|
1736
|
+
name,
|
|
1737
|
+
size,
|
|
1738
|
+
mime,
|
|
1739
|
+
...voice && { voice },
|
|
1740
|
+
...video && { video },
|
|
1741
|
+
...image && { image }
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
//#endregion
|
|
1018
1745
|
//#region src/typing.ts
|
|
1019
1746
|
/**
|
|
1020
1747
|
* `typing --for` and `group typing --for`: a `start` holds 6 s on the other side (WISP 401 § Typing, WISP 902 · Group
|
|
@@ -1433,6 +2160,9 @@ function decodeWithFfmpeg(path, command = process.env.GHOSTLY_FFMPEG || "ffmpeg"
|
|
|
1433
2160
|
* Files and voice notes in a chat (WISP 1100, phase 3), as the app's composer sends them (packages/browser
|
|
1434
2161
|
* platform/services.ts `sendFile`): the bytes go into the profile's file store first, then the engine offers them.
|
|
1435
2162
|
* Large files follow files/3: the receiving side's person (here, a command) accepts or declines each offer.
|
|
2163
|
+
*
|
|
2164
|
+
* In a group (WISP 503 · Group Files) the file is announced as a group message, and each member's app fetches the
|
|
2165
|
+
* bytes from whoever holds them: by itself up to the automatic limits, on `file accept` past them.
|
|
1436
2166
|
*/
|
|
1437
2167
|
var TYPES$1 = {
|
|
1438
2168
|
".png": "image/png",
|
|
@@ -1476,16 +2206,143 @@ function filesOf(messages, transfers) {
|
|
|
1476
2206
|
}));
|
|
1477
2207
|
}
|
|
1478
2208
|
/**
|
|
1479
|
-
* The chat a file
|
|
1480
|
-
*
|
|
2209
|
+
* The chat a file command names, or else a group (`group:<id>`, or a group's id, prefix or name where no chat has it),
|
|
2210
|
+
* as `forward` reads them. A name that is neither says what a chat would.
|
|
2211
|
+
*/
|
|
2212
|
+
function chatOrGroupOf(ctx, params) {
|
|
2213
|
+
const ref = str(params, "chat", true);
|
|
2214
|
+
if (ref.startsWith("group:")) return { group: findGroup(state(ctx).groups, ref.slice(6)) };
|
|
2215
|
+
try {
|
|
2216
|
+
return { link: chatOf(ctx, params) };
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
if (!(error instanceof CliError) || error.code !== "not_found") throw error;
|
|
2219
|
+
try {
|
|
2220
|
+
return { group: findGroup(state(ctx).groups, ref) };
|
|
2221
|
+
} catch {
|
|
2222
|
+
throw error;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* The chat or group a file belongs to: a file id starts with its chat's id (a group's file with `group-<id>-`), so it
|
|
2228
|
+
* may be left out; given, it must be the file's own. `where` is what the answer names it by.
|
|
1481
2229
|
*/
|
|
1482
2230
|
function fileChat(ctx, params, fileId) {
|
|
2231
|
+
const groupId = groupOfFile(fileId, state(ctx).groups.map((g) => g.id));
|
|
2232
|
+
if (groupId) {
|
|
2233
|
+
const named = params.chat === void 0 ? void 0 : chatOrGroupOf(ctx, params);
|
|
2234
|
+
if (named && !("group" in named && named.group.id === groupId)) throw new CliError("not_found", `No file ${fileId} in this ${"group" in named ? "group" : "chat"}`);
|
|
2235
|
+
return {
|
|
2236
|
+
linkId: `group:${groupId}`,
|
|
2237
|
+
where: { group: groupId }
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
1483
2240
|
const link = params.chat === void 0 ? state(ctx).links.find((l) => fileId.startsWith(`${l.id}-in-`) || fileId.startsWith(`${l.id}-out-`)) : chatOf(ctx, params);
|
|
1484
2241
|
if (!link) throw new CliError("not_found", `No file ${fileId}`);
|
|
1485
2242
|
if (!fileId.startsWith(`${link.id}-`)) throw new CliError("not_found", `No file ${fileId} in this chat`);
|
|
1486
|
-
return
|
|
2243
|
+
return {
|
|
2244
|
+
linkId: link.id,
|
|
2245
|
+
where: { chat: link.id }
|
|
2246
|
+
};
|
|
1487
2247
|
}
|
|
1488
2248
|
var VOICE_ONLY = "A voice note is audio (webm, ogg, mp4, mpeg, aac, m4a or wav) of at most 15 minutes, with peaks from 0 to 255";
|
|
2249
|
+
/** `--voice [ms] [--peaks …]`: the voice description, its length and waveform measured from the sound when not given. */
|
|
2250
|
+
async function voiceOf(params, path, mime) {
|
|
2251
|
+
if (params.voice === void 0) return {};
|
|
2252
|
+
if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
|
|
2253
|
+
let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
|
|
2254
|
+
let peaks = list(params, "peaks").map(Number);
|
|
2255
|
+
let warning;
|
|
2256
|
+
if (duration === void 0 || !peaks.length) {
|
|
2257
|
+
const measured = await measureVoice(path);
|
|
2258
|
+
if ("voice" in measured) {
|
|
2259
|
+
duration ??= measured.voice.duration;
|
|
2260
|
+
if (!peaks.length) peaks = measured.voice.peaks;
|
|
2261
|
+
} else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
|
|
2262
|
+
else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
|
|
2263
|
+
}
|
|
2264
|
+
const voice = parseVoiceMeta({
|
|
2265
|
+
duration,
|
|
2266
|
+
peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
|
|
2267
|
+
}, mime);
|
|
2268
|
+
if (!voice) throw new CliError("bad_request", VOICE_ONLY);
|
|
2269
|
+
return {
|
|
2270
|
+
voice,
|
|
2271
|
+
...warning ? { warning } : {}
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* A file announced in a group (WISP 503): its bytes staged under a group file id with their digest, as a chat's are, then
|
|
2276
|
+
* the announcement. This device is its first holder and serves the members who ask.
|
|
2277
|
+
*/
|
|
2278
|
+
async function sendToGroup(ctx, params, group, path, size) {
|
|
2279
|
+
if (size > GROUP_FILE_LIMITS.maxBytes) throw new CliError("refused", `Too large for a group (at most ${GROUP_FILE_LIMITS.maxBytes} bytes)`);
|
|
2280
|
+
if (!size) throw new CliError("refused", "An empty file cannot go to a group");
|
|
2281
|
+
const mime = mimeOf(path, str(params, "mime"));
|
|
2282
|
+
const replyTo = str(params, "reply");
|
|
2283
|
+
if (replyTo) {
|
|
2284
|
+
if (!(await node(ctx).groupMessages({ groupId: group.id })).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this group, or cannot be replied to");
|
|
2285
|
+
}
|
|
2286
|
+
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2287
|
+
const source = await openAsBlob(path, { type: mime });
|
|
2288
|
+
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
2289
|
+
const fileId = groupFileId(group.id, "out");
|
|
2290
|
+
const file = {
|
|
2291
|
+
id: fileId,
|
|
2292
|
+
name: sanitizeFileName(str(params, "name") ?? basename(path)),
|
|
2293
|
+
size,
|
|
2294
|
+
mime,
|
|
2295
|
+
...voice ? { voice } : {},
|
|
2296
|
+
...image ? { image } : {}
|
|
2297
|
+
};
|
|
2298
|
+
const timestamp = Date.now();
|
|
2299
|
+
const bytes = await fileBytes();
|
|
2300
|
+
const digest = await bytes.stage(fileId, source);
|
|
2301
|
+
await fileStore.put({
|
|
2302
|
+
id: fileId,
|
|
2303
|
+
linkId: `group:${group.id}`,
|
|
2304
|
+
bytes: bytes.kind,
|
|
2305
|
+
digest,
|
|
2306
|
+
createdAt: timestamp,
|
|
2307
|
+
direction: "out",
|
|
2308
|
+
metadata: {
|
|
2309
|
+
name: file.name,
|
|
2310
|
+
size,
|
|
2311
|
+
mime,
|
|
2312
|
+
timestamp,
|
|
2313
|
+
...voice ? { voice } : {},
|
|
2314
|
+
...image ? { image } : {}
|
|
2315
|
+
},
|
|
2316
|
+
transfer: {
|
|
2317
|
+
state: "transferring",
|
|
2318
|
+
transferred: 0,
|
|
2319
|
+
size
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
endTyping(ctx, { groupId: group.id }, false);
|
|
2323
|
+
const result = await node(ctx).sendGroupFile({
|
|
2324
|
+
groupId: group.id,
|
|
2325
|
+
file,
|
|
2326
|
+
...replyTo ? { replyTo } : {}
|
|
2327
|
+
});
|
|
2328
|
+
if (result.error) {
|
|
2329
|
+
await removeStored(fileId).catch(() => {});
|
|
2330
|
+
throw result.refused ? new CliError("refused", result.error) : groupSendRefused(result.error);
|
|
2331
|
+
}
|
|
2332
|
+
return {
|
|
2333
|
+
group: group.id,
|
|
2334
|
+
messageId: result.messageId ?? null,
|
|
2335
|
+
file: {
|
|
2336
|
+
id: fileId,
|
|
2337
|
+
name: file.name,
|
|
2338
|
+
size,
|
|
2339
|
+
mime,
|
|
2340
|
+
...voice ? { voice: true } : {},
|
|
2341
|
+
...image ? { image } : {}
|
|
2342
|
+
},
|
|
2343
|
+
...warning ? { warning } : {}
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
1489
2346
|
var ACTIONS = [
|
|
1490
2347
|
"accept",
|
|
1491
2348
|
"decline",
|
|
@@ -1497,7 +2354,7 @@ var ACTIONS = [
|
|
|
1497
2354
|
];
|
|
1498
2355
|
var FILE_METHODS = {
|
|
1499
2356
|
async "file.send"(ctx, params) {
|
|
1500
|
-
const
|
|
2357
|
+
const target = chatOrGroupOf(ctx, params);
|
|
1501
2358
|
const path = resolve(str(params, "path", true));
|
|
1502
2359
|
let info;
|
|
1503
2360
|
try {
|
|
@@ -1506,6 +2363,8 @@ var FILE_METHODS = {
|
|
|
1506
2363
|
throw new CliError("not_found", `No file ${path}`);
|
|
1507
2364
|
}
|
|
1508
2365
|
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
2366
|
+
if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
|
|
2367
|
+
const { link } = target;
|
|
1509
2368
|
if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
|
|
1510
2369
|
if (!link.capabilities?.largeFiles && info.size > LIMITS.maxFileBytes) throw new CliError("refused", `Too large for the contact's app (at most ${LIMITS.maxFileBytes} bytes)`);
|
|
1511
2370
|
if (typeof link.peerFileRoom === "number" && info.size > link.peerFileRoom) throw new CliError("refused", `Not enough space on the contact's device (${link.peerFileRoom} bytes free)`);
|
|
@@ -1514,25 +2373,7 @@ var FILE_METHODS = {
|
|
|
1514
2373
|
if (replyTo) {
|
|
1515
2374
|
if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this chat, or cannot be replied to");
|
|
1516
2375
|
}
|
|
1517
|
-
|
|
1518
|
-
if (params.voice !== void 0) {
|
|
1519
|
-
if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
|
|
1520
|
-
let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
|
|
1521
|
-
let peaks = list(params, "peaks").map(Number);
|
|
1522
|
-
if (duration === void 0 || !peaks.length) {
|
|
1523
|
-
const measured = await measureVoice(path);
|
|
1524
|
-
if ("voice" in measured) {
|
|
1525
|
-
duration ??= measured.voice.duration;
|
|
1526
|
-
if (!peaks.length) peaks = measured.voice.peaks;
|
|
1527
|
-
} else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
|
|
1528
|
-
else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
|
|
1529
|
-
}
|
|
1530
|
-
voice = parseVoiceMeta({
|
|
1531
|
-
duration,
|
|
1532
|
-
peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
|
|
1533
|
-
}, mime);
|
|
1534
|
-
if (!voice) throw new CliError("bad_request", VOICE_ONLY);
|
|
1535
|
-
}
|
|
2376
|
+
const { voice, warning } = await voiceOf(params, path, mime);
|
|
1536
2377
|
const source = await openAsBlob(path, { type: mime });
|
|
1537
2378
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
1538
2379
|
const wireId = toBase64Url$1(randomBytes$1(12));
|
|
@@ -1595,34 +2436,58 @@ var FILE_METHODS = {
|
|
|
1595
2436
|
};
|
|
1596
2437
|
},
|
|
1597
2438
|
async "file.list"(ctx, params) {
|
|
1598
|
-
const
|
|
2439
|
+
const target = chatOrGroupOf(ctx, params);
|
|
2440
|
+
if ("group" in target) return {
|
|
2441
|
+
group: target.group.id,
|
|
2442
|
+
files: filesOf(await node(ctx).groupMessages({ groupId: target.group.id }), state(ctx).transfers)
|
|
2443
|
+
};
|
|
1599
2444
|
return {
|
|
1600
|
-
chat: link.id,
|
|
1601
|
-
files: filesOf(await node(ctx).getMessages(link.id), state(ctx).transfers)
|
|
2445
|
+
chat: target.link.id,
|
|
2446
|
+
files: filesOf(await node(ctx).getMessages(target.link.id), state(ctx).transfers)
|
|
1602
2447
|
};
|
|
1603
2448
|
},
|
|
1604
2449
|
/**
|
|
1605
2450
|
* files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
|
|
1606
2451
|
* stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
|
|
2452
|
+
* A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself) and `request`.
|
|
1607
2453
|
*/
|
|
1608
2454
|
async "file.action"(ctx, params) {
|
|
1609
2455
|
const fileId = str(params, "file", true);
|
|
1610
|
-
const
|
|
2456
|
+
const { linkId, where } = fileChat(ctx, params, fileId);
|
|
1611
2457
|
const action = oneOf(params, "action", ACTIONS, "accept");
|
|
2458
|
+
if ("group" in where) {
|
|
2459
|
+
if (action !== "accept" && action !== "request") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
|
|
2460
|
+
if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", "Nothing to ask for: the file is all here");
|
|
2461
|
+
try {
|
|
2462
|
+
await node(ctx).fileAction({
|
|
2463
|
+
linkId,
|
|
2464
|
+
fileId,
|
|
2465
|
+
action
|
|
2466
|
+
});
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
throw new CliError("refused", error instanceof Error ? error.message : String(error));
|
|
2469
|
+
}
|
|
2470
|
+
return {
|
|
2471
|
+
...where,
|
|
2472
|
+
file: fileId,
|
|
2473
|
+
action,
|
|
2474
|
+
transfer: state(ctx).transfers[fileId] ?? null
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
1612
2477
|
if (action === "resend" || action === "request") {
|
|
1613
|
-
const outgoing = fileId.startsWith(`${
|
|
2478
|
+
const outgoing = fileId.startsWith(`${linkId}-out-`);
|
|
1614
2479
|
if (outgoing !== (action === "resend")) throw new CliError("bad_request", outgoing ? "A file sent from here is sent again: file resend" : "A received file is asked for again: file request");
|
|
1615
2480
|
const transfer = state(ctx).transfers[fileId];
|
|
1616
2481
|
if (!transfer || transfer.state === "done") throw new CliError("refused", outgoing ? "Nothing to send again: it arrived whole" : "Nothing to ask for: the file is all here");
|
|
1617
2482
|
if (transfer.state === "failed" && (!outgoing || !transfer.retry)) throw new CliError("refused", `It ended: ${transfer.error ?? "failed"}`);
|
|
1618
2483
|
}
|
|
1619
2484
|
await node(ctx).fileAction({
|
|
1620
|
-
linkId
|
|
2485
|
+
linkId,
|
|
1621
2486
|
fileId,
|
|
1622
2487
|
action
|
|
1623
2488
|
});
|
|
1624
2489
|
return {
|
|
1625
|
-
|
|
2490
|
+
...where,
|
|
1626
2491
|
file: fileId,
|
|
1627
2492
|
action,
|
|
1628
2493
|
transfer: state(ctx).transfers[fileId] ?? null
|
|
@@ -1634,7 +2499,7 @@ var FILE_METHODS = {
|
|
|
1634
2499
|
*/
|
|
1635
2500
|
async "file.wait"(ctx, params) {
|
|
1636
2501
|
const fileId = str(params, "file", true);
|
|
1637
|
-
const
|
|
2502
|
+
const { where } = fileChat(ctx, params, fileId);
|
|
1638
2503
|
const stored = await fileStore.get(fileId);
|
|
1639
2504
|
if (!stored && !state(ctx).transfers[fileId]) throw new CliError("not_found", `No file ${fileId}`);
|
|
1640
2505
|
const transfer = await waitForState(ctx, (s) => {
|
|
@@ -1655,11 +2520,11 @@ var FILE_METHODS = {
|
|
|
1655
2520
|
});
|
|
1656
2521
|
if (transfer.state === "failed") throw new CliError("engine", transfer.error ?? "The transfer failed", {
|
|
1657
2522
|
file: fileId,
|
|
1658
|
-
|
|
2523
|
+
...where,
|
|
1659
2524
|
retry: !!transfer.retry
|
|
1660
2525
|
});
|
|
1661
2526
|
return {
|
|
1662
|
-
|
|
2527
|
+
...where,
|
|
1663
2528
|
file: fileId,
|
|
1664
2529
|
state: "done",
|
|
1665
2530
|
size: transfer.size
|
|
@@ -1911,7 +2776,7 @@ async function sendCard(ctx, params, kind) {
|
|
|
1911
2776
|
status: "running",
|
|
1912
2777
|
startedAt: now,
|
|
1913
2778
|
updatedAt: now
|
|
1914
|
-
} : { state: "active" },
|
|
2779
|
+
} : kind === "routine" ? { state: "active" } : {},
|
|
1915
2780
|
...fields,
|
|
1916
2781
|
kind,
|
|
1917
2782
|
id
|
|
@@ -2034,6 +2899,82 @@ async function flushCardUpdates(ctx) {
|
|
|
2034
2899
|
for (const pace of waiting) pace.flush();
|
|
2035
2900
|
await Promise.all(waiting.map((pace) => pace.flushed));
|
|
2036
2901
|
}
|
|
2902
|
+
/** A usage card's fields: all of them are said again by each report, so one not given goes from the card. */
|
|
2903
|
+
var USAGE_FIELDS = [
|
|
2904
|
+
"left",
|
|
2905
|
+
"used",
|
|
2906
|
+
"limit",
|
|
2907
|
+
"label",
|
|
2908
|
+
"account",
|
|
2909
|
+
"window",
|
|
2910
|
+
"resetsAt",
|
|
2911
|
+
"updatedAt",
|
|
2912
|
+
"windows"
|
|
2913
|
+
];
|
|
2914
|
+
/** The id a bot's usage card goes by when it names none: one live card per chat. */
|
|
2915
|
+
var USAGE_CARD_ID = "usage";
|
|
2916
|
+
/**
|
|
2917
|
+
* One chat's usage report (WISP 405 § Usage): the bot's usage card there, kept as one message. The first report sends
|
|
2918
|
+
* it; every later one edits it in place (quiet: no sound, no unread, no move in the list), paced as a task's updates.
|
|
2919
|
+
* A report is whole: what it does not say goes from the card. A card at its edits' cap gives way to a new message.
|
|
2920
|
+
*/
|
|
2921
|
+
async function reportUsage(ctx, ref, fields, params) {
|
|
2922
|
+
const target = chatOrGroup(ctx, ref);
|
|
2923
|
+
const id = typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID;
|
|
2924
|
+
const report = {
|
|
2925
|
+
updatedAt: Date.now(),
|
|
2926
|
+
...fields,
|
|
2927
|
+
kind: "usage",
|
|
2928
|
+
id
|
|
2929
|
+
};
|
|
2930
|
+
checked$1(report);
|
|
2931
|
+
const sent = await cardMessage(ctx, target, "usage", id).catch(() => void 0);
|
|
2932
|
+
const wait = oneOf(params, "wait", ["none", "sent"], "none");
|
|
2933
|
+
if (!sent || (sent.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) return {
|
|
2934
|
+
...await sendCard(ctx, {
|
|
2935
|
+
...params,
|
|
2936
|
+
chat: ref,
|
|
2937
|
+
card: report,
|
|
2938
|
+
wait
|
|
2939
|
+
}, "usage"),
|
|
2940
|
+
updated: false
|
|
2941
|
+
};
|
|
2942
|
+
const patch = {};
|
|
2943
|
+
for (const key of USAGE_FIELDS) patch[key] = report[key] ?? null;
|
|
2944
|
+
return {
|
|
2945
|
+
...await updateCard(ctx, {
|
|
2946
|
+
...params,
|
|
2947
|
+
chat: ref,
|
|
2948
|
+
usage: id,
|
|
2949
|
+
card: patch,
|
|
2950
|
+
wait: wait === "none" ? "none" : target.group ? "sent" : "confirmed"
|
|
2951
|
+
}, "usage"),
|
|
2952
|
+
updated: true
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
/** `usage send`: one chat or group, or (`all`) every 1:1 chat with a contact; a chat that refuses is said, not fatal. */
|
|
2956
|
+
async function sendUsage(ctx, params) {
|
|
2957
|
+
const fields = fieldsOf(params);
|
|
2958
|
+
if (!bool(params, "all")) return reportUsage(ctx, str(params, "chat", true), fields, params);
|
|
2959
|
+
if (str(params, "chat")) throw new CliError("bad_request", "Name a chat or --all, not both");
|
|
2960
|
+
checked$1({
|
|
2961
|
+
updatedAt: Date.now(),
|
|
2962
|
+
...fields,
|
|
2963
|
+
kind: "usage",
|
|
2964
|
+
id: typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID
|
|
2965
|
+
});
|
|
2966
|
+
const chats = state(ctx).links.filter((link) => link.profile && link.peerPubKeyZ32);
|
|
2967
|
+
const results = [];
|
|
2968
|
+
for (const link of chats) try {
|
|
2969
|
+
results.push(await reportUsage(ctx, link.id, fields, params));
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
results.push({
|
|
2972
|
+
chat: link.id,
|
|
2973
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
return { chats: results };
|
|
2977
|
+
}
|
|
2037
2978
|
var STATUS_CARD_METHODS = {
|
|
2038
2979
|
/** WISP 405 · Status Cards: a task card in a chat or a group; the answer names the task's id and its message. */
|
|
2039
2980
|
"task.send": (ctx, params) => sendCard(ctx, params, "task"),
|
|
@@ -2042,7 +2983,9 @@ var STATUS_CARD_METHODS = {
|
|
|
2042
2983
|
/** A routine card (its schedule, state, runs); `run` records one. */
|
|
2043
2984
|
"routine.send": (ctx, params) => sendCard(ctx, params, "routine"),
|
|
2044
2985
|
/** A routine's update, as a task's; `run` records a run as its last and the newest of its recent ones. */
|
|
2045
|
-
"routine.update": (ctx, params) => updateCard(ctx, params, "routine")
|
|
2986
|
+
"routine.update": (ctx, params) => updateCard(ctx, params, "routine"),
|
|
2987
|
+
/** WISP 405 § Usage: how much of a quota the bot has left, kept as one card per chat; `all` for every 1:1 chat. */
|
|
2988
|
+
"usage.send": sendUsage
|
|
2046
2989
|
};
|
|
2047
2990
|
//#endregion
|
|
2048
2991
|
//#region src/buttons.ts
|
|
@@ -6248,7 +7191,7 @@ function openPassportWindow(url) {
|
|
|
6248
7191
|
return window.open(url, "pubky-passport", "popup,width=520,height=760");
|
|
6249
7192
|
}
|
|
6250
7193
|
/** A 401 or 403 from the homeserver: the session it was given is not one it accepts. */
|
|
6251
|
-
var refused = (e) => {
|
|
7194
|
+
var refused$1 = (e) => {
|
|
6252
7195
|
const status = e?.data?.statusCode;
|
|
6253
7196
|
return status === 401 || status === 403;
|
|
6254
7197
|
};
|
|
@@ -6479,7 +7422,7 @@ async function approveAndWork(options, work, { grant, cookie, opened, settled, c
|
|
|
6479
7422
|
throw e;
|
|
6480
7423
|
}
|
|
6481
7424
|
const fail = (what) => (e) => {
|
|
6482
|
-
if (viaCookie && !cookieKept && refused(e)) throw new Error("Pubky Ring approved, but this browser blocks your homeserver's sign-in cookie (Vanadium and Safari block cookies from other sites), so nothing was written. Approve with Pubky Passport instead, or use Firefox or the Ghostly desktop app.");
|
|
7425
|
+
if (viaCookie && !cookieKept && refused$1(e)) throw new Error("Pubky Ring approved, but this browser blocks your homeserver's sign-in cookie (Vanadium and Safari block cookies from other sites), so nothing was written. Approve with Pubky Passport instead, or use Firefox or the Ghostly desktop app.");
|
|
6483
7426
|
throw failure(what, e);
|
|
6484
7427
|
};
|
|
6485
7428
|
const approved = session;
|
|
@@ -8489,7 +9432,7 @@ async function restoreProfile(home, name, file, passphrase) {
|
|
|
8489
9432
|
if (!reader) {
|
|
8490
9433
|
const payload = await readWhole(file, passphrase ?? "");
|
|
8491
9434
|
work = await restoringFolder(home, name);
|
|
8492
|
-
await writeWhole(work, payload);
|
|
9435
|
+
await writeWhole$1(work, payload);
|
|
8493
9436
|
return await finishRestore(home, name, work);
|
|
8494
9437
|
}
|
|
8495
9438
|
const next = async () => {
|
|
@@ -8582,7 +9525,7 @@ async function readWhole(file, passphrase) {
|
|
|
8582
9525
|
if (payload?.format !== FORMAT_1 || typeof payload.store !== "string" || !Array.isArray(payload.files)) throw new CliError("refused", "This backup is not a headless Ghostly profile");
|
|
8583
9526
|
return payload;
|
|
8584
9527
|
}
|
|
8585
|
-
async function writeWhole(paths, payload) {
|
|
9528
|
+
async function writeWhole$1(paths, payload) {
|
|
8586
9529
|
await mkdir(paths.db, {
|
|
8587
9530
|
recursive: true,
|
|
8588
9531
|
mode: 448
|
|
@@ -12206,6 +13149,565 @@ function connectDaemon(path, timeoutMs = 2e3) {
|
|
|
12206
13149
|
});
|
|
12207
13150
|
}
|
|
12208
13151
|
//#endregion
|
|
13152
|
+
//#region src/apps.ts
|
|
13153
|
+
var APP_SOURCE = "ghostly-app.json";
|
|
13154
|
+
var APP_REVOKE_FILE = "ghostly-revoke.json";
|
|
13155
|
+
var STORE_INDEX_FILE = "ghostly-store.json";
|
|
13156
|
+
var STORE_SIG_FILE = "ghostly-store.sig";
|
|
13157
|
+
/** How long `app verify` waits for a URL, and how many redirects it follows (each checked as the first). */
|
|
13158
|
+
var FETCH_TIMEOUT_MS = 6e4;
|
|
13159
|
+
var MAX_REDIRECTS = 5;
|
|
13160
|
+
var KEY_HEADER = {
|
|
13161
|
+
publisher: "Ghostly publisher key (WISP 1200). Secret: never share or commit it. Back it up: an app signed with it is updated only with it.",
|
|
13162
|
+
store: "Ghostly store key (WISP 1200). Secret: keep it offline, never share or commit it. Back it up: a store is its key."
|
|
13163
|
+
};
|
|
13164
|
+
var PEM = /-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)-----END PRIVATE KEY-----/;
|
|
13165
|
+
/** Refuses a key file that is not a plain file of this user's, or that anyone else may read or write. */
|
|
13166
|
+
function checkKeyFile(path) {
|
|
13167
|
+
const info = statSync(path);
|
|
13168
|
+
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file: --key names a key file`);
|
|
13169
|
+
if (process.platform === "win32") return;
|
|
13170
|
+
if (process.getuid && info.uid !== process.getuid()) throw new CliError("refused", `${path} is not this user's: a key file must be yours alone`);
|
|
13171
|
+
if ((info.mode & 63) !== 0) throw new CliError("refused", `${path} may be read by others (mode ${(info.mode & 511).toString(8)}): run chmod 600 ${path}, and think whether the key was seen`);
|
|
13172
|
+
}
|
|
13173
|
+
/** Reads a key file: the PEM block, an Ed25519 key, and the kind its first line names (when it names one). */
|
|
13174
|
+
function readKey(path, kind) {
|
|
13175
|
+
checkKeyFile(path);
|
|
13176
|
+
const text = readFileSync(path, "utf8");
|
|
13177
|
+
const other = kind === "publisher" ? "store" : "publisher";
|
|
13178
|
+
if (text.startsWith(`Ghostly ${other} key`)) throw new CliError("refused", `${path} is a ${other} key: ${kind === "publisher" ? "a store key never signs an app" : "a publisher key never signs a store index"} (WISP 1200 · Stores)`);
|
|
13179
|
+
const block = PEM.exec(text);
|
|
13180
|
+
let seed;
|
|
13181
|
+
if (block) try {
|
|
13182
|
+
const key = createPrivateKey(block[0]);
|
|
13183
|
+
const d = key.asymmetricKeyType === "ed25519" ? key.export({ format: "jwk" }).d : void 0;
|
|
13184
|
+
if (d) seed = fromBase64Url$1(d);
|
|
13185
|
+
} catch {}
|
|
13186
|
+
if (!seed || seed.length !== 32) throw new CliError("bad_request", `${path} holds no Ed25519 private key (a PEM "PRIVATE KEY" block, as openssl genpkey -algorithm ed25519 writes)`);
|
|
13187
|
+
const signer = seedSigner(seed);
|
|
13188
|
+
return {
|
|
13189
|
+
signer,
|
|
13190
|
+
key: toZ32(signer.publicKey),
|
|
13191
|
+
created: false
|
|
13192
|
+
};
|
|
13193
|
+
}
|
|
13194
|
+
/** Makes a key file: owner-only from the start, never over a file that is there. */
|
|
13195
|
+
function makeKey(path, kind) {
|
|
13196
|
+
mkdirSync(dirname(path), {
|
|
13197
|
+
recursive: true,
|
|
13198
|
+
mode: 448
|
|
13199
|
+
});
|
|
13200
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
13201
|
+
const pem = privateKey.export({
|
|
13202
|
+
type: "pkcs8",
|
|
13203
|
+
format: "pem"
|
|
13204
|
+
});
|
|
13205
|
+
const fd = openSync(path, "wx", 384);
|
|
13206
|
+
try {
|
|
13207
|
+
writeSync(fd, `${KEY_HEADER[kind]}\n${pem}`);
|
|
13208
|
+
} finally {
|
|
13209
|
+
closeSync(fd);
|
|
13210
|
+
}
|
|
13211
|
+
return {
|
|
13212
|
+
...readKey(path, kind),
|
|
13213
|
+
created: true
|
|
13214
|
+
};
|
|
13215
|
+
}
|
|
13216
|
+
/** What the person is told on stderr when a key was made: where, which, and to back it up. Never the key. */
|
|
13217
|
+
function madeKeyNote(path, kind, key) {
|
|
13218
|
+
return kind === "publisher" ? `new publisher key written to ${path}; back it up (${appFingerprint(key)}: an app is updated only with the key that signed it, and phase 1 has no recovery)` : `new store key written to ${path}; back it up and keep it offline (${appFingerprint(key)}: a store is its key)`;
|
|
13219
|
+
}
|
|
13220
|
+
var refused = (what, reading, extra = {}) => new CliError("refused", `${what} (${reading.reason}${reading.detail ? `: ${reading.detail}` : ""})`, {
|
|
13221
|
+
reason: reading.reason,
|
|
13222
|
+
...reading.detail ? { detail: reading.detail } : {},
|
|
13223
|
+
...extra
|
|
13224
|
+
});
|
|
13225
|
+
/** A bundle file's bytes: its size is checked before it is read, so a huge file is refused unread. */
|
|
13226
|
+
function readBundleFile(path) {
|
|
13227
|
+
const info = statSync(path);
|
|
13228
|
+
if (info.isDirectory()) throw new CliError("bad_request", `${path} is a folder: name a .ghostlyapp file`);
|
|
13229
|
+
if (info.size > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${path} is not a valid bundle`, {
|
|
13230
|
+
reason: "too-large",
|
|
13231
|
+
detail: `${info.size} bytes`
|
|
13232
|
+
});
|
|
13233
|
+
return new Uint8Array(readFileSync(path));
|
|
13234
|
+
}
|
|
13235
|
+
/**
|
|
13236
|
+
* A bundle at an https URL, as a client fetches one: `https:` only, no user or password, jsDelivr only at a full
|
|
13237
|
+
* commit (WISP 1200 · Manifest, "Every URL"); every redirect is held to the same rule, and the download stops past
|
|
13238
|
+
* 16 MiB.
|
|
13239
|
+
*/
|
|
13240
|
+
async function fetchBundle(url) {
|
|
13241
|
+
let at = url;
|
|
13242
|
+
for (let hop = 0;; hop++) {
|
|
13243
|
+
if (!isAppUrl(at)) throw new CliError(hop ? "refused" : "usage", hop ? `${url} redirects to ${at}, which a client does not read (https only, no user or password, jsDelivr only at a full commit)` : `Not a URL a client reads: https only, at most ${APP_BUNDLE_LIMITS.url} characters, no user or password, and jsDelivr only as https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-hex commit>/<path>`);
|
|
13244
|
+
let response;
|
|
13245
|
+
try {
|
|
13246
|
+
response = await fetch(at, {
|
|
13247
|
+
redirect: "manual",
|
|
13248
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
13249
|
+
});
|
|
13250
|
+
} catch (error) {
|
|
13251
|
+
if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not answer in ${FETCH_TIMEOUT_MS / 1e3} s`);
|
|
13252
|
+
throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
|
|
13253
|
+
}
|
|
13254
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
13255
|
+
await response.body?.cancel();
|
|
13256
|
+
if (hop >= MAX_REDIRECTS) throw new CliError("refused", `${url} redirects more than ${MAX_REDIRECTS} times`);
|
|
13257
|
+
at = new URL(response.headers.get("location"), at).href;
|
|
13258
|
+
continue;
|
|
13259
|
+
}
|
|
13260
|
+
if (response.status === 404 || response.status === 410) {
|
|
13261
|
+
await response.body?.cancel();
|
|
13262
|
+
throw new CliError("not_found", `${at} answered ${response.status}`);
|
|
13263
|
+
}
|
|
13264
|
+
if (!response.ok || !response.body) {
|
|
13265
|
+
await response.body?.cancel();
|
|
13266
|
+
throw new CliError("unavailable", `${at} answered ${response.status}`);
|
|
13267
|
+
}
|
|
13268
|
+
const limit = APP_BUNDLE_LIMITS.bundleBytes;
|
|
13269
|
+
const tooLarge = (bytes) => refused(`${url} is not a valid bundle`, {
|
|
13270
|
+
reason: "too-large",
|
|
13271
|
+
detail: `${bytes} bytes`
|
|
13272
|
+
});
|
|
13273
|
+
const length = Number(response.headers.get("content-length"));
|
|
13274
|
+
if (Number.isFinite(length) && length > limit) {
|
|
13275
|
+
await response.body.cancel();
|
|
13276
|
+
throw tooLarge(length);
|
|
13277
|
+
}
|
|
13278
|
+
const chunks = [];
|
|
13279
|
+
let total = 0;
|
|
13280
|
+
const reader = response.body.getReader();
|
|
13281
|
+
try {
|
|
13282
|
+
for (;;) {
|
|
13283
|
+
const { done, value } = await reader.read();
|
|
13284
|
+
if (done) break;
|
|
13285
|
+
total += value.length;
|
|
13286
|
+
if (total > limit) {
|
|
13287
|
+
await reader.cancel();
|
|
13288
|
+
throw tooLarge(`more than ${limit}`);
|
|
13289
|
+
}
|
|
13290
|
+
chunks.push(value);
|
|
13291
|
+
}
|
|
13292
|
+
} catch (error) {
|
|
13293
|
+
if (error instanceof CliError) throw error;
|
|
13294
|
+
if (error instanceof Error && error.name === "TimeoutError") throw new CliError("timeout", `${at} did not finish in ${FETCH_TIMEOUT_MS / 1e3} s`);
|
|
13295
|
+
throw new CliError("unavailable", `Could not read ${at}: ${error instanceof Error ? error.message : String(error)}`);
|
|
13296
|
+
}
|
|
13297
|
+
const out = new Uint8Array(total);
|
|
13298
|
+
let offset = 0;
|
|
13299
|
+
for (const chunk of chunks) {
|
|
13300
|
+
out.set(chunk, offset);
|
|
13301
|
+
offset += chunk.length;
|
|
13302
|
+
}
|
|
13303
|
+
return out;
|
|
13304
|
+
}
|
|
13305
|
+
}
|
|
13306
|
+
/** What a checked bundle is, as `app verify` and `app publish` print it. */
|
|
13307
|
+
function described(bundle, bytes) {
|
|
13308
|
+
const m = bundle.manifest;
|
|
13309
|
+
return {
|
|
13310
|
+
ref: appRef(m.publisher, m.name),
|
|
13311
|
+
publisher: m.publisher,
|
|
13312
|
+
fingerprint: appFingerprint(m.publisher),
|
|
13313
|
+
name: m.name,
|
|
13314
|
+
title: m.title,
|
|
13315
|
+
version: m.version,
|
|
13316
|
+
sequence: m.sequence,
|
|
13317
|
+
digest: bundle.digest,
|
|
13318
|
+
bytes: bytes.length,
|
|
13319
|
+
sha256: toBase64Url$1(createHash("sha256").update(bytes).digest())
|
|
13320
|
+
};
|
|
13321
|
+
}
|
|
13322
|
+
/** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
|
|
13323
|
+
async function verifyApp(source) {
|
|
13324
|
+
const bytes = /^[a-z][a-z0-9+.-]*:\/\//i.test(source) ? await fetchBundle(source) : readBundleFile(resolve(source));
|
|
13325
|
+
const read = readAppBundle(bytes);
|
|
13326
|
+
if (!read.ok) throw refused(`${source} is not a valid bundle`, read);
|
|
13327
|
+
return {
|
|
13328
|
+
valid: true,
|
|
13329
|
+
...described(read.bundle, bytes),
|
|
13330
|
+
manifest: read.bundle.manifest
|
|
13331
|
+
};
|
|
13332
|
+
}
|
|
13333
|
+
/** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
|
|
13334
|
+
function collectFiles(dir, leaveOut) {
|
|
13335
|
+
const files = [];
|
|
13336
|
+
const skipped = [];
|
|
13337
|
+
let bytes = 0;
|
|
13338
|
+
const walk = (folder) => {
|
|
13339
|
+
for (const name of readdirSync(folder).sort()) {
|
|
13340
|
+
const full = join(folder, name);
|
|
13341
|
+
const path = relative(dir, full).split(sep).join("/");
|
|
13342
|
+
if (leaveOut.has(full)) continue;
|
|
13343
|
+
if (name.startsWith(".")) {
|
|
13344
|
+
skipped.push(path);
|
|
13345
|
+
continue;
|
|
13346
|
+
}
|
|
13347
|
+
const info = lstatSync(full);
|
|
13348
|
+
if (info.isSymbolicLink()) throw new CliError("bad_request", `${path} is a link: a bundle holds the folder's own files (copy it in, or remove it)`);
|
|
13349
|
+
if (info.isDirectory()) {
|
|
13350
|
+
walk(full);
|
|
13351
|
+
continue;
|
|
13352
|
+
}
|
|
13353
|
+
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
13354
|
+
files.push({
|
|
13355
|
+
path,
|
|
13356
|
+
full,
|
|
13357
|
+
size: info.size
|
|
13358
|
+
});
|
|
13359
|
+
bytes += info.size;
|
|
13360
|
+
if (files.length > APP_BUNDLE_LIMITS.files) throw refused(`${dir} holds more files than a bundle may`, {
|
|
13361
|
+
reason: "too-many-files",
|
|
13362
|
+
detail: `more than ${APP_BUNDLE_LIMITS.files}`
|
|
13363
|
+
});
|
|
13364
|
+
if (bytes > APP_BUNDLE_LIMITS.bundleBytes) throw refused(`${dir} holds more than a bundle may`, {
|
|
13365
|
+
reason: "too-large",
|
|
13366
|
+
detail: `more than ${APP_BUNDLE_LIMITS.bundleBytes} bytes of files`
|
|
13367
|
+
});
|
|
13368
|
+
}
|
|
13369
|
+
};
|
|
13370
|
+
walk(dir);
|
|
13371
|
+
return {
|
|
13372
|
+
files,
|
|
13373
|
+
skipped
|
|
13374
|
+
};
|
|
13375
|
+
}
|
|
13376
|
+
/** Fields of `ghostly-app.json` the command writes itself. */
|
|
13377
|
+
var WRITTEN = ["publisher", "files"];
|
|
13378
|
+
function readSource(path) {
|
|
13379
|
+
if (!existsSync(path)) throw new CliError("not_found", `No ${APP_SOURCE} in ${dirname(path)}: it holds the manifest's fields (name, version, title, tagline, entry, permissions, runtime, license, ...) but publisher, sequence and files, which publish writes`);
|
|
13380
|
+
let value;
|
|
13381
|
+
try {
|
|
13382
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
13383
|
+
} catch (error) {
|
|
13384
|
+
throw new CliError("bad_request", `${path} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
13385
|
+
}
|
|
13386
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${path} is not a JSON object`);
|
|
13387
|
+
const draft = value;
|
|
13388
|
+
const written = WRITTEN.filter((k) => k in draft);
|
|
13389
|
+
if (written.length) throw new CliError("bad_request", `${path} has ${written.join(" and ")}: publish writes ${written.length > 1 ? "them" : "it"} from the key and the folder's files`);
|
|
13390
|
+
if (draft.ghostlyApp !== void 0 && draft.ghostlyApp !== 1) throw refused(`${path} is not a manifest this release makes`, {
|
|
13391
|
+
reason: "unsupported-format",
|
|
13392
|
+
detail: "ghostlyApp is not 1"
|
|
13393
|
+
});
|
|
13394
|
+
if (draft.sequence !== void 0 && !(Number.isSafeInteger(draft.sequence) && draft.sequence >= 1)) throw new CliError("bad_request", `${path}: sequence is a whole number from 1 (or leave it out: publish raises it by itself)`);
|
|
13395
|
+
return draft;
|
|
13396
|
+
}
|
|
13397
|
+
/** Writes a file whole or not at all: a reader never sees half a bundle (and a dot file left by a crash is never bundled). */
|
|
13398
|
+
function writeWhole(path, bytes) {
|
|
13399
|
+
const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
|
|
13400
|
+
writeFileSync(tmp, bytes);
|
|
13401
|
+
renameSync(tmp, path);
|
|
13402
|
+
}
|
|
13403
|
+
/**
|
|
13404
|
+
* `app publish <dir> --key <file> [--out <file>]`: `<dir>/ghostly-app.json` (the manifest without `publisher`,
|
|
13405
|
+
* `sequence` and `files`) and every other file in `<dir>` become one signed bundle, by default `<dir>/app.ghostlyapp`.
|
|
13406
|
+
* The sequence is `--sequence` (higher than the bundle already at `--out`'s), else one more than that bundle's (or
|
|
13407
|
+
* the source's `sequence`, if higher, else 1). The
|
|
13408
|
+
* key file is made when it is missing and no earlier bundle is there to update.
|
|
13409
|
+
*/
|
|
13410
|
+
async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
13411
|
+
if (sequenceArg !== void 0 && !(Number.isSafeInteger(sequenceArg) && sequenceArg >= 1)) throw new CliError("usage", `--sequence takes a whole number from 1, not ${sequenceArg}`);
|
|
13412
|
+
const dir = resolve(dirArg);
|
|
13413
|
+
if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
|
|
13414
|
+
const keyPath = resolve(keyArg);
|
|
13415
|
+
const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
|
|
13416
|
+
const sourcePath = join(dir, APP_SOURCE);
|
|
13417
|
+
const draft = readSource(sourcePath);
|
|
13418
|
+
let previous = null;
|
|
13419
|
+
if (existsSync(out)) {
|
|
13420
|
+
const read = readAppBundle(readBundleFile(out));
|
|
13421
|
+
if (!read.ok) throw refused(`${out} is there and is not a valid bundle: move it away, or name another --out`, read);
|
|
13422
|
+
previous = read.bundle.manifest;
|
|
13423
|
+
}
|
|
13424
|
+
let key;
|
|
13425
|
+
if (existsSync(keyPath)) key = readKey(keyPath, "publisher");
|
|
13426
|
+
else if (previous) throw new CliError("not_found", `No key file ${keyPath}: ${out} is signed by ${appFingerprint(previous.publisher)}, and only that key publishes its next version`);
|
|
13427
|
+
else key = makeKey(keyPath, "publisher");
|
|
13428
|
+
if (previous && previous.publisher !== key.key) throw refused(`${out} is ${appRef(previous.publisher, previous.name)}, signed by another key (${appFingerprint(previous.publisher)}): a version of an app is signed by its own key`, { reason: "other-app" }, { publisher: previous.publisher });
|
|
13429
|
+
if (previous && previous.name !== draft.name) throw refused(`${out} is the app ${JSON.stringify(previous.name)}, not ${JSON.stringify(draft.name)}: name another --out for another app`, { reason: "other-app" });
|
|
13430
|
+
if (sequenceArg !== void 0 && previous && sequenceArg <= previous.sequence) throw refused(`--sequence ${sequenceArg} is not higher than the ${previous.sequence} of ${out}: every client refuses a lower one, and the same one with other content is equivocation`, { reason: sequenceArg < previous.sequence ? "rollback" : "equivocation" }, { previous: previous.sequence });
|
|
13431
|
+
const sequence = sequenceArg ?? Math.max(previous ? previous.sequence + 1 : 1, draft.sequence ?? 1);
|
|
13432
|
+
const { files, skipped } = collectFiles(dir, /* @__PURE__ */ new Set([
|
|
13433
|
+
sourcePath,
|
|
13434
|
+
out,
|
|
13435
|
+
join(dir, APP_REVOKE_FILE)
|
|
13436
|
+
]));
|
|
13437
|
+
const contents = files.map((f) => ({
|
|
13438
|
+
path: f.path,
|
|
13439
|
+
bytes: new Uint8Array(readFileSync(f.full))
|
|
13440
|
+
}));
|
|
13441
|
+
const manifest = {
|
|
13442
|
+
...draft,
|
|
13443
|
+
ghostlyApp: 1,
|
|
13444
|
+
publisher: key.key,
|
|
13445
|
+
sequence,
|
|
13446
|
+
files: contents.map((f) => ({
|
|
13447
|
+
path: f.path,
|
|
13448
|
+
size: f.bytes.length,
|
|
13449
|
+
sha256: toBase64Url$1(createHash("sha256").update(f.bytes).digest())
|
|
13450
|
+
}))
|
|
13451
|
+
};
|
|
13452
|
+
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
|
|
13453
|
+
if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
|
|
13454
|
+
const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
|
|
13455
|
+
const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
|
|
13456
|
+
const read = readAppBundle(bundle);
|
|
13457
|
+
if (!read.ok) throw refused("The bundle made is not valid", read);
|
|
13458
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
13459
|
+
writeWhole(out, bundle);
|
|
13460
|
+
return {
|
|
13461
|
+
...described(read.bundle, bundle),
|
|
13462
|
+
previous: previous?.sequence ?? null,
|
|
13463
|
+
out,
|
|
13464
|
+
files: contents.map((f) => f.path),
|
|
13465
|
+
...skipped.length ? { skipped } : {},
|
|
13466
|
+
keyCreated: key.created,
|
|
13467
|
+
...key.created ? { warning: madeKeyNote(keyPath, "publisher", key.key) } : {}
|
|
13468
|
+
};
|
|
13469
|
+
}
|
|
13470
|
+
/**
|
|
13471
|
+
* `app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]`: a
|
|
13472
|
+
* `ghostly-revoke/1` statement (WISP 1200 · Publisher keys, Revocation) for the app whose bundle is `<dir>/app.ghostlyapp`
|
|
13473
|
+
* (or `--bundle`), signed by that app's publisher key and added to `<dir>/ghostly-revoke.json`, which stays canonical.
|
|
13474
|
+
* The key is never made here: only the key that signed the app may revoke its versions.
|
|
13475
|
+
*/
|
|
13476
|
+
async function revokeApp(dirArg, keyArg, what) {
|
|
13477
|
+
const dir = resolve(dirArg);
|
|
13478
|
+
const bundlePath = resolve(what.bundle ?? join(dir, "app.ghostlyapp"));
|
|
13479
|
+
const listPath = join(dir, APP_REVOKE_FILE);
|
|
13480
|
+
const usage = "ghostly app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]";
|
|
13481
|
+
const digests = what.digests ?? [];
|
|
13482
|
+
if (digests.length > 0 === (what.upTo !== void 0)) throw new CliError("usage", `Give --digest (again for each) or --up-to, one of the two: ${usage}`);
|
|
13483
|
+
if (what.upTo !== void 0 && !(Number.isSafeInteger(what.upTo) && what.upTo >= 1)) throw new CliError("usage", `--up-to takes a sequence, a whole number from 1, not ${what.upTo}`);
|
|
13484
|
+
if (!existsSync(bundlePath)) throw new CliError("not_found", `No bundle ${bundlePath}: a revocation names the app of the bundle in <dir> (or --bundle)`);
|
|
13485
|
+
const read = readAppBundle(readBundleFile(bundlePath));
|
|
13486
|
+
if (!read.ok) throw refused(`${bundlePath} is not a valid bundle`, read);
|
|
13487
|
+
const manifest = read.bundle.manifest;
|
|
13488
|
+
const ref = appRef(manifest.publisher, manifest.name);
|
|
13489
|
+
const keyPath = resolve(keyArg);
|
|
13490
|
+
if (!existsSync(keyPath)) throw new CliError("not_found", `No key file ${keyPath}: only the key that signed ${ref} (${appFingerprint(manifest.publisher)}) revokes its versions`);
|
|
13491
|
+
const key = readKey(keyPath, "publisher");
|
|
13492
|
+
if (key.key !== manifest.publisher) throw refused(`${keyPath} is not the publisher key of ${ref} (${appFingerprint(manifest.publisher)}): a revocation by any other key is refused by every client`, { reason: "signature-key" });
|
|
13493
|
+
const statement = digests.length ? {
|
|
13494
|
+
ghostlyRevoke: 1,
|
|
13495
|
+
app: ref,
|
|
13496
|
+
digests
|
|
13497
|
+
} : {
|
|
13498
|
+
ghostlyRevoke: 1,
|
|
13499
|
+
app: ref,
|
|
13500
|
+
upTo: what.upTo
|
|
13501
|
+
};
|
|
13502
|
+
if (what.reason !== void 0) statement.reason = what.reason;
|
|
13503
|
+
const problem = checkAppRevokeStatement(statement);
|
|
13504
|
+
if (problem) throw refused("Not a revocation a client reads", {
|
|
13505
|
+
reason: "bad-revocation",
|
|
13506
|
+
detail: problem
|
|
13507
|
+
});
|
|
13508
|
+
let list = [];
|
|
13509
|
+
if (existsSync(listPath)) {
|
|
13510
|
+
const held = readAppRevocations(new Uint8Array(readFileSync(listPath)));
|
|
13511
|
+
if (!held.ok) throw refused(`${listPath} is there and is not a valid ghostly-revoke.json: fix or move it first`, held);
|
|
13512
|
+
list = held.revocations;
|
|
13513
|
+
}
|
|
13514
|
+
const signed = await signAppRevocation(statement, key.signer);
|
|
13515
|
+
const same = list.some((r) => canonicalJson(r.statement) === canonicalJson(signed.statement));
|
|
13516
|
+
if (!same) {
|
|
13517
|
+
if (list.length >= APP_STATEMENT_LIMITS.revocations) throw refused(`${listPath} holds ${list.length} revocations already`, {
|
|
13518
|
+
reason: "bad-revocation",
|
|
13519
|
+
detail: `at most ${APP_STATEMENT_LIMITS.revocations}`
|
|
13520
|
+
});
|
|
13521
|
+
list = [...list, signed];
|
|
13522
|
+
const bytes = canonicalJsonBytes(list);
|
|
13523
|
+
const check = readAppRevocations(bytes);
|
|
13524
|
+
if (!check.ok) throw refused("The revocation list made is not valid", check);
|
|
13525
|
+
writeWhole(listPath, bytes);
|
|
13526
|
+
}
|
|
13527
|
+
return {
|
|
13528
|
+
app: ref,
|
|
13529
|
+
...digests.length ? {
|
|
13530
|
+
digests,
|
|
13531
|
+
current: digests.includes(read.bundle.digest)
|
|
13532
|
+
} : {
|
|
13533
|
+
upTo: what.upTo,
|
|
13534
|
+
current: manifest.sequence <= what.upTo
|
|
13535
|
+
},
|
|
13536
|
+
...what.reason !== void 0 ? { reason: what.reason } : {},
|
|
13537
|
+
added: !same,
|
|
13538
|
+
revocations: list.length,
|
|
13539
|
+
file: listPath
|
|
13540
|
+
};
|
|
13541
|
+
}
|
|
13542
|
+
/**
|
|
13543
|
+
* `store sign <index> --key <file> [--out <dir>]`: a store index written by hand (any JSON layout; `key` may be left
|
|
13544
|
+
* out and is then the signing key's) becomes `ghostly-store.json`, canonical, and `ghostly-store.sig`, in `--out` or
|
|
13545
|
+
* beside the index. It is read back as a client reads it (`expires` at most 90 days ahead), and refused when the index
|
|
13546
|
+
* already there, signed by the same key, has a higher `sequence`, or the same one with other content.
|
|
13547
|
+
*/
|
|
13548
|
+
async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() / 1e3)) {
|
|
13549
|
+
const indexPath = resolve(indexArg);
|
|
13550
|
+
const keyPath = resolve(keyArg);
|
|
13551
|
+
const outDir = resolve(outArg ?? dirname(indexPath));
|
|
13552
|
+
let value;
|
|
13553
|
+
try {
|
|
13554
|
+
value = JSON.parse(readFileSync(indexPath, "utf8"));
|
|
13555
|
+
} catch (error) {
|
|
13556
|
+
if (error instanceof SyntaxError) throw new CliError("bad_request", `${indexPath} is not JSON: ${error.message}`);
|
|
13557
|
+
throw error;
|
|
13558
|
+
}
|
|
13559
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError("bad_request", `${indexPath} is not a JSON object`);
|
|
13560
|
+
const index = value;
|
|
13561
|
+
let key;
|
|
13562
|
+
if (existsSync(keyPath)) key = readKey(keyPath, "store");
|
|
13563
|
+
else if (index.key !== void 0) throw new CliError("not_found", `No key file ${keyPath}: the index names the store key ${typeof index.key === "string" ? appFingerprint(index.key) : JSON.stringify(index.key)}`);
|
|
13564
|
+
else key = makeKey(keyPath, "store");
|
|
13565
|
+
if (index.key !== void 0 && index.key !== key.key) throw refused(`${indexPath} names another store key than ${keyPath}'s (${appFingerprint(key.key)})`, { reason: "store-key" });
|
|
13566
|
+
index.key = key.key;
|
|
13567
|
+
const checked = checkAppStoreIndex(index);
|
|
13568
|
+
if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
|
|
13569
|
+
const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
|
|
13570
|
+
const read = readAppStore(indexBytes, sigBytes, now, key.key);
|
|
13571
|
+
if (!read.ok) throw refused(`${indexPath} would be refused by a client`, read);
|
|
13572
|
+
const store = read.store;
|
|
13573
|
+
const indexOut = join(outDir, STORE_INDEX_FILE), sigOut = join(outDir, STORE_SIG_FILE);
|
|
13574
|
+
let previous = null;
|
|
13575
|
+
if (existsSync(indexOut) && existsSync(sigOut)) {
|
|
13576
|
+
const held = readAppStore(new Uint8Array(readFileSync(indexOut)), new Uint8Array(readFileSync(sigOut)), now);
|
|
13577
|
+
if (held.ok && held.store.index.key === key.key) {
|
|
13578
|
+
previous = held.store.index.sequence;
|
|
13579
|
+
const decision = appStoreDecision({
|
|
13580
|
+
key: key.key,
|
|
13581
|
+
sequence: held.store.index.sequence,
|
|
13582
|
+
digest: held.store.digest
|
|
13583
|
+
}, {
|
|
13584
|
+
key: key.key,
|
|
13585
|
+
sequence: store.index.sequence,
|
|
13586
|
+
digest: store.digest
|
|
13587
|
+
});
|
|
13588
|
+
if (decision === "rollback" || decision === "equivocation") throw refused(`${indexOut} already holds sequence ${held.store.index.sequence} of this store: ${decision === "rollback" ? "a lower one is refused by every client" : "the same one with other content is refused as equivocation"}; raise sequence`, { reason: decision }, { held: held.store.index.sequence });
|
|
13589
|
+
}
|
|
13590
|
+
}
|
|
13591
|
+
mkdirSync(outDir, { recursive: true });
|
|
13592
|
+
writeWhole(indexOut, indexBytes);
|
|
13593
|
+
writeWhole(sigOut, sigBytes);
|
|
13594
|
+
const notes = [...key.created ? [madeKeyNote(keyPath, "store", key.key)] : [], ...store.expired ? [`expires (${(/* @__PURE__ */ new Date(store.index.expires * 1e3)).toISOString()}) is past: clients still install from it and say the store was not updated since then`] : []];
|
|
13595
|
+
return {
|
|
13596
|
+
key: key.key,
|
|
13597
|
+
fingerprint: appFingerprint(key.key),
|
|
13598
|
+
name: store.index.name,
|
|
13599
|
+
kind: store.index.kind,
|
|
13600
|
+
sequence: store.index.sequence,
|
|
13601
|
+
previous,
|
|
13602
|
+
expires: store.index.expires,
|
|
13603
|
+
expired: store.expired,
|
|
13604
|
+
apps: store.index.apps.length,
|
|
13605
|
+
removed: store.index.removed.length,
|
|
13606
|
+
revoked: store.index.revoked.length,
|
|
13607
|
+
digest: store.digest,
|
|
13608
|
+
index: indexOut,
|
|
13609
|
+
sig: sigOut,
|
|
13610
|
+
keyCreated: key.created,
|
|
13611
|
+
...notes.length ? { warning: notes.join("; ") } : {}
|
|
13612
|
+
};
|
|
13613
|
+
}
|
|
13614
|
+
//#endregion
|
|
13615
|
+
//#region src/commands/apps.ts
|
|
13616
|
+
/** The key file a command signs with: always named, never a default, never on the command line itself. */
|
|
13617
|
+
function keyFile(value, usage) {
|
|
13618
|
+
if (typeof value !== "string" || !value) throw new CliError("usage", `--key <file> is needed: ghostly ${usage}`);
|
|
13619
|
+
return value;
|
|
13620
|
+
}
|
|
13621
|
+
var key = {
|
|
13622
|
+
type: "string",
|
|
13623
|
+
description: "The key file (made, owner-only, when it is missing)"
|
|
13624
|
+
};
|
|
13625
|
+
/**
|
|
13626
|
+
* The publisher tools of WISP 1200 (Apps · Publishing): they run here, on files, with no profile or daemon. One entry
|
|
13627
|
+
* per command, in alphabetical order (test/commands.test.ts checks).
|
|
13628
|
+
*/
|
|
13629
|
+
var commands$8 = {
|
|
13630
|
+
"app publish": {
|
|
13631
|
+
method: "app.publish",
|
|
13632
|
+
usage: "app publish <dir> --key <file> [--out <file>]",
|
|
13633
|
+
summary: "Bundle and sign an app (WISP 1200): <dir>/ghostly-app.json and every other file in <dir> become one .ghostlyapp, its sequence one more than the bundle at --out (default <dir>/app.ghostlyapp)",
|
|
13634
|
+
args: ["dir"],
|
|
13635
|
+
options: {
|
|
13636
|
+
key: {
|
|
13637
|
+
...key,
|
|
13638
|
+
description: "The publisher key file (made, owner-only, on first use; back it up)"
|
|
13639
|
+
},
|
|
13640
|
+
out: {
|
|
13641
|
+
type: "string",
|
|
13642
|
+
description: "Where the bundle goes (default <dir>/app.ghostlyapp); the bundle there is the version this one follows"
|
|
13643
|
+
},
|
|
13644
|
+
sequence: {
|
|
13645
|
+
type: "number",
|
|
13646
|
+
description: "This version's sequence, higher than the bundle at --out's (default: one more than it)"
|
|
13647
|
+
}
|
|
13648
|
+
},
|
|
13649
|
+
run: ({ options }, a) => publishApp(a.dir, keyFile(options.key, "app publish <dir> --key <file> [--out <file>] [--sequence n]"), options.out, options.sequence)
|
|
13650
|
+
},
|
|
13651
|
+
"app revoke": {
|
|
13652
|
+
method: "app.revoke",
|
|
13653
|
+
usage: "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>] [--bundle <file>]",
|
|
13654
|
+
summary: "Revoke versions of your app (WISP 1200 · Revocation): a ghostly-revoke/1 statement signed with its publisher key, added to <dir>/ghostly-revoke.json",
|
|
13655
|
+
args: ["dir"],
|
|
13656
|
+
options: {
|
|
13657
|
+
key: {
|
|
13658
|
+
type: "string",
|
|
13659
|
+
description: "The app's publisher key file (never made here)"
|
|
13660
|
+
},
|
|
13661
|
+
digest: {
|
|
13662
|
+
type: "list",
|
|
13663
|
+
description: "A version's digest, base64url as app verify prints it (again for each, up to 64)"
|
|
13664
|
+
},
|
|
13665
|
+
"up-to": {
|
|
13666
|
+
type: "number",
|
|
13667
|
+
description: "Every version with this sequence or a lower one"
|
|
13668
|
+
},
|
|
13669
|
+
reason: {
|
|
13670
|
+
type: "string",
|
|
13671
|
+
description: "Why, one line of up to 200 characters, shown to people"
|
|
13672
|
+
},
|
|
13673
|
+
bundle: {
|
|
13674
|
+
type: "string",
|
|
13675
|
+
description: "The bundle naming the app (default <dir>/app.ghostlyapp)"
|
|
13676
|
+
}
|
|
13677
|
+
},
|
|
13678
|
+
run: ({ options }, a) => revokeApp(a.dir, keyFile(options.key, "app revoke <dir> --key <file> (--digest <digest>... | --up-to <sequence>) [--reason <text>]"), {
|
|
13679
|
+
digests: options.digest,
|
|
13680
|
+
upTo: options["up-to"],
|
|
13681
|
+
reason: options.reason,
|
|
13682
|
+
bundle: options.bundle
|
|
13683
|
+
})
|
|
13684
|
+
},
|
|
13685
|
+
"app verify": {
|
|
13686
|
+
method: "app.verify",
|
|
13687
|
+
usage: "app verify <bundle|url>",
|
|
13688
|
+
summary: "Check a .ghostlyapp as a client would, from a file or an https URL: its manifest and digest, or refused (exit 1) with the reason",
|
|
13689
|
+
args: ["bundle"],
|
|
13690
|
+
run: (_, a) => verifyApp(a.bundle)
|
|
13691
|
+
},
|
|
13692
|
+
"store sign": {
|
|
13693
|
+
method: "store.sign",
|
|
13694
|
+
usage: "store sign <index> --key <file> [--out <dir>]",
|
|
13695
|
+
summary: "Sign a store index (WISP 1200 · Stores): writes ghostly-store.json, canonical, and ghostly-store.sig beside it or in --out",
|
|
13696
|
+
args: ["index"],
|
|
13697
|
+
options: {
|
|
13698
|
+
key: {
|
|
13699
|
+
...key,
|
|
13700
|
+
description: "The store key file (made, owner-only, when it is missing and the index names no key; keep it offline)"
|
|
13701
|
+
},
|
|
13702
|
+
out: {
|
|
13703
|
+
type: "string",
|
|
13704
|
+
description: "The folder to write ghostly-store.json and ghostly-store.sig in (default: the index's)"
|
|
13705
|
+
}
|
|
13706
|
+
},
|
|
13707
|
+
run: ({ options }, a) => signStore(a.index, keyFile(options.key, "store sign <index> --key <file> [--out <dir>]"), options.out)
|
|
13708
|
+
}
|
|
13709
|
+
};
|
|
13710
|
+
//#endregion
|
|
12209
13711
|
//#region src/commands/shared.ts
|
|
12210
13712
|
var wait = {
|
|
12211
13713
|
wait: {
|
|
@@ -12556,6 +14058,82 @@ var taskOptions = {
|
|
|
12556
14058
|
},
|
|
12557
14059
|
...cardOptions
|
|
12558
14060
|
};
|
|
14061
|
+
/**
|
|
14062
|
+
* `--also week=80` or `--also week=80@2026-10-10T00:00:00Z`: another window of a usage card, its percent left and, after
|
|
14063
|
+
* `@`, when it resets.
|
|
14064
|
+
*/
|
|
14065
|
+
function usageWindowOf(value) {
|
|
14066
|
+
const match = /^([^=]+)=(\d+(?:\.\d+)?)(?:@(.+))?$/.exec(value.trim());
|
|
14067
|
+
if (!match) throw new CliError("usage", `--also takes window=percent or window=percent@time, like week=80@2026-10-10T00:00:00Z, not ${JSON.stringify(value)}`);
|
|
14068
|
+
const resetsAt = cardTime(match[3], "also");
|
|
14069
|
+
return {
|
|
14070
|
+
window: match[1].trim(),
|
|
14071
|
+
left: Number(match[2]),
|
|
14072
|
+
...resetsAt !== void 0 && { resetsAt }
|
|
14073
|
+
};
|
|
14074
|
+
}
|
|
14075
|
+
/** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
|
|
14076
|
+
function usageFields(options) {
|
|
14077
|
+
const fields = { ...cardJson(options.json) };
|
|
14078
|
+
for (const key of [
|
|
14079
|
+
"id",
|
|
14080
|
+
"left",
|
|
14081
|
+
"used",
|
|
14082
|
+
"limit",
|
|
14083
|
+
"label",
|
|
14084
|
+
"account",
|
|
14085
|
+
"window"
|
|
14086
|
+
]) if (options[key] !== void 0) fields[key] = options[key];
|
|
14087
|
+
const resets = cardTime(options.resets, "resets");
|
|
14088
|
+
if (resets !== void 0) fields.resetsAt = resets;
|
|
14089
|
+
if (Array.isArray(options.also) && options.also.length) fields.windows = options.also.map(usageWindowOf);
|
|
14090
|
+
return fields;
|
|
14091
|
+
}
|
|
14092
|
+
/** Options `usage send` takes. */
|
|
14093
|
+
var usageOptions = {
|
|
14094
|
+
left: {
|
|
14095
|
+
type: "number",
|
|
14096
|
+
description: "Percent of the quota left, 0 to 100"
|
|
14097
|
+
},
|
|
14098
|
+
used: {
|
|
14099
|
+
type: "number",
|
|
14100
|
+
description: "How much is used, with --limit (instead of --left; the percent is worked out)"
|
|
14101
|
+
},
|
|
14102
|
+
limit: {
|
|
14103
|
+
type: "number",
|
|
14104
|
+
description: "The quota, with --used"
|
|
14105
|
+
},
|
|
14106
|
+
label: {
|
|
14107
|
+
type: "string",
|
|
14108
|
+
description: "What the quota is of, like Claude (24 characters)"
|
|
14109
|
+
},
|
|
14110
|
+
account: {
|
|
14111
|
+
type: "string",
|
|
14112
|
+
description: "Which of your accounts it is, short (24 characters)"
|
|
14113
|
+
},
|
|
14114
|
+
window: {
|
|
14115
|
+
type: "string",
|
|
14116
|
+
description: "The quota's window, as people say it: \"5 h\", \"week\" (16 characters)"
|
|
14117
|
+
},
|
|
14118
|
+
resets: {
|
|
14119
|
+
type: "string",
|
|
14120
|
+
description: "When the window starts again: milliseconds or a date like 2026-10-07T18:00:00Z"
|
|
14121
|
+
},
|
|
14122
|
+
also: {
|
|
14123
|
+
type: "list",
|
|
14124
|
+
description: "Another window, window=percent[@time], like week=80@2026-10-10T00:00:00Z (up to 3; shown in the details)"
|
|
14125
|
+
},
|
|
14126
|
+
id: {
|
|
14127
|
+
type: "string",
|
|
14128
|
+
description: "The card's id (default usage: one card per chat)"
|
|
14129
|
+
},
|
|
14130
|
+
all: {
|
|
14131
|
+
type: "boolean",
|
|
14132
|
+
description: "Every 1:1 chat with a contact, instead of one chat"
|
|
14133
|
+
},
|
|
14134
|
+
json: cardOptions.json,
|
|
14135
|
+
text: cardOptions.text
|
|
14136
|
+
};
|
|
12559
14137
|
//#endregion
|
|
12560
14138
|
//#region src/commands/calls.ts
|
|
12561
14139
|
/** Voice calls: one entry per command, in alphabetical order (test/commands.test.ts checks). */
|
|
@@ -13054,6 +14632,27 @@ var commands$6 = {
|
|
|
13054
14632
|
status: options.status,
|
|
13055
14633
|
for: options.for
|
|
13056
14634
|
})
|
|
14635
|
+
},
|
|
14636
|
+
"usage send": {
|
|
14637
|
+
method: "usage.send",
|
|
14638
|
+
usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--wait none|sent] [--timeout s]",
|
|
14639
|
+
summary: "Say how much of your quota is left (a bot's usage card, one per chat, updated in place); --all for every 1:1 chat",
|
|
14640
|
+
args: ["chat?"],
|
|
14641
|
+
options: {
|
|
14642
|
+
...usageOptions,
|
|
14643
|
+
...groupWait
|
|
14644
|
+
},
|
|
14645
|
+
params: ({ options }, { chat }) => {
|
|
14646
|
+
if (!chat && options.all !== true) throw new CliError("usage", "Name a chat or group, or give --all: ghostly usage send <chat|--all> --left <percent>");
|
|
14647
|
+
return {
|
|
14648
|
+
chat,
|
|
14649
|
+
all: options.all === true,
|
|
14650
|
+
card: usageFields(options),
|
|
14651
|
+
text: options.text,
|
|
14652
|
+
wait: options.wait,
|
|
14653
|
+
timeout: options.timeout
|
|
14654
|
+
};
|
|
14655
|
+
}
|
|
13057
14656
|
}
|
|
13058
14657
|
};
|
|
13059
14658
|
//#endregion
|
|
@@ -13063,7 +14662,7 @@ var commands$5 = {
|
|
|
13063
14662
|
"file accept": {
|
|
13064
14663
|
method: "file.action",
|
|
13065
14664
|
usage: "file accept [<chat>] <file>",
|
|
13066
|
-
summary: "Take a file the contact offers",
|
|
14665
|
+
summary: "Take a file the contact offers, or download a group's file that did not come by itself (<chat> may be the group)",
|
|
13067
14666
|
args: ["chat?", "file"],
|
|
13068
14667
|
params: (_, a) => ({
|
|
13069
14668
|
chat: a.chat,
|
|
@@ -13095,8 +14694,8 @@ var commands$5 = {
|
|
|
13095
14694
|
},
|
|
13096
14695
|
"file list": {
|
|
13097
14696
|
method: "file.list",
|
|
13098
|
-
usage: "file list <chat>",
|
|
13099
|
-
summary: "A chat's files and their transfers",
|
|
14697
|
+
usage: "file list <chat|group>",
|
|
14698
|
+
summary: "A chat's or a group's files and their transfers",
|
|
13100
14699
|
args: ["chat"],
|
|
13101
14700
|
params: (_, { chat }) => ({ chat })
|
|
13102
14701
|
},
|
|
@@ -13114,7 +14713,7 @@ var commands$5 = {
|
|
|
13114
14713
|
"file request": {
|
|
13115
14714
|
method: "file.action",
|
|
13116
14715
|
usage: "file request [<chat>] <file>",
|
|
13117
|
-
summary: "Ask again for a file that stopped arriving: it goes on from what is here",
|
|
14716
|
+
summary: "Ask again for a file that stopped arriving: it goes on from what is here (a group's file: asked of whoever holds it)",
|
|
13118
14717
|
args: ["chat?", "file"],
|
|
13119
14718
|
params: (_, a) => ({
|
|
13120
14719
|
chat: a.chat,
|
|
@@ -13183,13 +14782,13 @@ var commands$5 = {
|
|
|
13183
14782
|
},
|
|
13184
14783
|
"file send": {
|
|
13185
14784
|
method: "file.send",
|
|
13186
|
-
usage: "file send <chat> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
|
|
13187
|
-
summary:
|
|
14785
|
+
usage: "file send <chat|group> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
|
|
14786
|
+
summary: `Send a file (or, with --voice, a voice note), as a reply with --reply; to a group, every member's app fetches it (up to ${GROUP_FILE_LIMITS.maxBytes / 1024 / 1024} MiB)`,
|
|
13188
14787
|
args: ["chat", "path"],
|
|
13189
14788
|
options: {
|
|
13190
14789
|
name: {
|
|
13191
14790
|
type: "string",
|
|
13192
|
-
description: "The name the contact sees"
|
|
14791
|
+
description: "The name the contact (or the group) sees"
|
|
13193
14792
|
},
|
|
13194
14793
|
mime: {
|
|
13195
14794
|
type: "string",
|
|
@@ -14055,7 +15654,8 @@ var COMMANDS = {
|
|
|
14055
15654
|
...commands$1,
|
|
14056
15655
|
...commands$2,
|
|
14057
15656
|
...commands,
|
|
14058
|
-
...commands$7
|
|
15657
|
+
...commands$7,
|
|
15658
|
+
...commands$8
|
|
14059
15659
|
};
|
|
14060
15660
|
/** Commands that take text (argument or stdin), with the secret guard and delivery waits. */
|
|
14061
15661
|
var TEXT_COMMANDS = {
|
|
@@ -14668,10 +16268,11 @@ function loadCallStack() {
|
|
|
14668
16268
|
}
|
|
14669
16269
|
/**
|
|
14670
16270
|
* The ICE servers of a call, as libdatachannel takes them: the apps' STUN servers (RTC_CONFIG; libjuice uses the
|
|
14671
|
-
* first), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
|
|
16271
|
+
* first; none with `GHOSTLY_STUN=0`), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
|
|
16272
|
+
* apps' calls use them.
|
|
14672
16273
|
*/
|
|
14673
|
-
function callIceServers(extra = []) {
|
|
14674
|
-
const out = (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
|
|
16274
|
+
function callIceServers(extra = [], env = process.env) {
|
|
16275
|
+
const out = env.GHOSTLY_STUN === "0" ? [] : (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
|
|
14675
16276
|
for (const server of callRtcConfig(extra).iceServers?.slice(RTC_CONFIG.iceServers?.length ?? 0) ?? []) for (const url of [server.urls].flat()) {
|
|
14676
16277
|
const m = /^(stun|turns?):([^:?\s]+)(?::(\d+))?(?:\?transport=(udp|tcp))?$/i.exec(url);
|
|
14677
16278
|
if (!m) continue;
|
|
@@ -15734,8 +17335,11 @@ var EventHub = class {
|
|
|
15734
17335
|
const shape = `${transfer.state}|${transfer.stage ?? ""}`, before = this.transfers.get(id);
|
|
15735
17336
|
this.transfers.set(id, shape);
|
|
15736
17337
|
if (quiet || before === shape) continue;
|
|
17338
|
+
const chat = state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null;
|
|
17339
|
+
const group = chat ? void 0 : state.groups.find((g) => id.startsWith(`group-${g.id}-`))?.id;
|
|
15737
17340
|
const fields = {
|
|
15738
|
-
chat
|
|
17341
|
+
chat,
|
|
17342
|
+
...group ? { group } : {},
|
|
15739
17343
|
file: id,
|
|
15740
17344
|
direction: transfer.direction ?? (id.includes("-in-") ? "in" : "out"),
|
|
15741
17345
|
size: transfer.size,
|
|
@@ -16050,6 +17654,203 @@ function groupShape(group) {
|
|
|
16050
17654
|
};
|
|
16051
17655
|
}
|
|
16052
17656
|
//#endregion
|
|
17657
|
+
//#region ../browser/src/platform/irohWeb.ts
|
|
17658
|
+
/**
|
|
17659
|
+
* Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
|
|
17660
|
+
* Desktop's native Iroh, but relay only. A page cannot send UDP, so every
|
|
17661
|
+
* packet goes through an Iroh relay over a WebSocket; the relay sees who talks
|
|
17662
|
+
* to whom and when, never the frames.
|
|
17663
|
+
*
|
|
17664
|
+
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
17665
|
+
* never on the app's first load.
|
|
17666
|
+
*/
|
|
17667
|
+
/**
|
|
17668
|
+
* n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
|
|
17669
|
+
* dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
|
|
17670
|
+
* because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
|
|
17671
|
+
*/
|
|
17672
|
+
var DEFAULT_IROH_RELAYS = [
|
|
17673
|
+
"https://use1-1.relay.n0.iroh.link/",
|
|
17674
|
+
"https://euc1-1.relay.n0.iroh.link/",
|
|
17675
|
+
"https://aps1-1.relay.n0.iroh.link/",
|
|
17676
|
+
"https://usw1-1.relay.n0.iroh.link/"
|
|
17677
|
+
];
|
|
17678
|
+
/**
|
|
17679
|
+
* The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
|
|
17680
|
+
* relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
|
|
17681
|
+
* open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
|
|
17682
|
+
*/
|
|
17683
|
+
var BROWSER_SPELLING = "plain";
|
|
17684
|
+
/**
|
|
17685
|
+
* Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
|
|
17686
|
+
* may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
|
|
17687
|
+
*/
|
|
17688
|
+
function irohRelayProblem(value) {
|
|
17689
|
+
let url;
|
|
17690
|
+
try {
|
|
17691
|
+
url = new URL(value);
|
|
17692
|
+
} catch {
|
|
17693
|
+
return `Not a relay address: ${value}`;
|
|
17694
|
+
}
|
|
17695
|
+
const local = [
|
|
17696
|
+
"localhost",
|
|
17697
|
+
"127.0.0.1",
|
|
17698
|
+
"[::1]"
|
|
17699
|
+
].includes(url.hostname);
|
|
17700
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return `Use an https:// relay address: ${value}`;
|
|
17701
|
+
if (url.username || url.password || url.search || url.hash || value.length > 256) return `Not a relay address: ${value}`;
|
|
17702
|
+
return null;
|
|
17703
|
+
}
|
|
17704
|
+
var loaded = null;
|
|
17705
|
+
/** Loads the wasm once per page. Tests pass their own `load` (Node reads the file). */
|
|
17706
|
+
function loadIrohWasm() {
|
|
17707
|
+
loaded ??= import("./assets/ghostly_iroh_web-Dvzh3Y6E.js").then(async (module) => {
|
|
17708
|
+
await module.default();
|
|
17709
|
+
return module;
|
|
17710
|
+
}).catch((error) => {
|
|
17711
|
+
loaded = null;
|
|
17712
|
+
throw error;
|
|
17713
|
+
});
|
|
17714
|
+
return loaded;
|
|
17715
|
+
}
|
|
17716
|
+
var SEND_BUDGET = 122880;
|
|
17717
|
+
var MAX_FRAME = 61440;
|
|
17718
|
+
/** One paired channel over one QUIC stream. Sends are queued in order. */
|
|
17719
|
+
var IrohWebChannel = class {
|
|
17720
|
+
conn;
|
|
17721
|
+
forget;
|
|
17722
|
+
reader = null;
|
|
17723
|
+
buffered = [];
|
|
17724
|
+
queue = Promise.resolve();
|
|
17725
|
+
closed = false;
|
|
17726
|
+
bufferedAmount = 0;
|
|
17727
|
+
sendBudget = SEND_BUDGET;
|
|
17728
|
+
onClose = null;
|
|
17729
|
+
constructor(conn, forget) {
|
|
17730
|
+
this.conn = conn;
|
|
17731
|
+
this.forget = forget;
|
|
17732
|
+
this.read();
|
|
17733
|
+
}
|
|
17734
|
+
get onMessage() {
|
|
17735
|
+
return this.reader;
|
|
17736
|
+
}
|
|
17737
|
+
set onMessage(reader) {
|
|
17738
|
+
this.reader = reader;
|
|
17739
|
+
if (reader) for (const text of this.buffered.splice(0)) reader(text);
|
|
17740
|
+
}
|
|
17741
|
+
async read() {
|
|
17742
|
+
try {
|
|
17743
|
+
for (;;) {
|
|
17744
|
+
const text = await this.conn.recv();
|
|
17745
|
+
if (text === void 0 || this.closed) break;
|
|
17746
|
+
if (this.reader) this.reader(text);
|
|
17747
|
+
else if (this.buffered.length < 64) this.buffered.push(text);
|
|
17748
|
+
else break;
|
|
17749
|
+
}
|
|
17750
|
+
} catch {}
|
|
17751
|
+
this.close();
|
|
17752
|
+
}
|
|
17753
|
+
send(data) {
|
|
17754
|
+
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");
|
|
17755
|
+
this.bufferedAmount += data.length;
|
|
17756
|
+
this.queue = this.queue.then(() => this.closed ? void 0 : this.conn.send(data)).catch(() => this.close()).finally(() => {
|
|
17757
|
+
this.bufferedAmount -= data.length;
|
|
17758
|
+
});
|
|
17759
|
+
}
|
|
17760
|
+
drained() {
|
|
17761
|
+
return this.queue;
|
|
17762
|
+
}
|
|
17763
|
+
get rttMs() {
|
|
17764
|
+
const rtt = this.conn.rtt();
|
|
17765
|
+
return rtt >= 0 ? rtt : void 0;
|
|
17766
|
+
}
|
|
17767
|
+
close() {
|
|
17768
|
+
if (this.closed) return;
|
|
17769
|
+
this.closed = true;
|
|
17770
|
+
this.buffered = [];
|
|
17771
|
+
try {
|
|
17772
|
+
this.conn.close();
|
|
17773
|
+
} catch {}
|
|
17774
|
+
this.forget();
|
|
17775
|
+
this.onClose?.();
|
|
17776
|
+
}
|
|
17777
|
+
};
|
|
17778
|
+
/** A NativeEndpoint for `iroh/1`, as the Desktop's is, for one chat's transport seed. */
|
|
17779
|
+
async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
17780
|
+
const seed = fromBase64Url$1(seedB64);
|
|
17781
|
+
if (seed.length !== 32) throw new Error("Invalid transport seed");
|
|
17782
|
+
const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
|
|
17783
|
+
const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
|
|
17784
|
+
const channels = /* @__PURE__ */ new Set();
|
|
17785
|
+
let stopped = false;
|
|
17786
|
+
let handler = null;
|
|
17787
|
+
const early = [];
|
|
17788
|
+
const bind = (conn) => {
|
|
17789
|
+
const channel = new IrohWebChannel(conn, () => channels.delete(channel));
|
|
17790
|
+
channels.add(channel);
|
|
17791
|
+
return {
|
|
17792
|
+
channel,
|
|
17793
|
+
binding: conn.binding()
|
|
17794
|
+
};
|
|
17795
|
+
};
|
|
17796
|
+
const endpoint = {
|
|
17797
|
+
transport: "iroh/1",
|
|
17798
|
+
descriptor: {
|
|
17799
|
+
...node.address(),
|
|
17800
|
+
relayed: true
|
|
17801
|
+
},
|
|
17802
|
+
onDescriptor: null,
|
|
17803
|
+
get onConnection() {
|
|
17804
|
+
return handler;
|
|
17805
|
+
},
|
|
17806
|
+
set onConnection(value) {
|
|
17807
|
+
handler = value;
|
|
17808
|
+
if (value) for (const bound of early.splice(0)) value(bound);
|
|
17809
|
+
},
|
|
17810
|
+
async connect(descriptor) {
|
|
17811
|
+
if (stopped) throw new Error("Iroh endpoint is stopped");
|
|
17812
|
+
const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
|
|
17813
|
+
if (stopped) {
|
|
17814
|
+
conn.close();
|
|
17815
|
+
throw new Error("Iroh endpoint is stopped");
|
|
17816
|
+
}
|
|
17817
|
+
return bind(conn);
|
|
17818
|
+
},
|
|
17819
|
+
async close() {
|
|
17820
|
+
if (stopped) return;
|
|
17821
|
+
stopped = true;
|
|
17822
|
+
for (const channel of [...channels]) channel.close();
|
|
17823
|
+
for (const bound of early.splice(0)) bound.channel.close();
|
|
17824
|
+
await node.close().catch(() => {});
|
|
17825
|
+
}
|
|
17826
|
+
};
|
|
17827
|
+
(async () => {
|
|
17828
|
+
while (!stopped) {
|
|
17829
|
+
let conn;
|
|
17830
|
+
try {
|
|
17831
|
+
conn = await node.accept();
|
|
17832
|
+
} catch {
|
|
17833
|
+
break;
|
|
17834
|
+
}
|
|
17835
|
+
if (!conn) break;
|
|
17836
|
+
if (stopped) {
|
|
17837
|
+
conn.close();
|
|
17838
|
+
break;
|
|
17839
|
+
}
|
|
17840
|
+
const bound = bind(conn);
|
|
17841
|
+
const accept = endpoint.onConnection;
|
|
17842
|
+
if (accept) accept(bound);
|
|
17843
|
+
else if (early.length < 4) early.push(bound);
|
|
17844
|
+
else bound.channel.close();
|
|
17845
|
+
}
|
|
17846
|
+
if (!stopped) {
|
|
17847
|
+
endpoint.onUnavailable?.();
|
|
17848
|
+
await endpoint.close();
|
|
17849
|
+
}
|
|
17850
|
+
})();
|
|
17851
|
+
return endpoint;
|
|
17852
|
+
}
|
|
17853
|
+
//#endregion
|
|
16053
17854
|
//#region ../browser/src/engine/paymentAdapters/fedimintSdk.ts
|
|
16054
17855
|
var fedimintDatabase = (id) => `ghostly-fedimint-${id}.db`;
|
|
16055
17856
|
var MODULE_WASM = () => import("./assets/fedimint_client_wasm_bg-BtHHlFpp.js");
|
|
@@ -16936,6 +18737,39 @@ function pkarrRelays(env = process.env) {
|
|
|
16936
18737
|
return relays;
|
|
16937
18738
|
}
|
|
16938
18739
|
/**
|
|
18740
|
+
* The Iroh relays of a private network or a test, `GHOSTLY_IROH_RELAYS` ("https://…,…"; http:// on loopback): the only
|
|
18741
|
+
* relays this process's Iroh homes on, whatever the profile's `irohRelays` setting says. Null when unset (the setting,
|
|
18742
|
+
* else n0's public relays).
|
|
18743
|
+
*/
|
|
18744
|
+
function irohRelays(env = process.env) {
|
|
18745
|
+
const relays = (env.GHOSTLY_IROH_RELAYS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
|
|
18746
|
+
if (!relays.length) return null;
|
|
18747
|
+
const bad = relays.map((url) => irohRelayProblem(url)).find(Boolean);
|
|
18748
|
+
if (bad) throw new CliError("usage", `GHOSTLY_IROH_RELAYS: ${bad}`);
|
|
18749
|
+
return relays;
|
|
18750
|
+
}
|
|
18751
|
+
/**
|
|
18752
|
+
* The apps' public STUN servers (Google's, `RTC_CONFIG`), which every WebRTC connection asks for its public address:
|
|
18753
|
+
* `GHOSTLY_STUN=0` leaves them out (a private network, or tests on one machine, where host candidates connect). The
|
|
18754
|
+
* profile's own `iceServers` stay.
|
|
18755
|
+
*/
|
|
18756
|
+
function publicStun(env = process.env) {
|
|
18757
|
+
return env.GHOSTLY_STUN !== "0";
|
|
18758
|
+
}
|
|
18759
|
+
/** An RTC configuration without the apps' public STUN servers. */
|
|
18760
|
+
function withoutPublicStun(config) {
|
|
18761
|
+
if (!config?.iceServers) return config;
|
|
18762
|
+
const ours = new Set((RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()));
|
|
18763
|
+
const iceServers = config.iceServers.map((server) => ({
|
|
18764
|
+
...server,
|
|
18765
|
+
urls: [server.urls].flat().filter((url) => !ours.has(url))
|
|
18766
|
+
})).filter((server) => server.urls.length);
|
|
18767
|
+
return {
|
|
18768
|
+
...config,
|
|
18769
|
+
iceServers
|
|
18770
|
+
};
|
|
18771
|
+
}
|
|
18772
|
+
/**
|
|
16939
18773
|
* The Mainline DHT, for Pkarr beside the relays (`RelaysAndDht`): `GHOSTLY_DHT=0` leaves it out (relays only, as the web
|
|
16940
18774
|
* app), `GHOSTLY_DHT_BOOTSTRAP` ("host:port,…") replaces the public routers (a testnet; one all on loopback binds there).
|
|
16941
18775
|
* `GHOSTLY_PKARR_RELAYS` without a bootstrap leaves it out too, as on the Desktop: a private network stays off the
|
|
@@ -16971,12 +18805,43 @@ async function installWebRtc() {
|
|
|
16971
18805
|
"RTCPeerConnectionIceEvent",
|
|
16972
18806
|
"RTCCertificate"
|
|
16973
18807
|
]) scope[name] ??= polyfill[name];
|
|
18808
|
+
refuseDataAnswers(scope);
|
|
18809
|
+
if (!publicStun()) leaveOutPublicStun(scope);
|
|
16974
18810
|
return true;
|
|
16975
18811
|
} catch (error) {
|
|
16976
18812
|
process.stderr.write(`ghostly: WebRTC is unavailable (${error instanceof Error ? error.message : String(error)}); chats use HyperDHT, Iroh or the DHT\n`);
|
|
16977
18813
|
return false;
|
|
16978
18814
|
}
|
|
16979
18815
|
}
|
|
18816
|
+
/**
|
|
18817
|
+
* For tests: `GHOSTLY_TEST_REFUSE_DATA_ANSWERS=<n>` refuses one in every n answers this process applies to its offers
|
|
18818
|
+
* (the first, the n+1st, and so on), as libdatachannel 0.24.5 refuses one in its race (`ANSWER_REFUSED_REDIALS` in
|
|
18819
|
+
* packages/core/src/datalink.ts): the connection closes, and setRemoteDescription throws.
|
|
18820
|
+
*/
|
|
18821
|
+
function refuseDataAnswers(scope) {
|
|
18822
|
+
const every = Number(process.env.GHOSTLY_TEST_REFUSE_DATA_ANSWERS) || 0;
|
|
18823
|
+
if (every <= 0) return;
|
|
18824
|
+
let answers = 0;
|
|
18825
|
+
const Base = scope.RTCPeerConnection;
|
|
18826
|
+
scope.RTCPeerConnection = class extends Base {
|
|
18827
|
+
async setRemoteDescription(description) {
|
|
18828
|
+
if (description.type === "answer" && answers++ % every === 0) {
|
|
18829
|
+
this.close();
|
|
18830
|
+
throw new Error("libdatachannel error while adding remote description: Got a remote candidate without ICE transport (GHOSTLY_TEST_REFUSE_DATA_ANSWERS)");
|
|
18831
|
+
}
|
|
18832
|
+
return super.setRemoteDescription(description);
|
|
18833
|
+
}
|
|
18834
|
+
};
|
|
18835
|
+
}
|
|
18836
|
+
/** `GHOSTLY_STUN=0`: every connection the engine makes leaves the apps' public STUN servers out of its configuration. */
|
|
18837
|
+
function leaveOutPublicStun(scope) {
|
|
18838
|
+
const Base = scope.RTCPeerConnection;
|
|
18839
|
+
scope.RTCPeerConnection = class extends Base {
|
|
18840
|
+
constructor(config) {
|
|
18841
|
+
super(withoutPublicStun(config));
|
|
18842
|
+
}
|
|
18843
|
+
};
|
|
18844
|
+
}
|
|
16980
18845
|
/** Server-sent events, which browsers have and Node does not: Arkade follows its server with them. */
|
|
16981
18846
|
async function installEventSource() {
|
|
16982
18847
|
const scope = globalThis;
|
|
@@ -17017,13 +18882,17 @@ function leftOutIn(mark) {
|
|
|
17017
18882
|
}
|
|
17018
18883
|
async function startRuntime(paths, options = {}) {
|
|
17019
18884
|
const pinned = pkarrRelays();
|
|
18885
|
+
const pinnedIroh = irohRelays();
|
|
17020
18886
|
const trace = process.env.GHOSTLY_LINK_TRACE;
|
|
17021
|
-
if (trace)
|
|
18887
|
+
if (trace) {
|
|
18888
|
+
setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
|
|
18889
|
+
(await import("./assets/joinTrace-CO8mlhNO.js").then((n) => n.t)).setJoinTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
|
|
18890
|
+
}
|
|
17022
18891
|
const store = await openPersistentIndexedDb(paths.db);
|
|
17023
18892
|
const restored = join(paths.dir, RESTORED_MARK);
|
|
17024
18893
|
if (existsSync(restored)) {
|
|
17025
18894
|
try {
|
|
17026
|
-
const rows = await import("./assets/restoredRows-
|
|
18895
|
+
const rows = await import("./assets/restoredRows-lhTKGmCO.js");
|
|
17027
18896
|
await rows.markRestoredWallet();
|
|
17028
18897
|
await rows.markLeftOutFiles(leftOutIn(restored));
|
|
17029
18898
|
await store.compact();
|
|
@@ -17042,19 +18911,20 @@ async function startRuntime(paths, options = {}) {
|
|
|
17042
18911
|
await installEventSource();
|
|
17043
18912
|
await preloadWalletModules();
|
|
17044
18913
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
17045
|
-
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-
|
|
18914
|
+
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-3AQ2tJzW.js").then((n) => n.o), import("./assets/fileBytes-CAZDydIe.js")]);
|
|
17046
18915
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
17047
|
-
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-
|
|
18916
|
+
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-HMzdsyn_.js").then((n) => n.t), import("./assets/endpoint-D_3bYafG.js")]);
|
|
17048
18917
|
const network = hyperdhtNetwork();
|
|
17049
18918
|
const dht = mainlineNetwork();
|
|
17050
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
18919
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-DQxDSWXm.js"))).Mainline({
|
|
17051
18920
|
bootstrap: dht.bootstrap,
|
|
17052
18921
|
host: dht.host
|
|
17053
18922
|
});
|
|
17054
|
-
const transport = mainline || pinned ? new (await (import("./assets/mainline-
|
|
18923
|
+
const transport = mainline || pinned ? new (await (import("./assets/mainline-DQxDSWXm.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
|
|
17055
18924
|
const server = new EngineServer({
|
|
17056
18925
|
...transport ? { transport } : {},
|
|
17057
18926
|
irohWeb: true,
|
|
18927
|
+
...pinnedIroh ? { irohRelays: pinnedIroh } : {},
|
|
17058
18928
|
singleDevice: true,
|
|
17059
18929
|
nativeTransports: { "hyperdht/1": (seedB64) => createHyperEndpoint(fromBase64Url$1(seedB64), network) },
|
|
17060
18930
|
automaticWallets: false,
|
|
@@ -17574,6 +19444,7 @@ async function warnVersion(client) {
|
|
|
17574
19444
|
*/
|
|
17575
19445
|
function chatOnly(method, params) {
|
|
17576
19446
|
if (method.startsWith("task.") || method.startsWith("routine.") || method.startsWith("button.")) return false;
|
|
19447
|
+
if (method === "usage.send") return params.all === true;
|
|
17577
19448
|
return method.startsWith("chat.") || params.chat !== void 0 && params.group === void 0 && !method.startsWith("group.");
|
|
17578
19449
|
}
|
|
17579
19450
|
async function session(g, chat = false) {
|
|
@@ -18448,6 +20319,15 @@ async function tableCommand(name, argv) {
|
|
|
18448
20319
|
const g = globals(parsed);
|
|
18449
20320
|
pretty = g.pretty;
|
|
18450
20321
|
const args = positionals(command, parsed.positionals);
|
|
20322
|
+
if (command.run) {
|
|
20323
|
+
const result = await command.run(parsed, args);
|
|
20324
|
+
if (typeof result.warning === "string") {
|
|
20325
|
+
process.stderr.write(`ghostly: ${result.warning}\n`);
|
|
20326
|
+
delete result.warning;
|
|
20327
|
+
}
|
|
20328
|
+
print(result);
|
|
20329
|
+
return;
|
|
20330
|
+
}
|
|
18451
20331
|
const params = command.params?.(parsed, args) ?? {};
|
|
18452
20332
|
for (const key of Object.keys(params)) if (params[key] === void 0) delete params[key];
|
|
18453
20333
|
if (command.method === "identity.complete") {
|
|
@@ -18623,6 +20503,6 @@ async function run(argv) {
|
|
|
18623
20503
|
//#region src/bin.ts
|
|
18624
20504
|
run(process.argv.slice(2));
|
|
18625
20505
|
//#endregion
|
|
18626
|
-
export {
|
|
20506
|
+
export { BACKUP_MEDIA_TYPE as $, requireTurn as A, paymentWireId as At, networkLabel as B, breezDatabasesFor as C, storedGroupFiles as Ct, sourceKey as D, replyRef as Dt, ProviderSources as E, receivedPairedReply as Et, sealSeed as F, streamStored as Ft, BITCOIN_INVOICE_ON_TESTNET as G, cashuMint as H, unsealSeed as I, fakesLightning as J, DEFAULT_MINTS as K, ModeChanged as L, setSingleWriterGate as M, readStored as Mt, intentRepository as N, removeStored as Nt, awayFrom as O, replyTo as Ot, newDeviceKey as P, storedSize as Pt, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, groupOfFile as St, lightningPaying as T, pairedWireReply as Tt, MESSAGE_DETAILS_MAX_BYTES as U, CASHU_MINT_SOURCE as V, WALLET_TYPES as W, isWorthlessMint as X, isTestMint as Y, mintNetwork as Z, LightningCards as _, verifyIdentity as _t, irohRelayProblem as a, reserveAdapterIds as at, normalizePhrase as b, groupFileId as bt, createFailure as c, SourceConfigError as ct, paymentNetwork as d, networkMode as dt, MAX_BACKUP_BYTES as et, paymentNetworksOf as f, redact as ft, CASHU_CARD as g, boundedIdentityFetch as gt, rpcHost as h, setOwnDidSource as ht, createIrohWebEndpoint as i, registeredOnchainProviders as it, setAwayWallets as j, FileAppender as jt, refuseAway as k, paymentAlias as kt, createTiming as l, SourceUnreachableError as lt, networkReason as m, resolveAtprotoDid as mt, loadFedimintSdk as n, onAdaptersChanged as nt, WALLET_NAMES as o, NothingSpentError as ot, walletInstances as p, assertPublicServer as pt, TEST_MINT as q, DEFAULT_IROH_RELAYS as r, registeredLightningProviders as rt, assertConfirmedReal as s, PROVIDER_PLATFORMS as st, fedimintDatabase as t, identityProviders as tt, crossNetwork as u, isNothingSpentError as ut, cardsKey as v, chosenResolver as vt, dropBreezDatabase as w, groupReply as wt, breezDatabase as x, groupFileText as xt, isRecoveryPhrase as y, GroupFileDesk as yt, WrongNetworkError as z };
|
|
18627
20507
|
|
|
18628
20508
|
//# sourceMappingURL=ghostly.mjs.map
|