@ghostlytools/cli 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +25 -9
  2. package/dist/assets/api-Dq2QXQDk.js +4172 -0
  3. package/dist/assets/api-Dq2QXQDk.js.map +1 -0
  4. package/dist/assets/api-TiB5QSG9.js +3 -0
  5. package/dist/assets/apiKit-CA2x5GJF.js +200 -0
  6. package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
  7. package/dist/assets/apps-BxV9i_Y4.js +786 -0
  8. package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
  9. package/dist/assets/apps-C-T8jeji.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
  12. package/dist/assets/backup-Bugnn3Ue.js +2 -0
  13. package/dist/assets/backup-CnMN2oUK.js +731 -0
  14. package/dist/assets/backup-CnMN2oUK.js.map +1 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/bitcoind-CZchoh43.js +349 -0
  17. package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
  18. package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
  19. package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
  20. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  21. package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
  22. package/dist/assets/buttons-CvNBzh2S.js +496 -0
  23. package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
  24. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
  25. package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
  26. package/dist/assets/envelope-WjingYSc.js +90 -0
  27. package/dist/assets/envelope-WjingYSc.js.map +1 -0
  28. package/dist/assets/errors-DO5z6h_l.js +105 -0
  29. package/dist/assets/errors-DO5z6h_l.js.map +1 -0
  30. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  31. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  32. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  33. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  34. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  35. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  36. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  37. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  38. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  39. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  40. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  41. package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
  42. package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
  43. package/dist/assets/host-CWt-jtqz.js +4673 -0
  44. package/dist/assets/host-CWt-jtqz.js.map +1 -0
  45. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  46. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  47. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
  48. package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
  49. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  50. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  51. package/dist/assets/irohWeb-4oChIOIp.js +544 -0
  52. package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
  53. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  54. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  55. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  56. package/dist/assets/main-DbqcdShN.js +4409 -0
  57. package/dist/assets/main-DbqcdShN.js.map +1 -0
  58. package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
  59. package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
  60. package/dist/assets/mints-BrlY773j.js +74 -0
  61. package/dist/assets/mints-BrlY773j.js.map +1 -0
  62. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  63. package/dist/assets/networkReason-CetKkTPo.js +154 -0
  64. package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
  65. package/dist/assets/nwc-CgkqyvsE.js +612 -0
  66. package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
  67. package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
  68. package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
  69. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  70. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  71. package/dist/assets/profiles-BHc9pEYg.js +139 -0
  72. package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
  73. package/dist/assets/registry-DlAKO0Fy.js +3793 -0
  74. package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
  75. package/dist/assets/registry-OBJBnHVQ.js +2 -0
  76. package/dist/assets/resolve-CouHhCpJ.js +591 -0
  77. package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
  78. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  79. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  80. package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
  81. package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
  82. package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
  83. package/dist/assets/src-DDzSERyc.js.map +1 -0
  84. package/dist/assets/turnRefs-B87dtrHM.js +323 -0
  85. package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
  86. package/dist/assets/types-C4_KafL_.js +122 -0
  87. package/dist/assets/types-C4_KafL_.js.map +1 -0
  88. package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
  89. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
  90. package/dist/ghostly.mjs +4 -22077
  91. package/dist/ghostly.mjs.map +1 -1
  92. package/package.json +1 -1
  93. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  94. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  95. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  96. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  97. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  98. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  99. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  100. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -0,0 +1,4673 @@
