staysfixed 0.4.0 → 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 +83 -6
- package/package.json +1 -1
- 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/focus.js +215 -0
package/src/v2/selfcheck.js
CHANGED
|
@@ -56,7 +56,7 @@ const run = promisify(execFile);
|
|
|
56
56
|
* @typedef {object} Case
|
|
57
57
|
* @property {string} name A sentence, because it is read back as one.
|
|
58
58
|
* @property {string} breaks What is wrong, in plain English.
|
|
59
|
-
* @property {'a finding'|'nothing'} expect
|
|
59
|
+
* @property {'a finding'|'nothing'|'no answer'} expect
|
|
60
60
|
* @property {RegExp[]} [mustSay]
|
|
61
61
|
* @property {boolean} [mustBeUnstable] It has to land in `newlyUnstable`, not in the findings.
|
|
62
62
|
* @property {(broken: boolean) => Record<string, string>} build
|
|
@@ -210,6 +210,46 @@ export const CASES = [
|
|
|
210
210
|
}),
|
|
211
211
|
},
|
|
212
212
|
|
|
213
|
+
{
|
|
214
|
+
name: 'a break buried in the middle of a huge output',
|
|
215
|
+
breaks:
|
|
216
|
+
'A program prints more than the tool will store, and the thing that broke is in the middle — past the head it keeps and before the tail it keeps. This is the case the tool used to be blind to: it kept the two ends and a COARSE size, so a change in the discarded middle left a byte-identical record and the run reported that nothing had changed. Exactly the shape of failure this whole thing exists to prevent, and it survived until 2026-08-30.',
|
|
217
|
+
expect: 'a finding',
|
|
218
|
+
// The marker in the middle is what has to have caught it. If some other part of the value
|
|
219
|
+
// reported instead, this case has stopped testing what it was written to test.
|
|
220
|
+
mustSay: [/bytes left out of the middle/],
|
|
221
|
+
build: (broken) => ({
|
|
222
|
+
'package.json': PKG,
|
|
223
|
+
'cli.js': [
|
|
224
|
+
"console.log('report begins');",
|
|
225
|
+
'for (let i = 0; i < 6000; i += 1) {',
|
|
226
|
+
broken
|
|
227
|
+
? " console.log(i === 3000 ? `row ${i}: could not be loaded at all` : `row ${i}: ok`);"
|
|
228
|
+
: ' console.log(`row ${i}: ok`);',
|
|
229
|
+
'}',
|
|
230
|
+
"console.log('report ends');",
|
|
231
|
+
'',
|
|
232
|
+
].join('\n'),
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
name: 'a build that takes ten times longer stays silent',
|
|
238
|
+
breaks:
|
|
239
|
+
'Nothing, and the product is markedly slower. How long something took is recorded and never compared, because a stopwatch on a shared machine measures the machine as much as the product — and comparing it is what made this corpus fail one case out of nine on a busy laptop while passing five times in a row on a quiet one. This case exists so that decision cannot be quietly undone: put timing back into the comparison and this goes red.',
|
|
240
|
+
expect: 'nothing',
|
|
241
|
+
build: (broken) => ({
|
|
242
|
+
'package.json': PKG,
|
|
243
|
+
'cli.js': [
|
|
244
|
+
// A sleep, deliberately, and never a busy loop. Loading the machine to test timing
|
|
245
|
+
// is how you take four other things down with you.
|
|
246
|
+
`await new Promise((done) => setTimeout(done, ${broken ? 900 : 40}));`,
|
|
247
|
+
"console.log('total 10.005');",
|
|
248
|
+
'',
|
|
249
|
+
].join('\n'),
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
|
|
213
253
|
{
|
|
214
254
|
name: 'a value that used to be steady is now random',
|
|
215
255
|
breaks:
|
|
@@ -226,6 +266,28 @@ export const CASES = [
|
|
|
226
266
|
'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
|
|
227
267
|
}),
|
|
228
268
|
},
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
name: 'a run that could not answer says so instead of passing',
|
|
272
|
+
breaks:
|
|
273
|
+
'The break is real and it is hidden by the product itself: this build writes a fresh set of randomly named files on every run and stamps a random id on what it prints, so the same build disagrees with itself about nearly every address it has. Everything that wobbles is subtracted before anything is compared — which is right, and which here removes the comparison altogether. The only honest answer is that this run says nothing, and until 2026-08-30 the engine said "nothing that already worked has changed", which is the same sentence it uses when a product is genuinely fine.',
|
|
274
|
+
expect: 'no answer',
|
|
275
|
+
build: (broken) => ({
|
|
276
|
+
'package.json': PKG,
|
|
277
|
+
'cli.js': [
|
|
278
|
+
"import fs from 'node:fs';",
|
|
279
|
+
"fs.mkdirSync('out', { recursive: true });",
|
|
280
|
+
'// A build tool writing hash-named artefacts. Nothing unusual, and every one of them',
|
|
281
|
+
'// is a new address that was not there on the last run.',
|
|
282
|
+
'for (let i = 0; i < 30; i += 1) {',
|
|
283
|
+
" fs.writeFileSync(`out/chunk-${Math.random().toString(36).slice(2, 10)}.txt`, 'x');",
|
|
284
|
+
'}',
|
|
285
|
+
'console.log(`request ${Math.random().toString(36).slice(2, 10)}`);',
|
|
286
|
+
broken ? "console.log('orders: could not be loaded');" : "console.log('orders: 2');",
|
|
287
|
+
'',
|
|
288
|
+
].join('\n'),
|
|
289
|
+
}),
|
|
290
|
+
},
|
|
229
291
|
];
|
|
230
292
|
|
|
231
293
|
// ---------------------------------------------------------------------------
|
|
@@ -237,7 +299,7 @@ export const CASES = [
|
|
|
237
299
|
* @property {string} name
|
|
238
300
|
* @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
|
|
239
301
|
* @property {string} [why] Why it did not, in one plain sentence.
|
|
240
|
-
* @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'} verdict
|
|
302
|
+
* @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'} verdict
|
|
241
303
|
*/
|
|
242
304
|
|
|
243
305
|
/**
|
|
@@ -245,6 +307,8 @@ export const CASES = [
|
|
|
245
307
|
* @property {boolean} passed
|
|
246
308
|
* @property {CaseResult[]} cases
|
|
247
309
|
* @property {boolean} ran False when the engine could not be driven at all.
|
|
310
|
+
* @property {boolean} [certain] False when at least one case could not be told either way.
|
|
311
|
+
* A run that is not certain is NOT a pass and NOT a failure.
|
|
248
312
|
* @property {string} [why] Why it could not run.
|
|
249
313
|
* @property {string} [workDir]
|
|
250
314
|
*/
|
|
@@ -287,48 +351,108 @@ export async function selfcheck(opts = {}) {
|
|
|
287
351
|
const cases = [];
|
|
288
352
|
|
|
289
353
|
for (const c of wanted) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
working = await plant(dir, c);
|
|
295
|
-
} catch (e) {
|
|
296
|
-
cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` });
|
|
354
|
+
const first = await runOne(check, workDir, c, 1);
|
|
355
|
+
if (first.caught) {
|
|
356
|
+
cases.push(first);
|
|
297
357
|
continue;
|
|
298
358
|
}
|
|
299
359
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
360
|
+
// IT FAILED. Before that becomes an accusation, it has to reproduce.
|
|
361
|
+
//
|
|
362
|
+
// This is the same rule the engine itself lives by, turned on the corpus: a difference
|
|
363
|
+
// that will not happen twice is not a difference. On the night of 2026-08-29 this corpus
|
|
364
|
+
// came back "1 of 9 wrong" while the test suite was running beside it and then passed
|
|
365
|
+
// five times in a row on a quiet machine — and a corpus that can be perturbed by a busy
|
|
366
|
+
// laptop is worth nothing on a busy laptop, because nobody can tell its noise from its
|
|
367
|
+
// signal. The cause was found and removed (see howLongItTook in adapters/contract.js),
|
|
368
|
+
// and this stays anyway, because the next machine-shaped thing to creep in should land
|
|
369
|
+
// as "I could not tell" rather than as a false accusation somebody learns to ignore.
|
|
370
|
+
//
|
|
371
|
+
// A second run that agrees is a real failure and is reported as one. A second run that
|
|
372
|
+
// disagrees is filed as UNTELLABLE, which is not a pass: the exit code is 2, the same
|
|
373
|
+
// one used for "the corpus could not be run at all", because both mean no answer.
|
|
374
|
+
const second = await runOne(check, workDir, c, 2);
|
|
375
|
+
if (!second.caught) {
|
|
376
|
+
cases.push({ ...second, why: `${second.why ?? 'it did not behave'} (it did this twice in a row, so it is real)` });
|
|
316
377
|
continue;
|
|
317
378
|
}
|
|
318
|
-
|
|
319
|
-
|
|
379
|
+
cases.push({
|
|
380
|
+
name: c.name,
|
|
381
|
+
caught: false,
|
|
382
|
+
verdict: 'could not tell',
|
|
383
|
+
why:
|
|
384
|
+
`it behaved on the second run and not on the first, so this says nothing either way. ` +
|
|
385
|
+
`The first time: ${first.why ?? 'it did not behave'}. ` +
|
|
386
|
+
`This machine's load was ${loadNow()} — something that comes and goes with how busy the machine is is not evidence about the engine. ` +
|
|
387
|
+
`Run it again on a quiet machine before believing either answer.`,
|
|
388
|
+
});
|
|
320
389
|
}
|
|
321
390
|
|
|
322
391
|
if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
|
|
323
392
|
|
|
393
|
+
const untellable = cases.some((r) => r.verdict === 'could not tell');
|
|
324
394
|
return {
|
|
325
395
|
passed: cases.length > 0 && cases.every((r) => r.caught),
|
|
326
396
|
ran: true,
|
|
397
|
+
certain: !untellable,
|
|
327
398
|
cases,
|
|
328
399
|
...(opts.keep ? { workDir } : {}),
|
|
329
400
|
};
|
|
330
401
|
}
|
|
331
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Build one case fresh and put the engine through it once.
|
|
405
|
+
*
|
|
406
|
+
* A fresh folder every attempt, deliberately. Re-running inside the same folder would leave
|
|
407
|
+
* the first attempt's stored captures sitting there, and the second attempt would be
|
|
408
|
+
* comparing against those rather than against the build that works.
|
|
409
|
+
*
|
|
410
|
+
* @param {any} check
|
|
411
|
+
* @param {string} workDir
|
|
412
|
+
* @param {Case} c
|
|
413
|
+
* @param {number} attempt
|
|
414
|
+
* @returns {Promise<CaseResult>}
|
|
415
|
+
*/
|
|
416
|
+
async function runOne(check, workDir, c, attempt) {
|
|
417
|
+
const dir = path.join(workDir, `${safe(c.name)}${attempt > 1 ? `-again-${attempt}` : ''}`);
|
|
418
|
+
/** @type {string} */
|
|
419
|
+
let working;
|
|
420
|
+
try {
|
|
421
|
+
working = await plant(dir, c);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return { name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @type {any} */
|
|
427
|
+
let result;
|
|
428
|
+
try {
|
|
429
|
+
// Exactly the call an agent makes, with exactly the arguments an agent
|
|
430
|
+
// sends. A corpus that reached past the front door would prove the engine
|
|
431
|
+
// works when driven in a way nobody drives it.
|
|
432
|
+
result = await check({
|
|
433
|
+
cwd: dir,
|
|
434
|
+
configFile: undefined,
|
|
435
|
+
against: working,
|
|
436
|
+
paired: true,
|
|
437
|
+
journeys: path.join(dir, 'journeys.json'),
|
|
438
|
+
only: [],
|
|
439
|
+
});
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return { name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return judge(c, result);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** How busy this machine is, in words, so an untellable result can name the likely reason. */
|
|
448
|
+
function loadNow() {
|
|
449
|
+
const [one] = os.loadavg();
|
|
450
|
+
const cores = os.cpus().length || 1;
|
|
451
|
+
const per = one / cores;
|
|
452
|
+
const how = per < 0.4 ? 'quiet' : per < 0.9 ? 'busy' : 'very busy';
|
|
453
|
+
return `${one.toFixed(1)} across ${cores} cores, which is ${how}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
332
456
|
/**
|
|
333
457
|
* Did the engine do what this case demands?
|
|
334
458
|
*
|
|
@@ -349,9 +473,35 @@ function judge(c, result) {
|
|
|
349
473
|
const findings = Array.isArray(result?.findings) ? result.findings : [];
|
|
350
474
|
const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
|
|
351
475
|
|
|
476
|
+
// The third expectation, and the one the other two cannot express: a run that is entitled
|
|
477
|
+
// to no verdict at all. What is demanded here is narrow on purpose — not that it found the
|
|
478
|
+
// break, which it cannot, but that it refused to call the run clean and said why in words a
|
|
479
|
+
// person can read.
|
|
480
|
+
if (c.expect === 'no answer') {
|
|
481
|
+
const said = String(result?.summary ?? '');
|
|
482
|
+
if (result?.ok === false && /no answer|not a pass/i.test(said)) {
|
|
483
|
+
return { name: c.name, caught: true, verdict: 'said it could not tell' };
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
name: c.name,
|
|
487
|
+
caught: false,
|
|
488
|
+
verdict: 'escaped',
|
|
489
|
+
why:
|
|
490
|
+
result?.ok === false
|
|
491
|
+
? `it did not pass, but it never said why in a way anybody could read: ${said.slice(0, 200)}`
|
|
492
|
+
: `it reported a clean run over a comparison that had been thrown away: ${said.slice(0, 200)}`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
352
496
|
if (c.expect === 'nothing') {
|
|
353
497
|
if (findings.length === 0 && unstable.length === 0) return { name: c.name, caught: true, verdict: 'quiet' };
|
|
354
|
-
|
|
498
|
+
// `unstable` holds WobbleEntry objects, not strings. Interpolating one printed
|
|
499
|
+
// "[object Object]" and turned the most important line in a failure report — the one
|
|
500
|
+
// saying WHAT went wrong — into nothing at all.
|
|
501
|
+
const named = unstable.map((/** @type {any} */ u) => (typeof u === 'string' ? u : `${u?.path ?? 'an address'} (was ${JSON.stringify(u?.a)}, then ${JSON.stringify(u?.b)})`));
|
|
502
|
+
const what = findings.length
|
|
503
|
+
? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}`
|
|
504
|
+
: `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${named.slice(0, 3).join('; ')}`;
|
|
355
505
|
return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
|
|
356
506
|
}
|
|
357
507
|
|
|
@@ -532,13 +682,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
532
682
|
|
|
533
683
|
if (json) {
|
|
534
684
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
535
|
-
|
|
685
|
+
if (result.passed) return 0;
|
|
686
|
+
if (!result.ran) return 2;
|
|
687
|
+
return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
|
|
536
688
|
}
|
|
537
689
|
|
|
538
690
|
if (!result.ran) {
|
|
539
691
|
process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
|
|
540
692
|
return 2;
|
|
541
693
|
}
|
|
694
|
+
const untellable = result.cases.filter((r) => r.verdict === 'could not tell');
|
|
542
695
|
|
|
543
696
|
/** @type {string[]} */
|
|
544
697
|
const out = ['Stays Fixed - checking that it can still catch things', ''];
|
|
@@ -549,14 +702,26 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
549
702
|
out.push('');
|
|
550
703
|
if (result.passed) {
|
|
551
704
|
out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
|
|
705
|
+
} else if (untellable.length > 0 && untellable.length === result.cases.filter((r) => !r.caught).length) {
|
|
706
|
+
// Nothing failed twice. Saying "wrong" here would be an accusation the evidence does not
|
|
707
|
+
// support, and saying "fine" would be worse.
|
|
708
|
+
out.push(
|
|
709
|
+
`${untellable.length} of ${result.cases.length} could not be told either way — ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
|
|
710
|
+
'That is not a pass and not a failure. Run it again on a quiet machine.',
|
|
711
|
+
);
|
|
552
712
|
} else {
|
|
553
|
-
const bad = result.cases.filter((r) => !r.caught);
|
|
554
|
-
out.push(`${bad.length} of ${result.cases.length} did not behave. Until that is fixed, a clean check from this tool does not mean what it says.`);
|
|
713
|
+
const bad = result.cases.filter((r) => !r.caught && r.verdict !== 'could not tell');
|
|
714
|
+
out.push(`${bad.length} of ${result.cases.length} did not behave, twice in a row each. Until that is fixed, a clean check from this tool does not mean what it says.`);
|
|
715
|
+
if (untellable.length > 0) out.push(`${untellable.length} more could not be told either way.`);
|
|
555
716
|
}
|
|
556
717
|
if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
|
|
557
718
|
|
|
558
719
|
process.stdout.write(out.join('\n') + '\n');
|
|
559
|
-
|
|
720
|
+
if (result.passed) return 0;
|
|
721
|
+
// "I could not test this" is exit 2 and never exit 0, and it is not exit 1 either: one of
|
|
722
|
+
// those says the engine is broken and the other says nobody knows, and they need different
|
|
723
|
+
// reactions from whoever is reading.
|
|
724
|
+
return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
|
|
560
725
|
}
|
|
561
726
|
|
|
562
727
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/v2/types.js
CHANGED
|
@@ -235,6 +235,12 @@
|
|
|
235
235
|
* False when we had no stability record for the reference,
|
|
236
236
|
* so `newlyUnstable` is empty for lack of evidence rather
|
|
237
237
|
* than because nothing became unstable.
|
|
238
|
+
* @property {boolean} [couldNotTell] True when the wobble measurement was too big to be a
|
|
239
|
+
* measurement — the same build answered differently at
|
|
240
|
+
* most of its own addresses, so subtracting it subtracts
|
|
241
|
+
* the answer. A run in this state has no verdict, and it
|
|
242
|
+
* must never be reported as a clean one.
|
|
243
|
+
* @property {string} [couldNotTellWhy] Said plainly, with the numbers in it.
|
|
238
244
|
* @property {string} note One plain sentence stating exactly that.
|
|
239
245
|
*/
|
|
240
246
|
|
|
@@ -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
|
+
}
|