@ghostlytools/cli 1.1.7 → 1.2.0
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 +16 -11
- package/SKILL.md +2 -2
- package/dist/assets/arkade-3szZ1IpP.js +362 -0
- package/dist/assets/arkade-3szZ1IpP.js.map +1 -0
- package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
- package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
- package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
- package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
- package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
- package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
- package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-DmTrpmba.js} +3 -3
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-DmTrpmba.js.map} +1 -1
- package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
- package/dist/assets/idb-CTF6LF5B.js.map +1 -0
- package/dist/assets/{mainline-B7wHcLaW.js → mainline-BueFJx3f.js} +2 -2
- package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-BueFJx3f.js.map} +1 -1
- package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
- package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
- package/dist/assets/{server-GJW_5wGI.js → server-BGXmSYx_.js} +1124 -1036
- package/dist/assets/server-BGXmSYx_.js.map +1 -0
- package/dist/assets/{src-Cp3o_yHN.js → src-BMUcYQ7b.js} +750 -242
- package/dist/assets/src-BMUcYQ7b.js.map +1 -0
- package/dist/assets/usdt-DklGBSeq.js +410 -0
- package/dist/assets/usdt-DklGBSeq.js.map +1 -0
- package/dist/ghostly.mjs +858 -137
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
- package/dist/assets/idb-BIdus0WX.js.map +0 -1
- package/dist/assets/server-GJW_5wGI.js.map +0 -1
- package/dist/assets/src-Cp3o_yHN.js.map +0 -1
package/dist/ghostly.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
|
|
3
|
-
import { $ as
|
|
4
|
-
import { c as fileStore,
|
|
5
|
-
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-
|
|
3
|
+
import { $ as SSHSIG_NAMESPACE, $a as readAppRevocations, $n as engineError, An as encodeDnsQuery, Ao as signalHasVideo, At as didJwsAlg, Bn as resolveLightningDestination, Bt as parseDidSignaturePaste, Cn as homeserverWebEndpoint, Co as RTC_CONFIG, Ct as DID_DOCUMENT_MAX_BYTES$1, Dn as dnsProofName, Do as callSignalHeardAt, Dt as checkDidJws, Ec as setLinkTraceSink, En as decodeDnsResponse, Eo as callRtcConfig, Et as DidError, Fa as RUN_RESULTS, Fc as openRelayPayload, Fn as recordsFromWellKnown, Ft as didWebFileUrl, Gc as fromBase64Url$1, Hc as MAX_AVATAR_LENGTH, Ht as staticDidDocument, Ia as STATUS_CARD_LIMITS, Ii as identityStatement, In as recordsName, It as didWebHost, Ja as appFingerprint, Jc as toZ32, Jt as resolveDidDht, Ka as APP_PREFIXES, Kc as randomBytes$1, Ki as irohDescriptor, Kr as sanitizeTypingStatus, La as buttonLabelClash, Ln as txtValue, Lt as normalizeDid, Ma as replySnippet, Mc as RELAY_PAYLOAD_MAX_BYTES, Mn as nip05RootKey, Mt as didWebDocumentUrl, Ni as boundVerifiedIdentity, Nn as normalizeDomain, Nt as didWebFile, On as domainTxtRecord, Oo as extractParamsFromSdp, Ot as checkDidSignature, Pa as ITEM_STATES, Pn as recordsFromTxt, Pt as didWebFileNames, Ra as checkStatusCard, Rn as wellKnownUrl, Rt as parseDid, Sn as pubkyProofPath, So as CALL_SIGNAL_MAX_AGE_MS, St as verifyAtprotoRecordProof, Tn as DOMAIN_FILE_MAX_BYTES, To as buildSdpFromSignal, Uc as sanitizeAvatar, Ur as TYPING_KINDS, Ut as strictBase64Url, Vn as decodeBolt11, Vr as transferEnded, Vt as signingKeys, Wr as TYPING_REFRESH_MS, Xa as checkAppRevokeStatement, Xc as utf8Encode, Ya as appRef, Yc as utf8Decode, Ys as inviteLink, Z as groupName, _ as readAppBundle, _n as isPubkyProofFolder, _t as isAtprotoDid, aa as PLAYABLE_AUDIO, ar as fetchesByItself, at as verifySshSignature, bn as pubkyProofCapability, bt as shortAtprotoDid, c as signAppStore, ca as fileMessageText, ci as checkPushEndpoint, ct as decodeAnyBitcoinAddress, dr as readGroupWantNo, dt as ATPROTO_PROOF_COLLECTION, ea as readImageMeta, eo as signAppObject, er as engineText, et as SshSigError, ft as ATPROTO_PROOF_SCOPE, g as isAppUrl, gn as isPubkyKey, gt as checkAtprotoProofRecord, h as checkAppManifest, hn as PUBKY_PROOF_MAX_BYTES, ho as GhostlyHttpError, ht as atprotoProofRkey, ia as sanitizeMime, in as WALLET_NETWORKS, ir as GROUP_WANT_NO_FRAME, it as sshSignCommand, ji as IdentityCheckUnavailable, jn as isPublicAddress, jt as didSignedMessages, kn as domainWellKnownFile, ko as parseCallSignal, kt as didGhostlyServiceEntry, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, ln as walletNetworkOf, lr as readGroupHave, lt as ATPROTO_DECLARED_SCOPE, m as assembleAppBundle, ma as RateWindow, mt as atprotoProofRecord, n as appStoreDecision, no as canonicalJson, nr as GROUP_HAVE_FRAME, nt as parseSshSignature, o as checkAppStoreIndex, oa as VOICE_LIMITS, oc as seedSigner, or as groupFileFallback, ot as verifyBitcoinMessage, pi as BUTTONS_CAPABILITY, pt as atprotoDidMethod, qa as APP_STATEMENT_LIMITS, qc as toBase64Url, qi as irohRelayUrl, ra as sanitizeFileName, ro as canonicalJsonBytes, rt as sameSshKey, s as readAppStore, sa as baseMime, sr as isGroupMessageId, st as BitcoinAddressError, to as signAppRevocation, tr as GROUP_FILE_LIMITS, tt as parseSshPublicKey, ua as voicePeaksOf, ur as readGroupWant, ut as ATPROTO_FULL_SCOPE, vn as newPubkyProofFolder, vt as normalizeAtprotoHandle, wn as pubkyHomeserverOf, wo as answersOffer, wt as DID_FILE_MAX_BYTES, xn as pubkyProofFile, xt as AtprotoProofError, yn as normalizePubkyKey, yo as LIMITS, yt as parseAtprotoDidDocument, zn as requestLnurlInvoice, zr as ChatFiles, zt as parseDidDocument } from "./assets/src-BMUcYQ7b.js";
|
|
4
|
+
import { c as fileStore, f as store$1, i as STORES, m as wrap, o as databaseName, p as transact, u as openDb } from "./assets/idb-CTF6LF5B.js";
|
|
5
|
+
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-BcYZhNgF.js";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { connect, createServer, isIP } from "node:net";
|
|
@@ -12,8 +12,8 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
12
12
|
import { createBase58check } from "@scure/base";
|
|
13
13
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
14
14
|
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
15
|
-
import { link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
-
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { decode, npubEncode } from "nostr-tools/nip19";
|
|
18
18
|
import { BunkerSigner } from "nostr-tools/nip46";
|
|
19
19
|
import { SimplePool } from "nostr-tools/pool";
|
|
@@ -35,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
|
|
|
35
35
|
import { format } from "node:util";
|
|
36
36
|
var package_default = {
|
|
37
37
|
name: "@ghostlytools/cli",
|
|
38
|
-
version: "1.
|
|
38
|
+
version: "1.2.0",
|
|
39
39
|
type: "module",
|
|
40
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.",
|
|
41
41
|
license: "MIT",
|
|
@@ -63,7 +63,7 @@ var package_default = {
|
|
|
63
63
|
dependencies: {
|
|
64
64
|
"@arkade-os/sdk": "0.4.78",
|
|
65
65
|
"@atproto/jwk-webcrypto": "^0.3.4",
|
|
66
|
-
"@atproto/oauth-client": "^0.8.
|
|
66
|
+
"@atproto/oauth-client": "^0.8.9",
|
|
67
67
|
"@bitcoindevkit/bdk-wallet-web": "0.3.0",
|
|
68
68
|
"@bitcoinerlab/descriptors-scure": "^3.2.0",
|
|
69
69
|
"@cashu/cashu-ts": "^4.11.0",
|
|
@@ -87,7 +87,7 @@ var package_default = {
|
|
|
87
87
|
"ethers": "6.17.0",
|
|
88
88
|
"eventsource": "^5.1.2",
|
|
89
89
|
"fake-indexeddb": "6.2.5",
|
|
90
|
-
"hyperdht": "6.34.
|
|
90
|
+
"hyperdht": "6.34.1",
|
|
91
91
|
"keet-identity-key": "3.2.0",
|
|
92
92
|
"mpg123-decoder": "1.0.3",
|
|
93
93
|
"node-datachannel": "^0.33.4",
|
|
@@ -101,7 +101,7 @@ var package_default = {
|
|
|
101
101
|
"@ghostly/iroh-web": "*",
|
|
102
102
|
"@types/node": "^26.6.4",
|
|
103
103
|
"typescript": "^5.9.3",
|
|
104
|
-
"vite": "^8.3.
|
|
104
|
+
"vite": "^8.3.3",
|
|
105
105
|
"vitest": "^5.0.3"
|
|
106
106
|
}
|
|
107
107
|
};
|
|
@@ -176,6 +176,7 @@ function localFileError(error) {
|
|
|
176
176
|
if (code === "ENOENT" || code === "ENOTDIR") return new CliError("not_found", `No such file or folder: ${where}`);
|
|
177
177
|
if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new CliError("refused", `Not allowed to write or read ${where}`);
|
|
178
178
|
if (code === "EISDIR") return new CliError("bad_request", `${where} is a folder: name a file`);
|
|
179
|
+
if (code === "ENAMETOOLONG") return new CliError("bad_request", `A name in ${where} is too long for this file system (at most 255 bytes)`);
|
|
179
180
|
}
|
|
180
181
|
//#endregion
|
|
181
182
|
//#region src/args.ts
|
|
@@ -282,6 +283,12 @@ function parseArgs(argv, spec, idSlot = () => false) {
|
|
|
282
283
|
options
|
|
283
284
|
};
|
|
284
285
|
}
|
|
286
|
+
/** The global option a word names (`--profile`, `--profile=bot`, `-p`), else undefined. */
|
|
287
|
+
function globalOption(arg) {
|
|
288
|
+
const eq = arg.indexOf("=");
|
|
289
|
+
const name = arg.startsWith("--") ? arg.slice(2, eq === -1 ? void 0 : eq) : arg.length === 2 && arg.startsWith("-") ? Object.entries(GLOBAL_OPTIONS).find(([, o]) => o.short === arg.slice(1))?.[0] : void 0;
|
|
290
|
+
return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? GLOBAL_OPTIONS[name] : void 0;
|
|
291
|
+
}
|
|
285
292
|
/**
|
|
286
293
|
* Global options given before the command (`ghostly --profile bot send …`) move after it, where every command's
|
|
287
294
|
* parser reads them. Stops at `--` and at the first word that is not a global option.
|
|
@@ -292,8 +299,7 @@ function liftGlobals(argv) {
|
|
|
292
299
|
while (i < argv.length) {
|
|
293
300
|
const arg = argv[i];
|
|
294
301
|
const eq = arg.indexOf("=");
|
|
295
|
-
const
|
|
296
|
-
const option = name ? GLOBAL_OPTIONS[name] : void 0;
|
|
302
|
+
const option = globalOption(arg);
|
|
297
303
|
if (!option || arg === "--") break;
|
|
298
304
|
const takesValue = option.type !== "boolean" && eq === -1;
|
|
299
305
|
lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
|
|
@@ -675,6 +681,7 @@ var ENGINE_METHODS = [
|
|
|
675
681
|
"joinGroupByLink",
|
|
676
682
|
"sendGroupMessage",
|
|
677
683
|
"sendGroupFile",
|
|
684
|
+
"groupFileCheck",
|
|
678
685
|
"pressButton",
|
|
679
686
|
"groupMessages",
|
|
680
687
|
"messagePage",
|
|
@@ -1043,7 +1050,7 @@ function groupReply(reply, myKey) {
|
|
|
1043
1050
|
//#endregion
|
|
1044
1051
|
//#region ../browser/src/engine/groupFiles.ts
|
|
1045
1052
|
/** 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
|
|
1053
|
+
var groupFileId = (groupId, direction, random = toBase64Url(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
|
|
1047
1054
|
/** The group a local file id names, among `groupIds`. */
|
|
1048
1055
|
var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
|
|
1049
1056
|
/** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
|
|
@@ -1051,23 +1058,28 @@ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessa
|
|
|
1051
1058
|
/** The profile's files store and file storage, as a 1:1 chat's files use them. */
|
|
1052
1059
|
var storedGroupFiles = {
|
|
1053
1060
|
list: (groupId) => fileStore.listForLink(`group:${groupId}`),
|
|
1061
|
+
groups: () => fileStore.groupIds(),
|
|
1054
1062
|
get: (id) => fileStore.get(id),
|
|
1055
1063
|
put: (file) => fileStore.put(file),
|
|
1056
1064
|
patch: (id, fields) => fileStore.patch(id, fields),
|
|
1057
1065
|
remove: (id) => removeStored(id),
|
|
1058
|
-
async write(id) {
|
|
1066
|
+
async write(id, resume = false) {
|
|
1059
1067
|
const stored = await fileStore.get(id);
|
|
1060
1068
|
const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
|
|
1061
|
-
|
|
1069
|
+
const size = await bytes.size(id);
|
|
1070
|
+
const offset = resume && size !== null ? size : 0;
|
|
1071
|
+
if (size !== null && size !== offset) await bytes.truncate(id, offset);
|
|
1062
1072
|
if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
|
|
1063
|
-
const appender = new FileAppender(bytes, id,
|
|
1073
|
+
const appender = new FileAppender(bytes, id, offset);
|
|
1064
1074
|
return {
|
|
1075
|
+
offset,
|
|
1065
1076
|
append: (chunk) => appender.append(chunk),
|
|
1066
1077
|
flush: () => appender.flush(),
|
|
1067
1078
|
digest: async () => {
|
|
1068
1079
|
await appender.close();
|
|
1069
1080
|
return bytes.digest(id);
|
|
1070
1081
|
},
|
|
1082
|
+
close: () => appender.close(),
|
|
1071
1083
|
discard: async () => {
|
|
1072
1084
|
await appender.close().catch(() => {});
|
|
1073
1085
|
await bytes.remove(id);
|
|
@@ -1077,8 +1089,14 @@ var storedGroupFiles = {
|
|
|
1077
1089
|
read: (file, offset, length) => readStored(file, offset, length),
|
|
1078
1090
|
room: async () => (await fileBytes()).room()
|
|
1079
1091
|
};
|
|
1080
|
-
/** Why a want waits, as the person reads it. */
|
|
1081
|
-
var
|
|
1092
|
+
/** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
|
|
1093
|
+
var WAITING_NOTES = {
|
|
1094
|
+
nobody: "Nobody you are connected to has this file yet",
|
|
1095
|
+
busy: "Its holders are busy; asking again soon",
|
|
1096
|
+
damaged: "It arrived damaged; ask again"
|
|
1097
|
+
};
|
|
1098
|
+
/** Why a want stopped: every holder served bytes that are not the author's file. */
|
|
1099
|
+
var DAMAGED$1 = "Every copy of this file arrived damaged and was deleted. Ask for it again later.";
|
|
1082
1100
|
var GroupFileDesk = class {
|
|
1083
1101
|
deps;
|
|
1084
1102
|
kept = /* @__PURE__ */ new Map();
|
|
@@ -1090,6 +1108,7 @@ var GroupFileDesk = class {
|
|
|
1090
1108
|
own = /* @__PURE__ */ new Map();
|
|
1091
1109
|
/** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
|
|
1092
1110
|
sentPace = /* @__PURE__ */ new Map();
|
|
1111
|
+
sentTaken = /* @__PURE__ */ new Map();
|
|
1093
1112
|
takenPace = /* @__PURE__ */ new Map();
|
|
1094
1113
|
/** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
|
|
1095
1114
|
ahead = /* @__PURE__ */ new Map();
|
|
@@ -1126,7 +1145,8 @@ var GroupFileDesk = class {
|
|
|
1126
1145
|
auto: !!file.group.auto,
|
|
1127
1146
|
asked: !!file.group.asked,
|
|
1128
1147
|
held: file.transfer?.state === "done",
|
|
1129
|
-
holders: []
|
|
1148
|
+
holders: [],
|
|
1149
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1130
1150
|
};
|
|
1131
1151
|
this.add(kept);
|
|
1132
1152
|
if (kept.held) this.show(kept, {
|
|
@@ -1135,6 +1155,14 @@ var GroupFileDesk = class {
|
|
|
1135
1155
|
size,
|
|
1136
1156
|
direction: kept.mine ? "out" : "in"
|
|
1137
1157
|
});
|
|
1158
|
+
else if (file.transfer?.state === "failed") this.show(kept, {
|
|
1159
|
+
state: "failed",
|
|
1160
|
+
transferred: 0,
|
|
1161
|
+
size,
|
|
1162
|
+
direction: "in",
|
|
1163
|
+
retry: true,
|
|
1164
|
+
error: file.transfer.error ?? DAMAGED$1
|
|
1165
|
+
});
|
|
1138
1166
|
else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
|
|
1139
1167
|
else this.show(kept, {
|
|
1140
1168
|
state: "transferring",
|
|
@@ -1147,9 +1175,27 @@ var GroupFileDesk = class {
|
|
|
1147
1175
|
for (const [id] of this.wants) this.ask(this.kept.get(id));
|
|
1148
1176
|
this.deps.changed();
|
|
1149
1177
|
}
|
|
1150
|
-
/**
|
|
1178
|
+
/**
|
|
1179
|
+
* At start, once every group was read: the files of a group this device no longer has (`groupIds` are the groups it
|
|
1180
|
+
* has, a tombstone or a join under way among them) go, with their bytes. A group left before Leave took its files
|
|
1181
|
+
* left them behind, where nothing lists them.
|
|
1182
|
+
*/
|
|
1183
|
+
async sweep(groupIds) {
|
|
1184
|
+
const here = new Set(groupIds);
|
|
1185
|
+
for (const groupId of await this.deps.store.groups().catch(() => [])) {
|
|
1186
|
+
if (here.has(groupId)) continue;
|
|
1187
|
+
for (const file of await this.deps.store.list(groupId).catch(() => [])) await this.deps.store.remove(file.id).catch(() => {});
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
|
|
1192
|
+
* one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
|
|
1193
|
+
*/
|
|
1151
1194
|
mayAnnounce(groupId) {
|
|
1152
|
-
|
|
1195
|
+
const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
|
|
1196
|
+
if (!wait) return null;
|
|
1197
|
+
const minutes = Math.ceil(wait / 6e4);
|
|
1198
|
+
return `You sent many files to this group just now. Wait ${minutes > 1 ? `${minutes} minutes` : "a minute"}.`;
|
|
1153
1199
|
}
|
|
1154
1200
|
/** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
|
|
1155
1201
|
outgoing(groupId, fileId, meta) {
|
|
@@ -1170,7 +1216,7 @@ var GroupFileDesk = class {
|
|
|
1170
1216
|
const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
|
|
1171
1217
|
if (ownId) {
|
|
1172
1218
|
this.own.delete(`${groupId}\n${m.file.d}`);
|
|
1173
|
-
this.
|
|
1219
|
+
for (const pace of this.sentPaces(groupId)) pace.take();
|
|
1174
1220
|
const kept = {
|
|
1175
1221
|
id: ownId,
|
|
1176
1222
|
groupId,
|
|
@@ -1181,7 +1227,8 @@ var GroupFileDesk = class {
|
|
|
1181
1227
|
auto: false,
|
|
1182
1228
|
asked: false,
|
|
1183
1229
|
held: true,
|
|
1184
|
-
holders: []
|
|
1230
|
+
holders: [],
|
|
1231
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1185
1232
|
};
|
|
1186
1233
|
this.add(kept);
|
|
1187
1234
|
const transfer = {
|
|
@@ -1218,7 +1265,8 @@ var GroupFileDesk = class {
|
|
|
1218
1265
|
auto,
|
|
1219
1266
|
asked: false,
|
|
1220
1267
|
held: false,
|
|
1221
|
-
holders
|
|
1268
|
+
holders,
|
|
1269
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1222
1270
|
};
|
|
1223
1271
|
const { name, size, mime, voice, video, image } = m.file;
|
|
1224
1272
|
this.add(kept);
|
|
@@ -1271,9 +1319,18 @@ var GroupFileDesk = class {
|
|
|
1271
1319
|
kept.asked = true;
|
|
1272
1320
|
await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
|
|
1273
1321
|
}
|
|
1322
|
+
kept.damaged.clear();
|
|
1323
|
+
if (this.deps.transfers.get(kept.id)?.state === "failed") await this.deps.store.patch(kept.id, { transfer: {
|
|
1324
|
+
state: "transferring",
|
|
1325
|
+
transferred: 0,
|
|
1326
|
+
size: kept.meta.size
|
|
1327
|
+
} }).catch(() => {});
|
|
1274
1328
|
const want = this.wants.get(kept.id);
|
|
1275
1329
|
if (!want) this.wants.set(kept.id, this.newWant());
|
|
1276
|
-
else if (!want.wire)
|
|
1330
|
+
else if (!want.wire) {
|
|
1331
|
+
want.tried.clear();
|
|
1332
|
+
want.why = void 0;
|
|
1333
|
+
}
|
|
1277
1334
|
this.ask(kept);
|
|
1278
1335
|
}
|
|
1279
1336
|
/** The group is gone from this device: its files go too. */
|
|
@@ -1348,6 +1405,7 @@ var GroupFileDesk = class {
|
|
|
1348
1405
|
}
|
|
1349
1406
|
edge.taking.set(frame.id, kept.id);
|
|
1350
1407
|
want.wire = frame.id;
|
|
1408
|
+
want.why = void 0;
|
|
1351
1409
|
want.movedAt = this.deps.now();
|
|
1352
1410
|
}
|
|
1353
1411
|
return edge.files.handle(frame);
|
|
@@ -1371,9 +1429,13 @@ var GroupFileDesk = class {
|
|
|
1371
1429
|
want.wire = void 0;
|
|
1372
1430
|
}
|
|
1373
1431
|
if (want.holder) want.tried.add(want.holder);
|
|
1374
|
-
else
|
|
1432
|
+
else {
|
|
1433
|
+
want.tried.clear();
|
|
1434
|
+
want.why = void 0;
|
|
1435
|
+
}
|
|
1375
1436
|
this.ask(kept);
|
|
1376
1437
|
}
|
|
1438
|
+
this.letGo();
|
|
1377
1439
|
for (const edge of this.edgesById.values()) {
|
|
1378
1440
|
if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
|
|
1379
1441
|
this.sendHave(edge, [...edge.have]);
|
|
@@ -1415,10 +1477,20 @@ var GroupFileDesk = class {
|
|
|
1415
1477
|
if (!want || kept.held || want.wire) return;
|
|
1416
1478
|
const membership = this.deps.membership(kept.groupId);
|
|
1417
1479
|
const edges = this.deps.edges(kept.groupId);
|
|
1418
|
-
const
|
|
1480
|
+
const known = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key));
|
|
1481
|
+
if (known.length && known.every((key) => kept.damaged.has(key))) {
|
|
1482
|
+
this.stopDamaged(kept);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const order = known.filter((key) => !want.tried.has(key) && !kept.damaged.has(key));
|
|
1486
|
+
let behind = false;
|
|
1419
1487
|
for (const key of order) {
|
|
1420
1488
|
const linkId = edges.get(key);
|
|
1421
1489
|
if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
|
|
1490
|
+
if (this.askedOf(linkId, want)) {
|
|
1491
|
+
behind = true;
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1422
1494
|
if (!this.deps.send(linkId, {
|
|
1423
1495
|
t: "group-want",
|
|
1424
1496
|
g: kept.groupId,
|
|
@@ -1435,7 +1507,7 @@ var GroupFileDesk = class {
|
|
|
1435
1507
|
this.show(kept, {
|
|
1436
1508
|
state: "transferring",
|
|
1437
1509
|
stage: "queued",
|
|
1438
|
-
transferred: 0,
|
|
1510
|
+
transferred: kept.stored ?? 0,
|
|
1439
1511
|
size: kept.meta.size,
|
|
1440
1512
|
direction: "in"
|
|
1441
1513
|
});
|
|
@@ -1446,13 +1518,50 @@ var GroupFileDesk = class {
|
|
|
1446
1518
|
linkId: void 0,
|
|
1447
1519
|
at: this.deps.now()
|
|
1448
1520
|
});
|
|
1521
|
+
if (behind) {
|
|
1522
|
+
this.show(kept, {
|
|
1523
|
+
state: "transferring",
|
|
1524
|
+
stage: "queued",
|
|
1525
|
+
transferred: kept.stored ?? 0,
|
|
1526
|
+
size: kept.meta.size,
|
|
1527
|
+
direction: "in"
|
|
1528
|
+
});
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const wait = want.why ?? "nobody";
|
|
1449
1532
|
this.show(kept, {
|
|
1450
1533
|
state: "transferring",
|
|
1451
1534
|
stage: "waiting",
|
|
1452
|
-
transferred: 0,
|
|
1535
|
+
transferred: kept.stored ?? 0,
|
|
1453
1536
|
size: kept.meta.size,
|
|
1454
1537
|
direction: "in",
|
|
1455
|
-
|
|
1538
|
+
wait,
|
|
1539
|
+
note: WAITING_NOTES[wait],
|
|
1540
|
+
...wait === "damaged" && { stalled: true }
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
/** Another file is asked of, or coming from, the member at the other end of this edge. */
|
|
1544
|
+
askedOf(linkId, except) {
|
|
1545
|
+
for (const want of this.wants.values()) if (want !== except && want.linkId === linkId) return true;
|
|
1546
|
+
return false;
|
|
1547
|
+
}
|
|
1548
|
+
/** No holder serves the author's bytes: the want stops, and stays stopped after a restart, until Download. */
|
|
1549
|
+
stopDamaged(kept) {
|
|
1550
|
+
this.wants.delete(kept.id);
|
|
1551
|
+
const { size } = kept.meta;
|
|
1552
|
+
this.deps.store.patch(kept.id, { transfer: {
|
|
1553
|
+
state: "failed",
|
|
1554
|
+
transferred: 0,
|
|
1555
|
+
size,
|
|
1556
|
+
error: DAMAGED$1
|
|
1557
|
+
} }).catch(() => {});
|
|
1558
|
+
this.show(kept, {
|
|
1559
|
+
state: "failed",
|
|
1560
|
+
transferred: 0,
|
|
1561
|
+
size,
|
|
1562
|
+
direction: "in",
|
|
1563
|
+
retry: true,
|
|
1564
|
+
error: DAMAGED$1
|
|
1456
1565
|
});
|
|
1457
1566
|
}
|
|
1458
1567
|
/** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
|
|
@@ -1461,9 +1570,11 @@ var GroupFileDesk = class {
|
|
|
1461
1570
|
const want = kept && this.wants.get(kept.id);
|
|
1462
1571
|
if (!kept || !want || want.linkId !== linkId || want.wire) return;
|
|
1463
1572
|
if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
|
|
1573
|
+
if (why === "busy") want.why = "busy";
|
|
1464
1574
|
want.tried.add(peer);
|
|
1465
1575
|
want.holder = want.linkId = void 0;
|
|
1466
1576
|
this.ask(kept);
|
|
1577
|
+
this.retry(groupId);
|
|
1467
1578
|
}
|
|
1468
1579
|
/** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
|
|
1469
1580
|
heard(groupId, from, ids) {
|
|
@@ -1499,8 +1610,12 @@ var GroupFileDesk = class {
|
|
|
1499
1610
|
});
|
|
1500
1611
|
return;
|
|
1501
1612
|
}
|
|
1502
|
-
const wire = toBase64Url
|
|
1613
|
+
const wire = toBase64Url(randomBytes$1(12));
|
|
1503
1614
|
edge.serving.set(wire, kept.id);
|
|
1615
|
+
edge.servedMoved.set(wire, {
|
|
1616
|
+
confirmed: 0,
|
|
1617
|
+
at: this.deps.now()
|
|
1618
|
+
});
|
|
1504
1619
|
const { name, mime, size, voice, video, image } = kept.meta;
|
|
1505
1620
|
const file = {
|
|
1506
1621
|
id: wire,
|
|
@@ -1524,8 +1639,13 @@ var GroupFileDesk = class {
|
|
|
1524
1639
|
if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
|
|
1525
1640
|
if (!kept) return "gone";
|
|
1526
1641
|
if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
|
|
1642
|
+
this.letGo();
|
|
1527
1643
|
const active = this.servingNow();
|
|
1528
|
-
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce
|
|
1644
|
+
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce) {
|
|
1645
|
+
edge.busy.add(messageId);
|
|
1646
|
+
return "busy";
|
|
1647
|
+
}
|
|
1648
|
+
if (active.some((a) => a.peer === edge.peer && a.sending)) return "busy";
|
|
1529
1649
|
const today = Math.floor(this.deps.now() / 864e5);
|
|
1530
1650
|
if (this.served.day !== today) this.served = {
|
|
1531
1651
|
day: today,
|
|
@@ -1534,18 +1654,37 @@ var GroupFileDesk = class {
|
|
|
1534
1654
|
const reserved = active.reduce((sum, a) => sum + a.size, 0);
|
|
1535
1655
|
return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
|
|
1536
1656
|
}
|
|
1537
|
-
/** Transfers served now, all groups together: to whom, and
|
|
1657
|
+
/** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
|
|
1538
1658
|
servingNow() {
|
|
1539
1659
|
const active = [];
|
|
1540
1660
|
for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
|
|
1541
1661
|
const record = edge.files.get("out", wire);
|
|
1542
1662
|
if (record && !transferEnded(record)) active.push({
|
|
1543
1663
|
peer: edge.peer,
|
|
1544
|
-
size: record.file.size
|
|
1664
|
+
size: record.file.size,
|
|
1665
|
+
sending: record.confirmed < record.file.size
|
|
1545
1666
|
});
|
|
1546
1667
|
}
|
|
1547
1668
|
return active;
|
|
1548
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
|
|
1672
|
+
* session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
|
|
1673
|
+
* on by then).
|
|
1674
|
+
*/
|
|
1675
|
+
letGo() {
|
|
1676
|
+
const now = this.deps.now();
|
|
1677
|
+
for (const edge of this.edgesById.values()) for (const wire of [...edge.serving.keys()]) {
|
|
1678
|
+
const record = edge.files.get("out", wire);
|
|
1679
|
+
if (!record || transferEnded(record)) continue;
|
|
1680
|
+
let moved = edge.servedMoved.get(wire);
|
|
1681
|
+
if (!moved || moved.confirmed !== record.confirmed) edge.servedMoved.set(wire, moved = {
|
|
1682
|
+
confirmed: record.confirmed,
|
|
1683
|
+
at: now
|
|
1684
|
+
});
|
|
1685
|
+
if (!edge.files.live || !this.deps.membership(edge.groupId)?.inRoster(edge.peer) || now - moved.at >= GROUP_FILE_LIMITS.wantWaitMs) edge.files.cancel("out", wire);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1549
1688
|
/** Says on an edge which files this device holds. */
|
|
1550
1689
|
sendHave(edge, ids) {
|
|
1551
1690
|
for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
|
|
@@ -1579,9 +1718,11 @@ var GroupFileDesk = class {
|
|
|
1579
1718
|
peer,
|
|
1580
1719
|
files: void 0,
|
|
1581
1720
|
serving: /* @__PURE__ */ new Map(),
|
|
1721
|
+
servedMoved: /* @__PURE__ */ new Map(),
|
|
1582
1722
|
taking: /* @__PURE__ */ new Map(),
|
|
1583
1723
|
have: /* @__PURE__ */ new Set(),
|
|
1584
|
-
haveAt: 0
|
|
1724
|
+
haveAt: 0,
|
|
1725
|
+
busy: /* @__PURE__ */ new Set()
|
|
1585
1726
|
};
|
|
1586
1727
|
edge.files = new ChatFiles({
|
|
1587
1728
|
send: (frame) => {
|
|
@@ -1615,13 +1756,28 @@ var GroupFileDesk = class {
|
|
|
1615
1756
|
const kept = this.kept.get(edge.taking.get(record.id) ?? "");
|
|
1616
1757
|
if (!kept) throw new Error("No such file");
|
|
1617
1758
|
await kept.idle;
|
|
1618
|
-
const writer = await this.deps.store.write(kept.id);
|
|
1759
|
+
const writer = await this.deps.store.write(kept.id, !kept.fresh);
|
|
1760
|
+
kept.fresh = false;
|
|
1761
|
+
const resumed = writer.offset > 0;
|
|
1762
|
+
let damaged = false;
|
|
1619
1763
|
return {
|
|
1620
|
-
offset:
|
|
1764
|
+
offset: writer.offset,
|
|
1621
1765
|
append: (bytes) => writer.append(bytes),
|
|
1622
1766
|
flush: () => writer.flush(),
|
|
1623
|
-
verify: async () =>
|
|
1624
|
-
|
|
1767
|
+
verify: async () => {
|
|
1768
|
+
damaged = await writer.digest() !== kept.meta.d;
|
|
1769
|
+
const want = this.wants.get(kept.id);
|
|
1770
|
+
if (damaged && want) want.why = "damaged";
|
|
1771
|
+
return !damaged;
|
|
1772
|
+
},
|
|
1773
|
+
discard: () => {
|
|
1774
|
+
if (damaged) {
|
|
1775
|
+
kept.stored = 0;
|
|
1776
|
+
if (resumed) kept.fresh = kept.unclear = true;
|
|
1777
|
+
else kept.damaged.add(edge.peer);
|
|
1778
|
+
}
|
|
1779
|
+
return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
|
|
1780
|
+
}
|
|
1625
1781
|
};
|
|
1626
1782
|
}
|
|
1627
1783
|
async openSource(edge, record) {
|
|
@@ -1643,6 +1799,12 @@ var GroupFileDesk = class {
|
|
|
1643
1799
|
};
|
|
1644
1800
|
this.served.bytes += record.confirmed;
|
|
1645
1801
|
edge.serving.delete(record.id);
|
|
1802
|
+
edge.servedMoved.delete(record.id);
|
|
1803
|
+
const serving = this.deps.settings().serveFiles;
|
|
1804
|
+
for (const other of this.edgesById.values()) {
|
|
1805
|
+
if (serving) for (const id of other.busy) other.have.add(id);
|
|
1806
|
+
other.busy.clear();
|
|
1807
|
+
}
|
|
1646
1808
|
}
|
|
1647
1809
|
return;
|
|
1648
1810
|
}
|
|
@@ -1652,6 +1814,7 @@ var GroupFileDesk = class {
|
|
|
1652
1814
|
want.moved = transferred;
|
|
1653
1815
|
want.movedAt = this.deps.now();
|
|
1654
1816
|
}
|
|
1817
|
+
if (record.state !== "failed" && record.state !== "declined" && record.state !== "cancelled") kept.stored = transferred;
|
|
1655
1818
|
const { size } = kept.meta;
|
|
1656
1819
|
switch (record.state) {
|
|
1657
1820
|
case "done":
|
|
@@ -1670,12 +1833,14 @@ var GroupFileDesk = class {
|
|
|
1670
1833
|
direction: "in"
|
|
1671
1834
|
});
|
|
1672
1835
|
this.queueHave(kept);
|
|
1836
|
+
this.retry(kept.groupId);
|
|
1673
1837
|
return;
|
|
1674
1838
|
case "failed":
|
|
1675
1839
|
case "declined":
|
|
1676
1840
|
case "cancelled":
|
|
1677
1841
|
edge.taking.delete(record.id);
|
|
1678
|
-
if (want.holder) want.tried.add(want.holder);
|
|
1842
|
+
if (want.holder && !kept.unclear) want.tried.add(want.holder);
|
|
1843
|
+
kept.unclear = false;
|
|
1679
1844
|
Object.assign(want, {
|
|
1680
1845
|
wire: void 0,
|
|
1681
1846
|
holder: void 0,
|
|
@@ -1683,6 +1848,7 @@ var GroupFileDesk = class {
|
|
|
1683
1848
|
moved: 0
|
|
1684
1849
|
});
|
|
1685
1850
|
this.ask(kept);
|
|
1851
|
+
this.retry(kept.groupId);
|
|
1686
1852
|
return;
|
|
1687
1853
|
case "verifying":
|
|
1688
1854
|
this.show(kept, {
|
|
@@ -1724,6 +1890,10 @@ var GroupFileDesk = class {
|
|
|
1724
1890
|
if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
|
|
1725
1891
|
return window;
|
|
1726
1892
|
}
|
|
1893
|
+
/** My announcements in a group, in a minute and in the window the members count them over. */
|
|
1894
|
+
sentPaces(groupId) {
|
|
1895
|
+
return [this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4), this.pace(this.sentTaken, groupId, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs)];
|
|
1896
|
+
}
|
|
1727
1897
|
show(kept, view) {
|
|
1728
1898
|
this.deps.transfers.set(kept.id, view);
|
|
1729
1899
|
this.deps.changed();
|
|
@@ -2283,6 +2453,8 @@ async function sendToGroup(ctx, params, group, path, size) {
|
|
|
2283
2453
|
if (replyTo) {
|
|
2284
2454
|
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
2455
|
}
|
|
2456
|
+
const check = await node(ctx).groupFileCheck({ groupId: group.id });
|
|
2457
|
+
if (check.error) throw check.refused ? new CliError("refused", check.error) : groupSendRefused(check.error);
|
|
2286
2458
|
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2287
2459
|
const source = await openAsBlob(path, { type: mime });
|
|
2288
2460
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
@@ -2343,6 +2515,20 @@ async function sendToGroup(ctx, params, group, path, size) {
|
|
|
2343
2515
|
...warning ? { warning } : {}
|
|
2344
2516
|
};
|
|
2345
2517
|
}
|
|
2518
|
+
/** Bytes a file system takes in one name: 255 on ext4, btrfs, APFS (NTFS counts 255 UTF-16 units, within this too). */
|
|
2519
|
+
var NAME_BYTES = 255;
|
|
2520
|
+
/**
|
|
2521
|
+
* `name` as an entry of a folder: an app's name may have 200 characters, which in UTF-8 can be 800 bytes. The end of
|
|
2522
|
+
* its stem goes until it fits, its extension stays (as `sanitizeFileName` shortens it), so "文…文.txt" is saved as a text.
|
|
2523
|
+
*/
|
|
2524
|
+
function fitName(name) {
|
|
2525
|
+
const fits = (candidate) => Buffer.byteLength(candidate) <= NAME_BYTES;
|
|
2526
|
+
if (fits(name)) return name;
|
|
2527
|
+
const extension = /\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? "";
|
|
2528
|
+
const stem = [...name.slice(0, name.length - extension.length)];
|
|
2529
|
+
while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
|
|
2530
|
+
return (stem.join("").trimEnd() || "file") + extension;
|
|
2531
|
+
}
|
|
2346
2532
|
var ACTIONS = [
|
|
2347
2533
|
"accept",
|
|
2348
2534
|
"decline",
|
|
@@ -2363,6 +2549,7 @@ var FILE_METHODS = {
|
|
|
2363
2549
|
throw new CliError("not_found", `No file ${path}`);
|
|
2364
2550
|
}
|
|
2365
2551
|
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
2552
|
+
await access(path, constants.R_OK);
|
|
2366
2553
|
if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
|
|
2367
2554
|
const { link } = target;
|
|
2368
2555
|
if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
|
|
@@ -2376,7 +2563,7 @@ var FILE_METHODS = {
|
|
|
2376
2563
|
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2377
2564
|
const source = await openAsBlob(path, { type: mime });
|
|
2378
2565
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
2379
|
-
const wireId = toBase64Url
|
|
2566
|
+
const wireId = toBase64Url(randomBytes$1(12));
|
|
2380
2567
|
const file = {
|
|
2381
2568
|
id: `${link.id}-out-${wireId}`,
|
|
2382
2569
|
name: sanitizeFileName(str(params, "name") ?? basename(path)),
|
|
@@ -2543,7 +2730,7 @@ var FILE_METHODS = {
|
|
|
2543
2730
|
if (stored.leftOut) throw new CliError("not_found", "Not in this backup: the light backup this profile was restored from left the file out");
|
|
2544
2731
|
const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
|
|
2545
2732
|
if (transfer && transfer.state !== "done") throw new CliError("unavailable", `The file is not all here yet (${transfer.transferred} of ${transfer.size} bytes)`);
|
|
2546
|
-
const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), sanitizeFileName(stored.metadata.name));
|
|
2733
|
+
const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), fitName(sanitizeFileName(stored.metadata.name)));
|
|
2547
2734
|
const flags = bool(params, "force") ? "w" : "wx";
|
|
2548
2735
|
let out;
|
|
2549
2736
|
try {
|
|
@@ -2596,7 +2783,7 @@ var DELIVERY_RANK = {
|
|
|
2596
2783
|
async function waitForEdit(ctx, chat, messageId, ms) {
|
|
2597
2784
|
const until = Date.now() + ms;
|
|
2598
2785
|
for (;;) {
|
|
2599
|
-
const message =
|
|
2786
|
+
const message = await node(ctx).getMessage(chat, messageId);
|
|
2600
2787
|
if (!message) throw new CliError("not_found", `No message ${messageId} in this chat`);
|
|
2601
2788
|
if (!message.edit?.pending) return message;
|
|
2602
2789
|
if (Date.now() >= until) throw new CliError("timeout", `Timed out after ${Math.round(ms / 1e3)} s: the contact has not confirmed the edit yet. It goes by itself once the chat is live and the contact's app shows edits, while this profile is online`, {
|
|
@@ -2648,8 +2835,7 @@ async function waitForMessage(ctx, chat, messageId, target, ms) {
|
|
|
2648
2835
|
if (error) reject(error);
|
|
2649
2836
|
else resolve(message);
|
|
2650
2837
|
};
|
|
2651
|
-
const check = (last = false) => void node(ctx).
|
|
2652
|
-
const message = messages.find((m) => m.id === messageId);
|
|
2838
|
+
const check = (last = false) => void node(ctx).getMessage(chat, messageId).then((message) => {
|
|
2653
2839
|
try {
|
|
2654
2840
|
const done = look(message);
|
|
2655
2841
|
if (done) finish(null, done);
|
|
@@ -2770,7 +2956,7 @@ async function sendCard(ctx, params, kind) {
|
|
|
2770
2956
|
const target = chatOrGroup(ctx, str(params, "chat", true));
|
|
2771
2957
|
const fields = fieldsOf(params);
|
|
2772
2958
|
const now = Date.now();
|
|
2773
|
-
const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url
|
|
2959
|
+
const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
|
|
2774
2960
|
const card = checked$1(withRun({
|
|
2775
2961
|
...kind === "task" ? {
|
|
2776
2962
|
status: "running",
|
|
@@ -2812,7 +2998,7 @@ async function sendCard(ctx, params, kind) {
|
|
|
2812
2998
|
});
|
|
2813
2999
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
2814
3000
|
if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
|
|
2815
|
-
let message =
|
|
3001
|
+
let message = await node(ctx).getMessage(target.linkId, result.messageId);
|
|
2816
3002
|
if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
|
|
2817
3003
|
return answer(target, kind, id, result.messageId, {
|
|
2818
3004
|
card,
|
|
@@ -2879,7 +3065,7 @@ async function updateCard(ctx, params, kind) {
|
|
|
2879
3065
|
pace.last = Date.now();
|
|
2880
3066
|
await editCard(ctx, target, message.id, card, text);
|
|
2881
3067
|
}
|
|
2882
|
-
let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) :
|
|
3068
|
+
let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
|
|
2883
3069
|
if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
|
|
2884
3070
|
const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
|
|
2885
3071
|
return answer(target, kind, id, message.id, {
|
|
@@ -3049,7 +3235,7 @@ function buttonsCard(params) {
|
|
|
3049
3235
|
if (!Array.isArray(buttons)) throw new CliError("bad_request", "buttons must be a list of {id, label, style?, once?}");
|
|
3050
3236
|
return checked({
|
|
3051
3237
|
kind: "buttons",
|
|
3052
|
-
id: str(params, "buttonsId") ?? `ask-${toBase64Url
|
|
3238
|
+
id: str(params, "buttonsId") ?? `ask-${toBase64Url(randomBytes$1(6))}`,
|
|
3053
3239
|
buttons
|
|
3054
3240
|
});
|
|
3055
3241
|
}
|
|
@@ -3104,7 +3290,7 @@ async function updateButtons(ctx, params) {
|
|
|
3104
3290
|
card
|
|
3105
3291
|
});
|
|
3106
3292
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
3107
|
-
let edited =
|
|
3293
|
+
let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
|
|
3108
3294
|
if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
|
|
3109
3295
|
const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
|
|
3110
3296
|
return {
|
|
@@ -3167,7 +3353,7 @@ async function pressButton(ctx, params) {
|
|
|
3167
3353
|
edges
|
|
3168
3354
|
};
|
|
3169
3355
|
}
|
|
3170
|
-
const reply = wait === "none" ?
|
|
3356
|
+
const reply = wait === "none" ? await node(ctx).getMessage(target.linkId, replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
|
|
3171
3357
|
return {
|
|
3172
3358
|
...answer,
|
|
3173
3359
|
delivery: reply?.delivery ?? null
|
|
@@ -4135,7 +4321,7 @@ var ResolverUnavailable = class extends DomainCheckError {};
|
|
|
4135
4321
|
async function askResolver(resolver, name, type, options) {
|
|
4136
4322
|
let response;
|
|
4137
4323
|
try {
|
|
4138
|
-
response = await options.fetch(`${resolver.url}?dns=${toBase64Url
|
|
4324
|
+
response = await options.fetch(`${resolver.url}?dns=${toBase64Url(encodeDnsQuery(name, type))}`, {
|
|
4139
4325
|
headers: { accept: "application/dns-message" },
|
|
4140
4326
|
maxBytes: DNS_MAX_BYTES,
|
|
4141
4327
|
signal: options.signal,
|
|
@@ -4845,8 +5031,8 @@ async function preparePgpEvidence(input, options = {}) {
|
|
|
4845
5031
|
return {
|
|
4846
5032
|
evidence: {
|
|
4847
5033
|
scheme: PGP_SCHEME,
|
|
4848
|
-
key: toBase64Url
|
|
4849
|
-
signature: toBase64Url
|
|
5034
|
+
key: toBase64Url(await minimalKey(pgp, key, signer, at, config(pgp))),
|
|
5035
|
+
signature: toBase64Url(signature)
|
|
4850
5036
|
},
|
|
4851
5037
|
verified: info
|
|
4852
5038
|
};
|
|
@@ -4858,7 +5044,7 @@ function parsePgpEvidence(raw) {
|
|
|
4858
5044
|
if (!e || typeof e !== "object" || Array.isArray(e) || e.scheme !== "openpgp/1" || Object.keys(e).sort().join(",") !== "key,scheme,signature" || typeof e.key !== "string" || typeof e.signature !== "string" || !b64.test(e.key) || !b64.test(e.signature) || e.key.length > Math.ceil(PGP_LIMITS.sharedKey * 4 / 3) || e.signature.length > Math.ceil(PGP_LIMITS.sharedSignature * 4 / 3)) throw new PgpProofError("Invalid OpenPGP proof");
|
|
4859
5045
|
let canonical;
|
|
4860
5046
|
try {
|
|
4861
|
-
canonical = toBase64Url
|
|
5047
|
+
canonical = toBase64Url(fromBase64Url$1(e.key)) === e.key && toBase64Url(fromBase64Url$1(e.signature)) === e.signature;
|
|
4862
5048
|
} catch {
|
|
4863
5049
|
canonical = false;
|
|
4864
5050
|
}
|
|
@@ -5580,7 +5766,7 @@ function segment(value) {
|
|
|
5580
5766
|
} catch {
|
|
5581
5767
|
return refuse("Malformed ID token");
|
|
5582
5768
|
}
|
|
5583
|
-
if (toBase64Url
|
|
5769
|
+
if (toBase64Url(bytes) !== value) refuse("Malformed ID token");
|
|
5584
5770
|
return bytes;
|
|
5585
5771
|
}
|
|
5586
5772
|
function json(value) {
|
|
@@ -5728,7 +5914,7 @@ async function authorizationRequest(options) {
|
|
|
5728
5914
|
if (!clientId) throw new Error(`${provider.name} sign-in is not set up in this Ghostly build yet.`);
|
|
5729
5915
|
const { nonce } = options;
|
|
5730
5916
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) throw new Error("Invalid sign-in nonce");
|
|
5731
|
-
const state = `${options.statePrefix ?? ""}${toBase64Url
|
|
5917
|
+
const state = `${options.statePrefix ?? ""}${toBase64Url(randomBytes$1(32))}`;
|
|
5732
5918
|
const url = new URL(provider.authorizationEndpoint);
|
|
5733
5919
|
const { scope, claims } = scopeFor$1(provider, options.reveal);
|
|
5734
5920
|
const params = {
|
|
@@ -5743,8 +5929,8 @@ async function authorizationRequest(options) {
|
|
|
5743
5929
|
};
|
|
5744
5930
|
let verifier;
|
|
5745
5931
|
if (provider.responseType === "code") {
|
|
5746
|
-
verifier = toBase64Url
|
|
5747
|
-
params.code_challenge = toBase64Url
|
|
5932
|
+
verifier = toBase64Url(randomBytes$1(32));
|
|
5933
|
+
params.code_challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
|
|
5748
5934
|
params.code_challenge_method = "S256";
|
|
5749
5935
|
}
|
|
5750
5936
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
@@ -6319,7 +6505,7 @@ var signer = {
|
|
|
6319
6505
|
return {
|
|
6320
6506
|
method: "sig",
|
|
6321
6507
|
vm: check.key.id,
|
|
6322
|
-
sig: toBase64Url
|
|
6508
|
+
sig: toBase64Url(check.signature)
|
|
6323
6509
|
};
|
|
6324
6510
|
}
|
|
6325
6511
|
};
|
|
@@ -7571,7 +7757,7 @@ var exactKeys = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArra
|
|
|
7571
7757
|
/** The key behind the fake in-app signer. Tests may replace it. */
|
|
7572
7758
|
var fakeKeyring = { seed: ed25519.utils.randomSecretKey() };
|
|
7573
7759
|
var fakeKeySubject = (seed = fakeKeyring.seed) => hex$3(ed25519.getPublicKey(seed));
|
|
7574
|
-
var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url
|
|
7760
|
+
var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url(ed25519.sign(statement.bytes, seed)) });
|
|
7575
7761
|
var fakeKey = {
|
|
7576
7762
|
id: "fake-key",
|
|
7577
7763
|
label: "Test key",
|
|
@@ -7654,13 +7840,13 @@ var ISSUER_SEED = unhex("7a".repeat(32));
|
|
|
7654
7840
|
var FAKE_ISSUER = "https://issuer.ghostly.test";
|
|
7655
7841
|
/** What the fake login page signs: iss, sub, nonce (= statement id), iat. */
|
|
7656
7842
|
function fakeAccountToken(statement, account = "alice", seed = ISSUER_SEED, iat = statement.binding.issuedAt) {
|
|
7657
|
-
const payload = toBase64Url
|
|
7843
|
+
const payload = toBase64Url(utf8Encode(JSON.stringify({
|
|
7658
7844
|
iss: FAKE_ISSUER,
|
|
7659
7845
|
sub: account,
|
|
7660
7846
|
nonce: statement.id,
|
|
7661
7847
|
iat
|
|
7662
7848
|
})));
|
|
7663
|
-
return { token: `${payload}.${toBase64Url
|
|
7849
|
+
return { token: `${payload}.${toBase64Url(ed25519.sign(utf8Encode(payload), seed))}` };
|
|
7664
7850
|
}
|
|
7665
7851
|
var fakeAccount = {
|
|
7666
7852
|
id: "fake-account",
|
|
@@ -8623,12 +8809,6 @@ var SERVICE_METHODS = {
|
|
|
8623
8809
|
};
|
|
8624
8810
|
//#endregion
|
|
8625
8811
|
//#region ../browser/src/backup/codec.ts
|
|
8626
|
-
/** Base64url without padding, in chunks: bundles can be many megabytes. */
|
|
8627
|
-
function toBase64Url(bytes) {
|
|
8628
|
-
let binary = "";
|
|
8629
|
-
for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
8630
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8631
|
-
}
|
|
8632
8812
|
function fromBase64Url(text) {
|
|
8633
8813
|
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("Invalid base64url");
|
|
8634
8814
|
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4));
|
|
@@ -12343,14 +12523,19 @@ var METHODS = {
|
|
|
12343
12523
|
label
|
|
12344
12524
|
});
|
|
12345
12525
|
node(ctx).setActiveLink({ linkId });
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12526
|
+
const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
|
|
12527
|
+
const progress = await waitForState(ctx, (s) => {
|
|
12528
|
+
const p = progressOf(s);
|
|
12529
|
+
return p && (p.stage !== "publishing" || ctx.mode === "daemon" && p.relayWaitUntil) ? p : void 0;
|
|
12530
|
+
}, 2e4, "the invite to be published").catch(() => progressOf(state(ctx)));
|
|
12531
|
+
const published = !!progress && progress.stage !== "publishing" && progress.reason !== "publish";
|
|
12532
|
+
const waitUntil = published ? void 0 : progress?.relayWaitUntil;
|
|
12350
12533
|
return {
|
|
12351
12534
|
chat: linkId,
|
|
12352
12535
|
invite: inviteCode,
|
|
12353
|
-
link: inviteLink(inviteCode)
|
|
12536
|
+
link: inviteLink(inviteCode),
|
|
12537
|
+
published,
|
|
12538
|
+
...waitUntil !== void 0 && { retryInMs: Math.max(0, waitUntil - Date.now()) }
|
|
12354
12539
|
};
|
|
12355
12540
|
},
|
|
12356
12541
|
async "invite.join"(ctx, params) {
|
|
@@ -12412,7 +12597,7 @@ var METHODS = {
|
|
|
12412
12597
|
});
|
|
12413
12598
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
12414
12599
|
if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
|
|
12415
|
-
let message =
|
|
12600
|
+
let message = await node(ctx).getMessage(link.id, result.messageId);
|
|
12416
12601
|
if (wait !== "none") message = await waitForMessage(ctx, link.id, result.messageId, wait, num(params, "timeout", 30, {
|
|
12417
12602
|
min: 1,
|
|
12418
12603
|
max: 3600
|
|
@@ -12448,7 +12633,7 @@ var METHODS = {
|
|
|
12448
12633
|
});
|
|
12449
12634
|
if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
|
|
12450
12635
|
const id = result.messageId ?? messageId;
|
|
12451
|
-
let message =
|
|
12636
|
+
let message = await node(ctx).getMessage(link.id, id);
|
|
12452
12637
|
if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
|
|
12453
12638
|
min: 1,
|
|
12454
12639
|
max: 3600
|
|
@@ -13149,6 +13334,229 @@ function connectDaemon(path, timeoutMs = 2e3) {
|
|
|
13149
13334
|
});
|
|
13150
13335
|
}
|
|
13151
13336
|
//#endregion
|
|
13337
|
+
//#region src/appTemplate.ts
|
|
13338
|
+
var html = (text) => text.replace(/[&<>"]/g, (c) => ({
|
|
13339
|
+
"&": "&",
|
|
13340
|
+
"<": "<",
|
|
13341
|
+
">": ">",
|
|
13342
|
+
"\"": """
|
|
13343
|
+
})[c]);
|
|
13344
|
+
/** `ghostly-app.json`: the manifest without `publisher`, `sequence` and `files`, which `app publish` writes. */
|
|
13345
|
+
function templateManifest({ name, title }) {
|
|
13346
|
+
return {
|
|
13347
|
+
name,
|
|
13348
|
+
version: "0.1.0",
|
|
13349
|
+
kind: "mini-app",
|
|
13350
|
+
title,
|
|
13351
|
+
tagline: "Say hello to a contact",
|
|
13352
|
+
description: "Open it in a chat and your contact gets a card to join. Each side can then say hello to the other.",
|
|
13353
|
+
entry: "index.html",
|
|
13354
|
+
permissions: ["chat"],
|
|
13355
|
+
view: "chat",
|
|
13356
|
+
runtime: {
|
|
13357
|
+
host: ">=1.2",
|
|
13358
|
+
clients: ["web", "desktop"]
|
|
13359
|
+
},
|
|
13360
|
+
license: "MIT"
|
|
13361
|
+
};
|
|
13362
|
+
}
|
|
13363
|
+
/**
|
|
13364
|
+
* `index.html`: the whole app in one file, as the sandbox needs it (no network, so nothing is loaded from elsewhere).
|
|
13365
|
+
* Its script is plain JavaScript typed with JSDoc against `@ghostlytools/sdk/app`, so it runs as written and still type
|
|
13366
|
+
* checks (test/appInit.test.ts does). The title is the only thing put into it, as text. Its type imports are in single
|
|
13367
|
+
* quotes: they are text of the page, not imports of the CLI's own build (test/packageDeps.test.ts reads those).
|
|
13368
|
+
*/
|
|
13369
|
+
function templatePage({ title }) {
|
|
13370
|
+
return `<!doctype html>
|
|
13371
|
+
<html lang="en">
|
|
13372
|
+
<head>
|
|
13373
|
+
<meta charset="utf-8">
|
|
13374
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13375
|
+
<title>${html(title)}</title>
|
|
13376
|
+
<style>
|
|
13377
|
+
:root { color-scheme: light dark; --bg: #ffffff; --fg: #16181d; --muted: #5b6270; --line: #d9dde3; --accent: #2f6fed; }
|
|
13378
|
+
:root[data-theme="dark"] { --bg: #16181d; --fg: #f2f4f7; --muted: #a3abb8; --line: #343a45; --accent: #7aa5ff; }
|
|
13379
|
+
body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, sans-serif; }
|
|
13380
|
+
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
13381
|
+
#status { margin: 0 0 16px; color: var(--muted); }
|
|
13382
|
+
button { font: inherit; padding: 8px 16px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: var(--bg); }
|
|
13383
|
+
button:disabled { opacity: 0.5; }
|
|
13384
|
+
ul { margin: 16px 0 0; padding: 0; list-style: none; }
|
|
13385
|
+
li { padding: 8px 0; border-top: 1px solid var(--line); }
|
|
13386
|
+
</style>
|
|
13387
|
+
</head>
|
|
13388
|
+
<body>
|
|
13389
|
+
<h1>${html(title)}</h1>
|
|
13390
|
+
<p id="status" role="status">Starting</p>
|
|
13391
|
+
<button id="hello" type="button" disabled>Say hello</button>
|
|
13392
|
+
<ul id="log" aria-label="Messages"></ul>
|
|
13393
|
+
<script type="module">
|
|
13394
|
+
// The types of the API come from the SDK (npm install --save-dev @ghostlytools/sdk); nothing of it runs here.
|
|
13395
|
+
/** @typedef {import('@ghostlytools/sdk/app').MiniAppApi} MiniAppApi */
|
|
13396
|
+
/** @typedef {import('@ghostlytools/sdk/app').MiniAppJson} MiniAppJson */
|
|
13397
|
+
|
|
13398
|
+
/** The longest text shown from a contact's frame. */
|
|
13399
|
+
const MAX_TEXT = 200;
|
|
13400
|
+
|
|
13401
|
+
const status = /** @type {HTMLElement} */ (document.getElementById("status"));
|
|
13402
|
+
const button = /** @type {HTMLButtonElement} */ (document.getElementById("hello"));
|
|
13403
|
+
const log = /** @type {HTMLElement} */ (document.getElementById("log"));
|
|
13404
|
+
|
|
13405
|
+
/** @param {string} text */
|
|
13406
|
+
function show(text) {
|
|
13407
|
+
const line = document.createElement("li");
|
|
13408
|
+
line.textContent = text;
|
|
13409
|
+
log.append(line);
|
|
13410
|
+
}
|
|
13411
|
+
|
|
13412
|
+
/**
|
|
13413
|
+
* What the contact's app sent, when it is a hello. A frame comes from another device: read only what this app sends,
|
|
13414
|
+
* and never put it in the page as HTML.
|
|
13415
|
+
* @param {MiniAppJson} data
|
|
13416
|
+
* @returns {string | null}
|
|
13417
|
+
*/
|
|
13418
|
+
function helloOf(data) {
|
|
13419
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || data.type !== "hello") return null;
|
|
13420
|
+
return typeof data.text === "string" && data.text.length <= MAX_TEXT ? data.text : null;
|
|
13421
|
+
}
|
|
13422
|
+
|
|
13423
|
+
/**
|
|
13424
|
+
* Why a send was refused, in words. The code is the rejection's message (MINI_APP_ERROR_CODES in the SDK); a later
|
|
13425
|
+
* client may add one.
|
|
13426
|
+
* @param {unknown} error
|
|
13427
|
+
*/
|
|
13428
|
+
function refusal(error) {
|
|
13429
|
+
switch (error instanceof Error ? error.message : "") {
|
|
13430
|
+
case "offline": return "Not sent: you are not both online";
|
|
13431
|
+
case "peer-closed": case "not-open": return "Not sent: your contact has not opened this app";
|
|
13432
|
+
case "not-allowed": return "Open this app in a chat to say hello";
|
|
13433
|
+
default: return "Not sent";
|
|
13434
|
+
}
|
|
13435
|
+
}
|
|
13436
|
+
|
|
13437
|
+
/** @param {MiniAppApi} ghostly */
|
|
13438
|
+
async function start(ghostly) {
|
|
13439
|
+
// Listen before anything is awaited, so no frame is missed.
|
|
13440
|
+
ghostly.chat.on("message", (data) => {
|
|
13441
|
+
const text = helloOf(data);
|
|
13442
|
+
if (text !== null) show("Your contact: " + text);
|
|
13443
|
+
});
|
|
13444
|
+
ghostly.chat.on("peer", (peer) => {
|
|
13445
|
+
status.textContent = peer.open ? "Your contact has this app open" : "Waiting for your contact to open this app";
|
|
13446
|
+
});
|
|
13447
|
+
|
|
13448
|
+
const context = await ghostly.context();
|
|
13449
|
+
const dark = context.theme ? context.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
13450
|
+
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
13451
|
+
status.textContent = !context.inChat ? "Open this app in a chat to say hello"
|
|
13452
|
+
: context.peer ? "Your contact has this app open" : "Waiting for your contact to open this app";
|
|
13453
|
+
|
|
13454
|
+
button.disabled = false;
|
|
13455
|
+
button.addEventListener("click", async () => {
|
|
13456
|
+
try {
|
|
13457
|
+
await ghostly.chat.send({ type: "hello", text: "Hello!" });
|
|
13458
|
+
show("You: Hello!");
|
|
13459
|
+
} catch (error) {
|
|
13460
|
+
show(refusal(error));
|
|
13461
|
+
}
|
|
13462
|
+
});
|
|
13463
|
+
}
|
|
13464
|
+
|
|
13465
|
+
/** Ghostly's runner gives the app \`window.ghostly\`; a browser tab has none. */
|
|
13466
|
+
const ghostly = /** @type {{ ghostly?: MiniAppApi }} */ (window).ghostly;
|
|
13467
|
+
if (ghostly) void start(ghostly);
|
|
13468
|
+
else status.textContent = "This page is a Ghostly app: publish it and open it in Ghostly";
|
|
13469
|
+
<\/script>
|
|
13470
|
+
</body>
|
|
13471
|
+
</html>
|
|
13472
|
+
`;
|
|
13473
|
+
}
|
|
13474
|
+
/** `README.md`: what the folder holds and the way from it to a store. */
|
|
13475
|
+
function templateReadme({ name, title }) {
|
|
13476
|
+
return `# ${title}
|
|
13477
|
+
|
|
13478
|
+
A [Ghostly](https://github.com/MiguelMedeiros/ghostly) app, started with \`ghostly app init\`. Opened in a 1:1 chat, it
|
|
13479
|
+
gives your contact a card to join, and each side can say hello to the other.
|
|
13480
|
+
|
|
13481
|
+
A Ghostly app ([WISP 1200](https://github.com/MiguelMedeiros/ghostly/blob/dev/docs/wisps/1200-marketplace.md)) is a
|
|
13482
|
+
page that runs in the client's sandbox. It has no network, and talks to Ghostly only through \`window.ghostly\`:
|
|
13483
|
+
frames of at most 32 KiB to the same app on the contact's side (\`chat.send\`, \`chat.on\`), storage per app and per
|
|
13484
|
+
chat, and the files of its own bundle. Apps run in Ghostly from release 1.2.
|
|
13485
|
+
|
|
13486
|
+
| File | What it is |
|
|
13487
|
+
|---|---|
|
|
13488
|
+
| \`ghostly-app.json\` | The manifest, without \`publisher\`, \`sequence\` and \`files\`: \`ghostly app publish\` writes those |
|
|
13489
|
+
| \`index.html\` | The app, whole: styles and script inline |
|
|
13490
|
+
| \`README.md\` | This file |
|
|
13491
|
+
|
|
13492
|
+
## Write it
|
|
13493
|
+
|
|
13494
|
+
\`index.html\` is plain JavaScript, typed with JSDoc against \`@ghostlytools/sdk/app\`: \`MiniAppApi\` is
|
|
13495
|
+
\`window.ghostly\`, and the same module has the limits (\`MINI_APP_LIMITS\`) and the codes a refused call rejects with
|
|
13496
|
+
(\`MINI_APP_ERROR_CODES\`). For the types in your editor:
|
|
13497
|
+
|
|
13498
|
+
\`\`\`sh
|
|
13499
|
+
npm install --save-dev @ghostlytools/sdk
|
|
13500
|
+
\`\`\`
|
|
13501
|
+
|
|
13502
|
+
- What a contact's app sends is untrusted: check its shape, bound its size, and show it as text, never as HTML.
|
|
13503
|
+
- \`"view": "chat"\` runs the app inside a chat with one contact. \`"full"\` is a full-screen app opened from the Apps page.
|
|
13504
|
+
- \`permissions\` lists what the person is asked for: \`chat\` (frames to the contact's app), \`name\` (the person's
|
|
13505
|
+
display name) and \`internet\`. Adding one later makes every install ask before it updates.
|
|
13506
|
+
|
|
13507
|
+
## Build
|
|
13508
|
+
|
|
13509
|
+
There is nothing to build yet: the page is the app. Once it grows into several source files, or TypeScript, build it
|
|
13510
|
+
into one self-contained \`index.html\` (scripts, styles and images inline) and put that file and \`ghostly-app.json\`
|
|
13511
|
+
alone in a folder of their own, which is the folder you publish.
|
|
13512
|
+
[ghostly-chess](https://github.com/MiguelMedeiros/ghostly-chess) does it with Vite and a small
|
|
13513
|
+
\`scripts/stage.mjs\`. \`ghostly app publish\` takes every file of the folder it is given but dot files, this README
|
|
13514
|
+
included, so do not run it on a repository with sources, \`node_modules\` or anything private in it.
|
|
13515
|
+
|
|
13516
|
+
## Publish
|
|
13517
|
+
|
|
13518
|
+
\`\`\`sh
|
|
13519
|
+
ghostly app publish . --key ~/ghostly-keys/${name}-publisher.key
|
|
13520
|
+
ghostly app verify app.ghostlyapp
|
|
13521
|
+
\`\`\`
|
|
13522
|
+
|
|
13523
|
+
\`publish\` signs the folder into \`app.ghostlyapp\`. The first run makes the publisher key at the path you name:
|
|
13524
|
+
|
|
13525
|
+
- Keep it **outside this folder** and its repository (\`publish\` refuses a key inside it), and never commit it.
|
|
13526
|
+
- Back it up. An app is updated only with the key that signed it; a lost key means a new app.
|
|
13527
|
+
|
|
13528
|
+
\`verify\` checks the bundle as Ghostly will and prints its \`ref\` (\`<publisher key>/${name}\`), \`sequence\` and
|
|
13529
|
+
\`digest\`. For the next version, raise \`version\` in \`ghostly-app.json\` and publish again: the sequence goes up
|
|
13530
|
+
by one by itself.
|
|
13531
|
+
|
|
13532
|
+
## Try it
|
|
13533
|
+
|
|
13534
|
+
Commit \`app.ghostlyapp\` to a public GitHub repository, then in Ghostly open Apps, choose Add, and paste its URL
|
|
13535
|
+
(\`https://raw.githubusercontent.com/<owner>/<repo>/HEAD/app.ghostlyapp\`). Set \`sources\` in \`ghostly-app.json\`
|
|
13536
|
+
to that URL, in a list, so installs made from it find your updates. Open the app from a chat's + menu; your contact
|
|
13537
|
+
gets a card to install and open it.
|
|
13538
|
+
|
|
13539
|
+
## List it in a store
|
|
13540
|
+
|
|
13541
|
+
A store is a signed list of apps. The official one is a Git repository,
|
|
13542
|
+
[ghostly-store](https://github.com/MiguelMedeiros/ghostly-store), and apps enter it by pull request:
|
|
13543
|
+
|
|
13544
|
+
1. Commit \`app.ghostlyapp\` to your public repository and note that commit.
|
|
13545
|
+
2. Add \`apps/${name}.<first 16 characters of your publisher key>/listing.json\` with the \`ref\`, \`sequence\`
|
|
13546
|
+
and \`digest\` that \`ghostly app verify\` printed. Its \`urls\` start with a jsDelivr URL pinned to the full commit
|
|
13547
|
+
(\`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-character commit>/app.ghostlyapp\`).
|
|
13548
|
+
3. Open the pull request. Its check verifies the bundle; a maintainer reviews the app and the owner signs the index.
|
|
13549
|
+
|
|
13550
|
+
The store's [CONTRIBUTING](https://github.com/MiguelMedeiros/ghostly-store/blob/main/CONTRIBUTING.md) has the rules.
|
|
13551
|
+
An app installed from a store updates only to the version that store lists, so each new version is a pull request too.
|
|
13552
|
+
|
|
13553
|
+
## If something goes wrong
|
|
13554
|
+
|
|
13555
|
+
If a version is harmful, \`ghostly app revoke . --key <your key> --up-to <sequence>\` writes \`ghostly-revoke.json\`;
|
|
13556
|
+
publish it beside \`app.ghostlyapp\` and Ghostly stops those versions.
|
|
13557
|
+
`;
|
|
13558
|
+
}
|
|
13559
|
+
//#endregion
|
|
13152
13560
|
//#region src/apps.ts
|
|
13153
13561
|
var APP_SOURCE = "ghostly-app.json";
|
|
13154
13562
|
var APP_REVOKE_FILE = "ghostly-revoke.json";
|
|
@@ -13316,7 +13724,7 @@ function described(bundle, bytes) {
|
|
|
13316
13724
|
sequence: m.sequence,
|
|
13317
13725
|
digest: bundle.digest,
|
|
13318
13726
|
bytes: bytes.length,
|
|
13319
|
-
sha256: toBase64Url
|
|
13727
|
+
sha256: toBase64Url(createHash("sha256").update(bytes).digest())
|
|
13320
13728
|
};
|
|
13321
13729
|
}
|
|
13322
13730
|
/** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
|
|
@@ -13330,6 +13738,70 @@ async function verifyApp(source) {
|
|
|
13330
13738
|
manifest: read.bundle.manifest
|
|
13331
13739
|
};
|
|
13332
13740
|
}
|
|
13741
|
+
/** A folder's name as an app's: lower case, anything else a hyphen (`My App` is `my-app`). */
|
|
13742
|
+
var nameFrom = (folder) => folder.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
13743
|
+
/** An app's name as a title: `my-app` is `My app`. */
|
|
13744
|
+
var titleFrom = (name) => (name[0].toUpperCase() + name.slice(1)).replace(/-/g, " ");
|
|
13745
|
+
/**
|
|
13746
|
+
* `app init <dir> [--name <name>] [--title <title>] [--force]`: a small working app in `<dir>` (made when missing),
|
|
13747
|
+
* which `app publish <dir>` signs as it is: `ghostly-app.json`, an `index.html` that says hello to the contact, and a
|
|
13748
|
+
* README with the way to a store (./appTemplate.ts). The name is `--name`, else the folder's; the title `--title`, else
|
|
13749
|
+
* the name's. Nothing is written when one of the three files is there, unless `--force`, which replaces those three only.
|
|
13750
|
+
*/
|
|
13751
|
+
async function initApp(dirArg, what = {}) {
|
|
13752
|
+
const usage = "ghostly app init <dir> [--name <name>] [--title <title>] [--force]";
|
|
13753
|
+
const dir = resolve(dirArg);
|
|
13754
|
+
const name = what.name ?? nameFrom(basename(dir));
|
|
13755
|
+
const title = what.title ?? (name ? titleFrom(name) : "");
|
|
13756
|
+
const page = templatePage({
|
|
13757
|
+
name,
|
|
13758
|
+
title
|
|
13759
|
+
});
|
|
13760
|
+
const pageBytes = new TextEncoder().encode(page);
|
|
13761
|
+
const checked = checkAppManifest({
|
|
13762
|
+
...templateManifest({
|
|
13763
|
+
name,
|
|
13764
|
+
title
|
|
13765
|
+
}),
|
|
13766
|
+
ghostlyApp: 1,
|
|
13767
|
+
publisher: toZ32(/* @__PURE__ */ new Uint8Array(32)),
|
|
13768
|
+
sequence: 1,
|
|
13769
|
+
files: [{
|
|
13770
|
+
path: "index.html",
|
|
13771
|
+
size: pageBytes.length,
|
|
13772
|
+
sha256: toBase64Url(createHash("sha256").update(pageBytes).digest())
|
|
13773
|
+
}]
|
|
13774
|
+
}, { strict: true });
|
|
13775
|
+
if (!checked.ok) {
|
|
13776
|
+
if (checked.detail === "name is not valid") throw new CliError("usage", what.name === void 0 ? `The folder's name gives no app name (${JSON.stringify(basename(dir))}): give --name, a lowercase letter then up to 31 lowercase letters, digits or hyphens: ${usage}` : `--name is a lowercase letter then up to 31 lowercase letters, digits or hyphens, not ${JSON.stringify(what.name)}`);
|
|
13777
|
+
if (checked.detail === "title is not valid") throw new CliError("usage", `--title is one line of 1 to ${APP_BUNDLE_LIMITS.title} characters, not ${JSON.stringify(title)}`);
|
|
13778
|
+
throw refused("The template makes no valid manifest", checked);
|
|
13779
|
+
}
|
|
13780
|
+
if (existsSync(dir) && !statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is a file: name a folder for the app`);
|
|
13781
|
+
const files = [
|
|
13782
|
+
[APP_SOURCE, `${JSON.stringify(templateManifest({
|
|
13783
|
+
name,
|
|
13784
|
+
title
|
|
13785
|
+
}), null, 2)}\n`],
|
|
13786
|
+
["index.html", page],
|
|
13787
|
+
["README.md", templateReadme({
|
|
13788
|
+
name,
|
|
13789
|
+
title
|
|
13790
|
+
})]
|
|
13791
|
+
];
|
|
13792
|
+
const held = files.map(([path]) => path).filter((path) => existsSync(join(dir, path)));
|
|
13793
|
+
if (held.length && !what.force) throw refused(`${dir} already holds ${held.join(", ")}: nothing was written (--force replaces ${held.length > 1 ? "them" : "it"})`, { reason: "exists" }, { files: held });
|
|
13794
|
+
mkdirSync(dir, { recursive: true });
|
|
13795
|
+
for (const [path, text] of files) writeWhole(join(dir, path), new TextEncoder().encode(text));
|
|
13796
|
+
return {
|
|
13797
|
+
dir,
|
|
13798
|
+
name,
|
|
13799
|
+
title,
|
|
13800
|
+
files: files.map(([path]) => path),
|
|
13801
|
+
...held.length ? { replaced: held } : {},
|
|
13802
|
+
next: `ghostly app publish ${dirArg} --key <a key file outside ${dirArg}>`
|
|
13803
|
+
};
|
|
13804
|
+
}
|
|
13333
13805
|
/** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
|
|
13334
13806
|
function collectFiles(dir, leaveOut) {
|
|
13335
13807
|
const files = [];
|
|
@@ -13368,11 +13840,22 @@ function collectFiles(dir, leaveOut) {
|
|
|
13368
13840
|
}
|
|
13369
13841
|
};
|
|
13370
13842
|
walk(dir);
|
|
13843
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
13371
13844
|
return {
|
|
13372
13845
|
files,
|
|
13373
13846
|
skipped
|
|
13374
13847
|
};
|
|
13375
13848
|
}
|
|
13849
|
+
/**
|
|
13850
|
+
* A private key in a file: a PEM private key block with its body (PKCS #8, OpenSSH, RSA, EC, encrypted), or a Ghostly
|
|
13851
|
+
* key file's first line. A library that only names the block's header in its code does not match.
|
|
13852
|
+
*/
|
|
13853
|
+
var PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n:,-]{40,}-----END [A-Z0-9 ]*PRIVATE KEY-----|^Ghostly (publisher|store) key \(WISP 1200\)/;
|
|
13854
|
+
/** Whether `path` is `dir` or anything under it. */
|
|
13855
|
+
var isInside = (dir, path) => {
|
|
13856
|
+
const rel = relative(dir, path);
|
|
13857
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
13858
|
+
};
|
|
13376
13859
|
/** Fields of `ghostly-app.json` the command writes itself. */
|
|
13377
13860
|
var WRITTEN = ["publisher", "files"];
|
|
13378
13861
|
function readSource(path) {
|
|
@@ -13412,6 +13895,7 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13412
13895
|
const dir = resolve(dirArg);
|
|
13413
13896
|
if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
|
|
13414
13897
|
const keyPath = resolve(keyArg);
|
|
13898
|
+
if (isInside(dir, keyPath)) throw refused(`${keyPath} is inside ${dir}, which is published: keep the publisher key outside the app's folder (and its repository)`, { reason: "private-key" });
|
|
13415
13899
|
const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
|
|
13416
13900
|
const sourcePath = join(dir, APP_SOURCE);
|
|
13417
13901
|
const draft = readSource(sourcePath);
|
|
@@ -13438,6 +13922,11 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13438
13922
|
path: f.path,
|
|
13439
13923
|
bytes: new Uint8Array(readFileSync(f.full))
|
|
13440
13924
|
}));
|
|
13925
|
+
const keyFile = contents.find((f) => PRIVATE_KEY.test(Buffer.from(f.bytes).toString("latin1")));
|
|
13926
|
+
if (keyFile) throw refused(`${keyFile.path} in ${dir} holds a private key, and a bundle is public: move it out of the app's folder`, {
|
|
13927
|
+
reason: "private-key",
|
|
13928
|
+
detail: keyFile.path
|
|
13929
|
+
});
|
|
13441
13930
|
const manifest = {
|
|
13442
13931
|
...draft,
|
|
13443
13932
|
ghostlyApp: 1,
|
|
@@ -13446,10 +13935,10 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13446
13935
|
files: contents.map((f) => ({
|
|
13447
13936
|
path: f.path,
|
|
13448
13937
|
size: f.bytes.length,
|
|
13449
|
-
sha256: toBase64Url
|
|
13938
|
+
sha256: toBase64Url(createHash("sha256").update(f.bytes).digest())
|
|
13450
13939
|
}))
|
|
13451
13940
|
};
|
|
13452
|
-
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
|
|
13941
|
+
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)), { strict: true });
|
|
13453
13942
|
if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
|
|
13454
13943
|
const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
|
|
13455
13944
|
const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
|
|
@@ -13564,7 +14053,7 @@ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() /
|
|
|
13564
14053
|
else key = makeKey(keyPath, "store");
|
|
13565
14054
|
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
14055
|
index.key = key.key;
|
|
13567
|
-
const checked = checkAppStoreIndex(index);
|
|
14056
|
+
const checked = checkAppStoreIndex(index, { strict: true });
|
|
13568
14057
|
if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
|
|
13569
14058
|
const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
|
|
13570
14059
|
const read = readAppStore(indexBytes, sigBytes, now, key.key);
|
|
@@ -13627,6 +14116,31 @@ var key = {
|
|
|
13627
14116
|
* per command, in alphabetical order (test/commands.test.ts checks).
|
|
13628
14117
|
*/
|
|
13629
14118
|
var commands$8 = {
|
|
14119
|
+
"app init": {
|
|
14120
|
+
method: "app.init",
|
|
14121
|
+
usage: "app init <dir> [--name <name>] [--title <title>] [--force]",
|
|
14122
|
+
summary: "Start an app (WISP 1200): a small working one in <dir>, its ghostly-app.json, an index.html that says hello to the contact and a README, ready for app publish",
|
|
14123
|
+
args: ["dir"],
|
|
14124
|
+
options: {
|
|
14125
|
+
name: {
|
|
14126
|
+
type: "string",
|
|
14127
|
+
description: "The app's name, lowercase letters, digits and hyphens (default: the folder's)"
|
|
14128
|
+
},
|
|
14129
|
+
title: {
|
|
14130
|
+
type: "string",
|
|
14131
|
+
description: "The title people see, up to 40 characters (default: from the name)"
|
|
14132
|
+
},
|
|
14133
|
+
force: {
|
|
14134
|
+
type: "boolean",
|
|
14135
|
+
description: "Replace the three files when they are there"
|
|
14136
|
+
}
|
|
14137
|
+
},
|
|
14138
|
+
run: ({ options }, a) => initApp(a.dir, {
|
|
14139
|
+
name: options.name,
|
|
14140
|
+
title: options.title,
|
|
14141
|
+
force: options.force === true
|
|
14142
|
+
})
|
|
14143
|
+
},
|
|
13630
14144
|
"app publish": {
|
|
13631
14145
|
method: "app.publish",
|
|
13632
14146
|
usage: "app publish <dir> --key <file> [--out <file>]",
|
|
@@ -14142,7 +14656,7 @@ var commands$7 = {
|
|
|
14142
14656
|
method: "call.answer",
|
|
14143
14657
|
usage: "call answer [<chat|call>] [--rate 48000]",
|
|
14144
14658
|
summary: "Answer a call that rings (the only one, or the one named)",
|
|
14145
|
-
args: ["call
|
|
14659
|
+
args: ["call...?"],
|
|
14146
14660
|
options: { rate },
|
|
14147
14661
|
params: ({ options }, a) => ({
|
|
14148
14662
|
call: a.call,
|
|
@@ -14153,7 +14667,7 @@ var commands$7 = {
|
|
|
14153
14667
|
method: "call.auto",
|
|
14154
14668
|
usage: "call auto [on|off] [--from <chat>]... [--rate 48000]",
|
|
14155
14669
|
summary: "Answer calls by themselves: from anyone, or the chats named (kept in the profile)",
|
|
14156
|
-
args: ["mode
|
|
14670
|
+
args: ["mode...?"],
|
|
14157
14671
|
options: {
|
|
14158
14672
|
from: {
|
|
14159
14673
|
type: "list",
|
|
@@ -14175,14 +14689,14 @@ var commands$7 = {
|
|
|
14175
14689
|
method: "call.flush",
|
|
14176
14690
|
usage: "call flush [<chat|call>]",
|
|
14177
14691
|
summary: "Drop the audio queued and not played yet (barge-in)",
|
|
14178
|
-
args: ["call
|
|
14692
|
+
args: ["call...?"],
|
|
14179
14693
|
params: (_, a) => ({ call: a.call })
|
|
14180
14694
|
},
|
|
14181
14695
|
"call hangup": {
|
|
14182
14696
|
method: "call.hangup",
|
|
14183
14697
|
usage: "call hangup [<chat|call>]",
|
|
14184
14698
|
summary: "Hang up, or decline a call that rings",
|
|
14185
|
-
args: ["call
|
|
14699
|
+
args: ["call...?"],
|
|
14186
14700
|
params: (_, a) => ({ call: a.call })
|
|
14187
14701
|
},
|
|
14188
14702
|
"call list": {
|
|
@@ -14336,7 +14850,7 @@ var commands$6 = {
|
|
|
14336
14850
|
method: "chat.rename",
|
|
14337
14851
|
usage: "chat rename <chat> <name...>",
|
|
14338
14852
|
summary: "Name a chat on this side (empty: the contact's name)",
|
|
14339
|
-
args: ["chat", "name
|
|
14853
|
+
args: ["chat", "name...?"],
|
|
14340
14854
|
params: (_, { chat, name }) => ({
|
|
14341
14855
|
chat,
|
|
14342
14856
|
name: name ?? ""
|
|
@@ -14414,14 +14928,17 @@ var commands$6 = {
|
|
|
14414
14928
|
},
|
|
14415
14929
|
timeout: groupWait.timeout
|
|
14416
14930
|
},
|
|
14417
|
-
params: ({ options }, a) =>
|
|
14418
|
-
|
|
14419
|
-
|
|
14420
|
-
|
|
14421
|
-
|
|
14422
|
-
|
|
14423
|
-
|
|
14424
|
-
|
|
14931
|
+
params: ({ options }, a) => {
|
|
14932
|
+
if (!Array.isArray(options.to) || !options.to.length) throw new CliError("usage", "Missing --to: ghostly forward <chat|group> <message>... --to <chat|group>... [--force] [--wait none|sent] [--timeout s]");
|
|
14933
|
+
return {
|
|
14934
|
+
chat: a.chat,
|
|
14935
|
+
messages: (a.message ?? "").split(" ").filter(Boolean),
|
|
14936
|
+
to: options.to,
|
|
14937
|
+
force: options.force === true,
|
|
14938
|
+
wait: options.wait,
|
|
14939
|
+
timeout: options.timeout
|
|
14940
|
+
};
|
|
14941
|
+
}
|
|
14425
14942
|
},
|
|
14426
14943
|
"invite create": {
|
|
14427
14944
|
method: "invite.create",
|
|
@@ -14499,7 +15016,7 @@ var commands$6 = {
|
|
|
14499
15016
|
args: [
|
|
14500
15017
|
"chat",
|
|
14501
15018
|
"message",
|
|
14502
|
-
"emoji
|
|
15019
|
+
"emoji...?"
|
|
14503
15020
|
],
|
|
14504
15021
|
options: { remove: {
|
|
14505
15022
|
type: "boolean",
|
|
@@ -14849,15 +15366,18 @@ var commands$4 = {
|
|
|
14849
15366
|
method: "profile.picture",
|
|
14850
15367
|
usage: "profile picture <jpeg> | --clear",
|
|
14851
15368
|
summary: "The picture contacts see (a square JPEG, 128 px is what the app sends)",
|
|
14852
|
-
args: ["path
|
|
15369
|
+
args: ["path...?"],
|
|
14853
15370
|
options: { clear: {
|
|
14854
15371
|
type: "boolean",
|
|
14855
15372
|
description: "Remove the picture"
|
|
14856
15373
|
} },
|
|
14857
|
-
params: ({ options }, a) =>
|
|
14858
|
-
|
|
14859
|
-
|
|
14860
|
-
|
|
15374
|
+
params: ({ options }, a) => {
|
|
15375
|
+
if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly profile picture <jpeg> | --clear");
|
|
15376
|
+
return {
|
|
15377
|
+
path: here(a.path),
|
|
15378
|
+
clear: options.clear === true
|
|
15379
|
+
};
|
|
15380
|
+
}
|
|
14861
15381
|
},
|
|
14862
15382
|
"status": {
|
|
14863
15383
|
method: "status",
|
|
@@ -15034,16 +15554,19 @@ var commands$3 = {
|
|
|
15034
15554
|
method: "group.picture",
|
|
15035
15555
|
usage: "group picture <group> <jpeg> | --clear",
|
|
15036
15556
|
summary: "Set or clear the group's picture (admin; a square JPEG)",
|
|
15037
|
-
args: ["group", "path
|
|
15557
|
+
args: ["group", "path...?"],
|
|
15038
15558
|
options: { clear: {
|
|
15039
15559
|
type: "boolean",
|
|
15040
15560
|
description: "Remove the picture"
|
|
15041
15561
|
} },
|
|
15042
|
-
params: ({ options }, a) =>
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15562
|
+
params: ({ options }, a) => {
|
|
15563
|
+
if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly group picture <group> <jpeg> | --clear");
|
|
15564
|
+
return {
|
|
15565
|
+
group: a.group,
|
|
15566
|
+
path: here(a.path),
|
|
15567
|
+
clear: options.clear === true
|
|
15568
|
+
};
|
|
15569
|
+
}
|
|
15047
15570
|
},
|
|
15048
15571
|
"group react": {
|
|
15049
15572
|
method: "group.react",
|
|
@@ -15052,7 +15575,7 @@ var commands$3 = {
|
|
|
15052
15575
|
args: [
|
|
15053
15576
|
"group",
|
|
15054
15577
|
"message",
|
|
15055
|
-
"emoji
|
|
15578
|
+
"emoji...?"
|
|
15056
15579
|
],
|
|
15057
15580
|
options: { remove: {
|
|
15058
15581
|
type: "boolean",
|
|
@@ -15662,7 +16185,7 @@ var TEXT_COMMANDS = {
|
|
|
15662
16185
|
"send": {
|
|
15663
16186
|
method: "chat.send",
|
|
15664
16187
|
target: "chat",
|
|
15665
|
-
args: ["chat", "text
|
|
16188
|
+
args: ["chat", "text...?"],
|
|
15666
16189
|
usage: "send <chat> [text...] [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent|delivered]",
|
|
15667
16190
|
summary: "Send a message (text from arguments or stdin); --button puts buttons under it, each label its own (case aside)",
|
|
15668
16191
|
options: {
|
|
@@ -15683,7 +16206,7 @@ var TEXT_COMMANDS = {
|
|
|
15683
16206
|
args: [
|
|
15684
16207
|
"chat",
|
|
15685
16208
|
"message",
|
|
15686
|
-
"text
|
|
16209
|
+
"text...?"
|
|
15687
16210
|
],
|
|
15688
16211
|
usage: "edit <chat> <message> [text... | --text <text> | --stdin] [--force] [--wait none|confirmed]",
|
|
15689
16212
|
summary: "Replace the text of a message you sent (the id send gave; in a group, group edit)",
|
|
@@ -15707,7 +16230,7 @@ var TEXT_COMMANDS = {
|
|
|
15707
16230
|
"group send": {
|
|
15708
16231
|
method: "group.send",
|
|
15709
16232
|
target: "group",
|
|
15710
|
-
args: ["group", "text
|
|
16233
|
+
args: ["group", "text...?"],
|
|
15711
16234
|
usage: "group send <group> [text...] [--mention <member>]... [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent] [--timeout s]",
|
|
15712
16235
|
summary: "Send to a group; mention members written as @name in the text; --button puts buttons under it",
|
|
15713
16236
|
options: {
|
|
@@ -15732,7 +16255,7 @@ var TEXT_COMMANDS = {
|
|
|
15732
16255
|
args: [
|
|
15733
16256
|
"group",
|
|
15734
16257
|
"message",
|
|
15735
|
-
"text
|
|
16258
|
+
"text...?"
|
|
15736
16259
|
],
|
|
15737
16260
|
usage: "group edit <group> <message> [text... | --text <text> | --stdin] [--mention <member>]... [--force] [--wait none|sent] [--timeout s]",
|
|
15738
16261
|
summary: "Replace the text of a message you sent to a group (the id group send gave)",
|
|
@@ -15773,13 +16296,15 @@ var ID_ARGS = /* @__PURE__ */ new Set([
|
|
|
15773
16296
|
"service",
|
|
15774
16297
|
"call"
|
|
15775
16298
|
]);
|
|
16299
|
+
/** A leading positional that may be left out (`name?`); a trailing one that may (`text...?`) shifts nothing. */
|
|
16300
|
+
var leadingOptional = (name) => name.endsWith("?") && !name.endsWith("...?");
|
|
15776
16301
|
/**
|
|
15777
16302
|
* Which of a command's positionals take an id, by index (a trailing `...` one covers the rest). An optional `name?`
|
|
15778
16303
|
* shifts the others, so it counts only where it and what may come in its place are all ids.
|
|
15779
16304
|
*/
|
|
15780
16305
|
function idSlot(command) {
|
|
15781
16306
|
const names = (command.args ?? []).map((name) => name.replace(/\?$/, ""));
|
|
15782
|
-
const shift = (command.args ?? []).filter(
|
|
16307
|
+
const shift = (command.args ?? []).filter(leadingOptional).length;
|
|
15783
16308
|
const rest = names.findIndex((name) => name.endsWith("..."));
|
|
15784
16309
|
const at = (index) => {
|
|
15785
16310
|
const name = rest !== -1 && index >= rest ? names[rest].slice(0, -3) : names[index];
|
|
@@ -15789,29 +16314,36 @@ function idSlot(command) {
|
|
|
15789
16314
|
}
|
|
15790
16315
|
/**
|
|
15791
16316
|
* The positionals of a command, by name; missing required ones are a usage error. A leading `name?` is optional: it
|
|
15792
|
-
* is given only when every required one is there too (`file accept [<chat>] <file>`).
|
|
16317
|
+
* is given only when every required one is there too (`file accept [<chat>] <file>`). A trailing `name...` takes the
|
|
16318
|
+
* rest and needs at least one word; `name...?` may take none (text from stdin, `chat rename`'s name back to the
|
|
16319
|
+
* contact's).
|
|
15793
16320
|
*/
|
|
15794
16321
|
function positionals(command, values) {
|
|
15795
16322
|
let names = command.args ?? [];
|
|
15796
|
-
const optional = names.filter(
|
|
16323
|
+
const optional = names.filter(leadingOptional);
|
|
15797
16324
|
if (optional.length) {
|
|
15798
16325
|
const skip = Math.max(0, optional.length - Math.max(0, values.length - (names.length - optional.length)));
|
|
15799
|
-
names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !name
|
|
16326
|
+
names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !leadingOptional(name))];
|
|
15800
16327
|
}
|
|
15801
16328
|
const out = {};
|
|
15802
16329
|
for (let i = 0; i < names.length; i++) {
|
|
15803
|
-
const name = names[i];
|
|
16330
|
+
const name = names[i].replace(/\?$/, "");
|
|
15804
16331
|
if (name.endsWith("...")) {
|
|
15805
16332
|
const rest = values.slice(i);
|
|
16333
|
+
if (!rest.length && !names[i].endsWith("?")) throw new CliError("usage", `Missing <${name.slice(0, -3)}>: ghostly ${command.usage}`);
|
|
15806
16334
|
out[name.slice(0, -3)] = rest.length ? rest.join(" ") : void 0;
|
|
15807
16335
|
return out;
|
|
15808
16336
|
}
|
|
15809
16337
|
if (values[i] === void 0) throw new CliError("usage", `Missing <${name}>: ghostly ${command.usage}`);
|
|
15810
16338
|
out[name] = values[i];
|
|
15811
16339
|
}
|
|
15812
|
-
|
|
16340
|
+
noMoreThan(names.length, values, command.usage);
|
|
15813
16341
|
return out;
|
|
15814
16342
|
}
|
|
16343
|
+
/** Words past a usage's arguments are refused, never dropped: `profile use my bot` would select `my`. */
|
|
16344
|
+
function noMoreThan(count, values, usage) {
|
|
16345
|
+
if (values.length > count) throw new CliError("usage", `Too many arguments: ghostly ${usage}`);
|
|
16346
|
+
}
|
|
15815
16347
|
//#endregion
|
|
15816
16348
|
//#region src/announce.ts
|
|
15817
16349
|
/**
|
|
@@ -15955,6 +16487,19 @@ function audioSocketPath(profileDir, callId) {
|
|
|
15955
16487
|
return join("/tmp", `ghostly-calls-${createHash("sha256").update(profileDir).digest("hex").slice(0, 24)}`, `${callId}.sock`);
|
|
15956
16488
|
}
|
|
15957
16489
|
/**
|
|
16490
|
+
* Removes the call sockets a daemon that died during a call left in this profile's folder (`audioSocketPath`; call
|
|
16491
|
+
* ids are 12 hex digits): no call outlives its daemon. A folder that is a link or someone else's is left alone.
|
|
16492
|
+
*/
|
|
16493
|
+
function clearAudioSockets(profileDir) {
|
|
16494
|
+
if (process.platform === "win32") return;
|
|
16495
|
+
const folder = dirname(audioSocketPath(profileDir, "0".repeat(12)));
|
|
16496
|
+
try {
|
|
16497
|
+
const info = lstatSync(folder);
|
|
16498
|
+
if (!info.isDirectory() || process.getuid && info.uid !== process.getuid()) return;
|
|
16499
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.name.endsWith(".sock") && !entry.isDirectory()) rmSync(join(folder, entry.name), { force: true });
|
|
16500
|
+
} catch {}
|
|
16501
|
+
}
|
|
16502
|
+
/**
|
|
15958
16503
|
* Serves one call's audio to one program at a time: what the program writes is handed to `onAudio` as it comes (raw
|
|
15959
16504
|
* PCM, any amount); `write` gives it the caller's audio. A second connection replaces the first. `close` ends the
|
|
15960
16505
|
* program's connection (it reads EOF) and removes the socket.
|
|
@@ -16137,8 +16682,6 @@ var ReorderBuffer = class {
|
|
|
16137
16682
|
}
|
|
16138
16683
|
}
|
|
16139
16684
|
};
|
|
16140
|
-
//#endregion
|
|
16141
|
-
//#region src/calls/audio.ts
|
|
16142
16685
|
/**
|
|
16143
16686
|
* One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
|
|
16144
16687
|
* comes in is put back in order, decoded, and handed on in 20 ms frames as soon as it is. Nothing waits on a clock
|
|
@@ -16161,6 +16704,8 @@ var CallAudio = class {
|
|
|
16161
16704
|
/** Frames sent and received, for `call list`. */
|
|
16162
16705
|
sent = 0;
|
|
16163
16706
|
received = 0;
|
|
16707
|
+
/** When the last packet, RTP or RTCP, came from the contact (Date.now(); 0: none yet), for the call's watch. */
|
|
16708
|
+
heardAt = 0;
|
|
16164
16709
|
constructor(options) {
|
|
16165
16710
|
this.options = options;
|
|
16166
16711
|
const now = options.now ?? Date.now;
|
|
@@ -16202,7 +16747,9 @@ var CallAudio = class {
|
|
|
16202
16747
|
}
|
|
16203
16748
|
/** Handles one packet from the track. Exposed for tests. */
|
|
16204
16749
|
receive(packet) {
|
|
16205
|
-
if (this.stopped
|
|
16750
|
+
if (this.stopped) return;
|
|
16751
|
+
this.heardAt = (this.options.now ?? Date.now)();
|
|
16752
|
+
if (isRtcp(packet)) return;
|
|
16206
16753
|
const rtp = parseRtp(packet);
|
|
16207
16754
|
if (!rtp || rtp.payloadType !== this.options.payloadType) return;
|
|
16208
16755
|
for (const item of this.reorder.push(rtp)) {
|
|
@@ -16231,7 +16778,7 @@ var CallAudio = class {
|
|
|
16231
16778
|
const due = this.startedAt + (this.ticks + 1) * 20;
|
|
16232
16779
|
this.timer = setTimeout(() => {
|
|
16233
16780
|
if (this.stopped) return;
|
|
16234
|
-
if (now() - due >
|
|
16781
|
+
if (now() - due > 1e3) {
|
|
16235
16782
|
this.startedAt = now();
|
|
16236
16783
|
this.ticks = 0;
|
|
16237
16784
|
} else this.ticks++;
|
|
@@ -16524,6 +17071,7 @@ var CallManager = class {
|
|
|
16524
17071
|
this.host = host;
|
|
16525
17072
|
this.configPath = join(host.profileDir, "calls.json");
|
|
16526
17073
|
this.auto = readAuto(this.configPath);
|
|
17074
|
+
if (host.answers !== false) clearAudioSockets(host.profileDir);
|
|
16527
17075
|
}
|
|
16528
17076
|
get now() {
|
|
16529
17077
|
return (this.host.now ?? Date.now)();
|
|
@@ -16808,6 +17356,9 @@ var CallManager = class {
|
|
|
16808
17356
|
startedAt: this.now,
|
|
16809
17357
|
connectedAt: null,
|
|
16810
17358
|
timer: null,
|
|
17359
|
+
watcher: null,
|
|
17360
|
+
stalledFrom: null,
|
|
17361
|
+
stalls: 0,
|
|
16811
17362
|
ended: false
|
|
16812
17363
|
};
|
|
16813
17364
|
this.calls.set(call.id, call);
|
|
@@ -16867,12 +17418,58 @@ var CallManager = class {
|
|
|
16867
17418
|
direction: call.direction,
|
|
16868
17419
|
audio: this.audioInfo(call)
|
|
16869
17420
|
});
|
|
17421
|
+
this.watch(call);
|
|
17422
|
+
}
|
|
17423
|
+
/**
|
|
17424
|
+
* libjuice says nothing of a contact whose side went away (a crash, a closed lid, a lost network) until its consent
|
|
17425
|
+
* checks fail, about 30 s later: a connected call watches the contact's packets instead. An app sends them all along
|
|
17426
|
+
* (silence while muted, and RTCP), and so does the CLI. Nothing for STALL_MS: `call.stalled`; packets again:
|
|
17427
|
+
* `call.resumed`; nothing for STALL_END_MS: the call ends as `failed`, and the contact's app is told.
|
|
17428
|
+
*/
|
|
17429
|
+
watch(call) {
|
|
17430
|
+
const { afterMs, endMs } = this.host.stall ?? {
|
|
17431
|
+
afterMs: 5e3,
|
|
17432
|
+
endMs: 15e3
|
|
17433
|
+
};
|
|
17434
|
+
const since = Date.now();
|
|
17435
|
+
call.watcher = setInterval(() => {
|
|
17436
|
+
if (call.ended) return;
|
|
17437
|
+
const heardAt = Math.max(since, call.media?.audio.heardAt ?? 0);
|
|
17438
|
+
const silentMs = Date.now() - heardAt;
|
|
17439
|
+
const fields = {
|
|
17440
|
+
call: call.id,
|
|
17441
|
+
chat: call.chat,
|
|
17442
|
+
direction: call.direction
|
|
17443
|
+
};
|
|
17444
|
+
if (silentMs >= endMs) {
|
|
17445
|
+
process.stderr.write(`ghostly: call ${call.id}: nothing heard from the contact for ${Math.round(silentMs / 1e3)} s\n`);
|
|
17446
|
+
this.end(call, "failed", true);
|
|
17447
|
+
} else if (silentMs >= afterMs && call.stalledFrom === null) {
|
|
17448
|
+
call.stalledFrom = heardAt;
|
|
17449
|
+
call.stalls++;
|
|
17450
|
+
process.stderr.write(`ghostly: call ${call.id}: stalled, nothing heard from the contact for ${silentMs} ms\n`);
|
|
17451
|
+
this.host.emit("call.stalled", `call.stalled:${call.id}:${call.stalls}`, {
|
|
17452
|
+
...fields,
|
|
17453
|
+
silentMs
|
|
17454
|
+
});
|
|
17455
|
+
} else if (silentMs < afterMs && call.stalledFrom !== null) {
|
|
17456
|
+
const gapMs = heardAt - call.stalledFrom;
|
|
17457
|
+
call.stalledFrom = null;
|
|
17458
|
+
process.stderr.write(`ghostly: call ${call.id}: resumed after ${gapMs} ms\n`);
|
|
17459
|
+
this.host.emit("call.resumed", `call.resumed:${call.id}:${call.stalls}`, {
|
|
17460
|
+
...fields,
|
|
17461
|
+
silentMs: gapMs
|
|
17462
|
+
});
|
|
17463
|
+
}
|
|
17464
|
+
}, Math.min(1e3, afterMs / 5));
|
|
17465
|
+
call.watcher.unref?.();
|
|
16870
17466
|
}
|
|
16871
17467
|
async end(call, reason, tell, report = true) {
|
|
16872
17468
|
if (call.ended) return;
|
|
16873
17469
|
call.ended = true;
|
|
16874
17470
|
call.reason = reason;
|
|
16875
17471
|
this.disarm(call);
|
|
17472
|
+
if (call.watcher) clearInterval(call.watcher);
|
|
16876
17473
|
this.calls.delete(call.id);
|
|
16877
17474
|
if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
|
|
16878
17475
|
call.media?.close();
|
|
@@ -16943,6 +17540,7 @@ var CallManager = class {
|
|
|
16943
17540
|
chat: call.chat,
|
|
16944
17541
|
direction: call.direction,
|
|
16945
17542
|
state: call.state,
|
|
17543
|
+
stalled: call.stalledFrom !== null,
|
|
16946
17544
|
video: call.video,
|
|
16947
17545
|
startedAt: call.startedAt,
|
|
16948
17546
|
connectedAt: call.connectedAt,
|
|
@@ -18428,10 +19026,11 @@ async function openPersistentIndexedDb(dir) {
|
|
|
18428
19026
|
const pending = /* @__PURE__ */ new WeakMap();
|
|
18429
19027
|
const snapshotPath = join(dir, SNAPSHOT);
|
|
18430
19028
|
const journalPath = join(dir, JOURNAL);
|
|
18431
|
-
|
|
18432
|
-
if (
|
|
19029
|
+
const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
|
|
19030
|
+
if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
|
|
19031
|
+
if (journalFound) replay(raw._databases, readFileSync(journalPath));
|
|
18433
19032
|
restoring = false;
|
|
18434
|
-
await writeSnapshot();
|
|
19033
|
+
if (journalFound || !snapshotFound) await writeSnapshot();
|
|
18435
19034
|
const storeProto = classes.ObjectStore.prototype;
|
|
18436
19035
|
const storeRecord = storeProto.storeRecord;
|
|
18437
19036
|
const deleteRecord = storeProto.deleteRecord;
|
|
@@ -18614,7 +19213,12 @@ async function probeClasses(factory) {
|
|
|
18614
19213
|
});
|
|
18615
19214
|
return classes;
|
|
18616
19215
|
}
|
|
18617
|
-
|
|
19216
|
+
/**
|
|
19217
|
+
* The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
|
|
19218
|
+
* built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
|
|
19219
|
+
* took seconds. A snapshot not in order (never written so) is stored record by record.
|
|
19220
|
+
*/
|
|
19221
|
+
function restore(classes, databases, snapshot, cmp) {
|
|
18618
19222
|
if (snapshot?.format !== 1) throw new Error("This profile was last used by a newer version of ghostly (its store has a format this version does not read). Update ghostly to open it.");
|
|
18619
19223
|
for (const saved of snapshot.databases) {
|
|
18620
19224
|
const db = new classes.Database(saved.name, saved.version);
|
|
@@ -18627,16 +19231,103 @@ function restore(classes, databases, snapshot) {
|
|
|
18627
19231
|
store.rawIndexes.set(i.name, index);
|
|
18628
19232
|
}
|
|
18629
19233
|
db.rawObjectStores.set(s.name, store);
|
|
18630
|
-
|
|
19234
|
+
const records = s.records.map(([key, value]) => ({
|
|
18631
19235
|
key,
|
|
18632
19236
|
value: blobsFromDisk(value)
|
|
18633
|
-
}
|
|
19237
|
+
}));
|
|
19238
|
+
if (records.some((record, at) => at > 0 && cmp(records[at - 1].key, record.key) >= 0)) {
|
|
19239
|
+
for (const record of records) store.storeRecord(record, false);
|
|
19240
|
+
continue;
|
|
19241
|
+
}
|
|
19242
|
+
fill(store.records, records);
|
|
19243
|
+
for (const index of store.rawIndexes.values()) fill(index.records, indexEntries(index, records, cmp));
|
|
18634
19244
|
}
|
|
18635
19245
|
databases.set(saved.name, db);
|
|
18636
19246
|
}
|
|
18637
19247
|
}
|
|
18638
|
-
/**
|
|
19248
|
+
/**
|
|
19249
|
+
* An index's entries for records in key order, sorted by the index's key: the index derives them itself (key path,
|
|
19250
|
+
* multi-entry, invalid keys skipped) into a list instead of its tree. A stable sort keeps equal index keys in the
|
|
19251
|
+
* records' key order, the tree's own tie-break.
|
|
19252
|
+
*/
|
|
19253
|
+
function indexEntries(index, records, cmp) {
|
|
19254
|
+
const entries = [];
|
|
19255
|
+
const tree = index.records;
|
|
19256
|
+
index.records = {
|
|
19257
|
+
get: () => void 0,
|
|
19258
|
+
put: (entry) => {
|
|
19259
|
+
entries.push(entry);
|
|
19260
|
+
}
|
|
19261
|
+
};
|
|
19262
|
+
try {
|
|
19263
|
+
for (const record of records) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
|
|
19264
|
+
} finally {
|
|
19265
|
+
index.records = tree;
|
|
19266
|
+
}
|
|
19267
|
+
return entries.sort((a, b) => cmp(a.key, b.key));
|
|
19268
|
+
}
|
|
19269
|
+
/**
|
|
19270
|
+
* Whether a key path surely finds nothing in a value: a plain object along it lacks the step (most messages have no
|
|
19271
|
+
* `card` for the card index). The index would throw and catch a DataError for each of those; anything else it judges.
|
|
19272
|
+
*/
|
|
19273
|
+
function absent(keyPath, value) {
|
|
19274
|
+
if (typeof keyPath !== "string" || keyPath === "") return false;
|
|
19275
|
+
let object = value;
|
|
19276
|
+
for (const step of keyPath.split(".")) {
|
|
19277
|
+
if (object === null || typeof object !== "object" || Array.isArray(object) || object instanceof Blob) return false;
|
|
19278
|
+
if (!Object.hasOwn(object, step)) return true;
|
|
19279
|
+
object = object[step];
|
|
19280
|
+
}
|
|
19281
|
+
return false;
|
|
19282
|
+
}
|
|
19283
|
+
/**
|
|
19284
|
+
* fake-indexeddb's red-black tree, built balanced from sorted records: every level full but the last, whose nodes
|
|
19285
|
+
* are red, so each path holds the same number of black nodes and later inserts rebalance as usual.
|
|
19286
|
+
*/
|
|
19287
|
+
function fill(store, sorted) {
|
|
19288
|
+
const deepest = sorted.length ? Math.floor(Math.log2(sorted.length)) : 0;
|
|
19289
|
+
const build = (low, high, parent, depth) => {
|
|
19290
|
+
if (low > high) return void 0;
|
|
19291
|
+
const middle = low + high >>> 1;
|
|
19292
|
+
const node = {
|
|
19293
|
+
record: sorted[middle],
|
|
19294
|
+
left: void 0,
|
|
19295
|
+
right: void 0,
|
|
19296
|
+
parent,
|
|
19297
|
+
deleted: false,
|
|
19298
|
+
red: depth > 0 && depth === deepest
|
|
19299
|
+
};
|
|
19300
|
+
node.left = build(low, middle - 1, node, depth + 1);
|
|
19301
|
+
node.right = build(middle + 1, high, node, depth + 1);
|
|
19302
|
+
return node;
|
|
19303
|
+
};
|
|
19304
|
+
store.records._root = build(0, sorted.length - 1, void 0, 0);
|
|
19305
|
+
store.records._numNodes = sorted.length;
|
|
19306
|
+
store.records._numTombstones = 0;
|
|
19307
|
+
}
|
|
19308
|
+
/**
|
|
19309
|
+
* Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
|
|
19310
|
+
*
|
|
19311
|
+
* A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
|
|
19312
|
+
* overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
|
|
19313
|
+
* tens of seconds to minutes to start, one write at a time against the whole profile.
|
|
19314
|
+
*/
|
|
18639
19315
|
function replay(databases, journal) {
|
|
19316
|
+
const touched = /* @__PURE__ */ new Map();
|
|
19317
|
+
try {
|
|
19318
|
+
replayEntries(databases, journal, touched);
|
|
19319
|
+
} finally {
|
|
19320
|
+
for (const [store, indexes] of touched) {
|
|
19321
|
+
store.rawIndexes = indexes;
|
|
19322
|
+
for (const index of indexes.values()) {
|
|
19323
|
+
index.records.clear();
|
|
19324
|
+
if (!index.initialized) continue;
|
|
19325
|
+
for (const record of store.records.values()) index.storeRecord(record);
|
|
19326
|
+
}
|
|
19327
|
+
}
|
|
19328
|
+
}
|
|
19329
|
+
}
|
|
19330
|
+
function replayEntries(databases, journal, touched) {
|
|
18640
19331
|
let offset = 0;
|
|
18641
19332
|
while (offset + 4 <= journal.length) {
|
|
18642
19333
|
const length = journal.readUInt32BE(offset);
|
|
@@ -18651,6 +19342,10 @@ function replay(databases, journal) {
|
|
|
18651
19342
|
for (const op of ops) {
|
|
18652
19343
|
const store = databases.get(op.db)?.rawObjectStores.get(op.store);
|
|
18653
19344
|
if (!store) continue;
|
|
19345
|
+
if (!touched.has(store)) {
|
|
19346
|
+
touched.set(store, store.rawIndexes);
|
|
19347
|
+
store.rawIndexes = /* @__PURE__ */ new Map();
|
|
19348
|
+
}
|
|
18654
19349
|
if (op.kind === "put") store.storeRecord({
|
|
18655
19350
|
key: op.key,
|
|
18656
19351
|
value: blobsFromDisk(op.value)
|
|
@@ -18892,7 +19587,7 @@ async function startRuntime(paths, options = {}) {
|
|
|
18892
19587
|
const restored = join(paths.dir, RESTORED_MARK);
|
|
18893
19588
|
if (existsSync(restored)) {
|
|
18894
19589
|
try {
|
|
18895
|
-
const rows = await import("./assets/restoredRows-
|
|
19590
|
+
const rows = await import("./assets/restoredRows-C_84q0Pd.js");
|
|
18896
19591
|
await rows.markRestoredWallet();
|
|
18897
19592
|
await rows.markLeftOutFiles(leftOutIn(restored));
|
|
18898
19593
|
await store.compact();
|
|
@@ -18911,16 +19606,16 @@ async function startRuntime(paths, options = {}) {
|
|
|
18911
19606
|
await installEventSource();
|
|
18912
19607
|
await preloadWalletModules();
|
|
18913
19608
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
18914
|
-
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-
|
|
19609
|
+
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
|
|
18915
19610
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
18916
|
-
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-
|
|
19611
|
+
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-BGXmSYx_.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
|
|
18917
19612
|
const network = hyperdhtNetwork();
|
|
18918
19613
|
const dht = mainlineNetwork();
|
|
18919
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
19614
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-BueFJx3f.js"))).Mainline({
|
|
18920
19615
|
bootstrap: dht.bootstrap,
|
|
18921
19616
|
host: dht.host
|
|
18922
19617
|
});
|
|
18923
|
-
const transport = mainline || pinned ? new (await (import("./assets/mainline-
|
|
19618
|
+
const transport = mainline || pinned ? new (await (import("./assets/mainline-BueFJx3f.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
|
|
18924
19619
|
const server = new EngineServer({
|
|
18925
19620
|
...transport ? { transport } : {},
|
|
18926
19621
|
irohWeb: true,
|
|
@@ -18930,6 +19625,7 @@ async function startRuntime(paths, options = {}) {
|
|
|
18930
19625
|
automaticWallets: false,
|
|
18931
19626
|
fedimintSdk: nodeFedimintSdk(join(paths.dir, "fedimint")),
|
|
18932
19627
|
servicesSupport: true,
|
|
19628
|
+
apps: false,
|
|
18933
19629
|
staysOnline: process.env.GHOSTLY_HUB !== "0",
|
|
18934
19630
|
localFetch: nodeLocalFetch,
|
|
18935
19631
|
pushSend: (request) => nodePushSend(request),
|
|
@@ -18979,6 +19675,7 @@ async function openHost(paths, mode, version, options = {}) {
|
|
|
18979
19675
|
await hub.open();
|
|
18980
19676
|
const node = runtime.server.node;
|
|
18981
19677
|
const now = node.getState();
|
|
19678
|
+
const mark = runtime.server.historyMark;
|
|
18982
19679
|
const histories = /* @__PURE__ */ new Map();
|
|
18983
19680
|
for (const link of now.links) histories.set(link.id, await node.getMessages(link.id));
|
|
18984
19681
|
for (const group of now.groups) histories.set(`group:${group.id}`, await node.groupMessages({ groupId: group.id }));
|
|
@@ -18992,7 +19689,10 @@ async function openHost(paths, mode, version, options = {}) {
|
|
|
18992
19689
|
});
|
|
18993
19690
|
const manager = calls;
|
|
18994
19691
|
hub.onCallSignal((chat, signal) => manager.onSignal(chat, signal));
|
|
18995
|
-
runtime.server.attach(hub.sink
|
|
19692
|
+
runtime.server.attach(hub.sink, {
|
|
19693
|
+
mark,
|
|
19694
|
+
ids: histories.keys()
|
|
19695
|
+
});
|
|
18996
19696
|
} catch (error) {
|
|
18997
19697
|
await runtime.close().catch(() => {});
|
|
18998
19698
|
release();
|
|
@@ -19320,6 +20020,7 @@ async function post(url, event) {
|
|
|
19320
20020
|
try {
|
|
19321
20021
|
if ((await fetch(url, {
|
|
19322
20022
|
method: "POST",
|
|
20023
|
+
redirect: "manual",
|
|
19323
20024
|
headers: {
|
|
19324
20025
|
"content-type": "application/json",
|
|
19325
20026
|
"x-ghostly-event": event.type,
|
|
@@ -19508,7 +20209,10 @@ var SPECIAL = [
|
|
|
19508
20209
|
{
|
|
19509
20210
|
out: o("string", "The backup file to write"),
|
|
19510
20211
|
"passphrase-file": o("string", "The passphrase, from this file (else GHOSTLY_BACKUP_PASSPHRASE)"),
|
|
19511
|
-
passphrase:
|
|
20212
|
+
passphrase: {
|
|
20213
|
+
...o("boolean", "Do not encrypt. The file then holds the profile's keys and wallet secrets in the clear"),
|
|
20214
|
+
negatedOnly: true
|
|
20215
|
+
},
|
|
19512
20216
|
light: o("boolean", "Leave out files over 1 MB (voice messages over 4 MB); their messages stay")
|
|
19513
20217
|
}
|
|
19514
20218
|
],
|
|
@@ -19579,7 +20283,8 @@ var SPECIAL = [
|
|
|
19579
20283
|
days: o("number", "How long the proof holds"),
|
|
19580
20284
|
timeout: o("number", "Seconds to wait for an approval")
|
|
19581
20285
|
}
|
|
19582
|
-
]
|
|
20286
|
+
],
|
|
20287
|
+
["version", "This CLI's version (also ghostly --version)"]
|
|
19583
20288
|
];
|
|
19584
20289
|
function help() {
|
|
19585
20290
|
const rows = [
|
|
@@ -19645,7 +20350,9 @@ function commandHelp(words) {
|
|
|
19645
20350
|
const options = Object.entries(row.options ?? {}).filter(([, o]) => o.description);
|
|
19646
20351
|
if (options.length) {
|
|
19647
20352
|
lines.push("", "Options:");
|
|
19648
|
-
|
|
20353
|
+
const labels = options.map(([name, o]) => ` --${o.negatedOnly ? "no-" : ""}${name}${o.type === "boolean" ? "" : ` <${o.type === "number" ? "n" : "value"}>`}`);
|
|
20354
|
+
const width = Math.max(26, ...labels.map((label) => label.length + 2));
|
|
20355
|
+
options.forEach(([, o], i) => lines.push(labels[i].padEnd(width) + o.description));
|
|
19649
20356
|
}
|
|
19650
20357
|
if (Object.values(TEXT_COMMANDS).includes(row)) lines.push("", " -- ends the options: everything after it is the text, options included (\"-- -1 --wait sent\" sends", " all of it). Put options before --.");
|
|
19651
20358
|
lines.push("");
|
|
@@ -19733,6 +20440,7 @@ async function profileCommand(sub, argv) {
|
|
|
19733
20440
|
return;
|
|
19734
20441
|
}
|
|
19735
20442
|
case "list": {
|
|
20443
|
+
noMoreThan(0, parsed.positionals, "profile list");
|
|
19736
20444
|
const current = currentProfile(g.home);
|
|
19737
20445
|
print({
|
|
19738
20446
|
profiles: listProfiles(g.home).map((name) => ({
|
|
@@ -19747,14 +20455,17 @@ async function profileCommand(sub, argv) {
|
|
|
19747
20455
|
case "use": {
|
|
19748
20456
|
const [name] = parsed.positionals;
|
|
19749
20457
|
if (!name) throw new CliError("usage", "ghostly profile use <name>");
|
|
20458
|
+
noMoreThan(1, parsed.positionals, "profile use <name>");
|
|
19750
20459
|
selectProfile(g.home, name);
|
|
19751
20460
|
print({ current: name });
|
|
19752
20461
|
return;
|
|
19753
20462
|
}
|
|
19754
20463
|
case "show":
|
|
20464
|
+
noMoreThan(0, parsed.positionals, "profile show");
|
|
19755
20465
|
print(await withSession(g, (s) => s.call("profile.get")));
|
|
19756
20466
|
return;
|
|
19757
20467
|
case "set": {
|
|
20468
|
+
noMoreThan(0, parsed.positionals, "profile set [--name <name>] [--share-profile | --no-share-profile]");
|
|
19758
20469
|
const params = {};
|
|
19759
20470
|
if (parsed.options.name !== void 0) params.name = parsed.options.name;
|
|
19760
20471
|
if (parsed.options["share-profile"] !== void 0) params.shareProfile = parsed.options["share-profile"];
|
|
@@ -19763,6 +20474,7 @@ async function profileCommand(sub, argv) {
|
|
|
19763
20474
|
return;
|
|
19764
20475
|
}
|
|
19765
20476
|
case "backup": {
|
|
20477
|
+
noMoreThan(0, parsed.positionals, "profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light]");
|
|
19766
20478
|
const out = parsed.options.out;
|
|
19767
20479
|
if (typeof out !== "string") throw new CliError("usage", "ghostly profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light] (or GHOSTLY_BACKUP_PASSPHRASE)");
|
|
19768
20480
|
const unsealed = parsed.options.passphrase === false;
|
|
@@ -19785,6 +20497,7 @@ async function profileCommand(sub, argv) {
|
|
|
19785
20497
|
case "restore": {
|
|
19786
20498
|
const [file, name] = parsed.positionals;
|
|
19787
20499
|
if (!file || !name) throw new CliError("usage", "ghostly profile restore <file> <new profile> [--passphrase-file f] [--use]");
|
|
20500
|
+
noMoreThan(2, parsed.positionals, "profile restore <file> <new profile> [--passphrase-file f] [--use]");
|
|
19788
20501
|
const protection = await backupFileProtection(file);
|
|
19789
20502
|
const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"]));
|
|
19790
20503
|
if (parsed.options.use) selectProfile(g.home, name);
|
|
@@ -19872,6 +20585,11 @@ async function daemonCommand(argv) {
|
|
|
19872
20585
|
});
|
|
19873
20586
|
const g = globals(parsed);
|
|
19874
20587
|
pretty = g.pretty;
|
|
20588
|
+
if (!sub || [
|
|
20589
|
+
"status",
|
|
20590
|
+
"stop",
|
|
20591
|
+
"restart"
|
|
20592
|
+
].includes(sub)) noMoreThan(0, parsed.positionals, sub ? `daemon ${sub}` : "daemon [--detach]");
|
|
19875
20593
|
if (sub === "status") {
|
|
19876
20594
|
const client = await connectDaemon(g.paths.socket);
|
|
19877
20595
|
if (!client) {
|
|
@@ -20143,6 +20861,7 @@ async function engineCommand(argv) {
|
|
|
20143
20861
|
const g = globals(parsed);
|
|
20144
20862
|
pretty = g.pretty;
|
|
20145
20863
|
if (parsed.options.list) {
|
|
20864
|
+
noMoreThan(0, parsed.positionals, "engine --list");
|
|
20146
20865
|
print({ methods: [...ENGINE_METHODS, ...ENGINE_READS].sort() });
|
|
20147
20866
|
return;
|
|
20148
20867
|
}
|
|
@@ -20186,6 +20905,7 @@ async function settingsCommand(sub, argv) {
|
|
|
20186
20905
|
const g = globals(parsed);
|
|
20187
20906
|
pretty = g.pretty;
|
|
20188
20907
|
if (sub === "get") {
|
|
20908
|
+
noMoreThan(0, parsed.positionals, "settings get [--show-secret]");
|
|
20189
20909
|
print(await withSession(g, (s) => s.call("settings.get", { showSecret: parsed.options["show-secret"] === true })));
|
|
20190
20910
|
return;
|
|
20191
20911
|
}
|
|
@@ -20198,6 +20918,7 @@ async function settingsCommand(sub, argv) {
|
|
|
20198
20918
|
} catch {
|
|
20199
20919
|
parsedValue = value;
|
|
20200
20920
|
}
|
|
20921
|
+
if (SETTABLE[key] === "string" && typeof parsedValue !== "string") parsedValue = value;
|
|
20201
20922
|
print(await withSession(g, (s) => s.call("settings.set", { [key]: parsedValue })));
|
|
20202
20923
|
return;
|
|
20203
20924
|
}
|
|
@@ -20358,7 +21079,7 @@ async function tableCommand(name, argv) {
|
|
|
20358
21079
|
* contact's, raw PCM); what the command says goes to stderr. It ends when the call does.
|
|
20359
21080
|
*/
|
|
20360
21081
|
async function callPipeCommand(argv) {
|
|
20361
|
-
const parsed = parseArgs(argv, {}, idSlot({ args: ["call
|
|
21082
|
+
const parsed = parseArgs(argv, {}, idSlot({ args: ["call...?"] }));
|
|
20362
21083
|
const g = globals(parsed);
|
|
20363
21084
|
const ref = parsed.positionals.join(" ") || void 0;
|
|
20364
21085
|
const client = await connectDaemon(g.paths.socket);
|
|
@@ -20400,7 +21121,7 @@ function helpAsked(argv) {
|
|
|
20400
21121
|
}
|
|
20401
21122
|
async function main(input) {
|
|
20402
21123
|
const argv = liftGlobals(input);
|
|
20403
|
-
const helpFor = argv.length === 0 ? [] : helpAsked(argv);
|
|
21124
|
+
const helpFor = argv.length === 0 || globalOption(argv[0]) ? [] : helpAsked(argv);
|
|
20404
21125
|
if (helpFor) {
|
|
20405
21126
|
process.stdout.write((helpFor.length ? commandHelp(helpFor) : help()) + "\n");
|
|
20406
21127
|
return 0;
|