staysfixed 0.6.0 → 0.6.2

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.
@@ -0,0 +1,1671 @@
1
+ /**
2
+ * The watch window: one real window, beside the thing being checked, that redraws
3
+ * itself while a check runs — and that a person can minimise, cover, move to
4
+ * another desktop or close without the check noticing.
5
+ *
6
+ * That last sentence is the whole specification, and it is the owner's own:
7
+ *
8
+ * "I don't want it to be completely invisible, then the user will not get to
9
+ * know what it is doing. That window should come up... But once we minimise
10
+ * it, it should keep working headless in the background. Not invisible. It
11
+ * should not keep bringing itself to the front."
12
+ *
13
+ * Four rules come out of that, and every awkward thing in this file is one of them.
14
+ *
15
+ * ONE. The check must never wait on the window. Every push is fire-and-forget with
16
+ * a hard timeout on it, the queue lives on this side, and a window that is slow,
17
+ * minimised, crashed or gone is a window that stops being pushed to — never a
18
+ * check that stops. `push` returns nothing at all, so a caller cannot accidentally
19
+ * await it.
20
+ *
21
+ * TWO. Minimised means minimised, not paused. A browser slows a hidden page's
22
+ * timers right down, so anything the panel worked out on a clock of its own would
23
+ * stall the moment you looked away. The fix is a division of labour: the ENGINE
24
+ * works everything out and pushes it, the PANEL only draws. A page that has been
25
+ * hidden for ten minutes catches up on one state message and has lost nothing.
26
+ * When the queue backs up it is EVIDENCE that goes — pictures nobody had time to
27
+ * look at — and never the newest state.
28
+ *
29
+ * THREE. It never takes the screen. Whoever was in front before this opened is put
30
+ * back in front afterwards, and the same dance is exported so the adapters can use
31
+ * it too — because the complaint that started all of this was not about this panel
32
+ * at all, it was about an app, a simulator and an emulator jumping in front of him
33
+ * while he worked.
34
+ *
35
+ * FOUR. It is our own window, not his. Chrome for Testing wherever there is one,
36
+ * a throwaway profile every time, its own port, and nothing on this machine that
37
+ * we did not start is ever touched.
38
+ *
39
+ * There is no server here and no port beyond the debugging one. The page is a
40
+ * local file, opened as an app window — no tabs, no address bar — and every update
41
+ * is one call into a function the page defines.
42
+ */
43
+
44
+ import { spawn, execFile } from 'node:child_process';
45
+ import { promisify } from 'node:util';
46
+ import fsp from 'node:fs/promises';
47
+ import os from 'node:os';
48
+ import path from 'node:path';
49
+ import { pathToFileURL } from 'node:url';
50
+
51
+ import { warn, detail } from '../../core/log.js';
52
+ import { messageOf } from '../../core/errors.js';
53
+ import { waitForEndpoint, listTargets, connect } from '../../drive/cdp.js';
54
+ import { freePort } from '../../drive/find.js';
55
+ import { createPage } from '../../drive/page.js';
56
+ import { stopProcess, delay } from '../../drive/browser.js';
57
+ import { surveyBrowsers } from '../browsers.js';
58
+ import { planPlacement, panelBeside, PANEL_MIN_WIDTH, PANEL_MAX_WIDTH } from '../../watch/place.js';
59
+
60
+ const execFileAsync = promisify(execFile);
61
+
62
+ /** @typedef {import('../../watch/place.js').Bounds} Bounds */
63
+ /** @typedef {import('./events.js').PanelEvent} PanelEvent */
64
+ /** @typedef {import('./events.js').PanelPlanShape} PanelPlan */
65
+
66
+ /** Every temp folder this file makes starts with this, so old ones can be found again. */
67
+ const TMP_PREFIX = 'staysfixed-v2-panel-';
68
+
69
+ /** A panel folder untouched for this long belongs to a window nobody has open any more. */
70
+ const STALE_MS = 24 * 60 * 60 * 1000;
71
+
72
+ /** How wide the panel is when nobody says otherwise. Legible down to 420. */
73
+ const DEFAULT_WIDTH = 480;
74
+
75
+ /** A panel shorter than this cannot show the state and the findings at the same time. */
76
+ const MIN_HEIGHT = 640;
77
+
78
+ /**
79
+ * The gap between the app's edge and the panel's. None, on purpose: two windows
80
+ * that touch read as one window, and a seam is what gives away that they are two
81
+ * separate programs sitting next to each other.
82
+ */
83
+ const GAP = 0;
84
+
85
+ /** How long to wait for the window to open before giving up on it. */
86
+ const OPEN_TIMEOUT_MS = 20_000;
87
+
88
+ /**
89
+ * How long any one push may take before we stop waiting for it.
90
+ *
91
+ * This is the number that makes "the check never waits on the window" true rather
92
+ * than merely intended. A minimised page, a page whose renderer has been suspended
93
+ * by the operating system, a browser being swapped back in — all of them can make
94
+ * one protocol call take seconds. After this we stop caring about that batch and
95
+ * carry on. Deliberately short: nothing here is worth a second of a check's time.
96
+ */
97
+ const PUSH_TIMEOUT_MS = 1500;
98
+
99
+ /** Updates sent in one call. More than this in flight means the run is outrunning the window. */
100
+ const MAX_BATCH = 32;
101
+
102
+ /**
103
+ * The most updates that may be waiting at once.
104
+ *
105
+ * Past this the queue is folded down rather than grown: state messages collapse to
106
+ * the newest, evidence is stripped, and only then are the oldest narrative lines
107
+ * dropped. A check that runs for an hour behind a minimised window must not turn
108
+ * into a hundred megabytes of queued JSON.
109
+ */
110
+ const MAX_QUEUE = 600;
111
+
112
+ /** How many queued updates keep their pictures when the queue is backing up. */
113
+ const KEEP_EVIDENCE = 3;
114
+
115
+ /**
116
+ * Pushes that may time out in a row before we treat the window as gone.
117
+ *
118
+ * Not one: a single slow call is a page being swapped back in, which is normal and
119
+ * recovers. Several in a row is a window nobody is going to see again.
120
+ */
121
+ const STALL_LIMIT = 8;
122
+
123
+ /**
124
+ * The window's own background, painted before the document is. This must be the
125
+ * panel's ground colour: it is the same surface, and a browser flashing white for
126
+ * a fifth of a second is exactly what makes a purpose-built window look like a
127
+ * browser tab.
128
+ */
129
+ const GROUND = { r: 16, g: 16, b: 16, a: 1 };
130
+
131
+ /** A window off by less than this was nudged by a window manager, not by a person. */
132
+ const MOVE_TOLERANCE = 8;
133
+
134
+ /** How often the page looks at where it is. Slow on purpose: nobody is racing. */
135
+ const MOVE_WATCH_MS = 1000;
136
+
137
+ /** Where the window a person arranged is written down, inside the project's own folder. */
138
+ const REMEMBER_FILE = 'watch-window.json';
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Not taking the screen — the part the adapters need as much as this file does
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * The name of the application currently in front, on macOS. Null anywhere else,
146
+ * and null whenever the machine will not say.
147
+ *
148
+ * Call this BEFORE you open, boot, launch or move anything, and hand what it gives
149
+ * you to `giveTheScreenBack` afterwards. Those two calls are the whole of the
150
+ * promise that this tool does not take the screen out from under somebody.
151
+ *
152
+ * @returns {Promise<string|null>}
153
+ */
154
+ export async function noteTheFrontmost() {
155
+ if (process.platform !== 'darwin') return null;
156
+ try {
157
+ const { stdout } = await execFileAsync(
158
+ 'osascript',
159
+ ['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
160
+ { timeout: 4000 },
161
+ );
162
+ const name = stdout.trim();
163
+ return name.length > 0 ? name : null;
164
+ } catch {
165
+ // No Apple Events permission, or no window server at all. Not worth a word:
166
+ // the worst case is that whatever we opened keeps the foreground, and the
167
+ // person can click back. It is never worth failing a check over.
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Put the screen back where it was.
174
+ *
175
+ * The other half of `noteTheFrontmost`, and the one implementation of it —
176
+ * the web, Electron, iOS, Android and Windows adapters all call this rather than
177
+ * each writing their own AppleScript, because three copies of this is how one of
178
+ * them ends up subtly not doing it.
179
+ *
180
+ * Safe to call with null, on any platform, at any time. Never throws.
181
+ *
182
+ * @param {string|null|undefined} who Whatever `noteTheFrontmost` gave you.
183
+ * @returns {Promise<void>}
184
+ */
185
+ export async function giveTheScreenBack(who) {
186
+ if (!who || process.platform !== 'darwin') return;
187
+ try {
188
+ await execFileAsync(
189
+ 'osascript',
190
+ [
191
+ '-e',
192
+ `tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(who)} to true`,
193
+ ],
194
+ { timeout: 4000 },
195
+ );
196
+ detail(`the screen was handed back to ${who}`);
197
+ } catch {
198
+ // Worst case whatever we opened keeps the foreground. Never a reason to fail.
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Do something that might take the screen, and hand the screen back afterwards.
204
+ *
205
+ * The whole dance in one call, for the common case: booting a simulator, starting
206
+ * a desktop app, moving a window. Whatever the work returns comes straight back
207
+ * out, and the screen is handed back even when the work throws.
208
+ *
209
+ * @template T
210
+ * @param {() => Promise<T>} work
211
+ * @returns {Promise<T>}
212
+ */
213
+ export async function withoutTakingTheScreen(work) {
214
+ const who = await noteTheFrontmost();
215
+ try {
216
+ return await work();
217
+ } finally {
218
+ await giveTheScreenBack(who);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * How far past the corner of every screen an off-screen window is put.
224
+ *
225
+ * Big enough that no arrangement of real monitors reaches it — a wall of 8K panels
226
+ * stacked up and to the left is still nowhere near — and small enough to stay a
227
+ * sane whole number for a window manager to store.
228
+ */
229
+ export const OFF_SCREEN_ORIGIN = -32_000;
230
+
231
+ /**
232
+ * Coordinates that no display covers.
233
+ *
234
+ * This is the answer for anything that CANNOT be run without a window. Electron is
235
+ * the case that matters: a desktop app has no headless mode, so the choice is a
236
+ * window on somebody's screen or a window nowhere, and "nowhere" is the one that
237
+ * lets him keep working. A window placed here still exists, still lays out, still
238
+ * runs its code, still answers the debugging protocol and still photographs — the
239
+ * picture comes from the compositor, which does not care where the window is — it
240
+ * simply never appears in front of anybody.
241
+ *
242
+ * Above and to the left, never below and to the right. A second monitor is nearly
243
+ * always placed to the right or above the main one and nearly never thirty
244
+ * thousand pixels away, so the negative corner is the one that stays empty. The
245
+ * whole window is put past the corner, not just its origin, so not even a title
246
+ * bar shows.
247
+ *
248
+ * Pass `displays` when you actually know where the screens are and the window goes
249
+ * just past the furthest one instead, which is gentler on a window manager that
250
+ * dislikes very large numbers.
251
+ *
252
+ * @param {{width?: number, height?: number}} [size] The window's size. Its position is ignored.
253
+ * @param {Bounds[]} [displays] Every screen, when the caller can measure them.
254
+ * @returns {Bounds} Same size, somewhere nobody can see.
255
+ */
256
+ export function offScreen(size, displays) {
257
+ const width = Math.max(1, Math.round(Number(size?.width) || 1280));
258
+ const height = Math.max(1, Math.round(Number(size?.height) || 800));
259
+
260
+ let left = OFF_SCREEN_ORIGIN;
261
+ let top = OFF_SCREEN_ORIGIN;
262
+ const known = (displays ?? []).filter((d) => d && Number.isFinite(Number(d.left)) && Number.isFinite(Number(d.top)));
263
+ if (known.length > 0) {
264
+ const margin = 200;
265
+ left = Math.min(...known.map((d) => Math.round(Number(d.left)))) - width - margin;
266
+ top = Math.min(...known.map((d) => Math.round(Number(d.top)))) - height - margin;
267
+ }
268
+ return { left, top, width, height, x: left, y: top };
269
+ }
270
+
271
+ /**
272
+ * Is this window somewhere nobody can see it?
273
+ *
274
+ * For an adapter that wants to say so out loud — "the app ran off-screen, so
275
+ * nothing appeared in front of you" is worth a line in a report, and a claim
276
+ * nobody checked is worth nothing.
277
+ *
278
+ * @param {Bounds|null|undefined} bounds
279
+ * @param {Bounds[]} [displays]
280
+ * @returns {boolean}
281
+ */
282
+ export function isOffScreen(bounds, displays) {
283
+ if (!bounds) return false;
284
+ const right = Number(bounds.left) + Number(bounds.width);
285
+ const bottom = Number(bounds.top) + Number(bounds.height);
286
+ const screens = displays ?? [];
287
+ if (screens.length === 0) return right <= 0 || bottom <= 0;
288
+ return !screens.some((screen) => {
289
+ const sr = Number(screen.left) + Number(screen.width);
290
+ const sb = Number(screen.top) + Number(screen.height);
291
+ return right > Number(screen.left) && Number(bounds.left) < sr && bottom > Number(screen.top) && Number(bounds.top) < sb;
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Move a window through macOS itself, naming the process by its unix id.
297
+ *
298
+ * The way to put a window somewhere when the thing owning it cannot be asked.
299
+ * Electron does not implement the part of the debugging protocol that moves
300
+ * windows — `Browser.getWindowForTarget` is simply not there — so a desktop app
301
+ * cannot be placed over the connection, and placing it is the entire point of
302
+ * `offScreen`.
303
+ *
304
+ * Two hard rules, both learned the expensive way. It names the process BY ITS
305
+ * UNIX ID, never by its name: a person's own copy of an app and the scratch copy
306
+ * under test have the same name, and a script that says `process "Terminal Deck"`
307
+ * moves whichever one macOS hands it. And it moves only the window that is exactly
308
+ * where the caller says it is, so an app with a second window open keeps it where
309
+ * it was.
310
+ *
311
+ * Position only. The size of a window being observed is part of what is being
312
+ * observed, and is never ours to change.
313
+ *
314
+ * @param {number} pid The process THIS run started. Never one we attached to.
315
+ * @param {Bounds} current Where the window is now.
316
+ * @param {{left: number, top: number}} target
317
+ * @returns {Promise<boolean>}
318
+ */
319
+ export async function moveWindowByPid(pid, current, target) {
320
+ if (process.platform !== 'darwin') return false;
321
+ if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return false;
322
+ const script = [
323
+ `tell application "System Events" to tell (first application process whose unix id is ${Math.round(pid)})`,
324
+ ' repeat with w in windows',
325
+ ` if (item 1 of (get position of w)) is ${Math.round(current.left)} and (item 2 of (get position of w)) is ${Math.round(current.top)} then`,
326
+ ` set position of w to {${Math.round(target.left)}, ${Math.round(target.top)}}`,
327
+ ' end if',
328
+ ' end repeat',
329
+ 'end tell',
330
+ ].join('\n');
331
+ try {
332
+ await execFileAsync('osascript', ['-e', script], { timeout: 8000 });
333
+ return true;
334
+ } catch {
335
+ // Usually no accessibility permission, sometimes an app that will not be
336
+ // scripted. Report it as not moved and let the caller say so.
337
+ return false;
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // The screen, and the windows standing on it
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * A window we can move: anything with a debugging connection and a target on it.
347
+ * @typedef {object} WindowRef
348
+ * @property {(method: string, params?: Record<string, unknown>) => Promise<any>} send
349
+ * @property {string} targetId
350
+ */
351
+
352
+ /**
353
+ * The usable screen area, in screen pixels — menu bar and dock already taken off.
354
+ *
355
+ * Always ask the PANEL's page and never the app's: an app being observed is often
356
+ * told it is on a screen exactly the size of the viewport we asked for, which is
357
+ * what makes an observation the same on every machine, and is also why it is the
358
+ * last thing to ask how big the real screen is.
359
+ *
360
+ * @param {{evaluate: (js: string) => Promise<any>}} page The panel's page.
361
+ * @returns {Promise<Bounds>}
362
+ */
363
+ export async function readScreen(page) {
364
+ /** A desk-sized screen, for a window that will not say. */
365
+ const fallback = { left: 0, top: 0, width: 1440, height: 900 };
366
+ try {
367
+ const raw = await page.evaluate(
368
+ '(function(){var s=window.screen||{};return {' +
369
+ 'left:s.availLeft,top:s.availTop,' +
370
+ 'width:s.availWidth||s.width,height:s.availHeight||s.height};})()',
371
+ );
372
+ if (!raw || typeof raw !== 'object') return fallback;
373
+ const width = Math.round(Number(raw.width));
374
+ const height = Math.round(Number(raw.height));
375
+ if (!(width > 0) || !(height > 0)) return fallback;
376
+ const left = Number(raw.left);
377
+ const top = Number(raw.top);
378
+ return {
379
+ // Not every browser reports availLeft/availTop; a single screen starts at zero.
380
+ left: Number.isFinite(left) ? Math.round(left) : 0,
381
+ top: Number.isFinite(top) ? Math.round(top) : 0,
382
+ width,
383
+ height,
384
+ };
385
+ } catch {
386
+ return fallback;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Where a window is right now, over the debugging protocol. Null when the target
392
+ * will not say — which is every Electron window there has ever been.
393
+ * @param {WindowRef} page
394
+ * @returns {Promise<Bounds|null>}
395
+ */
396
+ export async function readWindowBounds(page) {
397
+ try {
398
+ const found = await page.send('Browser.getWindowForTarget', { targetId: page.targetId });
399
+ const bounds = found?.bounds;
400
+ if (!bounds) return null;
401
+ const width = Math.round(Number(bounds.width));
402
+ const height = Math.round(Number(bounds.height));
403
+ if (!(width > 0) || !(height > 0)) return null;
404
+ return {
405
+ left: Math.round(Number(bounds.left) || 0),
406
+ top: Math.round(Number(bounds.top) || 0),
407
+ width,
408
+ height,
409
+ };
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Where a window is, as its own page sees it.
417
+ *
418
+ * `screenX`, `screenY`, `outerWidth` and `outerHeight` describe the window rather
419
+ * than the page, and they keep describing the window even with a device-metrics
420
+ * override applied. This is the only measurement a desktop app will give at all.
421
+ *
422
+ * @param {{evaluate: (js: string) => Promise<any>}} page
423
+ * @returns {Promise<Bounds|null>}
424
+ */
425
+ export async function readPageWindow(page) {
426
+ try {
427
+ const raw = await page.evaluate(
428
+ '({left:window.screenX,top:window.screenY,width:window.outerWidth,height:window.outerHeight})',
429
+ );
430
+ if (!raw || typeof raw !== 'object') return null;
431
+ const width = Math.round(Number(raw.width));
432
+ const height = Math.round(Number(raw.height));
433
+ if (!(width > 0) || !(height > 0)) return null;
434
+ return {
435
+ left: Math.round(Number(raw.left) || 0),
436
+ top: Math.round(Number(raw.top) || 0),
437
+ width,
438
+ height,
439
+ };
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Move a window, and say whether it went.
447
+ *
448
+ * Never throws. Not every window can be moved — Electron builds differ on this,
449
+ * and a window manager is entitled to say no — and where a window sits is a nicety
450
+ * that is never worth a failed check, nor a warning in the middle of a clean one.
451
+ *
452
+ * @param {WindowRef} page
453
+ * @param {Bounds} bounds
454
+ * @returns {Promise<boolean>}
455
+ */
456
+ export async function moveWindow(page, bounds) {
457
+ try {
458
+ const found = await page.send('Browser.getWindowForTarget', { targetId: page.targetId });
459
+ const windowId = found?.windowId;
460
+ if (typeof windowId !== 'number') return false;
461
+
462
+ // A maximised or minimised window refuses a size, and Chrome answers with an
463
+ // error rather than quietly restoring it for you. So it is put back to a
464
+ // normal window first, in a call of its own: the same call cannot both
465
+ // restore a window and place it.
466
+ const state = String(found?.bounds?.windowState ?? 'normal');
467
+ if (state !== 'normal') {
468
+ await page.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
469
+ }
470
+
471
+ await page.send('Browser.setWindowBounds', {
472
+ windowId,
473
+ bounds: {
474
+ windowState: 'normal',
475
+ left: Math.round(bounds.left),
476
+ top: Math.round(bounds.top),
477
+ width: Math.max(1, Math.round(bounds.width)),
478
+ height: Math.max(1, Math.round(bounds.height)),
479
+ },
480
+ });
481
+ return true;
482
+ } catch {
483
+ return false;
484
+ }
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Where the panel opens
489
+ // ---------------------------------------------------------------------------
490
+
491
+ /**
492
+ * @param {number} value
493
+ * @param {number} low
494
+ * @param {number} high
495
+ * @returns {number}
496
+ */
497
+ function clamp(value, low, high) {
498
+ return Math.min(high, Math.max(low, value));
499
+ }
500
+
501
+ /**
502
+ * The opening guess: how big the panel is, and where it goes before anything knows
503
+ * how big the screen is.
504
+ *
505
+ * Only a window can say what screen it is on, and there is no window yet when this
506
+ * is called — so the thing being checked is taken to be sitting in the top left
507
+ * corner and the panel is put beside it. `snapTo` replaces this with the real
508
+ * placement the moment there is something to sit beside.
509
+ *
510
+ * Pure on purpose, so the arithmetic can be checked without opening anything.
511
+ *
512
+ * @param {{width?: number, height?: number}} [appViewport]
513
+ * @param {import('../../types.js').WatchOptions} [watch]
514
+ * @returns {{width: number, height: number, x: number, y: number}}
515
+ */
516
+ export function panelBounds(appViewport, watch) {
517
+ const opts = watch ?? {};
518
+ const appWidth = Math.round(Number(appViewport?.width) || 1280);
519
+ const appHeight = Math.round(Number(appViewport?.height) || 800);
520
+
521
+ const asked = Number(opts.width);
522
+ const width = clamp(
523
+ Math.round(Number.isFinite(asked) && asked > 0 ? asked : DEFAULT_WIDTH),
524
+ PANEL_MIN_WIDTH,
525
+ PANEL_MAX_WIDTH,
526
+ );
527
+
528
+ const askedTall = Number(opts.height);
529
+ const height = Math.max(
530
+ MIN_HEIGHT,
531
+ Math.round(Number.isFinite(askedTall) && askedTall > 0 ? askedTall : appHeight),
532
+ );
533
+
534
+ const plan = planPlacement({
535
+ // A screen exactly big enough for the two of them, because there is no real
536
+ // one to ask yet. It keeps the windows adjacent and keeps every bit of the
537
+ // arithmetic in one file.
538
+ screen: { left: 0, top: 0, width: appWidth + GAP + width, height: Math.max(appHeight, height) },
539
+ appSize: { width: appWidth, height: appHeight },
540
+ panelWidth: width,
541
+ // `watch.side` names the side the PANEL goes on; `planPlacement` names the
542
+ // screen edge the APP is pinned to. With the app assumed to be in the corner
543
+ // those are the same arrangement said from opposite ends.
544
+ side: opts.side === 'left' ? 'right' : 'left',
545
+ gap: GAP,
546
+ });
547
+
548
+ return { width, height, x: plan.panel.left, y: plan.panel.top };
549
+ }
550
+
551
+ /**
552
+ * The height the person asked for, or nothing — in which case the panel is as tall
553
+ * as whatever it is standing next to.
554
+ * @param {import('../../types.js').WatchOptions|undefined} watch
555
+ * @returns {number|null}
556
+ */
557
+ function askedHeight(watch) {
558
+ const asked = Number(watch?.height);
559
+ return Number.isFinite(asked) && asked > 0 ? Math.round(asked) : null;
560
+ }
561
+
562
+ /**
563
+ * Where the panel goes before there is anything to sit beside.
564
+ *
565
+ * @param {Bounds} screen
566
+ * @param {{width: number, height: number}} size
567
+ * @param {import('../../types.js').WatchOptions|undefined} watch
568
+ * @param {{width: number, height: number}|null} [expectedApp]
569
+ * @returns {Bounds}
570
+ */
571
+ function firstPlace(screen, size, watch, expectedApp = null) {
572
+ const plan = planPlacement({
573
+ screen,
574
+ appSize: expectedApp,
575
+ panelWidth: size.width,
576
+ side: watch?.side === 'left' ? 'left' : 'right',
577
+ gap: GAP,
578
+ });
579
+ const tall = askedHeight(watch);
580
+ return tall ? { ...plan.panel, height: Math.min(tall, screen.height) } : plan.panel;
581
+ }
582
+
583
+ /**
584
+ * Would this window still make sense on this screen?
585
+ * @param {Bounds} bounds
586
+ * @param {Bounds} screen
587
+ * @returns {boolean}
588
+ */
589
+ function fitsOnScreen(bounds, screen) {
590
+ // A window is allowed to hang slightly over an edge — people put them there on
591
+ // purpose. A window remembered from a bigger monitor is not.
592
+ const slack = 24;
593
+ if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
594
+ return (
595
+ bounds.left >= screen.left - slack &&
596
+ bounds.top >= screen.top - slack &&
597
+ bounds.left + bounds.width <= screen.left + screen.width + slack &&
598
+ bounds.top + bounds.height <= screen.top + screen.height + slack
599
+ );
600
+ }
601
+
602
+ /**
603
+ * The window the person arranged last time, if there is one.
604
+ * @param {string|null|undefined} dir
605
+ * @returns {Promise<Bounds|null>}
606
+ */
607
+ async function readRemembered(dir) {
608
+ if (!dir) return null;
609
+ try {
610
+ const raw = JSON.parse(await fsp.readFile(path.join(dir, REMEMBER_FILE), 'utf8'));
611
+ const width = Math.round(Number(raw?.width));
612
+ const height = Math.round(Number(raw?.height));
613
+ if (!(width > 0) || !(height > 0)) return null;
614
+ return {
615
+ left: Math.round(Number(raw.left) || 0),
616
+ top: Math.round(Number(raw.top) || 0),
617
+ width,
618
+ height,
619
+ };
620
+ } catch {
621
+ // No file, or a file somebody has been editing. Either way: place it ourselves.
622
+ return null;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Write down where the person left the window.
628
+ *
629
+ * A window somebody has arranged is theirs, and it should still be theirs
630
+ * tomorrow. The screen is written down beside it so a position remembered from a
631
+ * second monitor can be recognised and ignored.
632
+ *
633
+ * @param {string|null|undefined} dir
634
+ * @param {Bounds} bounds
635
+ * @param {Bounds} screen
636
+ * @returns {Promise<void>}
637
+ */
638
+ async function writeRemembered(dir, bounds, screen) {
639
+ if (!dir) return;
640
+ try {
641
+ await fsp.mkdir(dir, { recursive: true });
642
+ const body = { ...bounds, screen, at: new Date().toISOString() };
643
+ await fsp.writeFile(path.join(dir, REMEMBER_FILE), JSON.stringify(body, null, 2) + '\n');
644
+ } catch {
645
+ // Remembering is a courtesy. A read-only folder is not a failed check.
646
+ }
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // The page inside the window
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /**
654
+ * The panel's own document.
655
+ *
656
+ * The design of that page belongs in `panel.js` next door — the look the owner chose after
657
+ * seeing four rendered side by side, and corrected twice — and the words it draws belong in
658
+ * `events.js`. This file only puts the result in a window.
659
+ *
660
+ * Asked for by name rather than imported at the top, and with a stand-in behind it, for one
661
+ * reason: a window that cannot be built must never be a check that cannot run. That has to
662
+ * hold for a page with a mistake in it exactly as much as it holds for a machine with no
663
+ * browser on it, and a plain `import` would take the whole tool down with the page.
664
+ *
665
+ * @param {PanelPlan} plan
666
+ * @returns {Promise<string>}
667
+ */
668
+ async function documentFor(plan) {
669
+ // Deliberately not a literal specifier: this has to typecheck, load and run whether or not
670
+ // the designed page is there.
671
+ const beside = new URL('./panel.js', import.meta.url).href;
672
+ try {
673
+ const mod = /** @type {Record<string, unknown>} */ (await import(beside));
674
+ const make = mod.panelHtml;
675
+ if (typeof make === 'function') {
676
+ const html = /** @type {(p: PanelPlan) => unknown} */ (make)(plan);
677
+ if (typeof html === 'string' && html.length > 0) return html;
678
+ }
679
+ detail('watch window: panel.js did not hand back a page, so the plain one is being used.');
680
+ } catch (e) {
681
+ detail(`watch window: the panel page could not be built, so the plain one is being used. ${messageOf(e)}`);
682
+ }
683
+ return plainPanelHtml(plan);
684
+ }
685
+
686
+ /**
687
+ * The stand-in page: what opens when the designed panel could not be built.
688
+ *
689
+ * Deliberately the least that is honest — what is being checked, the plain-English line for
690
+ * everything that has happened, and the verdict when there is one. It borrows the palette and
691
+ * the restraint from the real panel, so a window that has fallen back to this still looks like
692
+ * part of the same tool, and it says at the top that it IS the fallback rather than letting
693
+ * somebody think this is the product.
694
+ *
695
+ * Self-contained by rule: inline style, inline script, no address of any kind in it, no
696
+ * framework, nothing to fetch.
697
+ *
698
+ * @param {PanelPlan} plan
699
+ * @returns {string}
700
+ */
701
+ export function plainPanelHtml(plan = {}) {
702
+ const wanted = String(plan.theme ?? 'dark');
703
+ const theme = wanted === 'light' || wanted === 'system' ? wanted : 'dark';
704
+ const product = String(plan.product ?? '').trim() || 'this product';
705
+ const surfaces = (plan.surfaces ?? []).map((w) => String(w)).filter(Boolean).join(' \u00b7 ');
706
+ // Embedded as a JSON string the page parses, so nothing a journey is called can ever be read
707
+ // as code.
708
+ const seed = JSON.stringify(JSON.stringify({ product, surfaces })).replace(/</g, '\\u003c');
709
+
710
+ return `<!doctype html>
711
+ <html lang="en" data-theme="${theme}">
712
+ <head>
713
+ <meta charset="utf-8">
714
+ <meta name="viewport" content="width=device-width, initial-scale=1">
715
+ <title>Stays Fixed</title>
716
+ <style>
717
+ :root {
718
+ color-scheme: dark;
719
+ --ground: #101010; --card: rgba(255,255,255,0.035);
720
+ --ink: #ededed; --soft: #b2b2b2; --faint: #8d8d8d; --faintest: #6d6d6d;
721
+ --line: rgba(255,255,255,0.055);
722
+ --accent: #4fb3f0; --held: #25d366; --broke: #ff4438; --doubt: #e8b85c;
723
+ --resting: rgba(255,255,255,0.09);
724
+ }
725
+ :root[data-theme='light'], :root[data-theme='system'] {
726
+ color-scheme: light;
727
+ --ground: #d9dade; --card: rgba(255,255,255,0.6);
728
+ --ink: #14161a; --soft: #545a62; --faint: #5e646c; --faintest: #6f757d;
729
+ --line: rgba(20,22,26,0.11);
730
+ --accent: #474d56; --held: #757b83; --broke: #7e1105; --doubt: #8a5f06;
731
+ --resting: rgba(20,22,26,0.13);
732
+ }
733
+ * { box-sizing: border-box; }
734
+ html, body { margin: 0; padding: 0; height: 100%; }
735
+ body {
736
+ background: var(--ground); color: var(--ink); overflow: hidden;
737
+ font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
738
+ -webkit-font-smoothing: antialiased;
739
+ }
740
+ .mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
741
+ .panel { display: flex; flex-direction: column; height: 100%; }
742
+ .top { padding: 16px; border-bottom: 1px solid var(--line); }
743
+ .brand { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--faint); }
744
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
745
+ .dot.held { background: var(--held); } .dot.broke { background: var(--broke); } .dot.rest { background: var(--resting); }
746
+ h1 { margin: 8px 0 2px; font-size: 18px; font-weight: 560; letter-spacing: -0.01em; }
747
+ .sub { margin: 0; font-size: 11.5px; color: var(--soft); }
748
+ .note { margin-top: 10px; padding: 9px 11px; border-radius: 8px; background: var(--card);
749
+ box-shadow: inset 2px 0 0 var(--doubt), inset 0 0 0 1px var(--line); font-size: 11.5px; color: var(--soft); }
750
+ .body { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 8px 12px 14px; }
751
+ .row { display: flex; gap: 9px; align-items: baseline; padding: 6px 4px; }
752
+ .row + .row { box-shadow: inset 0 1px 0 var(--line); }
753
+ .row .t { color: var(--faintest); font-size: 11px; flex: 0 0 46px; }
754
+ .row .m { flex: 1 1 auto; font-size: 11.5px; color: var(--soft); overflow-wrap: anywhere; }
755
+ .row.last .m { color: var(--ink); }
756
+ .foot { border-top: 1px solid var(--line); padding: 10px 16px; font-size: 11px; color: var(--faint);
757
+ display: flex; justify-content: space-between; align-items: center; }
758
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
759
+ </style>
760
+ </head>
761
+ <body>
762
+ <div class="panel">
763
+ <div class="top">
764
+ <div class="brand"><span class="dot" id="dot"></span><span>Stays Fixed</span></div>
765
+ <h1 id="head">this product</h1>
766
+ <p class="sub" id="sub"></p>
767
+ <div class="note">This is the plain window. The designed panel could not be built on this
768
+ machine, so what you are looking at is every line the check has said, in order. The check
769
+ itself is unaffected.</div>
770
+ </div>
771
+ <div class="body" id="lines"></div>
772
+ <div class="foot"><span id="verdict">Running</span><span class="mono" id="clock">0.0s</span></div>
773
+ </div>
774
+ <script>
775
+ (function () {
776
+ var seed = {};
777
+ try { seed = JSON.parse(${seed}); } catch (e) { seed = {}; }
778
+ var lines = [];
779
+ var done = null;
780
+ var detached = false;
781
+ var el = function (id) { return document.getElementById(id); };
782
+ function text(node, value) { node.textContent = value == null ? '' : String(value); }
783
+
784
+ text(el('head'), seed.product || 'this product');
785
+ text(el('sub'), seed.surfaces || '');
786
+
787
+ function draw() {
788
+ var host = el('lines');
789
+ host.textContent = '';
790
+ for (var i = 0; i < lines.length; i++) {
791
+ var r = document.createElement('div');
792
+ r.className = 'row' + (i === lines.length - 1 ? ' last' : '');
793
+ var t = document.createElement('div');
794
+ t.className = 't mono';
795
+ text(t, (Math.round((lines[i].at || 0) / 100) / 10).toFixed(1) + 's');
796
+ var m = document.createElement('div');
797
+ m.className = 'm';
798
+ text(m, lines[i].message);
799
+ r.appendChild(t); r.appendChild(m);
800
+ host.appendChild(r);
801
+ }
802
+ host.scrollTop = host.scrollHeight;
803
+ var dot = el('dot');
804
+ dot.className = 'dot' + (done ? (done.ok ? ' held' : ' broke') : '');
805
+ if (done) text(el('verdict'), done.summary || (done.ok ? 'Nothing that worked has changed.' : 'Something changed.'));
806
+ }
807
+
808
+ var ticking = null;
809
+ function schedule() {
810
+ if (ticking) return;
811
+ ticking = requestAnimationFrame(function () { ticking = null; draw(); });
812
+ }
813
+
814
+ window.__staysfixed_push = function (input) {
815
+ if (detached || !input || typeof input !== 'object') return;
816
+ if (input.type === 'check:done' && input.verdict) done = input.verdict;
817
+ if (typeof input.message === 'string' && input.message.length > 0) {
818
+ lines.push({ at: input.at || 0, message: input.message });
819
+ if (lines.length > 500) lines.splice(0, lines.length - 500);
820
+ }
821
+ schedule();
822
+ };
823
+ window.__staysfixed_detach = function () { detached = true; };
824
+
825
+ // The clock is the one thing here on a timer, and it is the one thing allowed to be wrong
826
+ // while the window is hidden: a browser slows a hidden page right down. Everything that
827
+ // matters is worked out by the engine and pushed, so nothing else can drift at all.
828
+ var born = Date.now();
829
+ setInterval(function () {
830
+ if (detached) return;
831
+ var ms = done ? (done.durationMs || 0) : Date.now() - born;
832
+ text(el('clock'), (Math.round(ms / 100) / 10).toFixed(1) + 's');
833
+ }, 200);
834
+
835
+ draw();
836
+ })();
837
+ </script>
838
+ </body>
839
+ </html>
840
+ `;
841
+ }
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // Opening it
845
+ // ---------------------------------------------------------------------------
846
+
847
+ /**
848
+ * A watch window that is open and listening.
849
+ *
850
+ * `push` hands back nothing on purpose. There is no promise here to accidentally
851
+ * await, no error to accidentally catch, and therefore no way for a check to end
852
+ * up waiting on a window.
853
+ *
854
+ * @typedef {object} Panel
855
+ * @property {(event: PanelEvent) => void} push
856
+ * @property {() => Promise<void>} close
857
+ * @property {string} url
858
+ * @property {(beside: BesideThis) => Promise<void>} snapTo
859
+ * @property {() => boolean} placedByHand
860
+ * @property {() => PanelHealth} health
861
+ */
862
+
863
+ /**
864
+ * How the window is bearing up. For the check's own report, and for tests: a
865
+ * claim that the panel never held the run up is worth having a number behind.
866
+ *
867
+ * @typedef {object} PanelHealth
868
+ * @property {boolean} alive We still think there is a window to push to.
869
+ * @property {number} pushed Events handed over.
870
+ * @property {number} delivered Events the window actually took.
871
+ * @property {number} dropped Events folded away because the queue was backing up.
872
+ * @property {number} stalls Pushes that ran past their timeout and were abandoned.
873
+ * @property {number} queued Waiting right now.
874
+ */
875
+
876
+ /**
877
+ * Something for the panel to sit beside.
878
+ *
879
+ * Every field optional, because what an adapter can offer differs: a browser
880
+ * answers the debugging protocol, a desktop app answers only its own page, and a
881
+ * phone answers neither.
882
+ *
883
+ * @typedef {object} BesideThis
884
+ * @property {number|null} [pid] The process THIS run started. Never one we attached to.
885
+ * @property {any} [page] Its page, when it has one we can ask.
886
+ * @property {Bounds} [window] Where its window is, when the caller already knows.
887
+ * @property {boolean} [hasWindow] False for anything headless, which is most things.
888
+ */
889
+
890
+ /**
891
+ * @typedef {object} OpenPanelOptions
892
+ * @property {PanelPlan} [plan]
893
+ * @property {import('../../types.js').WatchOptions} [watch]
894
+ * @property {string} [dir] Where to remember the window position. The project's own folder.
895
+ * @property {{width: number, height: number}} [appViewport]
896
+ * @property {AbortSignal} [signal] Give up on opening. See `givenUp` below.
897
+ */
898
+
899
+ /**
900
+ * Giving up on a window that is still opening.
901
+ *
902
+ * A window takes a second or two to appear, and on a busy machine it takes longer. A check
903
+ * that finishes inside that — a small check, or a check somebody stopped — must not then sit
904
+ * waiting for a window it no longer wants. Worse, every one of the three waits below holds
905
+ * the program open on a timer, so without this a finished run keeps the terminal for another
906
+ * twenty seconds with nothing left to say.
907
+ *
908
+ * So opening takes a signal. When it is raised every wait stops at once, the browser we
909
+ * started is stopped, the throwaway folder goes, and `openPanel` hands back null exactly as
910
+ * it does for a machine with no browser on it. Nothing is reported: this is somebody
911
+ * finishing, not something going wrong.
912
+ *
913
+ * @param {AbortSignal|undefined} signal
914
+ * @returns {boolean}
915
+ */
916
+ function givenUp(signal) {
917
+ return signal?.aborted === true;
918
+ }
919
+
920
+ /** Thrown to leave the opening sequence when the caller has given up. Never shown to anybody. */
921
+ const GIVEN_UP = 'the watch window was given up on before it opened';
922
+
923
+ /**
924
+ * The flags. Short, because nothing here is being observed: the panel needs a
925
+ * clean window, a profile of its own, and nothing on it that says "browser".
926
+ *
927
+ * The three throttling flags at the end are the ones this file exists for. A
928
+ * window sitting behind another one, or minimised, normally has its timers slowed
929
+ * to a crawl and its renderer put to sleep; these keep it awake enough to take an
930
+ * update. They are a courtesy, not the guarantee — the guarantee is that the
931
+ * engine holds the state and the page only draws, so a page that DOES fall asleep
932
+ * loses nothing.
933
+ *
934
+ * @param {{port: number, profileDir: string, url: string, bounds: Bounds}} ctx
935
+ * @returns {string[]}
936
+ */
937
+ function panelArgs(ctx) {
938
+ return [
939
+ `--remote-debugging-port=${ctx.port}`,
940
+ // Node's WebSocket sends no Origin header, and recent Chrome refuses a socket
941
+ // from an unknown one.
942
+ '--remote-allow-origins=*',
943
+ // Never the browser the person actually uses: their tabs, their extensions,
944
+ // their signed-in session. This one is thrown away afterwards.
945
+ `--user-data-dir=${ctx.profileDir}`,
946
+ // An app window: no tabs, no address bar, nothing but the panel.
947
+ `--app=${ctx.url}`,
948
+ `--window-size=${ctx.bounds.width},${ctx.bounds.height}`,
949
+ `--window-position=${ctx.bounds.left},${ctx.bounds.top}`,
950
+ // Everything a browser puts on a window that this is not.
951
+ '--no-first-run',
952
+ '--no-default-browser-check',
953
+ '--disable-infobars',
954
+ '--hide-crash-restore-bubble',
955
+ '--disable-features=Translate,MediaRouter',
956
+ '--disable-extensions',
957
+ '--disable-background-networking',
958
+ '--disable-component-update',
959
+ '--disable-default-apps',
960
+ '--disable-sync',
961
+ '--metrics-recording-only',
962
+ '--disable-client-side-phishing-detection',
963
+ '--no-service-autorun',
964
+ '--password-store=basic',
965
+ '--use-mock-keychain',
966
+ '--mute-audio',
967
+ '--disable-notifications',
968
+ '--deny-permission-prompts',
969
+ '--disable-background-timer-throttling',
970
+ '--disable-backgrounding-occluded-windows',
971
+ '--disable-renderer-backgrounding',
972
+ ];
973
+ }
974
+
975
+ /**
976
+ * Take away the folders left by panels that were kept open and then closed by
977
+ * hand. A window we leave up owns its profile until the browser exits, and by then
978
+ * this process is usually gone, so the tidying happens next time instead.
979
+ * @returns {Promise<void>}
980
+ */
981
+ async function sweepOldPanels() {
982
+ try {
983
+ const parent = os.tmpdir();
984
+ const now = Date.now();
985
+ for (const name of await fsp.readdir(parent)) {
986
+ if (!name.startsWith(TMP_PREFIX)) continue;
987
+ const full = path.join(parent, name);
988
+ // The profile is what a live browser keeps writing to, so it is the honest
989
+ // measure of whether anyone still has this window open.
990
+ const stat =
991
+ (await fsp.stat(path.join(full, 'profile')).catch(() => null)) ?? (await fsp.stat(full).catch(() => null));
992
+ if (!stat || now - stat.mtimeMs < STALE_MS) continue;
993
+ await fsp.rm(full, { recursive: true, force: true }).catch(() => {});
994
+ }
995
+ } catch {
996
+ // Housekeeping. Never worth a word, never worth a failure.
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Find the window showing our page.
1002
+ * @param {string} endpoint
1003
+ * @param {number} deadline epoch ms
1004
+ * @param {AbortSignal} [signal]
1005
+ * @returns {Promise<any>}
1006
+ */
1007
+ async function findPanelTarget(endpoint, deadline, signal) {
1008
+ for (;;) {
1009
+ if (givenUp(signal)) return null;
1010
+ const targets = /** @type {any[]} */ (await listTargets(endpoint).catch(() => []));
1011
+ const hit = targets.find((t) => t && t.type === 'page' && String(t.url ?? '').startsWith('file://'));
1012
+ if (hit) return hit;
1013
+ if (Date.now() > deadline || givenUp(signal)) return null;
1014
+ await delay(120);
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * Paint the window's own background before the document paints its own, and tell
1020
+ * it which look to use.
1021
+ *
1022
+ * The panel opens on a brand new browser profile, and a fresh profile answers
1023
+ * "which colour scheme do you prefer" with "light" however the computer around it
1024
+ * is set — so the look is stated rather than asked for.
1025
+ *
1026
+ * @param {{send: (method: string, params?: Record<string, unknown>) => Promise<any>}} page
1027
+ * @param {'dark'|'light'|'system'} theme
1028
+ * @returns {Promise<void>}
1029
+ */
1030
+ async function darkenWindow(page, theme) {
1031
+ try {
1032
+ await page.send('Emulation.setDefaultBackgroundColorOverride', { color: GROUND });
1033
+ } catch {
1034
+ // An older build without it just flashes. Not worth a word.
1035
+ }
1036
+ if (theme === 'system') return;
1037
+ try {
1038
+ await page.send('Emulation.setEmulatedMedia', {
1039
+ features: [{ name: 'prefers-color-scheme', value: theme }],
1040
+ });
1041
+ } catch (e) {
1042
+ detail(`watch window: could not set the look. ${messageOf(e)}`);
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Watch the panel's window for a hand on it.
1048
+ *
1049
+ * The page checks its own position on a slow interval — four numbers, once a
1050
+ * second, no protocol traffic at all — and remembers the moment they stop matching
1051
+ * what we last set. Reading that is then one call, made only when we are about to
1052
+ * move the window or write down where it ended up.
1053
+ *
1054
+ * @param {{evaluate: (js: string) => Promise<any>}} page
1055
+ * @param {Bounds} expected Where we have just put it.
1056
+ * @returns {Promise<void>}
1057
+ */
1058
+ async function watchForHandMove(page, expected) {
1059
+ const source =
1060
+ '(function(){var e=' +
1061
+ JSON.stringify(expected) +
1062
+ ';var w=window.__staysfixed_place;' +
1063
+ 'if(w){w.expected=e;return true;}' +
1064
+ 'w=window.__staysfixed_place={moved:false,bounds:null,expected:e};' +
1065
+ 'w.look=function(){' +
1066
+ 'var b={left:window.screenX,top:window.screenY,width:window.outerWidth,height:window.outerHeight};' +
1067
+ 'w.bounds=b;var x=w.expected;if(!x)return;' +
1068
+ 'if(Math.abs(b.left-x.left)>' +
1069
+ MOVE_TOLERANCE +
1070
+ '||Math.abs(b.top-x.top)>' +
1071
+ MOVE_TOLERANCE +
1072
+ '||Math.abs(b.width-x.width)>' +
1073
+ MOVE_TOLERANCE +
1074
+ '||Math.abs(b.height-x.height)>' +
1075
+ MOVE_TOLERANCE +
1076
+ ')w.moved=true;};' +
1077
+ 'w.timer=setInterval(w.look,' +
1078
+ MOVE_WATCH_MS +
1079
+ ');return true;})()';
1080
+ await page.evaluate(source).catch(() => {});
1081
+ }
1082
+
1083
+ /**
1084
+ * Has the person moved it, and where is it now?
1085
+ * @param {{evaluate: (js: string) => Promise<any>}} page
1086
+ * @returns {Promise<{moved: boolean, bounds: Bounds|null}>}
1087
+ */
1088
+ async function readHandMove(page) {
1089
+ try {
1090
+ const raw = await page.evaluate(
1091
+ '(function(){var w=window.__staysfixed_place;if(!w)return null;' +
1092
+ // Look once more before answering, so a window moved a moment ago still counts.
1093
+ 'if(typeof w.look==="function")w.look();' +
1094
+ 'return {moved:!!w.moved,bounds:w.bounds};})()',
1095
+ );
1096
+ if (!raw || typeof raw !== 'object') return { moved: false, bounds: null };
1097
+ const b = raw.bounds;
1098
+ const bounds =
1099
+ b && Number.isFinite(Number(b.width)) && Number(b.width) > 0
1100
+ ? {
1101
+ left: Math.round(Number(b.left) || 0),
1102
+ top: Math.round(Number(b.top) || 0),
1103
+ width: Math.round(Number(b.width)),
1104
+ height: Math.round(Number(b.height)),
1105
+ }
1106
+ : null;
1107
+ return { moved: raw.moved === true, bounds };
1108
+ } catch {
1109
+ return { moved: false, bounds: null };
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * @param {{evaluate: (js: string) => Promise<any>}} page
1115
+ * @param {number} deadline epoch ms
1116
+ * @param {AbortSignal} [signal]
1117
+ * @returns {Promise<void>}
1118
+ */
1119
+ async function waitForPanelReady(page, deadline, signal) {
1120
+ for (;;) {
1121
+ if (givenUp(signal)) throw new Error(GIVEN_UP);
1122
+ const ready = await page.evaluate('typeof window.__staysfixed_push === "function"').catch(() => false);
1123
+ if (ready === true) return;
1124
+ if (givenUp(signal)) throw new Error(GIVEN_UP);
1125
+ if (Date.now() > deadline) throw new Error('the panel page never finished loading');
1126
+ await delay(80);
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * The browser to open the panel with.
1132
+ *
1133
+ * Chrome for Testing wherever there is one — it is a separate application from
1134
+ * the browser he uses, so opening it cannot take his own browser over. His own is
1135
+ * the last resort, and even then it gets a throwaway profile of its own and is a
1136
+ * separate process, so his tabs and his session are never touched.
1137
+ *
1138
+ * @returns {Promise<{binary: string, name: string, borrowed: boolean}|null>}
1139
+ */
1140
+ async function browserForPanel() {
1141
+ try {
1142
+ // A window that shows nothing is no use for a panel, so the headless-only
1143
+ // shells are dropped by asking for a visible one.
1144
+ const survey = await surveyBrowsers({ headless: false });
1145
+ const chosen = survey.chosen;
1146
+ if (!chosen) return null;
1147
+ return { binary: chosen.binary, name: chosen.name, borrowed: chosen.everyday === true };
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Open the watch window.
1155
+ *
1156
+ * Returns null — never throws — when there is no browser to open it with, or the
1157
+ * window will not start. A check without a live view is a check; a check that
1158
+ * failed because its live view failed would be indefensible.
1159
+ *
1160
+ * @param {OpenPanelOptions} [opts]
1161
+ * @returns {Promise<Panel|null>}
1162
+ */
1163
+ export async function openPanel(opts = {}) {
1164
+ const watch = opts.watch ?? {};
1165
+ const signal = opts.signal;
1166
+ if (givenUp(signal)) return null;
1167
+ const theme = /** @type {'dark'|'light'|'system'} */ (
1168
+ watch.theme === 'light' || watch.theme === 'system' ? watch.theme : 'dark'
1169
+ );
1170
+ const chrome = await browserForPanel();
1171
+ if (!chrome) {
1172
+ warn(
1173
+ 'There is no browser on this machine that the watch window can open, so this check has no live view. The check itself carries on as normal.',
1174
+ );
1175
+ return null;
1176
+ }
1177
+ if (chrome.borrowed) {
1178
+ detail(
1179
+ 'The watch window is opening in your own browser, because there is no Chrome for Testing here. It gets a throwaway profile of its own, so your tabs and your session are untouched.',
1180
+ );
1181
+ }
1182
+
1183
+ await sweepOldPanels();
1184
+
1185
+ const size = panelBounds(opts.appViewport, watch);
1186
+ // Where it was left last time, if anywhere. Opening there is the difference
1187
+ // between a window that comes back and a window that jumps.
1188
+ const remembered = await readRemembered(opts.dir);
1189
+ const opening = remembered ?? { left: size.x, top: size.y, width: size.width, height: size.height };
1190
+
1191
+ /** @type {string|null} */
1192
+ let temp = null;
1193
+ /** @type {import('node:child_process').ChildProcess|null} */
1194
+ let child = null;
1195
+ /** @type {import('../../types.js').CdpSession|null} */
1196
+ let cdp = null;
1197
+
1198
+ try {
1199
+ temp = await fsp.mkdtemp(path.join(os.tmpdir(), TMP_PREFIX));
1200
+ const pageFile = path.join(temp, 'panel.html');
1201
+ const profileDir = path.join(temp, 'profile');
1202
+ await fsp.mkdir(profileDir, { recursive: true });
1203
+ await fsp.writeFile(pageFile, await documentFor({ ...(opts.plan ?? {}), theme }));
1204
+ const url = pathToFileURL(pageFile).href;
1205
+
1206
+ const port = await freePort();
1207
+ detail(`watch window: ${chrome.name}`);
1208
+ detail(`watch window port: ${port}`);
1209
+
1210
+ // Remember who has the screen before anything opens, so it can be handed
1211
+ // straight back. `watch.foreground` is for when you WANT to watch it work.
1212
+ const previousApp = watch.foreground === true ? null : await noteTheFrontmost();
1213
+
1214
+ child = spawn(chrome.binary, panelArgs({ port, profileDir, url, bounds: opening }), {
1215
+ // The window outlives this command when it is kept open, so it cannot be
1216
+ // tied to our process group or our pipes.
1217
+ detached: true,
1218
+ stdio: 'ignore',
1219
+ });
1220
+ child.unref();
1221
+ // A browser that dies later must not crash the check with an unhandled error.
1222
+ child.on('error', () => {});
1223
+
1224
+ const endpoint = `http://127.0.0.1:${port}`;
1225
+ // Every wait from here down can be called off. A run that finishes while the window is
1226
+ // still coming up stops waiting the moment it says so, rather than holding the terminal
1227
+ // open for another twenty seconds over a window nobody wants any more.
1228
+ const version = await waitForEndpoint(endpoint, {
1229
+ timeoutMs: OPEN_TIMEOUT_MS,
1230
+ intervalMs: 100,
1231
+ ...(signal ? { signal } : {}),
1232
+ });
1233
+ if (givenUp(signal)) throw new Error(GIVEN_UP);
1234
+ const wsUrl = version?.webSocketDebuggerUrl;
1235
+ if (!wsUrl) throw new Error('the window opened but offered no debugging connection');
1236
+
1237
+ cdp = /** @type {import('../../types.js').CdpSession} */ (await connect(wsUrl, { timeoutMs: 15_000 }));
1238
+ if (givenUp(signal)) throw new Error(GIVEN_UP);
1239
+
1240
+ const target = await findPanelTarget(endpoint, Date.now() + OPEN_TIMEOUT_MS, signal);
1241
+ if (givenUp(signal)) throw new Error(GIVEN_UP);
1242
+ if (!target) throw new Error('the window opened but never showed the panel');
1243
+
1244
+ const targetId = String(target.id ?? target.targetId);
1245
+ const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
1246
+ const sessionId = String(attached.sessionId);
1247
+ const page = await createPage(
1248
+ cdp,
1249
+ /** @type {any} */ ({ sessionId, targetId, baseUrl: null, timeoutMs: 15_000 }),
1250
+ );
1251
+
1252
+ // Before the document paints, so the window is never a white rectangle.
1253
+ await darkenWindow(page, theme);
1254
+
1255
+ // Now there is a window, there is finally something that can say how big the
1256
+ // screen is. A position on the command line is a guess; this is the answer.
1257
+ const screen = await readScreen(page);
1258
+ const kept = remembered && fitsOnScreen(remembered, screen) ? remembered : null;
1259
+ const first = kept ?? firstPlace(screen, size, watch, null);
1260
+ await moveWindow(page, first);
1261
+ await watchForHandMove(page, first);
1262
+
1263
+ // The page defines its own update function as it loads; until it exists there
1264
+ // is nothing to push into.
1265
+ await waitForPanelReady(page, Date.now() + OPEN_TIMEOUT_MS, signal);
1266
+
1267
+ // Last, and every time — including when this is a re-open. Whoever was in
1268
+ // front before goes back in front.
1269
+ await giveTheScreenBack(previousApp);
1270
+
1271
+ return makePanel({
1272
+ cdp,
1273
+ page,
1274
+ sessionId,
1275
+ child,
1276
+ temp,
1277
+ url,
1278
+ keepOpen: watch.keepOpen !== false,
1279
+ dir: opts.dir ?? null,
1280
+ side: watch.side === 'left' ? 'left' : 'right',
1281
+ panelWidth: size.width,
1282
+ askedHeight: askedHeight(watch),
1283
+ placed: first,
1284
+ // Opened where they left it, so it is already theirs: nothing snaps it.
1285
+ byHand: kept !== null,
1286
+ foreground: watch.foreground === true,
1287
+ });
1288
+ } catch (e) {
1289
+ // Somebody finishing is not something going wrong. A run that ends before its window
1290
+ // finished opening is the ordinary case for a short check, and it deserves silence.
1291
+ if (givenUp(signal)) {
1292
+ detail('The check finished before the watch window had opened, so the window was called off.');
1293
+ } else {
1294
+ warn(
1295
+ `The watch window could not open, so this check has no live view. The check itself carries on. ${messageOf(e)}`,
1296
+ );
1297
+ }
1298
+ if (cdp) await cdp.close().catch(() => {});
1299
+ if (child) await stopProcess(child, 2000).catch(() => {});
1300
+ if (temp) await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
1301
+ return null;
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Give up on a promise after a while.
1307
+ *
1308
+ * The whole of "the check never waits on the window", in six lines. The promise
1309
+ * itself is not cancelled — nothing in the debugging protocol can be — it is
1310
+ * simply stopped being waited for, and whatever it eventually does is ignored.
1311
+ *
1312
+ * @template T
1313
+ * @param {Promise<T>} work
1314
+ * @param {number} ms
1315
+ * @returns {Promise<T|undefined>} undefined means it ran out of time.
1316
+ */
1317
+ function within(work, ms) {
1318
+ return new Promise((resolve) => {
1319
+ const timer = setTimeout(() => resolve(undefined), ms);
1320
+ // Never hold the process open waiting for a window's answer.
1321
+ if (typeof timer.unref === 'function') timer.unref();
1322
+ work.then(
1323
+ (value) => {
1324
+ clearTimeout(timer);
1325
+ resolve(value);
1326
+ },
1327
+ () => {
1328
+ clearTimeout(timer);
1329
+ resolve(undefined);
1330
+ },
1331
+ );
1332
+ });
1333
+ }
1334
+
1335
+ /**
1336
+ * What may be thrown away when the window cannot keep up, and what may never be.
1337
+ *
1338
+ * The panel builds its picture out of this stream in order, so most of it is load-bearing: a
1339
+ * lost `journey:done` leaves a journey stuck on "running" for the rest of the check, which is
1340
+ * worse than no window at all. Two kinds are not.
1341
+ *
1342
+ * A RUNNING COUNT is superseded the moment the next one arrives — only the last one per
1343
+ * journey was ever going to be drawn — so all but the newest can go.
1344
+ *
1345
+ * A NOTE is a line in a list. A line nobody read while the window was minimised is a line
1346
+ * worth losing, and the oldest is the one to lose.
1347
+ *
1348
+ * Nothing else is ever dropped. That is the rule the whole queue is built around: coalesce
1349
+ * the counters, drop the oldest chatter, never touch the newest state.
1350
+ */
1351
+
1352
+ /** A counter that the next one of its kind replaces outright. */
1353
+ const SUPERSEDED_BY_THE_NEXT = 'journey:addresses';
1354
+
1355
+ /** Chatter. Worth showing, never worth holding a check up for. */
1356
+ const CHATTER = 'note';
1357
+
1358
+ /** How many notes are kept when the queue is folded down. */
1359
+ const KEEP_NOTES = 120;
1360
+
1361
+ /**
1362
+ * @param {{
1363
+ * cdp: import('../../types.js').CdpSession,
1364
+ * page: import('../../types.js').PageHandle,
1365
+ * sessionId: string,
1366
+ * child: import('node:child_process').ChildProcess,
1367
+ * temp: string,
1368
+ * url: string,
1369
+ * keepOpen: boolean,
1370
+ * dir: string|null,
1371
+ * side: 'left'|'right',
1372
+ * panelWidth: number,
1373
+ * askedHeight: number|null,
1374
+ * placed: Bounds,
1375
+ * byHand: boolean,
1376
+ * foreground: boolean,
1377
+ * }} ctx
1378
+ * @returns {Panel}
1379
+ */
1380
+ function makePanel(ctx) {
1381
+ /** @type {any[]} */
1382
+ const queue = [];
1383
+ let sending = false;
1384
+ let dead = false;
1385
+ let stalls = 0;
1386
+ let pushed = 0;
1387
+ let delivered = 0;
1388
+ let dropped = 0;
1389
+
1390
+ /** Where we last put the window ourselves — or where the person last left it. */
1391
+ let placed = ctx.placed;
1392
+ /** Once this is true, nothing in here moves the window again. */
1393
+ let byHand = ctx.byHand;
1394
+ /** The snap happens once a check, whether or not it worked. */
1395
+ let snapped = false;
1396
+
1397
+ // A window we leave up owns its folder until the browser finally exits.
1398
+ ctx.child.once('exit', () => {
1399
+ fsp.rm(ctx.temp, { recursive: true, force: true }).catch(() => {});
1400
+ });
1401
+
1402
+ /**
1403
+ * Fold the queue down when the window cannot keep up.
1404
+ *
1405
+ * In order, and the order is the rule: EVIDENCE first — a path to a picture nobody had time
1406
+ * to look at — then the RUNNING COUNTS, of which only the newest per journey was ever going
1407
+ * to be drawn, and only then the oldest CHATTER. State is never touched, which is what makes
1408
+ * a minimised window correct the instant it is looked at rather than merely eventually.
1409
+ *
1410
+ * When the queue is still long after all of that, it stays long. A window falling behind is
1411
+ * a window falling behind; it is not a reason to start lying to it.
1412
+ */
1413
+ function fold() {
1414
+ // Evidence first: a path to a picture nobody had time to look at.
1415
+ for (let i = 0; i < queue.length - KEEP_EVIDENCE; i++) {
1416
+ if (queue[i]?.evidence === undefined) continue;
1417
+ queue[i] = { ...queue[i], evidence: undefined };
1418
+ }
1419
+ if (queue.length <= MAX_QUEUE) return;
1420
+
1421
+ // Then the running counts, of which only the newest per journey was ever going to be drawn.
1422
+ /** @type {Map<string, number>} */
1423
+ const newestCount = new Map();
1424
+ for (let i = 0; i < queue.length; i++) {
1425
+ if (queue[i]?.type !== SUPERSEDED_BY_THE_NEXT) continue;
1426
+ newestCount.set(String(queue[i]?.journey ?? ''), i);
1427
+ }
1428
+ if (newestCount.size > 0) {
1429
+ const keep = new Set(newestCount.values());
1430
+ for (let i = queue.length - 1; i >= 0; i--) {
1431
+ if (queue[i]?.type === SUPERSEDED_BY_THE_NEXT && !keep.has(i)) {
1432
+ queue.splice(i, 1);
1433
+ dropped += 1;
1434
+ }
1435
+ }
1436
+ }
1437
+ if (queue.length <= MAX_QUEUE) return;
1438
+
1439
+ // Then the oldest chatter, and only chatter. Everything left is state the panel
1440
+ // needs in order, and it stays even if that means the queue stays long.
1441
+ let notes = 0;
1442
+ for (const item of queue) if (item?.type === CHATTER) notes += 1;
1443
+ let overspill = Math.max(0, Math.min(queue.length - MAX_QUEUE, notes - KEEP_NOTES));
1444
+ for (let i = 0; i < queue.length && overspill > 0; ) {
1445
+ if (queue[i]?.type !== CHATTER) {
1446
+ i += 1;
1447
+ continue;
1448
+ }
1449
+ queue.splice(i, 1);
1450
+ dropped += 1;
1451
+ overspill -= 1;
1452
+ }
1453
+ }
1454
+
1455
+ /**
1456
+ * One call, however many updates are waiting. The page is handed JSON as a
1457
+ * string and parses it itself, so nothing a journey is called can ever be read
1458
+ * as code.
1459
+ * @param {any[]} batch
1460
+ * @returns {Promise<boolean>} false when it ran out of time or the window refused it.
1461
+ */
1462
+ async function send(batch) {
1463
+ const payload = JSON.stringify(JSON.stringify(batch));
1464
+ const answer = await within(
1465
+ ctx.page.evaluate(
1466
+ '(function(){var list;try{list=JSON.parse(' +
1467
+ payload +
1468
+ ');}catch(e){return 0;}' +
1469
+ 'if(typeof window.__staysfixed_push!=="function")return 0;' +
1470
+ 'for(var i=0;i<list.length;i++){window.__staysfixed_push(list[i]);}' +
1471
+ 'return list.length;})()',
1472
+ ),
1473
+ PUSH_TIMEOUT_MS,
1474
+ );
1475
+ return answer !== undefined;
1476
+ }
1477
+
1478
+ async function drain() {
1479
+ if (sending || dead) return;
1480
+ sending = true;
1481
+ try {
1482
+ while (queue.length > 0 && !dead) {
1483
+ // Taken off the queue before it is sent, on purpose: an update that fails
1484
+ // is dropped, never retried into a check it would hold up.
1485
+ const batch = queue.splice(0, MAX_BATCH);
1486
+ const landed = await send(batch);
1487
+ if (landed) {
1488
+ delivered += batch.length;
1489
+ stalls = 0;
1490
+ continue;
1491
+ }
1492
+ stalls += 1;
1493
+ dropped += batch.length;
1494
+ // A window that has gone is a window to stop pushing to. So is one that
1495
+ // has been too slow too many times in a row: whatever is wrong with it,
1496
+ // it is not going to be read, and a check has better things to do.
1497
+ if (!ctx.cdp.isOpen() || stalls >= STALL_LIMIT) {
1498
+ dead = true;
1499
+ detail(
1500
+ ctx.cdp.isOpen()
1501
+ ? 'The watch window stopped keeping up, so nothing more is being sent to it. The check is unaffected.'
1502
+ : 'The watch window has gone. The check is unaffected.',
1503
+ );
1504
+ }
1505
+ }
1506
+ } finally {
1507
+ sending = false;
1508
+ }
1509
+ }
1510
+
1511
+ /**
1512
+ * Has the person taken hold of the window since we last put it somewhere?
1513
+ * @returns {Promise<boolean>}
1514
+ */
1515
+ async function handHasIt() {
1516
+ if (byHand) return true;
1517
+ const seen = await readHandMove(ctx.page);
1518
+ if (seen.moved) {
1519
+ byHand = true;
1520
+ if (seen.bounds) placed = seen.bounds;
1521
+ }
1522
+ return byHand;
1523
+ }
1524
+
1525
+ /**
1526
+ * Put the panel flush against the thing being checked, so the two of them read
1527
+ * as one window with a side panel.
1528
+ *
1529
+ * Once a check, and never over the top of a window the person has already moved:
1530
+ * a window somebody has arranged is theirs, and a tool that drags it back is a
1531
+ * tool you close. Everything here is best-effort — a window that will not move
1532
+ * is a disappointment, never a failed check — and the screen is handed back
1533
+ * afterwards, because moving windows can pull one to the front.
1534
+ *
1535
+ * @param {BesideThis} beside
1536
+ * @returns {Promise<void>}
1537
+ */
1538
+ async function snapTo(beside) {
1539
+ if (dead || snapped) return;
1540
+ snapped = true;
1541
+ try {
1542
+ if (await handHasIt()) return;
1543
+
1544
+ const previousApp = ctx.foreground ? null : await noteTheFrontmost();
1545
+ const screen = await readScreen(ctx.page);
1546
+
1547
+ // Anything headless has a window on paper and nothing on the screen, and
1548
+ // snapping against one would leave the panel hugging thin air.
1549
+ const appPage = beside?.hasWindow === false ? null : (beside?.page ?? null);
1550
+ // Ask the protocol first and the page second: a browser answers the first,
1551
+ // and a desktop app only ever answers the second.
1552
+ const current =
1553
+ beside?.window ??
1554
+ (appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null);
1555
+
1556
+ const target = current
1557
+ ? panelBeside(current, screen, ctx.panelWidth, ctx.side)
1558
+ : planPlacement({ screen, appSize: null, panelWidth: ctx.panelWidth, side: ctx.side, gap: GAP }).panel;
1559
+ const bounds = ctx.askedHeight ? { ...target, height: Math.min(ctx.askedHeight, screen.height) } : target;
1560
+
1561
+ if (await moveWindow(ctx.page, bounds)) {
1562
+ placed = bounds;
1563
+ // Tell the page where we just put it, so our own move is not read as theirs.
1564
+ await watchForHandMove(ctx.page, bounds);
1565
+ }
1566
+
1567
+ await giveTheScreenBack(previousApp);
1568
+ } catch {
1569
+ // Where the windows sit is a nicety. It is never worth a failed check.
1570
+ }
1571
+ }
1572
+
1573
+ /**
1574
+ * Hand one update over. Returns nothing, waits for nothing, throws nothing.
1575
+ * @param {PanelEvent} event
1576
+ * @returns {void}
1577
+ */
1578
+ function push(event) {
1579
+ if (dead || !event || typeof event !== 'object') return;
1580
+ pushed += 1;
1581
+ queue.push(event);
1582
+ fold();
1583
+ // Deliberately not awaited. A check must never wait on a window whose only
1584
+ // job is to be looked at.
1585
+ void drain();
1586
+ }
1587
+
1588
+ /**
1589
+ * Let whatever is queued land, but not for long.
1590
+ * @returns {Promise<void>}
1591
+ */
1592
+ async function settle() {
1593
+ const deadline = Date.now() + 2000;
1594
+ while ((queue.length > 0 || sending) && !dead && Date.now() < deadline) {
1595
+ await drain();
1596
+ if (queue.length > 0) await delay(50);
1597
+ }
1598
+ }
1599
+
1600
+ /** @type {Promise<void>|null} */
1601
+ let closing = null;
1602
+
1603
+ /** @returns {Promise<void>} */
1604
+ function close() {
1605
+ closing ??= (async () => {
1606
+ await settle().catch(() => {});
1607
+
1608
+ // Last look before the connection goes: if they moved it, that is where it
1609
+ // belongs from now on.
1610
+ try {
1611
+ const seen = await readHandMove(ctx.page);
1612
+ if (seen.moved && seen.bounds) {
1613
+ byHand = true;
1614
+ placed = seen.bounds;
1615
+ }
1616
+ if (byHand) await writeRemembered(ctx.dir, placed, await readScreen(ctx.page));
1617
+ } catch {
1618
+ // A window that has already gone cannot say where it was.
1619
+ }
1620
+
1621
+ // Stop the clock in the page, so a window left up does not sit there
1622
+ // counting seconds next to a result that is already final.
1623
+ await within(
1624
+ ctx.page.evaluate(
1625
+ '(function(){var w=window.__staysfixed_place;if(w&&w.timer){clearInterval(w.timer);w.timer=null;}' +
1626
+ 'if(typeof window.__staysfixed_detach==="function")window.__staysfixed_detach();return true;})()',
1627
+ ),
1628
+ PUSH_TIMEOUT_MS,
1629
+ );
1630
+ dead = true;
1631
+
1632
+ try {
1633
+ await ctx.cdp.send('Target.detachFromTarget', { sessionId: ctx.sessionId });
1634
+ } catch {
1635
+ // The window may already be gone, which is where we were heading.
1636
+ }
1637
+
1638
+ if (!ctx.keepOpen) {
1639
+ try {
1640
+ await ctx.cdp.send('Browser.close');
1641
+ } catch {
1642
+ // Asking politely can fail if it is already quitting.
1643
+ }
1644
+ }
1645
+
1646
+ try {
1647
+ await ctx.cdp.close();
1648
+ } catch {
1649
+ // Hanging up cannot meaningfully fail.
1650
+ }
1651
+
1652
+ if (ctx.keepOpen) {
1653
+ // Leave it up. The result is what the person opened the panel to read, and
1654
+ // it should still be there when they look over.
1655
+ return;
1656
+ }
1657
+ await stopProcess(ctx.child, 3000);
1658
+ await fsp.rm(ctx.temp, { recursive: true, force: true }).catch(() => {});
1659
+ })();
1660
+ return closing;
1661
+ }
1662
+
1663
+ return {
1664
+ push,
1665
+ close,
1666
+ url: ctx.url,
1667
+ snapTo,
1668
+ placedByHand: () => byHand,
1669
+ health: () => ({ alive: !dead, pushed, delivered, dropped, stalls, queued: queue.length }),
1670
+ };
1671
+ }