staysfixed 0.1.0 → 0.2.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/CHANGELOG.md +1 -1
- package/README.md +34 -11
- package/package.json +1 -1
- package/src/cli/check.js +71 -4
- package/src/cli/index.js +109 -6
- package/src/cli/init.js +3 -1
- package/src/cli/walk.js +66 -3
- package/src/core/events.js +171 -0
- package/src/core/paths.js +8 -1
- package/src/drive/page.js +60 -4
- package/src/freeze/fonts.js +131 -41
- package/src/freeze/settle.js +177 -2
- package/src/guard/run.js +41 -2
- package/src/picture/capture.js +59 -5
- package/src/picture/compare.js +60 -0
- package/src/picture/run.js +163 -26
- package/src/picture/store.js +65 -0
- package/src/report/console.js +80 -1
- package/src/run.js +229 -39
- package/src/types.js +68 -0
- package/src/walk/run.js +76 -0
- package/src/watch/index.js +286 -0
- package/src/watch/panel.js +1678 -0
- package/src/watch/place.js +279 -0
- package/src/watch/window.js +1242 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watch window: a real browser window, opened beside the app being checked,
|
|
3
|
+
* that redraws itself as the run happens.
|
|
4
|
+
*
|
|
5
|
+
* There is no server here and no port beyond the debugging one. The page is
|
|
6
|
+
* written to a temp file, opened over `file://` as an app window — no tabs, no
|
|
7
|
+
* address bar — and every update is one call into a function the page defines.
|
|
8
|
+
* That is the same machinery that drives everything else in this tool, so the
|
|
9
|
+
* watch window adds no dependency and nothing new to go wrong.
|
|
10
|
+
*
|
|
11
|
+
* Three rules shape this file.
|
|
12
|
+
*
|
|
13
|
+
* The window must never change what the pictures look like. It is a separate
|
|
14
|
+
* browser process with its own throwaway profile, launched with none of the
|
|
15
|
+
* determinism flags, and it never touches the app being photographed. Moving
|
|
16
|
+
* the app's WINDOW is allowed and is not the same thing: what a picture is of
|
|
17
|
+
* comes from `Emulation.setDeviceMetricsOverride`, which does not care where on
|
|
18
|
+
* the screen the window happens to be.
|
|
19
|
+
*
|
|
20
|
+
* It must never take the screen. Automation that steals the foreground takes
|
|
21
|
+
* the window out from under whatever the person was actually doing, so the
|
|
22
|
+
* panel opens behind them and the screen is handed straight back.
|
|
23
|
+
*
|
|
24
|
+
* And it must land where it belongs: the app hard against one edge of the
|
|
25
|
+
* screen, the panel flush against it, no seam, so the two of them read as one
|
|
26
|
+
* window with a side panel. The arithmetic for that lives next door in
|
|
27
|
+
* `place.js`. Move the panel yourself and it stays where you put it, this run
|
|
28
|
+
* and the next one — a window a person has arranged is theirs.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawn, execFile } from 'node:child_process';
|
|
32
|
+
import { promisify } from 'node:util';
|
|
33
|
+
import fsp from 'node:fs/promises';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
37
|
+
|
|
38
|
+
import { warn, detail } from '../core/log.js';
|
|
39
|
+
import { messageOf } from '../core/errors.js';
|
|
40
|
+
import { DEFAULT_VIEWPORT } from '../core/config.js';
|
|
41
|
+
import { waitForEndpoint, listTargets, connect } from '../drive/cdp.js';
|
|
42
|
+
import { findChrome, freePort } from '../drive/find.js';
|
|
43
|
+
import { createPage } from '../drive/page.js';
|
|
44
|
+
import { stopProcess, delay } from '../drive/browser.js';
|
|
45
|
+
import { verdictFor } from '../report/console.js';
|
|
46
|
+
import { panelHtml } from './panel.js';
|
|
47
|
+
import { planPlacement, panelBeside, fitsAlongside, PANEL_MIN_WIDTH, PANEL_MAX_WIDTH } from './place.js';
|
|
48
|
+
|
|
49
|
+
const execFileAsync = promisify(execFile);
|
|
50
|
+
|
|
51
|
+
/** Every temp folder this file makes starts with this, so old ones can be found again. */
|
|
52
|
+
const TMP_PREFIX = 'staysfixed-panel-';
|
|
53
|
+
|
|
54
|
+
/** A panel folder untouched for this long belongs to a window nobody has open any more. */
|
|
55
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/** How wide the panel is when nobody says otherwise. Legible down to 420. */
|
|
58
|
+
const DEFAULT_WIDTH = 460;
|
|
59
|
+
|
|
60
|
+
/** A panel shorter than this cannot show the picture and the list at the same time. */
|
|
61
|
+
const MIN_HEIGHT = 640;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The gap between the app's edge and the panel's. None, on purpose: two windows
|
|
65
|
+
* that touch read as one window, and a seam is what gives away that they are two
|
|
66
|
+
* separate programs sitting next to each other.
|
|
67
|
+
*/
|
|
68
|
+
const GAP = 0;
|
|
69
|
+
|
|
70
|
+
/** How long to wait for the window to open before giving up on it. */
|
|
71
|
+
const OPEN_TIMEOUT_MS = 20_000;
|
|
72
|
+
|
|
73
|
+
/** Updates sent in one call. More than this in flight means the run is outrunning the window. */
|
|
74
|
+
const MAX_BATCH = 32;
|
|
75
|
+
|
|
76
|
+
/** How many queued updates keep their pictures when the queue is backing up. */
|
|
77
|
+
const KEEP_PICTURES = 3;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The window's own background, painted before the document is. This must be the
|
|
81
|
+
* panel's ground colour from `panel.js`: it is the same surface, and a browser
|
|
82
|
+
* flashing white for a fifth of a second is exactly what makes a purpose-built
|
|
83
|
+
* window look like a browser tab.
|
|
84
|
+
*/
|
|
85
|
+
const GROUND = { r: 21, g: 23, b: 25, a: 1 };
|
|
86
|
+
|
|
87
|
+
/** A window off by less than this was nudged by a window manager, not by a person. */
|
|
88
|
+
const MOVE_TOLERANCE = 8;
|
|
89
|
+
|
|
90
|
+
/** How often the page looks at where it is. Slow on purpose: nobody is racing. */
|
|
91
|
+
const MOVE_WATCH_MS = 1000;
|
|
92
|
+
|
|
93
|
+
/** Where the window a person arranged is written down, inside the project's own folder. */
|
|
94
|
+
const REMEMBER_FILE = 'watch-window.json';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A watch window that is open and listening.
|
|
98
|
+
* @typedef {object} Panel
|
|
99
|
+
* @property {(event: import('../types.js').RunEvent) => Promise<void>} push
|
|
100
|
+
* @property {() => Promise<void>} close
|
|
101
|
+
* @property {string} url
|
|
102
|
+
* @property {(app: import('../types.js').LaunchedApp) => Promise<void>} snapTo
|
|
103
|
+
* Put the app against its edge and the panel flush against it. Once per run.
|
|
104
|
+
* @property {() => boolean} placedByHand
|
|
105
|
+
* True once the person has moved the window themselves. Nothing moves it after that.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the panel is told before anything starts. Counts are accepted as well as
|
|
110
|
+
* lists, so a caller that only knows how many there are still gets a header.
|
|
111
|
+
* @typedef {object} PlanInput
|
|
112
|
+
* @property {string} [project]
|
|
113
|
+
* @property {string|import('../types.js').AppConfig} [app]
|
|
114
|
+
* @property {import('./panel.js').PanelRow[]|number} [screens]
|
|
115
|
+
* @property {import('./panel.js').PanelRow[]|number} [guards]
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {object} OpenPanelOptions
|
|
120
|
+
* @property {import('../types.js').Project} [project]
|
|
121
|
+
* @property {PlanInput} [plan]
|
|
122
|
+
* @property {import('../types.js').WatchOptions} [watch]
|
|
123
|
+
* @property {{width: number, height: number}} [appViewport]
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {number} value
|
|
128
|
+
* @param {number} low
|
|
129
|
+
* @param {number} high
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
function clamp(value, low, high) {
|
|
133
|
+
return Math.min(high, Math.max(low, value));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The opening guess: how big the panel is, and where it goes before anything
|
|
138
|
+
* knows how big the screen is.
|
|
139
|
+
*
|
|
140
|
+
* Only a window can say what screen it is on (`readScreen`), and there is no
|
|
141
|
+
* window yet when this is called — so the app is taken to be sitting at the top
|
|
142
|
+
* left corner and the panel is put beside it. `snapTo` replaces this with the
|
|
143
|
+
* real placement the moment the app is up.
|
|
144
|
+
*
|
|
145
|
+
* `watch.side` names the side the PANEL goes on, which is how the option has
|
|
146
|
+
* always read. `planPlacement` names the screen edge the APP is pinned to. With
|
|
147
|
+
* the app assumed to be in the corner those are the same arrangement said from
|
|
148
|
+
* opposite ends — a panel to the right of the app IS an app pinned left — which
|
|
149
|
+
* is why the side is mirrored on the way through.
|
|
150
|
+
*
|
|
151
|
+
* Pure on purpose, so the arithmetic can be checked without opening anything.
|
|
152
|
+
*
|
|
153
|
+
* @param {{width?: number, height?: number}} [appViewport]
|
|
154
|
+
* @param {import('../types.js').WatchOptions} [watch]
|
|
155
|
+
* @returns {{width: number, height: number, x: number, y: number}}
|
|
156
|
+
*/
|
|
157
|
+
export function panelBounds(appViewport, watch) {
|
|
158
|
+
const opts = watch ?? {};
|
|
159
|
+
const appWidth = Math.round(Number(appViewport?.width) || DEFAULT_VIEWPORT.width);
|
|
160
|
+
const appHeight = Math.round(Number(appViewport?.height) || DEFAULT_VIEWPORT.height);
|
|
161
|
+
|
|
162
|
+
const asked = Number(opts.width);
|
|
163
|
+
const width = clamp(
|
|
164
|
+
Math.round(Number.isFinite(asked) && asked > 0 ? asked : DEFAULT_WIDTH),
|
|
165
|
+
PANEL_MIN_WIDTH,
|
|
166
|
+
PANEL_MAX_WIDTH,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const askedHeight = Number(opts.height);
|
|
170
|
+
const height = Math.max(
|
|
171
|
+
MIN_HEIGHT,
|
|
172
|
+
Math.round(Number.isFinite(askedHeight) && askedHeight > 0 ? askedHeight : appHeight),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const plan = planPlacement({
|
|
176
|
+
// A screen exactly big enough for the two of them, because there is no real
|
|
177
|
+
// one to ask yet. It keeps the windows adjacent and keeps every bit of the
|
|
178
|
+
// arithmetic in one file.
|
|
179
|
+
screen: { left: 0, top: 0, width: appWidth + GAP + width, height: Math.max(appHeight, height) },
|
|
180
|
+
appSize: { width: appWidth, height: appHeight },
|
|
181
|
+
panelWidth: width,
|
|
182
|
+
side: opts.side === 'left' ? 'right' : 'left',
|
|
183
|
+
gap: GAP,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return { width, height, x: plan.panel.left, y: plan.panel.top };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// The screen, and the windows standing on it
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A window we can move: anything with a CDP connection and a target on it.
|
|
195
|
+
* Both the panel's page and the app's page are one of these.
|
|
196
|
+
* @typedef {object} WindowRef
|
|
197
|
+
* @property {(method: string, params?: Record<string, unknown>) => Promise<any>} send
|
|
198
|
+
* @property {string} targetId
|
|
199
|
+
*/
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The usable screen area, in screen pixels — menu bar and dock already taken off.
|
|
203
|
+
*
|
|
204
|
+
* Always ask the PANEL's page and never the app's. The app is photographed
|
|
205
|
+
* through `Emulation.setDeviceMetricsOverride`, which makes its page believe it
|
|
206
|
+
* is on a screen exactly the size of the viewport we asked for. That is what
|
|
207
|
+
* makes the pictures the same on every machine, and it is also why the app is
|
|
208
|
+
* the last thing here you should ask how big the screen is.
|
|
209
|
+
*
|
|
210
|
+
* @param {{evaluate: (js: string) => Promise<any>}} page The panel's page.
|
|
211
|
+
* @returns {Promise<import('./place.js').Bounds>}
|
|
212
|
+
*/
|
|
213
|
+
export async function readScreen(page) {
|
|
214
|
+
/** A desk-sized screen, for a window that will not say. */
|
|
215
|
+
const fallback = { left: 0, top: 0, width: 1440, height: 900 };
|
|
216
|
+
try {
|
|
217
|
+
const raw = await page.evaluate(
|
|
218
|
+
'(function(){var s=window.screen||{};return {' +
|
|
219
|
+
'left:s.availLeft,top:s.availTop,' +
|
|
220
|
+
'width:s.availWidth||s.width,height:s.availHeight||s.height};})()',
|
|
221
|
+
);
|
|
222
|
+
if (!raw || typeof raw !== 'object') return fallback;
|
|
223
|
+
const width = Math.round(Number(raw.width));
|
|
224
|
+
const height = Math.round(Number(raw.height));
|
|
225
|
+
if (!(width > 0) || !(height > 0)) return fallback;
|
|
226
|
+
const left = Number(raw.left);
|
|
227
|
+
const top = Number(raw.top);
|
|
228
|
+
return {
|
|
229
|
+
// Not every browser reports availLeft/availTop; a single screen starts at zero.
|
|
230
|
+
left: Number.isFinite(left) ? Math.round(left) : 0,
|
|
231
|
+
top: Number.isFinite(top) ? Math.round(top) : 0,
|
|
232
|
+
width,
|
|
233
|
+
height,
|
|
234
|
+
};
|
|
235
|
+
} catch {
|
|
236
|
+
return fallback;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Where a window is right now. Null when the target will not say.
|
|
242
|
+
* @param {WindowRef} page
|
|
243
|
+
* @returns {Promise<import('./place.js').Bounds|null>}
|
|
244
|
+
*/
|
|
245
|
+
export async function readWindowBounds(page) {
|
|
246
|
+
try {
|
|
247
|
+
const found = await page.send('Browser.getWindowForTarget', { targetId: page.targetId });
|
|
248
|
+
const bounds = found?.bounds;
|
|
249
|
+
if (!bounds) return null;
|
|
250
|
+
const width = Math.round(Number(bounds.width));
|
|
251
|
+
const height = Math.round(Number(bounds.height));
|
|
252
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
253
|
+
return { left: Math.round(Number(bounds.left) || 0), top: Math.round(Number(bounds.top) || 0), width, height };
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Move a window, and say whether it went.
|
|
261
|
+
*
|
|
262
|
+
* Never throws. Not every window can be moved — Electron builds differ on this,
|
|
263
|
+
* and a window manager is entitled to say no — and where a window sits is a
|
|
264
|
+
* nicety that is never worth a failed run, nor a warning in the middle of a
|
|
265
|
+
* clean one.
|
|
266
|
+
*
|
|
267
|
+
* @param {WindowRef} page
|
|
268
|
+
* @param {import('./place.js').Bounds} bounds
|
|
269
|
+
* @returns {Promise<boolean>}
|
|
270
|
+
*/
|
|
271
|
+
export async function moveWindow(page, bounds) {
|
|
272
|
+
try {
|
|
273
|
+
const found = await page.send('Browser.getWindowForTarget', { targetId: page.targetId });
|
|
274
|
+
const windowId = found?.windowId;
|
|
275
|
+
if (typeof windowId !== 'number') return false;
|
|
276
|
+
|
|
277
|
+
// A maximised or minimised window refuses a size, and Chrome answers with an
|
|
278
|
+
// error rather than quietly restoring it for you. So it is put back to a
|
|
279
|
+
// normal window first, in a call of its own: the same call cannot both
|
|
280
|
+
// restore a window and place it.
|
|
281
|
+
const state = String(found?.bounds?.windowState ?? 'normal');
|
|
282
|
+
if (state !== 'normal') {
|
|
283
|
+
await page.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await page.send('Browser.setWindowBounds', {
|
|
287
|
+
windowId,
|
|
288
|
+
bounds: {
|
|
289
|
+
windowState: 'normal',
|
|
290
|
+
left: Math.round(bounds.left),
|
|
291
|
+
top: Math.round(bounds.top),
|
|
292
|
+
width: Math.max(1, Math.round(bounds.width)),
|
|
293
|
+
height: Math.max(1, Math.round(bounds.height)),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
return true;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Where a window is, as its own page sees it.
|
|
304
|
+
*
|
|
305
|
+
* `screenX`, `screenY`, `outerWidth` and `outerHeight` describe the window, not
|
|
306
|
+
* the page, and they keep describing the window with
|
|
307
|
+
* `Emulation.setDeviceMetricsOverride` applied — measured on a real app being
|
|
308
|
+
* photographed at 1440x900, they still report the window it is actually in. This
|
|
309
|
+
* is the only measurement a desktop app will give us at all.
|
|
310
|
+
*
|
|
311
|
+
* @param {{evaluate: (js: string) => Promise<any>}} page
|
|
312
|
+
* @returns {Promise<import('./place.js').Bounds|null>}
|
|
313
|
+
*/
|
|
314
|
+
async function readPageWindow(page) {
|
|
315
|
+
try {
|
|
316
|
+
const raw = await page.evaluate(
|
|
317
|
+
'({left:window.screenX,top:window.screenY,width:window.outerWidth,height:window.outerHeight})',
|
|
318
|
+
);
|
|
319
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
320
|
+
const width = Math.round(Number(raw.width));
|
|
321
|
+
const height = Math.round(Number(raw.height));
|
|
322
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
323
|
+
return { left: Math.round(Number(raw.left) || 0), top: Math.round(Number(raw.top) || 0), width, height };
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Move a window through macOS itself, when the app has no way to be asked.
|
|
331
|
+
*
|
|
332
|
+
* Electron does not implement the part of the protocol that moves windows —
|
|
333
|
+
* `Browser.getWindowForTarget` is simply not there — so a desktop app cannot be
|
|
334
|
+
* placed over the debugging connection, and placing it is the entire point.
|
|
335
|
+
* macOS can do it, under two hard rules.
|
|
336
|
+
*
|
|
337
|
+
* It names the process BY ITS UNIX ID, never by its name. A person's own copy of
|
|
338
|
+
* an app and the scratch copy under test have the same name, and a script that
|
|
339
|
+
* says `process "Terminal Deck"` moves whichever one macOS hands it. The id is
|
|
340
|
+
* the one this run started itself, so nothing else on the machine can be caught
|
|
341
|
+
* by it — and an app we merely attached to has no id here and is never moved.
|
|
342
|
+
*
|
|
343
|
+
* And it moves only the window that is exactly where the page says it is, so an
|
|
344
|
+
* app with a second window open keeps it where it was. Position only: the size
|
|
345
|
+
* of the window being photographed is never ours to change.
|
|
346
|
+
*
|
|
347
|
+
* @param {number} pid
|
|
348
|
+
* @param {import('./place.js').Bounds} current Where the page says the window is now.
|
|
349
|
+
* @param {import('./place.js').Bounds} target
|
|
350
|
+
* @returns {Promise<boolean>}
|
|
351
|
+
*/
|
|
352
|
+
async function moveMacWindow(pid, current, target) {
|
|
353
|
+
if (process.platform !== 'darwin') return false;
|
|
354
|
+
const script = [
|
|
355
|
+
`tell application "System Events" to tell (first application process whose unix id is ${Math.round(pid)})`,
|
|
356
|
+
' repeat with w in windows',
|
|
357
|
+
` if (item 1 of (get position of w)) is ${current.left} and (item 2 of (get position of w)) is ${current.top} then`,
|
|
358
|
+
` set position of w to {${Math.round(target.left)}, ${Math.round(target.top)}}`,
|
|
359
|
+
' end if',
|
|
360
|
+
' end repeat',
|
|
361
|
+
'end tell',
|
|
362
|
+
].join('\n');
|
|
363
|
+
try {
|
|
364
|
+
await execFileAsync('osascript', ['-e', script], { timeout: 8000 });
|
|
365
|
+
return true;
|
|
366
|
+
} catch {
|
|
367
|
+
// Usually no accessibility permission, sometimes an app that will not be
|
|
368
|
+
// scripted. The panel goes beside it where it stands instead.
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Paint the window's own background before the document paints its own.
|
|
375
|
+
*
|
|
376
|
+
* The page sets its ground the moment it loads; the browser paints the window
|
|
377
|
+
* before that, and white for a fifth of a second is the single thing that makes
|
|
378
|
+
* a purpose-built window look like a browser opening. `--force-dark-mode` would
|
|
379
|
+
* cover it too and is not an option: it repaints pages, and nothing in this
|
|
380
|
+
* process may ever be able to change what something looks like.
|
|
381
|
+
*
|
|
382
|
+
* @param {{send: (method: string, params?: Record<string, unknown>) => Promise<any>}} page
|
|
383
|
+
* @returns {Promise<void>}
|
|
384
|
+
*/
|
|
385
|
+
async function darkenWindow(page, theme = 'dark') {
|
|
386
|
+
try {
|
|
387
|
+
// It lives in Emulation, not Page, despite being about the window rather
|
|
388
|
+
// than about emulating anything. Applied to the PANEL's own page only.
|
|
389
|
+
await page.send('Emulation.setDefaultBackgroundColorOverride', { color: GROUND });
|
|
390
|
+
} catch {
|
|
391
|
+
// An older build without it just flashes. Not worth a word.
|
|
392
|
+
}
|
|
393
|
+
if (theme === 'system') return;
|
|
394
|
+
try {
|
|
395
|
+
// Say which look we want rather than asking.
|
|
396
|
+
//
|
|
397
|
+
// The panel is written dark first, with a light palette behind
|
|
398
|
+
// `prefers-color-scheme: light`. But this window runs on a brand new browser
|
|
399
|
+
// profile, and a fresh profile answers that question with "light" whatever the
|
|
400
|
+
// computer around it is set to — so the panel came up pale on a dark desktop.
|
|
401
|
+
// This is our own window, not a page on somebody's site, so it does not have to
|
|
402
|
+
// guess: it is told. `--watch-theme light` or `system` for anyone who wants the
|
|
403
|
+
// other behaviour.
|
|
404
|
+
await page.send('Emulation.setEmulatedMedia', {
|
|
405
|
+
features: [{ name: 'prefers-color-scheme', value: theme }],
|
|
406
|
+
});
|
|
407
|
+
} catch (e) {
|
|
408
|
+
// A build that will not be told keeps whatever it chose.
|
|
409
|
+
detail('watch window: could not set the look —', e instanceof Error ? e.message : String(e));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Watch the panel's window for a hand on it.
|
|
415
|
+
*
|
|
416
|
+
* The page checks its own position on a slow interval — four numbers, once a
|
|
417
|
+
* second, no protocol traffic at all — and remembers the moment they stop
|
|
418
|
+
* matching what we last set. Reading that is then one call, made only when we
|
|
419
|
+
* are about to move the window or write down where it ended up.
|
|
420
|
+
*
|
|
421
|
+
* @param {{evaluate: (js: string) => Promise<any>}} page
|
|
422
|
+
* @param {import('./place.js').Bounds} expected Where we have just put it.
|
|
423
|
+
* @returns {Promise<void>}
|
|
424
|
+
*/
|
|
425
|
+
async function watchForHandMove(page, expected) {
|
|
426
|
+
const source =
|
|
427
|
+
'(function(){var e=' +
|
|
428
|
+
JSON.stringify(expected) +
|
|
429
|
+
';var w=window.__staysfixed_place;' +
|
|
430
|
+
'if(w){w.expected=e;return true;}' +
|
|
431
|
+
'w=window.__staysfixed_place={moved:false,bounds:null,expected:e};' +
|
|
432
|
+
'w.look=function(){' +
|
|
433
|
+
'var b={left:window.screenX,top:window.screenY,width:window.outerWidth,height:window.outerHeight};' +
|
|
434
|
+
'w.bounds=b;var x=w.expected;if(!x)return;' +
|
|
435
|
+
'if(Math.abs(b.left-x.left)>' +
|
|
436
|
+
MOVE_TOLERANCE +
|
|
437
|
+
'||Math.abs(b.top-x.top)>' +
|
|
438
|
+
MOVE_TOLERANCE +
|
|
439
|
+
'||Math.abs(b.width-x.width)>' +
|
|
440
|
+
MOVE_TOLERANCE +
|
|
441
|
+
'||Math.abs(b.height-x.height)>' +
|
|
442
|
+
MOVE_TOLERANCE +
|
|
443
|
+
')w.moved=true;};' +
|
|
444
|
+
'w.timer=setInterval(w.look,' +
|
|
445
|
+
MOVE_WATCH_MS +
|
|
446
|
+
');return true;})()';
|
|
447
|
+
await page.evaluate(source).catch(() => {});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Has the person moved it, and where is it now?
|
|
452
|
+
* @param {{evaluate: (js: string) => Promise<any>}} page
|
|
453
|
+
* @returns {Promise<{moved: boolean, bounds: import('./place.js').Bounds|null}>}
|
|
454
|
+
*/
|
|
455
|
+
async function readHandMove(page) {
|
|
456
|
+
try {
|
|
457
|
+
const raw = await page.evaluate(
|
|
458
|
+
'(function(){var w=window.__staysfixed_place;if(!w)return null;' +
|
|
459
|
+
// Look once more before answering, so a window moved a moment ago still counts.
|
|
460
|
+
'if(typeof w.look==="function")w.look();' +
|
|
461
|
+
'return {moved:!!w.moved,bounds:w.bounds};})()',
|
|
462
|
+
);
|
|
463
|
+
if (!raw || typeof raw !== 'object') return { moved: false, bounds: null };
|
|
464
|
+
const b = raw.bounds;
|
|
465
|
+
const bounds =
|
|
466
|
+
b && Number.isFinite(Number(b.width)) && Number(b.width) > 0
|
|
467
|
+
? {
|
|
468
|
+
left: Math.round(Number(b.left) || 0),
|
|
469
|
+
top: Math.round(Number(b.top) || 0),
|
|
470
|
+
width: Math.round(Number(b.width)),
|
|
471
|
+
height: Math.round(Number(b.height)),
|
|
472
|
+
}
|
|
473
|
+
: null;
|
|
474
|
+
return { moved: raw.moved === true, bounds };
|
|
475
|
+
} catch {
|
|
476
|
+
return { moved: false, bounds: null };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Stop the page watching itself. Called on the way out, so a window left open
|
|
482
|
+
* after the run is not still running a timer nobody reads.
|
|
483
|
+
* @param {{evaluate: (js: string) => Promise<any>}} page
|
|
484
|
+
* @returns {Promise<void>}
|
|
485
|
+
*/
|
|
486
|
+
async function stopWatchingMoves(page) {
|
|
487
|
+
await page
|
|
488
|
+
.evaluate(
|
|
489
|
+
'(function(){var w=window.__staysfixed_place;' +
|
|
490
|
+
'if(w&&w.timer){clearInterval(w.timer);w.timer=null;}return true;})()',
|
|
491
|
+
)
|
|
492
|
+
.catch(() => {});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Would this window still make sense on this screen?
|
|
497
|
+
* @param {import('./place.js').Bounds} bounds
|
|
498
|
+
* @param {import('./place.js').Bounds} screen
|
|
499
|
+
* @returns {boolean}
|
|
500
|
+
*/
|
|
501
|
+
function fitsOnScreen(bounds, screen) {
|
|
502
|
+
// A window is allowed to hang slightly over an edge — people put them there on
|
|
503
|
+
// purpose. A window remembered from a bigger monitor is not.
|
|
504
|
+
const slack = 24;
|
|
505
|
+
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
|
|
506
|
+
return (
|
|
507
|
+
bounds.left >= screen.left - slack &&
|
|
508
|
+
bounds.top >= screen.top - slack &&
|
|
509
|
+
bounds.left + bounds.width <= screen.left + screen.width + slack &&
|
|
510
|
+
bounds.top + bounds.height <= screen.top + screen.height + slack
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The window the person arranged last time, if there is one.
|
|
516
|
+
* @param {import('../types.js').Project|undefined|null} project
|
|
517
|
+
* @returns {Promise<import('./place.js').Bounds|null>}
|
|
518
|
+
*/
|
|
519
|
+
async function readRemembered(project) {
|
|
520
|
+
const dir = project?.paths?.dir;
|
|
521
|
+
if (!dir) return null;
|
|
522
|
+
try {
|
|
523
|
+
const raw = JSON.parse(await fsp.readFile(path.join(dir, REMEMBER_FILE), 'utf8'));
|
|
524
|
+
const width = Math.round(Number(raw?.width));
|
|
525
|
+
const height = Math.round(Number(raw?.height));
|
|
526
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
527
|
+
return { left: Math.round(Number(raw.left) || 0), top: Math.round(Number(raw.top) || 0), width, height };
|
|
528
|
+
} catch {
|
|
529
|
+
// No file, or a file somebody has been editing. Either way: place it ourselves.
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Write down where the person left the window.
|
|
536
|
+
*
|
|
537
|
+
* A window somebody has arranged is theirs, and it should still be theirs
|
|
538
|
+
* tomorrow — so the next run opens it there instead of dragging it back to
|
|
539
|
+
* where the arithmetic says it belongs. The screen is written down beside it so
|
|
540
|
+
* a position remembered from a second monitor can be recognised and ignored.
|
|
541
|
+
*
|
|
542
|
+
* @param {import('../types.js').Project|undefined|null} project
|
|
543
|
+
* @param {import('./place.js').Bounds} bounds
|
|
544
|
+
* @param {import('./place.js').Bounds} screen
|
|
545
|
+
* @returns {Promise<void>}
|
|
546
|
+
*/
|
|
547
|
+
async function writeRemembered(project, bounds, screen) {
|
|
548
|
+
const dir = project?.paths?.dir;
|
|
549
|
+
if (!dir) return;
|
|
550
|
+
try {
|
|
551
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
552
|
+
const body = { ...bounds, screen, at: new Date().toISOString() };
|
|
553
|
+
await fsp.writeFile(path.join(dir, REMEMBER_FILE), JSON.stringify(body, null, 2) + '\n');
|
|
554
|
+
} catch {
|
|
555
|
+
// Remembering is a courtesy. A read-only folder is not a failed run.
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A Mac app bundle is named by its folder, not by the program inside it.
|
|
561
|
+
* @param {string} binary
|
|
562
|
+
* @returns {string}
|
|
563
|
+
*/
|
|
564
|
+
function appNameFrom(binary) {
|
|
565
|
+
const bundle = binary.match(/([^/\\]+)\.app(?:[/\\]|$)/);
|
|
566
|
+
return bundle ? bundle[1] : path.basename(binary);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* What is being checked, said the way a person would say it.
|
|
571
|
+
* @param {string|import('../types.js').AppConfig|undefined} app
|
|
572
|
+
* @returns {string}
|
|
573
|
+
*/
|
|
574
|
+
function describeApp(app) {
|
|
575
|
+
if (typeof app === 'string') return app.trim();
|
|
576
|
+
if (!app || typeof app !== 'object') return '';
|
|
577
|
+
const a = /** @type {any} */ (app);
|
|
578
|
+
if (a.kind === 'electron' && a.binary) return `the desktop app ${appNameFrom(String(a.binary))}`;
|
|
579
|
+
if (a.url) return `the app at ${String(a.url)}`;
|
|
580
|
+
if (a.attach) return `the app already running at ${String(a.attach)}`;
|
|
581
|
+
return a.kind === 'electron' ? 'a desktop app' : 'a web app';
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Rows, whether the caller had the list or only the count. A count becomes
|
|
586
|
+
* placeholder rows that fill themselves in as the run names them.
|
|
587
|
+
* @param {import('./panel.js').PanelRow[]|number|undefined} value
|
|
588
|
+
* @returns {import('./panel.js').PanelRow[]}
|
|
589
|
+
*/
|
|
590
|
+
function rowsFrom(value) {
|
|
591
|
+
if (Array.isArray(value)) {
|
|
592
|
+
return value
|
|
593
|
+
.filter((row) => row && typeof row === 'object' && typeof (/** @type {any} */ (row).name) === 'string')
|
|
594
|
+
.map((row) => ({
|
|
595
|
+
name: String(/** @type {any} */ (row).name),
|
|
596
|
+
describe: /** @type {any} */ (row).describe ? String(/** @type {any} */ (row).describe) : undefined,
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @param {OpenPanelOptions} opts
|
|
604
|
+
* @returns {import('./panel.js').PanelPlan}
|
|
605
|
+
*/
|
|
606
|
+
function planFor(opts) {
|
|
607
|
+
const plan = opts.plan ?? {};
|
|
608
|
+
const root = opts.project?.paths?.root;
|
|
609
|
+
return {
|
|
610
|
+
project: String(plan.project ?? (root ? path.basename(root) : '')).trim(),
|
|
611
|
+
app: describeApp(plan.app ?? opts.project?.config?.app),
|
|
612
|
+
screens: rowsFrom(plan.screens),
|
|
613
|
+
guards: rowsFrom(plan.guards),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The flags. Short, because nothing here is being photographed: the panel needs
|
|
619
|
+
* a clean window, a profile of its own, and nothing on it that says "browser".
|
|
620
|
+
* @param {{port: number, profileDir: string, url: string, bounds: import('./place.js').Bounds}} ctx
|
|
621
|
+
* @returns {string[]}
|
|
622
|
+
*/
|
|
623
|
+
function panelArgs(ctx) {
|
|
624
|
+
return [
|
|
625
|
+
`--remote-debugging-port=${ctx.port}`,
|
|
626
|
+
// Node's WebSocket sends no Origin header, and recent Chrome refuses a
|
|
627
|
+
// socket from an unknown one.
|
|
628
|
+
'--remote-allow-origins=*',
|
|
629
|
+
// Never the browser the person actually uses: their tabs, their extensions,
|
|
630
|
+
// their signed-in session. This one is thrown away afterwards.
|
|
631
|
+
`--user-data-dir=${ctx.profileDir}`,
|
|
632
|
+
// An app window: no tabs, no address bar, nothing but the panel.
|
|
633
|
+
`--app=${ctx.url}`,
|
|
634
|
+
`--window-size=${ctx.bounds.width},${ctx.bounds.height}`,
|
|
635
|
+
`--window-position=${ctx.bounds.left},${ctx.bounds.top}`,
|
|
636
|
+
// Everything a browser puts on a window that this is not: the first-run
|
|
637
|
+
// page, the default-browser question, the translate strip, the cast icon,
|
|
638
|
+
// the "Chrome didn't shut down properly" bubble, and the info bar that
|
|
639
|
+
// announces automation. With a throwaway profile as well, what opens is a
|
|
640
|
+
// rectangle with our page in it.
|
|
641
|
+
'--no-first-run',
|
|
642
|
+
'--no-default-browser-check',
|
|
643
|
+
'--disable-infobars',
|
|
644
|
+
'--hide-crash-restore-bubble',
|
|
645
|
+
'--disable-features=Translate,MediaRouter',
|
|
646
|
+
'--disable-extensions',
|
|
647
|
+
'--disable-background-networking',
|
|
648
|
+
'--disable-component-update',
|
|
649
|
+
'--disable-default-apps',
|
|
650
|
+
'--disable-sync',
|
|
651
|
+
'--metrics-recording-only',
|
|
652
|
+
'--disable-client-side-phishing-detection',
|
|
653
|
+
'--no-service-autorun',
|
|
654
|
+
'--password-store=basic',
|
|
655
|
+
'--use-mock-keychain',
|
|
656
|
+
'--mute-audio',
|
|
657
|
+
'--disable-notifications',
|
|
658
|
+
'--deny-permission-prompts',
|
|
659
|
+
// A window sitting behind another one gets its timers slowed down, which
|
|
660
|
+
// would make the elapsed clock in the panel visibly wrong.
|
|
661
|
+
'--disable-background-timer-throttling',
|
|
662
|
+
'--disable-backgrounding-occluded-windows',
|
|
663
|
+
'--disable-renderer-backgrounding',
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The name of the application currently in front, on macOS. Null anywhere else.
|
|
669
|
+
* @returns {Promise<string|null>}
|
|
670
|
+
*/
|
|
671
|
+
async function frontmostApp() {
|
|
672
|
+
if (process.platform !== 'darwin') return null;
|
|
673
|
+
try {
|
|
674
|
+
const { stdout } = await execFileAsync(
|
|
675
|
+
'osascript',
|
|
676
|
+
['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
|
|
677
|
+
{ timeout: 4000 },
|
|
678
|
+
);
|
|
679
|
+
const name = stdout.trim();
|
|
680
|
+
return name.length > 0 ? name : null;
|
|
681
|
+
} catch {
|
|
682
|
+
// No Apple Events permission, or no window server at all. Not worth a word.
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Put the screen back where it was before the panel opened.
|
|
689
|
+
* @param {string} name
|
|
690
|
+
* @returns {Promise<void>}
|
|
691
|
+
*/
|
|
692
|
+
async function giveFocusBack(name) {
|
|
693
|
+
if (process.platform !== 'darwin') return;
|
|
694
|
+
try {
|
|
695
|
+
await execFileAsync(
|
|
696
|
+
'osascript',
|
|
697
|
+
[
|
|
698
|
+
'-e',
|
|
699
|
+
`tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(name)} to true`,
|
|
700
|
+
],
|
|
701
|
+
{ timeout: 4000 },
|
|
702
|
+
);
|
|
703
|
+
detail(`the watch window is open behind ${name}`);
|
|
704
|
+
} catch {
|
|
705
|
+
// Worst case the panel keeps the foreground. Never a reason to fail a run.
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Take away the folders left by panels that were kept open and then closed by
|
|
711
|
+
* hand. A window we leave up owns its profile until the browser exits, and by
|
|
712
|
+
* then this process is usually gone, so the tidying happens next time instead.
|
|
713
|
+
* @returns {Promise<void>}
|
|
714
|
+
*/
|
|
715
|
+
async function sweepOldPanels() {
|
|
716
|
+
try {
|
|
717
|
+
const parent = os.tmpdir();
|
|
718
|
+
const now = Date.now();
|
|
719
|
+
for (const name of await fsp.readdir(parent)) {
|
|
720
|
+
if (!name.startsWith(TMP_PREFIX)) continue;
|
|
721
|
+
const full = path.join(parent, name);
|
|
722
|
+
// The profile is what a live browser keeps writing to, so it is the
|
|
723
|
+
// honest measure of whether anyone still has this window open.
|
|
724
|
+
const stat =
|
|
725
|
+
(await fsp.stat(path.join(full, 'profile')).catch(() => null)) ?? (await fsp.stat(full).catch(() => null));
|
|
726
|
+
if (!stat || now - stat.mtimeMs < STALE_MS) continue;
|
|
727
|
+
await fsp.rm(full, { recursive: true, force: true }).catch(() => {});
|
|
728
|
+
}
|
|
729
|
+
} catch {
|
|
730
|
+
// Housekeeping. Never worth a word, never worth a failure.
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Find the window showing our page.
|
|
736
|
+
* @param {string} endpoint
|
|
737
|
+
* @param {number} deadline epoch ms
|
|
738
|
+
* @returns {Promise<any>}
|
|
739
|
+
*/
|
|
740
|
+
async function findPanelTarget(endpoint, deadline) {
|
|
741
|
+
for (;;) {
|
|
742
|
+
const targets = /** @type {any[]} */ (await listTargets(endpoint).catch(() => []));
|
|
743
|
+
const hit = targets.find((t) => t && t.type === 'page' && String(t.url ?? '').startsWith('file://'));
|
|
744
|
+
if (hit) return hit;
|
|
745
|
+
if (Date.now() > deadline) return null;
|
|
746
|
+
await delay(120);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Open the panel beside the app.
|
|
752
|
+
*
|
|
753
|
+
* Returns null — never throws — when there is no browser to open it with or the
|
|
754
|
+
* window will not start. A run without a live view is a run; a run that fails
|
|
755
|
+
* because its live view failed would be indefensible.
|
|
756
|
+
*
|
|
757
|
+
* @param {OpenPanelOptions} [opts]
|
|
758
|
+
* @returns {Promise<Panel|null>}
|
|
759
|
+
*/
|
|
760
|
+
export async function openPanel(opts = {}) {
|
|
761
|
+
const watch = opts.watch ?? {};
|
|
762
|
+
const chrome = findChrome();
|
|
763
|
+
if (!chrome) {
|
|
764
|
+
warn(
|
|
765
|
+
'There is no Chrome, Chromium, Edge or Brave on this machine, so the watch window cannot open. The check itself carries on as normal.',
|
|
766
|
+
);
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await sweepOldPanels();
|
|
771
|
+
|
|
772
|
+
const size = panelBounds(opts.appViewport, watch);
|
|
773
|
+
// Where it was left last time, if anywhere. Opening there is the difference
|
|
774
|
+
// between a window that comes back and a window that jumps.
|
|
775
|
+
const remembered = await readRemembered(opts.project);
|
|
776
|
+
const opening = remembered ?? { left: size.x, top: size.y, width: size.width, height: size.height };
|
|
777
|
+
// How big the app's window is expected to be, when it is going to have one.
|
|
778
|
+
// A guess, and only ever used to open the panel near where it will settle.
|
|
779
|
+
const expectedApp = appHasAWindow(opts.project?.config?.app)
|
|
780
|
+
? {
|
|
781
|
+
width: Math.round(Number(opts.appViewport?.width) || DEFAULT_VIEWPORT.width),
|
|
782
|
+
height: Math.round(Number(opts.appViewport?.height) || DEFAULT_VIEWPORT.height),
|
|
783
|
+
}
|
|
784
|
+
: null;
|
|
785
|
+
/** @type {string|null} */
|
|
786
|
+
let temp = null;
|
|
787
|
+
/** @type {import('node:child_process').ChildProcess|null} */
|
|
788
|
+
let child = null;
|
|
789
|
+
/** @type {import('../types.js').CdpSession|null} */
|
|
790
|
+
let cdp = null;
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
temp = await fsp.mkdtemp(path.join(os.tmpdir(), TMP_PREFIX));
|
|
794
|
+
const pageFile = path.join(temp, 'panel.html');
|
|
795
|
+
const profileDir = path.join(temp, 'profile');
|
|
796
|
+
await fsp.mkdir(profileDir, { recursive: true });
|
|
797
|
+
await fsp.writeFile(pageFile, panelHtml({ ...planFor(opts), theme: watch.theme ?? 'dark' }));
|
|
798
|
+
const url = pathToFileURL(pageFile).href;
|
|
799
|
+
|
|
800
|
+
const port = await freePort();
|
|
801
|
+
detail(`watch window: ${chrome}`);
|
|
802
|
+
detail(`watch window port: ${port}`);
|
|
803
|
+
|
|
804
|
+
// Remember who has the screen before anything opens, so it can be handed
|
|
805
|
+
// straight back. `watch.foreground` is for when you want to watch it work.
|
|
806
|
+
const previousApp = watch.foreground === true ? null : await frontmostApp();
|
|
807
|
+
|
|
808
|
+
child = spawn(chrome, panelArgs({ port, profileDir, url, bounds: opening }), {
|
|
809
|
+
// The window outlives this command when it is kept open, so it cannot be
|
|
810
|
+
// tied to our process group or our pipes.
|
|
811
|
+
detached: true,
|
|
812
|
+
stdio: 'ignore',
|
|
813
|
+
});
|
|
814
|
+
child.unref();
|
|
815
|
+
// A browser that dies later must not crash the run with an unhandled error.
|
|
816
|
+
child.on('error', () => {});
|
|
817
|
+
|
|
818
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
819
|
+
const version = await waitForEndpoint(endpoint, { timeoutMs: OPEN_TIMEOUT_MS, intervalMs: 100 });
|
|
820
|
+
const wsUrl = version?.webSocketDebuggerUrl;
|
|
821
|
+
if (!wsUrl) throw new Error('the window opened but offered no debugging connection');
|
|
822
|
+
|
|
823
|
+
cdp = /** @type {import('../types.js').CdpSession} */ (await connect(wsUrl, { timeoutMs: 15_000 }));
|
|
824
|
+
|
|
825
|
+
const target = await findPanelTarget(endpoint, Date.now() + OPEN_TIMEOUT_MS);
|
|
826
|
+
if (!target) throw new Error('the window opened but never showed the panel');
|
|
827
|
+
|
|
828
|
+
const targetId = String(target.id ?? target.targetId);
|
|
829
|
+
const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
830
|
+
const sessionId = String(attached.sessionId);
|
|
831
|
+
const page = await createPage(
|
|
832
|
+
cdp,
|
|
833
|
+
/** @type {any} */ ({ sessionId, targetId, baseUrl: null, timeoutMs: 15_000 }),
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// Before the document paints, so the window is never a white rectangle.
|
|
837
|
+
await darkenWindow(page, watch.theme ?? 'dark');
|
|
838
|
+
|
|
839
|
+
// Now there is a window, there is finally something that can say how big the
|
|
840
|
+
// screen is. A position on the command line is a guess; this is the answer.
|
|
841
|
+
const screen = await readScreen(page);
|
|
842
|
+
const kept = remembered && fitsOnScreen(remembered, screen) ? remembered : null;
|
|
843
|
+
const first = kept ?? firstPlace(screen, size, watch, expectedApp);
|
|
844
|
+
await moveWindow(page, first);
|
|
845
|
+
await watchForHandMove(page, first);
|
|
846
|
+
|
|
847
|
+
// The page defines its own update function as it loads; until it exists
|
|
848
|
+
// there is nothing to push into.
|
|
849
|
+
await waitForPanelReady(page, Date.now() + OPEN_TIMEOUT_MS);
|
|
850
|
+
|
|
851
|
+
if (previousApp) await giveFocusBack(previousApp);
|
|
852
|
+
|
|
853
|
+
return makePanel({
|
|
854
|
+
cdp,
|
|
855
|
+
page,
|
|
856
|
+
sessionId,
|
|
857
|
+
child,
|
|
858
|
+
temp,
|
|
859
|
+
url,
|
|
860
|
+
keepOpen: watch.keepOpen !== false,
|
|
861
|
+
project: opts.project ?? null,
|
|
862
|
+
side: watch.side === 'left' ? 'left' : 'right',
|
|
863
|
+
panelWidth: size.width,
|
|
864
|
+
askedHeight: askedHeight(watch),
|
|
865
|
+
placed: first,
|
|
866
|
+
// Opened where they left it, so it is already theirs: nothing snaps it.
|
|
867
|
+
byHand: kept !== null,
|
|
868
|
+
foreground: watch.foreground === true,
|
|
869
|
+
});
|
|
870
|
+
} catch (e) {
|
|
871
|
+
warn(`The watch window could not open, so this run has no live view. The check itself carries on. ${messageOf(e)}`);
|
|
872
|
+
if (cdp) await cdp.close().catch(() => {});
|
|
873
|
+
if (child) await stopProcess(child, 2000).catch(() => {});
|
|
874
|
+
if (temp) await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The height the person asked for, or nothing — in which case the panel is as
|
|
881
|
+
* tall as whatever it is standing next to.
|
|
882
|
+
* @param {import('../types.js').WatchOptions} watch
|
|
883
|
+
* @returns {number|null}
|
|
884
|
+
*/
|
|
885
|
+
function askedHeight(watch) {
|
|
886
|
+
const asked = Number(watch?.height);
|
|
887
|
+
return Number.isFinite(asked) && asked > 0 ? Math.round(asked) : null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Will this app put a window on the screen at all?
|
|
892
|
+
*
|
|
893
|
+
* A headless browser has a window on paper and nothing on the screen, and
|
|
894
|
+
* snapping the panel against one would leave it hugging thin air.
|
|
895
|
+
*
|
|
896
|
+
* @param {import('../types.js').AppConfig|undefined} app
|
|
897
|
+
* @returns {boolean}
|
|
898
|
+
*/
|
|
899
|
+
function appHasAWindow(app) {
|
|
900
|
+
if (!app) return false;
|
|
901
|
+
if (app.kind === 'electron') return true;
|
|
902
|
+
return app.headless === false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Where the panel goes before the app is open.
|
|
907
|
+
*
|
|
908
|
+
* `snapTo` places it properly once the app's real window can be measured, so
|
|
909
|
+
* this only has to be close: given the size the app is expected to be, it opens
|
|
910
|
+
* within a few pixels of where it will stay, and the correction is a nudge
|
|
911
|
+
* rather than a leap across the screen.
|
|
912
|
+
*
|
|
913
|
+
* @param {import('./place.js').Bounds} screen
|
|
914
|
+
* @param {{width: number, height: number}} size
|
|
915
|
+
* @param {import('../types.js').WatchOptions} watch
|
|
916
|
+
* @param {{width: number, height: number}|null} [expectedApp]
|
|
917
|
+
* @returns {import('./place.js').Bounds}
|
|
918
|
+
*/
|
|
919
|
+
function firstPlace(screen, size, watch, expectedApp = null) {
|
|
920
|
+
const plan = planPlacement({
|
|
921
|
+
screen,
|
|
922
|
+
appSize: expectedApp,
|
|
923
|
+
panelWidth: size.width,
|
|
924
|
+
side: watch?.side === 'left' ? 'left' : 'right',
|
|
925
|
+
gap: GAP,
|
|
926
|
+
});
|
|
927
|
+
const height = askedHeight(watch);
|
|
928
|
+
return height ? { ...plan.panel, height: Math.min(height, screen.height) } : plan.panel;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* @param {import('../types.js').PageHandle} page
|
|
933
|
+
* @param {number} deadline epoch ms
|
|
934
|
+
* @returns {Promise<void>}
|
|
935
|
+
*/
|
|
936
|
+
async function waitForPanelReady(page, deadline) {
|
|
937
|
+
for (;;) {
|
|
938
|
+
const ready = await page.evaluate('typeof window.__staysfixed_push === "function"').catch(() => false);
|
|
939
|
+
if (ready === true) return;
|
|
940
|
+
if (Date.now() > deadline) throw new Error('the panel page never finished loading');
|
|
941
|
+
await delay(80);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Add the one thing the page cannot work out for itself: the verdict, in the
|
|
947
|
+
* exact words the terminal prints. Those sentences live in one place
|
|
948
|
+
* (`verdictFor`) and this is how the window gets them.
|
|
949
|
+
* @param {import('../types.js').RunEvent} event
|
|
950
|
+
* @returns {any}
|
|
951
|
+
*/
|
|
952
|
+
function enrich(event) {
|
|
953
|
+
if (event.type !== 'run:done' || !event.summary) return event;
|
|
954
|
+
try {
|
|
955
|
+
return { ...event, verdict: verdictFor(event.summary) };
|
|
956
|
+
} catch {
|
|
957
|
+
// A summary we cannot read still deserves to reach the window.
|
|
958
|
+
return event;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* @param {{
|
|
964
|
+
* cdp: import('../types.js').CdpSession,
|
|
965
|
+
* page: import('../types.js').PageHandle,
|
|
966
|
+
* sessionId: string,
|
|
967
|
+
* child: import('node:child_process').ChildProcess,
|
|
968
|
+
* temp: string,
|
|
969
|
+
* url: string,
|
|
970
|
+
* keepOpen: boolean,
|
|
971
|
+
* project: import('../types.js').Project|null,
|
|
972
|
+
* side: 'left'|'right',
|
|
973
|
+
* panelWidth: number,
|
|
974
|
+
* askedHeight: number|null,
|
|
975
|
+
* placed: import('./place.js').Bounds,
|
|
976
|
+
* byHand: boolean,
|
|
977
|
+
* foreground: boolean,
|
|
978
|
+
* }} ctx
|
|
979
|
+
* @returns {Panel}
|
|
980
|
+
*/
|
|
981
|
+
function makePanel(ctx) {
|
|
982
|
+
/** @type {any[]} */
|
|
983
|
+
const queue = [];
|
|
984
|
+
let sending = false;
|
|
985
|
+
let dead = false;
|
|
986
|
+
|
|
987
|
+
/** Where we last put the window ourselves — or where the person last left it. */
|
|
988
|
+
let placed = ctx.placed;
|
|
989
|
+
/** Once this is true, nothing in here moves the window again. */
|
|
990
|
+
let byHand = ctx.byHand;
|
|
991
|
+
/** The snap happens once a run, whether or not it worked. */
|
|
992
|
+
let snapped = false;
|
|
993
|
+
|
|
994
|
+
// A window we leave up owns its folder until the browser finally exits.
|
|
995
|
+
ctx.child.once('exit', () => {
|
|
996
|
+
fsp.rm(ctx.temp, { recursive: true, force: true }).catch(() => {});
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* When updates arrive faster than the window can take them, it is the
|
|
1001
|
+
* pictures of the ones already overtaken that go — never the words, never an
|
|
1002
|
+
* outcome. A row that stayed on "running" would be a lie; a missing thumbnail
|
|
1003
|
+
* is a picture nobody had time to look at anyway.
|
|
1004
|
+
*/
|
|
1005
|
+
function trimQueue() {
|
|
1006
|
+
for (let i = 0; i < queue.length - KEEP_PICTURES; i++) {
|
|
1007
|
+
const event = queue[i];
|
|
1008
|
+
if (!event.thumbnail && !event.approvedThumb && !event.diffThumb) continue;
|
|
1009
|
+
queue[i] = { ...event, thumbnail: undefined, approvedThumb: undefined, diffThumb: undefined };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* One call, however many updates are waiting. The page is handed JSON as a
|
|
1015
|
+
* string and parses it itself, so nothing a screen name contains can ever be
|
|
1016
|
+
* read as code.
|
|
1017
|
+
* @param {any[]} batch
|
|
1018
|
+
* @returns {Promise<void>}
|
|
1019
|
+
*/
|
|
1020
|
+
async function send(batch) {
|
|
1021
|
+
const payload = JSON.stringify(JSON.stringify(batch));
|
|
1022
|
+
await ctx.page.evaluate(
|
|
1023
|
+
'(function(){var list;try{list=JSON.parse(' +
|
|
1024
|
+
payload +
|
|
1025
|
+
');}catch(e){return 0;}' +
|
|
1026
|
+
'if(typeof window.__staysfixed_push!=="function")return 0;' +
|
|
1027
|
+
'for(var i=0;i<list.length;i++){window.__staysfixed_push(list[i]);}' +
|
|
1028
|
+
'return list.length;})()',
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async function drain() {
|
|
1033
|
+
if (sending || dead) return;
|
|
1034
|
+
sending = true;
|
|
1035
|
+
try {
|
|
1036
|
+
while (queue.length > 0 && !dead) {
|
|
1037
|
+
// Taken off the queue before it is sent, on purpose: an update that
|
|
1038
|
+
// fails is dropped, never retried into a run it would hold up.
|
|
1039
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
1040
|
+
try {
|
|
1041
|
+
await send(batch);
|
|
1042
|
+
} catch {
|
|
1043
|
+
// Usually the person closed the window. Stop rather than complain
|
|
1044
|
+
// once a second for the rest of the run.
|
|
1045
|
+
if (!ctx.cdp.isOpen()) dead = true;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
} finally {
|
|
1049
|
+
sending = false;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Has the person taken hold of the window since we last put it somewhere?
|
|
1055
|
+
* @returns {Promise<boolean>}
|
|
1056
|
+
*/
|
|
1057
|
+
async function handHasIt() {
|
|
1058
|
+
if (byHand) return true;
|
|
1059
|
+
const seen = await readHandMove(ctx.page);
|
|
1060
|
+
if (seen.moved) {
|
|
1061
|
+
byHand = true;
|
|
1062
|
+
if (seen.bounds) placed = seen.bounds;
|
|
1063
|
+
}
|
|
1064
|
+
return byHand;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Put the app hard against its edge of the screen and the panel flush against
|
|
1069
|
+
* it, so the two of them read as one window with a side panel.
|
|
1070
|
+
*
|
|
1071
|
+
* Once a run, and never over the top of a window the person has already moved:
|
|
1072
|
+
* a window somebody has arranged is theirs, and a tool that drags it back is a
|
|
1073
|
+
* tool you close. Everything here is best-effort — a window that will not move
|
|
1074
|
+
* is a disappointment, never a failed check.
|
|
1075
|
+
*
|
|
1076
|
+
* @param {import('../types.js').LaunchedApp} app
|
|
1077
|
+
* @returns {Promise<void>}
|
|
1078
|
+
*/
|
|
1079
|
+
async function snapTo(app) {
|
|
1080
|
+
if (dead || snapped) return;
|
|
1081
|
+
snapped = true;
|
|
1082
|
+
try {
|
|
1083
|
+
if (await handHasIt()) return;
|
|
1084
|
+
|
|
1085
|
+
// Moving windows can pull one to the front. Note who has the screen so it
|
|
1086
|
+
// can be handed back, the same way opening the panel does.
|
|
1087
|
+
const previousApp = ctx.foreground ? null : await frontmostApp();
|
|
1088
|
+
|
|
1089
|
+
const screen = await readScreen(ctx.page);
|
|
1090
|
+
// A headless browser reports a window with a size and shows nothing, so it
|
|
1091
|
+
// is treated as no window at all: the panel takes the screen edge instead.
|
|
1092
|
+
const appPage = appHasAWindow(ctx.project?.config?.app) ? (app.page ?? null) : null;
|
|
1093
|
+
// Ask the protocol first and the page second: a browser answers the first,
|
|
1094
|
+
// and a desktop app only ever answers the second.
|
|
1095
|
+
const current = appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null;
|
|
1096
|
+
|
|
1097
|
+
const plan = planPlacement({
|
|
1098
|
+
screen,
|
|
1099
|
+
// Its own size, always. The size of the app's window is part of what the
|
|
1100
|
+
// pictures are of, so this may move it and may never resize it.
|
|
1101
|
+
appSize: current ? { width: current.width, height: current.height } : null,
|
|
1102
|
+
panelWidth: ctx.panelWidth,
|
|
1103
|
+
side: ctx.side,
|
|
1104
|
+
gap: GAP,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
if (plan.app && current && !fitsAlongside(screen, current, ctx.panelWidth)) {
|
|
1108
|
+
// Said out loud, because otherwise part of the app quietly hangs off the
|
|
1109
|
+
// screen and it looks like something went wrong.
|
|
1110
|
+
detail(
|
|
1111
|
+
`the app's window is ${current.width} wide and the panel is ${ctx.panelWidth}, ` +
|
|
1112
|
+
`which is more than this ${screen.width} screen: part of the app sits off the edge. ` +
|
|
1113
|
+
'A narrower panel (--watch-width) or a smaller app window fixes it.',
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
let moved = false;
|
|
1118
|
+
if (plan.app && appPage && current) {
|
|
1119
|
+
// Two ways to move a window, tried in order. A web app is driven by a
|
|
1120
|
+
// browser, which does it over the protocol; a desktop app is Electron,
|
|
1121
|
+
// which cannot, and on a Mac goes through macOS instead. `app.pid` is
|
|
1122
|
+
// the process this run started — an app we merely attached to has none,
|
|
1123
|
+
// and is left exactly where its owner put it.
|
|
1124
|
+
moved = await moveWindow(appPage, plan.app);
|
|
1125
|
+
if (!moved && typeof app.pid === 'number') moved = await moveMacWindow(app.pid, current, plan.app);
|
|
1126
|
+
if (moved) {
|
|
1127
|
+
// Some windows take the call and ignore it. Believe the window, not
|
|
1128
|
+
// the answer it gave.
|
|
1129
|
+
const after = (await readPageWindow(appPage)) ?? (await readWindowBounds(appPage));
|
|
1130
|
+
if (after) moved = Math.abs(after.left - plan.app.left) <= MOVE_TOLERANCE * 2;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Flush against the app either way: against where we put it if it moved,
|
|
1135
|
+
// and against where it stands if it would not budge. Only with no app
|
|
1136
|
+
// window to find at all does the panel fall back to the screen's edge.
|
|
1137
|
+
const target = moved
|
|
1138
|
+
? plan.panel
|
|
1139
|
+
: current
|
|
1140
|
+
? panelBeside(current, screen, ctx.panelWidth, ctx.side)
|
|
1141
|
+
: planPlacement({ screen, appSize: null, panelWidth: ctx.panelWidth, side: ctx.side, gap: GAP }).panel;
|
|
1142
|
+
const bounds = ctx.askedHeight
|
|
1143
|
+
? { ...target, height: Math.min(ctx.askedHeight, screen.height) }
|
|
1144
|
+
: target;
|
|
1145
|
+
|
|
1146
|
+
if (await moveWindow(ctx.page, bounds)) {
|
|
1147
|
+
placed = bounds;
|
|
1148
|
+
// Tell the page where we just put it, so our own move is not read as theirs.
|
|
1149
|
+
await watchForHandMove(ctx.page, bounds);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (previousApp) await giveFocusBack(previousApp);
|
|
1153
|
+
} catch {
|
|
1154
|
+
// Where the windows sit is a nicety. It is never worth a failed run.
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* @param {import('../types.js').RunEvent} event
|
|
1160
|
+
* @returns {Promise<void>}
|
|
1161
|
+
*/
|
|
1162
|
+
async function push(event) {
|
|
1163
|
+
if (dead || !event || typeof event !== 'object') return;
|
|
1164
|
+
queue.push(enrich(event));
|
|
1165
|
+
trimQueue();
|
|
1166
|
+
// Deliberately not awaited. A check must never wait on a window whose only
|
|
1167
|
+
// job is to be looked at.
|
|
1168
|
+
void drain();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Let whatever is queued land, but not for long.
|
|
1173
|
+
* @returns {Promise<void>}
|
|
1174
|
+
*/
|
|
1175
|
+
async function settle() {
|
|
1176
|
+
const deadline = Date.now() + 2000;
|
|
1177
|
+
while ((queue.length > 0 || sending) && !dead && Date.now() < deadline) {
|
|
1178
|
+
await drain();
|
|
1179
|
+
if (queue.length > 0) await delay(50);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** @type {Promise<void>|null} */
|
|
1184
|
+
let closing = null;
|
|
1185
|
+
|
|
1186
|
+
/** @returns {Promise<void>} */
|
|
1187
|
+
function close() {
|
|
1188
|
+
closing ??= (async () => {
|
|
1189
|
+
await settle().catch(() => {});
|
|
1190
|
+
|
|
1191
|
+
// Last look before the connection goes: if they moved it, that is where it
|
|
1192
|
+
// belongs from now on.
|
|
1193
|
+
try {
|
|
1194
|
+
const seen = await readHandMove(ctx.page);
|
|
1195
|
+
if (seen.moved && seen.bounds) {
|
|
1196
|
+
byHand = true;
|
|
1197
|
+
placed = seen.bounds;
|
|
1198
|
+
}
|
|
1199
|
+
if (byHand) await writeRemembered(ctx.project, placed, await readScreen(ctx.page));
|
|
1200
|
+
} catch {
|
|
1201
|
+
// A window that has already gone cannot say where it was.
|
|
1202
|
+
}
|
|
1203
|
+
await stopWatchingMoves(ctx.page);
|
|
1204
|
+
|
|
1205
|
+
// Stop the clock in the page, so a window left up does not sit there
|
|
1206
|
+
// counting seconds next to a result that is already final.
|
|
1207
|
+
await ctx.page.evaluate('window.__staysfixed_detach && window.__staysfixed_detach()').catch(() => {});
|
|
1208
|
+
dead = true;
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
await ctx.cdp.send('Target.detachFromTarget', { sessionId: ctx.sessionId });
|
|
1212
|
+
} catch {
|
|
1213
|
+
// The window may already be gone, which is where we were heading.
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!ctx.keepOpen) {
|
|
1217
|
+
try {
|
|
1218
|
+
await ctx.cdp.send('Browser.close');
|
|
1219
|
+
} catch {
|
|
1220
|
+
// Asking politely can fail if it is already quitting.
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
try {
|
|
1225
|
+
await ctx.cdp.close();
|
|
1226
|
+
} catch {
|
|
1227
|
+
// Hanging up cannot meaningfully fail.
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (ctx.keepOpen) {
|
|
1231
|
+
// Leave it up. The result is what the person opened the panel to read,
|
|
1232
|
+
// and it should still be there when they look over.
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
await stopProcess(ctx.child, 3000);
|
|
1236
|
+
await fsp.rm(ctx.temp, { recursive: true, force: true }).catch(() => {});
|
|
1237
|
+
})();
|
|
1238
|
+
return closing;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return { push, close, url: ctx.url, snapTo, placedByHand: () => byHand };
|
|
1242
|
+
}
|