staysfixed 0.6.2 → 0.7.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.
@@ -27,10 +27,13 @@
27
27
  * look at — and never the newest state.
28
28
  *
29
29
  * THREE. It never takes the screen. Whoever was in front before this opened is put
30
- * back in front afterwards, and the same dance is exported so the adapters can use
31
- * it too because the complaint that started all of this was not about this panel
32
- * at all, it was about an app, a simulator and an emulator jumping in front of him
33
- * while he worked.
30
+ * back in front afterwards, and the same dance is exported so that anything else
31
+ * that moves a window can do it too `check.js` wraps the move that puts a desktop
32
+ * app out of sight in it. It is only half the answer, and the smaller half: an app
33
+ * this tool opens is ALLOWED to appear once, because being able to watch it work is
34
+ * most of how you come to trust it. What must never happen is it coming forward
35
+ * again after that, and the thing that stops it is the screen guard in
36
+ * `watch/focus.js`, which runs for the whole of a check.
34
37
  *
35
38
  * FOUR. It is our own window, not his. Chrome for Testing wherever there is one,
36
39
  * a throwaway profile every time, its own port, and nothing on this machine that
@@ -172,10 +175,10 @@ export async function noteTheFrontmost() {
172
175
  /**
173
176
  * Put the screen back where it was.
174
177
  *
175
- * The other half of `noteTheFrontmost`, and the one implementation of it
176
- * the web, Electron, iOS, Android and Windows adapters all call this rather than
177
- * each writing their own AppleScript, because three copies of this is how one of
178
- * them ends up subtly not doing it.
178
+ * The other half of `noteTheFrontmost`, and the one implementation of it. Called
179
+ * here when the panel opens, and by `check.js` around anything that moves somebody
180
+ * else's window, rather than each of them writing its own AppleScript because
181
+ * three copies of this is how one of them ends up subtly not doing it.
179
182
  *
180
183
  * Safe to call with null, on any platform, at any time. Never throws.
181
184
  *
@@ -338,6 +341,60 @@ export async function moveWindowByPid(pid, current, target) {
338
341
  }
339
342
  }
340
343
 
344
+ /**
345
+ * Where a desktop app's biggest window is, asked of macOS by the app's unix id.
346
+ *
347
+ * The other half of `moveWindowByPid`, and the reason either of them exists: an
348
+ * Electron app does not implement the part of the debugging protocol that reads or
349
+ * moves a window, so a desktop app under check can only be measured through the
350
+ * window server. Without this the panel has nothing to sit beside and a run has no
351
+ * way to tell whether the app it opened is on somebody's screen.
352
+ *
353
+ * The BIGGEST window, not the first. A desktop app usually has several — a status
354
+ * item, a hidden helper, a print panel — and the first one macOS hands back is not
355
+ * reliably the one a person is looking at.
356
+ *
357
+ * Null on anything but macOS, and null whenever the machine will not say: no
358
+ * accessibility permission, an app that refuses to be scripted, an app with no
359
+ * window yet. Every one of those means "we cannot place this", which is a
360
+ * disappointment and never a failed check.
361
+ *
362
+ * @param {number} pid The process THIS run started. Never one we attached to.
363
+ * @returns {Promise<Bounds|null>}
364
+ */
365
+ export async function windowBoundsByPid(pid) {
366
+ if (process.platform !== 'darwin') return null;
367
+ if (!Number.isFinite(Number(pid)) || Number(pid) <= 0) return null;
368
+ const script = [
369
+ `tell application "System Events" to tell (first application process whose unix id is ${Math.round(pid)})`,
370
+ ' set out to ""',
371
+ ' repeat with w in windows',
372
+ ' set p to position of w',
373
+ ' set s to size of w',
374
+ ' set out to out & (item 1 of p) & "," & (item 2 of p) & "," & (item 1 of s) & "," & (item 2 of s) & linefeed',
375
+ ' end repeat',
376
+ ' return out',
377
+ 'end tell',
378
+ ].join('\n');
379
+ try {
380
+ const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 8000 });
381
+ /** @type {Bounds|null} */
382
+ let biggest = null;
383
+ for (const line of String(stdout).split('\n')) {
384
+ const parts = line.trim().split(',');
385
+ if (parts.length !== 4) continue;
386
+ const [left, top, width, height] = parts.map((n) => Math.round(Number(n)));
387
+ if (![left, top, width, height].every((n) => Number.isFinite(n))) continue;
388
+ if (!(width > 0) || !(height > 0)) continue;
389
+ if (biggest && biggest.width * biggest.height >= width * height) continue;
390
+ biggest = { left, top, width, height };
391
+ }
392
+ return biggest;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
341
398
  // ---------------------------------------------------------------------------
342
399
  // The screen, and the windows standing on it
343
400
  // ---------------------------------------------------------------------------
@@ -562,16 +619,23 @@ function askedHeight(watch) {
562
619
  /**
563
620
  * Where the panel goes before there is anything to sit beside.
564
621
  *
622
+ * There is deliberately nowhere here to pass an expected app size. Nothing knows it
623
+ * yet — the app has not been started — and a placement made from a guess is a
624
+ * window that jumps the moment `snapTo` puts it where it really belongs.
625
+ *
626
+ * With no app, `planPlacement` puts the panel against the edge it is given, so the
627
+ * side is passed straight through rather than flipped. That flip is only needed
628
+ * when there IS an app, because then the side names the edge the app goes to.
629
+ *
565
630
  * @param {Bounds} screen
566
631
  * @param {{width: number, height: number}} size
567
632
  * @param {import('../../types.js').WatchOptions|undefined} watch
568
- * @param {{width: number, height: number}|null} [expectedApp]
569
633
  * @returns {Bounds}
570
634
  */
571
- function firstPlace(screen, size, watch, expectedApp = null) {
635
+ function firstPlace(screen, size, watch) {
572
636
  const plan = planPlacement({
573
637
  screen,
574
- appSize: expectedApp,
638
+ appSize: null,
575
639
  panelWidth: size.width,
576
640
  side: watch?.side === 'left' ? 'left' : 'right',
577
641
  gap: GAP,
@@ -858,6 +922,21 @@ h1 { margin: 8px 0 2px; font-size: 18px; font-weight: 560; letter-spacing: -0.01
858
922
  * @property {(beside: BesideThis) => Promise<void>} snapTo
859
923
  * @property {() => boolean} placedByHand
860
924
  * @property {() => PanelHealth} health
925
+ * @property {PanelBrowser} browser Which browser the window is, and whose it is.
926
+ */
927
+
928
+ /**
929
+ * The browser the panel opened in.
930
+ *
931
+ * `borrowed` is the field that matters, and it is a safety rule rather than a
932
+ * detail: when this is the person's own everyday browser, nothing in this tool may
933
+ * treat its windows as belonging to the tool. The screen guard reads it for exactly
934
+ * that — claiming a borrowed browser would have it shoving the person out of their
935
+ * own tabs.
936
+ *
937
+ * @typedef {object} PanelBrowser
938
+ * @property {string} name
939
+ * @property {boolean} borrowed True when this is the browser the person uses.
861
940
  */
862
941
 
863
942
  /**
@@ -882,6 +961,10 @@ h1 { margin: 8px 0 2px; font-size: 18px; font-weight: 560; letter-spacing: -0.01
882
961
  *
883
962
  * @typedef {object} BesideThis
884
963
  * @property {number|null} [pid] The process THIS run started. Never one we attached to.
964
+ * A desktop app answers no protocol call about its own
965
+ * window, so its unix id is the only way to find out
966
+ * where it is — and the only way to move it out of the
967
+ * panel's way.
885
968
  * @property {any} [page] Its page, when it has one we can ask.
886
969
  * @property {Bounds} [window] Where its window is, when the caller already knows.
887
970
  * @property {boolean} [hasWindow] False for anything headless, which is most things.
@@ -1256,7 +1339,7 @@ export async function openPanel(opts = {}) {
1256
1339
  // screen is. A position on the command line is a guess; this is the answer.
1257
1340
  const screen = await readScreen(page);
1258
1341
  const kept = remembered && fitsOnScreen(remembered, screen) ? remembered : null;
1259
- const first = kept ?? firstPlace(screen, size, watch, null);
1342
+ const first = kept ?? firstPlace(screen, size, watch);
1260
1343
  await moveWindow(page, first);
1261
1344
  await watchForHandMove(page, first);
1262
1345
 
@@ -1284,6 +1367,7 @@ export async function openPanel(opts = {}) {
1284
1367
  // Opened where they left it, so it is already theirs: nothing snaps it.
1285
1368
  byHand: kept !== null,
1286
1369
  foreground: watch.foreground === true,
1370
+ browser: { name: chrome.name, borrowed: chrome.borrowed },
1287
1371
  });
1288
1372
  } catch (e) {
1289
1373
  // Somebody finishing is not something going wrong. A run that ends before its window
@@ -1374,6 +1458,7 @@ const KEEP_NOTES = 120;
1374
1458
  * placed: Bounds,
1375
1459
  * byHand: boolean,
1376
1460
  * foreground: boolean,
1461
+ * browser: PanelBrowser,
1377
1462
  * }} ctx
1378
1463
  * @returns {Panel}
1379
1464
  */
@@ -1546,16 +1631,48 @@ function makePanel(ctx) {
1546
1631
 
1547
1632
  // Anything headless has a window on paper and nothing on the screen, and
1548
1633
  // snapping against one would leave the panel hugging thin air.
1549
- const appPage = beside?.hasWindow === false ? null : (beside?.page ?? null);
1550
- // Ask the protocol first and the page second: a browser answers the first,
1551
- // and a desktop app only ever answers the second.
1634
+ const headless = beside?.hasWindow === false;
1635
+ const appPage = headless ? null : (beside?.page ?? null);
1636
+ const pid = headless ? 0 : Math.round(Number(beside?.pid) || 0);
1637
+ // Ask the protocol first, the page second, the window server last: a browser
1638
+ // answers the first, a desktop app answers only the second, and a desktop app
1639
+ // whose page we have not got answers only the third.
1552
1640
  const current =
1553
1641
  beside?.window ??
1554
- (appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null);
1555
-
1556
- const target = current
1557
- ? panelBeside(current, screen, ctx.panelWidth, ctx.side)
1558
- : planPlacement({ screen, appSize: null, panelWidth: ctx.panelWidth, side: ctx.side, gap: GAP }).panel;
1642
+ (appPage ? ((await readWindowBounds(appPage)) ?? (await readPageWindow(appPage))) : null) ??
1643
+ (pid > 0 ? await windowBoundsByPid(pid) : null);
1644
+
1645
+ /** @type {Bounds} */
1646
+ let target;
1647
+ if (current && pid > 0) {
1648
+ // This run started that app, so it is ours to move: pin it to its edge and put
1649
+ // the panel flush against it — one shape, two windows. `planPlacement` names the
1650
+ // edge the APP goes to, which is the opposite end of the same arrangement from
1651
+ // the side the PANEL sits on, so the side is flipped on the way in.
1652
+ const plan = planPlacement({
1653
+ screen,
1654
+ appSize: { width: current.width, height: current.height },
1655
+ panelWidth: ctx.panelWidth,
1656
+ side: ctx.side === 'left' ? 'right' : 'left',
1657
+ gap: GAP,
1658
+ });
1659
+ if (plan.app) await moveWindowByPid(pid, current, plan.app);
1660
+ // Where it ACTUALLY ended up, which is not always where it was asked to go: a
1661
+ // window manager may refuse, and there is no accessibility permission on some
1662
+ // machines at all. A panel placed where the app was supposed to be would then
1663
+ // land straight on top of it, which is worse than not snapping.
1664
+ const landed = (await windowBoundsByPid(pid)) ?? current;
1665
+ target = panelBeside(landed, screen, ctx.panelWidth, ctx.side === 'left' ? 'right' : 'left');
1666
+ } else if (current) {
1667
+ // Same flip, same reason: `panelBeside`'s side names which end of the pair the
1668
+ // APP is. Passing the panel's own side through here put the panel on the wrong
1669
+ // side of the app for every run that ever snapped.
1670
+ target = panelBeside(current, screen, ctx.panelWidth, ctx.side === 'left' ? 'right' : 'left');
1671
+ } else {
1672
+ // Nothing to sit beside. With no app, `planPlacement` puts the panel against the
1673
+ // edge it is given, so this one is NOT flipped.
1674
+ target = planPlacement({ screen, appSize: null, panelWidth: ctx.panelWidth, side: ctx.side, gap: GAP }).panel;
1675
+ }
1559
1676
  const bounds = ctx.askedHeight ? { ...target, height: Math.min(ctx.askedHeight, screen.height) } : target;
1560
1677
 
1561
1678
  if (await moveWindow(ctx.page, bounds)) {
@@ -1667,5 +1784,6 @@ function makePanel(ctx) {
1667
1784
  snapTo,
1668
1785
  placedByHand: () => byHand,
1669
1786
  health: () => ({ alive: !dead, pushed, delivered, dropped, stalls, queued: queue.length }),
1787
+ browser: ctx.browser,
1670
1788
  };
1671
1789
  }