tui-cap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,978 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_GUI_PORT = void 0;
7
+ exports.isPortInUse = isPortInUse;
8
+ exports.waitForPort = waitForPort;
9
+ exports.startServer = startServer;
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = require("node:fs");
12
+ const promises_1 = require("node:fs/promises");
13
+ const node_http_1 = __importDefault(require("node:http"));
14
+ const node_net_1 = __importDefault(require("node:net"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const parse_1 = require("./parse");
17
+ const paths_1 = require("./paths");
18
+ const meta_1 = require("./meta");
19
+ const timing_1 = require("./timing");
20
+ const input_1 = require("./input");
21
+ const anim_1 = require("./anim");
22
+ const edits_1 = require("./edits");
23
+ const palette_1 = require("./palette");
24
+ const svg_1 = require("./svg");
25
+ const version_1 = require("./version");
26
+ const DEFAULT_COLS = 120;
27
+ const DEFAULT_ROWS = 50;
28
+ const CAPTURE_EXT = /\.ans$/i;
29
+ /** Default port the GUI binds to (shared by the `gui` command and record auto-launch). */
30
+ exports.DEFAULT_GUI_PORT = 4787;
31
+ const frameCache = new Map();
32
+ /** Parse + cache the frames for one capture, keyed by file + mtime + dims. */
33
+ async function getFrames(name, dims) {
34
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
35
+ const st = await (0, promises_1.stat)(file);
36
+ const key = `${name}|${dims.cols}|${dims.rows}`;
37
+ const hit = frameCache.get(key);
38
+ if (hit && hit.mtimeMs === st.mtimeMs)
39
+ return hit.frames;
40
+ const data = await (0, promises_1.readFile)(file);
41
+ // Pull in capture timing (if recorded) so frames carry real durations. The
42
+ // sidecar is optional; without it frames simply have no startMs/durationMs.
43
+ const timing = await (0, timing_1.readTiming)(file);
44
+ // Pull in keystroke counts (if recorded) so frames are tagged as user typing
45
+ // and carry a prompt caret. Also optional; parse falls back to content growth.
46
+ const input = await (0, input_1.readInput)(file);
47
+ const frames = await (0, parse_1.extractFrames)(data, {
48
+ cols: dims.cols,
49
+ rows: dims.rows,
50
+ timing: timing?.events,
51
+ input: input?.events,
52
+ });
53
+ frameCache.set(key, { mtimeMs: st.mtimeMs, frames });
54
+ return frames;
55
+ }
56
+ /** Drop every cached frame set for one capture (all dims), e.g. after deletion. */
57
+ function invalidateFrames(name) {
58
+ const prefix = `${name}|`;
59
+ for (const key of frameCache.keys()) {
60
+ if (key.startsWith(prefix))
61
+ frameCache.delete(key);
62
+ }
63
+ }
64
+ const editsCache = new Map();
65
+ /**
66
+ * Load a recording's edits sidecar (cached by sidecar mtime). Returns the
67
+ * default empty document when there is no sidecar yet. Never throws.
68
+ */
69
+ async function getEditsDoc(name) {
70
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
71
+ let mtimeMs = null;
72
+ try {
73
+ mtimeMs = (await (0, promises_1.stat)((0, edits_1.editsPathFor)(file))).mtimeMs;
74
+ }
75
+ catch {
76
+ mtimeMs = null;
77
+ }
78
+ const hit = editsCache.get(name);
79
+ if (hit && hit.mtimeMs === mtimeMs)
80
+ return hit.doc;
81
+ const doc = (await (0, edits_1.readEdits)(file)) ?? (0, edits_1.defaultEdits)();
82
+ editsCache.set(name, { mtimeMs, doc });
83
+ return doc;
84
+ }
85
+ /** Drop the cached edits document for one capture (after a save or delete). */
86
+ function invalidateEdits(name) {
87
+ editsCache.delete(name);
88
+ }
89
+ /**
90
+ * The list of edits that apply to a recording at the given dims, or an empty
91
+ * list when edits are disabled, absent, or the source recording changed shape
92
+ * (re-record) so the saved anchors no longer fit.
93
+ */
94
+ async function applicableEdits(name, dims, editsOn) {
95
+ if (!editsOn)
96
+ return [];
97
+ const doc = await getEditsDoc(name);
98
+ if (doc.edits.length === 0)
99
+ return [];
100
+ if (!(0, edits_1.fingerprintMatches)(doc, { ansMtime: 0, cols: dims.cols, rows: dims.rows }))
101
+ return [];
102
+ return doc.edits;
103
+ }
104
+ /** Whether the `edits` query param disables edits for this request. */
105
+ function editsEnabled(q) {
106
+ const v = q.get('edits');
107
+ return v !== '0' && v !== 'false';
108
+ }
109
+ /** Whether the typing block cursor should be drawn. Off unless `cursor=1`/`true`
110
+ * is present, since it changes rendered appearance and SVG export. */
111
+ function cursorEnabled(q) {
112
+ const v = q.get('cursor');
113
+ return v === '1' || v === 'true';
114
+ }
115
+ /* ----------------------------------------------------------- safety + args */
116
+ /**
117
+ * Validate a capture name from a URL so it can only ever name a `.ans` file
118
+ * directly inside the captures folder — never escape it via `..` or absolute
119
+ * paths. Returns the clean basename, or null if the name is unsafe.
120
+ */
121
+ function safeCaptureName(raw) {
122
+ let name;
123
+ try {
124
+ name = decodeURIComponent(raw);
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ if (name.includes('\0'))
130
+ return null;
131
+ if (name !== node_path_1.default.basename(name))
132
+ return null;
133
+ if (name.startsWith('.'))
134
+ return null;
135
+ if (!CAPTURE_EXT.test(name))
136
+ return null;
137
+ return name;
138
+ }
139
+ /**
140
+ * Validate a user-supplied *new* capture name (from a rename request body),
141
+ * appending `.ans` when the user omits it. Like {@link safeCaptureName} it only
142
+ * ever permits a plain basename inside the captures folder — never a `..`
143
+ * traversal, absolute path, or dotfile. Returns the clean name, or null.
144
+ */
145
+ function cleanCaptureName(raw) {
146
+ let name = raw.trim();
147
+ if (!name)
148
+ return null;
149
+ if (!CAPTURE_EXT.test(name))
150
+ name += '.ans';
151
+ if (name.includes('\0'))
152
+ return null;
153
+ if (name !== node_path_1.default.basename(name))
154
+ return null;
155
+ if (name.startsWith('.'))
156
+ return null;
157
+ if (!CAPTURE_EXT.test(name))
158
+ return null;
159
+ return name;
160
+ }
161
+ function intParam(value, fallback, min, max) {
162
+ const n = value === null ? NaN : Number(value);
163
+ if (!Number.isFinite(n))
164
+ return fallback;
165
+ return Math.max(min, Math.min(max, Math.round(n)));
166
+ }
167
+ function floatParam(value, fallback, min, max) {
168
+ const n = value === null ? NaN : Number(value);
169
+ if (!Number.isFinite(n))
170
+ return fallback;
171
+ return Math.max(min, Math.min(max, n));
172
+ }
173
+ /**
174
+ * The dimensions a capture was recorded at, read from its `.meta.json` sidecar.
175
+ * Replaying through a terminal of the same size lands every cell back in its
176
+ * original column/row, so the recorded size is authoritative — the GUI no
177
+ * longer lets the user override it. Falls back to the defaults when a capture
178
+ * has no sidecar (e.g. recorded with --no-meta).
179
+ */
180
+ async function resolveDims(name) {
181
+ const meta = await (0, meta_1.readMeta)(node_path_1.default.join((0, paths_1.capturesDir)(), name));
182
+ if (meta) {
183
+ return {
184
+ cols: Math.max(1, Math.min(1000, Math.round(meta.cols))),
185
+ rows: Math.max(1, Math.min(1000, Math.round(meta.rows))),
186
+ };
187
+ }
188
+ return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS };
189
+ }
190
+ function renderOptsFromQuery(q) {
191
+ const theme = q.get('theme') === 'light' ? 'light' : 'dark';
192
+ const overrides = {};
193
+ const chrome = q.get('chrome');
194
+ if (chrome === '0' || chrome === 'false')
195
+ overrides.chrome = false;
196
+ const chromeStyle = q.get('chromeStyle');
197
+ if (chromeStyle === 'windows' ||
198
+ chromeStyle === 'windows-inactive' ||
199
+ chromeStyle === 'mac' ||
200
+ chromeStyle === 'mac-inactive')
201
+ overrides.chromeStyle = chromeStyle;
202
+ const title = q.get('title');
203
+ if (title !== null)
204
+ overrides.title = title;
205
+ const font = q.get('font');
206
+ if (font)
207
+ overrides.fontFamily = font;
208
+ if (q.get('fontSize'))
209
+ overrides.fontSize = intParam(q.get('fontSize'), 14, 6, 96);
210
+ if (q.get('lineHeight'))
211
+ overrides.lineHeight = floatParam(q.get('lineHeight'), 1.25, 0.8, 2.5);
212
+ if (q.get('padding'))
213
+ overrides.padding = intParam(q.get('padding'), 24, 0, 200);
214
+ if (q.get('radius'))
215
+ overrides.radius = intParam(q.get('radius'), 12, 0, 64);
216
+ // Uniform animation canvas: pin a minimum cols×rows so every frame in a
217
+ // recording renders at identical pixel dimensions (required for MP4 export).
218
+ if (q.get('fixedCols'))
219
+ overrides.fixedCols = intParam(q.get('fixedCols'), 0, 1, 100000);
220
+ if (q.get('fixedRows'))
221
+ overrides.fixedRows = intParam(q.get('fixedRows'), 0, 1, 100000);
222
+ return (0, svg_1.defaultRenderOptions)(theme, overrides);
223
+ }
224
+ /* ----------------------------------------------------------- http helpers */
225
+ function sendJson(res, status, body) {
226
+ const text = JSON.stringify(body);
227
+ res.writeHead(status, {
228
+ 'content-type': 'application/json; charset=utf-8',
229
+ 'cache-control': 'no-store',
230
+ });
231
+ res.end(text);
232
+ }
233
+ function sendError(res, status, message) {
234
+ sendJson(res, status, { error: message });
235
+ }
236
+ /** Read and JSON-parse a request body (capped), or null if absent/too big/invalid. */
237
+ function readJsonBody(req, limitBytes = 1_000_000) {
238
+ return new Promise((resolve) => {
239
+ const chunks = [];
240
+ let size = 0;
241
+ let aborted = false;
242
+ req.on('data', (c) => {
243
+ if (aborted)
244
+ return;
245
+ size += c.length;
246
+ if (size > limitBytes) {
247
+ aborted = true;
248
+ resolve(null);
249
+ return;
250
+ }
251
+ chunks.push(c);
252
+ });
253
+ req.on('end', () => {
254
+ if (aborted)
255
+ return;
256
+ const text = Buffer.concat(chunks).toString('utf8').trim();
257
+ if (!text)
258
+ return resolve(null);
259
+ try {
260
+ resolve(JSON.parse(text));
261
+ }
262
+ catch {
263
+ resolve(null);
264
+ }
265
+ });
266
+ req.on('error', () => resolve(null));
267
+ });
268
+ }
269
+ const MIME = {
270
+ '.html': 'text/html; charset=utf-8',
271
+ '.js': 'text/javascript; charset=utf-8',
272
+ '.css': 'text/css; charset=utf-8',
273
+ '.svg': 'image/svg+xml',
274
+ '.json': 'application/json; charset=utf-8',
275
+ '.ico': 'image/x-icon',
276
+ '.png': 'image/png',
277
+ '.woff2': 'font/woff2',
278
+ };
279
+ /** Locate the web/ assets, which sit next to this module in both dev and dist. */
280
+ function webDir() {
281
+ return node_path_1.default.join(__dirname, 'web');
282
+ }
283
+ async function serveStatic(res, urlPath) {
284
+ const root = webDir();
285
+ const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
286
+ const target = node_path_1.default.join(root, rel);
287
+ // Never serve outside the web root.
288
+ if (target !== root && !target.startsWith(root + node_path_1.default.sep)) {
289
+ sendError(res, 403, 'forbidden');
290
+ return;
291
+ }
292
+ try {
293
+ const body = await (0, promises_1.readFile)(target);
294
+ res.writeHead(200, { 'content-type': MIME[node_path_1.default.extname(target)] ?? 'application/octet-stream' });
295
+ res.end(body);
296
+ }
297
+ catch {
298
+ sendError(res, 404, 'not found');
299
+ }
300
+ }
301
+ /* ----------------------------------------------------------- API endpoints */
302
+ async function listRecordings(res) {
303
+ const dir = (0, paths_1.capturesDir)();
304
+ let names = [];
305
+ try {
306
+ names = (await (0, promises_1.readdir)(dir)).filter((n) => CAPTURE_EXT.test(n));
307
+ }
308
+ catch {
309
+ // captures dir may not exist yet — return an empty library
310
+ }
311
+ const items = await Promise.all(names.map(async (name) => {
312
+ const st = await (0, promises_1.stat)(node_path_1.default.join(dir, name));
313
+ let frameCount = 0;
314
+ let defaultIndex = 0;
315
+ try {
316
+ const frames = await getFrames(name, await resolveDims(name));
317
+ frameCount = frames.length;
318
+ defaultIndex = (0, parse_1.chooseFrame)(frames).index;
319
+ }
320
+ catch {
321
+ // unreadable capture — still list it, just with 0 frames
322
+ }
323
+ return { name, size: st.size, mtime: st.mtimeMs, frames: frameCount, defaultIndex };
324
+ }));
325
+ items.sort((a, b) => b.mtime - a.mtime);
326
+ sendJson(res, 200, { dir, recordings: items });
327
+ }
328
+ async function listFrames(res, name) {
329
+ const dims = await resolveDims(name);
330
+ const frames = await getFrames(name, dims);
331
+ const defaultIndex = (0, parse_1.chooseFrame)(frames).index;
332
+ // Trailing teardown trim: the animation runs from the first frame through the
333
+ // last *settled* one (the screen the user held on), dropping the half-erased
334
+ // exit frames after it. chooseFrame already finds that settled frame.
335
+ const animationEnd = defaultIndex;
336
+ const hasTiming = frames.some((f) => f.durationMs !== undefined);
337
+ // Content edits (recolours, rewrites, and especially spacing inserts/removes)
338
+ // change a frame's measured size, so measure the *edited* grids — the uniform
339
+ // animation canvas must be big enough for the edited content.
340
+ const edits = await applicableEdits(name, dims, true);
341
+ const editsDoc = await getEditsDoc(name);
342
+ const gridFor = (f) => (edits.length ? (0, edits_1.toDisplayGrid)(f.grid, edits) : f.grid);
343
+ // Uniform animation canvas: the largest content extent across the animation
344
+ // range [0..animationEnd]. The GUI pins every animation/export frame to this
345
+ // size (via fixedCols/fixedRows) so the MP4 has a single, stable resolution.
346
+ const animTheme = (0, svg_1.defaultRenderOptions)('dark').theme;
347
+ let animCols = 1;
348
+ let animRows = 1;
349
+ for (let i = 0; i <= Math.min(animationEnd, frames.length - 1); i++) {
350
+ const m = (0, svg_1.measureGrid)(gridFor(frames[i]), animTheme);
351
+ if (m.cols > animCols)
352
+ animCols = m.cols;
353
+ if (m.rows > animRows)
354
+ animRows = m.rows;
355
+ }
356
+ sendJson(res, 200, {
357
+ name,
358
+ cols: dims.cols,
359
+ rows: dims.rows,
360
+ defaultIndex,
361
+ animationEnd,
362
+ hasTiming,
363
+ editsRevision: editsDoc.revision,
364
+ animCanvas: { cols: animCols, rows: animRows },
365
+ frames: frames.map((f, i) => ({
366
+ index: i,
367
+ inAlt: f.inAlt,
368
+ preview: (0, parse_1.framePreview)(gridFor(f)),
369
+ startMs: f.startMs,
370
+ durationMs: f.durationMs,
371
+ isTyping: f.isTyping ?? false,
372
+ typedChars: f.typedChars ?? 0,
373
+ })),
374
+ });
375
+ }
376
+ async function renderFrameSvg(name, index, q) {
377
+ const dims = await resolveDims(name);
378
+ const frames = await getFrames(name, dims);
379
+ const i = Math.max(0, Math.min(frames.length - 1, index));
380
+ const edits = await applicableEdits(name, dims, editsEnabled(q));
381
+ // Apply content edits to the raw frame BEFORE trimming/rendering, so every
382
+ // output (preview, stills, MP4) inherits them through one shared transform.
383
+ const edited = edits.length ? (0, edits_1.toDisplayGrid)(frames[i].grid, edits) : frames[i].grid;
384
+ // `trim=0` keeps the captured grid untrimmed; pair it with fixedCols/fixedRows
385
+ // (see renderOptsFromQuery) so every animation frame renders at one uniform
386
+ // pixel size — required for MP4 export. The default trims background bleed for
387
+ // tighter stills.
388
+ const grid = q.get('trim') === '0' ? edited : (0, parse_1.trimBackgroundBleed)(edited);
389
+ const opts = renderOptsFromQuery(q);
390
+ // The caret is per-frame data (not a query param): when the cursor toggle is
391
+ // on, draw the block at this frame's prompt caret. Trimming may have dropped
392
+ // the empty caret cell, but renderSvg re-extends the canvas to include it.
393
+ opts.cursor = cursorEnabled(q) ? (frames[i].caret ?? null) : null;
394
+ return (0, svg_1.renderSvg)(grid, opts);
395
+ }
396
+ function svgFileName(name, index) {
397
+ const stem = node_path_1.default.basename(name, node_path_1.default.extname(name));
398
+ return `${stem}-frame${String(index + 1).padStart(2, '0')}.svg`;
399
+ }
400
+ async function serveFrameSvg(res, name, index, q) {
401
+ const svg = await renderFrameSvg(name, index, q);
402
+ const headers = {
403
+ 'content-type': 'image/svg+xml',
404
+ 'cache-control': 'no-store',
405
+ };
406
+ if (q.get('download') === '1') {
407
+ headers['content-disposition'] = `attachment; filename="${svgFileName(name, index)}"`;
408
+ }
409
+ res.writeHead(200, headers);
410
+ res.end(svg);
411
+ }
412
+ /**
413
+ * Geometry + per-row content for a frame, used by the GUI's selection overlay so
414
+ * hit-testing lines up with the displayed image. Returns the same geometry
415
+ * `renderSvg` uses (font size, padding, chrome bar) computed from the *edited*
416
+ * grid, plus two layers per row: the shown text/wide-cell columns (display) and
417
+ * the stable raw source text + source-row index (for anchor capture). Honours
418
+ * the same `trim`/`fixedCols`/`fixedRows`/`edits` params as the SVG endpoint so
419
+ * the two never disagree.
420
+ */
421
+ async function serveCells(res, name, index, q) {
422
+ const dims = await resolveDims(name);
423
+ const frames = await getFrames(name, dims);
424
+ const i = Math.max(0, Math.min(frames.length - 1, index));
425
+ const rawGrid = frames[i].grid;
426
+ const edits = await applicableEdits(name, dims, editsEnabled(q));
427
+ const { grid: displayGrid, sourceRows } = (0, edits_1.buildDisplay)(rawGrid, edits);
428
+ const shown = q.get('trim') === '0' ? displayGrid : (0, parse_1.trimBackgroundBleed)(displayGrid);
429
+ const opts = renderOptsFromQuery(q);
430
+ const cellW = opts.fontSize * opts.advance;
431
+ const cellH = Math.round(opts.fontSize * opts.lineHeight);
432
+ const fit = (0, svg_1.measureGrid)(shown, opts.theme);
433
+ const usedCols = Math.max(fit.cols, opts.fixedCols ?? 0);
434
+ const usedRows = Math.max(fit.rows, opts.fixedRows ?? 0);
435
+ const isWindows = opts.chromeStyle === 'windows' || opts.chromeStyle === 'windows-inactive';
436
+ const barH = opts.chrome ? (isWindows ? 32 : 40) : 0;
437
+ const contentLeft = opts.padding;
438
+ const contentTop = barH + opts.padding;
439
+ const width = Math.ceil(usedCols * cellW + opts.padding * 2);
440
+ const height = Math.ceil(usedRows * cellH + opts.padding * 2 + barH);
441
+ const rows = shown.lines.slice(0, usedRows).map((cells, idx) => {
442
+ const src = sourceRows[idx] ?? null;
443
+ // Per-glyph rendered foreground colour, keyed by start column, for the GUI
444
+ // eyedropper. Only real glyphs (non-space) are sampleable.
445
+ const fg = {};
446
+ for (const c of cells) {
447
+ if (c.char !== ' ')
448
+ fg[c.col] = (0, svg_1.cellForeground)(c, opts.theme);
449
+ }
450
+ return {
451
+ sourceRow: src,
452
+ text: (0, edits_1.rowText)(cells),
453
+ wide: cells.filter((c) => c.width === 2).map((c) => c.col),
454
+ fg,
455
+ rawLineText: src === null ? '' : (0, edits_1.rowText)(rawGrid.lines[src] ?? []),
456
+ };
457
+ });
458
+ const editsDoc = await getEditsDoc(name);
459
+ sendJson(res, 200, {
460
+ name,
461
+ index: i,
462
+ editsRevision: editsDoc.revision,
463
+ geometry: { cellW, cellH, contentLeft, contentTop, barH, width, height, cols: usedCols, rows: usedRows },
464
+ rows,
465
+ });
466
+ }
467
+ async function recordCommand(res, q) {
468
+ const cmd = (q.get('cmd') ?? '').trim();
469
+ let name = (q.get('name') ?? '').trim();
470
+ if (name && !CAPTURE_EXT.test(name))
471
+ name += '.ans';
472
+ const safeName = name ? safeCaptureName(name) : null;
473
+ if (name && !safeName) {
474
+ sendError(res, 400, 'invalid capture name');
475
+ return;
476
+ }
477
+ // Named → bake in `-o`; blank → omit it so the CLI auto-names the next
478
+ // copilot-capture_NN.ans. `out` reflects the file that will be written either
479
+ // way, so the UI can show it.
480
+ const out = safeName ?? (await (0, paths_1.nextCaptureName)());
481
+ const command = safeName
482
+ ? `tui-cap record -o ${safeName} -- ${cmd || '<command>'}`
483
+ : `tui-cap record -- ${cmd || '<command>'}`;
484
+ sendJson(res, 200, { command, out });
485
+ }
486
+ /** GET a recording's animation settings, falling back to defaults when unsaved. */
487
+ async function getAnimation(res, name) {
488
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
489
+ const saved = await (0, anim_1.readAnimation)(file);
490
+ sendJson(res, 200, { settings: saved ?? (0, anim_1.defaultAnimationSettings)() });
491
+ }
492
+ /** PUT (save) a recording's animation settings from a JSON body. */
493
+ async function putAnimation(req, res, name) {
494
+ const body = await readJsonBody(req);
495
+ if (body === null) {
496
+ sendError(res, 400, 'invalid JSON body');
497
+ return;
498
+ }
499
+ const settings = (0, anim_1.normalizeSettings)(body);
500
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
501
+ try {
502
+ await (0, anim_1.writeAnimation)(file, settings);
503
+ }
504
+ catch (err) {
505
+ sendError(res, 500, err instanceof Error ? err.message : 'could not save settings');
506
+ return;
507
+ }
508
+ sendJson(res, 200, { settings });
509
+ }
510
+ /** GET a recording's content edits, falling back to an empty doc when unsaved. */
511
+ async function getEdits(res, name) {
512
+ const doc = await getEditsDoc(name);
513
+ sendJson(res, 200, { edits: doc });
514
+ }
515
+ /**
516
+ * PUT (save) a recording's content edits from a JSON body. Bumps the document
517
+ * revision (for undo tagging + light optimistic concurrency) and stamps the
518
+ * current recording fingerprint so a later re-record can detect a shape change.
519
+ */
520
+ async function putEdits(req, res, name) {
521
+ const body = await readJsonBody(req);
522
+ if (body === null) {
523
+ sendError(res, 400, 'invalid JSON body');
524
+ return;
525
+ }
526
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
527
+ const dims = await resolveDims(name);
528
+ let ansMtime = 0;
529
+ try {
530
+ ansMtime = (await (0, promises_1.stat)(file)).mtimeMs;
531
+ }
532
+ catch {
533
+ // capture vanished mid-edit; fingerprint just stays zero (treated as unknown)
534
+ }
535
+ const incoming = (0, edits_1.normalizeEdits)(body, { ansMtime, cols: dims.cols, rows: dims.rows });
536
+ const prev = await getEditsDoc(name);
537
+ const doc = {
538
+ ...incoming,
539
+ recording: { ansMtime, cols: dims.cols, rows: dims.rows },
540
+ revision: prev.revision + 1,
541
+ };
542
+ try {
543
+ await (0, edits_1.writeEdits)(file, doc);
544
+ }
545
+ catch (err) {
546
+ sendError(res, 500, err instanceof Error ? err.message : 'could not save edits');
547
+ return;
548
+ }
549
+ invalidateEdits(name);
550
+ sendJson(res, 200, { edits: doc });
551
+ }
552
+ /** DELETE (reset) a recording's content edits by removing the sidecar. */
553
+ async function deleteEdits(res, name) {
554
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
555
+ await (0, promises_1.unlink)((0, edits_1.editsPathFor)(file)).catch(() => { });
556
+ invalidateEdits(name);
557
+ sendJson(res, 200, { reset: name });
558
+ }
559
+ /** The Primer palette the GUI offers for recolouring text, per theme. */
560
+ function servePalette(res) {
561
+ const swatches = (t) => ({
562
+ ansi: palette_1.ANSI_PALETTES[t],
563
+ background: palette_1.THEMES[t].background,
564
+ foreground: palette_1.THEMES[t].foreground,
565
+ });
566
+ sendJson(res, 200, { dark: swatches('dark'), light: swatches('light') });
567
+ }
568
+ /** Delete a capture and its meta sidecar, dropping any cached frames. */
569
+ async function deleteRecording(res, name) {
570
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
571
+ try {
572
+ await (0, promises_1.unlink)(file);
573
+ }
574
+ catch {
575
+ sendError(res, 404, 'recording not found');
576
+ return;
577
+ }
578
+ // Best-effort: sidecars may not exist (e.g. recorded with --no-meta, or never
579
+ // opened in the timeline editor).
580
+ await Promise.all([
581
+ (0, promises_1.unlink)((0, meta_1.metaPathFor)(file)).catch(() => { }),
582
+ (0, promises_1.unlink)((0, timing_1.timingPathFor)(file)).catch(() => { }),
583
+ (0, promises_1.unlink)((0, input_1.inputPathFor)(file)).catch(() => { }),
584
+ (0, promises_1.unlink)((0, anim_1.animPathFor)(file)).catch(() => { }),
585
+ (0, promises_1.unlink)((0, edits_1.editsPathFor)(file)).catch(() => { }),
586
+ ]);
587
+ invalidateFrames(name);
588
+ invalidateEdits(name);
589
+ sendJson(res, 200, { deleted: name });
590
+ }
591
+ /**
592
+ * Rename a capture and every sidecar that belongs to it (`.meta.json`,
593
+ * `.timing.json`, `.input.json`, `.anim.json`, `.edits.json`) in one go, so a
594
+ * project keeps all of its parts together. Refuses to clobber an existing
595
+ * recording. Sidecars are moved best-effort since any of them may be absent.
596
+ */
597
+ async function renameRecording(req, res, name) {
598
+ const body = await readJsonBody(req);
599
+ const to = body?.to;
600
+ if (typeof to !== 'string') {
601
+ sendError(res, 400, 'missing "to" name');
602
+ return;
603
+ }
604
+ const target = cleanCaptureName(to);
605
+ if (!target) {
606
+ sendError(res, 400, 'invalid target name');
607
+ return;
608
+ }
609
+ if (target === name) {
610
+ sendJson(res, 200, { renamed: name, to: target });
611
+ return;
612
+ }
613
+ const dir = (0, paths_1.capturesDir)();
614
+ const src = node_path_1.default.join(dir, name);
615
+ const dst = node_path_1.default.join(dir, target);
616
+ // Never overwrite an existing capture.
617
+ try {
618
+ await (0, promises_1.stat)(dst);
619
+ sendError(res, 409, `a recording named ${target} already exists`);
620
+ return;
621
+ }
622
+ catch {
623
+ // good — the target name is free
624
+ }
625
+ try {
626
+ await (0, promises_1.rename)(src, dst);
627
+ }
628
+ catch (err) {
629
+ sendError(res, 500, err instanceof Error ? err.message : 'could not rename recording');
630
+ return;
631
+ }
632
+ await Promise.all([
633
+ (0, promises_1.rename)((0, meta_1.metaPathFor)(src), (0, meta_1.metaPathFor)(dst)).catch(() => { }),
634
+ (0, promises_1.rename)((0, timing_1.timingPathFor)(src), (0, timing_1.timingPathFor)(dst)).catch(() => { }),
635
+ (0, promises_1.rename)((0, input_1.inputPathFor)(src), (0, input_1.inputPathFor)(dst)).catch(() => { }),
636
+ (0, promises_1.rename)((0, anim_1.animPathFor)(src), (0, anim_1.animPathFor)(dst)).catch(() => { }),
637
+ (0, promises_1.rename)((0, edits_1.editsPathFor)(src), (0, edits_1.editsPathFor)(dst)).catch(() => { }),
638
+ ]);
639
+ invalidateFrames(name);
640
+ invalidateEdits(name);
641
+ sendJson(res, 200, { renamed: name, to: target });
642
+ }
643
+ /**
644
+ * Reveal a capture in the OS file manager so the user can find it on disk:
645
+ * Finder (macOS) and Explorer (Windows) select the file; other platforms open
646
+ * the captures folder. Best-effort — spawned detached so it never blocks. Set
647
+ * GHCP_REVEAL_CMD to override the launcher (handy on Linux, and for tests).
648
+ */
649
+ function revealRecording(res, name) {
650
+ const file = node_path_1.default.join((0, paths_1.capturesDir)(), name);
651
+ let cmd;
652
+ let args;
653
+ const override = process.env.GHCP_REVEAL_CMD;
654
+ if (override) {
655
+ cmd = override;
656
+ args = [file];
657
+ }
658
+ else if (process.platform === 'darwin') {
659
+ cmd = 'open';
660
+ args = ['-R', file];
661
+ }
662
+ else if (process.platform === 'win32') {
663
+ cmd = 'explorer';
664
+ args = [`/select,${file}`];
665
+ }
666
+ else {
667
+ cmd = 'xdg-open';
668
+ args = [(0, paths_1.capturesDir)()];
669
+ }
670
+ try {
671
+ (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true }).unref();
672
+ sendJson(res, 200, { revealed: name });
673
+ }
674
+ catch (err) {
675
+ sendError(res, 500, err instanceof Error ? err.message : 'could not reveal file');
676
+ }
677
+ }
678
+ /**
679
+ * Open the captures folder itself in the OS file manager (as opposed to
680
+ * revealing a single file). Ensures the folder exists first so the launcher
681
+ * never fails on a fresh install. Best-effort — spawned detached.
682
+ */
683
+ async function openCapturesFolder(res) {
684
+ const dir = (0, paths_1.capturesDir)();
685
+ await (0, promises_1.mkdir)(dir, { recursive: true }).catch(() => { });
686
+ const override = process.env.GHCP_REVEAL_CMD;
687
+ let cmd;
688
+ if (override)
689
+ cmd = override;
690
+ else if (process.platform === 'darwin')
691
+ cmd = 'open';
692
+ else if (process.platform === 'win32')
693
+ cmd = 'explorer';
694
+ else
695
+ cmd = 'xdg-open';
696
+ try {
697
+ (0, node_child_process_1.spawn)(cmd, [dir], { stdio: 'ignore', detached: true }).unref();
698
+ sendJson(res, 200, { opened: dir });
699
+ }
700
+ catch (err) {
701
+ sendError(res, 500, err instanceof Error ? err.message : 'could not open folder');
702
+ }
703
+ }
704
+ /* ---------------------------------------------------------- version/update */
705
+ // Cache the registry lookup for the server's lifetime so repeated GUI loads and
706
+ // SSE-driven refreshes don't hammer npm. A failed lookup is cached only briefly
707
+ // so a transient network blip doesn't suppress the prompt for the full hour.
708
+ const LATEST_TTL_MS = 60 * 60 * 1000;
709
+ const LATEST_FAIL_TTL_MS = 60 * 1000;
710
+ let latestCache = null;
711
+ async function cachedLatestVersion() {
712
+ const now = Date.now();
713
+ if (latestCache && now < latestCache.expires)
714
+ return latestCache.value;
715
+ const value = await (0, version_1.fetchLatestVersion)();
716
+ latestCache = {
717
+ value,
718
+ expires: now + (value === null ? LATEST_FAIL_TTL_MS : LATEST_TTL_MS),
719
+ };
720
+ return value;
721
+ }
722
+ /** Report the running version and whether a newer one is published on npm. */
723
+ async function serveVersion(res) {
724
+ const current = (0, version_1.currentVersion)();
725
+ const latest = await cachedLatestVersion();
726
+ sendJson(res, 200, {
727
+ name: version_1.PACKAGE_NAME,
728
+ current,
729
+ latest,
730
+ updateAvailable: latest !== null && (0, version_1.isNewer)(latest, current),
731
+ installKind: (0, version_1.installKind)(),
732
+ updateCommand: (0, version_1.updateCommand)(),
733
+ });
734
+ }
735
+ /**
736
+ * Run the self-update on the user's behalf (the GUI "Update now" button).
737
+ * An npx invocation has nothing to update in place, so it returns guidance
738
+ * instead of shelling out. The freshly running server still holds the old code
739
+ * until it's restarted, which the client is told to do on success.
740
+ */
741
+ async function performUpdate(res) {
742
+ const kind = (0, version_1.installKind)();
743
+ if (kind === 'npx') {
744
+ sendJson(res, 200, {
745
+ ok: false,
746
+ skipped: true,
747
+ installKind: kind,
748
+ command: `npx ${version_1.PACKAGE_NAME}@latest`,
749
+ message: `You're running via npx — re-run \`npx ${version_1.PACKAGE_NAME}@latest\` to use the newest version.`,
750
+ });
751
+ return;
752
+ }
753
+ const result = await (0, version_1.runSelfUpdate)();
754
+ // Refresh the cache so a follow-up /api/version reflects the just-published
755
+ // latest (the running process is still the old build until restarted).
756
+ const latest = await cachedLatestVersion();
757
+ sendJson(res, result.ok ? 200 : 500, { ...result, installKind: kind, latest });
758
+ }
759
+ /* ----------------------------------------------------------- SSE (library) */
760
+ function serveEvents(req, res) {
761
+ res.writeHead(200, {
762
+ 'content-type': 'text/event-stream',
763
+ 'cache-control': 'no-store',
764
+ connection: 'keep-alive',
765
+ });
766
+ res.write('retry: 2000\n\n');
767
+ const dir = (0, paths_1.capturesDir)();
768
+ let timer = null;
769
+ const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
770
+ let watcher = null;
771
+ try {
772
+ watcher = (0, node_fs_1.watch)(dir, () => {
773
+ // Debounce bursts of fs events into a single notification.
774
+ if (timer)
775
+ clearTimeout(timer);
776
+ timer = setTimeout(() => res.write('event: recordings\ndata: changed\n\n'), 200);
777
+ });
778
+ }
779
+ catch {
780
+ // captures dir missing/unwatchable — the heartbeat still keeps SSE open
781
+ }
782
+ req.on('close', () => {
783
+ clearInterval(ping);
784
+ if (timer)
785
+ clearTimeout(timer);
786
+ watcher?.close();
787
+ });
788
+ }
789
+ /* ------------------------------------------------------------------ router */
790
+ async function handle(req, res) {
791
+ const url = new URL(req.url ?? '/', 'http://localhost');
792
+ const { pathname, searchParams } = url;
793
+ if (!pathname.startsWith('/api/')) {
794
+ await serveStatic(res, pathname);
795
+ return;
796
+ }
797
+ if (pathname === '/api/recordings' && req.method === 'GET') {
798
+ return listRecordings(res);
799
+ }
800
+ if (pathname === '/api/events' && req.method === 'GET') {
801
+ return serveEvents(req, res);
802
+ }
803
+ if (pathname === '/api/record-command' && req.method === 'GET') {
804
+ return recordCommand(res, searchParams);
805
+ }
806
+ if (pathname === '/api/palette' && req.method === 'GET') {
807
+ return servePalette(res);
808
+ }
809
+ if (pathname === '/api/open-folder' && req.method === 'POST') {
810
+ return openCapturesFolder(res);
811
+ }
812
+ if (pathname === '/api/version' && req.method === 'GET') {
813
+ return serveVersion(res);
814
+ }
815
+ if (pathname === '/api/update' && req.method === 'POST') {
816
+ return performUpdate(res);
817
+ }
818
+ // /api/recordings/:name/frames and /api/recordings/:name/frames/:i/svg
819
+ const framesMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/frames$/);
820
+ const svgMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/frames\/(\d+)\/svg$/);
821
+ const cellsMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/frames\/(\d+)\/cells$/);
822
+ const animMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/animation$/);
823
+ const editsMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/edits$/);
824
+ const revealMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/reveal$/);
825
+ const renameMatch = pathname.match(/^\/api\/recordings\/([^/]+)\/rename$/);
826
+ const deleteMatch = pathname.match(/^\/api\/recordings\/([^/]+)$/);
827
+ const rawName = framesMatch?.[1] ??
828
+ svgMatch?.[1] ??
829
+ cellsMatch?.[1] ??
830
+ animMatch?.[1] ??
831
+ editsMatch?.[1] ??
832
+ revealMatch?.[1] ??
833
+ renameMatch?.[1] ??
834
+ deleteMatch?.[1];
835
+ if (rawName !== undefined) {
836
+ const name = safeCaptureName(rawName);
837
+ if (!name)
838
+ return sendError(res, 400, 'invalid recording name');
839
+ try {
840
+ await (0, promises_1.stat)(node_path_1.default.join((0, paths_1.capturesDir)(), name));
841
+ }
842
+ catch {
843
+ return sendError(res, 404, 'recording not found');
844
+ }
845
+ if (framesMatch && req.method === 'GET')
846
+ return listFrames(res, name);
847
+ if (svgMatch && req.method === 'GET') {
848
+ return serveFrameSvg(res, name, Number(svgMatch[2]), searchParams);
849
+ }
850
+ if (cellsMatch && req.method === 'GET') {
851
+ return serveCells(res, name, Number(cellsMatch[2]), searchParams);
852
+ }
853
+ if (animMatch && req.method === 'GET')
854
+ return getAnimation(res, name);
855
+ if (animMatch && req.method === 'PUT')
856
+ return putAnimation(req, res, name);
857
+ if (editsMatch && req.method === 'GET')
858
+ return getEdits(res, name);
859
+ if (editsMatch && req.method === 'PUT')
860
+ return putEdits(req, res, name);
861
+ if (editsMatch && req.method === 'DELETE')
862
+ return deleteEdits(res, name);
863
+ if (revealMatch && req.method === 'POST')
864
+ return revealRecording(res, name);
865
+ if (renameMatch && req.method === 'POST')
866
+ return renameRecording(req, res, name);
867
+ if (deleteMatch && req.method === 'DELETE')
868
+ return deleteRecording(res, name);
869
+ }
870
+ sendError(res, 404, 'not found');
871
+ }
872
+ /* ------------------------------------------------------------------ listen */
873
+ /**
874
+ * Resolve whether something is already accepting connections on host:port by
875
+ * attempting a short-lived TCP connect. A successful connect means the port is
876
+ * in use (assume our GUI); a connection-refused or timeout means it's free.
877
+ * Used so `record` can skip launching a second server when one is already up.
878
+ */
879
+ function isPortInUse(port, host = '127.0.0.1', timeoutMs = 500) {
880
+ return new Promise((resolve) => {
881
+ const socket = new node_net_1.default.Socket();
882
+ let settled = false;
883
+ const finish = (inUse) => {
884
+ if (settled)
885
+ return;
886
+ settled = true;
887
+ socket.destroy();
888
+ resolve(inUse);
889
+ };
890
+ socket.setTimeout(timeoutMs);
891
+ socket.once('connect', () => finish(true));
892
+ socket.once('timeout', () => finish(false));
893
+ socket.once('error', () => finish(false));
894
+ socket.connect(port, host);
895
+ });
896
+ }
897
+ /**
898
+ * Poll until host:port accepts connections or the timeout elapses. Returns true
899
+ * once the port is live, false if it never came up. Used to confirm a
900
+ * detached GUI child actually started before reporting its URL.
901
+ */
902
+ async function waitForPort(port, host = '127.0.0.1', timeoutMs = 4000) {
903
+ const start = Date.now();
904
+ while (Date.now() - start < timeoutMs) {
905
+ if (await isPortInUse(port, host, 300))
906
+ return true;
907
+ await new Promise((r) => setTimeout(r, 150));
908
+ }
909
+ return false;
910
+ }
911
+ function listen(server, port, host, maxTries) {
912
+ return new Promise((resolve, reject) => {
913
+ let attempts = 0;
914
+ const onError = (err) => {
915
+ if (err.code === 'EADDRINUSE' && port !== 0 && attempts < maxTries) {
916
+ attempts++;
917
+ server.listen(port + attempts, host);
918
+ }
919
+ else {
920
+ reject(err);
921
+ }
922
+ };
923
+ server.on('error', onError);
924
+ server.listen(port, host, () => {
925
+ server.removeListener('error', onError);
926
+ resolve(server.address().port);
927
+ });
928
+ });
929
+ }
930
+ function openBrowser(url) {
931
+ let cmd;
932
+ let args;
933
+ if (process.platform === 'darwin') {
934
+ cmd = 'open';
935
+ args = [url];
936
+ }
937
+ else if (process.platform === 'win32') {
938
+ cmd = 'cmd';
939
+ args = ['/c', 'start', '', url];
940
+ }
941
+ else {
942
+ cmd = 'xdg-open';
943
+ args = [url];
944
+ }
945
+ try {
946
+ (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true }).unref();
947
+ }
948
+ catch {
949
+ // best-effort; the URL is printed for the user to open manually
950
+ }
951
+ }
952
+ /**
953
+ * Start the local GUI server. Reuses the capture/render pipeline and serves the
954
+ * `web/` frontend. Binds to localhost only and resolves once it's listening.
955
+ */
956
+ async function startServer(opts = {}) {
957
+ const host = opts.host ?? '127.0.0.1';
958
+ const desired = opts.port ?? exports.DEFAULT_GUI_PORT;
959
+ await (0, promises_1.mkdir)((0, paths_1.capturesDir)(), { recursive: true }).catch(() => { });
960
+ const server = node_http_1.default.createServer((req, res) => {
961
+ handle(req, res).catch((err) => {
962
+ const message = err instanceof Error ? err.message : String(err);
963
+ if (!res.headersSent)
964
+ sendError(res, 500, message);
965
+ else
966
+ res.end();
967
+ });
968
+ });
969
+ const port = await listen(server, desired, host, 20);
970
+ const url = `http://${host}:${port}`;
971
+ if (opts.open !== false)
972
+ openBrowser(url);
973
+ return {
974
+ url,
975
+ port,
976
+ close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
977
+ };
978
+ }