1
+ import { Go as answersOffer, Jo as callSignalHeardAt, Ko as buildSdpFromSignal, Oi as isPublicAddress, Uo as CALL_SIGNAL_MAX_AGE_MS, Wo as RTC_CONFIG, Xo as parseCallSignal, Yo as extractParamsFromSdp, Zc as setLinkTraceSink, Zo as signalHasVideo, gl as fromBase64Url, ii as checkPushEndpoint, qo as callRtcConfig } from "./src-DDzSERyc.js";
2
+ import { a as fedimintSdkOn, r as irohRelayProblem } from "./irohWeb-4oChIOIp.js";
3
+ import { r as asCliError, t as CliError } from "./errors-DO5z6h_l.js";
4
+ import { a as findChat } from "./apiKit-CA2x5GJF.js";
5
+ import { _ as flushCardUpdates, c as paymentJson, d as nodeLocalFetch, f as chatJson, g as resumeHolds, h as groupMessageJson, l as reportAppFrames, m as groupJson, p as chatMessageJson, r as callApi, u as servedApps } from "./api-Dq2QXQDk.js";
6
+ import { n as RESTORED_MARK, r as acquireLock } from "./profiles-BHc9pEYg.js";
7
+ import { r as privateFolder, t as ownFolder } from "./privateFolder-Bk9C9oFv.js";
8
+ import { createRequire } from "node:module";
9
+ import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { createHash, randomBytes } from "node:crypto";
13
+ import { createServer, isIP } from "node:net";
14
+ import { createInterface } from "node:readline";
15
+ import { lookup } from "node:dns/promises";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+ import { Worker } from "node:worker_threads";
18
+ import { lookup as lookup$1 } from "node:dns";
19
+ import { request } from "node:https";
20
+ import { deserialize, serialize } from "node:v8";
21
+ import { IDBFactory, IDBKeyRange, IDBTransaction } from "fake-indexeddb";
22
+ //#region src/announce.ts
23
+ /**
24
+ * What the app's chat screen does by itself, done here for a profile no screen shows (apps/ui/src/hooks/useChat.ts): the side
25
+ * that joined says "👋 <name> joined" once the chat first goes live, and the side that invited answers once. Contacts
26
+ * see the bot arrive as they see a person; each is said once per chat, across restarts.
27
+ */
28
+ function announceJoins(hub, node) {
29
+ const say = (chat) => {
30
+ if (hub.said(chat, "join")) return;
31
+ hub.markSaid(chat, "join");
32
+ const nick = node.getState().settings.nick?.trim();
33
+ node.sendMessage({
34
+ linkId: chat,
35
+ text: nick ? `👋 ${nick} joined` : "👋 joined"
36
+ }).catch(() => {});
37
+ };
38
+ return hub.onEvent((event) => {
39
+ const chat = event.chat;
40
+ if (!chat) return;
41
+ const link = node.getState().links.find((l) => l.id === chat);
42
+ if (!link?.profile) return;
43
+ if (event.type === "chat.connection" && event.live && link.pairingProgress?.role === "joiner") say(chat);
44
+ else if (event.type === "chat.joined") say(chat);
45
+ });
46
+ }
47
+ var CALL_RATES = [
48
+ 8e3,
49
+ 12e3,
50
+ 16e3,
51
+ 24e3,
52
+ 48e3
53
+ ];
54
+ var DEFAULT_RATE = 48e3;
55
+ var frameSamples = (rate) => rate * 20 / 1e3;
56
+ var frameBytes = (rate) => frameSamples(rate) * 2;
57
+ var bytesToMs = (rate, bytes) => Math.round(bytes / 2 / rate * 1e3);
58
+ function isCallRate(value) {
59
+ return CALL_RATES.includes(value);
60
+ }
61
+ /** The most a program may queue ahead of the call: five minutes of speech. What goes past it is dropped. */
62
+ var MAX_QUEUE_MS = 3e5;
63
+ /**
64
+ * What the program wrote, waiting to be sent: taken one 20 ms frame per tick, silence when there is nothing, and
65
+ * dropped whole by a flush (barge-in). The program may write any amount at any time, faster than real time too.
66
+ */
67
+ var PlaybackQueue = class {
68
+ rate;
69
+ now;
70
+ chunks = [];
71
+ head = 0;
72
+ length = 0;
73
+ lastPush = 0;
74
+ frame;
75
+ max;
76
+ silence;
77
+ /** Bytes dropped because the queue was full, since the start. */
78
+ dropped = 0;
79
+ constructor(rate, now = Date.now, maxMs = MAX_QUEUE_MS) {
80
+ this.rate = rate;
81
+ this.now = now;
82
+ this.frame = frameBytes(rate);
83
+ this.max = Math.floor(rate * maxMs / 1e3) * 2;
84
+ this.silence = Buffer.alloc(this.frame);
85
+ }
86
+ /** Adds what the program wrote. Returns the bytes it could not keep (the queue was full). */
87
+ push(chunk) {
88
+ if (!chunk.length) return 0;
89
+ this.lastPush = this.now();
90
+ const room = this.max - this.length;
91
+ const kept = chunk.length <= room ? chunk : chunk.subarray(0, Math.max(0, room));
92
+ const lost = chunk.length - kept.length;
93
+ this.dropped += lost;
94
+ if (kept.length) {
95
+ this.chunks.push(Buffer.from(kept));
96
+ this.length += kept.length;
97
+ }
98
+ return lost;
99
+ }
100
+ /** Queued audio, in milliseconds (whole frames and a partial one). */
101
+ get queuedMs() {
102
+ return bytesToMs(this.rate, this.length - this.length % 2);
103
+ }
104
+ /** One frame to send now, and whether it is the program's audio or silence. */
105
+ next() {
106
+ if (this.length >= this.frame) return {
107
+ frame: this.take(this.frame),
108
+ silent: false
109
+ };
110
+ const whole = this.length - this.length % 2;
111
+ if (whole > 0 && this.now() - this.lastPush >= 60) {
112
+ const frame = Buffer.alloc(this.frame);
113
+ this.take(whole).copy(frame);
114
+ return {
115
+ frame,
116
+ silent: false
117
+ };
118
+ }
119
+ return {
120
+ frame: this.silence,
121
+ silent: true
122
+ };
123
+ }
124
+ /** Drops everything queued; returns how much, in milliseconds. */
125
+ flush() {
126
+ const ms = this.queuedMs;
127
+ this.chunks = [];
128
+ this.head = 0;
129
+ this.length = 0;
130
+ return ms;
131
+ }
132
+ /** Drops up to that much from the front, in whole frames (audio whose time went by); returns how much, in milliseconds. */
133
+ skip(ms) {
134
+ const frames = Math.min(Math.floor(ms / 20), Math.floor(this.length / this.frame));
135
+ if (frames > 0) this.take(frames * this.frame);
136
+ return Math.max(0, frames) * 20;
137
+ }
138
+ take(bytes) {
139
+ const out = Buffer.allocUnsafe(bytes);
140
+ let filled = 0;
141
+ while (filled < bytes) {
142
+ const chunk = this.chunks[0];
143
+ const n = Math.min(bytes - filled, chunk.length - this.head);
144
+ chunk.copy(out, filled, this.head, this.head + n);
145
+ filled += n;
146
+ this.head += n;
147
+ if (this.head === chunk.length) {
148
+ this.chunks.shift();
149
+ this.head = 0;
150
+ }
151
+ }
152
+ this.length -= bytes;
153
+ return out;
154
+ }
155
+ };
156
+ //#endregion
157
+ //#region src/calls/audioSocket.ts
158
+ /**
159
+ * A call's audio socket (WISP 1100 § Calls): `<profile>/calls/<call>.sock`, 0600, in a folder only the owner may
160
+ * open. When that path is too long for a Unix socket: `/tmp/ghostly-calls-<hash of the profile>/<call>.sock`, in a
161
+ * folder of the owner's, 0700, checked as such before use. A named pipe on Windows.
162
+ */
163
+ function audioSocketPath(profileDir, callId) {
164
+ const inside = join(profileDir, "calls", `${callId}.sock`);
165
+ if (process.platform === "win32") return `\\\\.\\pipe\\ghostly-call-${createHash("sha256").update(inside).digest("hex").slice(0, 24)}`;
166
+ if (Buffer.byteLength(inside) <= 100) return inside;
167
+ return join("/tmp", `ghostly-calls-${createHash("sha256").update(profileDir).digest("hex").slice(0, 24)}`, `${callId}.sock`);
168
+ }
169
+ /**
170
+ * Removes the call sockets a daemon that died during a call left in this profile's folder (`audioSocketPath`; call
171
+ * ids are 12 hex digits): no call outlives its daemon. A folder that is a link or someone else's is left alone.
172
+ */
173
+ function clearAudioSockets(profileDir) {
174
+ if (process.platform === "win32") return;
175
+ const folder = dirname(audioSocketPath(profileDir, "0".repeat(12)));
176
+ try {
177
+ const info = lstatSync(folder);
178
+ if (!info.isDirectory() || process.getuid && info.uid !== process.getuid()) return;
179
+ for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.name.endsWith(".sock") && !entry.isDirectory()) rmSync(join(folder, entry.name), { force: true });
180
+ } catch {}
181
+ }
182
+ /**
183
+ * Serves one call's audio to one program at a time: what the program writes is handed to `onAudio` as it comes (raw
184
+ * PCM, any amount); `write` gives it the caller's audio. A second connection replaces the first. `close` ends the
185
+ * program's connection (it reads EOF) and removes the socket.
186
+ */
187
+ var AudioSocket = class AudioSocket {
188
+ path;
189
+ server;
190
+ rate;
191
+ client = null;
192
+ closed = false;
193
+ /** Frames the program did not read in time, since the start. */
194
+ dropped = 0;
195
+ constructor(path, server, rate) {
196
+ this.path = path;
197
+ this.server = server;
198
+ this.rate = rate;
199
+ }
200
+ static async open(path, rate, handlers) {
201
+ if (process.platform !== "win32") {
202
+ privateFolder(join(path, ".."), "calls will not put their audio there");
203
+ if (existsSync(path)) rmSync(path, { force: true });
204
+ }
205
+ let self = null;
206
+ const server = createServer((socket) => self.accept(socket, handlers));
207
+ await new Promise((resolve, reject) => {
208
+ server.once("error", reject);
209
+ server.listen(path, () => {
210
+ server.off("error", reject);
211
+ resolve();
212
+ });
213
+ });
214
+ if (process.platform !== "win32") chmodSync(path, 384);
215
+ self = new AudioSocket(path, server, rate);
216
+ return self;
217
+ }
218
+ get connected() {
219
+ return !!this.client && !this.client.destroyed;
220
+ }
221
+ accept(socket, handlers) {
222
+ if (this.closed) {
223
+ socket.destroy();
224
+ return;
225
+ }
226
+ const before = this.client;
227
+ this.client = socket;
228
+ before?.end();
229
+ socket.setNoDelay?.(true);
230
+ socket.on("data", (chunk) => {
231
+ if (this.client === socket) handlers.onAudio(chunk);
232
+ });
233
+ socket.on("error", () => {});
234
+ socket.on("close", () => {
235
+ if (this.client !== socket) return;
236
+ this.client = null;
237
+ handlers.onClient?.(false);
238
+ });
239
+ handlers.onClient?.(true);
240
+ }
241
+ /** One frame of the caller's audio for the program; dropped when nobody listens or the program stopped reading. */
242
+ write(frame) {
243
+ const client = this.client;
244
+ if (!client || client.destroyed) return;
245
+ if (bytesToMs(this.rate, client.writableLength) > 1e3) {
246
+ this.dropped++;
247
+ return;
248
+ }
249
+ client.write(frame);
250
+ }
251
+ /** Ends the program's connection (EOF once what was written is read) and removes the socket. */
252
+ close() {
253
+ if (this.closed) return Promise.resolve();
254
+ this.closed = true;
255
+ const client = this.client;
256
+ this.client = null;
257
+ client?.end();
258
+ if (client) setTimeout(() => client.destroy(), 2e3).unref();
259
+ return new Promise((resolve) => {
260
+ this.server.close(() => resolve());
261
+ if (process.platform !== "win32") rmSync(this.path, { force: true });
262
+ });
263
+ }
264
+ };
265
+ /** Whether a packet on the muxed port is RTCP (RFC 5761 § 4: its second byte is 192-223), not RTP. */
266
+ function isRtcp(packet) {
267
+ return packet.length >= 2 && packet[1] >= 192 && packet[1] <= 223;
268
+ }
269
+ /** Reads an RTP packet: CSRCs, a header extension and padding are stepped over. Null for anything malformed. */
270
+ function parseRtp(packet) {
271
+ if (packet.length < 12 || packet[0] >> 6 !== 2 || isRtcp(packet)) return null;
272
+ let offset = 12 + (packet[0] & 15) * 4;
273
+ if (packet[0] & 16) {
274
+ if (packet.length < offset + 4) return null;
275
+ offset += 4 + packet.readUInt16BE(offset + 2) * 4;
276
+ }
277
+ let end = packet.length;
278
+ if (packet[0] & 32) {
279
+ const padding = packet[packet.length - 1];
280
+ if (padding === 0) return null;
281
+ end -= padding;
282
+ }
283
+ if (end < offset) return null;
284
+ return {
285
+ payloadType: packet[1] & 127,
286
+ marker: (packet[1] & 128) !== 0,
287
+ sequence: packet.readUInt16BE(2),
288
+ timestamp: packet.readUInt32BE(4),
289
+ ssrc: packet.readUInt32BE(8),
290
+ payload: packet.subarray(offset, end)
291
+ };
292
+ }
293
+ /** Numbers this side's packets: one SSRC, a sequence and a timestamp from random starts. */
294
+ var RtpWriter = class {
295
+ ssrc;
296
+ payloadType;
297
+ sequence = randomBytes(2).readUInt16BE(0);
298
+ timestamp = randomBytes(4).readUInt32BE(0);
299
+ constructor(ssrc, payloadType) {
300
+ this.ssrc = ssrc;
301
+ this.payloadType = payloadType;
302
+ }
303
+ /** The next frame's packet. `marker` starts a talkspurt (the first sound after silence). */
304
+ packet(payload, marker = false) {
305
+ const header = Buffer.allocUnsafe(12);
306
+ header[0] = 128;
307
+ header[1] = (marker ? 128 : 0) | this.payloadType & 127;
308
+ header.writeUInt16BE(this.sequence, 2);
309
+ header.writeUInt32BE(this.timestamp, 4);
310
+ header.writeUInt32BE(this.ssrc >>> 0, 8);
311
+ this.sequence = this.sequence + 1 & 65535;
312
+ this.timestamp = this.timestamp + 960 >>> 0;
313
+ return Buffer.concat([header, payload]);
314
+ }
315
+ };
316
+ /** The most silence a gap in the packets becomes. */
317
+ var MAX_LOST_FRAMES = 3;
318
+ /** Sequence numbers compared across the 16-bit wrap: positive when `a` comes after `b`. */
319
+ function sequenceDelta(a, b) {
320
+ const d = a - b & 65535;
321
+ return d >= 32768 ? d - 65536 : d;
322
+ }
323
+ /**
324
+ * Puts packets back in order, holding at most `depth` of them for one that is late: a lost packet costs one frame
325
+ * of silence (40 ms of waiting at most), a duplicate or a packet that came too late is dropped, and a new stream (its
326
+ * SSRC changed, or its sequence jumped) starts over.
327
+ */
328
+ var ReorderBuffer = class {
329
+ depth;
330
+ expected = null;
331
+ ssrc = null;
332
+ held = /* @__PURE__ */ new Map();
333
+ constructor(depth = 2) {
334
+ this.depth = depth;
335
+ }
336
+ push(packet) {
337
+ if (this.ssrc !== packet.ssrc || this.expected === null || Math.abs(sequenceDelta(packet.sequence, this.expected)) > 1e3) {
338
+ this.ssrc = packet.ssrc;
339
+ this.expected = packet.sequence;
340
+ this.held.clear();
341
+ }
342
+ if (sequenceDelta(packet.sequence, this.expected) < 0 || this.held.has(packet.sequence)) return [];
343
+ this.held.set(packet.sequence, packet.payload);
344
+ const out = [];
345
+ this.release(out);
346
+ while (this.held.size > this.depth) {
347
+ const next = [...this.held.keys()].reduce((a, b) => sequenceDelta(a, b) < 0 ? a : b);
348
+ const gap = sequenceDelta(next, this.expected);
349
+ for (let i = 0; i < Math.min(gap, MAX_LOST_FRAMES); i++) out.push({ lost: true });
350
+ this.expected = next;
351
+ this.release(out);
352
+ }
353
+ return out;
354
+ }
355
+ release(out) {
356
+ for (;;) {
357
+ const payload = this.held.get(this.expected);
358
+ if (!payload) return;
359
+ this.held.delete(this.expected);
360
+ out.push({ payload });
361
+ this.expected = this.expected + 1 & 65535;
362
+ }
363
+ }
364
+ };
365
+ //#endregion
366
+ //#region src/calls/audio.ts
367
+ /** The longest stall of the sender whose frames are still sent after it (see `catchUp`). */
368
+ var MAX_CATCH_UP_MS = 1e3;
369
+ /**
370
+ * One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
371
+ * 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
372
+ * but the sending side, so the audio adds at most a frame of delay each way inside the CLI.
373
+ */
374
+ var CallAudio = class {
375
+ options;
376
+ queue;
377
+ writer;
378
+ reorder = new ReorderBuffer();
379
+ frame;
380
+ samples;
381
+ silence;
382
+ pending = Buffer.alloc(0);
383
+ timer = null;
384
+ startedAt = 0;
385
+ ticks = 0;
386
+ /** What was left in the queue after the last frame sent: the program's audio written ahead of the call. */
387
+ ahead = 0;
388
+ /** After a stall that was not replayed: how much of its audio is still to drop, down to what queue, and until when. */
389
+ owed = 0;
390
+ kept = 0;
391
+ owedUntil = 0;
392
+ talking = false;
393
+ /** The last silent frame's packet, how many silent frames in a row gave it, and that packet once it is kept (see `settle`). */
394
+ lastQuiet = null;
395
+ same = 0;
396
+ quiet = null;
397
+ stopped = false;
398
+ /** Frames sent and received, for `call list`. */
399
+ sent = 0;
400
+ received = 0;
401
+ /** When the last packet, RTP or RTCP, came from the contact (Date.now(); 0: none yet), for the call's watch. */
402
+ heardAt = 0;
403
+ constructor(options) {
404
+ this.options = options;
405
+ const now = options.now ?? Date.now;
406
+ this.queue = options.queue ?? new PlaybackQueue(options.rate, now);
407
+ this.writer = new RtpWriter(options.ssrc, options.payloadType);
408
+ this.frame = frameBytes(options.rate);
409
+ this.samples = frameSamples(options.rate);
410
+ this.silence = Buffer.alloc(this.frame);
411
+ options.track.onPacket((packet) => this.receive(packet));
412
+ }
413
+ /** Starts sending, a frame every 20 ms from now on. */
414
+ start() {
415
+ if (this.timer || this.stopped) return;
416
+ this.startedAt = (this.options.now ?? Date.now)();
417
+ this.ticks = 0;
418
+ this.schedule();
419
+ }
420
+ stop() {
421
+ this.stopped = true;
422
+ if (this.timer) clearTimeout(this.timer);
423
+ this.timer = null;
424
+ this.options.encoder.delete?.();
425
+ this.options.decoder.delete?.();
426
+ }
427
+ /** Sends the frame that is due now. Exposed for tests, which drive the clock themselves. */
428
+ tick() {
429
+ if (this.stopped) return;
430
+ const { frame, silent } = this.queue.next();
431
+ const still = silent || frame.equals(this.silence);
432
+ let payload;
433
+ if (still && this.quiet) payload = this.quiet;
434
+ else {
435
+ try {
436
+ payload = this.options.encoder.encode(frame, this.samples);
437
+ } catch {
438
+ return;
439
+ }
440
+ this.settle(still, payload);
441
+ }
442
+ const marker = !silent && !this.talking;
443
+ this.talking = !silent;
444
+ this.options.track.send(this.writer.packet(payload, marker));
445
+ this.sent++;
446
+ this.ahead = this.queue.queuedMs;
447
+ }
448
+ /**
449
+ * Keeps the packet silence encodes to once the encoder gave it SETTLED_FRAMES times in a row: it is sent again for
450
+ * the silent frames that follow, without the encoder, until the program's audio is back.
451
+ */
452
+ settle(still, payload) {
453
+ if (!still) {
454
+ this.lastQuiet = null;
455
+ this.quiet = null;
456
+ this.same = 0;
457
+ return;
458
+ }
459
+ if (this.lastQuiet?.equals(payload)) {
460
+ if (++this.same >= 25) this.quiet = this.lastQuiet;
461
+ return;
462
+ }
463
+ this.lastQuiet = Buffer.from(payload);
464
+ this.same = 1;
465
+ }
466
+ /** Handles one packet from the track. Exposed for tests. */
467
+ receive(packet) {
468
+ if (this.stopped) return;
469
+ this.heardAt = (this.options.now ?? Date.now)();
470
+ if (isRtcp(packet)) return;
471
+ const rtp = parseRtp(packet);
472
+ if (!rtp || rtp.payloadType !== this.options.payloadType) return;
473
+ for (const item of this.reorder.push(rtp)) {
474
+ let pcm;
475
+ if ("lost" in item) pcm = this.silence;
476
+ else try {
477
+ pcm = this.options.decoder.decode(item.payload);
478
+ } catch {
479
+ pcm = this.silence;
480
+ }
481
+ this.emit(pcm);
482
+ }
483
+ }
484
+ /** Hands decoded audio on in 20 ms frames, whatever the contact's packet length (10, 20, 40 or 60 ms). */
485
+ emit(pcm) {
486
+ let data = this.pending.length ? Buffer.concat([this.pending, pcm]) : pcm;
487
+ while (data.length >= this.frame) {
488
+ this.options.onFrame(Buffer.from(data.subarray(0, this.frame)));
489
+ this.received++;
490
+ data = data.subarray(this.frame);
491
+ }
492
+ this.pending = Buffer.from(data);
493
+ }
494
+ schedule() {
495
+ const now = this.options.now ?? Date.now;
496
+ const due = this.startedAt + (this.ticks + 1) * 20;
497
+ this.timer = setTimeout(() => {
498
+ if (this.stopped) return;
499
+ if (now() - due < 20) {
500
+ this.ticks++;
501
+ this.drop();
502
+ this.tick();
503
+ this.schedule();
504
+ return;
505
+ }
506
+ setImmediate(() => {
507
+ if (this.stopped) return;
508
+ this.catchUp(now() - due);
509
+ this.schedule();
510
+ });
511
+ }, Math.max(0, due - now()));
512
+ }
513
+ /**
514
+ * Meets the clock again after the loop was busy: every frame that is due goes out at once, so a loop that turns
515
+ * slower than a frame for a while (every 40 ms under a large file) keeps up, and a program writing at real time
516
+ * stays there. The contact's jitter buffer takes the short burst. A stall past MAX_CATCH_UP_MS is not replayed: the
517
+ * clock starts again and what the program wrote during it is dropped, or it would be heard that much late for the
518
+ * rest of the call.
519
+ */
520
+ catchUp(late) {
521
+ const now = this.options.now ?? Date.now;
522
+ if (late > 1e3) {
523
+ this.owed = late;
524
+ this.kept = this.ahead;
525
+ this.owedUntil = now() + MAX_CATCH_UP_MS;
526
+ this.startedAt = now();
527
+ this.ticks = 0;
528
+ this.drop();
529
+ this.tick();
530
+ return;
531
+ }
532
+ do {
533
+ this.ticks++;
534
+ this.drop();
535
+ this.tick();
536
+ } while (this.startedAt + (this.ticks + 1) * 20 <= now());
537
+ }
538
+ /**
539
+ * Drops the audio of a stall that was not replayed, as it is read: a long one does not fit the program's socket, so
540
+ * the rest of it comes over the next turns. What was queued before the stall (a clip written ahead) is kept.
541
+ */
542
+ drop() {
543
+ if (this.owed <= 0) return;
544
+ if ((this.options.now ?? Date.now)() > this.owedUntil) {
545
+ this.owed = 0;
546
+ return;
547
+ }
548
+ this.owed -= this.queue.skip(Math.min(this.owed, this.queue.queuedMs - this.kept));
549
+ }
550
+ };
551
+ //#endregion
552
+ //#region src/calls/media.ts
553
+ var loading = null;
554
+ /** The media stack, or why calls are not available here (the reason the call buttons of the contact's app show). */
555
+ function loadCallStack() {
556
+ return loading ??= (async () => {
557
+ if (process.env.GHOSTLY_WEBRTC === "0") return "Calls are off on this headless Ghostly (GHOSTLY_WEBRTC=0)";
558
+ try {
559
+ const ndc = await import("node-datachannel");
560
+ const lib = ndc.PeerConnection ? ndc : ndc.default;
561
+ const OpusScript = (await import("opusscript")).default;
562
+ const opus = (rate) => {
563
+ const codec = new OpusScript(rate, 1, OpusScript.Application.VOIP);
564
+ codec.setBitrate(32e3);
565
+ return codec;
566
+ };
567
+ opus(48e3).delete?.();
568
+ return {
569
+ ndc: lib,
570
+ opus
571
+ };
572
+ } catch (error) {
573
+ return `Calls are not available on this headless Ghostly (${error instanceof Error ? error.message : String(error)})`;
574
+ }
575
+ })();
576
+ }
577
+ /**
578
+ * The ICE servers of a call, as libdatachannel takes them: the apps' STUN servers (RTC_CONFIG; libjuice uses the
579
+ * first; none with `GHOSTLY_STUN=0`), then the profile's own (Settings `iceServers`: a TURN relay, typically), as the
580
+ * apps' calls use them.
581
+ */
582
+ function callIceServers(extra = [], env = process.env) {
583
+ const out = env.GHOSTLY_STUN === "0" ? [] : (RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()).slice(0, 2);
584
+ for (const server of callRtcConfig(extra).iceServers?.slice(RTC_CONFIG.iceServers?.length ?? 0) ?? []) for (const url of [server.urls].flat()) {
585
+ const m = /^(stun|turns?):([^:?\s]+)(?::(\d+))?(?:\?transport=(udp|tcp))?$/i.exec(url);
586
+ if (!m) continue;
587
+ const scheme = m[1].toLowerCase(), hostname = m[2], transport = m[4]?.toLowerCase();
588
+ if (scheme === "stun") {
589
+ out.push(`stun:${hostname}:${m[3] ?? 3478}`);
590
+ continue;
591
+ }
592
+ out.push({
593
+ hostname,
594
+ port: Number(m[3] ?? (scheme === "turns" ? 5349 : 3478)),
595
+ ...server.username ? { username: server.username } : {},
596
+ ...typeof server.credential === "string" ? { password: server.credential } : {},
597
+ relayType: scheme === "turns" ? "TurnTls" : transport === "tcp" ? "TurnTcp" : "TurnUdp"
598
+ });
599
+ }
600
+ return out;
601
+ }
602
+ /** A candidate as `call list` and the log show it: address, port and type. */
603
+ function describePair(pair) {
604
+ if (!pair) return null;
605
+ const one = (c) => `${c.address}:${c.port} ${c.type}`;
606
+ return `${one(pair.local)} <-> ${one(pair.remote)}`;
607
+ }
608
+ /**
609
+ * For tests (the e2e of a web app calling back a bot): the first this many answers the process applies are refused
610
+ * as libdatachannel 0.24.5 refuses one in its race (see CallManager's `redial`): the connection closes, and it throws.
611
+ */
612
+ var refuseAnswers = Number(process.env.GHOSTLY_CALL_REFUSE_ANSWERS) || 0;
613
+ /** How long a description waits for its candidates: a server reflexive one and a little after it, or this long. */
614
+ var GATHER_MS = 5e3;
615
+ var SETTLE_MS = 400;
616
+ /**
617
+ * With a candidate in (a host one comes in milliseconds), the server reflexive one is waited for this long at most,
618
+ * not the whole of `GATHER_MS`: a STUN server that answers does in tens of milliseconds, and one that never does (UDP
619
+ * to it blocked, as on many office, school and hotel networks) made every call ring 5 s late and connect 5 s after
620
+ * the answer. The apps' calls and the chats' data link bound theirs the same way (`HOST_GATHER_MS`,
621
+ * `OFFER_HOST_GATHER_MS`).
622
+ */
623
+ var HOST_GATHER_MS = 2e3;
624
+ /**
625
+ * A connection and its description, with its candidates. An early answer's (`held`) takes the contact's offer
626
+ * without its candidates: with none, this side sends the contact no check, so nothing connects, and no audio is sent
627
+ * or read, before the answer adds them.
628
+ */
629
+ async function openConnection(stack, options, offer, held = false) {
630
+ const { ndc } = stack;
631
+ const bind = process.env.GHOSTLY_CALL_BIND;
632
+ const pc = new ndc.PeerConnection("ghostly-call", {
633
+ iceServers: callIceServers(options.iceServers),
634
+ ...bind ? { bindAddress: bind } : {}
635
+ });
636
+ const born = Date.now();
637
+ const sink = options.log ?? (() => {});
638
+ const log = (line) => sink(`+${Date.now() - born}ms ${line}`);
639
+ let conn = null;
640
+ pc.onSignalingStateChange((state) => {
641
+ if (!conn?.closed) log(`signaling ${state}`);
642
+ });
643
+ pc.onIceStateChange((state) => {
644
+ if (conn?.closed) return;
645
+ let pair = null;
646
+ if (state === "connected" || state === "completed") try {
647
+ pair = describePair(pc.getSelectedCandidatePair());
648
+ } catch {}
649
+ if (conn) {
650
+ conn.ice.state = state;
651
+ if (state === "connected" || state === "completed") conn.ice.pair = pair;
652
+ }
653
+ log(`ice ${state}${pair ? ` over ${pair}` : ""}`);
654
+ });
655
+ pc.onStateChange((state) => {
656
+ if (conn?.closed) return;
657
+ log(`connection ${state}`);
658
+ if (!conn) return;
659
+ if (state === "failed") conn.failed = true;
660
+ if (state === "connected") conn.onState?.("connected");
661
+ else if (state === "failed" || state === "closed") conn.onState?.(state);
662
+ });
663
+ try {
664
+ const payloadType = offer?.ap ?? 111;
665
+ const ssrc = randomBytes(4).readUInt32BE(0) || 1;
666
+ const audioSection = new ndc.Audio("0", "SendRecv");
667
+ audioSection.addOpusCodec(payloadType);
668
+ audioSection.addSSRC(ssrc, "ghostly", "stream", "audio0");
669
+ const track = pc.addTrack(audioSection);
670
+ let videoTrack = null;
671
+ if (offer?.m?.includes("v")) {
672
+ const video = new ndc.Video(String(offer.m.indexOf("v")), "Inactive");
673
+ video.addVP8Codec(offer.vp ?? 96);
674
+ videoTrack = pc.addTrack(video);
675
+ videoTrack.onMessage(() => {});
676
+ }
677
+ const gathered = gathering(pc);
678
+ if (offer) pc.setRemoteDescription(buildSdpFromSignal(held ? {
679
+ ...offer,
680
+ c: []
681
+ } : offer), "offer");
682
+ else pc.setLocalDescription("offer");
683
+ await gathered;
684
+ const sdp = pc.localDescription()?.sdp;
685
+ if (!sdp) throw new Error("No local description");
686
+ const local = {
687
+ ...extractParamsFromSdp(sdp, {
688
+ maxCandidates: 8,
689
+ loopback: !!bind
690
+ }),
691
+ ss: [ssrc]
692
+ };
693
+ if (offer?.m) local.m = [...offer.m];
694
+ if (videoTrack) local.vr = 0;
695
+ conn = {
696
+ pc,
697
+ track,
698
+ videoTrack,
699
+ local,
700
+ payloadType,
701
+ ssrc,
702
+ log,
703
+ ice: {
704
+ state: "new",
705
+ pair: null
706
+ },
707
+ closed: false,
708
+ failed: false,
709
+ onState: null
710
+ };
711
+ log(`candidates here: ${(local.c ?? []).join(" | ") || "none"}`);
712
+ try {
713
+ conn.ice.state = pc.iceState();
714
+ } catch {}
715
+ return conn;
716
+ } catch (error) {
717
+ pc.close();
718
+ throw error;
719
+ }
720
+ }
721
+ function closeConnection(conn) {
722
+ conn.closed = true;
723
+ try {
724
+ conn.track.close();
725
+ } catch {}
726
+ try {
727
+ conn.videoTrack?.close();
728
+ } catch {}
729
+ try {
730
+ conn.pc.close();
731
+ } catch {}
732
+ }
733
+ /**
734
+ * The answer's connection, made while the call rings: its description and candidates are ready when the call is
735
+ * answered, so the answer goes at once, where gathering them then took up to HOST_GATHER_MS (a STUN server that never
736
+ * answers). The offer's candidates wait for the answer (see `openConnection`). libjuice gives up on a connection that
737
+ * has nothing to check after about 40 s: a call that rings longer has its early answer made again (CallManager).
738
+ */
739
+ var EarlyAnswer = class EarlyAnswer {
740
+ conn;
741
+ offer;
742
+ constructor(conn, offer) {
743
+ this.conn = conn;
744
+ this.offer = offer;
745
+ }
746
+ static async open(stack, offer, options) {
747
+ return new EarlyAnswer(await openConnection(stack, options, offer, true), offer);
748
+ }
749
+ /** Whether it can still answer: not closed, not given up on, not used. */
750
+ get usable() {
751
+ return !!this.conn && !this.conn.closed && !this.conn.failed;
752
+ }
753
+ /** Its connection, once, for the answer to the offer it was made for. */
754
+ take(offer) {
755
+ if (!this.usable || offer !== this.offer) return null;
756
+ const conn = this.conn;
757
+ this.conn = null;
758
+ return conn;
759
+ }
760
+ /** Closed unused: the call ended, or a newer one replaced it. Nothing once taken. */
761
+ close() {
762
+ if (this.conn) closeConnection(this.conn);
763
+ this.conn = null;
764
+ }
765
+ };
766
+ /**
767
+ * One call's peer connection. Its SDP never travels: the compact signal does (`local`), and each side rebuilds the
768
+ * other's SDP from it (`buildSdpFromSignal`), so the payload types, SSRC and mids here are the ones that rebuilt SDP
769
+ * uses: audio is mid 0, Opus is 111 unless the contact's offer says otherwise.
770
+ */
771
+ var CallMedia = class CallMedia {
772
+ conn;
773
+ audio;
774
+ log;
775
+ constructor(conn, audio) {
776
+ this.conn = conn;
777
+ this.audio = audio;
778
+ this.log = conn.log;
779
+ }
780
+ get local() {
781
+ return this.conn.local;
782
+ }
783
+ /** An offer: audio only (the contact's app answers one section, and its video lane stays closed). */
784
+ static async offer(stack, options) {
785
+ return CallMedia.create(stack, options, null);
786
+ }
787
+ /**
788
+ * The answer to a contact's offer, which is rebuilt into its SDP here: on the connection made while the call rang
789
+ * when it is still good, else on a new one.
790
+ */
791
+ static async answer(stack, offer, options, early) {
792
+ return CallMedia.create(stack, options, offer, early?.take(offer) ?? null);
793
+ }
794
+ static async create(stack, options, offer, early = null) {
795
+ const conn = early ?? await openConnection(stack, options, offer);
796
+ const { pc, track, log } = conn;
797
+ try {
798
+ const listeners = [];
799
+ track.onMessage((packet) => {
800
+ for (const listener of listeners) listener(packet);
801
+ });
802
+ const audio = new CallAudio({
803
+ rate: options.rate,
804
+ payloadType: conn.payloadType,
805
+ ssrc: conn.ssrc,
806
+ track: {
807
+ send: (packet) => {
808
+ if (track.isOpen()) track.sendMessageBinary(packet);
809
+ },
810
+ onPacket: (listener) => listeners.push(listener)
811
+ },
812
+ encoder: stack.opus(options.rate),
813
+ decoder: stack.opus(options.rate),
814
+ onFrame: options.onFrame,
815
+ queue: options.queue
816
+ });
817
+ const media = new CallMedia(conn, audio);
818
+ conn.onState = options.onState;
819
+ if (offer) log(`contact's candidates: ${(offer.c ?? []).join(" | ") || "none"}`);
820
+ if (offer) mdnsNote(offer, log);
821
+ track.onOpen(() => audio.start());
822
+ if (early && offer) {
823
+ log("answered on the connection made as the call rang: the contact's candidates go in now");
824
+ const mid = String(Math.max(0, (offer.m ?? ["a"]).indexOf("a")));
825
+ for (const candidate of offer.c ?? []) pc.addRemoteCandidate(`candidate:${candidate}`, mid);
826
+ }
827
+ if (track.isOpen()) audio.start();
828
+ return media;
829
+ } catch (error) {
830
+ closeConnection(conn);
831
+ throw error;
832
+ }
833
+ }
834
+ /** The contact's answer to this side's offer. */
835
+ applyAnswer(answer) {
836
+ this.log(`contact's candidates: ${(answer.c ?? []).join(" | ") || "none"}`);
837
+ mdnsNote(answer, this.log);
838
+ if (refuseAnswers > 0) {
839
+ refuseAnswers--;
840
+ this.conn.pc.close();
841
+ throw new Error("libdatachannel error while adding remote description: Got a remote candidate without ICE transport (GHOSTLY_CALL_REFUSE_ANSWERS)");
842
+ }
843
+ this.conn.pc.setRemoteDescription(buildSdpFromSignal(answer), "answer");
844
+ this.log("answer applied");
845
+ }
846
+ get ice() {
847
+ return this.conn.ice;
848
+ }
849
+ close() {
850
+ if (this.conn.closed) return;
851
+ this.audio.stop();
852
+ closeConnection(this.conn);
853
+ }
854
+ };
855
+ /**
856
+ * WebKit (Safari, the macOS app) hides its host addresses behind mDNS names, which other machines, and often this
857
+ * one, cannot resolve: then the call can only connect when the contact's own checks reach this side. The log says so,
858
+ * since that is what a call stuck on "connecting" needs to be understood.
859
+ */
860
+ function mdnsNote(signal, log) {
861
+ for (const candidate of signal.c ?? []) {
862
+ const host = candidate.split(" ")[4];
863
+ if (!host?.endsWith(".local")) continue;
864
+ lookup(host, { all: true }).then((found) => log(`${host} resolves here to ${found.map((a) => a.address).join(", ")}`), () => log(`${host} does not resolve here: the call connects only if the contact's checks reach this side`));
865
+ }
866
+ }
867
+ /** Resolves once the local description has its candidates (see GATHER_MS and HOST_GATHER_MS). */
868
+ function gathering(pc) {
869
+ return new Promise((resolve) => {
870
+ let settle = null;
871
+ let any = false;
872
+ const done = () => {
873
+ clearTimeout(cap);
874
+ clearTimeout(bounded);
875
+ if (settle) clearTimeout(settle);
876
+ resolve();
877
+ };
878
+ const cap = setTimeout(done, GATHER_MS);
879
+ const bounded = setTimeout(() => {
880
+ if (any) done();
881
+ }, HOST_GATHER_MS);
882
+ pc.onGatheringStateChange((state) => {
883
+ if (state === "complete") done();
884
+ });
885
+ pc.onLocalCandidate((candidate) => {
886
+ any = true;
887
+ if (!settle && / typ srflx/.test(candidate)) settle = setTimeout(done, SETTLE_MS);
888
+ });
889
+ });
890
+ }
891
+ //#endregion
892
+ //#region src/calls/manager.ts
893
+ /** An outgoing call rings this long, then ends as `unanswered`. */
894
+ var RING_MS = 6e4;
895
+ /** An answered or accepted call has this long to connect its media, then ends as `failed`. */
896
+ var CONNECT_MS = 3e4;
897
+ /** How long a call whose media closed or failed waits for the contact's hang-up signal to say why. */
898
+ var MEDIA_GRACE_MS = 3e3;
899
+ /** After a hang-up, the signal is cleared this much later (the apps' own delay). */
900
+ var CLEAR_MS = 5e3;
901
+ var REDIAL_BACKOFF_MS = [
902
+ 150,
903
+ 300,
904
+ 600,
905
+ 600
906
+ ];
907
+ var CallManager = class {
908
+ host;
909
+ calls = /* @__PURE__ */ new Map();
910
+ /** Per chat: the timestamp of the last signal handled. */
911
+ lastSignal = /* @__PURE__ */ new Map();
912
+ /** Per chat: the timestamp of the last signal sent (`stamp`). */
913
+ lastSent = /* @__PURE__ */ new Map();
914
+ /** Per chat: the timer that clears the hang-up signal. */
915
+ clearing = /* @__PURE__ */ new Map();
916
+ /** Per chat: when this side's last call there ended before it was answered (`CALL_PACE_MS`). */
917
+ unansweredAt = /* @__PURE__ */ new Map();
918
+ auto;
919
+ configPath;
920
+ stopping = false;
921
+ constructor(host) {
922
+ this.host = host;
923
+ this.configPath = join(host.profileDir, "calls.json");
924
+ this.auto = readAuto(this.configPath);
925
+ if (host.answers !== false) clearAudioSockets(host.profileDir);
926
+ }
927
+ get now() {
928
+ return (this.host.now ?? Date.now)();
929
+ }
930
+ /**
931
+ * A new signal's timestamp in a chat: now, or 1 ms after the last one sent there. The contact drops a signal no
932
+ * newer than the last it heard, so a redial or a hang-up in the same millisecond as the signal before it would be lost.
933
+ */
934
+ stamp(chat) {
935
+ const ts = Math.max(this.now, (this.lastSent.get(chat) ?? 0) + 1);
936
+ this.lastSent.set(chat, ts);
937
+ return ts;
938
+ }
939
+ /** A signal the contact's app sent in a chat (the engine's `call-signal`). */
940
+ onSignal(chat, json) {
941
+ if (this.stopping) return;
942
+ const signal = parseCallSignal(json, this.now);
943
+ if (!signal) return;
944
+ if (signal.ts <= (this.lastSignal.get(chat) ?? 0)) return;
945
+ const call = this.byChat(chat);
946
+ if (signal.t === "r" || signal.t === "a" && signal.re !== void 0) return;
947
+ if (call?.state === "connected" && signal.t !== "h" && signal.t !== "v") return;
948
+ if (signal.t === "o" && !call) {
949
+ this.lastSignal.set(chat, signal.ts);
950
+ this.incoming(chat, signal);
951
+ } else if (signal.t === "a" && call?.direction === "out" && call.offering && answersOffer(signal, call.offerTs)) {
952
+ this.lastSignal.set(chat, signal.ts);
953
+ this.accepted(call, signal);
954
+ } else if (signal.t === "o" && call?.direction === "out" && call.state === "ringing") {
955
+ this.lastSignal.set(chat, signal.ts);
956
+ const mine = call.offering ? call.offerTs : 0;
957
+ if (!mine || signal.ts < mine || signal.ts === mine && (signal.f ?? "") < (call.media?.local.f ?? "")) this.yieldTo(call, signal);
958
+ } else if (signal.t === "o" && call?.direction === "in" && call.state === "connecting" && call.media && signal.ts > (call.offer?.ts ?? 0)) {
959
+ this.lastSignal.set(chat, signal.ts);
960
+ this.reanswer(call, signal);
961
+ } else if (signal.t === "v") this.lastSignal.set(chat, signal.ts);
962
+ else if (signal.t === "h") {
963
+ this.lastSignal.set(chat, signal.ts);
964
+ if (call) {
965
+ const unreachable = signal.r === "u" && call.state !== "connected";
966
+ const failed = unreachable || call.state !== "connected" && (call.offering || call.direction === "out" && call.redials > 0);
967
+ const reason = call.state === "ringing" && call.direction === "in" ? "missed" : call.state === "ringing" && !unreachable ? "rejected" : failed ? "failed" : "remote-hangup";
968
+ this.end(call, reason, false);
969
+ }
970
+ }
971
+ }
972
+ /**
973
+ * The contact's offer won a glare: our call ends as `crossed`, sending nothing (a hang-up would end the contact's
974
+ * call, and clearing our signal could drop an answer we are about to give), and the contact's call rings here.
975
+ */
976
+ async yieldTo(call, offer) {
977
+ await this.end(call, "crossed", false);
978
+ if (this.lastSignal.get(call.chat) !== offer.ts || this.byChat(call.chat) || this.stopping) return;
979
+ this.incoming(call.chat, offer);
980
+ }
981
+ incoming(chat, offer) {
982
+ const link = this.link(chat);
983
+ const call = this.add(chat, "in", this.auto.rate);
984
+ call.offer = offer;
985
+ call.video = signalHasVideo(offer);
986
+ this.arm(call, Math.max(1e3, callSignalHeardAt(offer) + CALL_SIGNAL_MAX_AGE_MS - this.now), "missed", false);
987
+ this.host.emit("call.incoming", `call.incoming:${call.id}`, {
988
+ call: call.id,
989
+ chat,
990
+ name: link ? nameOf(link) : null,
991
+ video: call.video,
992
+ auto: this.autoFor(chat)
993
+ });
994
+ this.prepare(call);
995
+ if (this.host.answers !== false && this.autoFor(chat)) this.answer(call.id, {}).catch((error) => process.stderr.write(`ghostly: auto-answer failed: ${error instanceof Error ? error.message : String(error)}\n`));
996
+ }
997
+ accepted(call, answer) {
998
+ call.offering = false;
999
+ call.state = "connecting";
1000
+ const attempt = call.answered = call.attempt;
1001
+ call.answeredAt = Date.now();
1002
+ try {
1003
+ call.media.applyAnswer(answer);
1004
+ } catch (error) {
1005
+ if (attempt === call.attempt) this.refused(call, `the answer was refused (${error instanceof Error ? error.message : String(error)})`);
1006
+ return;
1007
+ }
1008
+ if (attempt === call.attempt) this.arm(call, CONNECT_MS, "failed", true);
1009
+ }
1010
+ /**
1011
+ * The contact's answer on this connection was refused, or the connection failed right after it (the same race,
1012
+ * whose handshake can also fail once the answer is in): offering again (`redial`) while there are tries left.
1013
+ */
1014
+ refused(call, what) {
1015
+ if (call.redials < this.maxRedials) {
1016
+ process.stderr.write(`ghostly: call ${call.id}: ${what}: offering again on a new connection\n`);
1017
+ this.redial(call);
1018
+ return;
1019
+ }
1020
+ process.stderr.write(`ghostly: call ${call.id}: ${what}\n`);
1021
+ this.end(call, "failed", true);
1022
+ }
1023
+ get maxRedials() {
1024
+ return this.host.maxRedials ?? 4;
1025
+ }
1026
+ /**
1027
+ * A new offer on a new connection, up to MAX_REDIALS times, each after a short wait. libdatachannel (0.24.5) can
1028
+ * refuse a good answer: when the contact's checks and its DTLS hello came before the answer (they do, the answer
1029
+ * crosses the chat session), ICE connects and the handshake ends inside setRemoteDescription, before the answer's
1030
+ * fingerprint is recorded; the fingerprint check fails, the connection closes its transports, and adding the
1031
+ * answer's candidates throws "Got a remote candidate without ICE transport". That connection is done for, and the
1032
+ * contact's side saw the handshake fail too, so both start over: the contact's side answers the newer offer
1033
+ * (`reanswer`). Each new connection runs the same race (CI runners lose about one in six, Linux arm64 twice in a
1034
+ * row), hence several. The race can also let the answer in and fail the handshake a moment later: a connection
1035
+ * that fails after its answer, before it ever connected, starts over the same way. The contact's side ends a call whose connection failed after MEDIA_GRACE_MS: every wait stays
1036
+ * well under it. Fixed upstream in libdatachannel 0235225a, which no release has yet.
1037
+ */
1038
+ async redial(call) {
1039
+ const wait = REDIAL_BACKOFF_MS[Math.min(call.redials, REDIAL_BACKOFF_MS.length - 1)];
1040
+ call.redials++;
1041
+ call.attempt++;
1042
+ call.media?.close();
1043
+ call.media = null;
1044
+ this.arm(call, CONNECT_MS, "failed", true);
1045
+ await new Promise((resolve) => setTimeout(resolve, wait));
1046
+ if (call.ended) return;
1047
+ try {
1048
+ const stack = await this.stack();
1049
+ if (!await this.connect(call, (media) => CallMedia.offer(stack, media))) return;
1050
+ call.offerTs = this.stamp(call.chat);
1051
+ call.offering = true;
1052
+ await this.send(call.chat, {
1053
+ t: "o",
1054
+ ts: call.offerTs,
1055
+ ...call.media.local,
1056
+ v: 0
1057
+ });
1058
+ } catch (error) {
1059
+ process.stderr.write(`ghostly: call ${call.id}: offering again failed (${error instanceof Error ? error.message : String(error)})\n`);
1060
+ this.end(call, "failed", true);
1061
+ }
1062
+ }
1063
+ /** The contact's newer offer (its `redial`): answered on a new connection, on the call's socket. */
1064
+ async reanswer(call, offer) {
1065
+ process.stderr.write(`ghostly: call ${call.id}: the contact offered again: answering on a new connection\n`);
1066
+ call.offer = offer;
1067
+ call.video = signalHasVideo(offer);
1068
+ this.arm(call, CONNECT_MS, "failed", true);
1069
+ try {
1070
+ const stack = await this.stack();
1071
+ if (!await this.connect(call, (media) => CallMedia.answer(stack, offer, media))) return;
1072
+ await this.send(call.chat, {
1073
+ t: "a",
1074
+ ts: this.stamp(call.chat),
1075
+ o: offer.ts,
1076
+ ...call.media.local,
1077
+ v: 0
1078
+ });
1079
+ } catch (error) {
1080
+ process.stderr.write(`ghostly: call ${call.id}: answering again failed (${error instanceof Error ? error.message : String(error)})\n`);
1081
+ this.end(call, "failed", true);
1082
+ }
1083
+ }
1084
+ /**
1085
+ * The ringing call's answer, made now (`EarlyAnswer`): when the call is answered, its candidates have been gathered
1086
+ * and the answer goes at once. Made again every EARLY_ANSWER_MS while it rings; when it cannot be made, the answer makes its own.
1087
+ */
1088
+ prepare(call) {
1089
+ if (this.host.answers === false || call.ended || call.state !== "ringing") return;
1090
+ call.earlyTimer = setTimeout(() => this.prepare(call), this.host.earlyAnswerMs ?? 3e4);
1091
+ call.earlyTimer.unref?.();
1092
+ if (call.making) return;
1093
+ const offer = call.offer;
1094
+ const making = {
1095
+ done: this.stack().then((stack) => EarlyAnswer.open(stack, offer, {
1096
+ iceServers: this.host.engine.getState().settings?.iceServers ?? [],
1097
+ log: (line) => process.stderr.write(`ghostly: call ${call.id}: ${line}\n`)
1098
+ })).catch((error) => {
1099
+ this.stopEarly(call);
1100
+ process.stderr.write(`ghostly: call ${call.id}: the answer could not be made ahead (${error instanceof Error ? error.message : String(error)})\n`);
1101
+ return null;
1102
+ }),
1103
+ claimed: false
1104
+ };
1105
+ call.making = making;
1106
+ making.done.then((early) => {
1107
+ if (call.making === making) call.making = null;
1108
+ if (making.claimed || !early) return;
1109
+ if (call.ended || call.state !== "ringing") {
1110
+ early.close();
1111
+ return;
1112
+ }
1113
+ call.early?.close();
1114
+ call.early = early;
1115
+ });
1116
+ }
1117
+ /** The answered call's early answer: the one made, if still good, or the one being made; none from then on. */
1118
+ async takeEarly(call) {
1119
+ this.stopEarly(call);
1120
+ const early = call.early;
1121
+ call.early = null;
1122
+ if (early?.usable) return early;
1123
+ early?.close();
1124
+ const making = call.making;
1125
+ if (!making) return null;
1126
+ making.claimed = true;
1127
+ return making.done;
1128
+ }
1129
+ /** The early answer is not made again. */
1130
+ stopEarly(call) {
1131
+ if (call.earlyTimer) clearTimeout(call.earlyTimer);
1132
+ call.earlyTimer = null;
1133
+ }
1134
+ async start(chat, options) {
1135
+ const link = this.callable(chat);
1136
+ if (this.byChat(link.id)) throw new CliError("busy", "A call is already on in this chat");
1137
+ this.paced(link.id);
1138
+ const rate = rateOf(options.rate, this.auto.rate);
1139
+ const stack = await this.stack();
1140
+ if (this.byChat(link.id)) throw new CliError("busy", "A call is already on in this chat");
1141
+ const call = this.add(link.id, "out", rate);
1142
+ try {
1143
+ await this.attach(call, (media) => CallMedia.offer(stack, media));
1144
+ if (call.reason === "crossed") throw new CliError("busy", "The contact called at the same time: their call rings here");
1145
+ if (call.ended) throw new CliError("unavailable", "The call ended before it was placed");
1146
+ call.offerTs = this.stamp(link.id);
1147
+ call.offering = true;
1148
+ await this.send(link.id, {
1149
+ t: "o",
1150
+ ts: call.offerTs,
1151
+ ...call.media.local,
1152
+ v: 0
1153
+ });
1154
+ } catch (error) {
1155
+ await this.end(call, "failed", false, false);
1156
+ throw error;
1157
+ }
1158
+ this.arm(call, RING_MS, "unanswered", true);
1159
+ this.host.emit("call.outgoing", `call.outgoing:${call.id}`, {
1160
+ call: call.id,
1161
+ chat: link.id,
1162
+ name: nameOf(link),
1163
+ audio: this.audioInfo(call)
1164
+ });
1165
+ return this.view(call);
1166
+ }
1167
+ async answer(ref, options) {
1168
+ const call = this.find(ref, (c) => c.direction === "in" && c.state === "ringing");
1169
+ if (call.direction !== "in" || call.state !== "ringing") throw new CliError("refused", "That call is not ringing here");
1170
+ this.callable(call.chat);
1171
+ call.rate = rateOf(options.rate, call.rate);
1172
+ const stack = await this.stack();
1173
+ if (call.ended) throw new CliError("unavailable", "The call ended before it was answered");
1174
+ if (call.state !== "ringing") throw new CliError("refused", "That call is not ringing here");
1175
+ call.state = "connecting";
1176
+ this.disarm(call);
1177
+ let early = null;
1178
+ try {
1179
+ early = await this.takeEarly(call);
1180
+ await this.attach(call, (media) => CallMedia.answer(stack, call.offer, media, early));
1181
+ if (call.ended) throw new CliError("unavailable", "The call ended before it was answered");
1182
+ await this.send(call.chat, {
1183
+ t: "a",
1184
+ ts: this.stamp(call.chat),
1185
+ o: call.offer.ts,
1186
+ ...call.media.local,
1187
+ v: 0
1188
+ });
1189
+ } catch (error) {
1190
+ await this.end(call, "failed", true, false);
1191
+ throw error;
1192
+ } finally {
1193
+ early?.close();
1194
+ }
1195
+ this.arm(call, CONNECT_MS, "failed", true);
1196
+ return this.view(call);
1197
+ }
1198
+ async hangup(ref) {
1199
+ const call = this.find(ref);
1200
+ const view = this.view(call);
1201
+ await this.end(call, "hangup", true);
1202
+ return {
1203
+ ...view,
1204
+ state: "ended",
1205
+ reason: "hangup"
1206
+ };
1207
+ }
1208
+ /** One call, by its id or its chat (or the only one). */
1209
+ get(ref) {
1210
+ return this.view(this.find(ref));
1211
+ }
1212
+ list() {
1213
+ return [...this.calls.values()].map((call) => this.view(call));
1214
+ }
1215
+ /** Drops the audio the program queued and the call has not sent yet (barge-in). */
1216
+ flush(ref) {
1217
+ const call = this.find(ref);
1218
+ const flushedMs = call.queue?.flush() ?? 0;
1219
+ return {
1220
+ call: call.id,
1221
+ chat: call.chat,
1222
+ flushedMs
1223
+ };
1224
+ }
1225
+ getAuto() {
1226
+ return {
1227
+ ...this.auto,
1228
+ from: [...this.auto.from]
1229
+ };
1230
+ }
1231
+ setAuto(next) {
1232
+ const from = (next.from ?? []).map((ref) => this.chatId(ref));
1233
+ this.auto = {
1234
+ on: next.on,
1235
+ from: next.on ? from : [],
1236
+ rate: rateOf(next.rate, next.on ? this.auto.rate : DEFAULT_RATE)
1237
+ };
1238
+ const tmp = `${this.configPath}.tmp`;
1239
+ writeFileSync(tmp, JSON.stringify({ autoAnswer: this.auto }, null, 2) + "\n", { mode: 384 });
1240
+ renameSync(tmp, this.configPath);
1241
+ return this.getAuto();
1242
+ }
1243
+ /** Before the engine stops: every call is hung up (the contact's app is told), its program gets EOF. */
1244
+ async stopAll() {
1245
+ this.stopping = true;
1246
+ await Promise.all([...this.calls.values()].map((call) => this.end(call, "stopped", true)));
1247
+ for (const timer of this.clearing.values()) clearTimeout(timer);
1248
+ this.clearing.clear();
1249
+ }
1250
+ add(chat, direction, rate) {
1251
+ if (this.stopping) throw new CliError("unavailable", "The daemon is stopping");
1252
+ const clearing = this.clearing.get(chat);
1253
+ if (clearing) {
1254
+ clearTimeout(clearing);
1255
+ this.clearing.delete(chat);
1256
+ }
1257
+ const call = {
1258
+ id: randomBytes(6).toString("hex"),
1259
+ chat,
1260
+ direction,
1261
+ state: "ringing",
1262
+ rate,
1263
+ video: false,
1264
+ offer: null,
1265
+ offerTs: 0,
1266
+ offering: false,
1267
+ redials: 0,
1268
+ attempt: 0,
1269
+ answered: 0,
1270
+ answeredAt: 0,
1271
+ media: null,
1272
+ early: null,
1273
+ making: null,
1274
+ earlyTimer: null,
1275
+ socket: null,
1276
+ queue: null,
1277
+ startedAt: this.now,
1278
+ connectedAt: null,
1279
+ timer: null,
1280
+ watcher: null,
1281
+ stalledFrom: null,
1282
+ stalls: 0,
1283
+ ended: false
1284
+ };
1285
+ this.calls.set(call.id, call);
1286
+ return call;
1287
+ }
1288
+ /**
1289
+ * The call's socket (open from now on: a program may connect before the media is up) and its media. A call that
1290
+ * ended while its socket opened closes it here: `end` found none to close, and nothing lists the call any more.
1291
+ */
1292
+ async attach(call, create) {
1293
+ const queue = call.queue = new PlaybackQueue(call.rate);
1294
+ const socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
1295
+ if (call.ended) {
1296
+ await socket.close();
1297
+ return;
1298
+ }
1299
+ call.socket = socket;
1300
+ await this.connect(call, create);
1301
+ }
1302
+ /**
1303
+ * A connection for the call, in place of the one it had (closed first); the socket and its queue stay. False when
1304
+ * the call ended, or asked for a newer connection, while this one was made: then it is closed.
1305
+ */
1306
+ async connect(call, create) {
1307
+ if (call.ended) return false;
1308
+ const attempt = ++call.attempt;
1309
+ call.media?.close();
1310
+ call.media = null;
1311
+ let failed = false;
1312
+ const media = await create({
1313
+ rate: call.rate,
1314
+ queue: call.queue ?? void 0,
1315
+ log: (line) => process.stderr.write(`ghostly: call ${call.id}: ${line}\n`),
1316
+ iceServers: this.host.engine.getState().settings?.iceServers ?? [],
1317
+ onFrame: (frame) => call.socket?.write(frame),
1318
+ onState: (state) => {
1319
+ if (call.ended || attempt !== call.attempt) return;
1320
+ if (state === "connected") {
1321
+ this.connected(call);
1322
+ return;
1323
+ }
1324
+ if (state === "closed" && failed) return;
1325
+ failed ||= state === "failed";
1326
+ if (state === "failed" && call.direction === "out" && call.answered === attempt && call.state !== "connected" && Date.now() - call.answeredAt < 2e3) {
1327
+ this.refused(call, "the connection failed as the answer went in");
1328
+ return;
1329
+ }
1330
+ this.arm(call, MEDIA_GRACE_MS, state === "closed" ? "remote-hangup" : "failed", false);
1331
+ }
1332
+ });
1333
+ if (call.ended || attempt !== call.attempt) {
1334
+ media.close();
1335
+ return false;
1336
+ }
1337
+ call.media = media;
1338
+ return true;
1339
+ }
1340
+ connected(call) {
1341
+ if (call.state === "connected") return;
1342
+ call.state = "connected";
1343
+ call.connectedAt = this.now;
1344
+ this.disarm(call);
1345
+ this.host.emit("call.connected", `call.connected:${call.id}`, {
1346
+ call: call.id,
1347
+ chat: call.chat,
1348
+ direction: call.direction,
1349
+ audio: this.audioInfo(call)
1350
+ });
1351
+ this.watch(call);
1352
+ }
1353
+ /**
1354
+ * libjuice says nothing of a contact whose side went away (a crash, a closed lid, a lost network) until its consent
1355
+ * checks fail, about 30 s later: a connected call watches the contact's packets instead. An app sends them all along
1356
+ * (silence while muted, and RTCP), and so does the CLI. Nothing for STALL_MS: `call.stalled`; packets again:
1357
+ * `call.resumed`; nothing for STALL_END_MS: the call ends as `failed`, and the contact's app is told.
1358
+ */
1359
+ watch(call) {
1360
+ const { afterMs, endMs } = this.host.stall ?? {
1361
+ afterMs: 5e3,
1362
+ endMs: 15e3
1363
+ };
1364
+ const since = Date.now();
1365
+ call.watcher = setInterval(() => {
1366
+ if (call.ended) return;
1367
+ const heardAt = Math.max(since, call.media?.audio.heardAt ?? 0);
1368
+ const silentMs = Date.now() - heardAt;
1369
+ const fields = {
1370
+ call: call.id,
1371
+ chat: call.chat,
1372
+ direction: call.direction
1373
+ };
1374
+ if (silentMs >= endMs) {
1375
+ process.stderr.write(`ghostly: call ${call.id}: nothing heard from the contact for ${Math.round(silentMs / 1e3)} s\n`);
1376
+ this.end(call, "failed", true);
1377
+ } else if (silentMs >= afterMs && call.stalledFrom === null) {
1378
+ call.stalledFrom = heardAt;
1379
+ call.stalls++;
1380
+ process.stderr.write(`ghostly: call ${call.id}: stalled, nothing heard from the contact for ${silentMs} ms\n`);
1381
+ this.host.emit("call.stalled", `call.stalled:${call.id}:${call.stalls}`, {
1382
+ ...fields,
1383
+ silentMs
1384
+ });
1385
+ } else if (silentMs < afterMs && call.stalledFrom !== null) {
1386
+ const gapMs = heardAt - call.stalledFrom;
1387
+ call.stalledFrom = null;
1388
+ process.stderr.write(`ghostly: call ${call.id}: resumed after ${gapMs} ms\n`);
1389
+ this.host.emit("call.resumed", `call.resumed:${call.id}:${call.stalls}`, {
1390
+ ...fields,
1391
+ silentMs: gapMs
1392
+ });
1393
+ }
1394
+ }, Math.min(1e3, afterMs / 5));
1395
+ call.watcher.unref?.();
1396
+ }
1397
+ async end(call, reason, tell, report = true) {
1398
+ if (call.ended) return;
1399
+ call.ended = true;
1400
+ call.reason = reason;
1401
+ this.disarm(call);
1402
+ if (call.watcher) clearInterval(call.watcher);
1403
+ this.calls.delete(call.id);
1404
+ if (call.direction === "out" && call.connectedAt === null && call.state === "ringing" && (reason === "hangup" || reason === "unanswered")) this.unansweredAt.set(call.chat, this.now);
1405
+ if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
1406
+ call.media?.close();
1407
+ this.stopEarly(call);
1408
+ call.early?.close();
1409
+ call.early = null;
1410
+ await call.socket?.close();
1411
+ if (tell) {
1412
+ await this.send(call.chat, {
1413
+ t: "h",
1414
+ ts: this.stamp(call.chat)
1415
+ }).catch(() => {});
1416
+ if (!this.stopping) {
1417
+ const timer = setTimeout(() => {
1418
+ this.clearing.delete(call.chat);
1419
+ this.sendRaw(call.chat, null).catch(() => {});
1420
+ }, CLEAR_MS);
1421
+ timer.unref?.();
1422
+ this.clearing.set(call.chat, timer);
1423
+ }
1424
+ } else if (reason !== "crossed") await this.sendRaw(call.chat, null).catch(() => {});
1425
+ if (report) this.host.emit("call.ended", `call.ended:${call.id}`, {
1426
+ call: call.id,
1427
+ chat: call.chat,
1428
+ direction: call.direction,
1429
+ reason,
1430
+ ...call.connectedAt !== null ? { duration: this.now - call.connectedAt } : {}
1431
+ });
1432
+ }
1433
+ /** Refuses a call placed too soon after this side's last unanswered one in the chat (`CALL_PACE_MS`). */
1434
+ paced(chat) {
1435
+ const pace = this.host.callPaceMs ?? 2e3, last = this.unansweredAt.get(chat);
1436
+ if (!pace || last === void 0) return;
1437
+ const left = last + pace - this.now;
1438
+ if (left <= 0) {
1439
+ this.unansweredAt.delete(chat);
1440
+ return;
1441
+ }
1442
+ throw new CliError("busy", `Your last call in this chat ended unanswered a moment ago: call again in ${Math.ceil(left / 100) / 10} s`, { retryAfterMs: left });
1443
+ }
1444
+ arm(call, ms, reason, tell) {
1445
+ this.disarm(call);
1446
+ call.timer = setTimeout(() => void this.end(call, reason, tell), ms);
1447
+ call.timer.unref?.();
1448
+ }
1449
+ disarm(call) {
1450
+ if (call.timer) clearTimeout(call.timer);
1451
+ call.timer = null;
1452
+ }
1453
+ send(chat, signal) {
1454
+ return this.sendRaw(chat, JSON.stringify(signal));
1455
+ }
1456
+ async sendRaw(chat, signal) {
1457
+ try {
1458
+ await this.host.engine.setCallSignal({
1459
+ linkId: chat,
1460
+ signal
1461
+ });
1462
+ } catch (error) {
1463
+ throw new CliError("unavailable", error instanceof Error ? error.message : String(error));
1464
+ }
1465
+ }
1466
+ async stack() {
1467
+ if (this.host.answers === false) throw new CliError("unavailable", "Calls need the daemon (a call outlives this command): start it with `ghostly daemon --detach`");
1468
+ const stack = await (this.host.stack ?? loadCallStack)();
1469
+ if (typeof stack === "string") throw new CliError("unavailable", stack);
1470
+ return stack;
1471
+ }
1472
+ audioInfo(call) {
1473
+ return call.socket ? {
1474
+ socket: call.socket.path,
1475
+ rate: call.rate,
1476
+ channels: 1,
1477
+ format: "s16le",
1478
+ frameMs: 20
1479
+ } : null;
1480
+ }
1481
+ view(call) {
1482
+ const audio = call.media?.audio;
1483
+ return {
1484
+ call: call.id,
1485
+ chat: call.chat,
1486
+ direction: call.direction,
1487
+ state: call.state,
1488
+ stalled: call.stalledFrom !== null,
1489
+ video: call.video,
1490
+ startedAt: call.startedAt,
1491
+ connectedAt: call.connectedAt,
1492
+ audio: this.audioInfo(call),
1493
+ ...call.socket ? { stats: {
1494
+ framesIn: audio?.received ?? 0,
1495
+ framesOut: audio?.sent ?? 0,
1496
+ queuedMs: call.queue?.queuedMs ?? 0,
1497
+ programConnected: call.socket.connected,
1498
+ ice: call.media?.ice ?? null
1499
+ } } : {}
1500
+ };
1501
+ }
1502
+ byChat(chat) {
1503
+ for (const call of this.calls.values()) if (call.chat === chat) return call;
1504
+ }
1505
+ /** A call by its id, or by its chat; with no ref, the only call (or the only one `prefer` picks). */
1506
+ find(ref, prefer) {
1507
+ if (ref) {
1508
+ const byId = this.calls.get(ref);
1509
+ if (byId) return byId;
1510
+ const call = this.byChat(this.chatId(ref));
1511
+ if (!call) throw new CliError("not_found", `No call in chat ${ref}`);
1512
+ return call;
1513
+ }
1514
+ const all = [...this.calls.values()];
1515
+ const picked = prefer && all.filter(prefer).length === 1 ? all.filter(prefer) : all;
1516
+ if (picked.length === 1) return picked[0];
1517
+ if (!picked.length) throw new CliError("not_found", "No call is on");
1518
+ throw new CliError("bad_request", "More than one call is on: name one (its id or its chat)", { calls: picked.map((c) => c.id) });
1519
+ }
1520
+ link(chat) {
1521
+ return this.host.engine.getState().links.find((l) => l.id === chat);
1522
+ }
1523
+ /** A chat by id, contact key, prefix or name (the API's rules, `findChat`). */
1524
+ chatId(ref) {
1525
+ return findChat(this.host.engine.getState().links, ref).id;
1526
+ }
1527
+ /** The chat, if a call can go in it now; else why not (the engine's reason). */
1528
+ callable(ref) {
1529
+ const link = this.link(this.chatId(ref));
1530
+ if (!link.profile) throw new CliError("unavailable", "Calls are in chats made since 0.4 (the chat session): this is a compatibility chat");
1531
+ if (link.callsUnavailable) throw new CliError("unavailable", link.callsUnavailable);
1532
+ return link;
1533
+ }
1534
+ autoFor(chat) {
1535
+ return this.auto.on && (this.auto.from.length === 0 || this.auto.from.includes(chat));
1536
+ }
1537
+ };
1538
+ function nameOf(link) {
1539
+ return link.label?.trim() || link.peerNick?.trim() || null;
1540
+ }
1541
+ function rateOf(value, fallback) {
1542
+ if (value === void 0 || value === null) return fallback;
1543
+ if (!isCallRate(value)) throw new CliError("bad_request", "rate must be 8000, 12000, 16000, 24000 or 48000");
1544
+ return value;
1545
+ }
1546
+ function readAuto(path) {
1547
+ const off = {
1548
+ on: false,
1549
+ from: [],
1550
+ rate: DEFAULT_RATE
1551
+ };
1552
+ if (!existsSync(path)) return off;
1553
+ try {
1554
+ const saved = JSON.parse(readFileSync(path, "utf8")).autoAnswer;
1555
+ if (!saved) return off;
1556
+ return {
1557
+ on: saved.on === true,
1558
+ from: Array.isArray(saved.from) ? saved.from.filter((c) => typeof c === "string") : [],
1559
+ rate: isCallRate(saved.rate) ? saved.rate : DEFAULT_RATE
1560
+ };
1561
+ } catch {
1562
+ return off;
1563
+ }
1564
+ }
1565
+ var SEEN_DB = "ghostly-cli";
1566
+ var SEEN = "seen";
1567
+ /**
1568
+ * How long the seen set keeps the note of a settled message after its last activity (its arrival, its edit, a reaction):
1569
+ * older notes are dropped, and a message that came here longer ago with no note is no news. A daemon kept a note per
1570
+ * message of its whole history in memory, for as long as it ran.
1571
+ */
1572
+ var SEEN_KEEP_MS = 2592e6;
1573
+ /** How often a daemon drops the notes that passed `SEEN_KEEP_MS`. */
1574
+ var SWEEP_EVERY_MS = 216e5;
1575
+ /** How long a `file.*` event waits for the message that carries its file. */
1576
+ var FILE_MESSAGE_WAIT_MS = 2e3;
1577
+ /** The notice an app sends when a chat first goes live (apps/ui/src/hooks/useChat.ts): shown as a line, not a message. */
1578
+ var JOIN_NOTICE = /^👋 (?:(.+) )?joined$/;
1579
+ /**
1580
+ * Turns the engine's events into the stream, keeps the journal, and remembers which messages it already reported
1581
+ * (a store of its own in the profile's IndexedDB): after a restart, only what is new is reported, and a first
1582
+ * start reports nothing of the history that was already there.
1583
+ */
1584
+ var EventHub = class {
1585
+ journalPath;
1586
+ now;
1587
+ seenDb;
1588
+ seq = 0;
1589
+ journalLines = 0;
1590
+ listeners = /* @__PURE__ */ new Set();
1591
+ stateListeners = /* @__PURE__ */ new Set();
1592
+ callListeners = /* @__PURE__ */ new Set();
1593
+ appListeners = /* @__PURE__ */ new Set();
1594
+ seen = /* @__PURE__ */ new Map();
1595
+ firstRun = false;
1596
+ chats = /* @__PURE__ */ new Map();
1597
+ groups = /* @__PURE__ */ new Map();
1598
+ payments = /* @__PURE__ */ new Map();
1599
+ transfers = /* @__PURE__ */ new Map();
1600
+ received = /* @__PURE__ */ new Map();
1601
+ /** File id to the message that carries it, for `file.*` events. */
1602
+ fileMessages = /* @__PURE__ */ new Map();
1603
+ /** `file.*` events whose message was not seen yet: they wait for it a moment (the engine stores it right after). */
1604
+ pendingFiles = /* @__PURE__ */ new Map();
1605
+ baselined = false;
1606
+ /** Chats and groups whose history the baseline did not read whole, and how to read it: until a file event needs it. */
1607
+ unread = /* @__PURE__ */ new Map();
1608
+ /**
1609
+ * Whether the seen set notes mentions (`+m`): false on a set written before it did, whose mentions were reported with
1610
+ * their `group.message` already. Its first baseline then notes them without a `group.mentioned` each.
1611
+ */
1612
+ mentionsNoted = true;
1613
+ /** Whether the notes carry their time (`@…`): false on a set written before they did, until a whole read stamps them. */
1614
+ agesNoted = true;
1615
+ sweeper = null;
1616
+ db;
1617
+ state = null;
1618
+ /** `seenDb`: the IndexedDB database of the seen set (tests give each hub its own). */
1619
+ constructor(journalPath, now = Date.now, seenDb = SEEN_DB) {
1620
+ this.journalPath = journalPath;
1621
+ this.now = now;
1622
+ this.seenDb = seenDb;
1623
+ }
1624
+ /** Loads the journal's last seq and the seen set; call before `sink` is attached. */
1625
+ async open() {
1626
+ if (existsSync(this.journalPath)) {
1627
+ const lines = readFileSync(this.journalPath, "utf8").split("\n").filter(Boolean);
1628
+ this.journalLines = lines.length;
1629
+ for (let i = lines.length - 1; i >= 0; i--) try {
1630
+ this.seq = JSON.parse(lines[i]).seq;
1631
+ break;
1632
+ } catch {}
1633
+ }
1634
+ this.db = await new Promise((resolve, reject) => {
1635
+ const request = indexedDB.open(this.seenDb, 1);
1636
+ request.onupgradeneeded = () => {
1637
+ request.result.createObjectStore(SEEN);
1638
+ this.firstRun = true;
1639
+ };
1640
+ request.onsuccess = () => resolve(request.result);
1641
+ request.onerror = () => reject(request.error);
1642
+ });
1643
+ const store = this.db.transaction(SEEN).objectStore(SEEN);
1644
+ const all = (request) => new Promise((resolve, reject) => {
1645
+ request.onsuccess = () => resolve(request.result);
1646
+ request.onerror = () => reject(request.error);
1647
+ });
1648
+ const [keys, values] = await Promise.all([all(store.getAllKeys()), all(store.getAll())]);
1649
+ for (let i = 0; i < keys.length; i++) {
1650
+ const [chat, id] = keys[i];
1651
+ this.seenOf(chat).set(id, values[i]);
1652
+ }
1653
+ this.mentionsNoted = this.firstRun || this.seen.get(FORMAT)?.get(MENTIONS_NOTED) === "yes";
1654
+ this.agesNoted = this.firstRun || this.seen.get(FORMAT)?.get(AGES_NOTED) === "yes";
1655
+ }
1656
+ /** The sink the engine server posts to. */
1657
+ sink = { post: (message) => {
1658
+ try {
1659
+ this.handle(message);
1660
+ } catch (error) {
1661
+ process.stderr.write(`ghostly: event error: ${error instanceof Error ? error.stack : String(error)}\n`);
1662
+ }
1663
+ } };
1664
+ /**
1665
+ * The engine as it is at start, before the sink is attached: chats and groups are known without events; messages
1666
+ * missing from the seen set are news (they came while no process derived events, a crash), except on a first
1667
+ * run, which only learns the history that was already there.
1668
+ */
1669
+ baseline(state, histories, rest) {
1670
+ this.stateChanged(state);
1671
+ for (const [chat, messages] of histories) this.messages(chat, messages);
1672
+ for (const [chat, messages] of rest?.newest ?? []) {
1673
+ this.messages(chat, messages, []);
1674
+ this.unread.set(chat, rest.whole);
1675
+ }
1676
+ this.baselined = true;
1677
+ this.firstRun = false;
1678
+ const marks = [MENTIONS_NOTED, AGES_NOTED].filter((mark) => this.seen.get(FORMAT)?.get(mark) !== "yes");
1679
+ for (const mark of marks) this.seenOf(FORMAT).set(mark, "yes");
1680
+ if (marks.length) this.persist(FORMAT, marks.map((mark) => [mark, "yes"]));
1681
+ this.mentionsNoted = true;
1682
+ this.agesNoted = true;
1683
+ this.sweep();
1684
+ if (!this.sweeper) {
1685
+ this.sweeper = setInterval(() => this.sweep(), SWEEP_EVERY_MS);
1686
+ this.sweeper.unref?.();
1687
+ }
1688
+ }
1689
+ /**
1690
+ * Drops the notes of settled messages, and of reactions, whose last activity is older than `SEEN_KEEP_MS`: from memory
1691
+ * and from the store. A message of mine still on its way (or failed, which a retry sends again) keeps its note.
1692
+ */
1693
+ sweep() {
1694
+ const cutoff = this.now() - SEEN_KEEP_MS;
1695
+ let dropped = 0;
1696
+ for (const [chat, known] of this.seen) {
1697
+ if (chat === FORMAT || chat.startsWith("said:")) continue;
1698
+ const reactions = chat.startsWith("reactions:");
1699
+ const gone = [];
1700
+ for (const [id, note] of known) {
1701
+ const at = timeIn(note);
1702
+ if (at === void 0 || at >= cutoff || !reactions && !settled(stateIn(note))) continue;
1703
+ known.delete(id);
1704
+ gone.push([id, null]);
1705
+ }
1706
+ if (gone.length) {
1707
+ this.persist(chat, gone);
1708
+ dropped += gone.length;
1709
+ }
1710
+ if (!known.size) this.seen.delete(chat);
1711
+ }
1712
+ return dropped;
1713
+ }
1714
+ /**
1715
+ * Whether these messages of a chat or group (its newest) are all as the seen set has them, reactions included: there
1716
+ * is then nothing to report of them. Never on a first run, nor on a seen set from before it noted mentions: those
1717
+ * take the whole history once.
1718
+ */
1719
+ inStep(chat, messages) {
1720
+ if (this.firstRun || !this.mentionsNoted || !this.agesNoted) return false;
1721
+ const known = this.seen.get(chat), reactions = this.seen.get(`reactions:${chat}`);
1722
+ const cutoff = this.now() - SEEN_KEEP_MS;
1723
+ return messages.every((message) => {
1724
+ const note = known?.get(message.id), before = note === void 0 ? void 0 : stateIn(note);
1725
+ if (note === void 0 && message.timestamp < cutoff && activityOf(message) < cutoff) return true;
1726
+ let state = stateOf(message);
1727
+ if (before !== void 0 && mentionedIn(before) && !mentionedIn(state)) state = withFlag(state, MENTIONED);
1728
+ if (before !== state) return false;
1729
+ return Object.entries(message.reactions ?? {}).every(([by, r]) => {
1730
+ const note = reactions?.get(`${message.id}|${by}`);
1731
+ return note === void 0 ? r.at < cutoff : stateIn(note) === String(r.n);
1732
+ });
1733
+ });
1734
+ }
1735
+ onEvent(listener) {
1736
+ this.listeners.add(listener);
1737
+ return () => this.listeners.delete(listener);
1738
+ }
1739
+ onState(listener) {
1740
+ this.stateListeners.add(listener);
1741
+ return () => this.stateListeners.delete(listener);
1742
+ }
1743
+ /** A call signal from a contact's app, as the engine gives it. */
1744
+ onCallSignal(listener) {
1745
+ this.callListeners.add(listener);
1746
+ return () => this.callListeners.delete(listener);
1747
+ }
1748
+ /** A frame of a contact's mini-app (apps/1), as the engine gives it: the app named by its chat app id. */
1749
+ onAppFrame(listener) {
1750
+ this.appListeners.add(listener);
1751
+ return () => this.appListeners.delete(listener);
1752
+ }
1753
+ /** Events after `since` still in the journal; a gap event first when older ones were dropped. */
1754
+ replay(since) {
1755
+ if (!existsSync(this.journalPath) || since >= this.seq) return [];
1756
+ const out = [];
1757
+ for (const line of readFileSync(this.journalPath, "utf8").split("\n")) {
1758
+ if (!line) continue;
1759
+ try {
1760
+ const event = JSON.parse(line);
1761
+ if (event.seq > since) out.push(event);
1762
+ } catch {}
1763
+ }
1764
+ if (out.length && out[0].seq > since + 1) out.unshift({
1765
+ seq: since,
1766
+ id: `events.gap:${since}:${out[0].seq}`,
1767
+ type: "events.gap",
1768
+ at: this.now(),
1769
+ from: since + 1,
1770
+ to: out[0].seq - 1
1771
+ });
1772
+ return out;
1773
+ }
1774
+ get lastSeq() {
1775
+ return this.seq;
1776
+ }
1777
+ /** Emits an event the host makes itself (`daemon.started`). */
1778
+ emit(type, id, fields = {}) {
1779
+ const event = {
1780
+ seq: ++this.seq,
1781
+ id,
1782
+ type,
1783
+ at: this.now(),
1784
+ ...fields
1785
+ };
1786
+ appendFileSync(this.journalPath, JSON.stringify(event) + "\n", { mode: 384 });
1787
+ if (++this.journalLines > 2e4) this.trimJournal();
1788
+ for (const listener of this.listeners) try {
1789
+ listener(event);
1790
+ } catch {}
1791
+ return event;
1792
+ }
1793
+ trimJournal() {
1794
+ const lines = readFileSync(this.journalPath, "utf8").split("\n").filter(Boolean).slice(-1e4);
1795
+ writeFileSync(this.journalPath + ".tmp", lines.join("\n") + "\n", { mode: 384 });
1796
+ renameSync(this.journalPath + ".tmp", this.journalPath);
1797
+ this.journalLines = lines.length;
1798
+ }
1799
+ handle(message) {
1800
+ switch (message.kind) {
1801
+ case "state":
1802
+ this.stateChanged(message.state);
1803
+ break;
1804
+ case "messages":
1805
+ this.messages(message.linkId, message.messages);
1806
+ break;
1807
+ case "message-changes":
1808
+ this.messages(message.linkId, message.messages, message.deleted);
1809
+ break;
1810
+ case "call-signal":
1811
+ for (const listener of this.callListeners) try {
1812
+ listener(message.linkId, message.signal);
1813
+ } catch (error) {
1814
+ process.stderr.write(`ghostly: call error: ${error instanceof Error ? error.stack : String(error)}\n`);
1815
+ }
1816
+ break;
1817
+ case "app-frame": for (const listener of this.appListeners) try {
1818
+ listener(message.linkId, message.event);
1819
+ } catch (error) {
1820
+ process.stderr.write(`ghostly: app frame error: ${error instanceof Error ? error.stack : String(error)}\n`);
1821
+ }
1822
+ }
1823
+ }
1824
+ stateChanged(state) {
1825
+ const quiet = !this.baselined;
1826
+ const links = new Map(state.links.map((link) => [link.id, link]));
1827
+ for (const [id, link] of links) {
1828
+ const shape = chatShape(link), before = this.chats.get(id);
1829
+ this.chats.set(id, shape);
1830
+ if (quiet) continue;
1831
+ if (!before) {
1832
+ this.emit("chat.created", `chat.created:${id}`, {
1833
+ chat: id,
1834
+ summary: {
1835
+ ...chatJson(link),
1836
+ invite: null
1837
+ }
1838
+ });
1839
+ continue;
1840
+ }
1841
+ if (shape.stage && shape.stage !== before.stage) {
1842
+ const progress = link.pairingProgress;
1843
+ this.emit("chat.pairing", `chat.pairing:${id}:${shape.stage}:${progress?.since ?? this.now()}`, {
1844
+ chat: id,
1845
+ stage: shape.stage,
1846
+ ...progress?.detail ? { detail: progress.detail } : {},
1847
+ ...progress?.reason ? { reason: progress.reason } : {},
1848
+ ...progress?.peerSeen ? { peerSeen: true } : {}
1849
+ });
1850
+ }
1851
+ if (shape.live !== before.live || shape.transport !== before.transport) {
1852
+ const at = this.now();
1853
+ this.emit("chat.connection", `chat.connection:${id}:${shape.live ? shape.transport : "down"}:${at}`, {
1854
+ chat: id,
1855
+ live: shape.live,
1856
+ transport: shape.transport,
1857
+ text: link.textDelivery ?? null
1858
+ });
1859
+ }
1860
+ if (shape.name !== before.name) this.emit("chat.renamed", `chat.renamed:${id}:${this.now()}`, {
1861
+ chat: id,
1862
+ name: shape.name
1863
+ });
1864
+ this.pinned("chat", id, before.pin, shape.pin);
1865
+ if (shape.typing !== before.typing) this.emit(shape.typing ? "typing.started" : "typing.stopped", `typing.${shape.typing ? "started" : "stopped"}:${id}:${this.now()}`, {
1866
+ chat: id,
1867
+ ...shape.typing ? typingFields(shape) : {}
1868
+ });
1869
+ else if (shape.typing && (shape.typingKind !== before.typingKind || shape.typingStatus !== before.typingStatus)) this.emit("typing.started", `typing.started:${id}:${this.now()}:${this.seq + 1}`, {
1870
+ chat: id,
1871
+ ...typingFields(shape)
1872
+ });
1873
+ }
1874
+ for (const id of [...this.chats.keys()]) {
1875
+ if (links.has(id)) continue;
1876
+ this.chats.delete(id);
1877
+ if (!quiet) this.emit("chat.removed", `chat.removed:${id}`, { chat: id });
1878
+ }
1879
+ const groups = new Map(state.groups.map((group) => [group.id, group]));
1880
+ for (const [id, group] of groups) {
1881
+ const shape = groupShape(group), before = this.groups.get(id);
1882
+ this.groups.set(id, shape);
1883
+ if (quiet) continue;
1884
+ if (!before) {
1885
+ this.emit("group.created", `group.created:${id}`, {
1886
+ group: id,
1887
+ summary: groupJson(group)
1888
+ });
1889
+ continue;
1890
+ }
1891
+ if (shape.status !== before.status) this.emit("group.status", `group.status:${id}:${shape.status}:${this.now()}`, {
1892
+ group: id,
1893
+ status: shape.status
1894
+ });
1895
+ const joined = shape.members.filter((key) => !before.members.includes(key));
1896
+ const left = before.members.filter((key) => !shape.members.includes(key));
1897
+ if (joined.length || left.length) this.emit("group.members", `group.members:${id}:${this.now()}`, {
1898
+ group: id,
1899
+ joined,
1900
+ left
1901
+ });
1902
+ this.pinned("group", id, before.pin, shape.pin);
1903
+ for (const [member, word] of Object.entries(shape.typing)) {
1904
+ const was = before.typing[member];
1905
+ if (was && was.kind === word.kind && was.status === word.status) continue;
1906
+ this.emit("group.typing.started", `group.typing.started:${id}:${member}:${this.now()}${was ? `:${this.seq + 1}` : ""}`, {
1907
+ group: id,
1908
+ member,
1909
+ ...word
1910
+ });
1911
+ }
1912
+ for (const member of Object.keys(before.typing)) if (!shape.typing[member]) this.emit("group.typing.stopped", `group.typing.stopped:${id}:${member}:${this.now()}`, {
1913
+ group: id,
1914
+ member
1915
+ });
1916
+ }
1917
+ for (const id of [...this.groups.keys()]) {
1918
+ if (groups.has(id)) continue;
1919
+ this.groups.delete(id);
1920
+ if (!quiet) this.emit("group.removed", `group.removed:${id}`, { group: id });
1921
+ }
1922
+ for (const [id, payment] of Object.entries(state.payments ?? {})) {
1923
+ const shape = `${payment.state}|${payment.error ?? ""}|${payment.closed ? 1 : 0}`, before = this.payments.get(id);
1924
+ this.payments.set(id, shape);
1925
+ if (quiet || before === shape) continue;
1926
+ const json = paymentJson(payment);
1927
+ if (before === void 0) this.emit("payment.created", `payment.created:${id}`, {
1928
+ chat: payment.linkId,
1929
+ payment: json
1930
+ });
1931
+ else this.emit("payment.updated", `payment.updated:${id}:${payment.state}:${this.now()}`, {
1932
+ chat: payment.linkId,
1933
+ payment: json
1934
+ });
1935
+ }
1936
+ for (const link of state.links) for (const identity of link.identities?.received ?? []) {
1937
+ const key = `${link.id}:${identity.id}`, shape = identity.status, before = this.received.get(key);
1938
+ this.received.set(key, shape);
1939
+ if (quiet || before === shape) continue;
1940
+ this.emit(before === void 0 ? "identity.received" : "identity.status", `identity.${before === void 0 ? "received" : "status"}:${key}:${shape}`, {
1941
+ chat: link.id,
1942
+ identity: {
1943
+ id: identity.id,
1944
+ provider: identity.provider,
1945
+ subject: identity.subject,
1946
+ status: identity.status,
1947
+ verified: identity.verified
1948
+ }
1949
+ });
1950
+ }
1951
+ for (const [id, transfer] of Object.entries(state.transfers ?? {})) {
1952
+ const shape = `${transfer.state}|${transfer.stage ?? ""}`, before = this.transfers.get(id);
1953
+ this.transfers.set(id, shape);
1954
+ if (quiet || before === shape) continue;
1955
+ const chat = state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null;
1956
+ const group = chat ? void 0 : state.groups.find((g) => id.startsWith(`group-${g.id}-`))?.id;
1957
+ const fields = {
1958
+ chat,
1959
+ ...group ? { group } : {},
1960
+ file: id,
1961
+ direction: transfer.direction ?? (id.includes("-in-") ? "in" : "out"),
1962
+ size: transfer.size,
1963
+ transferred: transfer.transferred,
1964
+ ...transfer.error ? { error: transfer.error } : {}
1965
+ };
1966
+ let emit = null;
1967
+ if (transfer.state === "done") {
1968
+ const key = `file.done:${id}`;
1969
+ emit = (messageId) => this.emit("file.done", key, {
1970
+ ...fields,
1971
+ messageId
1972
+ });
1973
+ } else if (transfer.state === "failed") {
1974
+ const key = `file.failed:${id}:${this.now()}`;
1975
+ emit = (messageId) => this.emit("file.failed", key, {
1976
+ ...fields,
1977
+ messageId,
1978
+ retry: !!transfer.retry
1979
+ });
1980
+ } else if (transfer.stage === "asking" && fields.direction === "in") {
1981
+ const key = `file.offered:${id}:${this.now()}`;
1982
+ emit = (messageId) => this.emit("file.offered", key, {
1983
+ ...fields,
1984
+ messageId,
1985
+ room: transfer.room ?? null
1986
+ });
1987
+ } else if (transfer.stage) {
1988
+ const key = `file.stage:${id}:${transfer.stage}:${this.now()}`, stage = {
1989
+ stage: transfer.stage,
1990
+ ...transfer.pausedBy ? { pausedBy: transfer.pausedBy } : {}
1991
+ };
1992
+ emit = (messageId) => this.emit("file.stage", key, {
1993
+ ...fields,
1994
+ messageId,
1995
+ ...stage
1996
+ });
1997
+ }
1998
+ if (emit) this.fileEvent(id, emit, chat ?? (group ? `group:${group}` : null));
1999
+ }
2000
+ this.state = state;
2001
+ for (const listener of this.stateListeners) try {
2002
+ listener(state);
2003
+ } catch {}
2004
+ }
2005
+ /**
2006
+ * Someone else pinned a message, or unpinned it (WISP 400 § Pinned message): `chat.pinned` or `group.pinned`, with
2007
+ * `messageId` (the row here; null when it is not here, or unpinned), `ref` (the id both sides know), `by` (`peer` or a
2008
+ * member key) and `removed`. What this profile pins itself says nothing: its command answered already.
2009
+ */
2010
+ pinned(kind, id, before, now) {
2011
+ if (!now || now.by === "me" || before?.id === now.id && before.at === now.at) return;
2012
+ const fields = {
2013
+ [kind]: id,
2014
+ messageId: now.messageId ?? null,
2015
+ ref: now.id || null,
2016
+ by: now.by,
2017
+ removed: !now.id
2018
+ };
2019
+ this.emit(`${kind}.pinned`, `${kind}.pinned:${id}:${now.id || "off"}:${now.at}`, fields);
2020
+ }
2021
+ /**
2022
+ * A `file.*` event names the message that carries the file. The engine can say where a transfer stands before it
2023
+ * stores that message: the event then waits for it, up to 2 s, and goes with `messageId: null` after that.
2024
+ */
2025
+ fileEvent(fileId, emit, chat) {
2026
+ const pending = this.pendingFiles.get(fileId);
2027
+ const messageId = this.fileMessages.get(fileId);
2028
+ if (messageId && !pending) {
2029
+ emit(messageId);
2030
+ return;
2031
+ }
2032
+ if (pending) {
2033
+ pending.emits.push(emit);
2034
+ return;
2035
+ }
2036
+ const timer = setTimeout(() => this.flushFile(fileId, null), FILE_MESSAGE_WAIT_MS);
2037
+ timer.unref?.();
2038
+ this.pendingFiles.set(fileId, {
2039
+ timer,
2040
+ emits: [emit]
2041
+ });
2042
+ const read = chat === null ? void 0 : this.unread.get(chat);
2043
+ if (!read) return;
2044
+ this.unread.delete(chat);
2045
+ read(chat).then((messages) => {
2046
+ for (const message of messages) {
2047
+ if (!message.file || this.fileMessages.has(message.file.id)) continue;
2048
+ this.fileMessages.set(message.file.id, message.id);
2049
+ this.flushFile(message.file.id, message.id);
2050
+ }
2051
+ }).catch(() => {});
2052
+ }
2053
+ flushFile(fileId, messageId) {
2054
+ const pending = this.pendingFiles.get(fileId);
2055
+ if (!pending) return;
2056
+ this.pendingFiles.delete(fileId);
2057
+ clearTimeout(pending.timer);
2058
+ for (const emit of pending.emits) emit(messageId);
2059
+ }
2060
+ /** Whether this side said something once in a chat (the join notice): kept with the seen set. */
2061
+ said(chat, what) {
2062
+ return this.seen.get(`said:${chat}`)?.has(what) ?? false;
2063
+ }
2064
+ markSaid(chat, what) {
2065
+ this.seenOf(`said:${chat}`).set(what, "yes");
2066
+ this.persist(`said:${chat}`, [[what, "yes"]]);
2067
+ }
2068
+ seenOf(chat) {
2069
+ let map = this.seen.get(chat);
2070
+ if (!map) this.seen.set(chat, map = /* @__PURE__ */ new Map());
2071
+ return map;
2072
+ }
2073
+ /** A chat's or group's whole history, or with `deleted` only what changed in it (then only those ids are gone). */
2074
+ messages(chat, messages, deleted) {
2075
+ const known = this.seenOf(chat);
2076
+ const group = chat.startsWith("group:") ? chat.slice(6) : null;
2077
+ const quiet = this.firstRun && !this.baselined || !!group && !deleted && messages.length === 0;
2078
+ const changes = [];
2079
+ const present = /* @__PURE__ */ new Set();
2080
+ this.reactions(chat, group, messages, quiet);
2081
+ const files = [];
2082
+ const cutoff = this.now() - SEEN_KEEP_MS;
2083
+ for (const message of messages) {
2084
+ present.add(message.id);
2085
+ if (message.file && this.fileMessages.get(message.file.id) !== message.id) {
2086
+ this.fileMessages.set(message.file.id, message.id);
2087
+ files.push([message.file.id, message.id]);
2088
+ }
2089
+ let state = stateOf(message);
2090
+ const note = known.get(message.id), before = note === void 0 ? void 0 : stateIn(note);
2091
+ const at = activityOf(message);
2092
+ const json = () => group ? groupMessageJson(message, this.state?.groups.find((g) => g.id === group)) : chatMessageJson(message, this.state?.links.find((l) => l.id === chat));
2093
+ if (before === void 0 && message.timestamp < cutoff) {
2094
+ if (at < cutoff) continue;
2095
+ const noted = noteOf(state, at);
2096
+ known.set(message.id, noted);
2097
+ changes.push([message.id, noted]);
2098
+ const edit = message.edit;
2099
+ if (quiet || !edit || edit.at < cutoff || message.sender === "me" && edit.restore) continue;
2100
+ if (group) this.emit("group.message.edited", `group.message.edited:${group}:${message.id}:${edit.seq}`, {
2101
+ group,
2102
+ messageId: message.id,
2103
+ edits: edit.seq,
2104
+ message: json()
2105
+ });
2106
+ else this.emit("message.edited", `message.edited:${chat}:${message.id}:${edit.seq}`, {
2107
+ chat,
2108
+ messageId: message.id,
2109
+ edits: edit.seq,
2110
+ message: json()
2111
+ });
2112
+ continue;
2113
+ }
2114
+ if (before !== void 0 && mentionedIn(before) && !mentionedIn(state)) state = withFlag(state, MENTIONED);
2115
+ if (before === state) {
2116
+ if (timeIn(note) === void 0) {
2117
+ const noted = noteOf(state, at);
2118
+ known.set(message.id, noted);
2119
+ changes.push([message.id, noted]);
2120
+ }
2121
+ continue;
2122
+ }
2123
+ const noted = noteOf(state, at);
2124
+ known.set(message.id, noted);
2125
+ changes.push([message.id, noted]);
2126
+ if (quiet) continue;
2127
+ if (before === void 0) {
2128
+ if (group) {
2129
+ const type = message.event ? "group.event" : message.sender === "peer" ? "group.message" : "group.sent";
2130
+ this.emit(type, `${type}:${group}:${message.id}`, {
2131
+ group,
2132
+ message: json()
2133
+ });
2134
+ } else {
2135
+ const notice = JOIN_NOTICE.exec(message.text);
2136
+ const type = notice ? message.sender === "peer" ? "chat.joined" : "chat.announced" : message.sender === "peer" ? "message.received" : "message.sent";
2137
+ this.emit(type, `${type}:${chat}:${message.id}`, {
2138
+ chat,
2139
+ message: json(),
2140
+ ...notice ? { name: notice[1] ?? null } : {}
2141
+ });
2142
+ }
2143
+ if (message.sender === "peer" && message.press) this.pressed(chat, group, message, json());
2144
+ continue;
2145
+ }
2146
+ if (message.sender === "peer" && message.press && !pressedIn(before)) this.pressed(chat, group, message, json());
2147
+ if (group && mentionedIn(state) && !mentionedIn(before) && this.mentionsNoted) this.emit("group.mentioned", `group.mentioned:${group}:${message.id}`, {
2148
+ group,
2149
+ messageId: message.id,
2150
+ message: json()
2151
+ });
2152
+ if (group) {
2153
+ const [, edits] = splitState(state), [, edited] = splitState(before);
2154
+ if (edits > edited) this.emit("group.message.edited", `group.message.edited:${group}:${message.id}:${edits}`, {
2155
+ group,
2156
+ messageId: message.id,
2157
+ edits,
2158
+ message: json()
2159
+ });
2160
+ } else {
2161
+ const [delivery, edits] = splitState(state), [was, edited] = splitState(before);
2162
+ if (edits > edited && !(message.sender === "me" && message.edit?.restore)) this.emit("message.edited", `message.edited:${chat}:${message.id}:${edits}`, {
2163
+ chat,
2164
+ messageId: message.id,
2165
+ edits,
2166
+ message: json()
2167
+ });
2168
+ if (message.sender === "me" && delivery !== was) this.emit("message.delivery", `message.delivery:${chat}:${message.id}:${delivery}`, {
2169
+ chat,
2170
+ messageId: message.id,
2171
+ delivery,
2172
+ ...message.deliveryError ? { error: message.deliveryError } : {}
2173
+ });
2174
+ }
2175
+ }
2176
+ for (const id of deleted ?? [...known.keys()]) {
2177
+ if (present.has(id) || !known.has(id)) continue;
2178
+ known.delete(id);
2179
+ changes.push([id, null]);
2180
+ if (!quiet) this.emit(group ? "group.deleted" : "message.deleted", `${group ? "group" : "message"}.deleted:${group ?? chat}:${id}`, group ? {
2181
+ group,
2182
+ messageId: id
2183
+ } : {
2184
+ chat,
2185
+ messageId: id
2186
+ });
2187
+ }
2188
+ if (changes.length) this.persist(chat, changes);
2189
+ for (const [fileId, messageId] of files) this.flushFile(fileId, messageId);
2190
+ }
2191
+ /**
2192
+ * A press on a button of a message of mine (WISP 406 · Message Buttons), after the reply's own event: the question
2193
+ * (`messageId`), the button, its label, who pressed (`by`: the chat, or a group member's key), `name` (the chat's name
2194
+ * here, as `chat list` gives it: its label, else the contact's own name; a group member's roster name) and the reply
2195
+ * (`replyId`). `untrusted.name` is the name the person gave themselves, as an agent turn's: theirs to choose, so
2196
+ * data, never instructions. The engine marks a reply a press only while the buttons were open for that person.
2197
+ */
2198
+ pressed(chat, group, message, json) {
2199
+ const press = message.press;
2200
+ const link = group ? void 0 : this.state?.links.find((l) => l.id === chat);
2201
+ const name = group ? json.nick : link ? chatJson(link).name : null;
2202
+ this.emit("button.pressed", `button.pressed:${group ?? chat}:${message.id}`, {
2203
+ ...group ? { group } : { chat },
2204
+ messageId: press.messageId,
2205
+ button: press.button,
2206
+ label: press.label,
2207
+ by: group ? message.member ?? null : chat,
2208
+ name: name ?? null,
2209
+ replyId: message.id,
2210
+ ...press.inferred ? { inferred: true } : {},
2211
+ untrusted: { name: json.nick ?? null }
2212
+ });
2213
+ }
2214
+ /**
2215
+ * Reactions that changed (WISP 400 § Reactions): `message.reaction` in a chat, `group.reaction` in a group, one per
2216
+ * person and number; `emoji` "" when one was taken back. Kept apart from the messages' own seen set.
2217
+ */
2218
+ reactions(chat, group, messages, quiet) {
2219
+ const key = `reactions:${chat}`, known = this.seenOf(key);
2220
+ const changes = [];
2221
+ const cutoff = this.now() - SEEN_KEEP_MS;
2222
+ for (const message of messages) for (const [by, r] of Object.entries(message.reactions ?? {})) {
2223
+ const id = `${message.id}|${by}`, value = String(r.n), note = known.get(id);
2224
+ const before = note === void 0 ? void 0 : stateIn(note);
2225
+ if (before === value) {
2226
+ if (timeIn(note) === void 0) {
2227
+ const noted = noteOf(value, r.at);
2228
+ known.set(id, noted);
2229
+ changes.push([id, noted]);
2230
+ }
2231
+ continue;
2232
+ }
2233
+ if (before === void 0 && r.at < cutoff) continue;
2234
+ const noted = noteOf(value, r.at);
2235
+ known.set(id, noted);
2236
+ changes.push([id, noted]);
2237
+ if (quiet || before === void 0 && !r.e) continue;
2238
+ const fields = {
2239
+ messageId: message.id,
2240
+ by,
2241
+ emoji: r.e,
2242
+ removed: !r.e,
2243
+ mine: message.sender === "me"
2244
+ };
2245
+ if (group) this.emit("group.reaction", `group.reaction:${group}:${message.id}:${by}:${r.n}`, {
2246
+ group,
2247
+ ...fields
2248
+ });
2249
+ else this.emit("message.reaction", `message.reaction:${chat}:${message.id}:${by}:${r.n}`, {
2250
+ chat,
2251
+ ...fields
2252
+ });
2253
+ }
2254
+ if (changes.length) this.persist(key, changes);
2255
+ }
2256
+ /**
2257
+ * Relaxed: nobody waits for it, and a note lost to a power cut is made again from the history at the next start
2258
+ * (its event is then reported a second time, as after a crash between a message's row and its note). A disk sync
2259
+ * for each one held the whole daemon for milliseconds, three times per message sent.
2260
+ */
2261
+ persist(chat, changes) {
2262
+ const store = this.db.transaction(SEEN, "readwrite", { durability: "relaxed" }).objectStore(SEEN);
2263
+ for (const [id, state] of changes) if (state === null) store.delete([chat, id]);
2264
+ else store.put(state, [chat, id]);
2265
+ }
2266
+ };
2267
+ function deliveryOf(message) {
2268
+ return message.sender === "peer" ? "received" : message.delivery ?? "sent";
2269
+ }
2270
+ /** What a received button press adds to its delivery in the seen set (`received+p`): it is reported once. */
2271
+ var PRESSED = "+p";
2272
+ /** What a group message that mentions me adds (`received+m`): a mention learned late is reported once. */
2273
+ var MENTIONED = "+m";
2274
+ /** The seen set's own notes: whether it notes mentions (sets from before `+m` do not). */
2275
+ var FORMAT = "format:";
2276
+ var MENTIONS_NOTED = "mentions";
2277
+ /** The seen set's mark that its notes carry their time. */
2278
+ var AGES_NOTED = "ages";
2279
+ /**
2280
+ * A note: what the seen set keeps of a message (or a reaction's number), then `@` and the time of its last activity in
2281
+ * base 36 (`received+p#e2@mg3k1x2a`), by which `sweep` drops it. A note from before has no time.
2282
+ */
2283
+ var noteOf = (state, at) => `${state}@${Math.max(0, Math.floor(at)).toString(36)}`;
2284
+ function stateIn(note) {
2285
+ const at = note.lastIndexOf("@");
2286
+ return at === -1 ? note : note.slice(0, at);
2287
+ }
2288
+ function timeIn(note) {
2289
+ const at = note.lastIndexOf("@");
2290
+ return at === -1 ? void 0 : parseInt(note.slice(at + 1), 36);
2291
+ }
2292
+ /** The last time a message changed here: it came, it was edited, someone reacted. */
2293
+ function activityOf(message) {
2294
+ let at = Math.max(message.timestamp, message.edit?.at ?? 0);
2295
+ for (const r of Object.values(message.reactions ?? {})) at = Math.max(at, r.at ?? 0);
2296
+ return at;
2297
+ }
2298
+ /** A message that will not change by itself any more: received, or mine and delivered. */
2299
+ function settled(state) {
2300
+ const delivery = splitState(state)[0].split("+")[0];
2301
+ return delivery === "received" || delivery === "delivered";
2302
+ }
2303
+ /**
2304
+ * What the seen set keeps of a message: its delivery (`+p` once a press on a message of mine was reported, `+m` once a
2305
+ * group message's mention of me was), and its edit number once edited (`sent#e3`, `received+p+m#e2`).
2306
+ */
2307
+ function stateOf(message) {
2308
+ const peer = message.sender === "peer";
2309
+ const delivery = deliveryOf(message) + (peer && message.press ? PRESSED : "") + (peer && message.mentioned && message.linkId.startsWith("group:") ? MENTIONED : "");
2310
+ return message.edit ? `${delivery}#e${message.edit.seq}` : delivery;
2311
+ }
2312
+ var flagsOf = (state) => splitState(state)[0].split("+").slice(1).map((flag) => "+" + flag);
2313
+ var pressedIn = (state) => flagsOf(state).includes(PRESSED);
2314
+ var mentionedIn = (state) => flagsOf(state).includes(MENTIONED);
2315
+ /** The state with a flag added before its edit number. */
2316
+ function withFlag(state, flag) {
2317
+ const at = state.lastIndexOf("#e");
2318
+ return at === -1 ? state + flag : state.slice(0, at) + flag + state.slice(at);
2319
+ }
2320
+ function splitState(state) {
2321
+ const at = state.lastIndexOf("#e");
2322
+ return at === -1 ? [state, 0] : [state.slice(0, at), Number(state.slice(at + 2)) || 0];
2323
+ }
2324
+ function chatShape(link) {
2325
+ const view = chatJson(link);
2326
+ return {
2327
+ stage: view.stage,
2328
+ live: view.live,
2329
+ transport: view.transport,
2330
+ name: view.name,
2331
+ typing: !!link.peerTyping,
2332
+ typingKind: link.peerTyping ? link.peerTypingKind ?? "typing" : "typing",
2333
+ typingStatus: link.peerTyping ? link.peerTypingStatus ?? null : null,
2334
+ pin: link.pin ?? null
2335
+ };
2336
+ }
2337
+ /** A `typing.started`'s word: the kind, and the contact's status line when it gave one (already cleaned). */
2338
+ function typingFields(shape) {
2339
+ return {
2340
+ kind: shape.typingKind,
2341
+ ...shape.typingStatus ? { status: shape.typingStatus } : {}
2342
+ };
2343
+ }
2344
+ function groupShape(group) {
2345
+ return {
2346
+ status: group.status ?? (group.invitation ? "invited" : null),
2347
+ members: group.members.map((m) => m.key).sort(),
2348
+ typing: Object.fromEntries((group.typing ?? []).map((t) => [t.key, {
2349
+ kind: t.kind ?? "typing",
2350
+ ...t.status ? { status: t.status } : {}
2351
+ }])),
2352
+ pin: group.pin ?? null
2353
+ };
2354
+ }
2355
+ //#endregion
2356
+ //#region src/runtime/fedimint.ts
2357
+ /**
2358
+ * The Fedimint client on Node (WISP 1100 § Wallet SDKs on Node): the app's own SDK and WebAssembly, each database in
2359
+ * a `worker_threads` worker (fedimintWorker.mjs) and a file of the profile's `fedimint/` folder, where the browser has
2360
+ * a module worker and the origin-private file system. Only the platform differs: the client and what the engine does
2361
+ * with it are the app's.
2362
+ */
2363
+ var WASM_PACKAGE = "@fedimint/fedimint-client-wasm-bundler";
2364
+ var DATABASE = /^ghostly-fedimint-[\w-]{1,80}\.db$/;
2365
+ /** The worker's file: beside this chunk wherever the bundler put it (the entry, or assets/), else the workspace's. */
2366
+ function workerFile() {
2367
+ for (const place of ["./fedimintWorker.mjs", "./assets/fedimintWorker.mjs"]) {
2368
+ const url = new URL(place, import.meta.url);
2369
+ if (existsSync(fileURLToPath(url))) return url;
2370
+ }
2371
+ throw new Error("The Fedimint worker is missing from this build");
2372
+ }
2373
+ var compiling;
2374
+ /** Compiled once per process (11 MB): each worker is handed the module, not the bytes. */
2375
+ function compiled() {
2376
+ return compiling ??= WebAssembly.compile(readFileSync(createRequire(import.meta.url).resolve(`${WASM_PACKAGE}/fedimint_client_wasm_bg.wasm`))).catch((error) => {
2377
+ compiling = void 0;
2378
+ throw error;
2379
+ });
2380
+ }
2381
+ function databasePath(dir, database) {
2382
+ if (!DATABASE.test(database)) throw new Error("Invalid Fedimint database name");
2383
+ return join(dir, database);
2384
+ }
2385
+ /** The SDK on this profile's folder `dir`. Loaded on first use, as in the app: a bot that joins no federation compiles nothing. */
2386
+ function nodeFedimintSdk(dir) {
2387
+ let loading;
2388
+ return () => loading ??= (async () => {
2389
+ const [{ Transport }, module] = await Promise.all([import("@fedimint/types"), compiled()]);
2390
+ mkdirSync(dir, {
2391
+ recursive: true,
2392
+ mode: 448
2393
+ });
2394
+ const glue = pathToFileURL(createRequire(import.meta.url).resolve(`${WASM_PACKAGE}/fedimint_client_wasm_bg.js`)).href;
2395
+ const worker = workerFile();
2396
+ class ThreadTransport extends Transport {
2397
+ logger = {
2398
+ debug() {},
2399
+ info() {},
2400
+ warn() {},
2401
+ error() {}
2402
+ };
2403
+ thread = new Worker(worker, { workerData: {
2404
+ dir,
2405
+ glue
2406
+ } });
2407
+ /** Why the thread ended, once it has: later requests fail with it rather than wait on nothing. */
2408
+ ended;
2409
+ constructor() {
2410
+ super();
2411
+ this.thread.on("message", (message) => this.messageHandler(message));
2412
+ this.thread.on("error", (error) => this.end(`The Fedimint client stopped: ${error instanceof Error && error.message || "unknown error"}`));
2413
+ this.thread.on("exit", () => this.end("The Fedimint client stopped"));
2414
+ }
2415
+ end(error) {
2416
+ if (this.ended) return;
2417
+ this.ended = error;
2418
+ this.messageHandler({
2419
+ type: "error",
2420
+ error
2421
+ });
2422
+ }
2423
+ postMessage(message) {
2424
+ const ended = this.ended;
2425
+ if (ended) {
2426
+ queueMicrotask(() => this.messageHandler({
2427
+ type: "error",
2428
+ error: ended,
2429
+ request_id: message.requestId
2430
+ }));
2431
+ return;
2432
+ }
2433
+ this.thread.postMessage(message.type === "init" ? {
2434
+ ...message,
2435
+ payload: {
2436
+ ...message.payload,
2437
+ module
2438
+ }
2439
+ } : message);
2440
+ }
2441
+ terminate() {
2442
+ this.ended ??= "The Fedimint client was closed";
2443
+ this.thread.terminate();
2444
+ }
2445
+ }
2446
+ return fedimintSdkOn({
2447
+ transport: async () => new ThreadTransport(),
2448
+ async exists(database) {
2449
+ try {
2450
+ return statSync(databasePath(dir, database)).size > 0;
2451
+ } catch {
2452
+ return false;
2453
+ }
2454
+ },
2455
+ async remove(database) {
2456
+ rmSync(databasePath(dir, database), { force: true });
2457
+ }
2458
+ });
2459
+ })().catch((error) => {
2460
+ loading = void 0;
2461
+ throw error;
2462
+ });
2463
+ }
2464
+ //#endregion
2465
+ //#region src/runtime/fileFetch.ts
2466
+ /**
2467
+ * `fetch` for `file:` URLs, which Node's does not do. The wallet SDKs' WebAssembly (Bark, BDK, Breez, Iroh) is loaded
2468
+ * the way a page loads it, `fetch(new URL("…wasm", import.meta.url))`; on Node that URL is a file beside the bundle.
2469
+ * Only `.wasm` files are answered, and nothing else of fetch changes.
2470
+ */
2471
+ function installFileFetch() {
2472
+ const original = globalThis.fetch;
2473
+ const patched = async (input, init) => {
2474
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
2475
+ if (url.startsWith("file:")) {
2476
+ const parsed = new URL(url);
2477
+ if (!parsed.pathname.endsWith(".wasm") || parsed.search || parsed.hash) throw new TypeError(`Refusing to fetch ${url}`);
2478
+ const bytes = await readFile(fileURLToPath(parsed));
2479
+ return new Response(bytes, { headers: { "content-type": "application/wasm" } });
2480
+ }
2481
+ return original(input, init);
2482
+ };
2483
+ globalThis.fetch = patched;
2484
+ }
2485
+ //#endregion
2486
+ //#region src/runtime/pushSend.ts
2487
+ /** An address as bytes (4 or 16), or null when it is not one. */
2488
+ function addressBytes(address) {
2489
+ const family = isIP(address);
2490
+ if (family === 4) return Uint8Array.from(address.split(".").map(Number));
2491
+ if (family !== 6) return null;
2492
+ let text = address.replace(/%.*$/, "");
2493
+ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
2494
+ if (v4) {
2495
+ const [a, b, c, d] = v4[1].split(".").map(Number);
2496
+ text = text.slice(0, -v4[1].length) + (a << 8 | b).toString(16) + ":" + (c << 8 | d).toString(16);
2497
+ }
2498
+ const [head, tail] = text.includes("::") ? text.split("::") : [text, null];
2499
+ const left = head ? head.split(":") : [];
2500
+ const right = tail ? tail.split(":") : [];
2501
+ const groups = tail === null ? left : [
2502
+ ...left,
2503
+ ...Array(8 - left.length - right.length).fill("0"),
2504
+ ...right
2505
+ ];
2506
+ if (groups.length !== 8) return null;
2507
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
2508
+ groups.forEach((group, i) => {
2509
+ const n = parseInt(group, 16);
2510
+ bytes[i * 2] = n >> 8;
2511
+ bytes[i * 2 + 1] = n & 255;
2512
+ });
2513
+ return bytes;
2514
+ }
2515
+ /** True when `address` is on the public Internet (see `isPublicAddress`). */
2516
+ function isPublicIp(address) {
2517
+ const bytes = addressBytes(address);
2518
+ return bytes !== null && isPublicAddress(bytes);
2519
+ }
2520
+ /** `lookup` over `resolve` (`dns.lookup`), refusing a name any of whose addresses is not public. */
2521
+ var publicOnly = (resolve) => (hostname, options, callback) => {
2522
+ resolve(hostname, {
2523
+ ...options,
2524
+ all: true
2525
+ }, (error, found) => {
2526
+ if (error) return callback(error, "", 4);
2527
+ const addresses = found;
2528
+ if (!addresses.length || addresses.some((a) => !isPublicIp(a.address))) return callback(Object.assign(/* @__PURE__ */ new Error(`${hostname} is not a public address`), { code: "ENOTFOUND" }), "", 4);
2529
+ if (options.all) return callback(null, addresses);
2530
+ callback(null, addresses[0].address, addresses[0].family);
2531
+ });
2532
+ };
2533
+ var publicOnlyLookup = publicOnly(lookup$1);
2534
+ /**
2535
+ * Posts a wake-up (WISP 401 § Wake-up push) the way the Desktop does from Rust: only to a push service's endpoint
2536
+ * (`checkPushEndpoint`), whose name must resolve to public addresses only, no redirect followed, no proxy, 10 s at most.
2537
+ * Answers the push service's status.
2538
+ */
2539
+ async function nodePushSend(push, lookup = publicOnlyLookup) {
2540
+ const url = checkPushEndpoint(push.url);
2541
+ return new Promise((resolve, reject) => {
2542
+ const req = request(url, {
2543
+ method: "POST",
2544
+ headers: {
2545
+ ...push.headers,
2546
+ "Content-Length": String(push.body.length)
2547
+ },
2548
+ lookup,
2549
+ agent: false,
2550
+ timeout: 1e4
2551
+ }, (res) => {
2552
+ res.resume();
2553
+ resolve(res.statusCode ?? 0);
2554
+ });
2555
+ req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error("The push service did not answer")));
2556
+ req.on("error", reject);
2557
+ req.end(push.body);
2558
+ });
2559
+ }
2560
+ //#endregion
2561
+ //#region src/runtime/rows.ts
2562
+ /** The warning `node:sqlite` gives as it loads (experimental on Node 22 and 23): said once for every command, it is noise. */
2563
+ var SQLITE_WARNING = /^SQLite is an experimental feature/;
2564
+ var loaded;
2565
+ /**
2566
+ * `node:sqlite`, or null where Node has none (before 22.13 it needs a flag): the store then keeps every value in
2567
+ * memory and in the snapshot, as before rows.db.
2568
+ */
2569
+ function loadSqlite() {
2570
+ loaded ??= (async () => {
2571
+ const emit = process.emitWarning;
2572
+ process.emitWarning = function(warning, ...rest) {
2573
+ if (SQLITE_WARNING.test(typeof warning === "string" ? warning : warning?.message ?? "")) return;
2574
+ return emit.call(process, warning, ...rest);
2575
+ };
2576
+ try {
2577
+ return await import("node:sqlite");
2578
+ } catch {
2579
+ return null;
2580
+ } finally {
2581
+ process.emitWarning = emit;
2582
+ }
2583
+ })();
2584
+ return loaded;
2585
+ }
2586
+ /** Format 2's rows, read. */
2587
+ function openRowFile(sqlite, path) {
2588
+ const db = new sqlite.DatabaseSync(path);
2589
+ db.exec("CREATE TABLE IF NOT EXISTS rows (id INTEGER PRIMARY KEY, value BLOB NOT NULL)");
2590
+ const select = db.prepare("SELECT value FROM rows WHERE id = ?");
2591
+ return {
2592
+ get(id) {
2593
+ const row = select.get(id);
2594
+ if (!row) throw new Error(`The store's rows.db has no row ${id}: the profile's store is damaged`);
2595
+ return deserialize(row.value);
2596
+ },
2597
+ count() {
2598
+ return Number(db.prepare("SELECT count(*) AS n FROM rows").get().n);
2599
+ },
2600
+ close() {
2601
+ db.close();
2602
+ }
2603
+ };
2604
+ }
2605
+ /** Where a record of format 2 has its value. */
2606
+ var ROW = Symbol("ghostly.row");
2607
+ /** Records of a snapshot of format 2, whose value is read from `rows` (a getter shared by all of them). */
2608
+ function rowRecords(rows) {
2609
+ const read = function() {
2610
+ return rows.get(this[ROW]);
2611
+ };
2612
+ const proto = Object.defineProperty({}, "value", {
2613
+ get: read,
2614
+ enumerable: true,
2615
+ configurable: true
2616
+ });
2617
+ return { make(key, row) {
2618
+ const record = Object.create(proto);
2619
+ record.key = key;
2620
+ record[ROW] = row;
2621
+ return record;
2622
+ } };
2623
+ }
2624
+ //#endregion
2625
+ //#region src/runtime/keyBytes.ts
2626
+ /**
2627
+ * An IndexedDB key as bytes whose order is the keys' own (W3C IndexedDB § Compare two keys: a number before a date
2628
+ * before a string before binary before an array; strings by UTF-16 code unit; arrays item by item, the shorter
2629
+ * first), so that SQLite, comparing them as blobs, sorts and ranges them as IndexedDB does. Held as a "latin1" string
2630
+ * (one char per byte): JavaScript compares those in the same order.
2631
+ *
2632
+ * Every encoding is prefix-free (numbers and dates are 9 bytes; strings, binary and arrays end with a 0 byte no unit or
2633
+ * item begins with), so a key followed by another (an index entry's key, then its record's) sorts as the pair does.
2634
+ */
2635
+ var NUMBER = 16;
2636
+ var DATE = 32;
2637
+ var STRING = 48;
2638
+ var BINARY = 64;
2639
+ var ARRAY = 80;
2640
+ function keyBytes(key) {
2641
+ const out = [];
2642
+ write(key, out, 0);
2643
+ let text = "";
2644
+ for (let at = 0; at < out.length; at += 8192) text += String.fromCharCode(...out.slice(at, at + 8192));
2645
+ return text;
2646
+ }
2647
+ function write(key, out, depth) {
2648
+ if (depth > 1e3) throw new Error("A key nested too deep");
2649
+ if (typeof key === "number") {
2650
+ out.push(NUMBER);
2651
+ number(key, out);
2652
+ return;
2653
+ }
2654
+ if (key instanceof Date) {
2655
+ out.push(DATE);
2656
+ number(key.getTime(), out);
2657
+ return;
2658
+ }
2659
+ if (typeof key === "string") {
2660
+ out.push(STRING);
2661
+ for (let at = 0; at < key.length; at++) {
2662
+ const unit = key.charCodeAt(at);
2663
+ if (unit < 126) out.push(unit + 1);
2664
+ else out.push(127, (unit >> 12) + 1, (unit >> 6 & 63) + 1, (unit & 63) + 1);
2665
+ }
2666
+ out.push(0);
2667
+ return;
2668
+ }
2669
+ if (key instanceof ArrayBuffer || ArrayBuffer.isView(key)) {
2670
+ const bytes = key instanceof ArrayBuffer ? new Uint8Array(key) : new Uint8Array(key.buffer, key.byteOffset, key.byteLength);
2671
+ out.push(BINARY);
2672
+ for (const byte of bytes) out.push((byte >> 4) + 1, (byte & 15) + 1);
2673
+ out.push(0);
2674
+ return;
2675
+ }
2676
+ if (Array.isArray(key)) {
2677
+ out.push(ARRAY);
2678
+ for (const item of key) write(item, out, depth + 1);
2679
+ out.push(0);
2680
+ return;
2681
+ }
2682
+ throw new Error("Not an IndexedDB key");
2683
+ }
2684
+ var view = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(8));
2685
+ /** A float64 big-endian, its sign bit flipped (a positive number) or every bit flipped (a negative one): bytes in the numbers' order. */
2686
+ function number(value, out) {
2687
+ view.setFloat64(0, value === 0 ? 0 : value);
2688
+ const negative = (view.getUint8(0) & 128) !== 0;
2689
+ for (let at = 0; at < 8; at++) {
2690
+ const byte = view.getUint8(at);
2691
+ out.push(negative ? ~byte & 255 : at === 0 ? byte ^ 128 : byte);
2692
+ }
2693
+ }
2694
+ //#endregion
2695
+ //#region src/runtime/sqlTree.ts
2696
+ var buffer = (bytes) => Buffer.from(bytes, "latin1");
2697
+ /**
2698
+ * Values just read from rows.db, which nothing else holds: fake-indexeddb copies each value it hands out, and for these
2699
+ * the copy is a second one (v8 made the first), 7.6 s of a 30 s profile of sends in a chat of 15,000. A value that goes
2700
+ * into a layer leaves the set: from then on the store holds it.
2701
+ */
2702
+ var fresh = /* @__PURE__ */ new WeakSet();
2703
+ function thaw(bytes) {
2704
+ const value = deserialize(bytes);
2705
+ if (value && typeof value === "object") fresh.add(value);
2706
+ return value;
2707
+ }
2708
+ /** A value to hand out: one just read from rows.db as it is, once; any other a copy, as fake-indexeddb makes. */
2709
+ function handOut(value) {
2710
+ return value && typeof value === "object" && fresh.delete(value) ? value : structuredClone(value);
2711
+ }
2712
+ /** rows.db's trees: the stores kept on disk and their indexes, each a tree id in `entries`. */
2713
+ var TreeFile = class {
2714
+ db;
2715
+ constraintError;
2716
+ /** The transaction whose writes come now (its rollback log), set by `storage.ts` around each; null for one without. */
2717
+ owner = null;
2718
+ live = /* @__PURE__ */ new Set();
2719
+ get;
2720
+ count;
2721
+ put;
2722
+ remove;
2723
+ empty;
2724
+ /** `range`'s page reads by their SQL: a few shapes (direction, bounds), each prepared once. */
2725
+ pages = /* @__PURE__ */ new Map();
2726
+ constructor(db, constraintError) {
2727
+ this.db = db;
2728
+ this.constraintError = constraintError;
2729
+ db.exec(`
2730
+ CREATE TABLE IF NOT EXISTS trees (id INTEGER PRIMARY KEY, db TEXT NOT NULL, store TEXT NOT NULL, idx TEXT NOT NULL, UNIQUE (db, store, idx));
2731
+ CREATE TABLE IF NOT EXISTS entries (tree INTEGER NOT NULL, id BLOB NOT NULL, pv BLOB, key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY (tree, id)) WITHOUT ROWID;
2732
+ CREATE INDEX IF NOT EXISTS entries_by_value ON entries (tree, pv) WHERE pv IS NOT NULL;
2733
+ CREATE TABLE IF NOT EXISTS meta (name TEXT PRIMARY KEY, value) WITHOUT ROWID;
2734
+ `);
2735
+ this.get = db.prepare("SELECT key, value FROM entries WHERE tree = ? AND id = ?");
2736
+ this.count = db.prepare("SELECT count(*) AS n FROM entries WHERE tree = ?");
2737
+ this.put = db.prepare("INSERT OR REPLACE INTO entries (tree, id, pv, key, value) VALUES (?, ?, ?, ?, ?)");
2738
+ this.remove = db.prepare("DELETE FROM entries WHERE tree = ? AND id = ?");
2739
+ this.empty = db.prepare("DELETE FROM entries WHERE tree = ?");
2740
+ }
2741
+ /** The tree of a store (`index` "") or of one of its indexes, made when it is new. */
2742
+ treeId(db, store, index = "") {
2743
+ const found = this.db.prepare("SELECT id FROM trees WHERE db = ? AND store = ? AND idx = ?").get(db, store, index);
2744
+ if (found) return Number(found.id);
2745
+ return Number(this.db.prepare("INSERT INTO trees (db, store, idx) VALUES (?, ?, ?)").run(db, store, index).lastInsertRowid);
2746
+ }
2747
+ tree(id, keysAreUnique, index) {
2748
+ const tree = new SqlTree(this, id, keysAreUnique, index);
2749
+ this.live.add(tree);
2750
+ return tree;
2751
+ }
2752
+ /** The last journal entry whose writes SQLite holds. */
2753
+ applied() {
2754
+ const row = this.db.prepare("SELECT value FROM meta WHERE name = 'applied'").get();
2755
+ return row ? Number(row.value) : 0;
2756
+ }
2757
+ /** A finished transaction's layers, numbered as its journal entry: they wait for `apply`. */
2758
+ complete(owner, seq) {
2759
+ for (const tree of this.live) tree.complete(owner, seq);
2760
+ }
2761
+ /** An aborted transaction's layers go, and what it wrote with them. */
2762
+ abort(owner) {
2763
+ for (const tree of this.live) tree.abort(owner);
2764
+ }
2765
+ /** The layers of the journal's entries up to `upTo` into SQLite, in one transaction that says so (`applied`). */
2766
+ apply(upTo) {
2767
+ const due = [...this.live].filter((tree) => tree.due(upTo));
2768
+ if (!due.length) return;
2769
+ this.db.exec("BEGIN");
2770
+ try {
2771
+ let top = 0;
2772
+ for (const tree of due) top = Math.max(top, tree.write(upTo));
2773
+ this.db.prepare("INSERT OR REPLACE INTO meta (name, value) VALUES ('applied', max(?, coalesce((SELECT value FROM meta WHERE name = 'applied'), 0)))").run(top);
2774
+ this.db.exec("COMMIT");
2775
+ } catch (error) {
2776
+ this.db.exec("ROLLBACK");
2777
+ throw error;
2778
+ }
2779
+ for (const tree of due) tree.settle(upTo);
2780
+ }
2781
+ /** Whether a store (`index` "") or one of its indexes has its tree here (a snapshot of format 3 names ones that must). */
2782
+ has(db, store, index = "") {
2783
+ return this.db.prepare("SELECT 1 FROM trees WHERE db = ? AND store = ? AND idx = ?").get(db, store, index) !== void 0;
2784
+ }
2785
+ /** Everything SQLite holds is on disk: before a snapshot takes the place of the journal that had it. */
2786
+ sync() {
2787
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2788
+ }
2789
+ /** Trees whose store or index is gone from the schema (deleted, a database removed) go, with their entries. */
2790
+ sweep(keep) {
2791
+ const ids = this.db.prepare("SELECT id FROM trees").all().map((row) => Number(row.id)).filter((id) => !keep.has(id));
2792
+ if (!ids.length) return;
2793
+ this.db.exec("BEGIN");
2794
+ try {
2795
+ for (const id of ids) {
2796
+ this.empty.run(id);
2797
+ this.db.prepare("DELETE FROM trees WHERE id = ?").run(id);
2798
+ }
2799
+ this.db.exec("COMMIT");
2800
+ } catch (error) {
2801
+ this.db.exec("ROLLBACK");
2802
+ throw error;
2803
+ }
2804
+ for (const tree of this.live) if (!keep.has(tree.id)) this.live.delete(tree);
2805
+ }
2806
+ /** Writes a tree's entries as they are, outside any transaction's layer (moving a store from memory). */
2807
+ load(id, entries, index, keysAreUnique) {
2808
+ this.empty.run(id);
2809
+ for (const entry of entries) this.row(id, entry, index, keysAreUnique);
2810
+ }
2811
+ /** @internal */
2812
+ row(id, entry, index, keysAreUnique) {
2813
+ const key = keyBytes(entry.key);
2814
+ const bytes = keysAreUnique ? key : key + keyBytes(entry.value);
2815
+ this.put.run(id, buffer(bytes), index ? buffer(keyBytes(entry.value)) : null, serialize(entry.key), serialize(entry.value));
2816
+ }
2817
+ /** @internal */
2818
+ read(id, bytes) {
2819
+ const row = this.get.get(id, buffer(bytes));
2820
+ return row && {
2821
+ key: deserialize(row.key),
2822
+ value: thaw(row.value)
2823
+ };
2824
+ }
2825
+ /** @internal */
2826
+ size(id) {
2827
+ return Number(this.count.get(id).n);
2828
+ }
2829
+ /** @internal */
2830
+ drop(id, bytes) {
2831
+ this.remove.run(id, buffer(bytes));
2832
+ }
2833
+ /** @internal */
2834
+ clearRows(id) {
2835
+ this.empty.run(id);
2836
+ }
2837
+ /** @internal The index entries whose record key's bytes are from `lower` up to (not with) `upper`, either one open. */
2838
+ withValue(id, lower, upper) {
2839
+ const where = ["tree = ?", "pv IS NOT NULL"], args = [id];
2840
+ if (lower !== void 0) {
2841
+ where.push("pv >= ?");
2842
+ args.push(buffer(lower));
2843
+ }
2844
+ if (upper !== void 0) {
2845
+ where.push("pv < ?");
2846
+ args.push(buffer(upper));
2847
+ }
2848
+ const sql = `SELECT key, value FROM entries INDEXED BY entries_by_value WHERE ${where.join(" AND ")}`;
2849
+ let statement = this.pages.get(sql);
2850
+ if (!statement) this.pages.set(sql, statement = this.db.prepare(sql));
2851
+ return statement.all(...args).map((row) => ({
2852
+ key: deserialize(row.key),
2853
+ value: thaw(row.value)
2854
+ }));
2855
+ }
2856
+ /** @internal */
2857
+ /**
2858
+ * The entries between two ids, a page at a time: no statement stays open between pages, so a read that takes the
2859
+ * first entry and stops (fake-indexeddb's `get`) leaves nothing that would lock the table for a later write.
2860
+ */
2861
+ *range(id, lower, upper, descending) {
2862
+ let from = descending ? upper : lower, first = true, size = 8;
2863
+ for (;;) {
2864
+ const where = ["tree = ?"], args = [id];
2865
+ const low = descending ? lower : from, high = descending ? from : upper;
2866
+ if (low !== void 0) {
2867
+ where.push(!descending && !first ? "id > ?" : "id >= ?");
2868
+ args.push(buffer(low));
2869
+ }
2870
+ if (high !== void 0) {
2871
+ where.push("id < ?");
2872
+ args.push(buffer(high));
2873
+ }
2874
+ args.push(size);
2875
+ const sql = `SELECT id, key, value FROM entries WHERE ${where.join(" AND ")} ORDER BY id ${descending ? "DESC" : "ASC"} LIMIT ?`;
2876
+ let statement = this.pages.get(sql);
2877
+ if (!statement) this.pages.set(sql, statement = this.db.prepare(sql));
2878
+ const rows = statement.all(...args);
2879
+ for (const row of rows) yield [Buffer.from(row.id).toString("latin1"), {
2880
+ key: deserialize(row.key),
2881
+ value: thaw(row.value)
2882
+ }];
2883
+ if (rows.length < size) return;
2884
+ from = Buffer.from(rows.at(-1).id).toString("latin1");
2885
+ first = false;
2886
+ size = Math.min(size * 4, 512);
2887
+ }
2888
+ }
2889
+ };
2890
+ var SqlTree = class {
2891
+ file;
2892
+ id;
2893
+ _keysAreUnique;
2894
+ index;
2895
+ pending = [];
2896
+ active = null;
2897
+ constructor(file, id, _keysAreUnique, index) {
2898
+ this.file = file;
2899
+ this.id = id;
2900
+ this._keysAreUnique = _keysAreUnique;
2901
+ this.index = index;
2902
+ }
2903
+ idOf(entry) {
2904
+ const key = keyBytes(entry.key);
2905
+ return this._keysAreUnique ? key : key + keyBytes(entry.value);
2906
+ }
2907
+ layer() {
2908
+ const owner = this.file.owner;
2909
+ if (this.active) {
2910
+ if (owner && this.active.owner && this.active.owner !== owner) throw new Error("Two transactions wrote one store kept on disk at once");
2911
+ if (owner && !this.active.owner) this.active.owner = owner;
2912
+ return this.active;
2913
+ }
2914
+ return this.active = {
2915
+ owner,
2916
+ seq: 0,
2917
+ cleared: false,
2918
+ puts: /* @__PURE__ */ new Map(),
2919
+ byValue: /* @__PURE__ */ new Map()
2920
+ };
2921
+ }
2922
+ /** An entry (or its removal, `entry` null) in this transaction's layer; an index's by its record key too. */
2923
+ set(bytes, record, entry) {
2924
+ if (entry?.value && typeof entry.value === "object") fresh.delete(entry.value);
2925
+ const layer = this.layer();
2926
+ layer.puts.set(bytes, entry);
2927
+ if (!this.index) return;
2928
+ const value = keyBytes(record.value);
2929
+ let ids = layer.byValue.get(value);
2930
+ if (!ids) layer.byValue.set(value, ids = /* @__PURE__ */ new Set());
2931
+ ids.add(bytes);
2932
+ }
2933
+ /** Newest first: the layer that has the entry says what it is; one cleared hides everything older. */
2934
+ lookup(bytes) {
2935
+ const layers = this.active ? [...this.pending, this.active] : this.pending;
2936
+ for (let at = layers.length - 1; at >= 0; at--) {
2937
+ const layer = layers[at];
2938
+ if (layer.puts.has(bytes)) return layer.puts.get(bytes) ?? null;
2939
+ if (layer.cleared) return null;
2940
+ }
2941
+ }
2942
+ size() {
2943
+ let hidden = false;
2944
+ const map = /* @__PURE__ */ new Map();
2945
+ for (const layer of this.active ? [...this.pending, this.active] : this.pending) {
2946
+ if (layer.cleared) {
2947
+ hidden = true;
2948
+ map.clear();
2949
+ }
2950
+ for (const [bytes, entry] of layer.puts) map.set(bytes, entry);
2951
+ }
2952
+ let size = hidden ? 0 : this.file.size(this.id);
2953
+ for (const [bytes, entry] of map) {
2954
+ const below = !hidden && this.file.read(this.id, bytes) !== void 0;
2955
+ if (entry && !below) size++;
2956
+ else if (!entry && below) size--;
2957
+ }
2958
+ return size;
2959
+ }
2960
+ get(record) {
2961
+ const bytes = this.idOf(record);
2962
+ const found = this.lookup(bytes);
2963
+ if (found !== void 0) return found ?? void 0;
2964
+ return this.file.read(this.id, bytes);
2965
+ }
2966
+ contains(record) {
2967
+ return this.get(record) !== void 0;
2968
+ }
2969
+ put(record, noOverwrite = false) {
2970
+ if (holdsBlob(record.value)) throw new Error("A store kept in rows.db takes no Blob");
2971
+ const existing = this.get(record);
2972
+ if (existing !== void 0 && noOverwrite) throw new this.file.constraintError();
2973
+ this.set(this.idOf(record), record, record);
2974
+ return existing;
2975
+ }
2976
+ delete(record) {
2977
+ if (this.get(record) === void 0) return;
2978
+ this.set(this.idOf(record), record, null);
2979
+ }
2980
+ clear() {
2981
+ const layer = this.layer();
2982
+ layer.puts.clear();
2983
+ layer.byValue.clear();
2984
+ layer.cleared = true;
2985
+ }
2986
+ *getAllRecords(descending = false) {
2987
+ yield* this.getRecords(void 0, descending);
2988
+ }
2989
+ *getRecords(range, descending = false) {
2990
+ if (this._keysAreUnique && range && onlyBytes(range) !== void 0) {
2991
+ const found = this.get({
2992
+ key: range.lower,
2993
+ value: void 0
2994
+ });
2995
+ if (found) yield found;
2996
+ return;
2997
+ }
2998
+ const [lower, upper] = bounds(range);
2999
+ let hidden = false;
3000
+ const map = /* @__PURE__ */ new Map();
3001
+ for (const layer of this.active ? [...this.pending, this.active] : this.pending) {
3002
+ if (layer.cleared) {
3003
+ hidden = true;
3004
+ map.clear();
3005
+ }
3006
+ for (const [bytes, entry] of layer.puts) map.set(bytes, entry);
3007
+ }
3008
+ const over = [...map].filter(([bytes]) => (lower === void 0 || bytes >= lower) && (upper === void 0 || bytes < upper)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
3009
+ if (descending) over.reverse();
3010
+ const base = hidden ? void 0 : this.file.range(this.id, lower, upper, descending);
3011
+ let next = base?.next();
3012
+ let at = 0;
3013
+ for (;;) {
3014
+ const below = next && !next.done ? next.value : void 0;
3015
+ const above = over[at];
3016
+ if (!below && !above) return;
3017
+ if (above && (!below || (descending ? above[0] >= below[0] : above[0] <= below[0]))) {
3018
+ if (below && above[0] === below[0]) next = base.next();
3019
+ at++;
3020
+ if (above[1]) yield above[1];
3021
+ } else {
3022
+ yield below[1];
3023
+ next = base.next();
3024
+ }
3025
+ }
3026
+ }
3027
+ /**
3028
+ * The index entries whose record key (`value`) is in `range`, looked up by its bytes, not walked: removing a chat
3029
+ * (its messages' keys from [chat] to [chat, []]) read every message of the profile, 1.5 s at 100,000.
3030
+ */
3031
+ deleteByValue(range) {
3032
+ const [lower, upper] = bounds(range);
3033
+ const only = onlyBytes(range);
3034
+ const candidates = this.index ? [...this.file.withValue(this.id, lower, upper), ...this.overlayWithValue(lower, upper, only)] : [...this.getAllRecords()];
3035
+ const deleted = [], seen = /* @__PURE__ */ new Set();
3036
+ for (const candidate of candidates) {
3037
+ const bytes = this.idOf(candidate);
3038
+ if (seen.has(bytes) || !range.includes(candidate.value)) continue;
3039
+ seen.add(bytes);
3040
+ const current = this.get(candidate);
3041
+ if (!current) continue;
3042
+ this.set(bytes, current, null);
3043
+ deleted.push(current);
3044
+ }
3045
+ return deleted;
3046
+ }
3047
+ /**
3048
+ * The entries of the layers whose record key's bytes are between these (as `withValue`), or are `only`'s. A
3049
+ * transaction that wrote over k records walked every entry of its layer k times: 16,000 took 6.6 s.
3050
+ */
3051
+ overlayWithValue(lower, upper, only) {
3052
+ const out = [];
3053
+ const take = (layer, ids) => {
3054
+ for (const bytes of ids) {
3055
+ const entry = layer.puts.get(bytes);
3056
+ if (entry) out.push(entry);
3057
+ }
3058
+ };
3059
+ for (const layer of this.active ? [...this.pending, this.active] : this.pending) {
3060
+ if (only !== void 0) {
3061
+ const ids = layer.byValue.get(only);
3062
+ if (ids) take(layer, ids);
3063
+ continue;
3064
+ }
3065
+ for (const [value, ids] of layer.byValue) {
3066
+ if (lower !== void 0 && value < lower || upper !== void 0 && value >= upper) continue;
3067
+ take(layer, ids);
3068
+ }
3069
+ }
3070
+ return out;
3071
+ }
3072
+ /** @internal */
3073
+ complete(owner, seq) {
3074
+ if (!this.active || this.active.owner !== owner && this.active.owner !== null) return;
3075
+ this.active.seq = seq;
3076
+ this.pending.push(this.active);
3077
+ this.active = null;
3078
+ }
3079
+ /** @internal */
3080
+ abort(owner) {
3081
+ if (this.active && (this.active.owner === owner || this.active.owner === null)) this.active = null;
3082
+ }
3083
+ /** @internal */
3084
+ due(upTo) {
3085
+ return this.pending.length > 0 && this.pending[0].seq <= upTo;
3086
+ }
3087
+ /** @internal */
3088
+ write(upTo) {
3089
+ let top = 0;
3090
+ for (const layer of this.pending) {
3091
+ if (layer.seq > upTo) break;
3092
+ top = layer.seq;
3093
+ if (layer.cleared) this.file.clearRows(this.id);
3094
+ for (const [bytes, entry] of layer.puts) if (entry) this.file.row(this.id, entry, this.index, this._keysAreUnique);
3095
+ else this.file.drop(this.id, bytes);
3096
+ }
3097
+ return top;
3098
+ }
3099
+ /** @internal */
3100
+ settle(upTo) {
3101
+ while (this.pending.length && this.pending[0].seq <= upTo) this.pending.shift();
3102
+ }
3103
+ /** Whether a transaction's writes are still waiting here (a snapshot then waits for them). */
3104
+ get busy() {
3105
+ return this.active !== null || this.pending.length > 0;
3106
+ }
3107
+ };
3108
+ /**
3109
+ * rows.db, owner-only (SQLite makes its WAL files with the database's mode), in WAL with NORMAL: the journal makes a
3110
+ * transaction durable, and `sync` runs before a snapshot takes the journal's place. With `secure_delete` SQLite zeroes
3111
+ * what a delete frees: a message or chat deleted is not left readable in the file's free pages after the fold.
3112
+ */
3113
+ function openTreeFile(sqlite, path, constraintError) {
3114
+ closeSync(openSync(path, "a", 384));
3115
+ const db = new sqlite.DatabaseSync(path);
3116
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA secure_delete = ON");
3117
+ return new TreeFile(db, constraintError);
3118
+ }
3119
+ /** A range's keys as bytes: from the first (in) up to the second (out), undefined where it is open. */
3120
+ function bounds(range) {
3121
+ return [range?.lower === void 0 ? void 0 : keyBytes(range.lower) + (range.lowerOpen ? "ÿ" : ""), range?.upper === void 0 ? void 0 : keyBytes(range.upper) + (range.upperOpen ? "" : "ÿ")];
3122
+ }
3123
+ /** A range of one key (IDBKeyRange.only, or a bound with the same key at both ends, both in): that key's bytes. */
3124
+ function onlyBytes(range) {
3125
+ if (range.lower === void 0 || range.upper === void 0 || range.lowerOpen || range.upperOpen) return void 0;
3126
+ const lower = keyBytes(range.lower);
3127
+ return keyBytes(range.upper) === lower ? lower : void 0;
3128
+ }
3129
+ function holdsBlob(value, depth = 0) {
3130
+ if (value instanceof Blob) return true;
3131
+ if (!value || typeof value !== "object" || depth > 32 || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return false;
3132
+ if (value instanceof Map) {
3133
+ for (const [k, v] of value) if (holdsBlob(k, depth + 1) || holdsBlob(v, depth + 1)) return true;
3134
+ return false;
3135
+ }
3136
+ for (const item of Object.values(value)) if (holdsBlob(item, depth + 1)) return true;
3137
+ return false;
3138
+ }
3139
+ //#endregion
3140
+ //#region src/runtime/storage.ts
3141
+ var SNAPSHOT = "snapshot.bin";
3142
+ var JOURNAL = "journal.bin";
3143
+ var ROWS = "rows.db";
3144
+ /**
3145
+ * The stores kept in `rows.db` by default: the engine's messages, and each file's state (its transfer and files/3
3146
+ * record), which grew the heap by every file ever sent or received. Not the files store: a file small enough to hold
3147
+ * whole is a Blob in its record, which SQLite does not take.
3148
+ */
3149
+ var ON_DISK = /* @__PURE__ */ new Set(["messages", "fileState"]);
3150
+ /** A journal past this is folded into the snapshot at the next quiet moment. */
3151
+ var COMPACT_BYTES = 33554432;
3152
+ /** A journal up to this is kept at close and at start: replaying it costs less than writing the snapshot again. */
3153
+ var KEEP_BYTES = 1048576;
3154
+ /**
3155
+ * The database a journal's close mark names. No profile has it, so a replay skips the mark (an older version's too:
3156
+ * it skips an op whose store it does not find).
3157
+ */
3158
+ var CLOSED = "__ghostly_closed__";
3159
+ /** The database a journal's first entry names, with the snapshot the journal follows: skipped by a replay as the mark is. */
3160
+ var FOLLOWS = "__ghostly_journal__";
3161
+ /** The database an entry's first op names, with the entry's number (`seq`): SQLite's trees hold the entries up to theirs. */
3162
+ var SEQ = "__ghostly_entry__";
3163
+ /** How long `close` waits for the transactions still running: one left open for good does not keep the process. */
3164
+ var CLOSE_WAIT_MS = 5e3;
3165
+ /** How long a relaxed transaction's entry may wait for a sync when no other transaction brings one. */
3166
+ var RELAXED_SYNC_MS = 1e3;
3167
+ var BLOB = "__ghostlyBlob";
3168
+ var RANGE = "__ghostlyRange";
3169
+ var PROBE = "__ghostly_storage_probe__";
3170
+ /**
3171
+ * Opens the store in `dir` (created 0700; files 0600) and installs it as this process's `indexedDB` and
3172
+ * `IDBKeyRange`. Call once, before anything touches IndexedDB.
3173
+ */
3174
+ async function openPersistentIndexedDb(dir, options = {}) {
3175
+ mkdirSync(dir, {
3176
+ recursive: true,
3177
+ mode: 448
3178
+ });
3179
+ const factory = new IDBFactory();
3180
+ const raw = factory;
3181
+ const classes = await probeClasses(factory);
3182
+ dropIndexEntriesByRecord(classes, (a, b) => factory.cmp(a, b));
3183
+ readWithoutCopy(classes);
3184
+ const order = keyOrder((a, b) => factory.cmp(a, b));
3185
+ const rowsPath = join(dir, ROWS);
3186
+ const rowsFound = existsSync(rowsPath);
3187
+ const sqlite = await loadSqlite();
3188
+ const trees = sqlite ? openTreeFile(sqlite, rowsPath, classes.constraintError) : null;
3189
+ const onDisk = options.rowsOnDisk ?? ((_db, store) => ON_DISK.has(store));
3190
+ const disk = (store) => trees !== null && raw._databases.get(store.rawDatabase.name) === store.rawDatabase && onDisk(store.rawDatabase.name, store.name);
3191
+ /** The number of the last journal entry made: SQLite says which ones its trees hold (`TreeFile.applied`). */
3192
+ let seq = trees?.applied() ?? 0;
3193
+ let writing = true;
3194
+ let restoring = true;
3195
+ let journalFd = null;
3196
+ let queue = Promise.resolve();
3197
+ let queued = 0;
3198
+ let schemaDirty = false;
3199
+ /** A write to the journal or the snapshot failed: what is held is not all on disk, and only a whole snapshot makes it so. */
3200
+ let unsaved = false;
3201
+ /** Set while the journal holds relaxed entries no sync has covered yet. */
3202
+ let relaxed = null;
3203
+ const pending = /* @__PURE__ */ new WeakMap();
3204
+ const snapshotPath = join(dir, SNAPSHOT);
3205
+ const journalPath = join(dir, JOURNAL);
3206
+ const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
3207
+ const saved = snapshotFound ? deserialize(readFileSync(snapshotPath)) : null;
3208
+ const rows = sqlite && saved?.format === 2 && rowsFound ? openRowFile(sqlite, rowsPath) : null;
3209
+ /** Stores kept on disk that a snapshot of format 1 had inline, written straight into rows.db by `restore`. */
3210
+ let restoredToDisk = false;
3211
+ try {
3212
+ if (saved) restoredToDisk = restore(classes, raw._databases, saved, order, {
3213
+ trees,
3214
+ rows,
3215
+ made: rows ? rowRecords(rows) : null,
3216
+ rowsFound,
3217
+ attach: attachTrees,
3218
+ onDisk
3219
+ });
3220
+ } catch (error) {
3221
+ rows?.close();
3222
+ trees?.db.close();
3223
+ if (!rowsFound) for (const file of [
3224
+ rowsPath,
3225
+ `${rowsPath}-wal`,
3226
+ `${rowsPath}-shm`
3227
+ ]) rmSync(file, { force: true });
3228
+ throw error;
3229
+ }
3230
+ /** The name of the snapshot on disk, which its journal begins with. */
3231
+ let snapshotName = saved?.journal;
3232
+ let journal = journalFound ? readFileSync(journalPath) : null;
3233
+ const named = journal ? follows(journal) : void 0;
3234
+ const folded = named !== void 0 && snapshotName !== void 0 && named !== snapshotName;
3235
+ if (folded) {
3236
+ rmSync(journalPath, { force: true });
3237
+ journal = null;
3238
+ }
3239
+ const replayed = journal ? replay(raw._databases, journal, order, {
3240
+ applied: restoredToDisk ? 0 : seq,
3241
+ onTrees
3242
+ }) : {
3243
+ end: 0,
3244
+ closed: !folded,
3245
+ seq: 0
3246
+ };
3247
+ seq = Math.max(seq, replayed.seq);
3248
+ if (trees) {
3249
+ trees.complete(null, seq);
3250
+ trees.apply(seq);
3251
+ }
3252
+ let moved = restoredToDisk;
3253
+ if (trees) {
3254
+ for (const db of raw._databases.values()) for (const store of db.rawObjectStores.values()) if (!store.deleted && disk(store) && !onTrees(store)) {
3255
+ toDisk(store);
3256
+ moved = true;
3257
+ }
3258
+ }
3259
+ restoring = false;
3260
+ /** Whether the journal on disk says the store is closed: nothing was written since its mark, or there is none. */
3261
+ let marked = replayed.closed;
3262
+ const kept = !!journal && named !== void 0 && named === snapshotName && replayed.end === journal.length && journal.length <= KEEP_BYTES;
3263
+ const rewrite = journal && !kept || !snapshotFound || moved;
3264
+ if (rewrite) await writeSnapshot();
3265
+ rows?.close();
3266
+ if (rows && rewrite) trees.db.exec("DROP TABLE IF EXISTS rows");
3267
+ const storeProto = classes.ObjectStore.prototype;
3268
+ const storeRecord = storeProto.storeRecord;
3269
+ const deleteRecord = storeProto.deleteRecord;
3270
+ const clear = storeProto.clear;
3271
+ const opsOf = (log) => {
3272
+ let ops = pending.get(log);
3273
+ if (!ops) pending.set(log, ops = []);
3274
+ return ops;
3275
+ };
3276
+ /**
3277
+ * A write to a store kept on disk goes to its trees' layer of this transaction (its rollback log names it): a store
3278
+ * made since the open (an upgrade) moves to the trees at its first write.
3279
+ */
3280
+ function owned(store, rollbackLog, work) {
3281
+ if (!disk(store)) return work();
3282
+ if (!onTrees(store)) toDisk(store);
3283
+ if (!rollbackLog) return work();
3284
+ trees.owner = rollbackLog;
3285
+ try {
3286
+ return work();
3287
+ } finally {
3288
+ trees.owner = null;
3289
+ }
3290
+ }
3291
+ storeProto.storeRecord = function(record, noOverwrite, rollbackLog) {
3292
+ const key = owned(this, rollbackLog, () => storeRecord.call(this, record, noOverwrite, rollbackLog));
3293
+ if (rollbackLog && !restoring) opsOf(rollbackLog).push({
3294
+ db: this.rawDatabase.name,
3295
+ store: this.name,
3296
+ kind: "put",
3297
+ key,
3298
+ value: record.value
3299
+ });
3300
+ return key;
3301
+ };
3302
+ storeProto.deleteRecord = function(key, rollbackLog) {
3303
+ owned(this, rollbackLog, () => deleteRecord.call(this, key, rollbackLog));
3304
+ if (rollbackLog && !restoring) opsOf(rollbackLog).push({
3305
+ db: this.rawDatabase.name,
3306
+ store: this.name,
3307
+ kind: "delete",
3308
+ key: keyToDisk(key)
3309
+ });
3310
+ };
3311
+ storeProto.clear = function(rollbackLog) {
3312
+ owned(this, rollbackLog, () => clear.call(this, rollbackLog));
3313
+ if (rollbackLog && !restoring) opsOf(rollbackLog).push({
3314
+ db: this.rawDatabase.name,
3315
+ store: this.name,
3316
+ kind: "clear"
3317
+ });
3318
+ };
3319
+ const indexProto = classes.Index.prototype;
3320
+ const initialize = indexProto.initialize;
3321
+ indexProto.initialize = function(transaction) {
3322
+ const store = this.rawObjectStore;
3323
+ if (disk(store) && onTrees(store) && !(this.records.records instanceof SqlTree)) {
3324
+ const tree = trees.tree(trees.treeId(store.rawDatabase.name, store.name, this.name), this.records.keysAreUnique, true);
3325
+ tree.clear();
3326
+ attachTree(this.records, tree, true);
3327
+ }
3328
+ return initialize.call(this, transaction);
3329
+ };
3330
+ const txProto = IDBTransaction.prototype;
3331
+ const dispatch = txProto.dispatchEvent;
3332
+ txProto.dispatchEvent = function(event) {
3333
+ if (event.type === "complete" && writing) {
3334
+ if (this.mode === "versionchange") {
3335
+ schemaDirty = true;
3336
+ if (trees) {
3337
+ trees.complete(this._rollbackLog, ++seq);
3338
+ written(seq);
3339
+ }
3340
+ } else {
3341
+ const ops = pending.get(this._rollbackLog);
3342
+ if (ops?.length) {
3343
+ const entry = ++seq;
3344
+ trees?.complete(this._rollbackLog, entry);
3345
+ append(ops, this.durability === "relaxed", entry);
3346
+ }
3347
+ }
3348
+ pending.delete(this._rollbackLog);
3349
+ if (schemaDirty) enqueue(writeSnapshot);
3350
+ } else if (event.type === "abort") {
3351
+ pending.delete(this._rollbackLog);
3352
+ trees?.abort(this._rollbackLog);
3353
+ }
3354
+ const dispatched = dispatch.call(this, event);
3355
+ if ((event.type === "complete" || event.type === "abort") && this._state === "finished") {
3356
+ const list = this.db._rawDatabase.transactions;
3357
+ const at = list.indexOf(this);
3358
+ if (at >= 0) list.splice(at, 1);
3359
+ }
3360
+ return dispatched;
3361
+ };
3362
+ const deleteDatabase = factory.deleteDatabase.bind(factory);
3363
+ factory.deleteDatabase = (name) => {
3364
+ const request = deleteDatabase(name);
3365
+ request.addEventListener("success", () => {
3366
+ schemaDirty = true;
3367
+ enqueue(writeSnapshot);
3368
+ });
3369
+ return request;
3370
+ };
3371
+ /** A committed transaction's entry, numbered `entry` (its first op says so, where there are trees). */
3372
+ function append(ops, lazy, entry) {
3373
+ if (trees) ops = [{
3374
+ db: SEQ,
3375
+ store: "",
3376
+ kind: "clear",
3377
+ seq: entry
3378
+ }, ...ops];
3379
+ if (queued === 0 && !ops.some((op) => op.kind === "put" && hasBlob(op.value))) {
3380
+ if (writeEntry(serialize(ops), lazy)) written(entry);
3381
+ return;
3382
+ }
3383
+ enqueue(async () => {
3384
+ if (writeEntry(serialize(await Promise.all(ops.map(async (op) => op.kind === "put" ? {
3385
+ ...op,
3386
+ value: await blobsToDisk(op.value)
3387
+ } : op))), lazy)) written(entry);
3388
+ });
3389
+ }
3390
+ /**
3391
+ * The journal has entry `entry`: the trees' layers up to it go to SQLite. One that fails stays in memory, where reads
3392
+ * find it, and goes with the next entry, or before the next snapshot (which fails without it).
3393
+ */
3394
+ function written(entry) {
3395
+ if (!trees || !writing) return;
3396
+ try {
3397
+ trees.apply(entry);
3398
+ } catch (error) {
3399
+ failed(error);
3400
+ }
3401
+ }
3402
+ /** Whether a store's records are in the trees already. */
3403
+ function onTrees(store) {
3404
+ return store.records.records instanceof SqlTree;
3405
+ }
3406
+ /** A tree in place of fake-indexeddb's in a record store; its `clear` and (an index's) `deleteByValue` with it. */
3407
+ function attachTree(records, tree, index) {
3408
+ records.records = tree;
3409
+ records.clear = () => {
3410
+ const deleted = [...tree.getAllRecords()];
3411
+ tree.clear();
3412
+ return deleted;
3413
+ };
3414
+ if (index) records.deleteByValue = (key) => tree.deleteByValue(key instanceof IDBKeyRange ? key : IDBKeyRange.only(key));
3415
+ }
3416
+ /** A store whose records and index entries are in rows.db already (a snapshot of format 3): its trees attached. */
3417
+ function attachTrees(store) {
3418
+ const db = store.rawDatabase.name;
3419
+ attachTree(store.records, trees.tree(trees.treeId(db, store.name), true, false), false);
3420
+ for (const index of store.rawIndexes.values()) if (!index.deleted) attachTree(index.records, trees.tree(trees.treeId(db, store.name, index.name), index.records.keysAreUnique, true), true);
3421
+ }
3422
+ /** A store kept on disk, held in memory: its records and index entries written to rows.db, its trees in place. */
3423
+ function toDisk(store) {
3424
+ const db = store.rawDatabase.name;
3425
+ const indexes = [...store.rawIndexes.values()].filter((index) => !index.deleted);
3426
+ const id = trees.treeId(db, store.name), ids = indexes.map((index) => trees.treeId(db, store.name, index.name));
3427
+ trees.db.exec("BEGIN");
3428
+ try {
3429
+ trees.load(id, store.records.values(), false, true);
3430
+ indexes.forEach((index, at) => trees.load(ids[at], index.records.values(), true, index.records.keysAreUnique));
3431
+ trees.db.exec("COMMIT");
3432
+ } catch (error) {
3433
+ trees.db.exec("ROLLBACK");
3434
+ throw error;
3435
+ }
3436
+ attachTree(store.records, trees.tree(id, true, false), false);
3437
+ indexes.forEach((index, at) => attachTree(index.records, trees.tree(ids[at], index.records.keysAreUnique, true), true));
3438
+ }
3439
+ /** The trees of the stores and indexes the schema has now: the others are swept after a snapshot. */
3440
+ function liveTrees() {
3441
+ const keep = /* @__PURE__ */ new Set();
3442
+ for (const db of raw._databases.values()) for (const store of db.rawObjectStores.values()) {
3443
+ if (store.deleted || !onTrees(store)) continue;
3444
+ keep.add(store.records.records.id);
3445
+ for (const index of store.rawIndexes.values()) if (!index.deleted && index.records.records instanceof SqlTree) keep.add(index.records.records.id);
3446
+ }
3447
+ return keep;
3448
+ }
3449
+ function enqueue(work) {
3450
+ queued++;
3451
+ queue = queue.then(work).catch(failed).finally(() => {
3452
+ queued--;
3453
+ });
3454
+ return queue;
3455
+ }
3456
+ function failed(error) {
3457
+ unsaved = true;
3458
+ process.stderr.write(`ghostly: storage write failed: ${error instanceof Error ? error.message : String(error)}\n`);
3459
+ }
3460
+ /**
3461
+ * `lazy`: a relaxed transaction's entry. A sync blocks the whole process for milliseconds, and what such a
3462
+ * transaction wrote can be made again (the event set's note of a message it already reported): written, it outlives
3463
+ * the process; a power cut may take its last second.
3464
+ */
3465
+ function writeEntry(entry, lazy = false) {
3466
+ if (!writing) return false;
3467
+ try {
3468
+ if (journalFd === null) {
3469
+ journalFd = openSync(journalPath, "a", 384);
3470
+ if (snapshotName !== void 0 && fstatSync(journalFd).size === 0) writeAll(journalFd, framed(serialize([{
3471
+ db: FOLLOWS,
3472
+ store: "",
3473
+ kind: "clear",
3474
+ snapshot: snapshotName
3475
+ }])));
3476
+ }
3477
+ marked = false;
3478
+ writeAll(journalFd, framed(entry));
3479
+ } catch (error) {
3480
+ unsaved = true;
3481
+ throw error;
3482
+ }
3483
+ if (lazy) relaxed ??= setTimeout(() => {
3484
+ try {
3485
+ sync();
3486
+ } catch (error) {
3487
+ failed(error);
3488
+ }
3489
+ }, RELAXED_SYNC_MS).unref();
3490
+ else sync();
3491
+ if (statSync(journalPath).size > COMPACT_BYTES) enqueue(writeSnapshot);
3492
+ return true;
3493
+ }
3494
+ function sync() {
3495
+ if (relaxed) {
3496
+ clearTimeout(relaxed);
3497
+ relaxed = null;
3498
+ }
3499
+ if (journalFd !== null) fsyncSync(journalFd);
3500
+ }
3501
+ async function writeSnapshot() {
3502
+ const dirty = schemaDirty;
3503
+ schemaDirty = false;
3504
+ try {
3505
+ await snapshotToDisk();
3506
+ } catch (error) {
3507
+ schemaDirty ||= dirty;
3508
+ throw error;
3509
+ }
3510
+ }
3511
+ /**
3512
+ * What is held, as a snapshot. `inline`: every value in it (format 1, `exportSnapshot`); otherwise the stores kept on
3513
+ * disk say only that they are (format 3).
3514
+ */
3515
+ async function capture(journalName, inline) {
3516
+ const snapshot = {
3517
+ format: 1,
3518
+ ...journalName !== void 0 && { journal: journalName },
3519
+ databases: []
3520
+ };
3521
+ for (const db of raw._databases.values()) {
3522
+ if (db.name === PROBE) continue;
3523
+ const stores = [];
3524
+ for (const store of db.rawObjectStores.values()) {
3525
+ if (store.deleted) continue;
3526
+ const live = [...store.rawIndexes.values()].filter((index) => !index.deleted);
3527
+ const meta = {
3528
+ name: store.name,
3529
+ keyPath: store.keyPath,
3530
+ autoIncrement: store.autoIncrement,
3531
+ keyGenerator: store.keyGenerator?.num ?? null,
3532
+ indexes: live.map(({ name, keyPath, multiEntry, unique }) => ({
3533
+ name,
3534
+ keyPath,
3535
+ multiEntry,
3536
+ unique
3537
+ }))
3538
+ };
3539
+ if (!inline && disk(store)) {
3540
+ if (!onTrees(store)) toDisk(store);
3541
+ stores.push({
3542
+ ...meta,
3543
+ records: [],
3544
+ onDisk: true
3545
+ });
3546
+ snapshot.format = 3;
3547
+ continue;
3548
+ }
3549
+ const records = [];
3550
+ for (const record of store.records.values()) records.push([record.key, await blobsToDisk(record.value)]);
3551
+ stores.push({
3552
+ ...meta,
3553
+ records
3554
+ });
3555
+ }
3556
+ snapshot.databases.push({
3557
+ name: db.name,
3558
+ version: db.version,
3559
+ stores
3560
+ });
3561
+ }
3562
+ return snapshot;
3563
+ }
3564
+ async function snapshotToDisk() {
3565
+ const name = randomBytes(8).toString("hex");
3566
+ const snapshot = await capture(name, false);
3567
+ if (trees) {
3568
+ trees.apply(seq);
3569
+ trees.sync();
3570
+ }
3571
+ const temporary = snapshotPath + ".tmp";
3572
+ const fd = openSync(temporary, "w", 384);
3573
+ try {
3574
+ writeAll(fd, serialize(snapshot));
3575
+ fsyncSync(fd);
3576
+ } finally {
3577
+ closeSync(fd);
3578
+ }
3579
+ renameSync(temporary, snapshotPath);
3580
+ try {
3581
+ trees?.sweep(liveTrees());
3582
+ } catch (error) {
3583
+ process.stderr.write(`ghostly: storage: old trees not removed: ${error instanceof Error ? error.message : String(error)}\n`);
3584
+ }
3585
+ snapshotName = name;
3586
+ unsaved = false;
3587
+ if (relaxed) {
3588
+ clearTimeout(relaxed);
3589
+ relaxed = null;
3590
+ }
3591
+ if (journalFd !== null) {
3592
+ closeSync(journalFd);
3593
+ journalFd = null;
3594
+ }
3595
+ rmSync(journalPath, { force: true });
3596
+ marked = true;
3597
+ }
3598
+ /** What `close` leaves: a short journal with the mark at its end, synced (its relaxed entries too), or a fresh snapshot. */
3599
+ async function leave() {
3600
+ if (schemaDirty || unsaved || journalBytes() > KEEP_BYTES) return writeSnapshot();
3601
+ if (marked) return;
3602
+ writeEntry(serialize([{
3603
+ db: CLOSED,
3604
+ store: "",
3605
+ kind: "clear"
3606
+ }]));
3607
+ marked = true;
3608
+ }
3609
+ function journalBytes() {
3610
+ return existsSync(journalPath) ? statSync(journalPath).size : 0;
3611
+ }
3612
+ /**
3613
+ * Resolves once no transaction is left to run. The engine does not wait for every write (`void db.deleteLink(…)`),
3614
+ * and fake-indexeddb runs a transaction on a later turn of the event loop: a command that closed in the turn it wrote
3615
+ * in (no chat to say goodbye to) took its last snapshot before the write, and answered done for what was not on disk.
3616
+ * Two quiet turns in a row: a write that begins when another ends (a chat's messages after its row) is seen too.
3617
+ */
3618
+ async function settled() {
3619
+ const until = Date.now() + CLOSE_WAIT_MS;
3620
+ for (let quiet = 0; quiet < 2 && Date.now() < until;) {
3621
+ await new Promise((resolve) => setImmediate(resolve));
3622
+ quiet = busy() ? 0 : quiet + 1;
3623
+ }
3624
+ }
3625
+ /** Whether a transaction is running or waiting to. */
3626
+ function busy() {
3627
+ return [...raw._databases.values()].some((db) => db.transactions.some((transaction) => transaction._state !== "finished"));
3628
+ }
3629
+ const installed = globalThis;
3630
+ installed.indexedDB = factory;
3631
+ installed.IDBKeyRange = IDBKeyRange;
3632
+ return {
3633
+ factory,
3634
+ recovered: !replayed.closed,
3635
+ flush: () => queue,
3636
+ async compact() {
3637
+ await settled();
3638
+ let thrown = null;
3639
+ await enqueue(() => writeSnapshot().catch((error) => {
3640
+ thrown = { error };
3641
+ throw error;
3642
+ }));
3643
+ if (thrown) throw thrown.error;
3644
+ },
3645
+ async close() {
3646
+ if (!writing) return;
3647
+ await settled();
3648
+ await enqueue(leave);
3649
+ writing = false;
3650
+ if (journalFd !== null) {
3651
+ closeSync(journalFd);
3652
+ journalFd = null;
3653
+ }
3654
+ trees?.db.close();
3655
+ },
3656
+ journalBytes,
3657
+ async exportSnapshot(path) {
3658
+ await settled();
3659
+ const snapshot = await capture(void 0, true);
3660
+ const fd = openSync(path, "w", 384);
3661
+ try {
3662
+ writeAll(fd, serialize(snapshot));
3663
+ fsyncSync(fd);
3664
+ } finally {
3665
+ closeSync(fd);
3666
+ }
3667
+ }
3668
+ };
3669
+ }
3670
+ /** Writes every byte: one write may take only a part (a disk nearly full). */
3671
+ function writeAll(fd, bytes) {
3672
+ for (let at = 0; at < bytes.length;) at += writeSync(fd, bytes, at);
3673
+ }
3674
+ /** A journal entry as it is on disk: its length, then its bytes. */
3675
+ function framed(entry) {
3676
+ const header = Buffer.alloc(4);
3677
+ header.writeUInt32BE(entry.length);
3678
+ return Buffer.concat([header, entry]);
3679
+ }
3680
+ /** The snapshot a journal says it follows, in its first entry; none in an older version's journal, or an empty one. */
3681
+ function follows(journal) {
3682
+ if (journal.length < 4 || 4 + journal.readUInt32BE(0) > journal.length) return void 0;
3683
+ try {
3684
+ const [op] = deserialize(journal.subarray(4, 4 + journal.readUInt32BE(0)));
3685
+ return op?.db === FOLLOWS ? op.snapshot : void 0;
3686
+ } catch {
3687
+ return;
3688
+ }
3689
+ }
3690
+ /** fake-indexeddb's raw classes, from a throwaway database (they are not exported). */
3691
+ async function probeClasses(factory) {
3692
+ const db = await new Promise((resolve, reject) => {
3693
+ const request = factory.open(PROBE, 1);
3694
+ request.onupgradeneeded = () => request.result.createObjectStore("s", { autoIncrement: true }).createIndex("i", "x");
3695
+ request.onsuccess = () => resolve(request.result);
3696
+ request.onerror = () => reject(request.error);
3697
+ });
3698
+ const rawDb = db._rawDatabase;
3699
+ const rawStore = rawDb.rawObjectStores.get("s");
3700
+ const classes = {
3701
+ Database: rawDb.constructor,
3702
+ ObjectStore: rawStore.constructor,
3703
+ Index: rawStore.rawIndexes.get("i").constructor,
3704
+ RecordStore: rawStore.records.constructor,
3705
+ constraintError: Error
3706
+ };
3707
+ const probe = new classes.RecordStore(true);
3708
+ probe.put({
3709
+ key: 1,
3710
+ value: 1
3711
+ });
3712
+ try {
3713
+ probe.put({
3714
+ key: 1,
3715
+ value: 2
3716
+ }, true);
3717
+ } catch (error) {
3718
+ classes.constraintError = error.constructor;
3719
+ }
3720
+ db.close();
3721
+ await new Promise((resolve) => {
3722
+ const request = factory.deleteDatabase(PROBE);
3723
+ request.onsuccess = request.onerror = () => resolve();
3724
+ });
3725
+ return classes;
3726
+ }
3727
+ /** The classes whose index upkeep is already by record: they are fake-indexeddb's own, shared by every store opened. */
3728
+ var byRecord = /* @__PURE__ */ new WeakSet();
3729
+ /**
3730
+ * fake-indexeddb drops an overwritten or deleted record from an index by walking every entry of it (an index is in
3731
+ * its own key's order, not the record's): each delivery mark, edit or reaction cost time with every message of the
3732
+ * profile, 170 ms at 10,000, and a long chat slowed every other one. The store has the records it just replaced or
3733
+ * removed in hand, and they say which entries to drop; a call that comes any other way still walks.
3734
+ */
3735
+ function dropIndexEntriesByRecord(classes, cmp) {
3736
+ const storeProto = classes.ObjectStore.prototype;
3737
+ const recordsProto = classes.RecordStore.prototype;
3738
+ if (byRecord.has(storeProto)) return;
3739
+ byRecord.add(storeProto);
3740
+ const { storeRecord, deleteRecord } = storeProto;
3741
+ const { put, delete: remove, deleteByValue } = recordsProto;
3742
+ let gone = null;
3743
+ storeProto.storeRecord = function(record, noOverwrite, rollbackLog) {
3744
+ if (!this.rawIndexes.size) return storeRecord.call(this, record, noOverwrite, rollbackLog);
3745
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
3746
+ records.put = (next, keep) => {
3747
+ const replaced = put.call(this.records, next, keep);
3748
+ gone = {
3749
+ indexes,
3750
+ records: replaced ? [replaced] : []
3751
+ };
3752
+ return replaced;
3753
+ };
3754
+ try {
3755
+ return storeRecord.call(this, record, noOverwrite, rollbackLog);
3756
+ } finally {
3757
+ delete records.put;
3758
+ gone = outer;
3759
+ }
3760
+ };
3761
+ storeProto.deleteRecord = function(key, rollbackLog) {
3762
+ if (!this.rawIndexes.size) return deleteRecord.call(this, key, rollbackLog);
3763
+ const records = this.records, indexes = this.rawIndexes, outer = gone;
3764
+ records.delete = (range) => {
3765
+ const removed = remove.call(this.records, range);
3766
+ gone = {
3767
+ indexes,
3768
+ records: removed
3769
+ };
3770
+ return removed;
3771
+ };
3772
+ try {
3773
+ deleteRecord.call(this, key, rollbackLog);
3774
+ } finally {
3775
+ delete records.delete;
3776
+ gone = outer;
3777
+ }
3778
+ };
3779
+ recordsProto.deleteByValue = function(key) {
3780
+ let index;
3781
+ if (gone) {
3782
+ for (const candidate of gone.indexes.values()) if (candidate.records === this) index = candidate;
3783
+ }
3784
+ if (!gone || !index) return deleteByValue.call(this, key);
3785
+ return dropEntries(index, gone.records, cmp);
3786
+ };
3787
+ }
3788
+ /** The classes whose reads of a store kept on disk hand out what rows.db gave: shared by every store opened. */
3789
+ var uncopied = /* @__PURE__ */ new WeakSet();
3790
+ /**
3791
+ * fake-indexeddb copies every value a read hands out (`structuredClone`), as it must for its tree, which keeps the
3792
+ * values. A store kept on disk read its values from rows.db just now (v8 made them), and copying them again made a
3793
+ * whole-chat read 1.6 times the cost of one in memory. Its `get` and `getAll`, and an index's (which read through the
3794
+ * store's `getValue`), hand those out as they are (`handOut`).
3795
+ */
3796
+ function readWithoutCopy(classes) {
3797
+ const storeProto = classes.ObjectStore.prototype;
3798
+ if (uncopied.has(storeProto)) return;
3799
+ uncopied.add(storeProto);
3800
+ const { getValue, getAllValues } = storeProto;
3801
+ storeProto.getValue = function(key) {
3802
+ if (!(this.records.records instanceof SqlTree)) return getValue.call(this, key);
3803
+ const record = this.records.get(key);
3804
+ return record === void 0 ? void 0 : handOut(record.value);
3805
+ };
3806
+ storeProto.getAllValues = function(range, count, direction) {
3807
+ if (!(this.records.records instanceof SqlTree)) return getAllValues.call(this, range, count, direction);
3808
+ const values = [], most = count || Infinity;
3809
+ for (const record of this.records.values(range, direction)) {
3810
+ values.push(handOut(record.value));
3811
+ if (values.length >= most) break;
3812
+ }
3813
+ return values;
3814
+ };
3815
+ }
3816
+ /** Drops from an index the entries of records that left its store, and gives back the ones it dropped. */
3817
+ function dropEntries(index, records, cmp) {
3818
+ const dropped = [];
3819
+ for (const entry of indexEntries(index, records, cmp)) {
3820
+ if (index.records.keysAreUnique) {
3821
+ const held = index.records.get(entry.key);
3822
+ if (!held || cmp(held.value, entry.value) !== 0) continue;
3823
+ }
3824
+ index.records.records.delete(entry);
3825
+ dropped.push(entry);
3826
+ }
3827
+ return dropped;
3828
+ }
3829
+ /**
3830
+ * `cmp` for keys that are stored already (a record's, an index entry's): numbers, strings and arrays of them are
3831
+ * ordered here, as IndexedDB orders them (a number before a string before an array; strings by code unit; arrays item
3832
+ * by item, then the shorter first). Anything else (a date, bytes, a value that is no key) is left to `cmp`, which
3833
+ * checks and copies both keys at every call: on the way back from a snapshot that was a third of a large store's time.
3834
+ */
3835
+ function keyOrder(cmp) {
3836
+ const order = (a, b) => {
3837
+ const first = typeof a, second = typeof b;
3838
+ if (first === "number" && second === "number") return a === b ? 0 : a > b ? 1 : a < b ? -1 : cmp(a, b);
3839
+ if (first === "string" && second === "string") return a === b ? 0 : a > b ? 1 : -1;
3840
+ if (first === "number" && second === "string") return -1;
3841
+ if (first === "string" && second === "number") return 1;
3842
+ if (Array.isArray(a)) {
3843
+ if (second === "number" || second === "string") return 1;
3844
+ if (!Array.isArray(b)) return cmp(a, b);
3845
+ const length = Math.min(a.length, b.length);
3846
+ for (let at = 0; at < length; at++) {
3847
+ const item = order(a[at], b[at]);
3848
+ if (item !== 0) return item;
3849
+ }
3850
+ return a.length === b.length ? 0 : a.length > b.length ? 1 : -1;
3851
+ }
3852
+ if (Array.isArray(b) && (first === "number" || first === "string")) return -1;
3853
+ return cmp(a, b);
3854
+ };
3855
+ return order;
3856
+ }
3857
+ /** Whether it wrote a store of format 1 into rows.db (the snapshot is to be written again, of format 3). */
3858
+ function restore(classes, databases, snapshot, cmp, disk) {
3859
+ if (snapshot?.format !== 1 && snapshot?.format !== 2 && snapshot?.format !== 3) 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.");
3860
+ if (snapshot.format !== 1 && !disk.trees) throw new Error(`This profile keeps its messages in rows.db, which needs Node 22.13 or newer (this is ${process.version}). Update Node to open it.`);
3861
+ const missing = /* @__PURE__ */ new Error("This profile's store is missing rows.db (beside snapshot.bin): its messages cannot be read. Restore the profile from a backup.");
3862
+ const named = snapshot.format === 2 && snapshot.databases.some((db) => db.stores.some((store) => store.rows?.ids.some((id) => id >= 0)));
3863
+ if (snapshot.format === 2 && (!disk.rowsFound || !disk.rows || named && disk.rows.count() === 0)) throw missing;
3864
+ const kept = (db, store) => disk.trees.has(db, store.name) && store.indexes.every((index) => disk.trees.has(db, store.name, index.name));
3865
+ if (snapshot.format === 3 && (!disk.rowsFound || snapshot.databases.some((db) => db.stores.some((store) => store.onDisk && !kept(db.name, store))))) throw missing;
3866
+ const intern = interner();
3867
+ let written = false;
3868
+ for (const saved of snapshot.databases) {
3869
+ const db = new classes.Database(saved.name, saved.version);
3870
+ for (const s of saved.stores) {
3871
+ const store = new classes.ObjectStore(db, s.name, s.keyPath, s.autoIncrement);
3872
+ if (store.keyGenerator && s.keyGenerator !== null) store.keyGenerator.num = s.keyGenerator;
3873
+ for (const i of s.indexes) {
3874
+ const index = new classes.Index(store, i.name, i.keyPath, i.multiEntry, i.unique);
3875
+ index.initialized = true;
3876
+ store.rawIndexes.set(i.name, index);
3877
+ }
3878
+ db.rawObjectStores.set(s.name, store);
3879
+ if (s.onDisk) {
3880
+ disk.attach(store);
3881
+ continue;
3882
+ }
3883
+ if (s.rows) {
3884
+ const kept = s.rows;
3885
+ const records = kept.keys.map((key, at) => kept.ids[at] >= 0 ? disk.made.make(intern(key), kept.ids[at]) : {
3886
+ key: intern(key),
3887
+ value: void 0
3888
+ });
3889
+ for (const [at, value] of kept.inline) records[at] = {
3890
+ key: records[at].key,
3891
+ value: blobsFromDisk(value)
3892
+ };
3893
+ fill(store.records, records);
3894
+ for (const index of store.rawIndexes.values()) {
3895
+ const entries = kept.indexes[index.name];
3896
+ fill(index.records, entries ? entries.map(([key, at]) => ({
3897
+ key: intern(key),
3898
+ value: records[at].key
3899
+ })) : indexEntries(index, records, cmp));
3900
+ }
3901
+ continue;
3902
+ }
3903
+ const ordered = s.records.every(([key], at) => at === 0 || cmp(s.records[at - 1][0], key) < 0);
3904
+ if (ordered && disk.trees && disk.onDisk(saved.name, s.name)) {
3905
+ recordsToDisk(disk.trees, saved.name, store, s.records, cmp);
3906
+ s.records = [];
3907
+ disk.attach(store);
3908
+ written = true;
3909
+ continue;
3910
+ }
3911
+ const records = s.records.map(([key, value]) => ({
3912
+ key,
3913
+ value: blobsFromDisk(value)
3914
+ }));
3915
+ if (!ordered) {
3916
+ for (const record of records) store.storeRecord(record, false);
3917
+ continue;
3918
+ }
3919
+ fill(store.records, records);
3920
+ for (const index of store.rawIndexes.values()) fill(index.records, indexEntries(index, records, cmp));
3921
+ }
3922
+ databases.set(saved.name, db);
3923
+ }
3924
+ return written;
3925
+ }
3926
+ /** How many of a snapshot's records go to rows.db at a time, each with its index entries. */
3927
+ var MOVE_BATCH = 2048;
3928
+ /**
3929
+ * A store of a snapshot of format 1 that is kept on disk, written into rows.db from the snapshot's records (in key
3930
+ * order), a batch at a time with its index entries, in one SQLite transaction: building fake-indexeddb's trees first and
3931
+ * copying them out held every message in those trees beside the snapshot at the update's first start (1.1 GB at
3932
+ * 175,000, where the released start took 720 MB). Each batch's values are let go of in the snapshot once written.
3933
+ */
3934
+ function recordsToDisk(trees, db, store, saved, cmp) {
3935
+ const indexes = [...store.rawIndexes.values()];
3936
+ const id = trees.treeId(db, store.name), ids = indexes.map((index) => trees.treeId(db, store.name, index.name));
3937
+ trees.db.exec("BEGIN");
3938
+ try {
3939
+ for (const tree of [id, ...ids]) trees.clearRows(tree);
3940
+ for (let from = 0; from < saved.length; from += MOVE_BATCH) {
3941
+ const batch = saved.slice(from, from + MOVE_BATCH).map(([key, value]) => ({
3942
+ key,
3943
+ value: blobsFromDisk(value)
3944
+ }));
3945
+ saved.fill(void 0, from, from + MOVE_BATCH);
3946
+ for (const record of batch) trees.row(id, record, false, true);
3947
+ indexes.forEach((index, at) => {
3948
+ for (const entry of indexEntries(index, batch, cmp)) trees.row(ids[at], entry, true, index.records.keysAreUnique);
3949
+ });
3950
+ }
3951
+ trees.db.exec("COMMIT");
3952
+ } catch (error) {
3953
+ trees.db.exec("ROLLBACK");
3954
+ throw error;
3955
+ }
3956
+ }
3957
+ /**
3958
+ * One string for every copy of a chat's id among the keys (a message's key and its index entries each name its chat):
3959
+ * a snapshot read back gives each its own.
3960
+ */
3961
+ function interner() {
3962
+ const pool = /* @__PURE__ */ new Map();
3963
+ const one = (text) => {
3964
+ const held = pool.get(text);
3965
+ if (held !== void 0) return held;
3966
+ pool.set(text, text);
3967
+ return text;
3968
+ };
3969
+ return (key) => {
3970
+ if (typeof key === "string") return key.length <= 64 ? one(key) : key;
3971
+ if (Array.isArray(key) && typeof key[0] === "string" && key[0].length <= 64) key[0] = one(key[0]);
3972
+ return key;
3973
+ };
3974
+ }
3975
+ /**
3976
+ * An index's entries for records in key order, sorted by the index's key: the index derives them itself (key path,
3977
+ * multi-entry, invalid keys skipped) into a list instead of its tree. A stable sort keeps equal index keys in the
3978
+ * records' key order, the tree's own tie-break.
3979
+ */
3980
+ function indexEntries(index, records, cmp) {
3981
+ const entries = [];
3982
+ const tree = index.records;
3983
+ index.records = {
3984
+ get: () => void 0,
3985
+ put: (entry) => {
3986
+ entries.push(entry);
3987
+ }
3988
+ };
3989
+ try {
3990
+ for (const record of records) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
3991
+ } finally {
3992
+ index.records = tree;
3993
+ }
3994
+ return entries.sort((a, b) => cmp(a.key, b.key));
3995
+ }
3996
+ /**
3997
+ * Whether a key path surely finds nothing in a value: a plain object along it lacks the step (most messages have no
3998
+ * `card` for the card index). The index would throw and catch a DataError for each of those; anything else it judges.
3999
+ */
4000
+ function absent(keyPath, value) {
4001
+ if (typeof keyPath !== "string" || keyPath === "") return false;
4002
+ let object = value;
4003
+ for (const step of keyPath.split(".")) {
4004
+ if (object === null || typeof object !== "object" || Array.isArray(object) || object instanceof Blob) return false;
4005
+ if (!Object.hasOwn(object, step)) return true;
4006
+ object = object[step];
4007
+ }
4008
+ return false;
4009
+ }
4010
+ /**
4011
+ * fake-indexeddb's red-black tree, built balanced from sorted records: every level full but the last, whose nodes
4012
+ * are red, so each path holds the same number of black nodes and later inserts rebalance as usual.
4013
+ */
4014
+ function fill(store, sorted) {
4015
+ const deepest = sorted.length ? Math.floor(Math.log2(sorted.length)) : 0;
4016
+ const build = (low, high, parent, depth) => {
4017
+ if (low > high) return void 0;
4018
+ const middle = low + high >>> 1;
4019
+ const node = {
4020
+ record: sorted[middle],
4021
+ left: void 0,
4022
+ right: void 0,
4023
+ parent,
4024
+ deleted: false,
4025
+ red: depth > 0 && depth === deepest
4026
+ };
4027
+ node.left = build(low, middle - 1, node, depth + 1);
4028
+ node.right = build(middle + 1, high, node, depth + 1);
4029
+ return node;
4030
+ };
4031
+ store.records._root = build(0, sorted.length - 1, void 0, 0);
4032
+ store.records._numNodes = sorted.length;
4033
+ store.records._numTombstones = 0;
4034
+ }
4035
+ /**
4036
+ * Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
4037
+ *
4038
+ * A store's indexes are set aside while its ops replay and brought in step once at the end: fake-indexeddb drops an
4039
+ * overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
4040
+ * tens of seconds to minutes to start, one write at a time against the whole profile. Only the rows the journal
4041
+ * touched change in an index (what each was before goes, what it is now comes in): a journal of one message costs one
4042
+ * message, not the profile's history once per index. A store the journal cleared has its indexes built again whole.
4043
+ */
4044
+ function replay(databases, journal, cmp, trees) {
4045
+ const touched = /* @__PURE__ */ new Map();
4046
+ try {
4047
+ return replayEntries(databases, journal, touched, trees);
4048
+ } finally {
4049
+ for (const [store, { indexes, was, now }] of touched) {
4050
+ const records = store.records;
4051
+ delete records.put;
4052
+ delete records.delete;
4053
+ store.rawIndexes = indexes;
4054
+ for (const index of indexes.values()) {
4055
+ if (!was) index.records.clear();
4056
+ if (!index.initialized) continue;
4057
+ if (was) dropEntries(index, [...was], cmp);
4058
+ for (const record of was ? now : store.records.values()) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
4059
+ }
4060
+ }
4061
+ }
4062
+ }
4063
+ function replayEntries(databases, journal, touched, trees) {
4064
+ let offset = 0, closed = false, last = 0;
4065
+ while (offset + 4 <= journal.length) {
4066
+ const length = journal.readUInt32BE(offset);
4067
+ if (offset + 4 + length > journal.length) break;
4068
+ let ops;
4069
+ try {
4070
+ ops = deserialize(journal.subarray(offset + 4, offset + 4 + length));
4071
+ } catch {
4072
+ break;
4073
+ }
4074
+ offset += 4 + length;
4075
+ closed = ops.some((op) => op.db === CLOSED);
4076
+ const marker = ops.find((op) => op.db === SEQ);
4077
+ const entry = marker?.kind === "clear" ? marker.seq : void 0;
4078
+ if (entry !== void 0) last = Math.max(last, entry);
4079
+ for (const op of ops) {
4080
+ const store = databases.get(op.db)?.rawObjectStores.get(op.store);
4081
+ if (!store) continue;
4082
+ const onTrees = trees.onTrees(store);
4083
+ if (onTrees && entry !== void 0 && entry <= trees.applied) continue;
4084
+ let change = onTrees ? void 0 : touched.get(store);
4085
+ if (!onTrees && !change) touched.set(store, change = watch(store));
4086
+ if (op.kind === "put") store.storeRecord({
4087
+ key: op.key,
4088
+ value: blobsFromDisk(op.value)
4089
+ }, false);
4090
+ else if (op.kind === "delete") store.deleteRecord(keyFromDisk(op.key));
4091
+ else {
4092
+ if (change) change.was = null;
4093
+ store.clear();
4094
+ }
4095
+ }
4096
+ }
4097
+ return {
4098
+ end: offset,
4099
+ closed: closed && offset === journal.length,
4100
+ seq: last
4101
+ };
4102
+ }
4103
+ /**
4104
+ * Sets a store's indexes aside and notes, as its records are written over and removed, which ones left and which
4105
+ * ones came: the record store hands back what a put replaced and what a delete removed, so a row costs no lookup.
4106
+ * A record that came and left again was never in an index.
4107
+ */
4108
+ function watch(store) {
4109
+ const change = {
4110
+ indexes: store.rawIndexes,
4111
+ was: store.rawIndexes.size ? /* @__PURE__ */ new Set() : null,
4112
+ now: /* @__PURE__ */ new Set()
4113
+ };
4114
+ store.rawIndexes = /* @__PURE__ */ new Map();
4115
+ if (!change.was) return change;
4116
+ const { put, delete: remove } = store.records;
4117
+ const left = (record) => {
4118
+ if (!change.now.delete(record)) change.was?.add(record);
4119
+ };
4120
+ store.records.put = (next, keep) => {
4121
+ const replaced = put.call(store.records, next, keep);
4122
+ if (replaced) left(replaced);
4123
+ change.now.add(next);
4124
+ return replaced;
4125
+ };
4126
+ store.records.delete = (key) => {
4127
+ const removed = remove.call(store.records, key);
4128
+ removed.forEach(left);
4129
+ return removed;
4130
+ };
4131
+ return change;
4132
+ }
4133
+ function keyToDisk(key) {
4134
+ if (key instanceof IDBKeyRange) return { [RANGE]: {
4135
+ lower: key.lower,
4136
+ upper: key.upper,
4137
+ lowerOpen: key.lowerOpen,
4138
+ upperOpen: key.upperOpen
4139
+ } };
4140
+ return key;
4141
+ }
4142
+ function keyFromDisk(key) {
4143
+ const range = key?.[RANGE];
4144
+ if (!range) return key;
4145
+ if (range.lower === void 0) return IDBKeyRange.upperBound(range.upper, range.upperOpen);
4146
+ if (range.upper === void 0) return IDBKeyRange.lowerBound(range.lower, range.lowerOpen);
4147
+ return IDBKeyRange.bound(range.lower, range.upper, range.lowerOpen, range.upperOpen);
4148
+ }
4149
+ function hasBlob(value, depth = 0) {
4150
+ if (value instanceof Blob) return true;
4151
+ if (!value || typeof value !== "object" || depth > 32 || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return false;
4152
+ if (value instanceof Map) {
4153
+ for (const [k, v] of value) if (hasBlob(k, depth + 1) || hasBlob(v, depth + 1)) return true;
4154
+ return false;
4155
+ }
4156
+ for (const item of Object.values(value)) if (hasBlob(item, depth + 1)) return true;
4157
+ return false;
4158
+ }
4159
+ /** v8 cannot serialize a Blob: it goes to disk as its bytes and type, and comes back a Blob. */
4160
+ async function blobsToDisk(value, depth = 0) {
4161
+ if (!hasBlob(value, depth)) return value;
4162
+ if (value instanceof Blob) return { [BLOB]: {
4163
+ type: value.type,
4164
+ bytes: new Uint8Array(await value.arrayBuffer())
4165
+ } };
4166
+ if (Array.isArray(value)) return Promise.all(value.map((item) => blobsToDisk(item, depth + 1)));
4167
+ if (value instanceof Map) return new Map(await Promise.all([...value].map(async ([k, v]) => [k, await blobsToDisk(v, depth + 1)])));
4168
+ const out = {};
4169
+ for (const [k, v] of Object.entries(value)) out[k] = await blobsToDisk(v, depth + 1);
4170
+ return out;
4171
+ }
4172
+ function blobsFromDisk(value, depth = 0) {
4173
+ if (!value || typeof value !== "object" || depth > 32 || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value;
4174
+ const blob = value[BLOB];
4175
+ if (blob) return new Blob([blob.bytes], { type: blob.type });
4176
+ if (Array.isArray(value)) return value.map((item) => blobsFromDisk(item, depth + 1));
4177
+ if (value instanceof Map) return new Map([...value].map(([k, v]) => [k, blobsFromDisk(v, depth + 1)]));
4178
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value;
4179
+ const out = {};
4180
+ for (const [k, v] of Object.entries(value)) out[k] = blobsFromDisk(v, depth + 1);
4181
+ return out;
4182
+ }
4183
+ //#endregion
4184
+ //#region src/runtime/engine.ts
4185
+ /**
4186
+ * The HyperDHT network: `GHOSTLY_HYPERDHT_BOOTSTRAP` ("host:port,…") replaces the public bootstrap nodes, as it does
4187
+ * for the Desktop's sidecar; a network all on loopback (the tests') is announced on loopback too.
4188
+ */
4189
+ function hyperdhtNetwork(env = process.env.GHOSTLY_HYPERDHT_BOOTSTRAP) {
4190
+ const bootstrap = (env ?? "").split(",").map((node) => node.trim()).filter(Boolean);
4191
+ if (!bootstrap.length) return {};
4192
+ return {
4193
+ bootstrap,
4194
+ ...bootstrap.every((node) => /^(127\.0\.0\.1|localhost):\d+$/.test(node)) ? { host: "127.0.0.1" } : {}
4195
+ };
4196
+ }
4197
+ /**
4198
+ * The Pkarr relays of a private network or a test, `GHOSTLY_PKARR_RELAYS` ("http://…,…"), the Desktop's name for it:
4199
+ * the only relays this process uses, whatever the profile's `relays` setting says, from its very first publish. Null
4200
+ * when unset.
4201
+ */
4202
+ function pkarrRelays(env = process.env) {
4203
+ const relays = (env.GHOSTLY_PKARR_RELAYS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
4204
+ if (!relays.length) return null;
4205
+ const bad = relays.find((url) => !/^https?:\/\/[^/\s]/i.test(url));
4206
+ if (bad) throw new CliError("usage", `GHOSTLY_PKARR_RELAYS takes http:// or https:// URLs, comma-separated, not ${JSON.stringify(bad)}`);
4207
+ return relays;
4208
+ }
4209
+ /**
4210
+ * The Iroh relays of a private network or a test, `GHOSTLY_IROH_RELAYS` ("https://…,…"; http:// on loopback): the only
4211
+ * relays this process's Iroh homes on, whatever the profile's `irohRelays` setting says. Null when unset (the setting,
4212
+ * else n0's public relays).
4213
+ */
4214
+ function irohRelays(env = process.env) {
4215
+ const relays = (env.GHOSTLY_IROH_RELAYS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
4216
+ if (!relays.length) return null;
4217
+ const bad = relays.map((url) => irohRelayProblem(url)).find(Boolean);
4218
+ if (bad) throw new CliError("usage", `GHOSTLY_IROH_RELAYS: ${bad}`);
4219
+ return relays;
4220
+ }
4221
+ /**
4222
+ * The apps' public STUN servers (Google's, `RTC_CONFIG`), which every WebRTC connection asks for its public address:
4223
+ * `GHOSTLY_STUN=0` leaves them out (a private network, or tests on one machine, where host candidates connect). The
4224
+ * profile's own `iceServers` stay.
4225
+ */
4226
+ function publicStun(env = process.env) {
4227
+ return env.GHOSTLY_STUN !== "0";
4228
+ }
4229
+ /** An RTC configuration without the apps' public STUN servers. */
4230
+ function withoutPublicStun(config) {
4231
+ if (!config?.iceServers) return config;
4232
+ const ours = new Set((RTC_CONFIG.iceServers ?? []).flatMap((server) => [server.urls].flat()));
4233
+ const iceServers = config.iceServers.map((server) => ({
4234
+ ...server,
4235
+ urls: [server.urls].flat().filter((url) => !ours.has(url))
4236
+ })).filter((server) => server.urls.length);
4237
+ return {
4238
+ ...config,
4239
+ iceServers
4240
+ };
4241
+ }
4242
+ /**
4243
+ * The Mainline DHT, for Pkarr beside the relays (`RelaysAndDht`): `GHOSTLY_DHT=0` leaves it out (relays only, as the web
4244
+ * app), `GHOSTLY_DHT_BOOTSTRAP` ("host:port,…") replaces the public routers (a testnet; one all on loopback binds there).
4245
+ * `GHOSTLY_PKARR_RELAYS` without a bootstrap leaves it out too, as on the Desktop: a private network stays off the
4246
+ * public DHT.
4247
+ */
4248
+ function mainlineNetwork(env = process.env) {
4249
+ if (env.GHOSTLY_DHT === "0") return { off: true };
4250
+ const bootstrap = (env.GHOSTLY_DHT_BOOTSTRAP ?? "").split(",").map((node) => node.trim()).filter(Boolean);
4251
+ if (!bootstrap.length && pkarrRelays(env)) return { off: true };
4252
+ if (!bootstrap.length) return { off: false };
4253
+ return {
4254
+ off: false,
4255
+ bootstrap,
4256
+ ...bootstrap.every((node) => /^(127\.0\.0\.1|localhost):\d+$/.test(node)) ? { host: "127.0.0.1" } : {}
4257
+ };
4258
+ }
4259
+ /**
4260
+ * WebRTC for the engine (WISP 101): libdatachannel through node-datachannel's W3C polyfill. Chats go live over it with
4261
+ * browsers directly, and groups need it (their links use WebRTC alone). A native module: without it, or with
4262
+ * `GHOSTLY_WEBRTC=0`, the CLI runs without WebRTC and says so.
4263
+ */
4264
+ async function installWebRtc() {
4265
+ if (process.env.GHOSTLY_WEBRTC === "0") return false;
4266
+ try {
4267
+ const polyfill = await import("node-datachannel/polyfill");
4268
+ const scope = globalThis;
4269
+ for (const name of [
4270
+ "RTCPeerConnection",
4271
+ "RTCSessionDescription",
4272
+ "RTCIceCandidate",
4273
+ "RTCDataChannel",
4274
+ "RTCDataChannelEvent",
4275
+ "RTCPeerConnectionIceEvent",
4276
+ "RTCCertificate"
4277
+ ]) scope[name] ??= polyfill[name];
4278
+ refuseDataAnswers(scope);
4279
+ if (!publicStun()) leaveOutPublicStun(scope);
4280
+ return true;
4281
+ } catch (error) {
4282
+ process.stderr.write(`ghostly: WebRTC is unavailable (${error instanceof Error ? error.message : String(error)}); chats use HyperDHT, Iroh or the DHT\n`);
4283
+ return false;
4284
+ }
4285
+ }
4286
+ /**
4287
+ * For tests: `GHOSTLY_TEST_REFUSE_DATA_ANSWERS=<n>` refuses one in every n answers this process applies to its offers
4288
+ * (the first, the n+1st, and so on), as libdatachannel 0.24.5 refuses one in its race (`ANSWER_REFUSED_REDIALS` in
4289
+ * packages/core/src/datalink.ts): the connection closes, and setRemoteDescription throws.
4290
+ */
4291
+ function refuseDataAnswers(scope) {
4292
+ const every = Number(process.env.GHOSTLY_TEST_REFUSE_DATA_ANSWERS) || 0;
4293
+ if (every <= 0) return;
4294
+ let answers = 0;
4295
+ const Base = scope.RTCPeerConnection;
4296
+ scope.RTCPeerConnection = class extends Base {
4297
+ async setRemoteDescription(description) {
4298
+ if (description.type === "answer" && answers++ % every === 0) {
4299
+ this.close();
4300
+ throw new Error("libdatachannel error while adding remote description: Got a remote candidate without ICE transport (GHOSTLY_TEST_REFUSE_DATA_ANSWERS)");
4301
+ }
4302
+ return super.setRemoteDescription(description);
4303
+ }
4304
+ };
4305
+ }
4306
+ /** `GHOSTLY_STUN=0`: every connection the engine makes leaves the apps' public STUN servers out of its configuration. */
4307
+ function leaveOutPublicStun(scope) {
4308
+ const Base = scope.RTCPeerConnection;
4309
+ scope.RTCPeerConnection = class extends Base {
4310
+ constructor(config) {
4311
+ super(withoutPublicStun(config));
4312
+ }
4313
+ };
4314
+ }
4315
+ /** Server-sent events, which browsers have and Node does not: Arkade follows its server with them. */
4316
+ async function installEventSource() {
4317
+ const scope = globalThis;
4318
+ if (scope.EventSource) return;
4319
+ scope.EventSource = (await import("eventsource")).EventSource;
4320
+ }
4321
+ /**
4322
+ * Arkade's descriptor library is CommonJS and `require`s `@scure/bip32`, which is ESM. When the engine's own imports
4323
+ * are still evaluating that module, Node's require(esm) hands the library a namespace without `HDKey` yet ("reading
4324
+ * 'fromMasterSeed'"). Loading it whole first avoids the race.
4325
+ */
4326
+ async function preloadWalletModules() {
4327
+ await import("@scure/bip32").catch(() => {});
4328
+ await import("@bitcoinerlab/descriptors-scure").catch(() => {});
4329
+ }
4330
+ /** Iroh's wasm, from next to the bundle (a package) or from the workspace (development and tests). */
4331
+ function irohWasmBytes() {
4332
+ for (const place of ["./ghostly_iroh_web_bg.wasm", "./assets/ghostly_iroh_web_bg.wasm"]) {
4333
+ const path = fileURLToPath(new URL(place, import.meta.url));
4334
+ if (existsSync(path)) return readFileSync(path);
4335
+ }
4336
+ return readFileSync(createRequire(import.meta.url).resolve("@ghostly/iroh-web/wasm"));
4337
+ }
4338
+ /**
4339
+ * Starts the app's engine on this profile (WISP 1100 § Runtime): IndexedDB on disk, WebRTC through libdatachannel,
4340
+ * HyperDHT native in this process, Iroh's wasm build (relay only, as the web app), Pkarr through the relays in the
4341
+ * settings and the Mainline DHT directly (read when the relays fail, written always).
4342
+ * The caller holds the profile's lock.
4343
+ */
4344
+ /** The files a light backup left out, as the restore wrote them in the mark (an empty mark: none). */
4345
+ function leftOutIn(mark) {
4346
+ try {
4347
+ const ids = JSON.parse(readFileSync(mark, "utf8") || "{}").leftOut;
4348
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
4349
+ } catch {
4350
+ return [];
4351
+ }
4352
+ }
4353
+ async function startRuntime(paths, options = {}) {
4354
+ const pinned = pkarrRelays();
4355
+ const pinnedIroh = irohRelays();
4356
+ const trace = process.env.GHOSTLY_LINK_TRACE;
4357
+ if (trace) {
4358
+ setLinkTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
4359
+ (await import("./joinTrace-D-fnt2p3.js")).setJoinTraceSink((line) => appendFileSync(trace, line + "\n", { mode: 384 }));
4360
+ }
4361
+ const store = await openPersistentIndexedDb(paths.db);
4362
+ const restored = join(paths.dir, RESTORED_MARK);
4363
+ if (existsSync(restored)) {
4364
+ try {
4365
+ const rows = await import("./restoredRows-Db3oUTko.js");
4366
+ await rows.markRestoredWallet();
4367
+ await rows.markLeftOutFiles(leftOutIn(restored));
4368
+ await store.compact();
4369
+ } catch (error) {
4370
+ await store.close().catch(() => {});
4371
+ throw error;
4372
+ }
4373
+ rmSync(restored, { force: true });
4374
+ }
4375
+ const webrtc = await installWebRtc();
4376
+ const stack = webrtc ? await loadCallStack() : "Calls need WebRTC, which is off on this headless Ghostly";
4377
+ const callsUnavailable = typeof stack === "string" ? stack : null;
4378
+ installFileFetch();
4379
+ const testMints = process.env.GHOSTLY_TEST_MINTS;
4380
+ if (testMints) (await import("./mints-CqTdA4Gk.js")).declareTestMints(testMints.split(","));
4381
+ await installEventSource();
4382
+ await preloadWalletModules();
4383
+ (await import("./ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
4384
+ const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./fileBytes-CgbibpbV.js"), import("./fileBytes-CfSXjTFa.js")]);
4385
+ registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
4386
+ const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./server-Ss0gJ-nw.js"), import("./endpoint-BOTFMpfS.js")]);
4387
+ const network = hyperdhtNetwork();
4388
+ const dht = mainlineNetwork();
4389
+ const mainline = dht.off ? null : new (await (import("./mainline-ZkiOnevs.js"))).Mainline({
4390
+ bootstrap: dht.bootstrap,
4391
+ host: dht.host
4392
+ });
4393
+ const transport = mainline || pinned ? new (await (import("./mainline-ZkiOnevs.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
4394
+ const apps = servedApps(paths.dir).length > 0;
4395
+ const server = new EngineServer({
4396
+ ...transport ? { transport } : {},
4397
+ irohWeb: true,
4398
+ ...pinnedIroh ? { irohRelays: pinnedIroh } : {},
4399
+ singleDevice: true,
4400
+ nativeTransports: { "hyperdht/1": (seedB64) => createHyperEndpoint(fromBase64Url(seedB64), network) },
4401
+ automaticWallets: false,
4402
+ fedimintSdk: nodeFedimintSdk(join(paths.dir, "fedimint")),
4403
+ servicesSupport: true,
4404
+ apps: apps ? "wire" : false,
4405
+ staysOnline: process.env.GHOSTLY_HUB !== "0",
4406
+ localFetch: nodeLocalFetch,
4407
+ pushSend: (request) => nodePushSend(request),
4408
+ callsSupport: callsUnavailable === null,
4409
+ ...callsUnavailable ? { callsUnavailable } : {},
4410
+ ...options.deferGroups ? { deferGroups: true } : {},
4411
+ ...options.offline ? { offline: true } : {},
4412
+ ...process.env.GHOSTLY_TEST_WEBRTC_GROUP_LINKS === "1" ? { webrtcGroupLinks: true } : {}
4413
+ });
4414
+ try {
4415
+ await server.ready;
4416
+ } catch (error) {
4417
+ await mainline?.destroy().catch(() => {});
4418
+ await store.close().catch(() => {});
4419
+ throw error;
4420
+ }
4421
+ let closing = null;
4422
+ return {
4423
+ paths,
4424
+ server,
4425
+ store,
4426
+ webrtc,
4427
+ callsUnavailable,
4428
+ apps,
4429
+ close: () => closing ??= (async () => {
4430
+ await server.node.shutdown().catch(() => {});
4431
+ await mainline?.destroy().catch(() => {});
4432
+ await store.close();
4433
+ })()
4434
+ };
4435
+ }
4436
+ //#endregion
4437
+ //#region src/host.ts
4438
+ /** The longest request line the daemon reads (WISP 1100 § Framing). */
4439
+ var MAX_LINE = 16777216;
4440
+ /**
4441
+ * How many of a chat's newest messages the start reads to see whether the events are in step with its history: the
4442
+ * whole history of every chat and group, read at every start, took a daemon's start and its memory with every message
4443
+ * the profile ever stored (1 s and 70 MB at 100,000).
4444
+ */
4445
+ var BASELINE_NEWEST = 100;
4446
+ /**
4447
+ * Runs a profile in this process: takes its lock, starts the engine, and derives events from it. A daemon then
4448
+ * serves the socket (`serve`); a one-shot command calls the API directly and closes. A one-shot for one chat passes
4449
+ * `deferGroups`: the groups' sessions would spend the relays' budget its message needs.
4450
+ */
4451
+ async function openHost(paths, mode, version, options = {}) {
4452
+ const release = acquireLock(paths);
4453
+ let runtime;
4454
+ try {
4455
+ runtime = await startRuntime(paths, options);
4456
+ } catch (error) {
4457
+ release();
4458
+ throw error;
4459
+ }
4460
+ const hub = new EventHub(paths.events);
4461
+ let calls;
4462
+ try {
4463
+ await hub.open();
4464
+ const node = runtime.server.node;
4465
+ const now = node.getState();
4466
+ const mark = runtime.server.historyMark;
4467
+ const histories = /* @__PURE__ */ new Map(), newest = /* @__PURE__ */ new Map();
4468
+ const whole = (id) => id.startsWith("group:") ? node.groupMessages({ groupId: id.slice(6) }) : node.getMessages(id);
4469
+ const closed = !runtime.store.recovered;
4470
+ for (const id of [...now.links.map((link) => link.id), ...now.groups.map((group) => `group:${group.id}`)]) {
4471
+ const page = closed && !runtime.server.historyChanged(id) ? await node.messagePage({
4472
+ linkId: id,
4473
+ limit: 100
4474
+ }) : null;
4475
+ if (page && !page.more) histories.set(id, page.messages);
4476
+ else if (page && hub.inStep(id, page.messages)) newest.set(id, page.messages);
4477
+ else histories.set(id, await whole(id));
4478
+ }
4479
+ hub.baseline(now, histories, {
4480
+ newest,
4481
+ whole
4482
+ });
4483
+ announceJoins(hub, node);
4484
+ calls = new CallManager({
4485
+ engine: node,
4486
+ emit: (type, id, fields) => hub.emit(type, id, fields),
4487
+ profileDir: paths.dir,
4488
+ answers: mode === "daemon"
4489
+ });
4490
+ const manager = calls;
4491
+ hub.onCallSignal((chat, signal) => manager.onSignal(chat, signal));
4492
+ reportAppFrames(hub, node, paths.dir);
4493
+ runtime.server.attach(hub.sink, {
4494
+ mark,
4495
+ ids: [...histories.keys(), ...newest.keys()]
4496
+ });
4497
+ } catch (error) {
4498
+ await runtime.close().catch(() => {});
4499
+ release();
4500
+ throw error;
4501
+ }
4502
+ const ctx = {
4503
+ runtime,
4504
+ hub,
4505
+ mode,
4506
+ version,
4507
+ calls
4508
+ };
4509
+ resumeHolds(ctx);
4510
+ let closing = null;
4511
+ return {
4512
+ ctx,
4513
+ close: () => closing ??= (async () => {
4514
+ await Promise.all([calls.stopAll(), flushCardUpdates(ctx)]).catch(() => {});
4515
+ runtime.server.detach(hub.sink);
4516
+ await runtime.close().catch(() => {});
4517
+ release();
4518
+ })()
4519
+ };
4520
+ }
4521
+ async function serve(host) {
4522
+ const { ctx } = host;
4523
+ const path = ctx.runtime.paths.socket;
4524
+ if (process.platform !== "win32") {
4525
+ if (dirname(path) !== ctx.runtime.paths.dir) privateFolder(dirname(path), "the daemon will not put its socket there");
4526
+ else ownFolder(ctx.runtime.paths.dir, "the daemon will not put its socket there");
4527
+ }
4528
+ if (existsSync(path)) rmSync(path, { force: true });
4529
+ const sockets = /* @__PURE__ */ new Set();
4530
+ const server = createServer((socket) => {
4531
+ sockets.add(socket);
4532
+ socket.on("close", () => sockets.delete(socket));
4533
+ connection(ctx, socket);
4534
+ });
4535
+ await new Promise((resolve, reject) => {
4536
+ server.once("error", reject);
4537
+ const umask = ownerOnlyUmask();
4538
+ try {
4539
+ server.listen(path, () => {
4540
+ server.off("error", reject);
4541
+ resolve();
4542
+ });
4543
+ } finally {
4544
+ umask?.();
4545
+ }
4546
+ });
4547
+ if (process.platform !== "win32") chmodSync(path, 384);
4548
+ let closing = null;
4549
+ return {
4550
+ server,
4551
+ close: () => closing ??= new Promise((resolve) => {
4552
+ server.close(() => resolve());
4553
+ for (const socket of sockets) socket.end();
4554
+ setTimeout(() => {
4555
+ for (const socket of sockets) socket.destroy();
4556
+ }, 500).unref();
4557
+ })
4558
+ };
4559
+ }
4560
+ var guarded = false;
4561
+ /**
4562
+ * A daemon says an error nobody caught (with its stack, on stderr: the daemon's log when detached) and keeps serving:
4563
+ * one request gone wrong must not take the profile offline. One-shot commands do not call this; they still fail loudly.
4564
+ *
4565
+ * `outputs`: what it writes to. A write there that fails (a pipe whose reader is gone: the process that started the
4566
+ * daemon died) is dropped, not an error nobody caught: saying it would be one more failed write, and the next, with
4567
+ * no turn left for anything else (a full core, and no stop on SIGTERM).
4568
+ */
4569
+ function keepServing(proc = process, write = (text) => {
4570
+ process.stderr.write(text);
4571
+ }, outputs = proc === process ? [process.stdout, process.stderr] : []) {
4572
+ if (proc === process) {
4573
+ if (guarded) return;
4574
+ guarded = true;
4575
+ }
4576
+ const log = (what) => (error) => {
4577
+ try {
4578
+ write(`ghostly: ${what}, the daemon keeps serving: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
4579
+ } catch {}
4580
+ };
4581
+ for (const output of outputs) output.on("error", () => {});
4582
+ proc.on("unhandledRejection", log("unhandled rejection"));
4583
+ proc.on("uncaughtException", log("uncaught exception"));
4584
+ }
4585
+ /** Sets a 077 umask; the function it returns puts the old one back. Null where there is none (Windows, a worker). */
4586
+ function ownerOnlyUmask() {
4587
+ if (process.platform === "win32") return null;
4588
+ try {
4589
+ const old = process.umask(63);
4590
+ return () => {
4591
+ process.umask(old);
4592
+ };
4593
+ } catch {
4594
+ return null;
4595
+ }
4596
+ }
4597
+ function connection(ctx, socket) {
4598
+ const unsubscribes = [];
4599
+ const write = (value) => {
4600
+ if (!socket.destroyed) socket.write(JSON.stringify(value) + "\n");
4601
+ };
4602
+ socket.on("close", () => {
4603
+ for (const off of unsubscribes) off();
4604
+ });
4605
+ socket.on("error", () => {});
4606
+ const lines = createInterface({
4607
+ input: socket,
4608
+ crlfDelay: Infinity
4609
+ });
4610
+ let buffered = 0;
4611
+ socket.on("data", (chunk) => {
4612
+ buffered += chunk.length;
4613
+ if (buffered > 16777216) socket.destroy();
4614
+ });
4615
+ lines.on("line", (line) => {
4616
+ buffered = 0;
4617
+ if (!line.trim()) return;
4618
+ let request;
4619
+ try {
4620
+ request = JSON.parse(line);
4621
+ } catch {
4622
+ write({
4623
+ id: null,
4624
+ error: new CliError("bad_request", "Not JSON").toJSON()
4625
+ });
4626
+ return;
4627
+ }
4628
+ const id = typeof request.id === "number" || typeof request.id === "string" ? request.id : null;
4629
+ if (typeof request.method !== "string") {
4630
+ write({
4631
+ id,
4632
+ error: new CliError("bad_request", "method is required").toJSON()
4633
+ });
4634
+ return;
4635
+ }
4636
+ if (request.method === "events.subscribe") {
4637
+ const params = request.params ?? {};
4638
+ const since = typeof params.since === "number" ? params.since : ctx.hub.lastSeq;
4639
+ const queued = [];
4640
+ let replaying = true;
4641
+ unsubscribes.push(ctx.hub.onEvent((event) => {
4642
+ if (replaying) queued.push(event);
4643
+ else write({ event });
4644
+ }));
4645
+ write({
4646
+ id,
4647
+ result: {
4648
+ subscribed: true,
4649
+ lastSeq: ctx.hub.lastSeq
4650
+ }
4651
+ });
4652
+ let last = since;
4653
+ for (const event of ctx.hub.replay(since)) {
4654
+ write({ event });
4655
+ last = event.seq;
4656
+ }
4657
+ replaying = false;
4658
+ for (const event of queued) if (event.seq > last) write({ event });
4659
+ return;
4660
+ }
4661
+ callApi(ctx, request.method, request.params ?? {}).then((result) => write({
4662
+ id,
4663
+ result: result ?? null
4664
+ }), (error) => write({
4665
+ id,
4666
+ error: asCliError(error).toJSON()
4667
+ }));
4668
+ });
4669
+ }
4670
+ //#endregion
4671
+ export { BASELINE_NEWEST, MAX_LINE, keepServing, openHost, serve };
4672
+
4673
+ //# sourceMappingURL=host-CWt-jtqz.js.map