staysfixed 0.3.1 → 0.6.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 +159 -3
- package/README.md +611 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +643 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +734 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +931 -0
- package/src/v2/adapters/source.js +1292 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +371 -0
- package/src/v2/check.js +1429 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +670 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1124 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1702 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +500 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +938 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +910 -0
- package/src/v2/run.js +1080 -0
- package/src/v2/sealed.js +568 -0
- package/src/v2/selfcheck.js +729 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +509 -0
- package/src/v2/waiver.js +511 -0
- package/src/v2/watch/focus.js +215 -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
|
+
}
|