@remnic/capture-screen 9.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/chunk-5EJ57MSJ.js +2150 -0
- package/dist/chunk-5EJ57MSJ.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +15 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/index.d.ts +710 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,2150 @@
|
|
|
1
|
+
// openclaw-engram: Local-first memory plugin
|
|
2
|
+
|
|
3
|
+
// src/axtree.ts
|
|
4
|
+
var SECURE_ROLE = "AXSecureTextField";
|
|
5
|
+
function nodeText(node) {
|
|
6
|
+
const pieces = [];
|
|
7
|
+
for (const field of [node.value, node.title, node.description, node.label]) {
|
|
8
|
+
if (typeof field === "string" && field.trim().length > 0) pieces.push(field.trim());
|
|
9
|
+
}
|
|
10
|
+
return pieces.join(" ");
|
|
11
|
+
}
|
|
12
|
+
function extractAxText(root, maxNodes) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
const stack = [root];
|
|
15
|
+
let visited = 0;
|
|
16
|
+
let truncated = false;
|
|
17
|
+
while (stack.length > 0) {
|
|
18
|
+
if (visited >= maxNodes) {
|
|
19
|
+
truncated = true;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
const node = stack.pop();
|
|
23
|
+
visited += 1;
|
|
24
|
+
if (node.offScreen === true) continue;
|
|
25
|
+
if (node.role === SECURE_ROLE) continue;
|
|
26
|
+
const text = nodeText(node);
|
|
27
|
+
if (text.length > 0) lines.push(text);
|
|
28
|
+
if (Array.isArray(node.children)) {
|
|
29
|
+
for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { text: lines.join("\n"), nodes: visited, truncated };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/simhash.ts
|
|
36
|
+
var MASK64 = (1n << 64n) - 1n;
|
|
37
|
+
var FNV_OFFSET = 14695981039346656037n;
|
|
38
|
+
var FNV_PRIME = 1099511628211n;
|
|
39
|
+
var SHINGLE_SIZE = 2;
|
|
40
|
+
function tokenize(text) {
|
|
41
|
+
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
42
|
+
}
|
|
43
|
+
function shingles(tokens) {
|
|
44
|
+
if (tokens.length < SHINGLE_SIZE) {
|
|
45
|
+
return tokens.length > 0 ? [tokens.join(" ")] : [];
|
|
46
|
+
}
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let i = 0; i + SHINGLE_SIZE <= tokens.length; i++) {
|
|
49
|
+
out.push(tokens.slice(i, i + SHINGLE_SIZE).join(" "));
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function hash64(s) {
|
|
54
|
+
let h = FNV_OFFSET;
|
|
55
|
+
for (let i = 0; i < s.length; i++) {
|
|
56
|
+
h ^= BigInt(s.charCodeAt(i));
|
|
57
|
+
h = h * FNV_PRIME & MASK64;
|
|
58
|
+
}
|
|
59
|
+
return h;
|
|
60
|
+
}
|
|
61
|
+
function simhash(text) {
|
|
62
|
+
const grams = shingles(tokenize(text));
|
|
63
|
+
if (grams.length === 0) return 0n;
|
|
64
|
+
const votes = new Array(64).fill(0);
|
|
65
|
+
for (const gram of grams) {
|
|
66
|
+
const h = hash64(gram);
|
|
67
|
+
for (let b = 0; b < 64; b++) {
|
|
68
|
+
votes[b] += h >> BigInt(b) & 1n ? 1 : -1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let out = 0n;
|
|
72
|
+
for (let b = 0; b < 64; b++) {
|
|
73
|
+
if (votes[b] > 0) out |= 1n << BigInt(b);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function hammingDistance(a, b) {
|
|
78
|
+
let x = (a ^ b) & MASK64;
|
|
79
|
+
let count = 0;
|
|
80
|
+
while (x !== 0n) {
|
|
81
|
+
count += Number(x & 1n);
|
|
82
|
+
x >>= 1n;
|
|
83
|
+
}
|
|
84
|
+
return count;
|
|
85
|
+
}
|
|
86
|
+
function simhashToHex(h) {
|
|
87
|
+
return (h & MASK64).toString(16).padStart(16, "0");
|
|
88
|
+
}
|
|
89
|
+
function simhashFromHex(hex) {
|
|
90
|
+
return BigInt(`0x${hex}`) & MASK64;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/dedup.ts
|
|
94
|
+
var DedupCache = class _DedupCache {
|
|
95
|
+
#last = /* @__PURE__ */ new Map();
|
|
96
|
+
#threshold;
|
|
97
|
+
#ttlSeconds;
|
|
98
|
+
constructor(threshold, ttlSeconds) {
|
|
99
|
+
this.#threshold = threshold;
|
|
100
|
+
this.#ttlSeconds = ttlSeconds;
|
|
101
|
+
}
|
|
102
|
+
static #key(app, windowTitle) {
|
|
103
|
+
return `${app}\0${windowTitle}`;
|
|
104
|
+
}
|
|
105
|
+
/** Seed the last-stored fingerprint for a window (used to prime from the spool). */
|
|
106
|
+
seed(app, windowTitle, hash, atMs) {
|
|
107
|
+
this.#last.set(_DedupCache.#key(app, windowTitle), { hash, atMs });
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Decide whether a snapshot should be stored, updating the cache when it is.
|
|
111
|
+
* First snapshot of a window always stores. A negative elapsed (out-of-order
|
|
112
|
+
* capture) stores defensively rather than dropping data.
|
|
113
|
+
*/
|
|
114
|
+
shouldStore(app, windowTitle, hash, atMs) {
|
|
115
|
+
const key = _DedupCache.#key(app, windowTitle);
|
|
116
|
+
const prev = this.#last.get(key);
|
|
117
|
+
let store;
|
|
118
|
+
if (prev === void 0) {
|
|
119
|
+
store = true;
|
|
120
|
+
} else {
|
|
121
|
+
const elapsedSeconds = (atMs - prev.atMs) / 1e3;
|
|
122
|
+
store = elapsedSeconds < 0 || elapsedSeconds >= this.#ttlSeconds || hammingDistance(hash, prev.hash) > this.#threshold;
|
|
123
|
+
}
|
|
124
|
+
if (store) this.#last.set(key, { hash, atMs });
|
|
125
|
+
return store;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/denylist.ts
|
|
130
|
+
var DEFAULT_DENY_APPS = ["1Password*", "Bitwarden*", "KeePass*"];
|
|
131
|
+
var DEFAULT_DENY_TITLES = [
|
|
132
|
+
"*incognito*",
|
|
133
|
+
"*private browsing*",
|
|
134
|
+
"*inprivate*",
|
|
135
|
+
"*private window*"
|
|
136
|
+
];
|
|
137
|
+
var DEFAULT_DENY_URLS = [];
|
|
138
|
+
function globToRegExp(glob) {
|
|
139
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
140
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
141
|
+
}
|
|
142
|
+
function matchesAnyGlob(patterns, value) {
|
|
143
|
+
return patterns.some((pattern) => globToRegExp(pattern).test(value));
|
|
144
|
+
}
|
|
145
|
+
function firstMatch(patterns, value, kind) {
|
|
146
|
+
for (const pattern of patterns) {
|
|
147
|
+
if (globToRegExp(pattern).test(value)) return `${kind}:${pattern}`;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function matchDenyRule(candidate, lists) {
|
|
152
|
+
const appRule = firstMatch([...DEFAULT_DENY_APPS, ...lists.apps], candidate.app, "app");
|
|
153
|
+
if (appRule !== null) return appRule;
|
|
154
|
+
const titleRule = firstMatch([...DEFAULT_DENY_TITLES, ...lists.titles], candidate.windowTitle, "title");
|
|
155
|
+
if (titleRule !== null) return titleRule;
|
|
156
|
+
if (typeof candidate.browserUrl === "string" && candidate.browserUrl.length > 0) {
|
|
157
|
+
const urlRule = firstMatch([...DEFAULT_DENY_URLS, ...lists.urls], candidate.browserUrl, "url");
|
|
158
|
+
if (urlRule !== null) return urlRule;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/errors.ts
|
|
164
|
+
var CaptureConfigError = class extends Error {
|
|
165
|
+
constructor(message) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.name = "CaptureConfigError";
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var CaptureInputError = class extends Error {
|
|
171
|
+
constructor(message) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = "CaptureInputError";
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/redact.ts
|
|
178
|
+
var REDACTION_PLACEHOLDER = "[REDACTED]";
|
|
179
|
+
var SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/g;
|
|
180
|
+
var CARD_RE = /\b(?:\d[ -]?){13,19}\b/g;
|
|
181
|
+
function luhnValid(digits) {
|
|
182
|
+
let sum = 0;
|
|
183
|
+
let double = false;
|
|
184
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
185
|
+
let d = digits.charCodeAt(i) - 48;
|
|
186
|
+
if (double) {
|
|
187
|
+
d *= 2;
|
|
188
|
+
if (d > 9) d -= 9;
|
|
189
|
+
}
|
|
190
|
+
sum += d;
|
|
191
|
+
double = !double;
|
|
192
|
+
}
|
|
193
|
+
return sum % 10 === 0;
|
|
194
|
+
}
|
|
195
|
+
function redactCards(text) {
|
|
196
|
+
return text.replace(CARD_RE, (match) => {
|
|
197
|
+
const digits = match.replace(/[ -]/g, "");
|
|
198
|
+
if (digits.length < 13 || digits.length > 19 || !luhnValid(digits)) return match;
|
|
199
|
+
return REDACTION_PLACEHOLDER;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function compileRedactionPatterns(sources) {
|
|
203
|
+
return sources.map((source) => {
|
|
204
|
+
try {
|
|
205
|
+
return new RegExp(source, "g");
|
|
206
|
+
} catch {
|
|
207
|
+
throw new CaptureConfigError(`redactionPatterns: '${source}' is not a valid regular expression`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function redactText(text, userPatterns = []) {
|
|
212
|
+
let out = text.replace(SSN_RE, REDACTION_PLACEHOLDER);
|
|
213
|
+
out = redactCards(out);
|
|
214
|
+
for (const pattern of userPatterns) {
|
|
215
|
+
pattern.lastIndex = 0;
|
|
216
|
+
out = out.replace(pattern, REDACTION_PLACEHOLDER);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/capture.ts
|
|
222
|
+
import { createHash } from "crypto";
|
|
223
|
+
var DEFAULT_TERMINAL_APPS = [
|
|
224
|
+
"Terminal",
|
|
225
|
+
"iTerm2",
|
|
226
|
+
"iTerm",
|
|
227
|
+
"Alacritty",
|
|
228
|
+
"kitty",
|
|
229
|
+
"WezTerm",
|
|
230
|
+
"Warp",
|
|
231
|
+
"Hyper",
|
|
232
|
+
"Konsole",
|
|
233
|
+
"gnome-terminal*"
|
|
234
|
+
];
|
|
235
|
+
function isTerminalApp(app, terminalApps, includeDefaults = true) {
|
|
236
|
+
const patterns = includeDefaults ? [...DEFAULT_TERMINAL_APPS, ...terminalApps] : terminalApps;
|
|
237
|
+
return matchesAnyGlob(patterns, app);
|
|
238
|
+
}
|
|
239
|
+
function contentHash(fields) {
|
|
240
|
+
const hash = createHash("sha256");
|
|
241
|
+
const parts = [fields.capturedAtUtc, fields.app, fields.windowTitle, fields.browserUrl ?? "", fields.text, fields.textSource];
|
|
242
|
+
for (const field of parts) {
|
|
243
|
+
hash.update(`${Buffer.byteLength(field)}:`).update(field);
|
|
244
|
+
}
|
|
245
|
+
return hash.digest("hex");
|
|
246
|
+
}
|
|
247
|
+
var CaptureProcessor = class {
|
|
248
|
+
#denyApps;
|
|
249
|
+
#denyTitles;
|
|
250
|
+
#denyUrls;
|
|
251
|
+
#terminalApps;
|
|
252
|
+
#maxNodes;
|
|
253
|
+
#redaction;
|
|
254
|
+
#cache;
|
|
255
|
+
#ocr;
|
|
256
|
+
constructor(config, ocr) {
|
|
257
|
+
this.#denyApps = config.denyApps;
|
|
258
|
+
this.#denyTitles = config.denyTitles;
|
|
259
|
+
this.#denyUrls = config.denyUrls;
|
|
260
|
+
this.#terminalApps = [...DEFAULT_TERMINAL_APPS, ...config.terminalApps];
|
|
261
|
+
this.#maxNodes = config.maxNodes;
|
|
262
|
+
this.#redaction = compileRedactionPatterns(config.redactionPatterns);
|
|
263
|
+
this.#cache = new DedupCache(config.simhashThreshold, config.dedupTtlSeconds);
|
|
264
|
+
this.#ocr = ocr;
|
|
265
|
+
}
|
|
266
|
+
/** Seed the dedup cache from prior spool state so restarts don't re-store. */
|
|
267
|
+
seed(app, windowTitle, simhashHex, capturedAtUtc) {
|
|
268
|
+
this.#cache.seed(app, windowTitle, BigInt(`0x${simhashHex}`), Date.parse(capturedAtUtc));
|
|
269
|
+
}
|
|
270
|
+
process(candidate) {
|
|
271
|
+
const denyRule = matchDenyRule(
|
|
272
|
+
{ app: candidate.app, windowTitle: candidate.windowTitle, browserUrl: candidate.browserUrl },
|
|
273
|
+
{ apps: this.#denyApps, titles: this.#denyTitles, urls: this.#denyUrls }
|
|
274
|
+
);
|
|
275
|
+
if (denyRule !== null) return { action: "denied", rule: denyRule };
|
|
276
|
+
const extracted = this.#extractText(candidate);
|
|
277
|
+
if (extracted === null) return { action: "skipped", reason: "ocr-unavailable" };
|
|
278
|
+
const { source } = extracted;
|
|
279
|
+
const text = redactText(extracted.text, this.#redaction);
|
|
280
|
+
const fingerprint = simhash(text);
|
|
281
|
+
const atMs = Date.parse(candidate.capturedAtUtc);
|
|
282
|
+
if (!this.#cache.shouldStore(candidate.app, candidate.windowTitle, fingerprint, atMs)) {
|
|
283
|
+
return { action: "skipped", reason: "dedup" };
|
|
284
|
+
}
|
|
285
|
+
const browserUrl = candidate.browserUrl ?? null;
|
|
286
|
+
return {
|
|
287
|
+
action: "store",
|
|
288
|
+
snapshot: {
|
|
289
|
+
capturedAtUtc: candidate.capturedAtUtc,
|
|
290
|
+
app: candidate.app,
|
|
291
|
+
windowTitle: candidate.windowTitle,
|
|
292
|
+
browserUrl,
|
|
293
|
+
text,
|
|
294
|
+
textSource: source,
|
|
295
|
+
contentHash: contentHash({
|
|
296
|
+
capturedAtUtc: candidate.capturedAtUtc,
|
|
297
|
+
app: candidate.app,
|
|
298
|
+
windowTitle: candidate.windowTitle,
|
|
299
|
+
browserUrl,
|
|
300
|
+
text,
|
|
301
|
+
textSource: source
|
|
302
|
+
}),
|
|
303
|
+
simhash: simhashToHex(fingerprint)
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** Resolve visible text + its source, or null when OCR was needed but unavailable. */
|
|
308
|
+
#extractText(candidate) {
|
|
309
|
+
if (typeof candidate.text === "string") {
|
|
310
|
+
return { text: candidate.text, source: candidate.textSource ?? "ax" };
|
|
311
|
+
}
|
|
312
|
+
const axText = candidate.ax === void 0 ? "" : extractAxText(candidate.ax, this.#maxNodes).text;
|
|
313
|
+
const needsOcr = isTerminalApp(candidate.app, this.#terminalApps, false) || axText.trim() === "";
|
|
314
|
+
if (!needsOcr) return { text: axText, source: "ax" };
|
|
315
|
+
const ocrText = this.#ocr === void 0 ? null : this.#ocr(candidate);
|
|
316
|
+
if (ocrText !== null && ocrText.trim() !== "") return { text: ocrText, source: "ocr" };
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function computeStats(snapshots, date, timezone, maxDwellSeconds) {
|
|
321
|
+
const ordered = [...snapshots].sort((a, b) => {
|
|
322
|
+
const at = Date.parse(a.capturedAtUtc);
|
|
323
|
+
const bt = Date.parse(b.capturedAtUtc);
|
|
324
|
+
if (at !== bt) return at - bt;
|
|
325
|
+
return a.id - b.id;
|
|
326
|
+
});
|
|
327
|
+
const seconds = /* @__PURE__ */ new Map();
|
|
328
|
+
const counts = /* @__PURE__ */ new Map();
|
|
329
|
+
let totalSeconds = 0;
|
|
330
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
331
|
+
const snap = ordered[i];
|
|
332
|
+
counts.set(snap.app, (counts.get(snap.app) ?? 0) + 1);
|
|
333
|
+
if (i + 1 < ordered.length) {
|
|
334
|
+
const gap = (Date.parse(ordered[i + 1].capturedAtUtc) - Date.parse(snap.capturedAtUtc)) / 1e3;
|
|
335
|
+
const dwell = Math.max(0, Math.min(gap, maxDwellSeconds));
|
|
336
|
+
seconds.set(snap.app, (seconds.get(snap.app) ?? 0) + dwell);
|
|
337
|
+
totalSeconds += dwell;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const apps = [...counts.keys()].map((app) => ({ app, seconds: seconds.get(app) ?? 0, snapshotCount: counts.get(app) ?? 0 })).sort((a, b) => b.seconds !== a.seconds ? b.seconds - a.seconds : a.app < b.app ? -1 : a.app > b.app ? 1 : 0);
|
|
341
|
+
return { date, timezone, snapshotCount: ordered.length, totalSeconds, apps };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/constants.ts
|
|
345
|
+
var CAPTURE_SCREEN_VERSION = "9.14.0";
|
|
346
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
347
|
+
var DEFAULT_PORT = 4341;
|
|
348
|
+
var SPOOL_SCHEMA_VERSION = 1;
|
|
349
|
+
var MAX_SNAPSHOTS_LIMIT = 500;
|
|
350
|
+
var DEFAULT_SNAPSHOTS_LIMIT = 100;
|
|
351
|
+
var DEFAULT_SPOOL_RETENTION_DAYS = 14;
|
|
352
|
+
var DEFAULT_SIMHASH_THRESHOLD = 10;
|
|
353
|
+
var DEFAULT_DEDUP_TTL_SECONDS = 60;
|
|
354
|
+
var DEFAULT_SESSION_GAP_SECONDS = 300;
|
|
355
|
+
var DEFAULT_MAX_NODES = 4e3;
|
|
356
|
+
var DEFAULT_MAX_DWELL_SECONDS = 300;
|
|
357
|
+
var DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
358
|
+
var DEFAULT_SETTLE_MS = 500;
|
|
359
|
+
var DEFAULT_IDLE_FALLBACK_SECONDS = 30;
|
|
360
|
+
|
|
361
|
+
// src/config.ts
|
|
362
|
+
import { readFileSync } from "fs";
|
|
363
|
+
|
|
364
|
+
// src/util.ts
|
|
365
|
+
var LOOPBACK_HOSTS = {
|
|
366
|
+
"127.0.0.1": true,
|
|
367
|
+
"::1": true,
|
|
368
|
+
localhost: true,
|
|
369
|
+
"::ffff:127.0.0.1": true
|
|
370
|
+
};
|
|
371
|
+
function stripIpv6Brackets(host) {
|
|
372
|
+
const h = host.trim();
|
|
373
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
374
|
+
}
|
|
375
|
+
function isLoopbackHost(host) {
|
|
376
|
+
return Object.hasOwn(LOOPBACK_HOSTS, stripIpv6Brackets(host).toLowerCase());
|
|
377
|
+
}
|
|
378
|
+
function formatHostForUrl(host) {
|
|
379
|
+
const bare = stripIpv6Brackets(host);
|
|
380
|
+
return bare.includes(":") ? `[${bare}]` : bare;
|
|
381
|
+
}
|
|
382
|
+
function describeValue(value) {
|
|
383
|
+
if (value === null) return "null";
|
|
384
|
+
if (Array.isArray(value)) return "an array";
|
|
385
|
+
const t = typeof value;
|
|
386
|
+
if (t === "string") return `a string`;
|
|
387
|
+
if (t === "object") return "an object";
|
|
388
|
+
return `${t} (${String(value)})`;
|
|
389
|
+
}
|
|
390
|
+
function sanitizeError(err) {
|
|
391
|
+
if (!(err instanceof Error)) return "unknown error";
|
|
392
|
+
const code = err.code;
|
|
393
|
+
return typeof code === "string" && code.length > 0 ? `${err.name} (${code})` : err.name;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/coerce.ts
|
|
397
|
+
function coerceNumber(value, label, bounds = {}) {
|
|
398
|
+
let n;
|
|
399
|
+
if (typeof value === "number") {
|
|
400
|
+
n = value;
|
|
401
|
+
} else if (typeof value === "string" && value.trim() !== "") {
|
|
402
|
+
n = Number(value);
|
|
403
|
+
} else {
|
|
404
|
+
throw new CaptureConfigError(`${label}: expected a number, got ${describeValue(value)}`);
|
|
405
|
+
}
|
|
406
|
+
if (!Number.isFinite(n)) {
|
|
407
|
+
throw new CaptureConfigError(`${label}: '${String(value)}' is not a finite number`);
|
|
408
|
+
}
|
|
409
|
+
if (bounds.integer && !Number.isInteger(n)) {
|
|
410
|
+
throw new CaptureConfigError(`${label}: expected an integer, got ${n}`);
|
|
411
|
+
}
|
|
412
|
+
if (bounds.min !== void 0 && n < bounds.min) {
|
|
413
|
+
throw new CaptureConfigError(`${label}: must be >= ${bounds.min}, got ${n}`);
|
|
414
|
+
}
|
|
415
|
+
if (bounds.max !== void 0 && n > bounds.max) {
|
|
416
|
+
throw new CaptureConfigError(`${label}: must be <= ${bounds.max}, got ${n}`);
|
|
417
|
+
}
|
|
418
|
+
return n;
|
|
419
|
+
}
|
|
420
|
+
function coerceStringArray(value, label) {
|
|
421
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
422
|
+
throw new CaptureConfigError(`${label}: expected an array of strings, got ${describeValue(value)}`);
|
|
423
|
+
}
|
|
424
|
+
return [...value];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// src/config.ts
|
|
428
|
+
function defaultDaemonConfig() {
|
|
429
|
+
return {
|
|
430
|
+
host: DEFAULT_HOST,
|
|
431
|
+
port: DEFAULT_PORT,
|
|
432
|
+
spoolRetentionDays: DEFAULT_SPOOL_RETENTION_DAYS,
|
|
433
|
+
simhashThreshold: DEFAULT_SIMHASH_THRESHOLD,
|
|
434
|
+
dedupTtlSeconds: DEFAULT_DEDUP_TTL_SECONDS,
|
|
435
|
+
sessionGapSeconds: DEFAULT_SESSION_GAP_SECONDS,
|
|
436
|
+
maxNodes: DEFAULT_MAX_NODES,
|
|
437
|
+
maxDwellSeconds: DEFAULT_MAX_DWELL_SECONDS,
|
|
438
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
439
|
+
settleMs: DEFAULT_SETTLE_MS,
|
|
440
|
+
idleFallbackSeconds: DEFAULT_IDLE_FALLBACK_SECONDS,
|
|
441
|
+
denyApps: [],
|
|
442
|
+
denyTitles: [],
|
|
443
|
+
denyUrls: [],
|
|
444
|
+
terminalApps: [],
|
|
445
|
+
redactionPatterns: []
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
var KNOWN_TOP_KEYS = {
|
|
449
|
+
host: true,
|
|
450
|
+
port: true,
|
|
451
|
+
spoolRetentionDays: true,
|
|
452
|
+
simhashThreshold: true,
|
|
453
|
+
dedupTtlSeconds: true,
|
|
454
|
+
sessionGapSeconds: true,
|
|
455
|
+
maxNodes: true,
|
|
456
|
+
maxDwellSeconds: true,
|
|
457
|
+
pollIntervalMs: true,
|
|
458
|
+
settleMs: true,
|
|
459
|
+
idleFallbackSeconds: true,
|
|
460
|
+
denyApps: true,
|
|
461
|
+
denyTitles: true,
|
|
462
|
+
denyUrls: true,
|
|
463
|
+
terminalApps: true,
|
|
464
|
+
redactionPatterns: true
|
|
465
|
+
};
|
|
466
|
+
function asObject(value, label) {
|
|
467
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
468
|
+
throw new CaptureConfigError(`${label}: expected an object, got ${describeValue(value)}`);
|
|
469
|
+
}
|
|
470
|
+
return value;
|
|
471
|
+
}
|
|
472
|
+
function requireString(value, label) {
|
|
473
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
474
|
+
throw new CaptureConfigError(`${label}: expected a non-empty string, got ${describeValue(value)}`);
|
|
475
|
+
}
|
|
476
|
+
return value.trim();
|
|
477
|
+
}
|
|
478
|
+
function parseDaemonConfig(raw) {
|
|
479
|
+
const cfg = defaultDaemonConfig();
|
|
480
|
+
const obj = asObject(raw, "config");
|
|
481
|
+
for (const key of Object.keys(obj)) {
|
|
482
|
+
if (!Object.hasOwn(KNOWN_TOP_KEYS, key)) {
|
|
483
|
+
console.warn(`remnic-capture-screen: config: ignoring unknown key '${key}'`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (obj.host !== void 0) cfg.host = requireString(obj.host, "host");
|
|
487
|
+
if (obj.port !== void 0) cfg.port = coerceNumber(obj.port, "port", { integer: true, min: 1, max: 65535 });
|
|
488
|
+
if (obj.spoolRetentionDays !== void 0) {
|
|
489
|
+
cfg.spoolRetentionDays = coerceNumber(obj.spoolRetentionDays, "spoolRetentionDays", { integer: true, min: 1 });
|
|
490
|
+
}
|
|
491
|
+
if (obj.simhashThreshold !== void 0) {
|
|
492
|
+
cfg.simhashThreshold = coerceNumber(obj.simhashThreshold, "simhashThreshold", { integer: true, min: 0, max: 64 });
|
|
493
|
+
}
|
|
494
|
+
if (obj.dedupTtlSeconds !== void 0) {
|
|
495
|
+
cfg.dedupTtlSeconds = coerceNumber(obj.dedupTtlSeconds, "dedupTtlSeconds", { min: 0 });
|
|
496
|
+
}
|
|
497
|
+
if (obj.sessionGapSeconds !== void 0) {
|
|
498
|
+
cfg.sessionGapSeconds = coerceNumber(obj.sessionGapSeconds, "sessionGapSeconds", { min: 0 });
|
|
499
|
+
}
|
|
500
|
+
if (obj.maxNodes !== void 0) {
|
|
501
|
+
cfg.maxNodes = coerceNumber(obj.maxNodes, "maxNodes", { integer: true, min: 1 });
|
|
502
|
+
}
|
|
503
|
+
if (obj.maxDwellSeconds !== void 0) {
|
|
504
|
+
cfg.maxDwellSeconds = coerceNumber(obj.maxDwellSeconds, "maxDwellSeconds", { min: 1 });
|
|
505
|
+
}
|
|
506
|
+
if (obj.pollIntervalMs !== void 0) {
|
|
507
|
+
cfg.pollIntervalMs = coerceNumber(obj.pollIntervalMs, "pollIntervalMs", { integer: true, min: 100 });
|
|
508
|
+
}
|
|
509
|
+
if (obj.settleMs !== void 0) {
|
|
510
|
+
cfg.settleMs = coerceNumber(obj.settleMs, "settleMs", { integer: true, min: 0 });
|
|
511
|
+
}
|
|
512
|
+
if (obj.idleFallbackSeconds !== void 0) {
|
|
513
|
+
cfg.idleFallbackSeconds = coerceNumber(obj.idleFallbackSeconds, "idleFallbackSeconds", { min: 1 });
|
|
514
|
+
}
|
|
515
|
+
if (obj.denyApps !== void 0) cfg.denyApps = coerceStringArray(obj.denyApps, "denyApps");
|
|
516
|
+
if (obj.denyTitles !== void 0) cfg.denyTitles = coerceStringArray(obj.denyTitles, "denyTitles");
|
|
517
|
+
if (obj.denyUrls !== void 0) cfg.denyUrls = coerceStringArray(obj.denyUrls, "denyUrls");
|
|
518
|
+
if (obj.terminalApps !== void 0) cfg.terminalApps = coerceStringArray(obj.terminalApps, "terminalApps");
|
|
519
|
+
if (obj.redactionPatterns !== void 0) {
|
|
520
|
+
cfg.redactionPatterns = coerceStringArray(obj.redactionPatterns, "redactionPatterns");
|
|
521
|
+
}
|
|
522
|
+
return cfg;
|
|
523
|
+
}
|
|
524
|
+
function loadDaemonConfig(configPath) {
|
|
525
|
+
let text;
|
|
526
|
+
try {
|
|
527
|
+
text = readFileSync(configPath, "utf8");
|
|
528
|
+
} catch {
|
|
529
|
+
throw new CaptureConfigError(`config not found at ${configPath} \u2014 run \`remnic-capture-screen init\` first`);
|
|
530
|
+
}
|
|
531
|
+
let raw;
|
|
532
|
+
try {
|
|
533
|
+
raw = JSON.parse(text);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
throw new CaptureConfigError(`config at ${configPath} is not valid JSON: ${err.message}`);
|
|
536
|
+
}
|
|
537
|
+
return parseDaemonConfig(raw);
|
|
538
|
+
}
|
|
539
|
+
function serializeDaemonConfig(cfg) {
|
|
540
|
+
return `${JSON.stringify(cfg, null, 2)}
|
|
541
|
+
`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/control.ts
|
|
545
|
+
import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "fs";
|
|
546
|
+
import { randomBytes } from "crypto";
|
|
547
|
+
import path from "path";
|
|
548
|
+
function writePidFile(pidPath, pid, options = {}) {
|
|
549
|
+
mkdirSync(path.dirname(pidPath), { recursive: true });
|
|
550
|
+
const record = {
|
|
551
|
+
pid,
|
|
552
|
+
instanceId: options.instanceId ?? null,
|
|
553
|
+
startedAtIso: options.startedAtIso ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
554
|
+
host: options.host ?? null,
|
|
555
|
+
port: options.port ?? null
|
|
556
|
+
};
|
|
557
|
+
const tmp = `${pidPath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
558
|
+
writeFileSync(tmp, `${JSON.stringify(record)}
|
|
559
|
+
`, "utf8");
|
|
560
|
+
renameSync(tmp, pidPath);
|
|
561
|
+
}
|
|
562
|
+
function readPidRecord(pidPath) {
|
|
563
|
+
let text;
|
|
564
|
+
try {
|
|
565
|
+
text = readFileSync2(pidPath, "utf8");
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
let parsed;
|
|
570
|
+
try {
|
|
571
|
+
parsed = JSON.parse(text);
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
576
|
+
const record = parsed;
|
|
577
|
+
const pid = typeof record.pid === "number" ? record.pid : Number.NaN;
|
|
578
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
579
|
+
const port = typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0 ? record.port : null;
|
|
580
|
+
return {
|
|
581
|
+
pid,
|
|
582
|
+
instanceId: typeof record.instanceId === "string" ? record.instanceId : null,
|
|
583
|
+
startedAtIso: typeof record.startedAtIso === "string" ? record.startedAtIso : "",
|
|
584
|
+
host: typeof record.host === "string" && record.host !== "" ? record.host : null,
|
|
585
|
+
port
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function readPidFile(pidPath) {
|
|
589
|
+
return readPidRecord(pidPath)?.pid ?? null;
|
|
590
|
+
}
|
|
591
|
+
function isProcessAlive(pid) {
|
|
592
|
+
try {
|
|
593
|
+
process.kill(pid, 0);
|
|
594
|
+
return true;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
return err.code === "EPERM";
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function removePidFile(pidPath) {
|
|
600
|
+
rmSync(pidPath, { force: true });
|
|
601
|
+
}
|
|
602
|
+
function removePidFileIfOwner(pidPath, pid) {
|
|
603
|
+
const record = readPidRecord(pidPath);
|
|
604
|
+
if (record && record.pid === pid) rmSync(pidPath, { force: true });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/token.ts
|
|
608
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
609
|
+
import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
610
|
+
import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
611
|
+
import path2 from "path";
|
|
612
|
+
function generateToken() {
|
|
613
|
+
return randomBytes2(32).toString("base64url");
|
|
614
|
+
}
|
|
615
|
+
function loadOrCreateToken(tokenPath) {
|
|
616
|
+
mkdirSync2(path2.dirname(tokenPath), { recursive: true });
|
|
617
|
+
if (existsSync(tokenPath)) {
|
|
618
|
+
chmodSync(tokenPath, 384);
|
|
619
|
+
const existing = readFileSync3(tokenPath, "utf8").trim();
|
|
620
|
+
if (existing) return existing;
|
|
621
|
+
}
|
|
622
|
+
const token = generateToken();
|
|
623
|
+
try {
|
|
624
|
+
writeFileSync2(tokenPath, `${token}
|
|
625
|
+
`, { mode: 384, flag: "wx" });
|
|
626
|
+
chmodSync(tokenPath, 384);
|
|
627
|
+
return token;
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if (err.code !== "EEXIST") throw err;
|
|
630
|
+
chmodSync(tokenPath, 384);
|
|
631
|
+
const raced = readFileSync3(tokenPath, "utf8").trim();
|
|
632
|
+
if (raced) return raced;
|
|
633
|
+
writeFileSync2(tokenPath, `${token}
|
|
634
|
+
`, { mode: 384 });
|
|
635
|
+
chmodSync(tokenPath, 384);
|
|
636
|
+
return token;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function tokensMatch(expected, presented) {
|
|
640
|
+
const a = Buffer2.from(expected, "utf8");
|
|
641
|
+
const b = Buffer2.from(presented, "utf8");
|
|
642
|
+
if (a.length !== b.length) return false;
|
|
643
|
+
return timingSafeEqual(a, b);
|
|
644
|
+
}
|
|
645
|
+
function bearerFromHeader(header) {
|
|
646
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
647
|
+
if (!value) return null;
|
|
648
|
+
const trimmed = value.trim();
|
|
649
|
+
if (trimmed.slice(0, 6).toLowerCase() !== "bearer") return null;
|
|
650
|
+
const separator = trimmed.charCodeAt(6);
|
|
651
|
+
if (separator !== 32 && separator !== 9) return null;
|
|
652
|
+
const token = trimmed.slice(6).trim();
|
|
653
|
+
return token || null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/validate.ts
|
|
657
|
+
import { Buffer as Buffer3 } from "buffer";
|
|
658
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
659
|
+
function parseSnapshotDate(value) {
|
|
660
|
+
if (typeof value !== "string" || !DATE_RE.test(value)) {
|
|
661
|
+
throw new CaptureInputError(`invalid date '${value ?? ""}' \u2014 expected YYYY-MM-DD`);
|
|
662
|
+
}
|
|
663
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
664
|
+
const dt = new Date(Date.UTC(year, month - 1, day));
|
|
665
|
+
dt.setUTCFullYear(year);
|
|
666
|
+
if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) {
|
|
667
|
+
throw new CaptureInputError(`invalid date '${value}' \u2014 not a real calendar date`);
|
|
668
|
+
}
|
|
669
|
+
return value;
|
|
670
|
+
}
|
|
671
|
+
function assertValidTimezone(value) {
|
|
672
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
673
|
+
throw new CaptureInputError("invalid timezone '' \u2014 expected an IANA timezone");
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: value });
|
|
677
|
+
} catch {
|
|
678
|
+
throw new CaptureInputError(`invalid timezone '${value}' \u2014 not a known IANA timezone`);
|
|
679
|
+
}
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
function parseLimit(value) {
|
|
683
|
+
if (value === null || value === void 0) return DEFAULT_SNAPSHOTS_LIMIT;
|
|
684
|
+
const n = Number(value);
|
|
685
|
+
if (value === "" || !Number.isInteger(n) || n < 1 || n > MAX_SNAPSHOTS_LIMIT) {
|
|
686
|
+
throw new CaptureInputError(
|
|
687
|
+
`invalid limit '${value}' \u2014 expected an integer between 1 and ${MAX_SNAPSHOTS_LIMIT}`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
return n;
|
|
691
|
+
}
|
|
692
|
+
function encodeCursor(capturedAtUtc, id) {
|
|
693
|
+
return Buffer3.from(JSON.stringify([capturedAtUtc, id]), "utf8").toString("base64url");
|
|
694
|
+
}
|
|
695
|
+
function decodeCursor(value) {
|
|
696
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
697
|
+
let parsed;
|
|
698
|
+
try {
|
|
699
|
+
parsed = JSON.parse(Buffer3.from(value, "base64url").toString("utf8"));
|
|
700
|
+
} catch {
|
|
701
|
+
throw new CaptureInputError("invalid cursor \u2014 not a recognized pagination token");
|
|
702
|
+
}
|
|
703
|
+
if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === "string" && typeof parsed[1] === "number" && Number.isInteger(parsed[1]) && parsed[1] >= 0 && /^\d{4}-\d{2}-\d{2}T/.test(parsed[0]) && Number.isFinite(Date.parse(parsed[0])) && new Date(parsed[0]).toISOString() === parsed[0]) {
|
|
704
|
+
return { capturedAtUtc: parsed[0], id: parsed[1] };
|
|
705
|
+
}
|
|
706
|
+
throw new CaptureInputError("invalid cursor \u2014 not a recognized pagination token");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/daemon.ts
|
|
710
|
+
import http from "http";
|
|
711
|
+
import { Buffer as Buffer4 } from "buffer";
|
|
712
|
+
function snapshotToWire(snap) {
|
|
713
|
+
const wire = {
|
|
714
|
+
capturedAtUtc: snap.capturedAtUtc,
|
|
715
|
+
app: snap.app,
|
|
716
|
+
windowTitle: snap.windowTitle,
|
|
717
|
+
text: snap.text,
|
|
718
|
+
textSource: snap.textSource,
|
|
719
|
+
contentHash: snap.contentHash,
|
|
720
|
+
simhash: snap.simhash
|
|
721
|
+
};
|
|
722
|
+
if (snap.browserUrl !== null) wire.browserUrl = snap.browserUrl;
|
|
723
|
+
return wire;
|
|
724
|
+
}
|
|
725
|
+
function sendJson(res, status, body) {
|
|
726
|
+
const payload = JSON.stringify(body);
|
|
727
|
+
res.writeHead(status, {
|
|
728
|
+
"content-type": "application/json; charset=utf-8",
|
|
729
|
+
"content-length": Buffer4.byteLength(payload),
|
|
730
|
+
"cache-control": "no-store"
|
|
731
|
+
});
|
|
732
|
+
res.end(payload);
|
|
733
|
+
}
|
|
734
|
+
function handleHealth(deps, res) {
|
|
735
|
+
const body = {
|
|
736
|
+
ok: true,
|
|
737
|
+
version: CAPTURE_SCREEN_VERSION,
|
|
738
|
+
platform: process.platform,
|
|
739
|
+
capturing: deps.capturing ?? false,
|
|
740
|
+
axAvailable: deps.axAvailable ?? false,
|
|
741
|
+
ocrAvailable: deps.ocrAvailable ?? false,
|
|
742
|
+
pendingCount: deps.spool.countSnapshots(),
|
|
743
|
+
instanceId: deps.spool.meta("instance_id"),
|
|
744
|
+
replayStatus: deps.spool.meta("replay_status"),
|
|
745
|
+
pid: process.pid
|
|
746
|
+
};
|
|
747
|
+
if (deps.helperHint) body.helperHint = deps.helperHint;
|
|
748
|
+
sendJson(res, 200, body);
|
|
749
|
+
}
|
|
750
|
+
function handleSnapshots(deps, url, res) {
|
|
751
|
+
const date = parseSnapshotDate(url.searchParams.get("date"));
|
|
752
|
+
const timezone = assertValidTimezone(url.searchParams.get("timezone"));
|
|
753
|
+
const limit = parseLimit(url.searchParams.get("limit"));
|
|
754
|
+
const cursor = url.searchParams.get("cursor");
|
|
755
|
+
const page = deps.spool.querySnapshots({ date, timezone, cursor, limit });
|
|
756
|
+
sendJson(res, 200, { snapshots: page.snapshots.map(snapshotToWire), nextCursor: page.nextCursor });
|
|
757
|
+
}
|
|
758
|
+
function handleStats(deps, url, res) {
|
|
759
|
+
const date = parseSnapshotDate(url.searchParams.get("date"));
|
|
760
|
+
const timezone = assertValidTimezone(url.searchParams.get("timezone"));
|
|
761
|
+
const stats = computeStats(deps.spool.daySnapshots(date, timezone), date, timezone, deps.config.maxDwellSeconds);
|
|
762
|
+
sendJson(res, 200, stats);
|
|
763
|
+
}
|
|
764
|
+
function createRequestHandler(deps) {
|
|
765
|
+
if (!isLoopbackHost(deps.config.host)) {
|
|
766
|
+
throw new CaptureConfigError(
|
|
767
|
+
`refusing to bind non-loopback host '${deps.config.host}': capture-screen serves plain HTTP with no TLS contract; bind a loopback address (127.0.0.1 or ::1) only`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (!deps.token) {
|
|
771
|
+
throw new CaptureConfigError("daemon requires a bearer token");
|
|
772
|
+
}
|
|
773
|
+
return (req, res) => {
|
|
774
|
+
try {
|
|
775
|
+
const presented = bearerFromHeader(req.headers["authorization"]);
|
|
776
|
+
if (!presented || !tokensMatch(deps.token, presented)) {
|
|
777
|
+
res.setHeader("www-authenticate", "Bearer");
|
|
778
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (req.method !== "GET") {
|
|
782
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
786
|
+
switch (url.pathname) {
|
|
787
|
+
case "/v1/health":
|
|
788
|
+
handleHealth(deps, res);
|
|
789
|
+
return;
|
|
790
|
+
case "/v1/snapshots":
|
|
791
|
+
handleSnapshots(deps, url, res);
|
|
792
|
+
return;
|
|
793
|
+
case "/v1/stats":
|
|
794
|
+
handleStats(deps, url, res);
|
|
795
|
+
return;
|
|
796
|
+
default:
|
|
797
|
+
sendJson(res, 404, { error: "not found" });
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
if (err instanceof CaptureInputError) {
|
|
801
|
+
sendJson(res, 400, { error: err.message });
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
sendJson(res, 500, { error: "internal error" });
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function startDaemon(deps) {
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
let handler;
|
|
811
|
+
try {
|
|
812
|
+
handler = createRequestHandler(deps);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
reject(err);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const server = http.createServer(handler);
|
|
818
|
+
const onError = (err) => reject(err);
|
|
819
|
+
server.once("error", onError);
|
|
820
|
+
server.listen(deps.config.port, deps.config.host, () => {
|
|
821
|
+
server.removeListener("error", onError);
|
|
822
|
+
server.on("error", (err) => {
|
|
823
|
+
process.stderr.write(`capture-screen daemon server error: ${err.code ?? err.name}
|
|
824
|
+
`);
|
|
825
|
+
});
|
|
826
|
+
const address = server.address();
|
|
827
|
+
const port = typeof address === "object" && address ? address.port : deps.config.port;
|
|
828
|
+
const host = deps.config.host;
|
|
829
|
+
resolve({
|
|
830
|
+
server,
|
|
831
|
+
host,
|
|
832
|
+
port,
|
|
833
|
+
url: `http://${formatHostForUrl(host)}:${port}`,
|
|
834
|
+
close: () => new Promise((res2, rej2) => {
|
|
835
|
+
server.close((closeErr) => closeErr ? rej2(closeErr) : res2());
|
|
836
|
+
})
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/paths.ts
|
|
843
|
+
import os from "os";
|
|
844
|
+
import path3 from "path";
|
|
845
|
+
function expandTilde(p) {
|
|
846
|
+
if (p === "~") return os.homedir();
|
|
847
|
+
if (p.startsWith("~/")) return path3.join(os.homedir(), p.slice(2));
|
|
848
|
+
return p;
|
|
849
|
+
}
|
|
850
|
+
function captureBaseDir(env = process.env) {
|
|
851
|
+
const override = env.REMNIC_CAPTURE_SCREEN_DIR?.trim();
|
|
852
|
+
if (override) return expandTilde(override);
|
|
853
|
+
return path3.join(os.homedir(), ".remnic", "capture-screen");
|
|
854
|
+
}
|
|
855
|
+
function capturePaths(baseDir = captureBaseDir()) {
|
|
856
|
+
return {
|
|
857
|
+
baseDir,
|
|
858
|
+
configPath: path3.join(baseDir, "screen.json"),
|
|
859
|
+
spoolPath: path3.join(baseDir, "screen.sqlite"),
|
|
860
|
+
tokenPath: path3.join(baseDir, "token"),
|
|
861
|
+
pidPath: path3.join(baseDir, "daemon.pid"),
|
|
862
|
+
logPath: path3.join(baseDir, "daemon.log")
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/helper.ts
|
|
867
|
+
import { spawn } from "child_process";
|
|
868
|
+
var MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
869
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
870
|
+
function helperPackageName(platform = process.platform, arch = process.arch) {
|
|
871
|
+
return `@remnic/capture-native-${platform}-${arch}`;
|
|
872
|
+
}
|
|
873
|
+
function installHint(pkg) {
|
|
874
|
+
return `native capture helper (${pkg}) is not available on this install \u2014 it ships via a tracked follow-up (https://github.com/joshuaswarren/remnic/issues/2139). To enable live screen capture now, build the Swift helper from source (packages/capture-native-darwin-helper) and set REMNIC_CAPTURE_HELPER_BIN to the binary`;
|
|
875
|
+
}
|
|
876
|
+
function isModuleNotFound(err) {
|
|
877
|
+
const code = err?.code;
|
|
878
|
+
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
879
|
+
}
|
|
880
|
+
async function resolveHelperBinaryPath(env = process.env) {
|
|
881
|
+
const override = env.REMNIC_CAPTURE_HELPER_BIN?.trim();
|
|
882
|
+
if (override) return { binaryPath: expandTilde(override), hint: null };
|
|
883
|
+
const pkg = helperPackageName();
|
|
884
|
+
try {
|
|
885
|
+
const mod = await import(pkg);
|
|
886
|
+
if (mod && typeof mod === "object" && "helperBinaryPath" in mod) {
|
|
887
|
+
const value = mod.helperBinaryPath;
|
|
888
|
+
if (typeof value === "string" && value.length > 0) return { binaryPath: value, hint: null };
|
|
889
|
+
}
|
|
890
|
+
return { binaryPath: null, hint: `${pkg} is installed but exports no helperBinaryPath` };
|
|
891
|
+
} catch (err) {
|
|
892
|
+
if (isModuleNotFound(err)) return { binaryPath: null, hint: installHint(pkg) };
|
|
893
|
+
return { binaryPath: null, hint: `${pkg} failed to load; reinstall it to enable live capture` };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
function spawnHelper(binaryPath, args, timeoutMs) {
|
|
897
|
+
return new Promise((resolve, reject) => {
|
|
898
|
+
const child = spawn(binaryPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
899
|
+
const chunks = [];
|
|
900
|
+
let size = 0;
|
|
901
|
+
let settled = false;
|
|
902
|
+
const timer = setTimeout(() => {
|
|
903
|
+
if (settled) return;
|
|
904
|
+
settled = true;
|
|
905
|
+
child.kill("SIGKILL");
|
|
906
|
+
reject(new CaptureInputError("native helper timed out"));
|
|
907
|
+
}, timeoutMs);
|
|
908
|
+
child.stdout.on("data", (chunk) => {
|
|
909
|
+
size += chunk.length;
|
|
910
|
+
if (size > MAX_OUTPUT_BYTES) {
|
|
911
|
+
if (settled) return;
|
|
912
|
+
settled = true;
|
|
913
|
+
clearTimeout(timer);
|
|
914
|
+
child.kill("SIGKILL");
|
|
915
|
+
reject(new CaptureInputError("native helper produced too much output"));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
chunks.push(chunk);
|
|
919
|
+
});
|
|
920
|
+
child.on("error", (err) => {
|
|
921
|
+
if (settled) return;
|
|
922
|
+
settled = true;
|
|
923
|
+
clearTimeout(timer);
|
|
924
|
+
const code = err.code;
|
|
925
|
+
reject(new CaptureInputError(`native helper failed to spawn (${code ?? err.name})`));
|
|
926
|
+
});
|
|
927
|
+
child.on("close", (code) => {
|
|
928
|
+
if (settled) return;
|
|
929
|
+
settled = true;
|
|
930
|
+
clearTimeout(timer);
|
|
931
|
+
resolve({ code, stdout: Buffer.concat(chunks).toString("utf8") });
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
async function runHelperCommand(binaryPath, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
936
|
+
const outcome = await spawnHelper(binaryPath, args, timeoutMs);
|
|
937
|
+
if (outcome.code !== 0) {
|
|
938
|
+
throw new CaptureInputError(`native helper exited with status ${outcome.code ?? "unknown"}`);
|
|
939
|
+
}
|
|
940
|
+
if (outcome.stdout.trim() === "") {
|
|
941
|
+
throw new CaptureInputError("native helper produced no output");
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
return JSON.parse(outcome.stdout);
|
|
945
|
+
} catch {
|
|
946
|
+
throw new CaptureInputError("native helper produced invalid JSON");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
var NativeHelper = class {
|
|
950
|
+
binaryPath;
|
|
951
|
+
constructor(binaryPath) {
|
|
952
|
+
this.binaryPath = binaryPath;
|
|
953
|
+
}
|
|
954
|
+
/** `<helper> ax-snapshot [--frontmost|--pid N] [--max-nodes N]` -> window + AX tree JSON. */
|
|
955
|
+
async axSnapshot(opts = {}) {
|
|
956
|
+
const args = ["ax-snapshot"];
|
|
957
|
+
if (opts.pid !== void 0) args.push("--pid", String(opts.pid));
|
|
958
|
+
else args.push("--frontmost");
|
|
959
|
+
if (opts.maxNodes !== void 0) args.push("--max-nodes", String(opts.maxNodes));
|
|
960
|
+
const json = await runHelperCommand(this.binaryPath, args);
|
|
961
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
962
|
+
throw new CaptureInputError("native helper ax-snapshot did not return an object");
|
|
963
|
+
}
|
|
964
|
+
if (!("app" in json) || !("windowTitle" in json) || !("tree" in json)) {
|
|
965
|
+
throw new CaptureInputError("native helper ax-snapshot missing app/windowTitle/tree");
|
|
966
|
+
}
|
|
967
|
+
const app = json.app;
|
|
968
|
+
const windowTitle = json.windowTitle;
|
|
969
|
+
const browserUrl = "browserUrl" in json ? json.browserUrl : void 0;
|
|
970
|
+
const tree = json.tree;
|
|
971
|
+
if (typeof app !== "string" || typeof windowTitle !== "string") {
|
|
972
|
+
throw new CaptureInputError("native helper ax-snapshot app/windowTitle must be strings");
|
|
973
|
+
}
|
|
974
|
+
if (tree === null || typeof tree !== "object" || Array.isArray(tree)) {
|
|
975
|
+
throw new CaptureInputError("native helper ax-snapshot tree must be an object");
|
|
976
|
+
}
|
|
977
|
+
const axTree = tree;
|
|
978
|
+
return {
|
|
979
|
+
app,
|
|
980
|
+
windowTitle,
|
|
981
|
+
...typeof browserUrl === "string" ? { browserUrl } : {},
|
|
982
|
+
tree: axTree
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
/** `<helper> ocr-window [--frontmost|--window ID]` -> `{ text }` JSON. */
|
|
986
|
+
async ocrWindow(opts = {}) {
|
|
987
|
+
const args = ["ocr-window"];
|
|
988
|
+
if (opts.windowId !== void 0) args.push("--window", opts.windowId);
|
|
989
|
+
else args.push("--frontmost");
|
|
990
|
+
const json = await runHelperCommand(this.binaryPath, args);
|
|
991
|
+
if (json !== null && typeof json === "object" && !Array.isArray(json) && "text" in json) {
|
|
992
|
+
const text = json.text;
|
|
993
|
+
if (typeof text === "string") return text;
|
|
994
|
+
}
|
|
995
|
+
throw new CaptureInputError("native helper ocr-window did not return a text field");
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// src/live.ts
|
|
1000
|
+
async function captureViaHelper(helper, processor, config, capturedAtUtc) {
|
|
1001
|
+
const snap = await helper.axSnapshot({ frontmost: true, maxNodes: config.maxNodes });
|
|
1002
|
+
return captureFromSnapshot(snap, helper, processor, config, capturedAtUtc);
|
|
1003
|
+
}
|
|
1004
|
+
async function captureFromSnapshot(snap, helper, processor, config, capturedAtUtc) {
|
|
1005
|
+
const axText = extractAxText(snap.tree, config.maxNodes).text;
|
|
1006
|
+
const candidate = {
|
|
1007
|
+
capturedAtUtc,
|
|
1008
|
+
app: snap.app,
|
|
1009
|
+
windowTitle: snap.windowTitle,
|
|
1010
|
+
...snap.browserUrl != null ? { browserUrl: snap.browserUrl } : {}
|
|
1011
|
+
};
|
|
1012
|
+
const denied = matchDenyRule(
|
|
1013
|
+
{ app: snap.app, windowTitle: snap.windowTitle, browserUrl: snap.browserUrl ?? null },
|
|
1014
|
+
{ apps: config.denyApps, titles: config.denyTitles, urls: config.denyUrls }
|
|
1015
|
+
) !== null;
|
|
1016
|
+
if (denied) {
|
|
1017
|
+
} else if (isTerminalApp(snap.app, config.terminalApps) || axText.trim() === "") {
|
|
1018
|
+
try {
|
|
1019
|
+
const ocrText = await helper.ocrWindow({ frontmost: true });
|
|
1020
|
+
if (ocrText.trim() !== "") {
|
|
1021
|
+
candidate.text = ocrText;
|
|
1022
|
+
candidate.textSource = "ocr";
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
candidate.text = axText;
|
|
1028
|
+
candidate.textSource = "ax";
|
|
1029
|
+
}
|
|
1030
|
+
return processor.process(candidate);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// src/replay.ts
|
|
1034
|
+
import { lstatSync, readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
1035
|
+
import path4 from "path";
|
|
1036
|
+
var REPLAY_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1037
|
+
function asObject2(value, where) {
|
|
1038
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1039
|
+
throw new CaptureConfigError(`${where}: expected a snapshot object`);
|
|
1040
|
+
}
|
|
1041
|
+
return value;
|
|
1042
|
+
}
|
|
1043
|
+
function parseTimestamp(value, where) {
|
|
1044
|
+
if (typeof value !== "string" || !REPLAY_INSTANT.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
1045
|
+
throw new CaptureConfigError(`${where}: expected an ISO instant with a Z or numeric offset`);
|
|
1046
|
+
}
|
|
1047
|
+
const [cy, cm, cd] = value.slice(0, 10).split("-").map(Number);
|
|
1048
|
+
const probe = new Date(Date.UTC(cy, cm - 1, cd));
|
|
1049
|
+
probe.setUTCFullYear(cy);
|
|
1050
|
+
if (probe.getUTCFullYear() !== cy || probe.getUTCMonth() !== cm - 1 || probe.getUTCDate() !== cd) {
|
|
1051
|
+
throw new CaptureConfigError(`${where}: '${value}' is not a real calendar date`);
|
|
1052
|
+
}
|
|
1053
|
+
return new Date(value).toISOString();
|
|
1054
|
+
}
|
|
1055
|
+
function requireString2(value, where) {
|
|
1056
|
+
if (typeof value !== "string") throw new CaptureConfigError(`${where}: expected a string`);
|
|
1057
|
+
return value;
|
|
1058
|
+
}
|
|
1059
|
+
function parseCandidate(raw, where) {
|
|
1060
|
+
const obj = asObject2(raw, where);
|
|
1061
|
+
const capturedAtUtc = parseTimestamp(obj.capturedAtUtc, `${where}.capturedAtUtc`);
|
|
1062
|
+
const candidate = {
|
|
1063
|
+
capturedAtUtc,
|
|
1064
|
+
app: requireString2(obj.app, `${where}.app`),
|
|
1065
|
+
windowTitle: requireString2(obj.windowTitle, `${where}.windowTitle`)
|
|
1066
|
+
};
|
|
1067
|
+
if (obj.browserUrl !== void 0 && obj.browserUrl !== null) {
|
|
1068
|
+
candidate.browserUrl = requireString2(obj.browserUrl, `${where}.browserUrl`);
|
|
1069
|
+
}
|
|
1070
|
+
if (obj.text !== void 0) candidate.text = requireString2(obj.text, `${where}.text`);
|
|
1071
|
+
if (obj.textSource !== void 0) {
|
|
1072
|
+
if (obj.textSource !== "ax" && obj.textSource !== "ocr") {
|
|
1073
|
+
throw new CaptureConfigError(`${where}.textSource: expected 'ax' or 'ocr'`);
|
|
1074
|
+
}
|
|
1075
|
+
candidate.textSource = obj.textSource;
|
|
1076
|
+
}
|
|
1077
|
+
if (obj.ax !== void 0) candidate.ax = asObject2(obj.ax, `${where}.ax`);
|
|
1078
|
+
if (candidate.text === void 0 && candidate.ax === void 0) {
|
|
1079
|
+
throw new CaptureConfigError(`${where}: a fixture must carry 'text' or 'ax' (one is required)`);
|
|
1080
|
+
}
|
|
1081
|
+
return candidate;
|
|
1082
|
+
}
|
|
1083
|
+
function listFixtureFiles(dir) {
|
|
1084
|
+
let entries;
|
|
1085
|
+
try {
|
|
1086
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
1087
|
+
throw new CaptureConfigError(`replay dir ${dir} is a symlink; refusing to follow it`);
|
|
1088
|
+
}
|
|
1089
|
+
entries = readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
if (err instanceof CaptureConfigError) throw err;
|
|
1092
|
+
throw new CaptureConfigError(`replay dir not found or unreadable: ${dir}`);
|
|
1093
|
+
}
|
|
1094
|
+
if (entries.length === 0) {
|
|
1095
|
+
throw new CaptureConfigError(`replay dir ${dir} contains no *.json fixtures`);
|
|
1096
|
+
}
|
|
1097
|
+
return entries;
|
|
1098
|
+
}
|
|
1099
|
+
function parseReplayDir(dir) {
|
|
1100
|
+
const entries = listFixtureFiles(dir);
|
|
1101
|
+
const candidates = [];
|
|
1102
|
+
for (const name of entries) {
|
|
1103
|
+
const filePath = path4.join(dir, name);
|
|
1104
|
+
if (lstatSync(filePath).isSymbolicLink()) {
|
|
1105
|
+
throw new CaptureConfigError(`replay fixture ${name} is a symlink; refusing to follow it`);
|
|
1106
|
+
}
|
|
1107
|
+
let raw;
|
|
1108
|
+
try {
|
|
1109
|
+
raw = JSON.parse(readFileSync4(filePath, "utf8"));
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
throw new CaptureConfigError(`replay fixture ${name} is not valid JSON: ${err.message}`);
|
|
1112
|
+
}
|
|
1113
|
+
const docs = Array.isArray(raw) ? raw : [raw];
|
|
1114
|
+
docs.forEach((doc, i) => candidates.push(parseCandidate(doc, `${name}[${i}]`)));
|
|
1115
|
+
}
|
|
1116
|
+
const indexed = candidates.map((candidate, index) => ({ candidate, index }));
|
|
1117
|
+
indexed.sort((a, b) => {
|
|
1118
|
+
const at = Date.parse(a.candidate.capturedAtUtc);
|
|
1119
|
+
const bt = Date.parse(b.candidate.capturedAtUtc);
|
|
1120
|
+
return at !== bt ? at - bt : a.index - b.index;
|
|
1121
|
+
});
|
|
1122
|
+
return { candidates: indexed.map((entry) => entry.candidate), files: entries.length };
|
|
1123
|
+
}
|
|
1124
|
+
function seedProcessor(processor, spool) {
|
|
1125
|
+
for (const fp of spool.latestFingerprints()) {
|
|
1126
|
+
processor.seed(fp.app, fp.windowTitle, fp.simhash, fp.capturedAtUtc);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function commit(processor, spool, config, candidate, result) {
|
|
1130
|
+
const decision = processor.process(candidate);
|
|
1131
|
+
if (decision.action === "denied") {
|
|
1132
|
+
result.denied += 1;
|
|
1133
|
+
} else if (decision.action === "skipped") {
|
|
1134
|
+
if (decision.reason === "dedup") result.deduped += 1;
|
|
1135
|
+
else result.ocrSkipped += 1;
|
|
1136
|
+
} else {
|
|
1137
|
+
const inserted = spool.insertSnapshot(decision.snapshot, config.sessionGapSeconds);
|
|
1138
|
+
if (inserted.inserted) {
|
|
1139
|
+
result.stored += 1;
|
|
1140
|
+
if (inserted.supersededId !== null) result.superseded += 1;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
var REPLAY_COMMIT_BATCH = 25;
|
|
1145
|
+
function ingestReplayDir(spool, dir, config, ocr) {
|
|
1146
|
+
const { candidates, files } = parseReplayDir(dir);
|
|
1147
|
+
const processor = new CaptureProcessor(config, ocr);
|
|
1148
|
+
seedProcessor(processor, spool);
|
|
1149
|
+
const result = {
|
|
1150
|
+
files,
|
|
1151
|
+
candidates: candidates.length,
|
|
1152
|
+
stored: 0,
|
|
1153
|
+
denied: 0,
|
|
1154
|
+
deduped: 0,
|
|
1155
|
+
ocrSkipped: 0,
|
|
1156
|
+
superseded: 0,
|
|
1157
|
+
aborted: false
|
|
1158
|
+
};
|
|
1159
|
+
for (const candidate of candidates) commit(processor, spool, config, candidate, result);
|
|
1160
|
+
return result;
|
|
1161
|
+
}
|
|
1162
|
+
async function ingestReplayDirResponsive(spool, dir, config, options = {}) {
|
|
1163
|
+
const { candidates, files } = parseReplayDir(dir);
|
|
1164
|
+
const processor = new CaptureProcessor(config, options.ocr);
|
|
1165
|
+
seedProcessor(processor, spool);
|
|
1166
|
+
const result = {
|
|
1167
|
+
files,
|
|
1168
|
+
candidates: candidates.length,
|
|
1169
|
+
stored: 0,
|
|
1170
|
+
denied: 0,
|
|
1171
|
+
deduped: 0,
|
|
1172
|
+
ocrSkipped: 0,
|
|
1173
|
+
superseded: 0,
|
|
1174
|
+
aborted: false
|
|
1175
|
+
};
|
|
1176
|
+
for (let i = 0; i < candidates.length; i += REPLAY_COMMIT_BATCH) {
|
|
1177
|
+
if (options.signal?.aborted) {
|
|
1178
|
+
result.aborted = true;
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
for (const candidate of candidates.slice(i, i + REPLAY_COMMIT_BATCH)) {
|
|
1182
|
+
commit(processor, spool, config, candidate, result);
|
|
1183
|
+
}
|
|
1184
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1185
|
+
}
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/daywindow.ts
|
|
1190
|
+
var DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
1191
|
+
function isValidDate(date) {
|
|
1192
|
+
if (typeof date !== "string" || !DATE_PATTERN.test(date)) return false;
|
|
1193
|
+
const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
1194
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === date;
|
|
1195
|
+
}
|
|
1196
|
+
function timezoneOffsetIso(instant, timezone) {
|
|
1197
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1198
|
+
timeZone: timezone,
|
|
1199
|
+
timeZoneName: "longOffset"
|
|
1200
|
+
}).formatToParts(instant);
|
|
1201
|
+
const name = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
1202
|
+
const match = name.match(/GMT([+-]\d{2}:\d{2})?/);
|
|
1203
|
+
return match?.[1] ?? "+00:00";
|
|
1204
|
+
}
|
|
1205
|
+
function shiftIsoDate(date, days) {
|
|
1206
|
+
const parsed = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
1207
|
+
parsed.setUTCDate(parsed.getUTCDate() + days);
|
|
1208
|
+
return parsed.toISOString().slice(0, 10);
|
|
1209
|
+
}
|
|
1210
|
+
function zonedDayStartIso(date, timezone) {
|
|
1211
|
+
const prevDate = shiftIsoDate(date, -1);
|
|
1212
|
+
const probeOffsets = new Set(
|
|
1213
|
+
[
|
|
1214
|
+
`${prevDate}T12:00:00Z`,
|
|
1215
|
+
`${prevDate}T23:00:00Z`,
|
|
1216
|
+
`${date}T00:00:00Z`,
|
|
1217
|
+
`${date}T12:00:00Z`,
|
|
1218
|
+
`${date}T23:00:00Z`
|
|
1219
|
+
].map((iso) => timezoneOffsetIso(new Date(iso), timezone))
|
|
1220
|
+
);
|
|
1221
|
+
let best = null;
|
|
1222
|
+
for (const offset of probeOffsets) {
|
|
1223
|
+
const candidate = Date.parse(`${date}T00:00:00${offset}`);
|
|
1224
|
+
if (!Number.isFinite(candidate)) continue;
|
|
1225
|
+
if (timezoneOffsetIso(new Date(candidate), timezone) !== offset) continue;
|
|
1226
|
+
if (best === null || candidate < best) best = candidate;
|
|
1227
|
+
}
|
|
1228
|
+
if (best === null) {
|
|
1229
|
+
for (let minute = 1; minute <= 180 && best === null; minute++) {
|
|
1230
|
+
const hh = String(Math.floor(minute / 60)).padStart(2, "0");
|
|
1231
|
+
const mm = String(minute % 60).padStart(2, "0");
|
|
1232
|
+
for (const offset of probeOffsets) {
|
|
1233
|
+
const candidate = Date.parse(`${date}T${hh}:${mm}:00${offset}`);
|
|
1234
|
+
if (!Number.isFinite(candidate)) continue;
|
|
1235
|
+
if (timezoneOffsetIso(new Date(candidate), timezone) !== offset) continue;
|
|
1236
|
+
if (best === null || candidate < best) best = candidate;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (best === null) {
|
|
1241
|
+
const noon = timezoneOffsetIso(/* @__PURE__ */ new Date(`${date}T12:00:00Z`), timezone);
|
|
1242
|
+
best = Date.parse(`${date}T00:00:00${noon}`);
|
|
1243
|
+
}
|
|
1244
|
+
if (best === null || !Number.isFinite(best)) {
|
|
1245
|
+
throw new CaptureInputError(`could not resolve a local day start for '${date}' in '${timezone}'`);
|
|
1246
|
+
}
|
|
1247
|
+
return new Date(best).toISOString();
|
|
1248
|
+
}
|
|
1249
|
+
function activityDayWindow(date, timezone) {
|
|
1250
|
+
if (!isValidDate(date)) {
|
|
1251
|
+
throw new CaptureInputError(`invalid date '${date}' \u2014 expected a real YYYY-MM-DD day`);
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
|
1255
|
+
} catch {
|
|
1256
|
+
throw new CaptureInputError(`invalid timezone '${timezone}' \u2014 not a known IANA timezone`);
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
startUtc: new Date(zonedDayStartIso(date, timezone)).toISOString(),
|
|
1260
|
+
endUtc: new Date(zonedDayStartIso(shiftIsoDate(date, 1), timezone)).toISOString()
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/spool.ts
|
|
1265
|
+
import { chmodSync as chmodSync2 } from "fs";
|
|
1266
|
+
import { DatabaseSync } from "sqlite";
|
|
1267
|
+
var SCHEMA_SQL = `
|
|
1268
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
1269
|
+
key TEXT PRIMARY KEY,
|
|
1270
|
+
value TEXT NOT NULL
|
|
1271
|
+
);
|
|
1272
|
+
CREATE TABLE IF NOT EXISTS snapshots (
|
|
1273
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1274
|
+
captured_at_utc TEXT NOT NULL,
|
|
1275
|
+
app_name TEXT NOT NULL,
|
|
1276
|
+
window_title TEXT NOT NULL,
|
|
1277
|
+
browser_url TEXT,
|
|
1278
|
+
text TEXT NOT NULL,
|
|
1279
|
+
text_source TEXT NOT NULL,
|
|
1280
|
+
content_hash TEXT NOT NULL UNIQUE,
|
|
1281
|
+
simhash TEXT NOT NULL,
|
|
1282
|
+
superseded_by INTEGER REFERENCES snapshots(id) ON DELETE SET NULL
|
|
1283
|
+
);
|
|
1284
|
+
CREATE INDEX IF NOT EXISTS idx_snap_keyset ON snapshots(captured_at_utc, id);
|
|
1285
|
+
CREATE INDEX IF NOT EXISTS idx_snap_window ON snapshots(app_name, window_title, captured_at_utc);
|
|
1286
|
+
`;
|
|
1287
|
+
var SELECT_COLUMNS = "id, captured_at_utc AS capturedAtUtc, app_name AS app, window_title AS windowTitle, browser_url AS browserUrl, text, text_source AS textSource, content_hash AS contentHash, simhash, superseded_by AS supersededBy";
|
|
1288
|
+
var ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1289
|
+
function canonicalInstant(value) {
|
|
1290
|
+
const match = typeof value === "string" ? ISO_INSTANT.exec(value) : null;
|
|
1291
|
+
if (!match || !Number.isFinite(Date.parse(value))) {
|
|
1292
|
+
throw new CaptureConfigError(`capturedAtUtc: '${value}' is not a canonical ISO instant (need date, time, and Z or offset)`);
|
|
1293
|
+
}
|
|
1294
|
+
const year = Number(match[1]);
|
|
1295
|
+
const month = Number(match[2]);
|
|
1296
|
+
const day = Number(match[3]);
|
|
1297
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
1298
|
+
probe.setUTCFullYear(year);
|
|
1299
|
+
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
|
|
1300
|
+
throw new CaptureConfigError(`capturedAtUtc: '${value}' is not a real calendar date`);
|
|
1301
|
+
}
|
|
1302
|
+
return new Date(value).toISOString();
|
|
1303
|
+
}
|
|
1304
|
+
var Spool = class {
|
|
1305
|
+
#db;
|
|
1306
|
+
#closed = false;
|
|
1307
|
+
constructor(location) {
|
|
1308
|
+
this.#db = new DatabaseSync(location);
|
|
1309
|
+
this.#db.exec("PRAGMA journal_mode = WAL;");
|
|
1310
|
+
this.#db.exec("PRAGMA foreign_keys = ON;");
|
|
1311
|
+
this.#db.exec("PRAGMA busy_timeout = 5000;");
|
|
1312
|
+
this.#db.exec(SCHEMA_SQL);
|
|
1313
|
+
if (location !== ":memory:") {
|
|
1314
|
+
try {
|
|
1315
|
+
chmodSync2(location, 384);
|
|
1316
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
1317
|
+
try {
|
|
1318
|
+
chmodSync2(`${location}${suffix}`, 384);
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
} catch {
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("schema_version", String(SPOOL_SCHEMA_VERSION));
|
|
1326
|
+
this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("instance_id", `scr_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`);
|
|
1327
|
+
}
|
|
1328
|
+
close() {
|
|
1329
|
+
if (this.#closed) return;
|
|
1330
|
+
this.#closed = true;
|
|
1331
|
+
this.#db.close();
|
|
1332
|
+
}
|
|
1333
|
+
meta(key) {
|
|
1334
|
+
const row = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
1335
|
+
return row?.value ?? null;
|
|
1336
|
+
}
|
|
1337
|
+
setMeta(key, value) {
|
|
1338
|
+
this.#db.prepare("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Insert a snapshot. Idempotent by content_hash (INSERT OR IGNORE): a repeat
|
|
1342
|
+
* returns the existing row's id with `inserted:false` and performs no
|
|
1343
|
+
* supersession. On a genuinely new row, the previous non-superseded snapshot
|
|
1344
|
+
* of the same (app, window) captured within `sessionGapSeconds` is marked
|
|
1345
|
+
* superseded_by this row.
|
|
1346
|
+
*/
|
|
1347
|
+
insertSnapshot(input, sessionGapSeconds) {
|
|
1348
|
+
if (typeof input.text !== "string") throw new CaptureConfigError("snapshot.text: expected a string");
|
|
1349
|
+
if (input.textSource !== "ax" && input.textSource !== "ocr") {
|
|
1350
|
+
throw new CaptureConfigError("snapshot.textSource: expected 'ax' or 'ocr'");
|
|
1351
|
+
}
|
|
1352
|
+
if (typeof input.contentHash !== "string" || input.contentHash === "") {
|
|
1353
|
+
throw new CaptureConfigError("snapshot.contentHash: expected a non-empty string");
|
|
1354
|
+
}
|
|
1355
|
+
if (typeof input.simhash !== "string" || input.simhash === "") {
|
|
1356
|
+
throw new CaptureConfigError("snapshot.simhash: expected a non-empty string");
|
|
1357
|
+
}
|
|
1358
|
+
const capturedAtUtc = canonicalInstant(input.capturedAtUtc);
|
|
1359
|
+
const browserUrl = input.browserUrl ?? null;
|
|
1360
|
+
const db = this.#db;
|
|
1361
|
+
db.exec("BEGIN");
|
|
1362
|
+
try {
|
|
1363
|
+
const result = db.prepare(
|
|
1364
|
+
"INSERT OR IGNORE INTO snapshots(captured_at_utc, app_name, window_title, browser_url, text, text_source, content_hash, simhash) VALUES (?,?,?,?,?,?,?,?)"
|
|
1365
|
+
).run(capturedAtUtc, input.app, input.windowTitle, browserUrl, input.text, input.textSource, input.contentHash, input.simhash);
|
|
1366
|
+
if (Number(result.changes) === 0) {
|
|
1367
|
+
const existing = db.prepare("SELECT id FROM snapshots WHERE content_hash = ?").get(input.contentHash);
|
|
1368
|
+
db.exec("COMMIT");
|
|
1369
|
+
return { id: existing?.id ?? 0, inserted: false, supersededId: null };
|
|
1370
|
+
}
|
|
1371
|
+
const id = Number(result.lastInsertRowid);
|
|
1372
|
+
const supersededId = this.#supersede(id, input.app, input.windowTitle, capturedAtUtc, sessionGapSeconds);
|
|
1373
|
+
db.exec("COMMIT");
|
|
1374
|
+
return { id, inserted: true, supersededId };
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
db.exec("ROLLBACK");
|
|
1377
|
+
throw err;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
/** Link the prior in-session snapshot of the same window to `newId`. */
|
|
1381
|
+
#supersede(newId, app, windowTitle, capturedAtUtc, sessionGapSeconds) {
|
|
1382
|
+
const prior = this.#db.prepare(
|
|
1383
|
+
"SELECT id, captured_at_utc AS capturedAtUtc FROM snapshots WHERE app_name = ? AND window_title = ? AND superseded_by IS NULL AND id <> ? AND captured_at_utc <= ? ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
1384
|
+
).get(app, windowTitle, newId, capturedAtUtc);
|
|
1385
|
+
if (prior === void 0) return null;
|
|
1386
|
+
const gapSeconds = (Date.parse(capturedAtUtc) - Date.parse(prior.capturedAtUtc)) / 1e3;
|
|
1387
|
+
if (gapSeconds < 0 || gapSeconds > sessionGapSeconds) return null;
|
|
1388
|
+
this.#db.prepare("UPDATE snapshots SET superseded_by = ? WHERE id = ?").run(newId, prior.id);
|
|
1389
|
+
return prior.id;
|
|
1390
|
+
}
|
|
1391
|
+
getSnapshot(id) {
|
|
1392
|
+
const row = this.#db.prepare(`SELECT ${SELECT_COLUMNS} FROM snapshots WHERE id = ?`).get(id);
|
|
1393
|
+
return row ? { ...row } : null;
|
|
1394
|
+
}
|
|
1395
|
+
countSnapshots() {
|
|
1396
|
+
return this.#db.prepare("SELECT COUNT(*) AS n FROM snapshots").get().n;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Snapshots whose capture instant falls in the half-open [start, end) UTC
|
|
1400
|
+
* window of the requested local day, paged by the stable (captured_at_utc, id)
|
|
1401
|
+
* keyset. The id tiebreak keeps pagination correct across snapshots that
|
|
1402
|
+
* share a capture instant.
|
|
1403
|
+
*/
|
|
1404
|
+
querySnapshots(opts) {
|
|
1405
|
+
const { startUtc, endUtc } = activityDayWindow(opts.date, opts.timezone);
|
|
1406
|
+
const cursor = decodeCursor(opts.cursor ?? null);
|
|
1407
|
+
const afterAt = cursor ? cursor.capturedAtUtc : "";
|
|
1408
|
+
const afterId = cursor ? cursor.id : 0;
|
|
1409
|
+
const rows = this.#db.prepare(
|
|
1410
|
+
`SELECT ${SELECT_COLUMNS} FROM snapshots WHERE superseded_by IS NULL AND captured_at_utc >= ? AND captured_at_utc < ? AND (captured_at_utc > ? OR (captured_at_utc = ? AND id > ?)) ORDER BY captured_at_utc ASC, id ASC LIMIT ?`
|
|
1411
|
+
).all(startUtc, endUtc, afterAt, afterAt, afterId, opts.limit + 1);
|
|
1412
|
+
const hasMore = rows.length > opts.limit;
|
|
1413
|
+
const page = hasMore ? rows.slice(0, opts.limit) : rows;
|
|
1414
|
+
const last = page[page.length - 1];
|
|
1415
|
+
return {
|
|
1416
|
+
snapshots: page.map((row) => ({ ...row })),
|
|
1417
|
+
nextCursor: hasMore && last ? encodeCursor(last.capturedAtUtc, last.id) : null
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/** All snapshots in a local day's window, ordered — the basis for /v1/stats. */
|
|
1421
|
+
daySnapshots(date, timezone) {
|
|
1422
|
+
const { startUtc, endUtc } = activityDayWindow(date, timezone);
|
|
1423
|
+
const rows = this.#db.prepare(
|
|
1424
|
+
`SELECT ${SELECT_COLUMNS} FROM snapshots WHERE superseded_by IS NULL AND captured_at_utc >= ? AND captured_at_utc < ? ORDER BY captured_at_utc ASC, id ASC`
|
|
1425
|
+
).all(startUtc, endUtc);
|
|
1426
|
+
return rows.map((row) => ({ ...row }));
|
|
1427
|
+
}
|
|
1428
|
+
/** Latest non-superseded fingerprint per (app, window) — primes the dedup cache. */
|
|
1429
|
+
latestFingerprints() {
|
|
1430
|
+
const rows = this.#db.prepare(
|
|
1431
|
+
"SELECT app_name AS app, window_title AS windowTitle, simhash, captured_at_utc AS capturedAtUtc FROM snapshots s WHERE superseded_by IS NULL AND id = (SELECT MAX(id) FROM snapshots t WHERE t.app_name = s.app_name AND t.window_title = s.window_title)"
|
|
1432
|
+
).all();
|
|
1433
|
+
return rows;
|
|
1434
|
+
}
|
|
1435
|
+
/** Retention janitor: drop snapshots older than `days` (cutoff from `nowMs`). Returns rows removed. */
|
|
1436
|
+
pruneOlderThan(days, nowMs = Date.now()) {
|
|
1437
|
+
const cutoff = new Date(nowMs - days * 864e5).toISOString();
|
|
1438
|
+
const result = this.#db.prepare("DELETE FROM snapshots WHERE captured_at_utc < ?").run(cutoff);
|
|
1439
|
+
return Number(result.changes);
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
// src/cli.ts
|
|
1444
|
+
import { spawn as spawn2 } from "child_process";
|
|
1445
|
+
import { chmodSync as chmodSync3, existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1446
|
+
import { setTimeout as delay } from "timers/promises";
|
|
1447
|
+
import { dirname } from "path";
|
|
1448
|
+
|
|
1449
|
+
// src/scheduler.ts
|
|
1450
|
+
var systemClock = {
|
|
1451
|
+
now: () => Date.now(),
|
|
1452
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
1453
|
+
clearInterval: (handle) => clearInterval(handle)
|
|
1454
|
+
};
|
|
1455
|
+
var CaptureScheduler = class {
|
|
1456
|
+
#helper;
|
|
1457
|
+
#processor;
|
|
1458
|
+
#spool;
|
|
1459
|
+
#config;
|
|
1460
|
+
#hooks;
|
|
1461
|
+
#clock;
|
|
1462
|
+
#timer = null;
|
|
1463
|
+
#inflight = false;
|
|
1464
|
+
#current = null;
|
|
1465
|
+
#lastKey = null;
|
|
1466
|
+
#changeAt = 0;
|
|
1467
|
+
#pending = false;
|
|
1468
|
+
#lastCaptureAt = Number.NEGATIVE_INFINITY;
|
|
1469
|
+
constructor(helper, processor, spool, config, hooks = {}, clock = systemClock) {
|
|
1470
|
+
this.#helper = helper;
|
|
1471
|
+
this.#processor = processor;
|
|
1472
|
+
this.#spool = spool;
|
|
1473
|
+
this.#config = config;
|
|
1474
|
+
this.#hooks = hooks;
|
|
1475
|
+
this.#clock = clock;
|
|
1476
|
+
}
|
|
1477
|
+
/** Begin polling. Idempotent; stops automatically when `signal` aborts. */
|
|
1478
|
+
start(signal) {
|
|
1479
|
+
if (this.#timer !== null) return;
|
|
1480
|
+
this.#timer = this.#clock.setInterval(() => {
|
|
1481
|
+
this.#current = this.tick();
|
|
1482
|
+
}, this.#config.pollIntervalMs);
|
|
1483
|
+
signal?.addEventListener("abort", () => void this.stop(), { once: true });
|
|
1484
|
+
}
|
|
1485
|
+
/** Stop polling and await any in-flight tick, so a caller can safely close
|
|
1486
|
+
* shared resources (the spool) once this resolves. */
|
|
1487
|
+
async stop() {
|
|
1488
|
+
if (this.#timer !== null) {
|
|
1489
|
+
this.#clock.clearInterval(this.#timer);
|
|
1490
|
+
this.#timer = null;
|
|
1491
|
+
}
|
|
1492
|
+
if (this.#current !== null) {
|
|
1493
|
+
await this.#current.catch(() => void 0);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* One poll cycle. Exposed (not private) so tests can drive the loop
|
|
1498
|
+
* deterministically with a fake clock instead of real timers. Overlapping
|
|
1499
|
+
* ticks are skipped so a slow helper never runs two captures at once.
|
|
1500
|
+
*/
|
|
1501
|
+
async tick() {
|
|
1502
|
+
if (this.#inflight) return;
|
|
1503
|
+
this.#inflight = true;
|
|
1504
|
+
try {
|
|
1505
|
+
const snap = await this.#helper.axSnapshot({ frontmost: true, maxNodes: this.#config.maxNodes });
|
|
1506
|
+
const key = `${snap.app}\0${snap.windowTitle}\0${snap.browserUrl ?? ""}`;
|
|
1507
|
+
const now = this.#clock.now();
|
|
1508
|
+
if (this.#lastCaptureAt === Number.NEGATIVE_INFINITY) {
|
|
1509
|
+
this.#lastCaptureAt = now;
|
|
1510
|
+
}
|
|
1511
|
+
if (key !== this.#lastKey) {
|
|
1512
|
+
this.#lastKey = key;
|
|
1513
|
+
this.#changeAt = now;
|
|
1514
|
+
this.#pending = true;
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const settled = this.#pending && now - this.#changeAt >= this.#config.settleMs;
|
|
1518
|
+
const idle = !this.#pending && now - this.#lastCaptureAt >= this.#config.idleFallbackSeconds * 1e3;
|
|
1519
|
+
if (!settled && !idle) return;
|
|
1520
|
+
const decision = await captureFromSnapshot(
|
|
1521
|
+
snap,
|
|
1522
|
+
this.#helper,
|
|
1523
|
+
this.#processor,
|
|
1524
|
+
this.#config,
|
|
1525
|
+
new Date(now).toISOString()
|
|
1526
|
+
);
|
|
1527
|
+
this.#pending = false;
|
|
1528
|
+
this.#lastCaptureAt = now;
|
|
1529
|
+
if (decision.action === "store") {
|
|
1530
|
+
this.#spool.insertSnapshot(decision.snapshot, this.#config.sessionGapSeconds);
|
|
1531
|
+
this.#hooks.onStore?.(snap.app, snap.windowTitle);
|
|
1532
|
+
}
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
this.#hooks.onError?.(err);
|
|
1535
|
+
} finally {
|
|
1536
|
+
this.#inflight = false;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
// src/cli.ts
|
|
1542
|
+
var CAPTURE_TOKEN_ENV = "REMNIC_CAPTURE_TOKEN";
|
|
1543
|
+
var LEGACY_CAPTURE_TOKEN_ENV = "ENGRAM_CAPTURE_TOKEN";
|
|
1544
|
+
var VALUE_FLAGS = {
|
|
1545
|
+
replay: true,
|
|
1546
|
+
host: true,
|
|
1547
|
+
port: true,
|
|
1548
|
+
listen: true,
|
|
1549
|
+
"base-dir": true,
|
|
1550
|
+
spool: true,
|
|
1551
|
+
lines: true
|
|
1552
|
+
};
|
|
1553
|
+
var BOOLEAN_FLAGS = {
|
|
1554
|
+
foreground: true,
|
|
1555
|
+
force: true,
|
|
1556
|
+
help: true
|
|
1557
|
+
};
|
|
1558
|
+
var COMMAND_FLAGS = {
|
|
1559
|
+
init: { force: true },
|
|
1560
|
+
start: { foreground: true, replay: true, host: true, port: true, listen: true, spool: true },
|
|
1561
|
+
stop: { force: true },
|
|
1562
|
+
status: {},
|
|
1563
|
+
"install-service": {},
|
|
1564
|
+
logs: { lines: true },
|
|
1565
|
+
"test-snapshot": {},
|
|
1566
|
+
help: {}
|
|
1567
|
+
};
|
|
1568
|
+
var GLOBAL_FLAGS = { "base-dir": true, spool: true, help: true };
|
|
1569
|
+
var READINESS_TIMEOUT_MS = 1e4;
|
|
1570
|
+
var STOP_TIMEOUT_MS = 1e4;
|
|
1571
|
+
function parseArgs(argv) {
|
|
1572
|
+
const tokens = [];
|
|
1573
|
+
const flags = {};
|
|
1574
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1575
|
+
const arg = argv[i];
|
|
1576
|
+
if (arg.startsWith("--")) {
|
|
1577
|
+
const key = arg.slice(2);
|
|
1578
|
+
if (key === "auth-token") {
|
|
1579
|
+
throw new CaptureInputError(
|
|
1580
|
+
`--auth-token is not accepted; set the ${CAPTURE_TOKEN_ENV} environment variable instead`
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
if (Object.hasOwn(VALUE_FLAGS, key)) {
|
|
1584
|
+
const next = argv[i + 1];
|
|
1585
|
+
if (next === void 0 || next.startsWith("--")) throw new CaptureInputError(`flag --${key} requires a value`);
|
|
1586
|
+
flags[key] = next;
|
|
1587
|
+
i += 1;
|
|
1588
|
+
} else if (Object.hasOwn(BOOLEAN_FLAGS, key)) {
|
|
1589
|
+
flags[key] = true;
|
|
1590
|
+
} else {
|
|
1591
|
+
throw new CaptureInputError(`unknown flag --${key}`);
|
|
1592
|
+
}
|
|
1593
|
+
} else {
|
|
1594
|
+
tokens.push(arg);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
const command = tokens.length > 0 ? tokens[0] : "help";
|
|
1598
|
+
return { command, positionals: tokens.slice(1), flags };
|
|
1599
|
+
}
|
|
1600
|
+
function resolvePaths(flags, env) {
|
|
1601
|
+
const baseDir = typeof flags["base-dir"] === "string" ? captureBaseDir({ ...env, REMNIC_CAPTURE_SCREEN_DIR: flags["base-dir"] }) : captureBaseDir(env);
|
|
1602
|
+
const paths = capturePaths(baseDir);
|
|
1603
|
+
if (typeof flags.spool === "string") return { ...paths, spoolPath: expandTilde(flags.spool) };
|
|
1604
|
+
return paths;
|
|
1605
|
+
}
|
|
1606
|
+
function loadConfigOrDefault(paths, stderr) {
|
|
1607
|
+
if (existsSync2(paths.configPath)) return loadDaemonConfig(paths.configPath);
|
|
1608
|
+
stderr(`no config at ${paths.configPath}; using defaults (run \`init\` to customize)`);
|
|
1609
|
+
return defaultDaemonConfig();
|
|
1610
|
+
}
|
|
1611
|
+
function applyBindingOverrides(config, flags) {
|
|
1612
|
+
const next = { ...config };
|
|
1613
|
+
if (typeof flags.listen === "string") {
|
|
1614
|
+
const idx = flags.listen.lastIndexOf(":");
|
|
1615
|
+
if (idx <= 0) throw new CaptureInputError(`--listen expects host:port, got '${flags.listen}'`);
|
|
1616
|
+
next.host = flags.listen.slice(0, idx);
|
|
1617
|
+
next.port = coerceNumber(flags.listen.slice(idx + 1), "--listen port", { integer: true, min: 1, max: 65535 });
|
|
1618
|
+
}
|
|
1619
|
+
if (typeof flags.host === "string") next.host = flags.host;
|
|
1620
|
+
if (typeof flags.port === "string") next.port = coerceNumber(flags.port, "--port", { integer: true, min: 1, max: 65535 });
|
|
1621
|
+
next.host = stripIpv6Brackets(next.host);
|
|
1622
|
+
return next;
|
|
1623
|
+
}
|
|
1624
|
+
function healthUrlFor(host, port) {
|
|
1625
|
+
return `http://${formatHostForUrl(host)}:${port}/v1/health`;
|
|
1626
|
+
}
|
|
1627
|
+
function recordHealthUrl(record, paths, stderr) {
|
|
1628
|
+
if (record.host !== null && record.port !== null) return healthUrlFor(record.host, record.port);
|
|
1629
|
+
const config = loadConfigOrDefault(paths, stderr);
|
|
1630
|
+
return healthUrlFor(config.host, config.port);
|
|
1631
|
+
}
|
|
1632
|
+
function resolveToken(paths, env, create) {
|
|
1633
|
+
const fromEnv = (env[CAPTURE_TOKEN_ENV] ?? env[LEGACY_CAPTURE_TOKEN_ENV])?.trim();
|
|
1634
|
+
if (fromEnv) return fromEnv;
|
|
1635
|
+
if (create) return loadOrCreateToken(paths.tokenPath);
|
|
1636
|
+
if (existsSync2(paths.tokenPath)) return readFileSync5(paths.tokenPath, "utf8").trim();
|
|
1637
|
+
return "";
|
|
1638
|
+
}
|
|
1639
|
+
function tokenHeader(paths, env) {
|
|
1640
|
+
const token = resolveToken(paths, env, false);
|
|
1641
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
1642
|
+
}
|
|
1643
|
+
function ensurePrivateDir(dir) {
|
|
1644
|
+
let isLink = false;
|
|
1645
|
+
try {
|
|
1646
|
+
isLink = lstatSync2(dir).isSymbolicLink();
|
|
1647
|
+
} catch {
|
|
1648
|
+
}
|
|
1649
|
+
if (isLink) {
|
|
1650
|
+
throw new CaptureInputError(`refusing to use symlinked private directory '${dir}'`);
|
|
1651
|
+
}
|
|
1652
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1653
|
+
try {
|
|
1654
|
+
chmodSync3(dir, 448);
|
|
1655
|
+
} catch {
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function ensureSpoolParentDir(spoolPath) {
|
|
1659
|
+
const dir = dirname(spoolPath);
|
|
1660
|
+
let stat;
|
|
1661
|
+
try {
|
|
1662
|
+
stat = lstatSync2(dir);
|
|
1663
|
+
} catch {
|
|
1664
|
+
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (stat.isSymbolicLink()) {
|
|
1668
|
+
throw new CaptureInputError(`refusing to open the capture spool under symlinked directory '${dir}'`);
|
|
1669
|
+
}
|
|
1670
|
+
if (!stat.isDirectory()) {
|
|
1671
|
+
throw new CaptureInputError(`capture spool parent '${dir}' exists but is not a directory`);
|
|
1672
|
+
}
|
|
1673
|
+
if (process.platform !== "win32" && (stat.mode & 63) !== 0) {
|
|
1674
|
+
throw new CaptureInputError(
|
|
1675
|
+
`capture spool directory '${dir}' is not owner-only (mode ${(stat.mode & 511).toString(8)}); point --spool at a private 0700 directory (the daemon creates one when absent)`
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
async function probeIdentity(paths, env, url) {
|
|
1680
|
+
try {
|
|
1681
|
+
const res = await fetch(url, { headers: tokenHeader(paths, env), signal: AbortSignal.timeout(2e3) });
|
|
1682
|
+
if (!res.ok) return null;
|
|
1683
|
+
const body = await res.json();
|
|
1684
|
+
if (body !== null && typeof body === "object" && "instanceId" in body && "pid" in body) {
|
|
1685
|
+
const instanceId = body.instanceId;
|
|
1686
|
+
const pid = body.pid;
|
|
1687
|
+
if (typeof instanceId === "string" && typeof pid === "number") return { instanceId, pid };
|
|
1688
|
+
}
|
|
1689
|
+
return null;
|
|
1690
|
+
} catch {
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
function recordChildPidOrTerminate(pid, paths, binding, stderr) {
|
|
1695
|
+
const existing = readPidRecord(paths.pidPath);
|
|
1696
|
+
if (existing !== null && existing.pid === pid && existing.instanceId !== null) return true;
|
|
1697
|
+
try {
|
|
1698
|
+
writePidFile(paths.pidPath, pid, binding);
|
|
1699
|
+
return true;
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
try {
|
|
1702
|
+
process.kill(pid, "SIGTERM");
|
|
1703
|
+
} catch {
|
|
1704
|
+
}
|
|
1705
|
+
stderr(`failed to record daemon pid: ${sanitizeError(err)}; terminated child pid ${pid}`);
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
async function isOwnRunningDaemon(record, paths, env, stderr) {
|
|
1710
|
+
if (record.instanceId === null) return true;
|
|
1711
|
+
const live = await probeIdentity(paths, env, recordHealthUrl(record, paths, stderr));
|
|
1712
|
+
if (live === null) return true;
|
|
1713
|
+
return live.instanceId === record.instanceId && live.pid === record.pid;
|
|
1714
|
+
}
|
|
1715
|
+
async function recordedDaemonIsRunning(record, paths, env, stderr) {
|
|
1716
|
+
if (record.pid === process.pid) return false;
|
|
1717
|
+
if (!isProcessAlive(record.pid)) return false;
|
|
1718
|
+
return isOwnRunningDaemon(record, paths, env, stderr);
|
|
1719
|
+
}
|
|
1720
|
+
async function superviseReplay(spool, replayDir, config, io, signal) {
|
|
1721
|
+
await Promise.resolve();
|
|
1722
|
+
spool.setMeta("replay_status", "running");
|
|
1723
|
+
try {
|
|
1724
|
+
const summary = await ingestReplayDirResponsive(spool, replayDir, config, { signal });
|
|
1725
|
+
if (summary.aborted) {
|
|
1726
|
+
spool.setMeta("replay_status", "cancelled");
|
|
1727
|
+
io.stdout(`replay: cancelled after ${summary.stored} snapshot(s)`);
|
|
1728
|
+
} else {
|
|
1729
|
+
spool.setMeta("replay_status", "ok");
|
|
1730
|
+
io.stdout(
|
|
1731
|
+
`replay: stored ${summary.stored} of ${summary.candidates} candidate(s) (denied ${summary.denied}, deduped ${summary.deduped}, ocr-skipped ${summary.ocrSkipped}) from ${summary.files} fixture file(s)`
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
const message = err instanceof CaptureConfigError || err instanceof CaptureInputError ? err.message : sanitizeError(err);
|
|
1736
|
+
const sanitized = message.replace(/\/\S+/g, "<path>");
|
|
1737
|
+
spool.setMeta("replay_status", `failed: ${sanitized}`);
|
|
1738
|
+
io.stderr(`replay ingestion failed: ${message}`);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
function cmdInit(paths, flags, stdout) {
|
|
1742
|
+
ensurePrivateDir(paths.baseDir);
|
|
1743
|
+
if (existsSync2(paths.configPath) && flags.force !== true) {
|
|
1744
|
+
stdout(`config already exists at ${paths.configPath} (use --force to overwrite)`);
|
|
1745
|
+
} else {
|
|
1746
|
+
writeFileSync3(paths.configPath, serializeDaemonConfig(defaultDaemonConfig()), "utf8");
|
|
1747
|
+
stdout(`wrote default config to ${paths.configPath}`);
|
|
1748
|
+
}
|
|
1749
|
+
const token = loadOrCreateToken(paths.tokenPath);
|
|
1750
|
+
stdout(`token ready at ${paths.tokenPath} (${token.length} chars, mode 0600)`);
|
|
1751
|
+
stdout(`set ${CAPTURE_TOKEN_ENV} to override the token file when starting the daemon`);
|
|
1752
|
+
stdout(`spool will be created at ${paths.spoolPath} on first start`);
|
|
1753
|
+
return 0;
|
|
1754
|
+
}
|
|
1755
|
+
async function cmdStart(paths, flags, env, stdout, stderr) {
|
|
1756
|
+
const config = applyBindingOverrides(loadConfigOrDefault(paths, stderr), flags);
|
|
1757
|
+
if (!isLoopbackHost(config.host)) {
|
|
1758
|
+
stderr(
|
|
1759
|
+
`refusing to bind non-loopback host '${config.host}': capture-screen serves plain HTTP with no TLS contract; use a loopback address (127.0.0.1 or ::1)`
|
|
1760
|
+
);
|
|
1761
|
+
return 1;
|
|
1762
|
+
}
|
|
1763
|
+
const replayDir = typeof flags.replay === "string" ? expandTilde(flags.replay) : null;
|
|
1764
|
+
const previousRecord = readPidRecord(paths.pidPath);
|
|
1765
|
+
if (previousRecord !== null) {
|
|
1766
|
+
if (await recordedDaemonIsRunning(previousRecord, paths, env, stderr)) {
|
|
1767
|
+
stdout(`daemon already running (pid ${previousRecord.pid})`);
|
|
1768
|
+
return 0;
|
|
1769
|
+
}
|
|
1770
|
+
if (previousRecord.pid !== process.pid) removePidFile(paths.pidPath);
|
|
1771
|
+
}
|
|
1772
|
+
if (flags.foreground !== true) {
|
|
1773
|
+
const entry = process.argv[1];
|
|
1774
|
+
const forwarded = ["start", "--foreground"];
|
|
1775
|
+
if (replayDir) forwarded.push("--replay", replayDir);
|
|
1776
|
+
if (typeof flags["base-dir"] === "string") forwarded.push("--base-dir", flags["base-dir"]);
|
|
1777
|
+
if (typeof flags.spool === "string") forwarded.push("--spool", flags.spool);
|
|
1778
|
+
if (typeof flags.host === "string") forwarded.push("--host", flags.host);
|
|
1779
|
+
if (typeof flags.port === "string") forwarded.push("--port", flags.port);
|
|
1780
|
+
if (typeof flags.listen === "string") forwarded.push("--listen", flags.listen);
|
|
1781
|
+
ensurePrivateDir(paths.baseDir);
|
|
1782
|
+
const logFd = openSync(paths.logPath, "a");
|
|
1783
|
+
const child = spawn2(process.execPath, [entry, ...forwarded], {
|
|
1784
|
+
detached: true,
|
|
1785
|
+
stdio: ["ignore", logFd, logFd],
|
|
1786
|
+
env: { ...process.env, ...env }
|
|
1787
|
+
});
|
|
1788
|
+
child.on("error", (err) => stderr(`daemon failed to launch: ${sanitizeError(err)}`));
|
|
1789
|
+
child.unref();
|
|
1790
|
+
if (typeof child.pid !== "number") {
|
|
1791
|
+
stderr("failed to spawn daemon process");
|
|
1792
|
+
return 1;
|
|
1793
|
+
}
|
|
1794
|
+
if (!recordChildPidOrTerminate(child.pid, paths, { host: config.host, port: config.port }, stderr)) return 1;
|
|
1795
|
+
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
|
1796
|
+
while (Date.now() < deadline) {
|
|
1797
|
+
if (!isProcessAlive(child.pid)) {
|
|
1798
|
+
removePidFileIfOwner(paths.pidPath, child.pid);
|
|
1799
|
+
stderr(`daemon exited during startup; see ${paths.logPath}`);
|
|
1800
|
+
return 1;
|
|
1801
|
+
}
|
|
1802
|
+
if (readPidRecord(paths.pidPath)?.instanceId) {
|
|
1803
|
+
stdout(`started daemon (pid ${child.pid}); listening; logs at ${paths.logPath}`);
|
|
1804
|
+
return 0;
|
|
1805
|
+
}
|
|
1806
|
+
await delay(100);
|
|
1807
|
+
}
|
|
1808
|
+
try {
|
|
1809
|
+
process.kill(child.pid, "SIGTERM");
|
|
1810
|
+
} catch {
|
|
1811
|
+
}
|
|
1812
|
+
removePidFileIfOwner(paths.pidPath, child.pid);
|
|
1813
|
+
stderr(`daemon did not become ready within ${READINESS_TIMEOUT_MS / 1e3}s; terminated pid ${child.pid}. See ${paths.logPath}.`);
|
|
1814
|
+
return 1;
|
|
1815
|
+
}
|
|
1816
|
+
ensurePrivateDir(paths.baseDir);
|
|
1817
|
+
const token = resolveToken(paths, env, true);
|
|
1818
|
+
const helperRes = await resolveHelperBinaryPath(env);
|
|
1819
|
+
const axAvailable = helperRes.binaryPath !== null;
|
|
1820
|
+
ensureSpoolParentDir(paths.spoolPath);
|
|
1821
|
+
const spool = new Spool(paths.spoolPath);
|
|
1822
|
+
spool.pruneOlderThan(config.spoolRetentionDays);
|
|
1823
|
+
let handle;
|
|
1824
|
+
try {
|
|
1825
|
+
handle = await startDaemon({
|
|
1826
|
+
spool,
|
|
1827
|
+
config,
|
|
1828
|
+
token,
|
|
1829
|
+
capturing: axAvailable,
|
|
1830
|
+
axAvailable,
|
|
1831
|
+
ocrAvailable: axAvailable,
|
|
1832
|
+
helperHint: helperRes.hint
|
|
1833
|
+
});
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
spool.close();
|
|
1836
|
+
throw err;
|
|
1837
|
+
}
|
|
1838
|
+
try {
|
|
1839
|
+
writePidFile(paths.pidPath, process.pid, {
|
|
1840
|
+
instanceId: spool.meta("instance_id"),
|
|
1841
|
+
host: handle.host,
|
|
1842
|
+
port: handle.port
|
|
1843
|
+
});
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
await handle.close();
|
|
1846
|
+
spool.close();
|
|
1847
|
+
throw err;
|
|
1848
|
+
}
|
|
1849
|
+
stdout(`listening on ${handle.url}`);
|
|
1850
|
+
if (helperRes.hint) stdout(`note: ${helperRes.hint}`);
|
|
1851
|
+
const replayAbort = new AbortController();
|
|
1852
|
+
const replayTask = replayDir ? superviseReplay(spool, replayDir, config, { stdout, stderr }, replayAbort.signal) : Promise.resolve();
|
|
1853
|
+
let scheduler = null;
|
|
1854
|
+
if (helperRes.binaryPath !== null) {
|
|
1855
|
+
const processor = new CaptureProcessor(config);
|
|
1856
|
+
for (const fp of spool.latestFingerprints()) {
|
|
1857
|
+
processor.seed(fp.app, fp.windowTitle, fp.simhash, fp.capturedAtUtc);
|
|
1858
|
+
}
|
|
1859
|
+
scheduler = new CaptureScheduler(new NativeHelper(helperRes.binaryPath), processor, spool, config, {
|
|
1860
|
+
onError: (err) => stderr(`capture loop error: ${sanitizeError(err)}`)
|
|
1861
|
+
});
|
|
1862
|
+
scheduler.start();
|
|
1863
|
+
}
|
|
1864
|
+
return await new Promise((resolve) => {
|
|
1865
|
+
let closing = false;
|
|
1866
|
+
const shutdown = () => {
|
|
1867
|
+
if (closing) return;
|
|
1868
|
+
closing = true;
|
|
1869
|
+
replayAbort.abort();
|
|
1870
|
+
void Promise.resolve(scheduler?.stop()).catch(() => void 0).then(() => replayTask.catch(() => void 0)).then(() => handle.close().catch(() => void 0)).finally(() => {
|
|
1871
|
+
spool.close();
|
|
1872
|
+
removePidFileIfOwner(paths.pidPath, process.pid);
|
|
1873
|
+
resolve(0);
|
|
1874
|
+
});
|
|
1875
|
+
};
|
|
1876
|
+
process.once("SIGINT", shutdown);
|
|
1877
|
+
process.once("SIGTERM", shutdown);
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
async function cmdStop(paths, flags, env, stdout, stderr) {
|
|
1881
|
+
const record = readPidRecord(paths.pidPath);
|
|
1882
|
+
if (record === null || !isProcessAlive(record.pid)) {
|
|
1883
|
+
removePidFile(paths.pidPath);
|
|
1884
|
+
stdout("daemon not running");
|
|
1885
|
+
return 0;
|
|
1886
|
+
}
|
|
1887
|
+
if (record.instanceId !== null) {
|
|
1888
|
+
const live = await probeIdentity(paths, env, recordHealthUrl(record, paths, stderr));
|
|
1889
|
+
if (live !== null && (live.instanceId !== record.instanceId || live.pid !== record.pid)) {
|
|
1890
|
+
stderr(
|
|
1891
|
+
`recorded pid ${record.pid} does not match the daemon serving this endpoint (identity/pid mismatch); not signalling and preserving ${paths.pidPath}.`
|
|
1892
|
+
);
|
|
1893
|
+
return 1;
|
|
1894
|
+
}
|
|
1895
|
+
if (live === null && flags.force !== true) {
|
|
1896
|
+
stderr(
|
|
1897
|
+
`cannot confirm daemon identity for pid ${record.pid} (health unreachable); not signalling. Re-run \`stop --force\` to stop it anyway, or remove ${paths.pidPath}.`
|
|
1898
|
+
);
|
|
1899
|
+
return 1;
|
|
1900
|
+
}
|
|
1901
|
+
} else if (flags.force !== true) {
|
|
1902
|
+
stderr(
|
|
1903
|
+
`cannot verify daemon identity for pid ${record.pid} (no recorded instance id); not signalling. Re-run \`stop --force\` to stop it anyway, or remove ${paths.pidPath}.`
|
|
1904
|
+
);
|
|
1905
|
+
return 1;
|
|
1906
|
+
}
|
|
1907
|
+
try {
|
|
1908
|
+
process.kill(record.pid, "SIGTERM");
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
const code = err.code;
|
|
1911
|
+
if (code === "ESRCH") {
|
|
1912
|
+
removePidFile(paths.pidPath);
|
|
1913
|
+
stdout("daemon not running");
|
|
1914
|
+
return 0;
|
|
1915
|
+
}
|
|
1916
|
+
if (code === "EPERM") {
|
|
1917
|
+
stderr(`daemon (pid ${record.pid}) is running but not controllable from this user`);
|
|
1918
|
+
return 1;
|
|
1919
|
+
}
|
|
1920
|
+
throw err;
|
|
1921
|
+
}
|
|
1922
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
1923
|
+
while (Date.now() < deadline) {
|
|
1924
|
+
if (!isProcessAlive(record.pid) || readPidRecord(paths.pidPath) === null) {
|
|
1925
|
+
stdout(`daemon (pid ${record.pid}) stopped`);
|
|
1926
|
+
return 0;
|
|
1927
|
+
}
|
|
1928
|
+
await delay(100);
|
|
1929
|
+
}
|
|
1930
|
+
stdout(`sent SIGTERM to daemon (pid ${record.pid}); still shutting down after ${STOP_TIMEOUT_MS / 1e3}s`);
|
|
1931
|
+
return 0;
|
|
1932
|
+
}
|
|
1933
|
+
async function cmdStatus(paths, env, stdout, stderr) {
|
|
1934
|
+
const record = readPidRecord(paths.pidPath);
|
|
1935
|
+
if (record === null || !isProcessAlive(record.pid)) {
|
|
1936
|
+
stdout("status: not running");
|
|
1937
|
+
return 0;
|
|
1938
|
+
}
|
|
1939
|
+
try {
|
|
1940
|
+
const res = await fetch(recordHealthUrl(record, paths, stderr), {
|
|
1941
|
+
headers: tokenHeader(paths, env),
|
|
1942
|
+
signal: AbortSignal.timeout(2e3)
|
|
1943
|
+
});
|
|
1944
|
+
const body = await res.text();
|
|
1945
|
+
stdout(`status: running (pid ${record.pid}) \u2014 HTTP ${res.status} ${body}`);
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
stdout(`status: process alive (pid ${record.pid}) but health check failed (${sanitizeError(err)})`);
|
|
1948
|
+
}
|
|
1949
|
+
return 0;
|
|
1950
|
+
}
|
|
1951
|
+
function cmdInstallService(stdout) {
|
|
1952
|
+
stdout(
|
|
1953
|
+
`install-service is not yet implemented for platform '${process.platform}'. No service was installed. Run \`remnic-capture-screen start\` under your process manager (launchd on macOS, systemd --user on Linux) once the native capture helper is installed.`
|
|
1954
|
+
);
|
|
1955
|
+
return 0;
|
|
1956
|
+
}
|
|
1957
|
+
function cmdLogs(paths, flags, stdout) {
|
|
1958
|
+
if (!existsSync2(paths.logPath)) {
|
|
1959
|
+
stdout(`no log file at ${paths.logPath}`);
|
|
1960
|
+
return 0;
|
|
1961
|
+
}
|
|
1962
|
+
const lines = typeof flags.lines === "string" ? coerceNumber(flags.lines, "--lines", { integer: true, min: 1 }) : 200;
|
|
1963
|
+
const all = readFileSync5(paths.logPath, "utf8").split("\n");
|
|
1964
|
+
stdout(all.slice(Math.max(0, all.length - lines)).join("\n"));
|
|
1965
|
+
return 0;
|
|
1966
|
+
}
|
|
1967
|
+
async function cmdTestSnapshot(paths, env, stdout, stderr) {
|
|
1968
|
+
const config = loadConfigOrDefault(paths, stderr);
|
|
1969
|
+
const helperRes = await resolveHelperBinaryPath(env);
|
|
1970
|
+
if (helperRes.binaryPath === null) {
|
|
1971
|
+
stdout(
|
|
1972
|
+
JSON.stringify(
|
|
1973
|
+
{
|
|
1974
|
+
capturing: false,
|
|
1975
|
+
axAvailable: false,
|
|
1976
|
+
ocrAvailable: false,
|
|
1977
|
+
helperHint: helperRes.hint,
|
|
1978
|
+
note: "no live snapshot: native capture helper unavailable"
|
|
1979
|
+
},
|
|
1980
|
+
null,
|
|
1981
|
+
2
|
|
1982
|
+
)
|
|
1983
|
+
);
|
|
1984
|
+
return 0;
|
|
1985
|
+
}
|
|
1986
|
+
const helper = new NativeHelper(helperRes.binaryPath);
|
|
1987
|
+
const processor = new CaptureProcessor(config);
|
|
1988
|
+
const decision = await captureViaHelper(helper, processor, config, (/* @__PURE__ */ new Date()).toISOString());
|
|
1989
|
+
if (decision.action === "denied") {
|
|
1990
|
+
stdout(JSON.stringify({ action: "denied", rule: decision.rule }, null, 2));
|
|
1991
|
+
} else if (decision.action === "skipped") {
|
|
1992
|
+
stdout(JSON.stringify({ action: "skipped", reason: decision.reason }, null, 2));
|
|
1993
|
+
} else {
|
|
1994
|
+
const snap = decision.snapshot;
|
|
1995
|
+
stdout(
|
|
1996
|
+
JSON.stringify(
|
|
1997
|
+
{
|
|
1998
|
+
action: "would-store",
|
|
1999
|
+
app: snap.app,
|
|
2000
|
+
windowTitle: snap.windowTitle,
|
|
2001
|
+
textSource: snap.textSource,
|
|
2002
|
+
textPreview: snap.text.slice(0, 200),
|
|
2003
|
+
contentHash: snap.contentHash,
|
|
2004
|
+
simhash: snap.simhash,
|
|
2005
|
+
denyRule: null
|
|
2006
|
+
},
|
|
2007
|
+
null,
|
|
2008
|
+
2
|
|
2009
|
+
)
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
return 0;
|
|
2013
|
+
}
|
|
2014
|
+
function usage(stdout) {
|
|
2015
|
+
stdout(
|
|
2016
|
+
[
|
|
2017
|
+
`remnic-capture-screen v${CAPTURE_SCREEN_VERSION}`,
|
|
2018
|
+
"usage: remnic-capture-screen <command> [flags]",
|
|
2019
|
+
"commands: init | start | stop | status | install-service | logs | test-snapshot",
|
|
2020
|
+
"start flags: --foreground --replay <dir> --host <h> --port <n> --listen <host:port> --spool <path> --base-dir <dir>",
|
|
2021
|
+
`token: set ${CAPTURE_TOKEN_ENV} (never --auth-token)`
|
|
2022
|
+
].join("\n")
|
|
2023
|
+
);
|
|
2024
|
+
return 0;
|
|
2025
|
+
}
|
|
2026
|
+
async function runCapture(io) {
|
|
2027
|
+
const env = io.env ?? process.env;
|
|
2028
|
+
const stdout = io.stdout ?? ((line) => console.log(line));
|
|
2029
|
+
const stderr = io.stderr ?? ((line) => console.error(line));
|
|
2030
|
+
try {
|
|
2031
|
+
const parsed = parseArgs(io.argv);
|
|
2032
|
+
const paths = resolvePaths(parsed.flags, env);
|
|
2033
|
+
if (parsed.flags.help === true || parsed.positionals.includes("-h") || parsed.positionals.includes("--help")) {
|
|
2034
|
+
return usage(stdout);
|
|
2035
|
+
}
|
|
2036
|
+
if (parsed.positionals.length > 0) {
|
|
2037
|
+
stderr(`unexpected argument(s): ${parsed.positionals.join(" ")}`);
|
|
2038
|
+
usage(stderr);
|
|
2039
|
+
return 2;
|
|
2040
|
+
}
|
|
2041
|
+
const allowedFlags = COMMAND_FLAGS[parsed.command];
|
|
2042
|
+
if (allowedFlags !== void 0) {
|
|
2043
|
+
for (const key of Object.keys(parsed.flags)) {
|
|
2044
|
+
if (!Object.hasOwn(GLOBAL_FLAGS, key) && !Object.hasOwn(allowedFlags, key)) {
|
|
2045
|
+
stderr(`flag --${key} is not valid for command '${parsed.command}'`);
|
|
2046
|
+
usage(stderr);
|
|
2047
|
+
return 2;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
switch (parsed.command) {
|
|
2052
|
+
case "init":
|
|
2053
|
+
return cmdInit(paths, parsed.flags, stdout);
|
|
2054
|
+
case "start":
|
|
2055
|
+
return await cmdStart(paths, parsed.flags, env, stdout, stderr);
|
|
2056
|
+
case "stop":
|
|
2057
|
+
return await cmdStop(paths, parsed.flags, env, stdout, stderr);
|
|
2058
|
+
case "status":
|
|
2059
|
+
return await cmdStatus(paths, env, stdout, stderr);
|
|
2060
|
+
case "install-service":
|
|
2061
|
+
return cmdInstallService(stdout);
|
|
2062
|
+
case "logs":
|
|
2063
|
+
return cmdLogs(paths, parsed.flags, stdout);
|
|
2064
|
+
case "test-snapshot":
|
|
2065
|
+
return await cmdTestSnapshot(paths, env, stdout, stderr);
|
|
2066
|
+
case "help":
|
|
2067
|
+
case "--help":
|
|
2068
|
+
case "-h":
|
|
2069
|
+
return usage(stdout);
|
|
2070
|
+
default:
|
|
2071
|
+
stderr(`unknown command '${parsed.command}'`);
|
|
2072
|
+
usage(stderr);
|
|
2073
|
+
return 2;
|
|
2074
|
+
}
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
if (err instanceof CaptureConfigError || err instanceof CaptureInputError) {
|
|
2077
|
+
stderr(`error: ${err.message}`);
|
|
2078
|
+
return err instanceof CaptureInputError ? 2 : 1;
|
|
2079
|
+
}
|
|
2080
|
+
stderr(`error: ${sanitizeError(err)}`);
|
|
2081
|
+
return 1;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
export {
|
|
2086
|
+
SECURE_ROLE,
|
|
2087
|
+
extractAxText,
|
|
2088
|
+
simhash,
|
|
2089
|
+
hammingDistance,
|
|
2090
|
+
simhashToHex,
|
|
2091
|
+
simhashFromHex,
|
|
2092
|
+
DedupCache,
|
|
2093
|
+
DEFAULT_DENY_APPS,
|
|
2094
|
+
DEFAULT_DENY_TITLES,
|
|
2095
|
+
DEFAULT_DENY_URLS,
|
|
2096
|
+
globToRegExp,
|
|
2097
|
+
matchesAnyGlob,
|
|
2098
|
+
matchDenyRule,
|
|
2099
|
+
CaptureConfigError,
|
|
2100
|
+
CaptureInputError,
|
|
2101
|
+
REDACTION_PLACEHOLDER,
|
|
2102
|
+
compileRedactionPatterns,
|
|
2103
|
+
redactText,
|
|
2104
|
+
DEFAULT_TERMINAL_APPS,
|
|
2105
|
+
isTerminalApp,
|
|
2106
|
+
contentHash,
|
|
2107
|
+
CaptureProcessor,
|
|
2108
|
+
computeStats,
|
|
2109
|
+
CAPTURE_SCREEN_VERSION,
|
|
2110
|
+
DEFAULT_HOST,
|
|
2111
|
+
DEFAULT_PORT,
|
|
2112
|
+
SPOOL_SCHEMA_VERSION,
|
|
2113
|
+
defaultDaemonConfig,
|
|
2114
|
+
parseDaemonConfig,
|
|
2115
|
+
loadDaemonConfig,
|
|
2116
|
+
serializeDaemonConfig,
|
|
2117
|
+
writePidFile,
|
|
2118
|
+
readPidRecord,
|
|
2119
|
+
readPidFile,
|
|
2120
|
+
isProcessAlive,
|
|
2121
|
+
removePidFile,
|
|
2122
|
+
removePidFileIfOwner,
|
|
2123
|
+
generateToken,
|
|
2124
|
+
loadOrCreateToken,
|
|
2125
|
+
tokensMatch,
|
|
2126
|
+
bearerFromHeader,
|
|
2127
|
+
parseSnapshotDate,
|
|
2128
|
+
assertValidTimezone,
|
|
2129
|
+
parseLimit,
|
|
2130
|
+
encodeCursor,
|
|
2131
|
+
decodeCursor,
|
|
2132
|
+
createRequestHandler,
|
|
2133
|
+
startDaemon,
|
|
2134
|
+
expandTilde,
|
|
2135
|
+
captureBaseDir,
|
|
2136
|
+
capturePaths,
|
|
2137
|
+
helperPackageName,
|
|
2138
|
+
resolveHelperBinaryPath,
|
|
2139
|
+
runHelperCommand,
|
|
2140
|
+
NativeHelper,
|
|
2141
|
+
captureViaHelper,
|
|
2142
|
+
REPLAY_COMMIT_BATCH,
|
|
2143
|
+
ingestReplayDir,
|
|
2144
|
+
ingestReplayDirResponsive,
|
|
2145
|
+
activityDayWindow,
|
|
2146
|
+
Spool,
|
|
2147
|
+
superviseReplay,
|
|
2148
|
+
runCapture
|
|
2149
|
+
};
|
|
2150
|
+
//# sourceMappingURL=chunk-5EJ57MSJ.js.map
|