staysfixed 0.4.0 → 0.6.1
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 +170 -3
- package/README.md +92 -6
- package/package.json +1 -1
- package/src/v2/adapters/android-driver.js +8 -1
- package/src/v2/adapters/android.js +36 -4
- package/src/v2/adapters/contract.js +84 -6
- package/src/v2/adapters/electron.js +5 -5
- package/src/v2/adapters/http.js +7 -6
- package/src/v2/adapters/process.js +17 -6
- package/src/v2/adapters/source.js +62 -11
- package/src/v2/adapters/web.js +6 -6
- package/src/v2/cause.js +15 -8
- package/src/v2/check.js +121 -23
- package/src/v2/cli.js +16 -3
- package/src/v2/coverage.js +9 -1
- package/src/v2/detect.js +1 -1
- package/src/v2/doctor.js +15 -3
- package/src/v2/journeys/from-routes.js +3 -1
- package/src/v2/observation.js +62 -1
- package/src/v2/remote.js +4 -5
- package/src/v2/run.js +125 -9
- package/src/v2/sealed.js +10 -6
- package/src/v2/selfcheck.js +197 -32
- package/src/v2/types.js +6 -0
- package/src/v2/watch/events.js +1087 -0
- package/src/v2/watch/focus.js +215 -0
- package/src/v2/watch/index.js +382 -0
- package/src/v2/watch/panel.js +1660 -0
- package/src/v2/watch/window.js +1671 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Letting you watch, without taking your screen.
|
|
3
|
+
*
|
|
4
|
+
* The owner asked for something more precise than "run it invisibly", and he was right
|
|
5
|
+
* to. He wants to SEE it work — the app opening, the panel beside it, each check ticking
|
|
6
|
+
* green — because watching it is most of how you come to trust it. What he does not want
|
|
7
|
+
* is what it did to him tonight:
|
|
8
|
+
*
|
|
9
|
+
* "if i click something and bring [my app] on the first layer of the screen and i am
|
|
10
|
+
* working on something, after my click it will not keep bringing it up. it will just
|
|
11
|
+
* keep it back side and keep working."
|
|
12
|
+
*
|
|
13
|
+
* So the rule is not "stay hidden". It is: **come up once, then never come up again.**
|
|
14
|
+
*
|
|
15
|
+
* That distinction is the whole of this file. An app the tool opens is allowed to appear —
|
|
16
|
+
* it should, the first time, so a person can see what is happening. From the moment the
|
|
17
|
+
* person picks something else, whatever the tool launched loses the argument for good.
|
|
18
|
+
*
|
|
19
|
+
* ## Why a guard rather than a flag
|
|
20
|
+
*
|
|
21
|
+
* There is no flag for this. An Electron app calls `app.focus()` and `win.show()` from its
|
|
22
|
+
* own main process during startup, when a window opens, when a dialog appears; a simulator
|
|
23
|
+
* activates when it boots; a browser activates when a new window is created. None of that
|
|
24
|
+
* goes through us, so none of it can be forbidden at launch time. The only thing that
|
|
25
|
+
* actually works is to watch who is in front and put the person's app back when something
|
|
26
|
+
* of ours pushes in front of it.
|
|
27
|
+
*
|
|
28
|
+
* ## Why polling is the right answer here, unusually
|
|
29
|
+
*
|
|
30
|
+
* The standing rule in this codebase is events over polling. macOS does publish an
|
|
31
|
+
* activation notification, but reading it needs a process inside the window server session
|
|
32
|
+
* with an event loop — a small native helper or a persistent AppleScript, both of which are
|
|
33
|
+
* a thing to install and a thing to leave running on his machine. A twelve-line
|
|
34
|
+
* `osascript` every 400ms costs about a millisecond of CPU and installs nothing. The rule
|
|
35
|
+
* exists to stop wasteful polling; this is the case it does not cover.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { execFile } from 'node:child_process';
|
|
39
|
+
import { promisify } from 'node:util';
|
|
40
|
+
import { detail } from '../../core/log.js';
|
|
41
|
+
|
|
42
|
+
const run = promisify(execFile);
|
|
43
|
+
|
|
44
|
+
/** How often to look. Fast enough that a stolen screen is given back before it is annoying. */
|
|
45
|
+
const LOOK_EVERY_MS = 400;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long to leave the tool's window alone at the start.
|
|
49
|
+
*
|
|
50
|
+
* It has just been opened deliberately and a person is probably looking at it. Snatching
|
|
51
|
+
* focus away in the same instant would be its own kind of rude, and would also fight the
|
|
52
|
+
* launch itself while the app is still deciding which of its windows is in front.
|
|
53
|
+
*/
|
|
54
|
+
const GRACE_MS = 2500;
|
|
55
|
+
|
|
56
|
+
/** @typedef {{name: string}} Frontmost */
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Who is in front right now, by application name.
|
|
60
|
+
*
|
|
61
|
+
* Returns null rather than throwing on any failure — no window server, no Apple Events
|
|
62
|
+
* permission, a headless machine, a locked screen. Every one of those means "there is no
|
|
63
|
+
* screen to take", which is not an error and must never fail a check.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Promise<string|null>}
|
|
66
|
+
*/
|
|
67
|
+
export async function frontmostApp() {
|
|
68
|
+
if (process.platform !== 'darwin') return null;
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await run(
|
|
71
|
+
'osascript',
|
|
72
|
+
['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
|
|
73
|
+
{ timeout: 3000 },
|
|
74
|
+
);
|
|
75
|
+
const name = stdout.trim();
|
|
76
|
+
return name.length > 0 ? name : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bring one application back to the front.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} name
|
|
86
|
+
* @returns {Promise<boolean>} whether it worked
|
|
87
|
+
*/
|
|
88
|
+
export async function bringForward(name) {
|
|
89
|
+
if (process.platform !== 'darwin' || !name) return false;
|
|
90
|
+
try {
|
|
91
|
+
await run(
|
|
92
|
+
'osascript',
|
|
93
|
+
[
|
|
94
|
+
'-e',
|
|
95
|
+
`tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(name)} to true`,
|
|
96
|
+
],
|
|
97
|
+
{ timeout: 3000 },
|
|
98
|
+
);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @typedef {object} ScreenGuard
|
|
107
|
+
* @property {(name: string) => void} claim Tell the guard an application belongs to the tool.
|
|
108
|
+
* @property {() => Promise<void>} release Stop guarding. Always safe to call twice.
|
|
109
|
+
* @property {() => GuardReport} report What it did, for the run summary.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {object} GuardReport
|
|
114
|
+
* @property {number} handedBack How many times the screen was taken and given back.
|
|
115
|
+
* @property {string|null} yours The application the guard believes is yours.
|
|
116
|
+
* @property {string[]} ours Everything the tool opened.
|
|
117
|
+
* @property {boolean} watching False when there is no screen to guard.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Watch who is in front, and give the screen back when something of ours takes it.
|
|
122
|
+
*
|
|
123
|
+
* The bookkeeping is deliberately simple, because a clever version of this would guess
|
|
124
|
+
* wrong and fight the person for their own screen:
|
|
125
|
+
*
|
|
126
|
+
* - Anything the tool launches is `ours`, named as the tool launches it.
|
|
127
|
+
* - Anything else that is frontmost is *yours*, and the guard remembers the last one. That
|
|
128
|
+
* is how it learns what to put back — by watching what you actually chose, never by
|
|
129
|
+
* being told.
|
|
130
|
+
* - When one of ours is in front and you have chosen something since, yours goes back.
|
|
131
|
+
* - When one of ours is in front and you have chosen nothing yet, it is left alone. That
|
|
132
|
+
* first appearance is the point: it is how you see what is happening.
|
|
133
|
+
*
|
|
134
|
+
* @param {{claims?: string[], everyMs?: number, graceMs?: number}} [opts]
|
|
135
|
+
* @returns {ScreenGuard}
|
|
136
|
+
*/
|
|
137
|
+
export function guardTheScreen(opts = {}) {
|
|
138
|
+
const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
|
|
139
|
+
const graceMs = opts.graceMs ?? GRACE_MS;
|
|
140
|
+
|
|
141
|
+
/** @type {Set<string>} everything the tool opened */
|
|
142
|
+
const ours = new Set(opts.claims ?? []);
|
|
143
|
+
/** @type {string|null} the last application the person chose for themselves */
|
|
144
|
+
let yours = null;
|
|
145
|
+
let handedBack = 0;
|
|
146
|
+
let stopped = process.platform !== 'darwin';
|
|
147
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
148
|
+
let timer = null;
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
|
|
151
|
+
/** @param {string} name */
|
|
152
|
+
const claim = (name) => {
|
|
153
|
+
if (name) ours.add(name);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const isOurs = (/** @type {string} */ name) => {
|
|
157
|
+
for (const one of ours) {
|
|
158
|
+
// A launched application is often reported under a slightly different name than the
|
|
159
|
+
// path it was started from — "Terminal Deck" for a binary called "Terminal Deck", but
|
|
160
|
+
// "Electron" for a development build, and "Simulator" for a simulator boot. Matching
|
|
161
|
+
// loosely in both directions is what makes this work without a table of special cases.
|
|
162
|
+
if (name === one || name.includes(one) || one.includes(name)) return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const look = async () => {
|
|
168
|
+
if (stopped) return;
|
|
169
|
+
const front = await frontmostApp();
|
|
170
|
+
if (front) {
|
|
171
|
+
if (!isOurs(front)) {
|
|
172
|
+
// The person chose this. It is now what "yours" means.
|
|
173
|
+
yours = front;
|
|
174
|
+
} else if (yours && Date.now() - startedAt > graceMs) {
|
|
175
|
+
// Something of ours is in front, and there is somewhere to put you back.
|
|
176
|
+
const ok = await bringForward(yours);
|
|
177
|
+
if (ok) {
|
|
178
|
+
handedBack += 1;
|
|
179
|
+
detail(`the screen was taken by ${front}; gave it back to ${yours}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!stopped) timer = setTimeout(look, everyMs);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (!stopped) timer = setTimeout(look, everyMs);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
claim,
|
|
190
|
+
async release() {
|
|
191
|
+
stopped = true;
|
|
192
|
+
if (timer) clearTimeout(timer);
|
|
193
|
+
timer = null;
|
|
194
|
+
},
|
|
195
|
+
report() {
|
|
196
|
+
return { handedBack, yours, ours: [...ours], watching: !stopped };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One sentence for the summary, or nothing when there is nothing worth saying.
|
|
203
|
+
*
|
|
204
|
+
* A person who was not interrupted should not be told about the machinery that did not
|
|
205
|
+
* interrupt them. This only speaks when it actually did something.
|
|
206
|
+
*
|
|
207
|
+
* @param {GuardReport} report
|
|
208
|
+
* @returns {string|null}
|
|
209
|
+
*/
|
|
210
|
+
export function describeGuard(report) {
|
|
211
|
+
if (!report || report.handedBack === 0) return null;
|
|
212
|
+
const times = report.handedBack === 1 ? 'once' : `${report.handedBack} times`;
|
|
213
|
+
const back = report.yours ? ` to ${report.yours}` : '';
|
|
214
|
+
return `Something the check opened came to the front ${times} and the screen was handed straight back${back}.`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The watch window, from the check's point of view.
|
|
3
|
+
*
|
|
4
|
+
* A check describes itself into an event stream and carries on. This subscribes to that
|
|
5
|
+
* stream, opens a window beside whatever is being checked, and forwards everything to it. It
|
|
6
|
+
* has no other effect on the check: it takes nothing away from it, it holds nothing up, and
|
|
7
|
+
* when the window cannot open the check does not notice.
|
|
8
|
+
*
|
|
9
|
+
* THREE THINGS HERE ARE NOT OBVIOUS, and all three are the owner's one requirement said in
|
|
10
|
+
* different places:
|
|
11
|
+
*
|
|
12
|
+
* "That window should come up... But once we minimise it, it should keep working headless
|
|
13
|
+
* in the background. Not invisible. It should not keep bringing itself to the front."
|
|
14
|
+
*
|
|
15
|
+
* ONE. The window is opened WITHOUT being waited for. `attachWatcher` hands back a watcher
|
|
16
|
+
* immediately and the browser starts behind it, so the check begins in the same millisecond it
|
|
17
|
+
* would have begun with no window at all. That is safe only because the event stream hands a
|
|
18
|
+
* late listener everything that already happened, in order — so a window that took two seconds
|
|
19
|
+
* to open still draws the two seconds it missed.
|
|
20
|
+
*
|
|
21
|
+
* TWO. The ENGINE works everything out and the PANEL only draws. Every sentence and every
|
|
22
|
+
* number a person reads is made here, on this side, by `events.js`, and pushed over finished.
|
|
23
|
+
* Nothing in the page is computed on a clock of its own. That is what makes minimising the
|
|
24
|
+
* window harmless: a browser slows a hidden page right down, and a page that is only drawing
|
|
25
|
+
* what it was handed loses nothing by being slow. It catches up the moment it is looked at.
|
|
26
|
+
*
|
|
27
|
+
* THREE. Nothing here ever waits on the window. `push` hands back nothing, so there is no
|
|
28
|
+
* promise to await by accident; every send has a hard timeout on it in `window.js`; and
|
|
29
|
+
* stopping the watcher never blocks on a window that has stopped answering.
|
|
30
|
+
*
|
|
31
|
+
* The division of labour across these four files: `events.js` decides WHAT to say, `panel.js`
|
|
32
|
+
* decides how it LOOKS, `window.js` owns the window it is said in, and this file is the seam
|
|
33
|
+
* between all of that and a check.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { detail } from '../../core/log.js';
|
|
37
|
+
import { messageOf } from '../../core/errors.js';
|
|
38
|
+
import { openPanel } from './window.js';
|
|
39
|
+
import { attachPanel, panelPlan } from './events.js';
|
|
40
|
+
|
|
41
|
+
/** @typedef {import('../types.js').Journey} Journey */
|
|
42
|
+
/** @typedef {import('./events.js').PanelPlanShape} PanelPlan */
|
|
43
|
+
/** @typedef {import('./events.js').PanelReference} PanelReference */
|
|
44
|
+
/** @typedef {import('./window.js').Panel} Panel */
|
|
45
|
+
/** @typedef {import('./window.js').BesideThis} BesideThis */
|
|
46
|
+
/** @typedef {import('./window.js').PanelHealth} PanelHealth */
|
|
47
|
+
|
|
48
|
+
/** Panel width when nobody says otherwise. */
|
|
49
|
+
const DEFAULT_WIDTH = 480;
|
|
50
|
+
|
|
51
|
+
/** How long `snapTo` will wait for a window that is still opening before shrugging. */
|
|
52
|
+
const SNAP_WAIT_MS = 6000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How long stopping will wait for a window that is STILL opening.
|
|
56
|
+
*
|
|
57
|
+
* Short, and it has to be. A check that finishes in two seconds on a machine where a browser
|
|
58
|
+
* takes twenty to start would otherwise sit there at the end, done, with nothing to say,
|
|
59
|
+
* waiting on a window nobody is going to read. So stopping calls the opening off and gives it
|
|
60
|
+
* a moment to tidy up; a window that arrives after that closes itself, because the opening
|
|
61
|
+
* sequence already knows it was stopped.
|
|
62
|
+
*/
|
|
63
|
+
const STOP_WAIT_MS = 1500;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Anything with the two halves of an event stream on it.
|
|
67
|
+
*
|
|
68
|
+
* Written structurally rather than as the engine's own type so this can be handed a real
|
|
69
|
+
* check's stream, a stub in a test, or whatever the stream grows into next, without any of
|
|
70
|
+
* them having to import each other.
|
|
71
|
+
*
|
|
72
|
+
* @typedef {object} Watchable
|
|
73
|
+
* @property {(listener: (event: any) => void) => () => void} on
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the panel is told to do.
|
|
78
|
+
*
|
|
79
|
+
* `snap` lives here rather than in the shared options so that whether the two windows are
|
|
80
|
+
* pushed together stays a decision of the watch code.
|
|
81
|
+
*
|
|
82
|
+
* @typedef {import('../../types.js').WatchOptions & {snap?: boolean}} PanelOptions
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {object} AttachOptions
|
|
87
|
+
* @property {string} [product] One repository can build five. This names the one being checked.
|
|
88
|
+
* @property {string} [project] The folder it is being run in.
|
|
89
|
+
* @property {Journey[]} [journeys] Every journey about to be walked, in order.
|
|
90
|
+
* @property {PanelReference} [reference] What it is being compared against, already in words.
|
|
91
|
+
* @property {PanelPlan} [plan] The finished plan, when a caller has built one itself.
|
|
92
|
+
* @property {PanelOptions} [watch]
|
|
93
|
+
* @property {string} [dir] Where to remember the window position. The project's own folder.
|
|
94
|
+
* @property {{width: number, height: number}} [appViewport]
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A check's handle on its window.
|
|
99
|
+
*
|
|
100
|
+
* `snapTo` is how the check introduces the thing being observed once it is up. It is safe to
|
|
101
|
+
* call when there is no panel, when the panel cannot move windows, when the panel is still
|
|
102
|
+
* opening, and when the person asked for no snapping: in every one of those cases it does
|
|
103
|
+
* nothing and costs nothing.
|
|
104
|
+
*
|
|
105
|
+
* `health` is there so the claim this whole file makes — that the window never held the check
|
|
106
|
+
* up — has a number behind it rather than being taken on trust.
|
|
107
|
+
*
|
|
108
|
+
* @typedef {object} Watcher
|
|
109
|
+
* @property {() => Promise<void>} stop
|
|
110
|
+
* @property {(beside: BesideThis) => Promise<void>} snapTo
|
|
111
|
+
* @property {() => PanelHealth|null} health
|
|
112
|
+
* @property {() => boolean} open Is there a window right now.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wait for something, but not for long, and never mind if it does not arrive.
|
|
117
|
+
*
|
|
118
|
+
* The whole of "stopping never blocks on a window", in five lines. The promise itself carries
|
|
119
|
+
* on — nothing here can cancel it, and it does not need to, because whatever it eventually
|
|
120
|
+
* produces knows it was stopped and puts itself away.
|
|
121
|
+
*
|
|
122
|
+
* @template T
|
|
123
|
+
* @param {Promise<T>} work
|
|
124
|
+
* @param {number} ms
|
|
125
|
+
* @returns {Promise<T|null>}
|
|
126
|
+
*/
|
|
127
|
+
function soon(work, ms) {
|
|
128
|
+
/** @type {Promise<null>} */
|
|
129
|
+
const giveUp = new Promise((resolve) => {
|
|
130
|
+
const timer = setTimeout(() => resolve(null), ms);
|
|
131
|
+
// Never hold the program open waiting for a window.
|
|
132
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
133
|
+
});
|
|
134
|
+
return Promise.race([work.catch(() => null), giveUp]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A watcher that is not watching anything.
|
|
139
|
+
*
|
|
140
|
+
* Handed back whenever there is no window — switched off, no browser, a page that would not
|
|
141
|
+
* build — so every caller has the same shape to stop, whatever happened.
|
|
142
|
+
*
|
|
143
|
+
* @returns {Watcher}
|
|
144
|
+
*/
|
|
145
|
+
function noWatcher() {
|
|
146
|
+
return { stop: async () => {}, snapTo: async () => {}, health: () => null, open: () => false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The opening plan: what the window draws before anything has happened.
|
|
151
|
+
*
|
|
152
|
+
* A window that opens empty and fills up looks broken for the first few seconds, so it is
|
|
153
|
+
* given the product, the surfaces in play and every journey it is about to walk, in order,
|
|
154
|
+
* from the moment it appears.
|
|
155
|
+
*
|
|
156
|
+
* @param {AttachOptions} opts
|
|
157
|
+
* @returns {PanelPlan}
|
|
158
|
+
*/
|
|
159
|
+
function planFor(opts) {
|
|
160
|
+
if (opts.plan) return opts.plan;
|
|
161
|
+
return panelPlan({
|
|
162
|
+
...(opts.product ? { product: opts.product } : {}),
|
|
163
|
+
...(opts.project ? { project: opts.project } : {}),
|
|
164
|
+
...(opts.journeys ? { journeys: opts.journeys } : {}),
|
|
165
|
+
...(opts.reference ? { reference: opts.reference } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Open a window and point it at a check.
|
|
171
|
+
*
|
|
172
|
+
* Never throws, and never waits for the window. Every way this can go wrong ends the same way:
|
|
173
|
+
* a line of detail, a watcher that does nothing, and a check that carries on at full speed.
|
|
174
|
+
*
|
|
175
|
+
* @param {Watchable} events
|
|
176
|
+
* @param {AttachOptions} [opts]
|
|
177
|
+
* @returns {Promise<Watcher>}
|
|
178
|
+
*/
|
|
179
|
+
export async function attachWatcher(events, opts = {}) {
|
|
180
|
+
const watch = opts.watch ?? {};
|
|
181
|
+
if (watch.enabled === false) return noWatcher();
|
|
182
|
+
if (!events || typeof events.on !== 'function') return noWatcher();
|
|
183
|
+
|
|
184
|
+
const plan = { ...planFor(opts), theme: watch.theme ?? 'dark' };
|
|
185
|
+
|
|
186
|
+
/** @type {Panel|null} */
|
|
187
|
+
let panel = null;
|
|
188
|
+
/** @type {(() => void)|null} */
|
|
189
|
+
let unsubscribe = null;
|
|
190
|
+
let stopped = false;
|
|
191
|
+
// How stopping reaches a window that has not finished opening. Every wait inside `openPanel`
|
|
192
|
+
// watches this, so calling it off ends them all at once instead of one timeout at a time.
|
|
193
|
+
const givingUp = new AbortController();
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Start the window and, when it is up, start feeding it.
|
|
197
|
+
*
|
|
198
|
+
* Nothing awaits this. Everything that can go wrong inside it ends with no panel and a check
|
|
199
|
+
* that never knew there was going to be one.
|
|
200
|
+
*
|
|
201
|
+
* @type {Promise<Panel|null>}
|
|
202
|
+
*/
|
|
203
|
+
const opening = openPanel({
|
|
204
|
+
plan,
|
|
205
|
+
watch,
|
|
206
|
+
signal: givingUp.signal,
|
|
207
|
+
...(opts.dir ? { dir: opts.dir } : {}),
|
|
208
|
+
...(opts.appViewport ? { appViewport: opts.appViewport } : {}),
|
|
209
|
+
})
|
|
210
|
+
.then((open) => {
|
|
211
|
+
if (!open) return null;
|
|
212
|
+
if (stopped) {
|
|
213
|
+
// Stopped while it was still opening. Close it rather than leave a window nobody asked
|
|
214
|
+
// for standing on somebody's screen.
|
|
215
|
+
void open.close().catch(() => {});
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
panel = open;
|
|
219
|
+
// Everything that already happened arrives here first, in order, before the first live
|
|
220
|
+
// event does — which is the whole reason opening the window in the background is safe.
|
|
221
|
+
//
|
|
222
|
+
// `push` hands back nothing and swallows everything, so there is nothing in this
|
|
223
|
+
// callback that could hold a check up even by accident.
|
|
224
|
+
unsubscribe = attachPanel(events, (drawn) => open.push(drawn), {
|
|
225
|
+
plan,
|
|
226
|
+
onProblem: (problem) => {
|
|
227
|
+
detail(`The watch window refused an update. The check is unaffected. ${messageOf(problem)}`);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
return open;
|
|
231
|
+
})
|
|
232
|
+
.catch((e) => {
|
|
233
|
+
// openPanel reports its own trouble and hands back null; this is only here for whatever
|
|
234
|
+
// it could not have seen coming.
|
|
235
|
+
detail(`The watch window could not open, so this check has no live view. ${messageOf(e)}`);
|
|
236
|
+
return null;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Wait a little for a window that is still opening, and give up cheerfully.
|
|
241
|
+
*
|
|
242
|
+
* Used only by `snapTo`, which happens once, early, at the one moment the window and the
|
|
243
|
+
* thing being checked are both about to exist. Everything else in here refuses to wait at all.
|
|
244
|
+
*
|
|
245
|
+
* @returns {Promise<Panel|null>}
|
|
246
|
+
*/
|
|
247
|
+
async function panelSoon() {
|
|
248
|
+
if (panel) return panel;
|
|
249
|
+
return await soon(opening, SNAP_WAIT_MS);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const maySnap = watch.snap !== false;
|
|
253
|
+
|
|
254
|
+
/** @type {Promise<void>|null} */
|
|
255
|
+
let stopping = null;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
stop: () => {
|
|
259
|
+
stopping ??= (async () => {
|
|
260
|
+
stopped = true;
|
|
261
|
+
// Stop listening first, so nothing new is queued while we are putting it away.
|
|
262
|
+
try {
|
|
263
|
+
if (unsubscribe) unsubscribe();
|
|
264
|
+
} catch {
|
|
265
|
+
// Already gone. Nothing left to stop listening to.
|
|
266
|
+
}
|
|
267
|
+
// Call off a window that is still opening, and do not wait it out. A window that
|
|
268
|
+
// arrives late finds `stopped` already true and closes itself.
|
|
269
|
+
givingUp.abort();
|
|
270
|
+
const open = panel ?? (await soon(opening, STOP_WAIT_MS));
|
|
271
|
+
if (open) await open.close().catch(() => {});
|
|
272
|
+
})();
|
|
273
|
+
return stopping;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
snapTo: async (beside) => {
|
|
277
|
+
if (!maySnap) return;
|
|
278
|
+
try {
|
|
279
|
+
const open = await panelSoon();
|
|
280
|
+
if (open) await open.snapTo(beside);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
// Where two windows sit is a nicety. Losing it must never cost a check, and it is not
|
|
283
|
+
// worth a warning in the middle of a clean one.
|
|
284
|
+
detail(`The panel could not put itself beside what is being checked. ${messageOf(e)}`);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
health: () => (panel ? panel.health() : null),
|
|
289
|
+
open: () => panel !== null,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Settings
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* What the command line hands over. Anything the person did not type is left out, so the
|
|
299
|
+
* settings file still gets a say.
|
|
300
|
+
*
|
|
301
|
+
* @typedef {object} WatchFlags
|
|
302
|
+
* @property {boolean} [enabled]
|
|
303
|
+
* @property {'left'|'right'} [side]
|
|
304
|
+
* @property {number} [width]
|
|
305
|
+
* @property {number} [height]
|
|
306
|
+
* @property {boolean} [keepOpen]
|
|
307
|
+
* @property {boolean} [foreground]
|
|
308
|
+
* @property {boolean} [snap]
|
|
309
|
+
* @property {'dark'|'light'|'system'} [theme]
|
|
310
|
+
*/
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Settle what the panel should do: the settings file first, then anything typed on the command
|
|
314
|
+
* line, then the defaults.
|
|
315
|
+
*
|
|
316
|
+
* `--watch` can only ever turn the panel on. A person who did not type it has not said no to
|
|
317
|
+
* it — they have said nothing — so it never switches off a panel the settings file asked for.
|
|
318
|
+
*
|
|
319
|
+
* @param {{watch?: import('../../types.js').WatchOptions|boolean}|null} [config]
|
|
320
|
+
* @param {WatchFlags|null} [cli]
|
|
321
|
+
* @returns {PanelOptions}
|
|
322
|
+
*/
|
|
323
|
+
export function watchOptionsFrom(config, cli) {
|
|
324
|
+
const raw = config?.watch;
|
|
325
|
+
// Read as PanelOptions on the way in: a settings file is free to carry a `snap` that the
|
|
326
|
+
// shared shape does not yet describe.
|
|
327
|
+
const settings = /** @type {PanelOptions} */ (
|
|
328
|
+
raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
|
|
329
|
+
);
|
|
330
|
+
const flags = cli ?? {};
|
|
331
|
+
|
|
332
|
+
const width = firstNumber(flags.width, settings.width) ?? DEFAULT_WIDTH;
|
|
333
|
+
const height = firstNumber(flags.height, settings.height);
|
|
334
|
+
const side = flags.side ?? settings.side ?? 'right';
|
|
335
|
+
const theme = flags.theme ?? settings.theme;
|
|
336
|
+
|
|
337
|
+
/** @type {PanelOptions} */
|
|
338
|
+
const merged = {
|
|
339
|
+
enabled: flags.enabled === true || settings.enabled === true,
|
|
340
|
+
width,
|
|
341
|
+
side: side === 'left' ? 'left' : 'right',
|
|
342
|
+
keepOpen: firstBoolean(flags.keepOpen, settings.keepOpen) ?? true,
|
|
343
|
+
// Never, unless somebody explicitly asks. This is the whole complaint: it must not keep
|
|
344
|
+
// bringing itself to the front.
|
|
345
|
+
foreground: firstBoolean(flags.foreground, settings.foreground) ?? false,
|
|
346
|
+
// On by default: the point of the panel is that it reads as a side panel of the thing it
|
|
347
|
+
// is checking, and it cannot do that sitting somewhere else on the screen.
|
|
348
|
+
snap: firstBoolean(flags.snap, settings.snap) ?? true,
|
|
349
|
+
// Dark unless somebody asks otherwise. The panel opens on a brand new browser profile, and
|
|
350
|
+
// a fresh profile insists the computer is in light mode however it is really set, so this
|
|
351
|
+
// is stated rather than detected.
|
|
352
|
+
theme: theme === 'light' || theme === 'system' ? theme : 'dark',
|
|
353
|
+
};
|
|
354
|
+
// Left out rather than guessed: with no height the panel is as tall as whatever it is
|
|
355
|
+
// standing next to.
|
|
356
|
+
if (height !== undefined) merged.height = height;
|
|
357
|
+
return merged;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {...unknown} values
|
|
362
|
+
* @returns {number|undefined}
|
|
363
|
+
*/
|
|
364
|
+
function firstNumber(...values) {
|
|
365
|
+
for (const value of values) {
|
|
366
|
+
if (value === undefined || value === null || value === '') continue;
|
|
367
|
+
const n = Number(value);
|
|
368
|
+
if (Number.isFinite(n) && n > 0) return Math.round(n);
|
|
369
|
+
}
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @param {...unknown} values
|
|
375
|
+
* @returns {boolean|undefined}
|
|
376
|
+
*/
|
|
377
|
+
function firstBoolean(...values) {
|
|
378
|
+
for (const value of values) {
|
|
379
|
+
if (typeof value === 'boolean') return value;
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|