@paradow/ledge 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/{action-HUKZUHQ7.js → action-K42H5BDA.js} +5 -5
- package/dist/cli/{attach-ILMHKRUI.js → attach-KEE4A5YP.js} +28 -15
- package/dist/cli/{bridge-LFKUCKQP.js → bridge-X327AZY4.js} +2 -2
- package/dist/cli/{chunk-VGAPV4DC.js → chunk-25RVT5MR.js} +3 -103
- package/dist/cli/{chunk-Q6RMLRPI.js → chunk-3OJCWJAA.js} +2 -2
- package/dist/cli/chunk-4OYVM4MT.js +49 -0
- package/dist/cli/{chunk-IE52REEI.js → chunk-4TCSADFR.js} +5 -23
- package/dist/cli/{chunk-HOOHS3MX.js → chunk-53ISXX2R.js} +1 -1
- package/dist/cli/{chunk-6IYPRUQG.js → chunk-54KF5RBY.js} +1 -1
- package/dist/cli/{chunk-CXZETRPA.js → chunk-7NHQGP6J.js} +1 -1
- package/dist/cli/{chunk-U7J2AN3F.js → chunk-G7XPEVCG.js} +5 -5
- package/dist/cli/{chunk-2BLPGRLO.js → chunk-IWY7DSSO.js} +835 -1030
- package/dist/cli/{chunk-SUBKKCGA.js → chunk-KPDTYX7F.js} +3 -3
- package/dist/cli/chunk-RPH773TY.js +6 -0
- package/dist/cli/{chunk-YHZIHIGI.js → chunk-RX5CJ7YH.js} +3 -5
- package/dist/cli/{chunk-NLL2EQJ2.js → chunk-T547FLQN.js} +38 -4
- package/dist/cli/{chunk-K7UEKSB2.js → chunk-UJ2WBGLW.js} +2 -2
- package/dist/cli/{chunk-OPUIV6PR.js → chunk-ULOMKEB6.js} +4 -4
- package/dist/cli/{chunk-5JNJFBKZ.js → chunk-V4XKKL6K.js} +13 -40
- package/dist/cli/{chunk-EGXOBQZI.js → daemon-RO43S3S5.js} +4655 -2964
- package/dist/cli/{decide-3B6LGVNN.js → decide-KZFYL3HY.js} +5 -5
- package/dist/cli/{handover-IX7EFDIP.js → handover-BWNMI2RA.js} +5 -5
- package/dist/cli/{hook-G7UA6MB6.js → hook-ZLWXI5EQ.js} +21 -71
- package/dist/cli/index.js +18 -25
- package/dist/cli/launch-GCX7TXOH.js +28 -0
- package/dist/cli/{send-55RWY4LZ.js → send-SXZAPVRH.js} +5 -5
- package/dist/cli/{setup-K42IVMHS.js → setup-TJ32FMVJ.js} +4 -4
- package/dist/cli/{status-NT5ZUYM6.js → status-YDVRES4E.js} +3 -3
- package/dist/cli/{task-GABQ4SKY.js → task-TUSOVJK7.js} +5 -5
- package/dist/cli/{tui-AMW2XAH5.js → tui-TCVIRZCQ.js} +1411 -1277
- package/dist/cli/{uninstall-4IMMR55T.js → uninstall-QO7YMSLA.js} +4 -4
- package/dist/cli/{version-35XLTEMX.js → version-BAYSYWFP.js} +1 -1
- package/dist/cli/who-6AZYSP7T.js +12 -0
- package/dist/ledge-guide.md +26 -49
- package/package.json +2 -1
- package/dist/cli/chunk-5FKHISVK.js +0 -160
- package/dist/cli/chunk-M7364G3S.js +0 -6
- package/dist/cli/chunk-QQVWFOPU.js +0 -290
- package/dist/cli/daemon-UJTMG3D4.js +0 -2322
- package/dist/cli/launch-2GZIRF3A.js +0 -36
- package/dist/cli/team-IK2RYXW2.js +0 -205
- package/dist/cli/who-4FRIGYVT.js +0 -12
|
@@ -1,2322 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AttachmentStore,
|
|
3
|
-
fileJournal,
|
|
4
|
-
loginShellEnv,
|
|
5
|
-
quote,
|
|
6
|
-
reapOwner,
|
|
7
|
-
rpOf,
|
|
8
|
-
startPoller,
|
|
9
|
-
startServer,
|
|
10
|
-
tasksJournal,
|
|
11
|
-
teamsJournal
|
|
12
|
-
} from "./chunk-EGXOBQZI.js";
|
|
13
|
-
import "./chunk-OLLQBK6N.js";
|
|
14
|
-
import "./chunk-QQVWFOPU.js";
|
|
15
|
-
import {
|
|
16
|
-
QUESTION_TOOLS,
|
|
17
|
-
crossSession,
|
|
18
|
-
crossSessionNotices,
|
|
19
|
-
ledgeBody,
|
|
20
|
-
ledgeMessages,
|
|
21
|
-
ledgeNotice,
|
|
22
|
-
ledgePeer,
|
|
23
|
-
ledgeTask,
|
|
24
|
-
ledgeTeamNotice,
|
|
25
|
-
mailId,
|
|
26
|
-
readPeers,
|
|
27
|
-
reduce,
|
|
28
|
-
stripNote
|
|
29
|
-
} from "./chunk-VGAPV4DC.js";
|
|
30
|
-
import "./chunk-5FKHISVK.js";
|
|
31
|
-
import {
|
|
32
|
-
agentMessages
|
|
33
|
-
} from "./chunk-IE52REEI.js";
|
|
34
|
-
import {
|
|
35
|
-
DEFAULT_RELAY_URL,
|
|
36
|
-
DevicePollResponseSchema,
|
|
37
|
-
DeviceStartResponseSchema,
|
|
38
|
-
MobileConfigSchema,
|
|
39
|
-
PAIR_LABEL_MAX,
|
|
40
|
-
PushSendResultSchema,
|
|
41
|
-
RELAY_CLOSE_SIGNED_OUT,
|
|
42
|
-
RecentSchema,
|
|
43
|
-
RelayServerMessageSchema,
|
|
44
|
-
SealedFrameSchema,
|
|
45
|
-
SessionSchema,
|
|
46
|
-
backendOf
|
|
47
|
-
} from "./chunk-2BLPGRLO.js";
|
|
48
|
-
import {
|
|
49
|
-
LEDGE_VERSION
|
|
50
|
-
} from "./chunk-M7364G3S.js";
|
|
51
|
-
import {
|
|
52
|
-
SERVICE,
|
|
53
|
-
inDaemonService,
|
|
54
|
-
tmuxServerAtRisk
|
|
55
|
-
} from "./chunk-K7UEKSB2.js";
|
|
56
|
-
import {
|
|
57
|
-
realTmux,
|
|
58
|
-
runFile
|
|
59
|
-
} from "./chunk-NLL2EQJ2.js";
|
|
60
|
-
import {
|
|
61
|
-
log
|
|
62
|
-
} from "./chunk-6IYPRUQG.js";
|
|
63
|
-
import {
|
|
64
|
-
ensureLedgeDir,
|
|
65
|
-
paths
|
|
66
|
-
} from "./chunk-HOOHS3MX.js";
|
|
67
|
-
import "./chunk-U67V476Y.js";
|
|
68
|
-
|
|
69
|
-
// src/daemon/index.ts
|
|
70
|
-
import { writeFile as writeFile3, rm as rm2 } from "fs/promises";
|
|
71
|
-
import { join as join4 } from "path";
|
|
72
|
-
|
|
73
|
-
// src/daemon/store.ts
|
|
74
|
-
import { readFile, writeFile, rename, mkdir } from "fs/promises";
|
|
75
|
-
import { dirname } from "path";
|
|
76
|
-
var MAX_RECENTS = 12;
|
|
77
|
-
var ARCHIVE_MAX = 200;
|
|
78
|
-
var Store = class {
|
|
79
|
-
sessions = /* @__PURE__ */ new Map();
|
|
80
|
-
recentList = [];
|
|
81
|
-
listeners = /* @__PURE__ */ new Set();
|
|
82
|
-
eventListeners = /* @__PURE__ */ new Set();
|
|
83
|
-
applyListeners = /* @__PURE__ */ new Set();
|
|
84
|
-
timer = null;
|
|
85
|
-
saving = Promise.resolve();
|
|
86
|
-
journalPath;
|
|
87
|
-
/** Set by an unclean load and saved with the journal: a later clean-looking load must not be trusted for cleanups. */
|
|
88
|
-
incomplete = false;
|
|
89
|
-
constructor(opts = {}) {
|
|
90
|
-
this.journalPath = opts.journalPath ?? paths().journal;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Reads the journal. `clean` is false when it was missing, unreadable, or an entry was dropped by the schema, or when
|
|
94
|
-
* an earlier load was so (the mark is saved with the journal): what the store holds may then not be every session,
|
|
95
|
-
* and nothing may be cleaned up on the strength of it (the threads of the sessions it misses).
|
|
96
|
-
*/
|
|
97
|
-
async load() {
|
|
98
|
-
let raw;
|
|
99
|
-
try {
|
|
100
|
-
raw = await readFile(this.journalPath, "utf8");
|
|
101
|
-
} catch {
|
|
102
|
-
return { clean: false, reason: "no journal" };
|
|
103
|
-
}
|
|
104
|
-
const bad = async (reason) => {
|
|
105
|
-
this.incomplete = true;
|
|
106
|
-
await writeFile(`${this.journalPath}.incomplete`, raw, { flag: "wx" }).catch(() => void 0);
|
|
107
|
-
return { clean: false, reason };
|
|
108
|
-
};
|
|
109
|
-
if (!raw.trim()) return bad("empty journal");
|
|
110
|
-
let parsed;
|
|
111
|
-
try {
|
|
112
|
-
parsed = JSON.parse(raw);
|
|
113
|
-
} catch (e) {
|
|
114
|
-
log(`journal unreadable, starting empty: ${e.message}`);
|
|
115
|
-
return bad("unreadable journal");
|
|
116
|
-
}
|
|
117
|
-
const list = parsed?.sessions;
|
|
118
|
-
if (!Array.isArray(list)) {
|
|
119
|
-
log("journal unreadable, starting empty: no sessions array");
|
|
120
|
-
return bad("no sessions array");
|
|
121
|
-
}
|
|
122
|
-
let dropped = 0;
|
|
123
|
-
for (const item of list) {
|
|
124
|
-
const r = SessionSchema.safeParse(item);
|
|
125
|
-
if (r.success) this.sessions.set(r.data.id, r.data);
|
|
126
|
-
else {
|
|
127
|
-
dropped++;
|
|
128
|
-
log(`journal entry dropped: ${r.error.issues[0]?.message ?? "invalid"}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const recents = parsed?.recents;
|
|
132
|
-
if (Array.isArray(recents)) {
|
|
133
|
-
for (const item of recents) {
|
|
134
|
-
const r = RecentSchema.safeParse(item);
|
|
135
|
-
if (r.success) this.recentList.push(r.data);
|
|
136
|
-
}
|
|
137
|
-
this.recentList = this.recentList.slice(0, MAX_RECENTS);
|
|
138
|
-
}
|
|
139
|
-
if (dropped) return bad(`${dropped} journal ${dropped === 1 ? "entry" : "entries"} dropped`);
|
|
140
|
-
if (parsed.incomplete === true) {
|
|
141
|
-
this.incomplete = true;
|
|
142
|
-
return { clean: false, reason: "an earlier load was incomplete" };
|
|
143
|
-
}
|
|
144
|
-
return { clean: true };
|
|
145
|
-
}
|
|
146
|
-
all() {
|
|
147
|
-
return [...this.sessions.values()];
|
|
148
|
-
}
|
|
149
|
-
get(id) {
|
|
150
|
-
return this.sessions.get(id);
|
|
151
|
-
}
|
|
152
|
-
/** The folders of past sessions, most recent first. */
|
|
153
|
-
recents() {
|
|
154
|
-
return [...this.recentList];
|
|
155
|
-
}
|
|
156
|
-
upsert(session) {
|
|
157
|
-
this.sessions.set(session.id, session);
|
|
158
|
-
this.emit({ type: "updated", session });
|
|
159
|
-
}
|
|
160
|
-
/** Records a session's folder as the most recent one (one entry per folder, `MAX_RECENTS` kept). */
|
|
161
|
-
touchRecent(cwd, project, at) {
|
|
162
|
-
this.recentList = [{ cwd, project, lastAt: at }, ...this.recentList.filter((r) => r.cwd !== cwd)].slice(0, MAX_RECENTS);
|
|
163
|
-
this.emit({ type: "recents", recents: this.recents() });
|
|
164
|
-
}
|
|
165
|
-
/** Facts no event carries (the session's repository): the changed fields only, nothing emitted when they are equal. */
|
|
166
|
-
patch(id, fields) {
|
|
167
|
-
const cur = this.sessions.get(id);
|
|
168
|
-
if (!cur) return;
|
|
169
|
-
if (Object.keys(fields).every((k) => cur[k] === fields[k])) return;
|
|
170
|
-
this.upsert({ ...cur, ...fields });
|
|
171
|
-
}
|
|
172
|
-
/** Sets the archive date (kept if already set), then drops the oldest archives past `ARCHIVE_MAX`. */
|
|
173
|
-
archive(id, at) {
|
|
174
|
-
const cur = this.sessions.get(id);
|
|
175
|
-
if (!cur) return void 0;
|
|
176
|
-
const next = cur.archivedAt === void 0 ? { ...cur, archivedAt: at } : cur;
|
|
177
|
-
if (next !== cur) this.upsert(next);
|
|
178
|
-
const archived = this.all().filter((s) => s.archivedAt !== void 0).sort((a, b) => a.archivedAt.localeCompare(b.archivedAt));
|
|
179
|
-
for (const old of archived.slice(0, Math.max(0, archived.length - ARCHIVE_MAX))) this.remove(old.id);
|
|
180
|
-
return this.sessions.get(id);
|
|
181
|
-
}
|
|
182
|
-
/** Takes the session out of the archive; `at` (a restore by the user) restarts the retirement clock from then. */
|
|
183
|
-
unarchive(id, at) {
|
|
184
|
-
const cur = this.sessions.get(id);
|
|
185
|
-
if (!cur) return void 0;
|
|
186
|
-
if (cur.archivedAt === void 0) return cur;
|
|
187
|
-
const { archivedAt: _gone, ...rest } = cur;
|
|
188
|
-
const next = at !== void 0 ? { ...rest, restoredAt: at } : rest;
|
|
189
|
-
this.upsert(next);
|
|
190
|
-
return next;
|
|
191
|
-
}
|
|
192
|
-
/** Every event applied to a known session, before the reducer decides whether it changes anything. */
|
|
193
|
-
onEvent(listener) {
|
|
194
|
-
this.eventListeners.add(listener);
|
|
195
|
-
return () => this.eventListeners.delete(listener);
|
|
196
|
-
}
|
|
197
|
-
/** Every `apply` that changed a session, with the session as it was and as it is. A throwing listener cannot break `apply`. */
|
|
198
|
-
onApply(listener) {
|
|
199
|
-
this.applyListeners.add(listener);
|
|
200
|
-
return () => this.applyListeners.delete(listener);
|
|
201
|
-
}
|
|
202
|
-
apply(id, event) {
|
|
203
|
-
if (!this.sessions.has(id)) return void 0;
|
|
204
|
-
for (const l of this.eventListeners) {
|
|
205
|
-
try {
|
|
206
|
-
l(id, event);
|
|
207
|
-
} catch (e) {
|
|
208
|
-
log(`event listener failed: ${e.message}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
const cur = this.sessions.get(id);
|
|
212
|
-
if (!cur) return void 0;
|
|
213
|
-
const next = reduce(cur, event);
|
|
214
|
-
if (next === cur) return cur;
|
|
215
|
-
this.sessions.set(id, next);
|
|
216
|
-
this.emit({ type: "updated", session: next });
|
|
217
|
-
for (const l of this.applyListeners) {
|
|
218
|
-
try {
|
|
219
|
-
l(cur, next);
|
|
220
|
-
} catch (e) {
|
|
221
|
-
log(`apply listener failed: ${e.message}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return next;
|
|
225
|
-
}
|
|
226
|
-
remove(id) {
|
|
227
|
-
if (!this.sessions.delete(id)) return;
|
|
228
|
-
this.emit({ type: "removed", id });
|
|
229
|
-
}
|
|
230
|
-
onChange(listener) {
|
|
231
|
-
this.listeners.add(listener);
|
|
232
|
-
return () => this.listeners.delete(listener);
|
|
233
|
-
}
|
|
234
|
-
save() {
|
|
235
|
-
if (this.timer) {
|
|
236
|
-
clearTimeout(this.timer);
|
|
237
|
-
this.timer = null;
|
|
238
|
-
}
|
|
239
|
-
const run = this.saving.then(() => this.write());
|
|
240
|
-
this.saving = run.catch(() => void 0);
|
|
241
|
-
return run;
|
|
242
|
-
}
|
|
243
|
-
async write() {
|
|
244
|
-
await mkdir(dirname(this.journalPath), { recursive: true });
|
|
245
|
-
const tmp = `${this.journalPath}.tmp`;
|
|
246
|
-
await writeFile(tmp, JSON.stringify({ savedAt: (/* @__PURE__ */ new Date()).toISOString(), ...this.incomplete ? { incomplete: true } : {}, sessions: this.all(), recents: this.recentList }, null, 2));
|
|
247
|
-
await rename(tmp, this.journalPath);
|
|
248
|
-
}
|
|
249
|
-
emit(c) {
|
|
250
|
-
for (const l of this.listeners) {
|
|
251
|
-
try {
|
|
252
|
-
l(c);
|
|
253
|
-
} catch (e) {
|
|
254
|
-
log(`listener failed: ${e.message}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (this.timer) clearTimeout(this.timer);
|
|
258
|
-
this.timer = setTimeout(() => {
|
|
259
|
-
this.save().catch((e) => log(`journal save failed: ${e.message}`));
|
|
260
|
-
}, 200);
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
// src/daemon/history.ts
|
|
265
|
-
import { appendFile, mkdir as mkdir2, readdir, readFile as readFile2, rename as rename2, rm, writeFile as writeFile2 } from "fs/promises";
|
|
266
|
-
import { join } from "path";
|
|
267
|
-
|
|
268
|
-
// src/daemon/files.ts
|
|
269
|
-
var DIFF_LINES = 80;
|
|
270
|
-
var str = (v) => typeof v === "string" ? v : void 0;
|
|
271
|
-
var cap = (lines) => lines.length > DIFF_LINES ? [...lines.slice(0, DIFF_LINES), "\u2026"].join("\n") : lines.join("\n");
|
|
272
|
-
var edit = (oldS, newS) => [...oldS.split("\n").map((l) => `- ${l}`), ...newS.split("\n").map((l) => `+ ${l}`)];
|
|
273
|
-
function fromPatch(patch) {
|
|
274
|
-
const out = [];
|
|
275
|
-
let cur = null;
|
|
276
|
-
const flush = () => {
|
|
277
|
-
if (cur) {
|
|
278
|
-
if (cur.lines.length) cur.file.diff = cap(cur.lines);
|
|
279
|
-
out.push(cur.file);
|
|
280
|
-
cur = null;
|
|
281
|
-
}
|
|
282
|
-
};
|
|
283
|
-
for (const line of patch.split("\n")) {
|
|
284
|
-
const m = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
|
|
285
|
-
if (m) {
|
|
286
|
-
flush();
|
|
287
|
-
cur = { file: { path: m[2].trim(), change: m[1] === "Add" ? "add" : m[1] === "Update" ? "edit" : "delete" }, lines: [] };
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
const mv = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
291
|
-
if (mv) {
|
|
292
|
-
if (cur && cur.file.change === "edit") {
|
|
293
|
-
cur.file.from = cur.file.path;
|
|
294
|
-
cur.file.path = mv[1].trim();
|
|
295
|
-
}
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
if (/^\*\*\* (Begin|End) Patch/.test(line)) continue;
|
|
299
|
-
if (cur && cur.file.change !== "delete") cur.lines.push(line);
|
|
300
|
-
}
|
|
301
|
-
flush();
|
|
302
|
-
return out;
|
|
303
|
-
}
|
|
304
|
-
function filesOf(tool, input) {
|
|
305
|
-
if (!tool || !input) return [];
|
|
306
|
-
const path = str(input.file_path) ?? str(input.notebook_path);
|
|
307
|
-
switch (tool) {
|
|
308
|
-
case "Write":
|
|
309
|
-
return path ? [{ path, change: "write", ...str(input.content) !== void 0 ? { diff: cap(str(input.content).split("\n").map((l) => `+ ${l}`)) } : {} }] : [];
|
|
310
|
-
case "Edit":
|
|
311
|
-
return path ? [{ path, change: "edit", diff: cap(edit(str(input.old_string) ?? "", str(input.new_string) ?? "")) }] : [];
|
|
312
|
-
case "MultiEdit": {
|
|
313
|
-
const edits = Array.isArray(input.edits) ? input.edits : [];
|
|
314
|
-
return path ? [{ path, change: "edit", diff: cap(edits.flatMap((e) => edit(str(e.old_string) ?? "", str(e.new_string) ?? ""))) }] : [];
|
|
315
|
-
}
|
|
316
|
-
case "NotebookEdit":
|
|
317
|
-
return path ? [{ path, change: "edit" }] : [];
|
|
318
|
-
// OMP (lower case): `write` names the path and the content; a finished `edit` names the path and the diff OMP reports (`- old` / `+ new`, its line numbers dropped).
|
|
319
|
-
case "write": {
|
|
320
|
-
const p = str(input.path);
|
|
321
|
-
return p && path === void 0 ? [{ path: p, change: "write", ...str(input.content) !== void 0 ? { diff: cap(str(input.content).split("\n").map((l) => `+ ${l}`)) } : {} }] : [];
|
|
322
|
-
}
|
|
323
|
-
case "edit": {
|
|
324
|
-
const p = str(input.path);
|
|
325
|
-
const diff = str(input.diff);
|
|
326
|
-
if (!p || path !== void 0) return [];
|
|
327
|
-
const lines = (diff ?? "").split("\n").filter((l) => l[0] === "+" || l[0] === "-").map((l) => `${l[0]} ${l.slice(1).replace(/^\d+\|/, "")}`);
|
|
328
|
-
return [{ path: p, change: "edit", ...lines.length ? { diff: cap(lines) } : {} }];
|
|
329
|
-
}
|
|
330
|
-
case "apply_patch":
|
|
331
|
-
return fromPatch(str(input.patch) ?? str(input.input) ?? "");
|
|
332
|
-
case "shell":
|
|
333
|
-
case "Bash": {
|
|
334
|
-
const cmd = Array.isArray(input.command) ? input.command.map(String) : [];
|
|
335
|
-
return cmd[0] === "apply_patch" && cmd[1] ? fromPatch(cmd[1]) : [];
|
|
336
|
-
}
|
|
337
|
-
default:
|
|
338
|
-
return [];
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// src/daemon/history.ts
|
|
343
|
-
var MAX_ITEMS = 400;
|
|
344
|
-
var SYNC_MS = 1e3;
|
|
345
|
-
function inputText(input) {
|
|
346
|
-
if (!input) return void 0;
|
|
347
|
-
const cmd = input.command;
|
|
348
|
-
if (typeof cmd === "string") return cmd;
|
|
349
|
-
if (Array.isArray(cmd)) return cmd.map(String).join(" ");
|
|
350
|
-
const text = JSON.stringify(input, null, 2);
|
|
351
|
-
return text === "{}" ? void 0 : text;
|
|
352
|
-
}
|
|
353
|
-
var HANDBACK = /^<agent-message from="([^"]+)">\n?([\s\S]*?)(?:<\/agent-message>\s*)?$/;
|
|
354
|
-
function handBack(text) {
|
|
355
|
-
const m = HANDBACK.exec(text.trimStart());
|
|
356
|
-
if (!m) return void 0;
|
|
357
|
-
const lines = m[2].split("\n");
|
|
358
|
-
while (lines.length && (!lines[0].trim() || /^\[[^\]]*\]/.test(lines[0]))) lines.shift();
|
|
359
|
-
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
|
|
360
|
-
return { helperId: m[1], report: lines.map((l) => l.slice(Number.isFinite(indent) ? indent : 0)).join("\n").trim() };
|
|
361
|
-
}
|
|
362
|
-
var History = class {
|
|
363
|
-
threads = /* @__PURE__ */ new Map();
|
|
364
|
-
turns = /* @__PURE__ */ new Map();
|
|
365
|
-
synced = /* @__PURE__ */ new Map();
|
|
366
|
-
listeners = /* @__PURE__ */ new Set();
|
|
367
|
-
writes = Promise.resolve();
|
|
368
|
-
/** The rids of the cards Ledge's mailbox recorded: their result is the mailbox's, not the tool call's (their `via`, read back at load). */
|
|
369
|
-
mailed = /* @__PURE__ */ new Set();
|
|
370
|
-
dir;
|
|
371
|
-
/** The Ledge session a SendMessage address or a sender's name is, when it is one (peers.ts). */
|
|
372
|
-
peerOf;
|
|
373
|
-
/** The stored image an attachment id is, when the store still holds it. */
|
|
374
|
-
refOf;
|
|
375
|
-
constructor(opts = {}) {
|
|
376
|
-
this.dir = opts.dir ?? paths().history;
|
|
377
|
-
this.peerOf = opts.peerOf ?? (() => void 0);
|
|
378
|
-
this.refOf = opts.refOf ?? (() => null);
|
|
379
|
-
}
|
|
380
|
-
items(id) {
|
|
381
|
-
return this.threads.get(id) ?? [];
|
|
382
|
-
}
|
|
383
|
-
onItem(l) {
|
|
384
|
-
this.listeners.add(l);
|
|
385
|
-
return () => this.listeners.delete(l);
|
|
386
|
-
}
|
|
387
|
-
/** Waits for the journal writes issued so far (tests, shutdown). */
|
|
388
|
-
flush() {
|
|
389
|
-
return this.writes;
|
|
390
|
-
}
|
|
391
|
-
/** Reads every `<id>.jsonl`: one item per line, the last line of an id wins, a bad line is skipped. A file that cannot
|
|
392
|
-
* be read is logged and left alone: the thread is a display, it must not keep the daemon from starting. A journal
|
|
393
|
-
* holding more lines than items (updates, evicted items) is rewritten with the items kept, through the write chain.
|
|
394
|
-
* The mailbox's queue did not survive the restart: its cards still sending failed. */
|
|
395
|
-
async load() {
|
|
396
|
-
await mkdir2(this.dir, { recursive: true });
|
|
397
|
-
for (const entry of await readdir(this.dir, { withFileTypes: true })) {
|
|
398
|
-
const name = entry.name;
|
|
399
|
-
if (!entry.isFile() || !name.endsWith(".jsonl")) continue;
|
|
400
|
-
const id = name.slice(0, -".jsonl".length);
|
|
401
|
-
let text;
|
|
402
|
-
try {
|
|
403
|
-
text = await readFile2(join(this.dir, name), "utf8");
|
|
404
|
-
} catch (e) {
|
|
405
|
-
log(`history not read for ${id}: ${e.message}`);
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
const byId = /* @__PURE__ */ new Map();
|
|
409
|
-
let lines = 0;
|
|
410
|
-
for (const line of text.split("\n")) {
|
|
411
|
-
if (!line.trim()) continue;
|
|
412
|
-
lines++;
|
|
413
|
-
try {
|
|
414
|
-
const it = JSON.parse(line);
|
|
415
|
-
if (typeof it?.id === "string") byId.set(it.id, it);
|
|
416
|
-
} catch {
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
const items = [...byId.values()].slice(-MAX_ITEMS);
|
|
420
|
-
let lost = false;
|
|
421
|
-
items.forEach((it, i) => {
|
|
422
|
-
if (it.kind !== "sent" || it.via !== "ledge") return;
|
|
423
|
-
this.mailed.add(it.id);
|
|
424
|
-
if (it.status === "sending") {
|
|
425
|
-
items[i] = { ...it, status: "failed" };
|
|
426
|
-
lost = true;
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
this.threads.set(id, items);
|
|
430
|
-
this.turns.set(id, items.reduce((n, it) => typeof it.turn === "number" ? Math.max(n, it.turn) : n, 0));
|
|
431
|
-
if (lines > items.length || lost) this.compact(id, items);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
/** The ids of the images the journaled prompts refer to, read from every `<dir>/*.jsonl` once the pending writes landed. */
|
|
435
|
-
async attachmentIds() {
|
|
436
|
-
await this.writes;
|
|
437
|
-
const ids = /* @__PURE__ */ new Set();
|
|
438
|
-
let names;
|
|
439
|
-
try {
|
|
440
|
-
names = await readdir(this.dir);
|
|
441
|
-
} catch {
|
|
442
|
-
return ids;
|
|
443
|
-
}
|
|
444
|
-
for (const name of names) {
|
|
445
|
-
if (!name.endsWith(".jsonl")) continue;
|
|
446
|
-
let text;
|
|
447
|
-
try {
|
|
448
|
-
text = await readFile2(join(this.dir, name), "utf8");
|
|
449
|
-
} catch (e) {
|
|
450
|
-
log(`history not read for ${name}: ${e.message}`);
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
for (const line of text.split("\n")) {
|
|
454
|
-
if (!line.includes('"images"')) continue;
|
|
455
|
-
try {
|
|
456
|
-
for (const img of JSON.parse(line).images ?? []) if (typeof img?.id === "string") ids.add(img.id);
|
|
457
|
-
} catch {
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return ids;
|
|
462
|
-
}
|
|
463
|
-
/** The ids of the threads held, loaded or recorded. */
|
|
464
|
-
ids() {
|
|
465
|
-
return [...this.threads.keys()];
|
|
466
|
-
}
|
|
467
|
-
forget(id) {
|
|
468
|
-
this.threads.delete(id);
|
|
469
|
-
this.turns.delete(id);
|
|
470
|
-
this.writes = this.writes.then(() => rm(join(this.dir, `${id}.jsonl`), { force: true })).catch((e) => log(`history not removed for ${id}: ${e.message}`));
|
|
471
|
-
}
|
|
472
|
-
record(id, ev) {
|
|
473
|
-
const turn = this.turns.get(id) ?? 0;
|
|
474
|
-
const common = { at: ev.at, turn, ...ev.helperId ? { helperId: ev.helperId } : {} };
|
|
475
|
-
if (ev.helperId && ev.task) {
|
|
476
|
-
const helper = this.items(id).find((i) => i.id === ev.helperId);
|
|
477
|
-
if (helper?.kind === "helper" && !helper.task) this.put(id, { ...helper, task: ev.task });
|
|
478
|
-
}
|
|
479
|
-
switch (ev.name) {
|
|
480
|
-
case "UserPromptSubmit": {
|
|
481
|
-
const images = (ev.attachments ?? []).flatMap((a) => this.refOf(a) ?? []);
|
|
482
|
-
if (!ev.text && images.length === 0) return void 0;
|
|
483
|
-
const text = ev.text ?? "";
|
|
484
|
-
const back = handBack(text);
|
|
485
|
-
if (back) {
|
|
486
|
-
const helper = this.items(id).find((i) => i.id === back.helperId);
|
|
487
|
-
if (helper?.kind === "helper" && helper.state !== "exited") this.put(id, { ...helper, state: "exited" });
|
|
488
|
-
if (helper?.kind === "helper") this.settle(id, (i) => i.helperId === back.helperId);
|
|
489
|
-
return back.report ? this.put(id, { kind: "reply", id: `report-${back.helperId}`, at: ev.at, turn: helper?.turn ?? turn, helperId: back.helperId, text: back.report }) : void 0;
|
|
490
|
-
}
|
|
491
|
-
const messages = crossSession(text);
|
|
492
|
-
if (messages) {
|
|
493
|
-
this.turns.set(id, turn + 1);
|
|
494
|
-
let last;
|
|
495
|
-
messages.forEach((m, i) => {
|
|
496
|
-
const { sender, text: text2 } = stripNote(m.text);
|
|
497
|
-
last = this.receive(id, { kind: "received", id: `recv-${turn + 1}-${i}`, at: ev.at, turn: turn + 1, from: sender ?? m.name ?? m.from, text: text2 }, { ...m, ...sender ? { sender } : {} });
|
|
498
|
-
});
|
|
499
|
-
return last;
|
|
500
|
-
}
|
|
501
|
-
const ledge = ledgeMessages(text);
|
|
502
|
-
if (ledge) {
|
|
503
|
-
this.turns.set(id, turn + 1);
|
|
504
|
-
let last;
|
|
505
|
-
ledge.forEach((m, i) => {
|
|
506
|
-
last = this.receive(id, { kind: "received", id: `recv-${turn + 1}-${i}`, at: ev.at, turn: turn + 1, from: m.from, text: m.text }, m);
|
|
507
|
-
});
|
|
508
|
-
return last;
|
|
509
|
-
}
|
|
510
|
-
const notices = crossSessionNotices(text);
|
|
511
|
-
if (notices) {
|
|
512
|
-
this.turns.set(id, turn + 1);
|
|
513
|
-
let last;
|
|
514
|
-
notices.forEach((n, i) => {
|
|
515
|
-
last = this.put(id, this.noticeOf(n, { id: `notice-${turn + 1}-${i}`, at: ev.at, turn: turn + 1 }));
|
|
516
|
-
});
|
|
517
|
-
return last;
|
|
518
|
-
}
|
|
519
|
-
const failed = ledgeNotice(text);
|
|
520
|
-
if (failed) {
|
|
521
|
-
this.turns.set(id, turn + 1);
|
|
522
|
-
const peer = this.peerOf(failed.to);
|
|
523
|
-
return this.put(id, {
|
|
524
|
-
kind: "notice",
|
|
525
|
-
id: `notice-${turn + 1}-0`,
|
|
526
|
-
at: ev.at,
|
|
527
|
-
turn: turn + 1,
|
|
528
|
-
notice: "delivery",
|
|
529
|
-
status: "refused",
|
|
530
|
-
count: 1,
|
|
531
|
-
recipients: [failed.to],
|
|
532
|
-
name: failed.to,
|
|
533
|
-
...peer ? { peer } : {},
|
|
534
|
-
...failed.reason ? { reason: failed.reason } : {},
|
|
535
|
-
text: failed.text
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
const task = ledgeTask(text);
|
|
539
|
-
if (task) {
|
|
540
|
-
this.turns.set(id, turn + 1);
|
|
541
|
-
const peer = task.from ? this.peerOf(task.from) : void 0;
|
|
542
|
-
return this.put(id, {
|
|
543
|
-
kind: "notice",
|
|
544
|
-
id: `notice-${turn + 1}-0`,
|
|
545
|
-
at: ev.at,
|
|
546
|
-
turn: turn + 1,
|
|
547
|
-
notice: "other",
|
|
548
|
-
task: task.id,
|
|
549
|
-
...task.from ? { name: task.from } : {},
|
|
550
|
-
...peer ? { peer } : {},
|
|
551
|
-
summary: task.text.replaceAll(" \u23CE ", "\n"),
|
|
552
|
-
text: task.text
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
const joined = ledgeTeamNotice(text);
|
|
556
|
-
if (joined) {
|
|
557
|
-
this.turns.set(id, turn + 1);
|
|
558
|
-
return this.put(id, { kind: "notice", id: `notice-${turn + 1}-0`, at: ev.at, turn: turn + 1, notice: "other", team: joined.team, summary: joined.text.replaceAll(" \u23CE ", "\n"), text: joined.text });
|
|
559
|
-
}
|
|
560
|
-
this.turns.set(id, turn + 1);
|
|
561
|
-
return this.put(id, { kind: "prompt", id: `prompt-${turn + 1}`, at: ev.at, turn: turn + 1, text, ...images.length ? { images } : {} });
|
|
562
|
-
}
|
|
563
|
-
case "PreToolUse": {
|
|
564
|
-
if (!ev.tool) return void 0;
|
|
565
|
-
const existing = ev.toolUseId && this.items(id).find((i) => i.id === ev.toolUseId);
|
|
566
|
-
if (existing) return existing;
|
|
567
|
-
if (ev.tool === "SendMessage") return this.put(id, this.sentOf(ev, common, "sending"));
|
|
568
|
-
const input = inputText(ev.toolInput);
|
|
569
|
-
return this.put(id, { kind: "tool", id: ev.toolUseId ?? `tool-${ev.at}-${this.items(id).length}`, ...common, tool: ev.tool, label: ev.text ?? ev.tool, ...input ? { input } : {}, status: "running", files: filesOf(ev.tool, ev.toolInput) });
|
|
570
|
-
}
|
|
571
|
-
case "PostToolUse":
|
|
572
|
-
case "PostToolUseFailure": {
|
|
573
|
-
if (!ev.tool) return void 0;
|
|
574
|
-
const failed = ev.name === "PostToolUseFailure";
|
|
575
|
-
if (ev.tool === "SendMessage" && ev.toolUseId && this.mailed.has(ev.toolUseId)) return this.items(id).find((i) => i.id === ev.toolUseId);
|
|
576
|
-
if (ev.tool === "SendMessage") {
|
|
577
|
-
const sent = this.items(id).find((i) => i.kind === "sent" && i.id === ev.toolUseId);
|
|
578
|
-
if (sent?.status === "read") return sent;
|
|
579
|
-
return this.put(id, sent ? { ...sent, status: failed ? "failed" : "delivered" } : this.sentOf(ev, common, failed ? "failed" : "delivered"));
|
|
580
|
-
}
|
|
581
|
-
const row = this.callFor(id, ev);
|
|
582
|
-
const late = row && row.files.length === 0 && ev.toolInput ? filesOf(ev.tool, ev.toolInput) : [];
|
|
583
|
-
const plan = row && ev.tool === "todo" && ev.toolInput ? inputText(ev.toolInput) : void 0;
|
|
584
|
-
const base = row ? { ...row, ...late.length ? { files: late } : {}, ...plan ? { input: plan } : {} } : { kind: "tool", id: ev.toolUseId ?? `tool-${ev.at}-${this.items(id).length}`, ...common, tool: ev.tool, label: ev.text ?? ev.tool, status: "running", files: filesOf(ev.tool, ev.toolInput) };
|
|
585
|
-
const error = failed ? ev.error ?? (ev.interrupt ? "interrupted" : "failed") : void 0;
|
|
586
|
-
return this.put(id, { ...base, status: failed ? "failed" : "success", ...ev.output !== void 0 ? { output: ev.output } : {}, ...error ? { error } : {}, ...ev.durationMs !== void 0 ? { durationMs: ev.durationMs } : {} });
|
|
587
|
-
}
|
|
588
|
-
case "PermissionRequest": {
|
|
589
|
-
if (!ev.tool || !ev.text) return void 0;
|
|
590
|
-
if (QUESTION_TOOLS.has(ev.tool)) return void 0;
|
|
591
|
-
const last = [...this.items(id)].reverse().find((i) => i.kind === "permission");
|
|
592
|
-
if (last && last.kind === "permission" && last.decision === void 0 && last.text === ev.text) return last;
|
|
593
|
-
return this.put(id, { kind: "permission", id: `perm-${ev.at}-${this.items(id).length}`, ...common, tool: ev.tool, text: ev.text });
|
|
594
|
-
}
|
|
595
|
-
case "Decided": {
|
|
596
|
-
if (!ev.decision) {
|
|
597
|
-
if (!ev.byClose) return void 0;
|
|
598
|
-
const q = [...this.items(id)].reverse().find((i) => i.kind === "tool" && QUESTION_TOOLS.has(i.tool) && i.status === "running" && !i.helperId);
|
|
599
|
-
return q ? this.put(id, { ...q, status: "unknown", byClose: true }) : void 0;
|
|
600
|
-
}
|
|
601
|
-
const perm = [...this.items(id)].reverse().find((i) => i.kind === "permission" && i.decision === void 0);
|
|
602
|
-
return perm && perm.kind === "permission" ? this.put(id, { ...perm, decision: ev.decision, ...ev.byClose ? { byClose: true } : {} }) : void 0;
|
|
603
|
-
}
|
|
604
|
-
case "AgentMessage": {
|
|
605
|
-
if (!ev.messageId || !ev.text) return void 0;
|
|
606
|
-
const itemId = `msg-${ev.messageId}`;
|
|
607
|
-
const prev = this.items(id).find((i) => i.id === itemId);
|
|
608
|
-
return this.put(id, { kind: "reply", id: itemId, at: prev?.at ?? ev.at, turn: prev?.turn ?? turn, text: ev.text, ...ev.helperId ? { helperId: ev.helperId } : {} });
|
|
609
|
-
}
|
|
610
|
-
case "Stop": {
|
|
611
|
-
if (!ev.helperId) this.settle(id, (i) => i.turn === turn && !i.helperId);
|
|
612
|
-
if (ev.streamed) return void 0;
|
|
613
|
-
return ev.text ? this.put(id, { kind: "reply", id: `reply-${turn}`, at: ev.at, turn, text: ev.text }) : void 0;
|
|
614
|
-
}
|
|
615
|
-
case "SubagentStart": {
|
|
616
|
-
if (!ev.helperId) return void 0;
|
|
617
|
-
const task = ev.text ?? ev.task ?? "";
|
|
618
|
-
const prev = this.items(id).find((i) => i.id === ev.helperId);
|
|
619
|
-
if (prev) return prev;
|
|
620
|
-
return this.put(id, { kind: "helper", id: ev.helperId, at: ev.at, turn, task, ...ev.tool ? { agentType: ev.tool } : {}, state: "working" });
|
|
621
|
-
}
|
|
622
|
-
case "SubagentStop": {
|
|
623
|
-
if (!ev.helperId) return void 0;
|
|
624
|
-
const prev = this.items(id).find((i) => i.id === ev.helperId);
|
|
625
|
-
if (!prev && !ev.transcriptPath) return void 0;
|
|
626
|
-
const row = prev && prev.kind === "helper" ? prev : { kind: "helper", id: ev.helperId, at: ev.at, turn, task: ev.task ?? "", ...ev.tool ? { agentType: ev.tool } : {}, state: "working" };
|
|
627
|
-
this.put(id, { ...row, state: "exited" });
|
|
628
|
-
this.settle(id, (i) => i.helperId === ev.helperId);
|
|
629
|
-
return ev.text ? this.put(id, { kind: "reply", id: `report-${ev.helperId}`, at: ev.at, turn, helperId: ev.helperId, text: ev.text }) : void 0;
|
|
630
|
-
}
|
|
631
|
-
default:
|
|
632
|
-
return void 0;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
/** The row a result belongs to: by id, else the last running call of that tool in the same thread (Cursor gives no id). */
|
|
636
|
-
/** Inserts the agent's messages the transcript holds and the thread lacks, from the current turn's prompt on (or the
|
|
637
|
-
* helper's start). At most once a second per thread unless forced (end of a turn). */
|
|
638
|
-
sync(id, path, opts = {}) {
|
|
639
|
-
const key = `${id}/${opts.helperId ?? ""}`;
|
|
640
|
-
const now = opts.now ?? Date.now();
|
|
641
|
-
if (!opts.force && now - (this.synced.get(key) ?? 0) < SYNC_MS) return [];
|
|
642
|
-
this.synced.set(key, now);
|
|
643
|
-
const items = this.items(id);
|
|
644
|
-
const since = opts.helperId ? items.find((i) => i.id === opts.helperId)?.at : [...items].reverse().find((i) => i.kind === "prompt" || i.kind === "received" || i.kind === "notice" && !i.outgoing)?.at;
|
|
645
|
-
if (!since) return [];
|
|
646
|
-
const have = new Set(items.map((i) => i.id));
|
|
647
|
-
const turn = this.turns.get(id) ?? 0;
|
|
648
|
-
const added = [];
|
|
649
|
-
for (const m of agentMessages(path, { sidechain: !!opts.helperId })) {
|
|
650
|
-
if (m.at < since || have.has(`msg-${m.id}`)) continue;
|
|
651
|
-
added.push(this.put(id, { kind: "message", id: `msg-${m.id}`, at: m.at, turn, text: m.text, ...opts.helperId ? { helperId: opts.helperId } : {} }));
|
|
652
|
-
}
|
|
653
|
-
return added;
|
|
654
|
-
}
|
|
655
|
-
/** The mailbox has a message to send: its card, `sending`. A card of that id already (Claude's own PreToolUse) stays, marked as the mailbox's. */
|
|
656
|
-
recordSent(senderId, m) {
|
|
657
|
-
this.mailed.add(m.rid);
|
|
658
|
-
const existing = this.items(senderId).find((i) => i.kind === "sent" && i.id === m.rid);
|
|
659
|
-
if (existing) return this.put(senderId, { ...existing, ...m.peer ? { peer: m.peer } : {}, via: "ledge" });
|
|
660
|
-
return this.put(senderId, { kind: "sent", id: m.rid, at: m.at, turn: this.turns.get(senderId) ?? 0, to: m.to, ...m.peer ? { peer: m.peer } : {}, ...m.summary ? { summary: m.summary } : {}, text: m.text, status: "sending", via: "ledge" });
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* A task the session handed over (issue #92): its card in the sender's thread, in the turn it is in (not a prompt).
|
|
664
|
-
* One card per transmission: the same one again replaces it.
|
|
665
|
-
*/
|
|
666
|
-
recordTask(senderId, n) {
|
|
667
|
-
const { task, tr } = n;
|
|
668
|
-
const what = { give: "confi\xE9e \xE0", pass: "pass\xE9e \xE0", done: "rendue \xE0", refuse: "refus\xE9e, rendue \xE0", cancel: "annul\xE9e pour" }[tr.op];
|
|
669
|
-
const said = tr.op === "pass" ? tr.note : task.result;
|
|
670
|
-
return this.put(senderId, {
|
|
671
|
-
kind: "notice",
|
|
672
|
-
id: `task-${task.id}-${tr.index}`,
|
|
673
|
-
at: tr.at,
|
|
674
|
-
turn: this.turns.get(senderId) ?? 0,
|
|
675
|
-
notice: "other",
|
|
676
|
-
task: task.id,
|
|
677
|
-
taskOp: tr.op,
|
|
678
|
-
outgoing: true,
|
|
679
|
-
name: tr.toName,
|
|
680
|
-
...n.peer ? { peer: n.peer } : {},
|
|
681
|
-
summary: said ? `${task.title}
|
|
682
|
-
${quote(said)}` : task.title,
|
|
683
|
-
text: `T\xE2che \xAB ${task.title} \xBB ${what} ${tr.toName}${tr.op === "done" ? " (termin\xE9e)" : ""}`
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
/** The mailbox's outcome for a card; a `read` card stays read. */
|
|
687
|
-
markSent(senderId, rid, status) {
|
|
688
|
-
const sent = this.items(senderId).find((i) => i.kind === "sent" && i.id === rid);
|
|
689
|
-
if (sent && sent.status !== "read") this.put(senderId, { ...sent, status });
|
|
690
|
-
}
|
|
691
|
-
/** A SendMessage's card, from its input: `to` the address used, `peer` the Ledge session that address is. */
|
|
692
|
-
sentOf(ev, common, status) {
|
|
693
|
-
const input = ev.toolInput ?? {};
|
|
694
|
-
const to = typeof input.to === "string" ? input.to : "";
|
|
695
|
-
const peer = to ? this.peerOf(to) : void 0;
|
|
696
|
-
return { kind: "sent", id: ev.toolUseId ?? `sent-${ev.at}`, ...common, to, ...peer ? { peer } : {}, ...typeof input.summary === "string" && input.summary ? { summary: input.summary } : {}, text: typeof input.message === "string" ? stripNote(input.message).text : "", status };
|
|
697
|
-
}
|
|
698
|
-
/** A notice as a thread item: the session it is about named, and its Ledge session when it is one. */
|
|
699
|
-
noticeOf(n, common) {
|
|
700
|
-
const base = { kind: "notice", ...common, notice: n.kind, text: n.text };
|
|
701
|
-
if (n.kind === "other") return base;
|
|
702
|
-
if (n.kind === "delivery") return { ...base, status: n.status, count: n.count, recipients: n.recipients, ...n.reason ? { reason: n.reason } : {} };
|
|
703
|
-
const peer = n.peer ? this.peerOf(n.peer) : void 0;
|
|
704
|
-
return {
|
|
705
|
-
...base,
|
|
706
|
-
name: n.peer,
|
|
707
|
-
...peer ? { peer } : {},
|
|
708
|
-
..."time" in n && n.time ? { time: n.time } : {},
|
|
709
|
-
..."summary" in n && n.summary ? { summary: n.summary } : {},
|
|
710
|
-
..."reason" in n && n.reason ? { reason: n.reason } : {}
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
/** Puts a received card, paired with the sender's sent one when the sender is a Ledge session: the last of its
|
|
714
|
-
* messages to this session not read yet, the one whose text starts like the body if any (the hook may have cut
|
|
715
|
-
* either), both read as Ledge types a body (a multi-line one arrives on one line). A message the mailbox typed names
|
|
716
|
-
* its id: the sent card of that id is the one, and its text replaces a pointer's. That sent one becomes read, in the sender's thread. */
|
|
717
|
-
receive(id, recv, m) {
|
|
718
|
-
const peer = (m.sender ? this.peerOf(m.sender) : void 0) ?? this.peerOf(m.from) ?? (m.name ? this.peerOf(m.name) : void 0);
|
|
719
|
-
if (!peer) return this.put(id, recv);
|
|
720
|
-
const head = (t) => ledgeBody(t).trim().slice(0, 200);
|
|
721
|
-
const open = this.items(peer).filter((i) => i.kind === "sent" && i.peer === id && i.status !== "read");
|
|
722
|
-
const byId = m.id ? open.find((i) => mailId(i.id) === m.id) : void 0;
|
|
723
|
-
const sent = byId ?? [...open].reverse().find((i) => head(i.text) === head(recv.text)) ?? open.at(-1);
|
|
724
|
-
if (sent) this.put(peer, { ...sent, status: "read", received: recv.id });
|
|
725
|
-
return this.put(id, { ...recv, ...byId ? { text: byId.text } : {}, peer, ...sent ? { sent: sent.id } : {} });
|
|
726
|
-
}
|
|
727
|
-
/** The turn (or the helper) is over: an action still running will not get its result from this side. It shows as
|
|
728
|
-
* unknown, not as waiting forever; a result that arrives later still settles it (callFor finds it by id). */
|
|
729
|
-
settle(id, mine) {
|
|
730
|
-
for (const i of this.items(id)) if (i.kind === "tool" && i.status === "running" && mine(i)) this.put(id, { ...i, status: "unknown" });
|
|
731
|
-
}
|
|
732
|
-
callFor(id, ev) {
|
|
733
|
-
const items = this.items(id);
|
|
734
|
-
if (ev.toolUseId) {
|
|
735
|
-
const hit = items.find((i) => i.id === ev.toolUseId);
|
|
736
|
-
if (hit?.kind === "tool") return hit;
|
|
737
|
-
}
|
|
738
|
-
return [...items].reverse().find((i) => i.kind === "tool" && i.tool === ev.tool && i.status === "running" && i.helperId === ev.helperId);
|
|
739
|
-
}
|
|
740
|
-
/** Rewrites a journal with one line per item, via a temporary file so a crash leaves the old journal whole. */
|
|
741
|
-
compact(id, items) {
|
|
742
|
-
const file = join(this.dir, `${id}.jsonl`);
|
|
743
|
-
const tmp = `${file}.tmp`;
|
|
744
|
-
const text = items.map((it) => JSON.stringify(it) + "\n").join("");
|
|
745
|
-
this.writes = this.writes.then(() => writeFile2(tmp, text)).then(() => rename2(tmp, file)).catch((e) => log(`history not compacted for ${id}: ${e.message}`));
|
|
746
|
-
}
|
|
747
|
-
put(id, item) {
|
|
748
|
-
const items = [...this.items(id)];
|
|
749
|
-
const at = items.findIndex((i) => i.id === item.id);
|
|
750
|
-
if (at >= 0) items[at] = item;
|
|
751
|
-
else items.push(item);
|
|
752
|
-
this.threads.set(id, items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items);
|
|
753
|
-
for (const l of this.listeners) {
|
|
754
|
-
try {
|
|
755
|
-
l(id, item);
|
|
756
|
-
} catch (e) {
|
|
757
|
-
log(`history listener failed: ${e.message}`);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
const line = JSON.stringify(item) + "\n";
|
|
761
|
-
this.writes = this.writes.then(() => mkdir2(this.dir, { recursive: true })).then(() => appendFile(join(this.dir, `${id}.jsonl`), line)).catch((e) => log(`history not written for ${id}: ${e.message}`));
|
|
762
|
-
return item;
|
|
763
|
-
}
|
|
764
|
-
};
|
|
765
|
-
|
|
766
|
-
// src/update/self.ts
|
|
767
|
-
import { existsSync } from "fs";
|
|
768
|
-
import { readFile as readFile3, realpath } from "fs/promises";
|
|
769
|
-
import { homedir } from "os";
|
|
770
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
771
|
-
function gitRoot(script, exists = existsSync) {
|
|
772
|
-
for (let dir = dirname2(script); ; dir = dirname2(dir)) {
|
|
773
|
-
if (exists(join2(dir, ".git"))) return dir;
|
|
774
|
-
if (dirname2(dir) === dir) return null;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
function installKind(script, exists = existsSync) {
|
|
778
|
-
if (script.includes("/node_modules/@paradow/ledge/")) return "npm";
|
|
779
|
-
return gitRoot(script, exists) ? "git" : "unknown";
|
|
780
|
-
}
|
|
781
|
-
var underSystemd = (env, cgroup) => !!env.INVOCATION_ID && inDaemonService(cgroup);
|
|
782
|
-
var manualCommand = (root) => `cd ${root} && git pull && pnpm install && pnpm --filter @paradow/ledge build && systemctl --user restart ${SERVICE.replace(/\.service$/, "")}`;
|
|
783
|
-
async function daemonInfo() {
|
|
784
|
-
const script = await realpath(process.argv[1] ?? "").catch(() => process.argv[1] ?? "");
|
|
785
|
-
const cgroup = await readFile3("/proc/self/cgroup", "utf8").catch(() => "");
|
|
786
|
-
const install = installKind(script);
|
|
787
|
-
const root = install === "git" ? gitRoot(script) : null;
|
|
788
|
-
return { version: LEDGE_VERSION, install, systemd: underSystemd(process.env, cgroup), home: homedir(), ...root ? { manual: manualCommand(root) } : {} };
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// src/update/run-update.ts
|
|
792
|
-
import { dirname as dirname3, join as join3 } from "path";
|
|
793
|
-
var PACKAGE = "@paradow/ledge";
|
|
794
|
-
var NPM_TIMEOUT_MS = 3 * 6e4;
|
|
795
|
-
var RESTART_COMMAND = "systemctl --user restart ledge-daemon";
|
|
796
|
-
var npmCommand = (execPath, version) => ({ file: join3(dirname3(execPath), "npm"), args: ["i", "-g", `${PACKAGE}@${version}`] });
|
|
797
|
-
var lastLines = (output, n = 3) => output.split("\n").map((l) => l.trimEnd()).filter(Boolean).slice(-n).join("\n");
|
|
798
|
-
var npmEnv = (execPath, env) => ({ ...env, PATH: `${dirname3(execPath)}:${env.PATH ?? ""}` });
|
|
799
|
-
function createDaemonUpdater(deps) {
|
|
800
|
-
let busy = false;
|
|
801
|
-
let restarting = false;
|
|
802
|
-
const restartService = async (version, send) => {
|
|
803
|
-
send({ type: "update.progress", version, step: "restarting" });
|
|
804
|
-
await deps.save();
|
|
805
|
-
const code = await deps.restart();
|
|
806
|
-
if (code !== 0) {
|
|
807
|
-
log(`update: systemctl restart failed (${code})`);
|
|
808
|
-
return send({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
|
|
809
|
-
}
|
|
810
|
-
restarting = true;
|
|
811
|
-
};
|
|
812
|
-
return {
|
|
813
|
-
running: () => busy,
|
|
814
|
-
/** `reply` reaches only the asking client: a busy refusal must not fail the flow other overlays are following. */
|
|
815
|
-
async update(version, send, reply = send) {
|
|
816
|
-
if (busy) return reply({ type: "update.failed", version, reason: "busy" });
|
|
817
|
-
const info = deps.info();
|
|
818
|
-
const cmd = npmCommand(deps.execPath ?? process.execPath, version);
|
|
819
|
-
const shown = `${cmd.file} ${cmd.args.join(" ")}`;
|
|
820
|
-
if (info.install !== "npm") return send({ type: "update.failed", version, reason: "git-install", ...info.manual ? { command: info.manual } : {} });
|
|
821
|
-
if (!info.systemd) return send({ type: "update.failed", version, reason: "no-systemd", command: shown });
|
|
822
|
-
busy = true;
|
|
823
|
-
try {
|
|
824
|
-
if (await deps.atRisk()) return send({ type: "update.failed", version, reason: "sessions-at-risk" });
|
|
825
|
-
send({ type: "update.progress", version, step: "installing" });
|
|
826
|
-
log(`update: ${shown}`);
|
|
827
|
-
const r = await deps.npm(cmd);
|
|
828
|
-
if (r.code !== 0) {
|
|
829
|
-
log(`update: npm failed (${r.code})
|
|
830
|
-
${r.output}`);
|
|
831
|
-
return send({ type: "update.failed", version, reason: "npm-failed", detail: lastLines(r.output), command: shown });
|
|
832
|
-
}
|
|
833
|
-
await restartService(version, send);
|
|
834
|
-
} catch (e) {
|
|
835
|
-
log(`update: ${e.stack ?? String(e)}`);
|
|
836
|
-
send({ type: "update.failed", version, reason: "npm-failed", detail: e instanceof Error ? e.message : String(e) });
|
|
837
|
-
} finally {
|
|
838
|
-
if (!restarting) busy = false;
|
|
839
|
-
}
|
|
840
|
-
},
|
|
841
|
-
/** The restart alone, for the phone (spec 2026-10-28 mobile daemon update): the update's guards but npm, then the same restart. */
|
|
842
|
-
async restart(send, reply = send) {
|
|
843
|
-
const version = deps.info().version;
|
|
844
|
-
if (busy) return reply({ type: "update.failed", version, reason: "busy" });
|
|
845
|
-
if (!deps.info().systemd) return send({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
|
|
846
|
-
busy = true;
|
|
847
|
-
try {
|
|
848
|
-
if (await deps.atRisk()) return send({ type: "update.failed", version, reason: "sessions-at-risk" });
|
|
849
|
-
await restartService(version, send);
|
|
850
|
-
} catch (e) {
|
|
851
|
-
log(`restart: ${e.stack ?? String(e)}`);
|
|
852
|
-
send({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
|
|
853
|
-
} finally {
|
|
854
|
-
if (!restarting) busy = false;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
function realUpdaterDeps(opts) {
|
|
860
|
-
return {
|
|
861
|
-
info: () => opts.info,
|
|
862
|
-
atRisk: async () => (opts.ownedLive?.() ?? 0) > 0 || await tmuxServerAtRisk(),
|
|
863
|
-
npm: async (cmd) => {
|
|
864
|
-
const env = npmEnv(process.execPath, { ...process.env, ...await loginShellEnv() });
|
|
865
|
-
const r = await runFile(cmd.file, cmd.args, env, { timeoutMs: NPM_TIMEOUT_MS });
|
|
866
|
-
const output = `${r.stdout}
|
|
867
|
-
${r.stderr}`;
|
|
868
|
-
return r.code === 124 ? { code: 124, output: `npm timed out after 3 minutes
|
|
869
|
-
${output}` } : { code: r.code, output };
|
|
870
|
-
},
|
|
871
|
-
// --no-block: this systemctl runs inside the cgroup being stopped; the job must not depend on it staying alive.
|
|
872
|
-
restart: async () => (await runFile("systemctl", ["--user", "restart", "--no-block", SERVICE])).code,
|
|
873
|
-
save: opts.save
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// src/mobile/files.ts
|
|
878
|
-
import { readFileSync, writeFileSync, renameSync, chmodSync, unlinkSync } from "fs";
|
|
879
|
-
import { z } from "zod";
|
|
880
|
-
|
|
881
|
-
// ../seal/src/errors.ts
|
|
882
|
-
var SealError = class extends Error {
|
|
883
|
-
code;
|
|
884
|
-
constructor(code, message) {
|
|
885
|
-
super(message ?? code);
|
|
886
|
-
this.name = "SealError";
|
|
887
|
-
this.code = code;
|
|
888
|
-
}
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
// ../seal/src/encoding.ts
|
|
892
|
-
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
893
|
-
function b64u(bytes) {
|
|
894
|
-
let out = "";
|
|
895
|
-
for (let i = 0; i < bytes.length; i += 3) {
|
|
896
|
-
const n = bytes[i] << 16 | (bytes[i + 1] ?? 0) << 8 | (bytes[i + 2] ?? 0);
|
|
897
|
-
out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
|
|
898
|
-
if (i + 1 < bytes.length) out += ALPHABET[n >> 6 & 63];
|
|
899
|
-
if (i + 2 < bytes.length) out += ALPHABET[n & 63];
|
|
900
|
-
}
|
|
901
|
-
return out;
|
|
902
|
-
}
|
|
903
|
-
function fromB64u(text) {
|
|
904
|
-
if (text.length % 4 === 1) throw new SealError("bad_encoding", "invalid base64url length");
|
|
905
|
-
const out = new Uint8Array(Math.floor(text.length * 3 / 4));
|
|
906
|
-
let acc = 0;
|
|
907
|
-
let bits = 0;
|
|
908
|
-
let o = 0;
|
|
909
|
-
for (const ch of text) {
|
|
910
|
-
const v = ALPHABET.indexOf(ch);
|
|
911
|
-
if (v < 0) throw new SealError("bad_encoding", "invalid base64url character");
|
|
912
|
-
acc = acc << 6 | v;
|
|
913
|
-
bits += 6;
|
|
914
|
-
if (bits >= 8) {
|
|
915
|
-
bits -= 8;
|
|
916
|
-
out[o++] = acc >> bits & 255;
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
if (acc & (1 << bits) - 1) throw new SealError("bad_encoding", "non-zero trailing bits");
|
|
920
|
-
return out;
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// ../seal/src/bytes.ts
|
|
924
|
-
function concat(...parts) {
|
|
925
|
-
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
926
|
-
let o = 0;
|
|
927
|
-
for (const p of parts) {
|
|
928
|
-
out.set(p, o);
|
|
929
|
-
o += p.length;
|
|
930
|
-
}
|
|
931
|
-
return out;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ../seal/src/identity.ts
|
|
935
|
-
var subtle = globalThis.crypto.subtle;
|
|
936
|
-
var ALGO = { name: "ECDH", namedCurve: "P-256" };
|
|
937
|
-
async function generateIdentity(opts = {}) {
|
|
938
|
-
const extractable = opts.extractable ?? false;
|
|
939
|
-
const pair = await subtle.generateKey(ALGO, extractable, ["deriveBits"]);
|
|
940
|
-
const raw = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
941
|
-
const privateKey = pair.privateKey;
|
|
942
|
-
return { publicKey: b64u(raw), privateKey };
|
|
943
|
-
}
|
|
944
|
-
async function exportIdentity(id) {
|
|
945
|
-
return b64u(new Uint8Array(await subtle.exportKey("pkcs8", id.privateKey)));
|
|
946
|
-
}
|
|
947
|
-
async function importIdentity(pkcs8) {
|
|
948
|
-
const privateKey = await subtle.importKey("pkcs8", fromB64u(pkcs8), ALGO, true, ["deriveBits"]);
|
|
949
|
-
const jwk = await subtle.exportKey("jwk", privateKey);
|
|
950
|
-
if (!jwk.x || !jwk.y) throw new SealError("bad_key", "private key has no public coordinates");
|
|
951
|
-
const point = concat(new Uint8Array([4]), fromB64u(jwk.x), fromB64u(jwk.y));
|
|
952
|
-
const publicKey = b64u(point);
|
|
953
|
-
await importPublicKey(publicKey);
|
|
954
|
-
return { publicKey, privateKey };
|
|
955
|
-
}
|
|
956
|
-
async function importPublicKey(publicKey) {
|
|
957
|
-
const raw = fromB64u(publicKey);
|
|
958
|
-
if (raw.length !== 65 || raw[0] !== 4) throw new SealError("bad_key", "not an uncompressed P-256 point");
|
|
959
|
-
try {
|
|
960
|
-
return await subtle.importKey("raw", raw, ALGO, true, []);
|
|
961
|
-
} catch {
|
|
962
|
-
throw new SealError("bad_key", "point rejected by WebCrypto");
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// ../seal/src/channel.ts
|
|
967
|
-
var subtle2 = globalThis.crypto.subtle;
|
|
968
|
-
var enc = new TextEncoder();
|
|
969
|
-
var dec = new TextDecoder("utf-8", { fatal: true });
|
|
970
|
-
var ECDH = { name: "ECDH", namedCurve: "P-256" };
|
|
971
|
-
var VERSION = "ledge-seal/1";
|
|
972
|
-
var SEQ_BYTES = 8;
|
|
973
|
-
var TAG_BYTES = 16;
|
|
974
|
-
function seqBytes(seq) {
|
|
975
|
-
const out = new Uint8Array(SEQ_BYTES);
|
|
976
|
-
new DataView(out.buffer).setBigUint64(0, BigInt(seq));
|
|
977
|
-
return out;
|
|
978
|
-
}
|
|
979
|
-
async function ecdh(priv, pub) {
|
|
980
|
-
return new Uint8Array(await subtle2.deriveBits({ name: "ECDH", public: pub }, priv, 256));
|
|
981
|
-
}
|
|
982
|
-
async function ephemeralKeys(pkcs8) {
|
|
983
|
-
if (pkcs8 === void 0) {
|
|
984
|
-
const eph = await subtle2.generateKey(ECDH, false, ["deriveBits"]);
|
|
985
|
-
return { privateKey: eph.privateKey, raw: new Uint8Array(await subtle2.exportKey("raw", eph.publicKey)) };
|
|
986
|
-
}
|
|
987
|
-
const der = fromB64u(pkcs8);
|
|
988
|
-
const probe = await subtle2.importKey("pkcs8", der, ECDH, true, ["deriveBits"]);
|
|
989
|
-
const jwk = await subtle2.exportKey("jwk", probe);
|
|
990
|
-
if (!jwk.x || !jwk.y) throw new SealError("bad_key", "ephemeral key has no public coordinates");
|
|
991
|
-
const raw = concat(new Uint8Array([4]), fromB64u(jwk.x), fromB64u(jwk.y));
|
|
992
|
-
return { privateKey: await subtle2.importKey("pkcs8", der, ECDH, false, ["deriveBits"]), raw };
|
|
993
|
-
}
|
|
994
|
-
async function startHandshake(self, role, peerPublicKey, opts = {}) {
|
|
995
|
-
const peerStatic = await importPublicKey(peerPublicKey);
|
|
996
|
-
const ss = await ecdh(self.privateKey, peerStatic);
|
|
997
|
-
const eph = await ephemeralKeys(opts.ephemeralPkcs8);
|
|
998
|
-
const ephRaw = eph.raw;
|
|
999
|
-
let ephPrivate = eph.privateKey;
|
|
1000
|
-
return {
|
|
1001
|
-
hello: b64u(ephRaw),
|
|
1002
|
-
async finish(peerHello) {
|
|
1003
|
-
const priv = ephPrivate;
|
|
1004
|
-
ephPrivate = null;
|
|
1005
|
-
if (!priv) throw new SealError("bad_key", "handshake already finished");
|
|
1006
|
-
const peerEph = await importPublicKey(peerHello);
|
|
1007
|
-
const peerEphRaw = fromB64u(peerHello);
|
|
1008
|
-
const ee = await ecdh(priv, peerEph);
|
|
1009
|
-
const selfStaticRaw = fromB64u(self.publicKey);
|
|
1010
|
-
const peerStaticRaw = fromB64u(peerPublicKey);
|
|
1011
|
-
const [pcStatic, deviceStatic, pcEph, deviceEph] = role === "pc" ? [selfStaticRaw, peerStaticRaw, ephRaw, peerEphRaw] : [peerStaticRaw, selfStaticRaw, peerEphRaw, ephRaw];
|
|
1012
|
-
const th = new Uint8Array(
|
|
1013
|
-
await subtle2.digest("SHA-256", concat(enc.encode(VERSION), pcStatic, deviceStatic, pcEph, deviceEph))
|
|
1014
|
-
);
|
|
1015
|
-
const ikm = await subtle2.importKey("raw", concat(ss, ee), "HKDF", false, ["deriveBits"]);
|
|
1016
|
-
const okm = new Uint8Array(
|
|
1017
|
-
await subtle2.deriveBits(
|
|
1018
|
-
{ name: "HKDF", hash: "SHA-256", salt: th, info: enc.encode(`${VERSION} keys`) },
|
|
1019
|
-
ikm,
|
|
1020
|
-
512
|
|
1021
|
-
)
|
|
1022
|
-
);
|
|
1023
|
-
const aes = (raw, usage) => subtle2.importKey("raw", raw, { name: "AES-GCM" }, false, [usage]);
|
|
1024
|
-
const pcToDevice = okm.slice(0, 32);
|
|
1025
|
-
const deviceToPc = okm.slice(32, 64);
|
|
1026
|
-
const sendKey = await aes(role === "pc" ? pcToDevice : deviceToPc, "encrypt");
|
|
1027
|
-
const recvKey = await aes(role === "pc" ? deviceToPc : pcToDevice, "decrypt");
|
|
1028
|
-
let sendSeq = 0;
|
|
1029
|
-
let recvSeq = 0;
|
|
1030
|
-
const params = (seq) => ({
|
|
1031
|
-
name: "AES-GCM",
|
|
1032
|
-
iv: concat(new Uint8Array(4), seq),
|
|
1033
|
-
additionalData: concat(th, seq)
|
|
1034
|
-
});
|
|
1035
|
-
let sealChain = Promise.resolve();
|
|
1036
|
-
let openChain = Promise.resolve();
|
|
1037
|
-
const serial = (chain, task) => {
|
|
1038
|
-
const run = chain.then(task);
|
|
1039
|
-
return [run, run.catch(() => void 0)];
|
|
1040
|
-
};
|
|
1041
|
-
const sealOne = async (text) => {
|
|
1042
|
-
const seq = seqBytes(sendSeq++);
|
|
1043
|
-
const ct = new Uint8Array(await subtle2.encrypt(params(seq), sendKey, enc.encode(text)));
|
|
1044
|
-
return b64u(concat(seq, ct));
|
|
1045
|
-
};
|
|
1046
|
-
const openOne = async (sealed) => {
|
|
1047
|
-
const frame = fromB64u(sealed);
|
|
1048
|
-
if (frame.length < SEQ_BYTES + TAG_BYTES) throw new SealError("bad_seal", "frame too short");
|
|
1049
|
-
const seq = frame.slice(0, SEQ_BYTES);
|
|
1050
|
-
const got = new DataView(seq.buffer).getBigUint64(0);
|
|
1051
|
-
if (got > BigInt(recvSeq)) throw new SealError("gap", "a frame is missing before this one");
|
|
1052
|
-
if (got < BigInt(recvSeq)) throw new SealError("replay", "frame already seen");
|
|
1053
|
-
let plain;
|
|
1054
|
-
try {
|
|
1055
|
-
plain = await subtle2.decrypt(params(seq), recvKey, frame.slice(SEQ_BYTES));
|
|
1056
|
-
} catch {
|
|
1057
|
-
throw new SealError("bad_seal", "authentication failed");
|
|
1058
|
-
}
|
|
1059
|
-
recvSeq++;
|
|
1060
|
-
try {
|
|
1061
|
-
return dec.decode(plain);
|
|
1062
|
-
} catch {
|
|
1063
|
-
throw new SealError("bad_seal", "payload is not UTF-8");
|
|
1064
|
-
}
|
|
1065
|
-
};
|
|
1066
|
-
return {
|
|
1067
|
-
seal(text) {
|
|
1068
|
-
const [run, next] = serial(sealChain, () => sealOne(text));
|
|
1069
|
-
sealChain = next;
|
|
1070
|
-
return run;
|
|
1071
|
-
},
|
|
1072
|
-
open(sealed) {
|
|
1073
|
-
const [run, next] = serial(openChain, () => openOne(sealed));
|
|
1074
|
-
openChain = next;
|
|
1075
|
-
return run;
|
|
1076
|
-
}
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// ../seal/src/pairing.ts
|
|
1083
|
-
var subtle3 = globalThis.crypto.subtle;
|
|
1084
|
-
var enc2 = new TextEncoder();
|
|
1085
|
-
var COMMIT_DOMAIN = "ledge-pair/1 commit";
|
|
1086
|
-
var CODE_DOMAIN = "ledge-pair/1 code";
|
|
1087
|
-
function randomNonce() {
|
|
1088
|
-
return b64u(globalThis.crypto.getRandomValues(new Uint8Array(32)));
|
|
1089
|
-
}
|
|
1090
|
-
async function sha256(...parts) {
|
|
1091
|
-
return new Uint8Array(await subtle3.digest("SHA-256", concat(...parts)));
|
|
1092
|
-
}
|
|
1093
|
-
async function commit(devicePublicKey, deviceNonce) {
|
|
1094
|
-
return b64u(await sha256(enc2.encode(COMMIT_DOMAIN), fromB64u(devicePublicKey), fromB64u(deviceNonce)));
|
|
1095
|
-
}
|
|
1096
|
-
async function checkCommitment(commitment, devicePublicKey, deviceNonce) {
|
|
1097
|
-
let given;
|
|
1098
|
-
let expected;
|
|
1099
|
-
try {
|
|
1100
|
-
given = fromB64u(commitment);
|
|
1101
|
-
if (fromB64u(deviceNonce).length !== 32) return false;
|
|
1102
|
-
expected = fromB64u(await commit(devicePublicKey, deviceNonce));
|
|
1103
|
-
} catch {
|
|
1104
|
-
return false;
|
|
1105
|
-
}
|
|
1106
|
-
if (given.length !== expected.length) return false;
|
|
1107
|
-
let diff = 0;
|
|
1108
|
-
for (let i = 0; i < expected.length; i++) diff |= given[i] ^ expected[i];
|
|
1109
|
-
return diff === 0;
|
|
1110
|
-
}
|
|
1111
|
-
async function pairingCode(input) {
|
|
1112
|
-
const hash = await sha256(
|
|
1113
|
-
enc2.encode(CODE_DOMAIN),
|
|
1114
|
-
fromB64u(input.pcPublicKey),
|
|
1115
|
-
fromB64u(input.devicePublicKey),
|
|
1116
|
-
fromB64u(input.deviceNonce),
|
|
1117
|
-
fromB64u(input.pcNonce)
|
|
1118
|
-
);
|
|
1119
|
-
const n = new DataView(hash.buffer, hash.byteOffset, 4).getUint32(0, false) % 1e6;
|
|
1120
|
-
const digits = String(n).padStart(6, "0");
|
|
1121
|
-
return `${digits.slice(0, 3)} ${digits.slice(3)}`;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// ../seal/src/fingerprint.ts
|
|
1125
|
-
var subtle4 = globalThis.crypto.subtle;
|
|
1126
|
-
var DOMAIN = new TextEncoder().encode("ledge-key/1");
|
|
1127
|
-
async function fingerprint(publicKey) {
|
|
1128
|
-
await importPublicKey(publicKey);
|
|
1129
|
-
const digest = await subtle4.digest("SHA-256", concat(DOMAIN, fromB64u(publicKey)));
|
|
1130
|
-
return b64u(new Uint8Array(digest)).slice(0, 22);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// src/mobile/files.ts
|
|
1134
|
-
var RelaySessionSchema = z.object({ token: z.string().min(1), accountId: z.string().min(1), login: z.string(), relayUrl: z.string() });
|
|
1135
|
-
var DeviceCredentialSchema = z.object({
|
|
1136
|
-
id: z.string().min(1),
|
|
1137
|
-
publicKey: z.string().min(1),
|
|
1138
|
-
alg: z.union([z.literal(-7), z.literal(-257)]),
|
|
1139
|
-
counter: z.number().int().nonnegative(),
|
|
1140
|
-
rpId: z.string().min(1),
|
|
1141
|
-
transports: z.array(z.string()).optional()
|
|
1142
|
-
});
|
|
1143
|
-
var ApprovedDeviceSchema = z.object({
|
|
1144
|
-
id: z.string().min(1),
|
|
1145
|
-
publicKey: z.string().min(1),
|
|
1146
|
-
label: z.string(),
|
|
1147
|
-
approvedAt: z.string(),
|
|
1148
|
-
lastPeer: z.string().optional(),
|
|
1149
|
-
lastSeenAt: z.string().optional(),
|
|
1150
|
-
credential: DeviceCredentialSchema.optional(),
|
|
1151
|
-
/** The phone's push subscription and the relay's VAPID key it was made with. */
|
|
1152
|
-
push: z.object({ endpoint: z.string().min(1), p256dh: z.string().min(1), auth: z.string().min(1), key: z.string().min(1) }).optional()
|
|
1153
|
-
});
|
|
1154
|
-
function writePrivate(file, content) {
|
|
1155
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
1156
|
-
writeFileSync(tmp, content, { mode: 384 });
|
|
1157
|
-
chmodSync(tmp, 384);
|
|
1158
|
-
renameSync(tmp, file);
|
|
1159
|
-
}
|
|
1160
|
-
function readText(file, name) {
|
|
1161
|
-
try {
|
|
1162
|
-
return readFileSync(file, "utf8");
|
|
1163
|
-
} catch (e) {
|
|
1164
|
-
if (e.code === "ENOENT") return null;
|
|
1165
|
-
throw new Error(`${name} cannot be read (${e.code ?? "error"})`);
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
function readJson(file, name, schema) {
|
|
1169
|
-
const raw = readText(file, name);
|
|
1170
|
-
if (raw === null) return null;
|
|
1171
|
-
try {
|
|
1172
|
-
return schema.parse(JSON.parse(raw));
|
|
1173
|
-
} catch {
|
|
1174
|
-
log(`${name} is invalid, ignoring it`);
|
|
1175
|
-
return null;
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
var DEFAULT_CONFIG = () => ({ enabled: false, relayUrl: DEFAULT_RELAY_URL });
|
|
1179
|
-
var LEGACY_RELAY_URL = "https://server.ledge.paradow.dev";
|
|
1180
|
-
function readMobileConfig() {
|
|
1181
|
-
const c = readJson(paths().mobile, "mobile.json", MobileConfigSchema);
|
|
1182
|
-
if (!c) return DEFAULT_CONFIG();
|
|
1183
|
-
return c.relayUrl === LEGACY_RELAY_URL ? { ...c, relayUrl: DEFAULT_RELAY_URL } : c;
|
|
1184
|
-
}
|
|
1185
|
-
function writeMobileConfig(c) {
|
|
1186
|
-
writePrivate(paths().mobile, JSON.stringify(MobileConfigSchema.parse(c), null, 2) + "\n");
|
|
1187
|
-
}
|
|
1188
|
-
function readRelaySession() {
|
|
1189
|
-
const s = readJson(paths().mobileSession, "mobile-session.json", RelaySessionSchema);
|
|
1190
|
-
return s && s.relayUrl === readMobileConfig().relayUrl ? s : null;
|
|
1191
|
-
}
|
|
1192
|
-
function writeRelaySession(s) {
|
|
1193
|
-
writePrivate(paths().mobileSession, JSON.stringify(RelaySessionSchema.parse(s), null, 2) + "\n");
|
|
1194
|
-
}
|
|
1195
|
-
function clearRelaySession() {
|
|
1196
|
-
try {
|
|
1197
|
-
unlinkSync(paths().mobileSession);
|
|
1198
|
-
} catch {
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
async function loadPcIdentity() {
|
|
1202
|
-
const file = paths().mobileKey;
|
|
1203
|
-
const stored = readText(file, "mobile-key")?.trim();
|
|
1204
|
-
if (stored) {
|
|
1205
|
-
try {
|
|
1206
|
-
return await importIdentity(stored);
|
|
1207
|
-
} catch {
|
|
1208
|
-
log("mobile-key is invalid, creating a new key");
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
const id = await generateIdentity({ extractable: true });
|
|
1212
|
-
writePrivate(file, await exportIdentity(id));
|
|
1213
|
-
return importIdentity(await exportIdentity(id));
|
|
1214
|
-
}
|
|
1215
|
-
function readDevices() {
|
|
1216
|
-
return readJson(paths().devices, "devices.json", z.array(ApprovedDeviceSchema)) ?? [];
|
|
1217
|
-
}
|
|
1218
|
-
function writeDevices(list) {
|
|
1219
|
-
writePrivate(paths().devices, JSON.stringify(z.array(ApprovedDeviceSchema).parse(list), null, 2) + "\n");
|
|
1220
|
-
}
|
|
1221
|
-
var RevocationSchema = z.object({ peer: z.string().min(1), at: z.string() });
|
|
1222
|
-
function readRevocations() {
|
|
1223
|
-
return readJson(paths().revocations, "revocations.json", z.array(RevocationSchema)) ?? [];
|
|
1224
|
-
}
|
|
1225
|
-
function writeRevocations(list) {
|
|
1226
|
-
writePrivate(paths().revocations, JSON.stringify(z.array(RevocationSchema).parse(list), null, 2) + "\n");
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// src/mobile/link.ts
|
|
1230
|
-
import WebSocket from "ws";
|
|
1231
|
-
var BACKOFF_FIRST_MS = 1e3;
|
|
1232
|
-
var BACKOFF_MAX_MS = 6e4;
|
|
1233
|
-
var PING_EVERY_MS = 25e3;
|
|
1234
|
-
var PONG_WITHIN_MS = 1e4;
|
|
1235
|
-
var WELCOME_WITHIN_MS = 15e3;
|
|
1236
|
-
var ERROR_MAX = 200;
|
|
1237
|
-
var realSchedule = (fn, ms) => {
|
|
1238
|
-
const h = setTimeout(fn, ms);
|
|
1239
|
-
return () => clearTimeout(h);
|
|
1240
|
-
};
|
|
1241
|
-
function wsUrl(relayUrl) {
|
|
1242
|
-
const u = new URL("/ws", relayUrl);
|
|
1243
|
-
u.protocol = u.protocol === "http:" ? "ws:" : "wss:";
|
|
1244
|
-
return u.toString();
|
|
1245
|
-
}
|
|
1246
|
-
function createRelayLink(opts) {
|
|
1247
|
-
const now = opts.now ?? Date.now;
|
|
1248
|
-
const random = opts.random ?? Math.random;
|
|
1249
|
-
const schedule = opts.schedule ?? realSchedule;
|
|
1250
|
-
const Impl = opts.WebSocketImpl ?? WebSocket;
|
|
1251
|
-
const url = wsUrl(opts.relayUrl);
|
|
1252
|
-
let state = "off";
|
|
1253
|
-
let ws = null;
|
|
1254
|
-
let failures = 0;
|
|
1255
|
-
let cancelRetry = null;
|
|
1256
|
-
let cancelPing = null;
|
|
1257
|
-
let cancelDeadline = null;
|
|
1258
|
-
let finished = false;
|
|
1259
|
-
const setState = (s) => {
|
|
1260
|
-
state = s.state;
|
|
1261
|
-
opts.onState(s);
|
|
1262
|
-
};
|
|
1263
|
-
const stopPing = () => {
|
|
1264
|
-
cancelPing?.();
|
|
1265
|
-
cancelPing = null;
|
|
1266
|
-
};
|
|
1267
|
-
const stopDeadline = () => {
|
|
1268
|
-
cancelDeadline?.();
|
|
1269
|
-
cancelDeadline = null;
|
|
1270
|
-
};
|
|
1271
|
-
const armDeadline = (sock, error) => {
|
|
1272
|
-
stopDeadline();
|
|
1273
|
-
cancelDeadline = schedule(() => {
|
|
1274
|
-
cancelDeadline = null;
|
|
1275
|
-
if (ws === sock) fail(error);
|
|
1276
|
-
}, WELCOME_WITHIN_MS);
|
|
1277
|
-
};
|
|
1278
|
-
const drop = () => {
|
|
1279
|
-
stopPing();
|
|
1280
|
-
stopDeadline();
|
|
1281
|
-
const old = ws;
|
|
1282
|
-
ws = null;
|
|
1283
|
-
if (old) {
|
|
1284
|
-
old.removeAllListeners();
|
|
1285
|
-
old.on("error", () => {
|
|
1286
|
-
});
|
|
1287
|
-
old.terminate();
|
|
1288
|
-
}
|
|
1289
|
-
};
|
|
1290
|
-
const nextDelay = () => {
|
|
1291
|
-
const base = Math.min(BACKOFF_FIRST_MS * 2 ** failures, BACKOFF_MAX_MS);
|
|
1292
|
-
failures++;
|
|
1293
|
-
const jittered = base * (1 + (random() * 2 - 1) * 0.2);
|
|
1294
|
-
return Math.round(Math.min(jittered, BACKOFF_MAX_MS));
|
|
1295
|
-
};
|
|
1296
|
-
const fail = (error) => {
|
|
1297
|
-
drop();
|
|
1298
|
-
if (finished) return;
|
|
1299
|
-
const delay2 = nextDelay();
|
|
1300
|
-
const cut = error.slice(0, ERROR_MAX);
|
|
1301
|
-
log(`mobile link: offline (${cut}), retry in ${delay2} ms`);
|
|
1302
|
-
setState({ state: "offline", retryAt: new Date(now() + delay2).toISOString(), error: cut });
|
|
1303
|
-
cancelRetry = schedule(() => {
|
|
1304
|
-
cancelRetry = null;
|
|
1305
|
-
connect();
|
|
1306
|
-
}, delay2);
|
|
1307
|
-
};
|
|
1308
|
-
const signedOut = (reason) => {
|
|
1309
|
-
drop();
|
|
1310
|
-
finished = true;
|
|
1311
|
-
log(`mobile link: signed out (${reason})`);
|
|
1312
|
-
setState({ state: "signed_out", reason });
|
|
1313
|
-
};
|
|
1314
|
-
const armPing = (sock) => {
|
|
1315
|
-
stopPing();
|
|
1316
|
-
cancelPing = schedule(() => {
|
|
1317
|
-
if (ws !== sock) return;
|
|
1318
|
-
sock.ping();
|
|
1319
|
-
cancelPing = schedule(() => {
|
|
1320
|
-
if (ws === sock) fail("no pong from the relay");
|
|
1321
|
-
}, PONG_WITHIN_MS);
|
|
1322
|
-
}, PING_EVERY_MS);
|
|
1323
|
-
};
|
|
1324
|
-
const connect = () => {
|
|
1325
|
-
if (finished) return;
|
|
1326
|
-
setState({ state: "connecting" });
|
|
1327
|
-
const sock = new Impl(url, { headers: { authorization: `Bearer ${opts.token}` } });
|
|
1328
|
-
ws = sock;
|
|
1329
|
-
armDeadline(sock, "no answer to the upgrade from the relay");
|
|
1330
|
-
let lastError = "";
|
|
1331
|
-
sock.on("error", (e) => {
|
|
1332
|
-
lastError = e.message;
|
|
1333
|
-
});
|
|
1334
|
-
sock.on("unexpected-response", (_req, res) => {
|
|
1335
|
-
if (ws !== sock) return;
|
|
1336
|
-
res.resume();
|
|
1337
|
-
if (res.statusCode === 401) signedOut("session_invalid");
|
|
1338
|
-
else fail(`unexpected server response: ${res.statusCode ?? 0}`);
|
|
1339
|
-
});
|
|
1340
|
-
sock.on("open", () => {
|
|
1341
|
-
if (ws !== sock) return;
|
|
1342
|
-
armDeadline(sock, "no welcome from the relay");
|
|
1343
|
-
armPing(sock);
|
|
1344
|
-
});
|
|
1345
|
-
sock.on("pong", () => {
|
|
1346
|
-
if (ws === sock) armPing(sock);
|
|
1347
|
-
});
|
|
1348
|
-
sock.on("message", (data) => {
|
|
1349
|
-
if (ws !== sock) return;
|
|
1350
|
-
let parsed;
|
|
1351
|
-
try {
|
|
1352
|
-
parsed = JSON.parse(data.toString());
|
|
1353
|
-
} catch {
|
|
1354
|
-
parsed = void 0;
|
|
1355
|
-
}
|
|
1356
|
-
const r = RelayServerMessageSchema.safeParse(parsed);
|
|
1357
|
-
if (!r.success) {
|
|
1358
|
-
log("mobile link: ignored an invalid relay frame");
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
if (r.data.type === "welcome") {
|
|
1362
|
-
failures = 0;
|
|
1363
|
-
stopDeadline();
|
|
1364
|
-
if (state !== "online") {
|
|
1365
|
-
log("mobile link: online");
|
|
1366
|
-
setState({ state: "online" });
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
opts.onMessage(r.data);
|
|
1370
|
-
});
|
|
1371
|
-
sock.on("close", (code) => {
|
|
1372
|
-
if (ws !== sock) return;
|
|
1373
|
-
if (code === RELAY_CLOSE_SIGNED_OUT) signedOut("other_pc");
|
|
1374
|
-
else fail(lastError || `connection closed (${code})`);
|
|
1375
|
-
});
|
|
1376
|
-
};
|
|
1377
|
-
connect();
|
|
1378
|
-
return {
|
|
1379
|
-
send(m) {
|
|
1380
|
-
if (state !== "online" || !ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
1381
|
-
ws.send(JSON.stringify(m));
|
|
1382
|
-
return true;
|
|
1383
|
-
},
|
|
1384
|
-
retryNow() {
|
|
1385
|
-
if (finished || state !== "offline") return;
|
|
1386
|
-
cancelRetry?.();
|
|
1387
|
-
cancelRetry = null;
|
|
1388
|
-
connect();
|
|
1389
|
-
},
|
|
1390
|
-
close() {
|
|
1391
|
-
if (state === "off" && finished) return;
|
|
1392
|
-
finished = true;
|
|
1393
|
-
cancelRetry?.();
|
|
1394
|
-
cancelRetry = null;
|
|
1395
|
-
const old = ws;
|
|
1396
|
-
stopPing();
|
|
1397
|
-
stopDeadline();
|
|
1398
|
-
ws = null;
|
|
1399
|
-
if (old) {
|
|
1400
|
-
old.removeAllListeners();
|
|
1401
|
-
old.on("error", () => {
|
|
1402
|
-
});
|
|
1403
|
-
if (old.readyState === WebSocket.OPEN) old.close(1e3);
|
|
1404
|
-
else old.terminate();
|
|
1405
|
-
}
|
|
1406
|
-
setState({ state: "off" });
|
|
1407
|
-
}
|
|
1408
|
-
};
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// src/mobile/signin.ts
|
|
1412
|
-
import { setTimeout as delay } from "timers/promises";
|
|
1413
|
-
var CALL_TIMEOUT_MS = 15e3;
|
|
1414
|
-
var ERROR_MAX2 = 200;
|
|
1415
|
-
var Cancelled = class extends Error {
|
|
1416
|
-
};
|
|
1417
|
-
function startSignIn(opts) {
|
|
1418
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1419
|
-
const abort = new AbortController();
|
|
1420
|
-
const sleep = opts.sleep ?? ((ms) => delay(ms, void 0, { signal: abort.signal }));
|
|
1421
|
-
let cancelled = false;
|
|
1422
|
-
let deviceCode = "";
|
|
1423
|
-
const onCancel = new Promise((_resolve, reject) => {
|
|
1424
|
-
abort.signal.addEventListener("abort", () => reject(new Cancelled()), { once: true });
|
|
1425
|
-
});
|
|
1426
|
-
onCancel.catch(() => {
|
|
1427
|
-
});
|
|
1428
|
-
const guard = (p) => Promise.race([p, onCancel]);
|
|
1429
|
-
const post = async (path, body) => {
|
|
1430
|
-
if (cancelled) throw new Cancelled();
|
|
1431
|
-
const res = await guard(fetchImpl(new URL(path, opts.relayUrl).toString(), {
|
|
1432
|
-
method: "POST",
|
|
1433
|
-
headers: body === void 0 ? {} : { "content-type": "application/json" },
|
|
1434
|
-
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
1435
|
-
signal: AbortSignal.any([abort.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)])
|
|
1436
|
-
}));
|
|
1437
|
-
if (!res.ok) throw new Error(`relay answered ${res.status}`);
|
|
1438
|
-
return guard(res.json());
|
|
1439
|
-
};
|
|
1440
|
-
const run = async () => {
|
|
1441
|
-
const start = DeviceStartResponseSchema.parse(await post("/auth/device/start"));
|
|
1442
|
-
deviceCode = start.deviceCode;
|
|
1443
|
-
opts.onCode({
|
|
1444
|
-
userCode: start.userCode,
|
|
1445
|
-
verificationUri: start.verificationUri,
|
|
1446
|
-
expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
|
|
1447
|
-
});
|
|
1448
|
-
let interval = start.interval;
|
|
1449
|
-
for (; ; ) {
|
|
1450
|
-
await guard(sleep(interval * 1e3));
|
|
1451
|
-
const poll = DevicePollResponseSchema.parse(await post("/auth/device/poll", { deviceCode }));
|
|
1452
|
-
switch (poll.status) {
|
|
1453
|
-
case "pending":
|
|
1454
|
-
break;
|
|
1455
|
-
case "slow_down":
|
|
1456
|
-
interval = poll.interval;
|
|
1457
|
-
break;
|
|
1458
|
-
case "ok":
|
|
1459
|
-
return { status: "ok", session: { token: poll.token, accountId: poll.accountId, login: poll.login, relayUrl: opts.relayUrl } };
|
|
1460
|
-
default:
|
|
1461
|
-
return { status: poll.status };
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
};
|
|
1465
|
-
const result = run().catch((e) => {
|
|
1466
|
-
if (cancelled || e instanceof Cancelled) return { status: "cancelled" };
|
|
1467
|
-
let error = e instanceof Error ? e.name === "ZodError" ? "invalid relay answer" : e.message : "sign-in failed";
|
|
1468
|
-
if (deviceCode) error = error.split(deviceCode).join("[redacted]");
|
|
1469
|
-
return { status: "failed", error: error.slice(0, ERROR_MAX2) };
|
|
1470
|
-
});
|
|
1471
|
-
return {
|
|
1472
|
-
result,
|
|
1473
|
-
cancel() {
|
|
1474
|
-
if (cancelled) return;
|
|
1475
|
-
cancelled = true;
|
|
1476
|
-
abort.abort();
|
|
1477
|
-
}
|
|
1478
|
-
};
|
|
1479
|
-
}
|
|
1480
|
-
async function signOut(session, fetchImpl = fetch) {
|
|
1481
|
-
try {
|
|
1482
|
-
const res = await fetchImpl(new URL("/auth/logout", session.relayUrl).toString(), {
|
|
1483
|
-
method: "POST",
|
|
1484
|
-
headers: { authorization: `Bearer ${session.token}` },
|
|
1485
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
|
|
1486
|
-
});
|
|
1487
|
-
await res.body?.cancel();
|
|
1488
|
-
} catch {
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
// src/mobile/pairing.ts
|
|
1493
|
-
var PAIRING_TTL_MS = 5 * 6e4;
|
|
1494
|
-
var MAX_PAIRINGS = 3;
|
|
1495
|
-
var PAIRING_LINGER_MS = 1e4;
|
|
1496
|
-
var DEFAULT_LABEL = "Appareil";
|
|
1497
|
-
var realSchedule2 = (fn, ms) => {
|
|
1498
|
-
const h = setTimeout(fn, ms);
|
|
1499
|
-
return () => clearTimeout(h);
|
|
1500
|
-
};
|
|
1501
|
-
function cleanLabel(raw) {
|
|
1502
|
-
let s = raw.replace(new RegExp("\\p{Cc}", "gu"), "").trim();
|
|
1503
|
-
if (s.length > PAIR_LABEL_MAX) {
|
|
1504
|
-
s = s.slice(0, PAIR_LABEL_MAX);
|
|
1505
|
-
if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1);
|
|
1506
|
-
s = s.trim();
|
|
1507
|
-
}
|
|
1508
|
-
return s || DEFAULT_LABEL;
|
|
1509
|
-
}
|
|
1510
|
-
var isPairFrame = (f) => f.k.startsWith("pair_");
|
|
1511
|
-
function createPairing(opts) {
|
|
1512
|
-
const now = opts.now ?? Date.now;
|
|
1513
|
-
const ttlMs = opts.ttlMs ?? PAIRING_TTL_MS;
|
|
1514
|
-
const schedule = opts.schedule ?? realSchedule2;
|
|
1515
|
-
const entries = /* @__PURE__ */ new Map();
|
|
1516
|
-
let stopped = false;
|
|
1517
|
-
const active = (e) => e.state === "waiting" || e.state === "confirm" || e.state === "approved";
|
|
1518
|
-
const remove = (e) => {
|
|
1519
|
-
e.cancelTimer();
|
|
1520
|
-
if (entries.get(e.peer) === e) entries.delete(e.peer);
|
|
1521
|
-
};
|
|
1522
|
-
const settle = (e, state) => {
|
|
1523
|
-
e.cancelTimer();
|
|
1524
|
-
e.state = state;
|
|
1525
|
-
e.cancelTimer = schedule(() => {
|
|
1526
|
-
if (entries.get(e.peer) !== e) return;
|
|
1527
|
-
entries.delete(e.peer);
|
|
1528
|
-
opts.onChange();
|
|
1529
|
-
}, PAIRING_LINGER_MS);
|
|
1530
|
-
opts.onChange();
|
|
1531
|
-
};
|
|
1532
|
-
const onTtl = (e) => {
|
|
1533
|
-
if (entries.get(e.peer) !== e) return;
|
|
1534
|
-
if (e.state === "waiting" || e.state === "confirm") {
|
|
1535
|
-
log(`mobile pairing: expired for peer ${e.peer}`);
|
|
1536
|
-
settle(e, "expired");
|
|
1537
|
-
} else if (e.state === "approved") {
|
|
1538
|
-
remove(e);
|
|
1539
|
-
opts.onChange();
|
|
1540
|
-
}
|
|
1541
|
-
};
|
|
1542
|
-
const byId = (id) => [...entries.values()].find((e) => e.id === id);
|
|
1543
|
-
const onCommit = (peer, frame) => {
|
|
1544
|
-
const old = entries.get(peer);
|
|
1545
|
-
if (old) remove(old);
|
|
1546
|
-
const others = [...entries.values()].filter(active).length;
|
|
1547
|
-
if (others >= MAX_PAIRINGS) {
|
|
1548
|
-
log(`mobile pairing: refused peer ${peer}, ${MAX_PAIRINGS} pairings already pending`);
|
|
1549
|
-
opts.send(peer, { k: "pair_no" });
|
|
1550
|
-
if (old) opts.onChange();
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
const e = {
|
|
1554
|
-
id: b64u(globalThis.crypto.getRandomValues(new Uint8Array(16))),
|
|
1555
|
-
peer,
|
|
1556
|
-
label: cleanLabel(frame.label),
|
|
1557
|
-
commitment: frame.c,
|
|
1558
|
-
pcNonce: randomNonce(),
|
|
1559
|
-
expiresAt: now() + ttlMs,
|
|
1560
|
-
state: "waiting",
|
|
1561
|
-
code: null,
|
|
1562
|
-
device: null,
|
|
1563
|
-
revealing: false,
|
|
1564
|
-
cancelTimer: () => {
|
|
1565
|
-
}
|
|
1566
|
-
};
|
|
1567
|
-
entries.set(peer, e);
|
|
1568
|
-
e.cancelTimer = schedule(() => onTtl(e), ttlMs);
|
|
1569
|
-
opts.send(peer, { k: "pair_offer", pc: opts.pc.publicKey, n: e.pcNonce });
|
|
1570
|
-
opts.onChange();
|
|
1571
|
-
};
|
|
1572
|
-
const onReveal = async (peer, frame) => {
|
|
1573
|
-
const e = entries.get(peer);
|
|
1574
|
-
if (!e || e.state !== "waiting" || e.revealing) return;
|
|
1575
|
-
e.revealing = true;
|
|
1576
|
-
let ok = false;
|
|
1577
|
-
let code = "";
|
|
1578
|
-
let id = "";
|
|
1579
|
-
try {
|
|
1580
|
-
await importPublicKey(frame.d);
|
|
1581
|
-
if (fromB64u(frame.n).length === 32 && await checkCommitment(e.commitment, frame.d, frame.n)) {
|
|
1582
|
-
code = await pairingCode({ pcPublicKey: opts.pc.publicKey, devicePublicKey: frame.d, deviceNonce: frame.n, pcNonce: e.pcNonce });
|
|
1583
|
-
id = await fingerprint(frame.d);
|
|
1584
|
-
ok = true;
|
|
1585
|
-
}
|
|
1586
|
-
} catch {
|
|
1587
|
-
ok = false;
|
|
1588
|
-
}
|
|
1589
|
-
if (stopped || entries.get(peer) !== e || e.state !== "waiting") return;
|
|
1590
|
-
e.revealing = false;
|
|
1591
|
-
if (!ok) {
|
|
1592
|
-
log(`mobile pairing: reveal from peer ${peer} does not match its commitment`);
|
|
1593
|
-
remove(e);
|
|
1594
|
-
opts.send(peer, { k: "pair_no" });
|
|
1595
|
-
opts.onChange();
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
e.code = code;
|
|
1599
|
-
e.device = { publicKey: frame.d, fingerprint: id };
|
|
1600
|
-
e.state = "confirm";
|
|
1601
|
-
opts.onChange();
|
|
1602
|
-
};
|
|
1603
|
-
return {
|
|
1604
|
-
async receive(peer, frame) {
|
|
1605
|
-
if (!isPairFrame(frame)) return false;
|
|
1606
|
-
if (stopped) return true;
|
|
1607
|
-
if (frame.k === "pair_commit") onCommit(peer, frame);
|
|
1608
|
-
else if (frame.k === "pair_reveal") await onReveal(peer, frame);
|
|
1609
|
-
return true;
|
|
1610
|
-
},
|
|
1611
|
-
approve(id) {
|
|
1612
|
-
const e = byId(id);
|
|
1613
|
-
if (!e || e.state !== "confirm" || !e.device) return;
|
|
1614
|
-
if (now() > e.expiresAt) {
|
|
1615
|
-
settle(e, "expired");
|
|
1616
|
-
return;
|
|
1617
|
-
}
|
|
1618
|
-
opts.approve({
|
|
1619
|
-
id: e.device.fingerprint,
|
|
1620
|
-
publicKey: e.device.publicKey,
|
|
1621
|
-
label: e.label,
|
|
1622
|
-
approvedAt: new Date(now()).toISOString(),
|
|
1623
|
-
lastPeer: e.peer
|
|
1624
|
-
});
|
|
1625
|
-
log(`mobile pairing: approved device ${e.device.fingerprint}`);
|
|
1626
|
-
opts.send(e.peer, { k: "pair_ok" });
|
|
1627
|
-
e.state = "approved";
|
|
1628
|
-
e.cancelTimer();
|
|
1629
|
-
e.expiresAt = now() + ttlMs;
|
|
1630
|
-
e.cancelTimer = schedule(() => onTtl(e), ttlMs);
|
|
1631
|
-
opts.onChange();
|
|
1632
|
-
},
|
|
1633
|
-
deny(id) {
|
|
1634
|
-
const e = byId(id);
|
|
1635
|
-
if (!e || e.state !== "confirm" && e.state !== "waiting") return;
|
|
1636
|
-
opts.send(e.peer, { k: "pair_no" });
|
|
1637
|
-
settle(e, "refused");
|
|
1638
|
-
},
|
|
1639
|
-
linked(deviceId) {
|
|
1640
|
-
const e = [...entries.values()].find((x) => x.state === "approved" && x.device?.fingerprint === deviceId);
|
|
1641
|
-
if (e) settle(e, "linked");
|
|
1642
|
-
},
|
|
1643
|
-
views() {
|
|
1644
|
-
return [...entries.values()].map((e) => ({
|
|
1645
|
-
id: e.id,
|
|
1646
|
-
label: e.label,
|
|
1647
|
-
code: e.code,
|
|
1648
|
-
expiresAt: new Date(e.expiresAt).toISOString(),
|
|
1649
|
-
state: e.state
|
|
1650
|
-
}));
|
|
1651
|
-
},
|
|
1652
|
-
reset() {
|
|
1653
|
-
const dropped = [...entries.values()].filter((e) => e.state === "waiting" || e.state === "confirm");
|
|
1654
|
-
for (const e of dropped) remove(e);
|
|
1655
|
-
if (dropped.length) opts.onChange();
|
|
1656
|
-
},
|
|
1657
|
-
stop() {
|
|
1658
|
-
stopped = true;
|
|
1659
|
-
for (const e of entries.values()) e.cancelTimer();
|
|
1660
|
-
entries.clear();
|
|
1661
|
-
}
|
|
1662
|
-
};
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// src/mobile/channels.ts
|
|
1666
|
-
var READY = '{"t":"ready"}';
|
|
1667
|
-
var REKEY_INTERVAL_MS = 2e3;
|
|
1668
|
-
var REKEY_TABLE_PRUNE_AT = 256;
|
|
1669
|
-
var codeOf = (err) => err instanceof SealError ? err.code : err instanceof Error ? err.name : "error";
|
|
1670
|
-
function createChannels(opts) {
|
|
1671
|
-
const now = opts.now ?? Date.now;
|
|
1672
|
-
const entries = /* @__PURE__ */ new Map();
|
|
1673
|
-
const lastDevice = /* @__PURE__ */ new Map();
|
|
1674
|
-
const lastRekey = /* @__PURE__ */ new Map();
|
|
1675
|
-
const queues = /* @__PURE__ */ new Map();
|
|
1676
|
-
let epoch = 0;
|
|
1677
|
-
const peerGen = /* @__PURE__ */ new Map();
|
|
1678
|
-
let confirmations = 0;
|
|
1679
|
-
let pcFingerprint = null;
|
|
1680
|
-
const approved = (deviceId, publicKey) => opts.devices().find((d) => d.id === deviceId && (publicKey === void 0 || d.publicKey === publicKey));
|
|
1681
|
-
const hasConfirmed = (deviceId) => [...entries.values()].some((e) => e.deviceId === deviceId && e.confirmed);
|
|
1682
|
-
const close = (e, why) => {
|
|
1683
|
-
if (entries.get(e.peer) !== e) return;
|
|
1684
|
-
entries.delete(e.peer);
|
|
1685
|
-
log(`mobile channel: closed for device ${e.deviceId} on peer ${e.peer} (${why})`);
|
|
1686
|
-
if (e.confirmed && !hasConfirmed(e.deviceId)) opts.onClosed(e.deviceId);
|
|
1687
|
-
};
|
|
1688
|
-
const denied = (peer) => opts.send(peer, { k: "denied" });
|
|
1689
|
-
const rekey = (peer) => {
|
|
1690
|
-
const t = now();
|
|
1691
|
-
const last = lastRekey.get(peer);
|
|
1692
|
-
if (last !== void 0 && t - last < REKEY_INTERVAL_MS) return;
|
|
1693
|
-
if (lastRekey.size >= REKEY_TABLE_PRUNE_AT) {
|
|
1694
|
-
for (const [p, at] of lastRekey) if (t - at >= REKEY_INTERVAL_MS) lastRekey.delete(p);
|
|
1695
|
-
}
|
|
1696
|
-
lastRekey.set(peer, t);
|
|
1697
|
-
opts.send(peer, { k: "rekey" });
|
|
1698
|
-
};
|
|
1699
|
-
const onHello = async (peer, frame) => {
|
|
1700
|
-
const device = approved(frame.f);
|
|
1701
|
-
if (!device || await fingerprint(device.publicKey).catch(() => null) !== device.id) {
|
|
1702
|
-
log(`mobile channel: denied hello from peer ${peer} for fingerprint ${frame.f}`);
|
|
1703
|
-
denied(peer);
|
|
1704
|
-
return;
|
|
1705
|
-
}
|
|
1706
|
-
const e0 = epoch;
|
|
1707
|
-
const g0 = peerGen.get(peer) ?? 0;
|
|
1708
|
-
let channel;
|
|
1709
|
-
let reply;
|
|
1710
|
-
try {
|
|
1711
|
-
const hs = await startHandshake(opts.pc, "pc", device.publicKey);
|
|
1712
|
-
channel = await hs.finish(frame.e);
|
|
1713
|
-
reply = hs.hello;
|
|
1714
|
-
} catch (err) {
|
|
1715
|
-
log(`mobile channel: handshake failed for device ${device.id} on peer ${peer} (${codeOf(err)})`);
|
|
1716
|
-
const old2 = entries.get(peer);
|
|
1717
|
-
if (old2) close(old2, "new hello");
|
|
1718
|
-
if (epoch === e0 && (peerGen.get(peer) ?? 0) === g0) rekey(peer);
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
pcFingerprint ??= fingerprint(opts.pc.publicKey);
|
|
1722
|
-
const f = await pcFingerprint;
|
|
1723
|
-
if (epoch !== e0 || (peerGen.get(peer) ?? 0) !== g0) return;
|
|
1724
|
-
if (!approved(device.id, device.publicKey)) {
|
|
1725
|
-
log(`mobile channel: denied hello from peer ${peer}, device ${device.id} revoked`);
|
|
1726
|
-
denied(peer);
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
const old = entries.get(peer);
|
|
1730
|
-
if (old) close(old, "new hello");
|
|
1731
|
-
entries.set(peer, { peer, deviceId: device.id, publicKey: device.publicKey, channel, confirmed: false, confirmedSeq: 0 });
|
|
1732
|
-
lastDevice.set(peer, device.id);
|
|
1733
|
-
log(`mobile channel: hello from device ${device.id} on peer ${peer}`);
|
|
1734
|
-
opts.send(peer, { k: "hello", e: reply, f });
|
|
1735
|
-
};
|
|
1736
|
-
const onMsg = async (peer, frame) => {
|
|
1737
|
-
const e = entries.get(peer);
|
|
1738
|
-
if (!e) {
|
|
1739
|
-
const id = lastDevice.get(peer);
|
|
1740
|
-
if (id !== void 0 && !approved(id)) {
|
|
1741
|
-
log(`mobile channel: denied msg from peer ${peer}, device ${id} revoked`);
|
|
1742
|
-
denied(peer);
|
|
1743
|
-
} else {
|
|
1744
|
-
rekey(peer);
|
|
1745
|
-
}
|
|
1746
|
-
return;
|
|
1747
|
-
}
|
|
1748
|
-
if (!approved(e.deviceId, e.publicKey)) {
|
|
1749
|
-
close(e, "revoked");
|
|
1750
|
-
denied(peer);
|
|
1751
|
-
return;
|
|
1752
|
-
}
|
|
1753
|
-
let text;
|
|
1754
|
-
try {
|
|
1755
|
-
text = await e.channel.open(frame.s);
|
|
1756
|
-
} catch (err) {
|
|
1757
|
-
if (err instanceof SealError && err.code === "replay") return;
|
|
1758
|
-
if (entries.get(peer) !== e) return;
|
|
1759
|
-
close(e, codeOf(err));
|
|
1760
|
-
rekey(peer);
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
|
-
if (entries.get(peer) !== e) return;
|
|
1764
|
-
if (!approved(e.deviceId, e.publicKey)) {
|
|
1765
|
-
close(e, "revoked");
|
|
1766
|
-
denied(peer);
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
if (e.confirmed) {
|
|
1770
|
-
opts.onMessage(e.deviceId, text);
|
|
1771
|
-
return;
|
|
1772
|
-
}
|
|
1773
|
-
if (text !== READY) {
|
|
1774
|
-
close(e, "no key confirmation");
|
|
1775
|
-
rekey(peer);
|
|
1776
|
-
return;
|
|
1777
|
-
}
|
|
1778
|
-
const sealed = await e.channel.seal(READY);
|
|
1779
|
-
if (entries.get(peer) !== e) return;
|
|
1780
|
-
e.confirmed = true;
|
|
1781
|
-
e.confirmedSeq = ++confirmations;
|
|
1782
|
-
opts.send(peer, { k: "msg", s: sealed });
|
|
1783
|
-
log(`mobile channel: confirmed device ${e.deviceId} on peer ${peer}`);
|
|
1784
|
-
opts.onConfirmed(e.deviceId, peer);
|
|
1785
|
-
};
|
|
1786
|
-
const handle = async (peer, frame) => {
|
|
1787
|
-
if (frame.k === "hello") await onHello(peer, frame);
|
|
1788
|
-
else if (frame.k === "msg") await onMsg(peer, frame);
|
|
1789
|
-
};
|
|
1790
|
-
return {
|
|
1791
|
-
receive(peer, frame) {
|
|
1792
|
-
const run = (queues.get(peer) ?? Promise.resolve()).then(() => handle(peer, frame)).catch((err) => log(`mobile channel: error on peer ${peer} (${codeOf(err)})`));
|
|
1793
|
-
queues.set(peer, run);
|
|
1794
|
-
void run.then(() => {
|
|
1795
|
-
if (queues.get(peer) === run) queues.delete(peer);
|
|
1796
|
-
});
|
|
1797
|
-
return run;
|
|
1798
|
-
},
|
|
1799
|
-
async send(deviceId, text) {
|
|
1800
|
-
let best = null;
|
|
1801
|
-
for (const e2 of entries.values()) {
|
|
1802
|
-
if (e2.deviceId === deviceId && e2.confirmed && (!best || e2.confirmedSeq > best.confirmedSeq)) best = e2;
|
|
1803
|
-
}
|
|
1804
|
-
if (!best || !approved(best.deviceId, best.publicKey)) return false;
|
|
1805
|
-
const e = best;
|
|
1806
|
-
const sealed = await e.channel.seal(text);
|
|
1807
|
-
if (entries.get(e.peer) !== e) return false;
|
|
1808
|
-
opts.send(e.peer, { k: "msg", s: sealed });
|
|
1809
|
-
return true;
|
|
1810
|
-
},
|
|
1811
|
-
drop(peer) {
|
|
1812
|
-
peerGen.set(peer, (peerGen.get(peer) ?? 0) + 1);
|
|
1813
|
-
const e = entries.get(peer);
|
|
1814
|
-
if (e) close(e, "peer gone");
|
|
1815
|
-
},
|
|
1816
|
-
dropDevice(deviceId) {
|
|
1817
|
-
for (const e of [...entries.values()]) if (e.deviceId === deviceId) close(e, "revoked");
|
|
1818
|
-
},
|
|
1819
|
-
reset() {
|
|
1820
|
-
epoch++;
|
|
1821
|
-
peerGen.clear();
|
|
1822
|
-
for (const e of [...entries.values()]) close(e, "relay welcome");
|
|
1823
|
-
},
|
|
1824
|
-
online() {
|
|
1825
|
-
return new Set([...entries.values()].filter((e) => e.confirmed).map((e) => e.deviceId));
|
|
1826
|
-
}
|
|
1827
|
-
};
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
// src/mobile/service.ts
|
|
1831
|
-
var REVOKE_TIMEOUT_MS = 15e3;
|
|
1832
|
-
var PUSH_TIMEOUT_MS = 1e4;
|
|
1833
|
-
var REVOCATION_MAX_AGE_MS = 30 * 864e5;
|
|
1834
|
-
async function createMobileService(opts = {}) {
|
|
1835
|
-
const now = opts.now ?? Date.now;
|
|
1836
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1837
|
-
const pc = await loadPcIdentity();
|
|
1838
|
-
let config = readMobileConfig();
|
|
1839
|
-
let session = readRelaySession();
|
|
1840
|
-
let devices = readDevices();
|
|
1841
|
-
let revocations = readRevocations();
|
|
1842
|
-
let link = null;
|
|
1843
|
-
let linkState = { state: "off" };
|
|
1844
|
-
let signin = null;
|
|
1845
|
-
let signinError = null;
|
|
1846
|
-
let signinRun = null;
|
|
1847
|
-
let stopped = false;
|
|
1848
|
-
let pushKey;
|
|
1849
|
-
const changeListeners = /* @__PURE__ */ new Set();
|
|
1850
|
-
const messageListeners = /* @__PURE__ */ new Set();
|
|
1851
|
-
const changed = () => {
|
|
1852
|
-
if (stopped) return;
|
|
1853
|
-
for (const fn of changeListeners) {
|
|
1854
|
-
try {
|
|
1855
|
-
fn();
|
|
1856
|
-
} catch (e) {
|
|
1857
|
-
log(`mobile: change listener failed (${e.message})`);
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
};
|
|
1861
|
-
const sendFrame = (peer, frame) => {
|
|
1862
|
-
link?.send({ type: "send", to: peer, body: JSON.stringify(frame) });
|
|
1863
|
-
};
|
|
1864
|
-
const saveDevices = (next) => {
|
|
1865
|
-
writeDevices(next);
|
|
1866
|
-
devices = next;
|
|
1867
|
-
};
|
|
1868
|
-
const pairing = createPairing({
|
|
1869
|
-
pc,
|
|
1870
|
-
send: sendFrame,
|
|
1871
|
-
approve(d) {
|
|
1872
|
-
const old = devices.find((x) => x.id === d.id);
|
|
1873
|
-
saveDevices(old ? devices.map((x) => x.id === d.id ? { ...x, label: d.label } : x) : [...devices, d]);
|
|
1874
|
-
},
|
|
1875
|
-
onChange: changed,
|
|
1876
|
-
now,
|
|
1877
|
-
...opts.schedule ? { schedule: opts.schedule } : {}
|
|
1878
|
-
});
|
|
1879
|
-
const channels = createChannels({
|
|
1880
|
-
pc,
|
|
1881
|
-
devices: () => devices,
|
|
1882
|
-
send: sendFrame,
|
|
1883
|
-
onConfirmed(deviceId, peer) {
|
|
1884
|
-
if (devices.some((d) => d.id === deviceId)) {
|
|
1885
|
-
const at = new Date(now()).toISOString();
|
|
1886
|
-
try {
|
|
1887
|
-
saveDevices(devices.map((d) => d.id === deviceId ? { ...d, lastPeer: peer, lastSeenAt: at } : d));
|
|
1888
|
-
} catch (e) {
|
|
1889
|
-
log(`mobile: devices.json not written (${e.message})`);
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
pairing.linked(deviceId);
|
|
1893
|
-
changed();
|
|
1894
|
-
},
|
|
1895
|
-
onMessage(deviceId, text) {
|
|
1896
|
-
for (const fn of messageListeners) {
|
|
1897
|
-
try {
|
|
1898
|
-
fn(deviceId, text);
|
|
1899
|
-
} catch (e) {
|
|
1900
|
-
log(`mobile: message listener failed (${e.message})`);
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
},
|
|
1904
|
-
onClosed: () => changed(),
|
|
1905
|
-
now
|
|
1906
|
-
});
|
|
1907
|
-
const onRelayMessage = (m) => {
|
|
1908
|
-
switch (m.type) {
|
|
1909
|
-
case "welcome":
|
|
1910
|
-
channels.reset();
|
|
1911
|
-
pairing.reset();
|
|
1912
|
-
changed();
|
|
1913
|
-
return;
|
|
1914
|
-
case "deliver": {
|
|
1915
|
-
if (m.from === "pc") return;
|
|
1916
|
-
let raw;
|
|
1917
|
-
try {
|
|
1918
|
-
raw = JSON.parse(m.body);
|
|
1919
|
-
} catch {
|
|
1920
|
-
return;
|
|
1921
|
-
}
|
|
1922
|
-
const r = SealedFrameSchema.safeParse(raw);
|
|
1923
|
-
if (!r.success) return;
|
|
1924
|
-
if (r.data.k.startsWith("pair_")) {
|
|
1925
|
-
pairing.receive(m.from, r.data).catch((e) => log(`mobile pairing: error on peer ${m.from} (${e.name})`));
|
|
1926
|
-
} else {
|
|
1927
|
-
void channels.receive(m.from, r.data);
|
|
1928
|
-
}
|
|
1929
|
-
return;
|
|
1930
|
-
}
|
|
1931
|
-
case "presence":
|
|
1932
|
-
if (!m.online) channels.drop(m.peer);
|
|
1933
|
-
return;
|
|
1934
|
-
case "error":
|
|
1935
|
-
if (m.to) channels.drop(m.to);
|
|
1936
|
-
return;
|
|
1937
|
-
}
|
|
1938
|
-
};
|
|
1939
|
-
const closeLink = () => {
|
|
1940
|
-
const old = link;
|
|
1941
|
-
link = null;
|
|
1942
|
-
old?.close();
|
|
1943
|
-
if (old) pairing.reset();
|
|
1944
|
-
linkState = { state: "off" };
|
|
1945
|
-
pushKey = void 0;
|
|
1946
|
-
};
|
|
1947
|
-
const openLink = () => {
|
|
1948
|
-
if (stopped || link || !config.enabled || !session) return;
|
|
1949
|
-
const self = { link: null };
|
|
1950
|
-
const created = createRelayLink({
|
|
1951
|
-
relayUrl: session.relayUrl,
|
|
1952
|
-
token: session.token,
|
|
1953
|
-
onState(s) {
|
|
1954
|
-
if (self.link !== null && link !== self.link) return;
|
|
1955
|
-
linkState = s;
|
|
1956
|
-
changed();
|
|
1957
|
-
if (s.state === "online") {
|
|
1958
|
-
void retryRevocations();
|
|
1959
|
-
void readPushKey();
|
|
1960
|
-
}
|
|
1961
|
-
},
|
|
1962
|
-
onMessage(m) {
|
|
1963
|
-
if (link !== self.link) return;
|
|
1964
|
-
onRelayMessage(m);
|
|
1965
|
-
},
|
|
1966
|
-
now,
|
|
1967
|
-
...opts.WebSocketImpl ? { WebSocketImpl: opts.WebSocketImpl } : {},
|
|
1968
|
-
...opts.schedule ? { schedule: opts.schedule } : {}
|
|
1969
|
-
});
|
|
1970
|
-
self.link = created;
|
|
1971
|
-
link = created;
|
|
1972
|
-
};
|
|
1973
|
-
const cancelSignin = () => {
|
|
1974
|
-
signinRun?.cancel();
|
|
1975
|
-
signinRun = null;
|
|
1976
|
-
signin = null;
|
|
1977
|
-
};
|
|
1978
|
-
const startSignin = () => {
|
|
1979
|
-
if (!config.enabled) throw new Error("mobile access is disabled");
|
|
1980
|
-
cancelSignin();
|
|
1981
|
-
signinError = null;
|
|
1982
|
-
const relayUrl = config.relayUrl;
|
|
1983
|
-
const run = startSignIn({
|
|
1984
|
-
relayUrl,
|
|
1985
|
-
onCode(c) {
|
|
1986
|
-
if (signinRun !== run) return;
|
|
1987
|
-
signin = c;
|
|
1988
|
-
changed();
|
|
1989
|
-
},
|
|
1990
|
-
fetchImpl,
|
|
1991
|
-
...opts.sleep ? { sleep: opts.sleep } : {}
|
|
1992
|
-
});
|
|
1993
|
-
signinRun = run;
|
|
1994
|
-
void run.result.then((r) => {
|
|
1995
|
-
if (signinRun !== run) return;
|
|
1996
|
-
signinRun = null;
|
|
1997
|
-
signin = null;
|
|
1998
|
-
if (r.status === "ok") {
|
|
1999
|
-
writeRelaySession(r.session);
|
|
2000
|
-
session = r.session;
|
|
2001
|
-
log(`mobile: signed in as ${r.session.login}`);
|
|
2002
|
-
closeLink();
|
|
2003
|
-
openLink();
|
|
2004
|
-
} else if (r.status !== "cancelled") {
|
|
2005
|
-
signinError = r.status;
|
|
2006
|
-
log(`mobile: sign-in ended (${r.status === "failed" ? r.error : r.status})`);
|
|
2007
|
-
}
|
|
2008
|
-
changed();
|
|
2009
|
-
}).catch((e) => {
|
|
2010
|
-
log(`mobile: sign-in not saved (${e.message})`);
|
|
2011
|
-
if (signinRun === null) {
|
|
2012
|
-
signinError = "failed";
|
|
2013
|
-
changed();
|
|
2014
|
-
}
|
|
2015
|
-
});
|
|
2016
|
-
};
|
|
2017
|
-
const saveRevocations = (next) => {
|
|
2018
|
-
try {
|
|
2019
|
-
writeRevocations(next);
|
|
2020
|
-
} catch (e) {
|
|
2021
|
-
log(`mobile: revocations.json not written (${e.message})`);
|
|
2022
|
-
}
|
|
2023
|
-
revocations = next;
|
|
2024
|
-
};
|
|
2025
|
-
const revoking = /* @__PURE__ */ new Set();
|
|
2026
|
-
const tryRevoke = async (peer) => {
|
|
2027
|
-
if (!session || linkState.state !== "online" || revoking.has(peer)) return;
|
|
2028
|
-
revoking.add(peer);
|
|
2029
|
-
try {
|
|
2030
|
-
const res = await fetchImpl(new URL("/auth/devices/revoke", session.relayUrl).toString(), {
|
|
2031
|
-
method: "POST",
|
|
2032
|
-
headers: { authorization: `Bearer ${session.token}`, "content-type": "application/json" },
|
|
2033
|
-
body: JSON.stringify({ peer }),
|
|
2034
|
-
signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
|
|
2035
|
-
});
|
|
2036
|
-
await res.body?.cancel();
|
|
2037
|
-
if (res.ok || res.status === 404) {
|
|
2038
|
-
saveRevocations(revocations.filter((r) => r.peer !== peer));
|
|
2039
|
-
log(`mobile: relay revoked peer ${peer}`);
|
|
2040
|
-
} else log(`mobile: relay did not revoke peer ${peer} (status ${res.status}), kept for a retry`);
|
|
2041
|
-
} catch (e) {
|
|
2042
|
-
log(`mobile: relay did not revoke peer ${peer} (${e.name}), kept for a retry`);
|
|
2043
|
-
} finally {
|
|
2044
|
-
revoking.delete(peer);
|
|
2045
|
-
}
|
|
2046
|
-
};
|
|
2047
|
-
const readPushKey = async () => {
|
|
2048
|
-
const at = session;
|
|
2049
|
-
if (!at) return;
|
|
2050
|
-
try {
|
|
2051
|
-
const res = await fetchImpl(new URL("/push/key", at.relayUrl).toString(), {
|
|
2052
|
-
headers: { authorization: `Bearer ${at.token}` },
|
|
2053
|
-
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS)
|
|
2054
|
-
});
|
|
2055
|
-
let next;
|
|
2056
|
-
if (res.status === 404) {
|
|
2057
|
-
next = null;
|
|
2058
|
-
await res.body?.cancel();
|
|
2059
|
-
} else if (res.ok) {
|
|
2060
|
-
const body = await res.json();
|
|
2061
|
-
next = typeof body.publicKey === "string" && body.publicKey ? body.publicKey : void 0;
|
|
2062
|
-
} else await res.body?.cancel();
|
|
2063
|
-
if (next !== void 0 && session === at && next !== pushKey) {
|
|
2064
|
-
pushKey = next;
|
|
2065
|
-
changed();
|
|
2066
|
-
}
|
|
2067
|
-
} catch (e) {
|
|
2068
|
-
log(`mobile: relay push key not read (${e.name})`);
|
|
2069
|
-
}
|
|
2070
|
-
};
|
|
2071
|
-
const retryRevocations = async () => {
|
|
2072
|
-
const limit = now() - REVOCATION_MAX_AGE_MS;
|
|
2073
|
-
const stale = revocations.filter((r) => !(Date.parse(r.at) >= limit));
|
|
2074
|
-
if (stale.length > 0) {
|
|
2075
|
-
for (const r of stale) log(`mobile: giving up the revocation of peer ${r.peer} (older than 30 days)`);
|
|
2076
|
-
saveRevocations(revocations.filter((r) => !stale.includes(r)));
|
|
2077
|
-
}
|
|
2078
|
-
await Promise.all(revocations.map((r) => tryRevoke(r.peer)));
|
|
2079
|
-
};
|
|
2080
|
-
const revokeOnRelay = async (peer) => {
|
|
2081
|
-
if (!revocations.some((r) => r.peer === peer)) saveRevocations([...revocations, { peer, at: new Date(now()).toISOString() }]);
|
|
2082
|
-
await tryRevoke(peer);
|
|
2083
|
-
};
|
|
2084
|
-
const revoke = async (id) => {
|
|
2085
|
-
const d = devices.find((x) => x.id === id);
|
|
2086
|
-
if (!d) return;
|
|
2087
|
-
saveDevices(devices.filter((x) => x.id !== id));
|
|
2088
|
-
log(`mobile: revoked device ${id}`);
|
|
2089
|
-
channels.dropDevice(id);
|
|
2090
|
-
if (d.lastPeer) sendFrame(d.lastPeer, { k: "denied" });
|
|
2091
|
-
changed();
|
|
2092
|
-
if (d.lastPeer) await revokeOnRelay(d.lastPeer);
|
|
2093
|
-
};
|
|
2094
|
-
const pairingIn = (id, states) => {
|
|
2095
|
-
const p = pairing.views().find((v) => v.id === id);
|
|
2096
|
-
if (!p || !states.includes(p.state)) throw new Error("no such pairing waiting for a decision");
|
|
2097
|
-
};
|
|
2098
|
-
openLink();
|
|
2099
|
-
return {
|
|
2100
|
-
state() {
|
|
2101
|
-
const online = linkState.state === "online" ? channels.online() : /* @__PURE__ */ new Set();
|
|
2102
|
-
return {
|
|
2103
|
-
enabled: config.enabled,
|
|
2104
|
-
relayUrl: config.relayUrl,
|
|
2105
|
-
account: session ? { login: session.login, accountId: session.accountId } : null,
|
|
2106
|
-
signin,
|
|
2107
|
-
signinError,
|
|
2108
|
-
link: linkState,
|
|
2109
|
-
devices: devices.map((d) => ({
|
|
2110
|
-
id: d.id,
|
|
2111
|
-
label: d.label,
|
|
2112
|
-
approvedAt: d.approvedAt,
|
|
2113
|
-
online: online.has(d.id),
|
|
2114
|
-
...d.lastSeenAt ? { lastSeenAt: d.lastSeenAt } : {}
|
|
2115
|
-
})),
|
|
2116
|
-
pairings: pairing.views()
|
|
2117
|
-
};
|
|
2118
|
-
},
|
|
2119
|
-
revoke,
|
|
2120
|
-
credential(deviceId) {
|
|
2121
|
-
const c = devices.find((d) => d.id === deviceId)?.credential;
|
|
2122
|
-
return c && c.rpId === rpOf(config.relayUrl)?.rpId ? c : null;
|
|
2123
|
-
},
|
|
2124
|
-
setCredential(deviceId, c) {
|
|
2125
|
-
if (!devices.some((d) => d.id === deviceId)) throw new Error("no such device");
|
|
2126
|
-
saveDevices(devices.map((d) => d.id === deviceId ? { ...d, credential: c } : d));
|
|
2127
|
-
},
|
|
2128
|
-
pushOf(deviceId) {
|
|
2129
|
-
return devices.find((d) => d.id === deviceId)?.push ?? null;
|
|
2130
|
-
},
|
|
2131
|
-
setPush(deviceId, p) {
|
|
2132
|
-
if (!devices.some((d) => d.id === deviceId)) throw new Error("no such device");
|
|
2133
|
-
saveDevices(devices.map((d) => {
|
|
2134
|
-
if (d.id !== deviceId) return d;
|
|
2135
|
-
const { push: _old, ...rest } = d;
|
|
2136
|
-
return p ? { ...rest, push: p } : rest;
|
|
2137
|
-
}));
|
|
2138
|
-
changed();
|
|
2139
|
-
},
|
|
2140
|
-
relayPushKey: () => pushKey,
|
|
2141
|
-
async sendPush(msg) {
|
|
2142
|
-
const at = session;
|
|
2143
|
-
if (!at || linkState.state !== "online") return { result: "failed" };
|
|
2144
|
-
try {
|
|
2145
|
-
const res = await fetchImpl(new URL("/push/send", at.relayUrl).toString(), {
|
|
2146
|
-
method: "POST",
|
|
2147
|
-
headers: { authorization: `Bearer ${at.token}`, "content-type": "application/json" },
|
|
2148
|
-
body: JSON.stringify(msg),
|
|
2149
|
-
signal: AbortSignal.timeout(PUSH_TIMEOUT_MS)
|
|
2150
|
-
});
|
|
2151
|
-
if (!res.ok) {
|
|
2152
|
-
await res.body?.cancel();
|
|
2153
|
-
return { result: "failed", status: res.status };
|
|
2154
|
-
}
|
|
2155
|
-
const parsed = PushSendResultSchema.safeParse(await res.json());
|
|
2156
|
-
return parsed.success ? parsed.data : { result: "failed" };
|
|
2157
|
-
} catch (e) {
|
|
2158
|
-
log(`mobile: push not sent (${e.name})`);
|
|
2159
|
-
return { result: "failed" };
|
|
2160
|
-
}
|
|
2161
|
-
},
|
|
2162
|
-
onChange(fn) {
|
|
2163
|
-
changeListeners.add(fn);
|
|
2164
|
-
return () => {
|
|
2165
|
-
changeListeners.delete(fn);
|
|
2166
|
-
};
|
|
2167
|
-
},
|
|
2168
|
-
async handle(msg) {
|
|
2169
|
-
if (stopped) throw new Error("mobile access is stopped");
|
|
2170
|
-
switch (msg.type) {
|
|
2171
|
-
case "mobile.set": {
|
|
2172
|
-
const next = { enabled: msg.enabled, relayUrl: msg.relayUrl };
|
|
2173
|
-
const moved = next.relayUrl !== config.relayUrl;
|
|
2174
|
-
writeMobileConfig(next);
|
|
2175
|
-
config = next;
|
|
2176
|
-
if (moved) {
|
|
2177
|
-
cancelSignin();
|
|
2178
|
-
clearRelaySession();
|
|
2179
|
-
session = null;
|
|
2180
|
-
closeLink();
|
|
2181
|
-
}
|
|
2182
|
-
if (!config.enabled) {
|
|
2183
|
-
cancelSignin();
|
|
2184
|
-
closeLink();
|
|
2185
|
-
} else {
|
|
2186
|
-
openLink();
|
|
2187
|
-
}
|
|
2188
|
-
changed();
|
|
2189
|
-
return;
|
|
2190
|
-
}
|
|
2191
|
-
case "mobile.signin":
|
|
2192
|
-
startSignin();
|
|
2193
|
-
changed();
|
|
2194
|
-
return;
|
|
2195
|
-
case "mobile.signin.cancel":
|
|
2196
|
-
cancelSignin();
|
|
2197
|
-
changed();
|
|
2198
|
-
return;
|
|
2199
|
-
case "mobile.signout": {
|
|
2200
|
-
cancelSignin();
|
|
2201
|
-
const old = session;
|
|
2202
|
-
session = null;
|
|
2203
|
-
clearRelaySession();
|
|
2204
|
-
closeLink();
|
|
2205
|
-
changed();
|
|
2206
|
-
if (old) await signOut(old, fetchImpl);
|
|
2207
|
-
log("mobile: signed out");
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
case "mobile.retry":
|
|
2211
|
-
link?.retryNow();
|
|
2212
|
-
return;
|
|
2213
|
-
case "mobile.pairing.approve":
|
|
2214
|
-
pairingIn(msg.id, ["confirm"]);
|
|
2215
|
-
if (linkState.state !== "online") throw new Error("the relay is offline");
|
|
2216
|
-
pairing.approve(msg.id);
|
|
2217
|
-
return;
|
|
2218
|
-
case "mobile.pairing.deny":
|
|
2219
|
-
pairingIn(msg.id, ["confirm", "waiting"]);
|
|
2220
|
-
pairing.deny(msg.id);
|
|
2221
|
-
return;
|
|
2222
|
-
case "mobile.device.revoke":
|
|
2223
|
-
return revoke(msg.id);
|
|
2224
|
-
}
|
|
2225
|
-
},
|
|
2226
|
-
send: (deviceId, text) => channels.send(deviceId, text),
|
|
2227
|
-
onMessage(fn) {
|
|
2228
|
-
messageListeners.add(fn);
|
|
2229
|
-
return () => {
|
|
2230
|
-
messageListeners.delete(fn);
|
|
2231
|
-
};
|
|
2232
|
-
},
|
|
2233
|
-
async stop() {
|
|
2234
|
-
if (stopped) return;
|
|
2235
|
-
cancelSignin();
|
|
2236
|
-
closeLink();
|
|
2237
|
-
pairing.stop();
|
|
2238
|
-
stopped = true;
|
|
2239
|
-
changeListeners.clear();
|
|
2240
|
-
messageListeners.clear();
|
|
2241
|
-
}
|
|
2242
|
-
};
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
// src/daemon/index.ts
|
|
2246
|
-
async function startDaemon(opts = {}) {
|
|
2247
|
-
await ensureLedgeDir();
|
|
2248
|
-
const store = new Store();
|
|
2249
|
-
const loaded = await store.load();
|
|
2250
|
-
const tmux = opts.tmux ?? realTmux(opts.tmuxSocket ?? "ledge");
|
|
2251
|
-
let live = null;
|
|
2252
|
-
try {
|
|
2253
|
-
live = new Set(await tmux.listSessions());
|
|
2254
|
-
} catch (e) {
|
|
2255
|
-
log(`startup prune skipped, tmux unavailable: ${e.message}`);
|
|
2256
|
-
}
|
|
2257
|
-
if (live) for (const s of store.all()) {
|
|
2258
|
-
if (s.tmuxSession && !live.has(s.tmuxSession)) store.apply(s.id, { name: "PaneGone", at: (/* @__PURE__ */ new Date()).toISOString(), source: "daemon" });
|
|
2259
|
-
}
|
|
2260
|
-
for (const s of store.all()) {
|
|
2261
|
-
if (backendOf(s) !== "omp-rpc") continue;
|
|
2262
|
-
await reapOwner(join4(paths().dir, "omp", s.id)).catch(() => false);
|
|
2263
|
-
if (s.state === "exited") continue;
|
|
2264
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2265
|
-
store.apply(s.id, { name: "StopFailure", at, source: "daemon", text: "Ledge restarted: the OMP process ended with it" });
|
|
2266
|
-
store.apply(s.id, { name: "SessionEnd", at, source: "daemon", exitCode: null });
|
|
2267
|
-
}
|
|
2268
|
-
const daemon = await daemonInfo();
|
|
2269
|
-
let ompLive = () => 0;
|
|
2270
|
-
const updater = createDaemonUpdater(realUpdaterDeps({ info: daemon, save: () => store.save(), ownedLive: () => ompLive() }));
|
|
2271
|
-
const attachments = new AttachmentStore(paths().attachments);
|
|
2272
|
-
await attachments.load().catch((e) => log(`attachments not loaded: ${e.message}`));
|
|
2273
|
-
const history = new History({ peerOf: (t) => ledgePeer(t, readPeers(), store.all())?.id, refOf: (id) => attachments.ref(id) });
|
|
2274
|
-
try {
|
|
2275
|
-
await history.load();
|
|
2276
|
-
} catch (e) {
|
|
2277
|
-
log(`history not loaded: ${e.message}`);
|
|
2278
|
-
}
|
|
2279
|
-
if (loaded.clean) {
|
|
2280
|
-
for (const id of history.ids()) if (!store.get(id)) history.forget(id);
|
|
2281
|
-
} else log(`history cleanup skipped: ${loaded.reason}`);
|
|
2282
|
-
let mobile;
|
|
2283
|
-
let mobileUnavailable;
|
|
2284
|
-
try {
|
|
2285
|
-
mobile = await createMobileService();
|
|
2286
|
-
} catch (e) {
|
|
2287
|
-
log(`mobile access not available: ${e.message}`);
|
|
2288
|
-
mobileUnavailable = e.message.slice(0, 200);
|
|
2289
|
-
}
|
|
2290
|
-
const mail = fileJournal(paths().mail);
|
|
2291
|
-
const taskJournal = tasksJournal(paths().tasks);
|
|
2292
|
-
const teamJournal = teamsJournal(paths().teams);
|
|
2293
|
-
const server = await startServer({ store, tmux, port: opts.port, daemon, updater, history, mobile, mobileUnavailable, mail, tasks: taskJournal, teams: teamJournal, attachments });
|
|
2294
|
-
ompLive = () => server.omp.liveCount();
|
|
2295
|
-
const poller = startPoller({ store, tmux, broadcastPreview: server.preview, labelExited: server.labelExited });
|
|
2296
|
-
await writeFile3(paths().pid, String(process.pid));
|
|
2297
|
-
log(`daemon started on ${server.port} with ${store.all().length} sessions`);
|
|
2298
|
-
let stopping = null;
|
|
2299
|
-
const stop = () => stopping ??= (async () => {
|
|
2300
|
-
process.off("SIGTERM", onSignal);
|
|
2301
|
-
process.off("SIGINT", onSignal);
|
|
2302
|
-
poller.stop();
|
|
2303
|
-
await server.close();
|
|
2304
|
-
await mobile?.stop();
|
|
2305
|
-
await store.save();
|
|
2306
|
-
await history.flush();
|
|
2307
|
-
await mail.flush();
|
|
2308
|
-
await taskJournal.flush();
|
|
2309
|
-
await teamJournal.flush();
|
|
2310
|
-
await rm2(paths().pid, { force: true });
|
|
2311
|
-
log("daemon stopped");
|
|
2312
|
-
})();
|
|
2313
|
-
const onSignal = () => {
|
|
2314
|
-
stop().finally(() => process.exit(0));
|
|
2315
|
-
};
|
|
2316
|
-
process.once("SIGTERM", onSignal);
|
|
2317
|
-
process.once("SIGINT", onSignal);
|
|
2318
|
-
return { stop, port: server.port };
|
|
2319
|
-
}
|
|
2320
|
-
export {
|
|
2321
|
-
startDaemon
|
|
2322
|
-
};
|