things-api 0.19.1 → 0.19.3
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/README.md +1 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/dist/cli/commands/doctor.js +28 -8
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/ui-state.d.ts +11 -0
- package/dist/cli/commands/ui-state.js +27 -0
- package/dist/cli/commands/ui-state.js.map +1 -0
- package/dist/cli/help.js +2 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +2 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/contracts.d.ts +2 -2
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/osa.d.ts +42 -2
- package/dist/deputy/osa.js +79 -5
- package/dist/deputy/osa.js.map +1 -1
- package/dist/deputy/routing.d.ts +18 -0
- package/dist/deputy/routing.js +25 -0
- package/dist/deputy/routing.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ui-state.d.ts +46 -0
- package/dist/ui-state.js +107 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/write/accessibility-probe.js +4 -1
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.js +7 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/failure-hints.d.ts +7 -1
- package/dist/write/failure-hints.js +24 -0
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/field-limits.d.ts +100 -0
- package/dist/write/field-limits.js +144 -0
- package/dist/write/field-limits.js.map +1 -0
- package/dist/write/param-schema.d.ts +17 -0
- package/dist/write/param-schema.js +64 -4
- package/dist/write/param-schema.js.map +1 -1
- package/dist/write/pipeline.js +30 -0
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/promote-clone.d.ts +26 -0
- package/dist/write/promote-clone.js +201 -23
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/vectors/types.d.ts +25 -3
- package/dist/write/vectors/ui-state.d.ts +245 -0
- package/dist/write/vectors/ui-state.js +452 -0
- package/dist/write/vectors/ui-state.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +136 -4
- package/dist/write/vectors/ui.js +665 -65
- package/dist/write/vectors/ui.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +1 -0
- package/skills/things-cli/SKILL.md +2 -2
package/dist/write/vectors/ui.js
CHANGED
|
@@ -35,6 +35,7 @@ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachabi
|
|
|
35
35
|
import { certificationOf } from "./ui-certification.js";
|
|
36
36
|
import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
|
|
37
37
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
38
|
+
import { AX_DIALOG_SHELL_SNIPPET, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
|
|
38
39
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
39
40
|
const STEP_TIMEOUT_MS = 15_000;
|
|
40
41
|
/**
|
|
@@ -264,6 +265,36 @@ on rfField(c, rowLabel, tol)
|
|
|
264
265
|
return item 1 of hits
|
|
265
266
|
end tell
|
|
266
267
|
end rfField`;
|
|
268
|
+
/**
|
|
269
|
+
* The IN-SCRIPT half of the per-step focus guard (issue #620).
|
|
270
|
+
*
|
|
271
|
+
* A synthetic keystroke is not addressed at an element — System Events hands it
|
|
272
|
+
* to whatever application owns the screen at that instant. So every script that
|
|
273
|
+
* types re-asserts, in the same osascript hop that will do the typing, that
|
|
274
|
+
* Things is still frontmost; the drive-level census (see {@link guardedRun})
|
|
275
|
+
* runs a moment earlier and cannot close the last few milliseconds. The
|
|
276
|
+
* assertion FAILS CLOSED and names the application that owns the screen
|
|
277
|
+
* instead — never the contents of its window.
|
|
278
|
+
*
|
|
279
|
+
* This is the cheapest possible check: one System Events property read, no
|
|
280
|
+
* sleeps, no polling (UI-automation determinism doctrine).
|
|
281
|
+
*/
|
|
282
|
+
export const AX_FOCUS_GUARD_HANDLERS = `on fgFrontApp()
|
|
283
|
+
set frontName to ""
|
|
284
|
+
try
|
|
285
|
+
tell application "System Events" to set frontName to (name of first application process whose frontmost is true) as text
|
|
286
|
+
end try
|
|
287
|
+
return frontName
|
|
288
|
+
end fgFrontApp
|
|
289
|
+
|
|
290
|
+
on fgAssertFront(what)
|
|
291
|
+
set f to my fgFrontApp()
|
|
292
|
+
if f is "${THINGS_PROCESS}" then return true
|
|
293
|
+
if f is "" then
|
|
294
|
+
error "refused to " & what & ": the frontmost application could not be read, so there is no proof the keystrokes would reach Things — nothing was typed"
|
|
295
|
+
end if
|
|
296
|
+
error "refused to " & what & ": " & f & " is frontmost, not Things — a keystroke goes to whatever owns the screen, so nothing was typed"
|
|
297
|
+
end fgAssertFront`;
|
|
267
298
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
268
299
|
export function axResolveScript(path) {
|
|
269
300
|
return `${SE} to return (exists (${path}))`;
|
|
@@ -308,15 +339,30 @@ export function axPressScript(path) {
|
|
|
308
339
|
* so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
|
|
309
340
|
* a transport failure the pipeline re-verifies) if it never holds — the
|
|
310
341
|
* create/reschedule delta's rule assertion is the final DB-level authority.
|
|
342
|
+
*
|
|
343
|
+
* READ-BACK FIRST (issue #620 item 7): a field that ALREADY holds the requested
|
|
344
|
+
* value is left alone and the script returns {@link OK_ALREADY} — the whole
|
|
345
|
+
* keystroke class disappears for the defaults, which is most drives (the field
|
|
346
|
+
* incident died typing interval `1` into a field already showing `1`). The skip
|
|
347
|
+
* is proven by TWO reads a settle apart, because the one way a matching value
|
|
348
|
+
* can go stale is the UIC7 re-layout revert, which lands within that window; and
|
|
349
|
+
* whatever this decides, the pre-commit audit ({@link axAuditDialogScript})
|
|
350
|
+
* re-reads every control through its own address before the OK press, so a
|
|
351
|
+
* wrongly-skipped field cannot commit.
|
|
311
352
|
*/
|
|
312
353
|
export function axSetValueScript(path, value, attempts = 3) {
|
|
313
354
|
const v = escapeAppleScript(value);
|
|
314
355
|
const n = Math.max(1, Math.trunc(attempts));
|
|
315
|
-
return `${
|
|
356
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
357
|
+
|
|
358
|
+
${SE}
|
|
316
359
|
set tf to (${path})
|
|
360
|
+
${alreadyHoldsBlock("tf", v)}
|
|
317
361
|
repeat ${n} times
|
|
362
|
+
my fgAssertFront("type \\"${v}\\" into the field")
|
|
318
363
|
set focused of tf to true
|
|
319
364
|
delay 0.15
|
|
365
|
+
${focusedAssertBlock("tf", v)}
|
|
320
366
|
keystroke "${v}"
|
|
321
367
|
delay 0.1
|
|
322
368
|
key code 48
|
|
@@ -329,6 +375,44 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
329
375
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
330
376
|
end tell`;
|
|
331
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* What a typing primitive returns when it typed NOTHING because the field
|
|
380
|
+
* already held the requested value (issue #620 item 7). Distinct from "OK" so
|
|
381
|
+
* the drive can disclose the skip — and so a lab cell can assert that no
|
|
382
|
+
* keystroke hop fired.
|
|
383
|
+
*/
|
|
384
|
+
export const OK_ALREADY = "OK-ALREADY";
|
|
385
|
+
/**
|
|
386
|
+
* The read-back-first skip: two reads a settle apart, no keystroke either way.
|
|
387
|
+
* Shared verbatim by all three typing primitives so the law is one shape.
|
|
388
|
+
*/
|
|
389
|
+
function alreadyHoldsBlock(ref, escapedValue) {
|
|
390
|
+
return ` set v0 to ""
|
|
391
|
+
try
|
|
392
|
+
set v0 to ((value of ${ref}) as text)
|
|
393
|
+
end try
|
|
394
|
+
if v0 is "${escapedValue}" then
|
|
395
|
+
delay 0.3
|
|
396
|
+
set v1 to ""
|
|
397
|
+
try
|
|
398
|
+
set v1 to ((value of ${ref}) as text)
|
|
399
|
+
end try
|
|
400
|
+
if v1 is "${escapedValue}" then return "${OK_ALREADY}"
|
|
401
|
+
end if`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The element half of the focus guard: after asking for focus, PROVE the field
|
|
405
|
+
* took it before typing. A field that will not accept focus (the dialog is
|
|
406
|
+
* rebuilding, another sheet stole it) would otherwise receive the keystrokes
|
|
407
|
+
* somewhere else entirely.
|
|
408
|
+
*/
|
|
409
|
+
function focusedAssertBlock(ref, escapedValue) {
|
|
410
|
+
return ` set gotFocus to false
|
|
411
|
+
try
|
|
412
|
+
set gotFocus to (focused of ${ref}) as boolean
|
|
413
|
+
end try
|
|
414
|
+
if not gotFocus then error "refused to type \\"${escapedValue}\\": the field did not take keyboard focus, so the keystrokes would have gone somewhere else"`;
|
|
415
|
+
}
|
|
332
416
|
/**
|
|
333
417
|
* set-group-number: drive ONE of the Repeat dialog's two numeric fields —
|
|
334
418
|
* the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
|
|
@@ -370,13 +454,18 @@ export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, r
|
|
|
370
454
|
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
371
455
|
return `${AX_CADENCE_HANDLERS}
|
|
372
456
|
|
|
457
|
+
${AX_FOCUS_GUARD_HANDLERS}
|
|
458
|
+
|
|
373
459
|
${SE}
|
|
374
460
|
set g to (${groupPath})
|
|
375
461
|
my cgSettle(g)
|
|
376
462
|
set tf to my cgField(g, "${target}", ${tol})
|
|
463
|
+
${alreadyHoldsBlock("tf", v)}
|
|
377
464
|
repeat ${n} times
|
|
465
|
+
my fgAssertFront("type \\"${v}\\" into the ${target} field")
|
|
378
466
|
set focused of tf to true
|
|
379
467
|
delay 0.15
|
|
468
|
+
${focusedAssertBlock("tf", v)}
|
|
380
469
|
keystroke "${v}"
|
|
381
470
|
delay 0.1
|
|
382
471
|
key code 48
|
|
@@ -417,12 +506,17 @@ export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3
|
|
|
417
506
|
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
418
507
|
return `${AX_CADENCE_HANDLERS}
|
|
419
508
|
|
|
509
|
+
${AX_FOCUS_GUARD_HANDLERS}
|
|
510
|
+
|
|
420
511
|
${SE}
|
|
421
512
|
set c to (${containerPath})
|
|
422
513
|
set tf to my rfField(c, "${label}", ${tol})
|
|
514
|
+
${alreadyHoldsBlock("tf", v)}
|
|
423
515
|
repeat ${n} times
|
|
516
|
+
my fgAssertFront("type \\"${v}\\" into the \\"${label}\\" field")
|
|
424
517
|
set focused of tf to true
|
|
425
518
|
delay 0.15
|
|
519
|
+
${focusedAssertBlock("tf", v)}
|
|
426
520
|
keystroke "${v}"
|
|
427
521
|
delay 0.1
|
|
428
522
|
key code 48
|
|
@@ -568,11 +662,45 @@ on aqPad2(n)
|
|
|
568
662
|
return s
|
|
569
663
|
end aqPad2
|
|
570
664
|
|
|
665
|
+
on aqStamp(d)
|
|
666
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
667
|
+
end aqStamp
|
|
668
|
+
|
|
669
|
+
on aqRelative(s)
|
|
670
|
+
-- The first-occurrence control renders NEAR dates RELATIVELY — "Today" for the
|
|
671
|
+
-- current day — and a relative word can never be string-compared against a
|
|
672
|
+
-- typed ISO date (#625: make-repeating --when <today> refused its own correct
|
|
673
|
+
-- write, every time, because the audit compared "2026-07-05" against "Today").
|
|
674
|
+
-- Resolve the word against the app's own clock, the same way the selector
|
|
675
|
+
-- already does, rather than rebuilding the app's display string.
|
|
676
|
+
set rightNow to current date
|
|
677
|
+
if s is "Today" then return my aqStamp(rightNow)
|
|
678
|
+
if s is "Tomorrow" then return my aqStamp(rightNow + 86400)
|
|
679
|
+
if s is "Yesterday" then return my aqStamp(rightNow - 86400)
|
|
680
|
+
-- A weekday-only rendering names a day inside the coming week; anything
|
|
681
|
+
-- further out is rendered as a date, so the search is bounded at 7 days and a
|
|
682
|
+
-- word that resolves to nothing falls through to the date parse (and, failing
|
|
683
|
+
-- that, to a fail-closed mismatch — never a guess).
|
|
684
|
+
set wdNames to {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
|
685
|
+
repeat with i from 1 to 7
|
|
686
|
+
if s is (item i of wdNames) then
|
|
687
|
+
repeat with k from 1 to 7
|
|
688
|
+
set cand to rightNow + (k * 86400)
|
|
689
|
+
if ((weekday of cand) as text) is (item i of wdNames) then return my aqStamp(cand)
|
|
690
|
+
end repeat
|
|
691
|
+
end if
|
|
692
|
+
end repeat
|
|
693
|
+
return missing value
|
|
694
|
+
end aqRelative
|
|
695
|
+
|
|
571
696
|
on aqYMD(t)
|
|
572
|
-
-- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026"),
|
|
573
|
-
--
|
|
574
|
-
-- by rebuilding the app's
|
|
697
|
+
-- Occurrence-pop-up titles are LOCALIZED ("Sun, Jul 12, 2026") and, for near
|
|
698
|
+
-- dates, RELATIVE ("Today") — so the match is made by RESOLVING the title to a
|
|
699
|
+
-- calendar date and comparing components, never by rebuilding the app's
|
|
700
|
+
-- display string (the axSelectNextOccurrenceScript law).
|
|
575
701
|
set s to t as text
|
|
702
|
+
set rel to my aqRelative(s)
|
|
703
|
+
if rel is not missing value then return rel
|
|
576
704
|
try
|
|
577
705
|
set d to date s
|
|
578
706
|
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
@@ -998,7 +1126,10 @@ ${SE}
|
|
|
998
1126
|
delay 0.3
|
|
999
1127
|
end repeat
|
|
1000
1128
|
if not (exists menu item wantVal of menu 1 of pu) then
|
|
1001
|
-
|
|
1129
|
+
-- No Escape here (issue #620): a keystroke reaches whatever owns the
|
|
1130
|
+
-- screen, and this error path is exactly when that is least certain.
|
|
1131
|
+
-- The open menu is left for the driver's audited cleanup, which is the
|
|
1132
|
+
-- ONE place an Escape is decided.
|
|
1002
1133
|
error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
|
|
1003
1134
|
end if
|
|
1004
1135
|
click menu item wantVal of menu 1 of pu
|
|
@@ -1151,16 +1282,28 @@ return "OK"`;
|
|
|
1151
1282
|
export function axActivateScript() {
|
|
1152
1283
|
return `tell application "Things3" to activate`;
|
|
1153
1284
|
}
|
|
1154
|
-
/**
|
|
1285
|
+
/**
|
|
1286
|
+
* key: a space-separated keystroke spec (e.g. "down down return").
|
|
1287
|
+
*
|
|
1288
|
+
* Frontmost-guarded in-script (issue #620): `key code`/`keystroke` reach
|
|
1289
|
+
* whatever application owns the screen, so the script refuses — naming that
|
|
1290
|
+
* application — rather than firing keys into someone else's window.
|
|
1291
|
+
*/
|
|
1155
1292
|
export function axKeyScript(keys) {
|
|
1156
1293
|
const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
|
|
1294
|
+
const spec = keys.trim();
|
|
1157
1295
|
const lines = keys
|
|
1158
1296
|
.split(/\s+/)
|
|
1159
1297
|
.filter((k) => k !== "")
|
|
1160
1298
|
.map((k) => KEY_CODES[k] !== undefined
|
|
1161
1299
|
? `key code ${KEY_CODES[k]}`
|
|
1162
1300
|
: `keystroke "${escapeAppleScript(k)}"`);
|
|
1163
|
-
return
|
|
1301
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
1302
|
+
|
|
1303
|
+
my fgAssertFront("send the keystrokes \\"${escapeAppleScript(spec)}\\"")
|
|
1304
|
+
tell application "System Events" to tell process "Things3"
|
|
1305
|
+
${lines.join("\n ")}
|
|
1306
|
+
end tell`;
|
|
1164
1307
|
}
|
|
1165
1308
|
/**
|
|
1166
1309
|
* type-text: send literal text to whatever control holds focus (HXPC1). The
|
|
@@ -1176,7 +1319,10 @@ export function axKeyScript(keys) {
|
|
|
1176
1319
|
* be committed. One stable command shape.
|
|
1177
1320
|
*/
|
|
1178
1321
|
export function axTypeTextScript(text) {
|
|
1179
|
-
return `${
|
|
1322
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
1323
|
+
|
|
1324
|
+
my fgAssertFront("type into the focused field")
|
|
1325
|
+
${SE}
|
|
1180
1326
|
keystroke "${escapeAppleScript(text)}"
|
|
1181
1327
|
end tell`;
|
|
1182
1328
|
}
|
|
@@ -1297,9 +1443,64 @@ export function axPickerRowFrameScript(pickerPath, title) {
|
|
|
1297
1443
|
return ((item 1 of rp) as text) & " " & ((item 2 of rp) as text) & " " & ((item 1 of rs) as text) & " " & ((item 2 of rs) as text)
|
|
1298
1444
|
end tell`;
|
|
1299
1445
|
}
|
|
1300
|
-
/**
|
|
1446
|
+
/**
|
|
1447
|
+
* The abort keystroke, sent ONLY from the audited cleanup ladder (issue #620)
|
|
1448
|
+
* and only once that ladder has proven Things owns the screen. It is scoped to
|
|
1449
|
+
* the Things process for readability, but scoping is not what makes it safe —
|
|
1450
|
+
* a synthetic key goes to whatever is frontmost, which is why the script
|
|
1451
|
+
* carries the same in-script frontmost assertion every other keystroke does.
|
|
1452
|
+
*/
|
|
1301
1453
|
export function axAbortScript() {
|
|
1302
|
-
return
|
|
1454
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
1455
|
+
|
|
1456
|
+
my fgAssertFront("dismiss the open dialog with Escape")
|
|
1457
|
+
${SE}
|
|
1458
|
+
key code 53
|
|
1459
|
+
end tell`;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Dismiss the open dialog by PRESSING ITS OWN CANCEL BUTTON (issue #620).
|
|
1463
|
+
*
|
|
1464
|
+
* Preferred over Escape wherever it works, for two independent reasons: an
|
|
1465
|
+
* AXPress is addressed at an ELEMENT, so it cannot leak into another
|
|
1466
|
+
* application the way a keystroke can, and it works while Things is in the
|
|
1467
|
+
* BACKGROUND — the cleanup never has to steal the user's focus to undo its own
|
|
1468
|
+
* half-finished dialog. The button is addressed by its pinned English title,
|
|
1469
|
+
* exactly like every other selector in this vector, and the dialog shell is
|
|
1470
|
+
* resolved the same two ways the census resolves it (attached sheet, or the
|
|
1471
|
+
* detached editor window Things presents when it is not frontmost).
|
|
1472
|
+
*
|
|
1473
|
+
* Returns "OK" after pressing, or a diagnostic ("NO-DIALOG" / "NO-CANCEL") the
|
|
1474
|
+
* ladder falls through on — it never claims a dismissal; the caller re-reads
|
|
1475
|
+
* the census to decide that.
|
|
1476
|
+
*/
|
|
1477
|
+
export function axCancelDialogScript() {
|
|
1478
|
+
return `${SE}
|
|
1479
|
+
${AX_DIALOG_SHELL_SNIPPET}
|
|
1480
|
+
if shellRef is missing value then return "NO-DIALOG"
|
|
1481
|
+
if not (exists button "Cancel" of shellRef) then return "NO-CANCEL"
|
|
1482
|
+
click button "Cancel" of shellRef
|
|
1483
|
+
return "OK"
|
|
1484
|
+
end tell`;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The Cancel button's on-screen FRAME, resolved through the same addressed
|
|
1488
|
+
* dialog-shell path the AXPress dismissal uses (issue #629). Feeds the pointer
|
|
1489
|
+
* fallback: if `AXPress` on the button reports success and the dialog is still
|
|
1490
|
+
* standing, a real click at the button's own AX-resolved centre is the next
|
|
1491
|
+
* thing to try before discarding the window wholesale. The frame comes from the
|
|
1492
|
+
* tree, never from a remembered coordinate, so a moved dialog fails closed.
|
|
1493
|
+
*/
|
|
1494
|
+
export function axCancelFrameScript() {
|
|
1495
|
+
return `${SE}
|
|
1496
|
+
${AX_DIALOG_SHELL_SNIPPET}
|
|
1497
|
+
if shellRef is missing value then error "no dialog is open"
|
|
1498
|
+
if not (exists button "Cancel" of shellRef) then error "the open dialog has no Cancel button"
|
|
1499
|
+
set _b to button "Cancel" of shellRef
|
|
1500
|
+
set _p to position of _b
|
|
1501
|
+
set _s to size of _b
|
|
1502
|
+
return ((item 1 of _p) as text) & " " & ((item 2 of _p) as text) & " " & ((item 1 of _s) as text) & " " & ((item 2 of _s) as text)
|
|
1503
|
+
end tell`;
|
|
1303
1504
|
}
|
|
1304
1505
|
/**
|
|
1305
1506
|
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
@@ -1359,36 +1560,233 @@ async function sheetStillOpen(run) {
|
|
|
1359
1560
|
return !(res.ok && res.stdout.trim() === "false");
|
|
1360
1561
|
}
|
|
1361
1562
|
/**
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
|
|
1563
|
+
* Is the dialog the census found OURS to dismiss? `expected` is the kind this
|
|
1564
|
+
* drive was observed driving (latched from the census the drive itself ran). A
|
|
1565
|
+
* kind that does not match is left strictly alone: between our failure and this
|
|
1566
|
+
* cleanup, the person at the keyboard may have opened something of their own,
|
|
1567
|
+
* and dismissing it would be a mutation nobody asked for.
|
|
1568
|
+
*
|
|
1569
|
+
* With nothing latched (a drive that failed before any dialog was observed) the
|
|
1570
|
+
* two kinds this vector's recipes actually open are still treated as ours —
|
|
1571
|
+
* they are the dialogs our own steps would have opened — while an unrecognized
|
|
1572
|
+
* modal never is.
|
|
1573
|
+
*/
|
|
1574
|
+
function oursToDismiss(kind, expected) {
|
|
1575
|
+
if (kind === "none")
|
|
1576
|
+
return false;
|
|
1577
|
+
if (expected !== null)
|
|
1578
|
+
return kind === expected;
|
|
1579
|
+
return kind === "repeat" || kind === "move-picker";
|
|
1580
|
+
}
|
|
1581
|
+
/** How many stacked dialogs the cleanup will unwind before falling to the next rung. */
|
|
1582
|
+
const MAX_DISMISS_ROUNDS = 4;
|
|
1583
|
+
/** Press the dialog's own Cancel button (element-addressed, background-safe). */
|
|
1584
|
+
async function pressCancel(run) {
|
|
1585
|
+
const res = await run({
|
|
1586
|
+
primitive: "dismiss-dialog",
|
|
1587
|
+
label: "dismiss the open dialog (its Cancel button)",
|
|
1588
|
+
script: axCancelDialogScript(),
|
|
1589
|
+
}, STEP_TIMEOUT_MS);
|
|
1590
|
+
return res.ok && res.stdout.trim() === "OK";
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* The pointer fallback for the Cancel rung (issue #629): a real click at the
|
|
1594
|
+
* button's own AX-resolved centre, for the case where `AXPress` reports success
|
|
1595
|
+
* and the dialog is demonstrably still standing. Needs Things frontmost — the
|
|
1596
|
+
* HID tap posts at the foreground surface (NATIVE1-e) — so it activates first,
|
|
1597
|
+
* and re-reads the frame AFTERWARDS, because bringing Things forward can
|
|
1598
|
+
* re-attach a detached editor as a sheet and move the button.
|
|
1599
|
+
*/
|
|
1600
|
+
async function clickCancel(run) {
|
|
1601
|
+
await run({
|
|
1602
|
+
primitive: "activate",
|
|
1603
|
+
label: "bring Things forward to click its dialog's Cancel button",
|
|
1604
|
+
script: axActivateScript(),
|
|
1605
|
+
}, STEP_TIMEOUT_MS);
|
|
1606
|
+
const frameRes = await run({
|
|
1607
|
+
primitive: "resolve-frame",
|
|
1608
|
+
label: "locate the open dialog's Cancel button",
|
|
1609
|
+
script: axCancelFrameScript(),
|
|
1610
|
+
}, STEP_TIMEOUT_MS);
|
|
1611
|
+
if (!frameRes.ok)
|
|
1612
|
+
return false;
|
|
1613
|
+
const center = parseFrameCenter(frameRes.stdout);
|
|
1614
|
+
if (center === null)
|
|
1615
|
+
return false;
|
|
1616
|
+
const res = await run(clickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
|
|
1617
|
+
return res.ok;
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* The dismissal that needs NO working inspection (issue #629): press the
|
|
1621
|
+
* dialog's own Cancel button, PROVE the dialog is gone with one addressed
|
|
1622
|
+
* existence read, and fall through to a real click at the button's frame if the
|
|
1623
|
+
* press reported success while the dialog stayed up.
|
|
1624
|
+
*
|
|
1625
|
+
* This is the rung the field incident needed and did not have. Its cleanup
|
|
1626
|
+
* re-ran the census that had just stalled, learned nothing three times over,
|
|
1627
|
+
* and ended in the AX-blind close+reopen — which left the sheet standing, and
|
|
1628
|
+
* with it the app-wide scripting freeze that stopped the composite trashing its
|
|
1629
|
+
* own disposable copy (MODALX1 §2.1) and the Things Cloud sync gate.
|
|
1630
|
+
*
|
|
1631
|
+
* Everything here is addressed inside `process "Things3"`: {@link
|
|
1632
|
+
* axCancelDialogScript} and {@link axSheetOpenScript} are the same shape as the
|
|
1633
|
+
* drive steps that kept working while the census did not.
|
|
1634
|
+
*/
|
|
1635
|
+
async function semanticCancel(run, expected, owner) {
|
|
1636
|
+
const kind = expected === null ? {} : { sheetKind: expected };
|
|
1637
|
+
// A stack unwinds LIFO (MODALX1 §6), so press-and-verify in a loop rather
|
|
1638
|
+
// than pressing once and assuming.
|
|
1639
|
+
for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
|
|
1640
|
+
if (!(await pressCancel(run)))
|
|
1641
|
+
break;
|
|
1642
|
+
if (!(await sheetStillOpen(run))) {
|
|
1643
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (await clickCancel(run)) {
|
|
1647
|
+
if (!(await sheetStillOpen(run))) {
|
|
1648
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner, unverified: true };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return closeReopenRung(run, expected, owner, false, true);
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Clear a half-open dialog a failed drive left behind — AUDITED at every rung
|
|
1655
|
+
* (issue #620; supersedes the unconditional Escape, which was measured firing
|
|
1656
|
+
* into a foreign application's modal while the Things sheet it was meant for
|
|
1657
|
+
* stayed open all night).
|
|
1658
|
+
*
|
|
1659
|
+
* The ladder, cheapest and least disruptive first, re-reading the census after
|
|
1660
|
+
* every rung so nothing is ever CLAIMED to be dismissed:
|
|
1661
|
+
*
|
|
1662
|
+
* 0. census. No dialog + a reachable session → nothing to do. A dialog that
|
|
1663
|
+
* is not ours → left alone, reported.
|
|
1664
|
+
* 1. press the dialog's own CANCEL button — element-addressed, so it needs
|
|
1665
|
+
* neither focus nor the frontmost slot, and it cannot leak into another
|
|
1666
|
+
* app. This is the rung that clears the ordinary case.
|
|
1667
|
+
* 2. Escape, but only from a state where Things demonstrably owns the screen:
|
|
1668
|
+
* if it does not, RE-ACTIVATE Things, RE-AUDIT, and only then send it.
|
|
1669
|
+
* 3. the app-level close+reopen (SESSGATE) — the maneuver that works with no
|
|
1670
|
+
* Accessibility tree at all, and the documented recovery for the app-wide
|
|
1671
|
+
* AppleScript freeze a stuck sheet imposes (oddities §9cc), which is what
|
|
1672
|
+
* makes a caller's follow-up cleanup mutations land again.
|
|
1673
|
+
*
|
|
1674
|
+
* `expected` is the dialog kind this drive was observed driving; see
|
|
1675
|
+
* {@link oursToDismiss}.
|
|
1373
1676
|
*/
|
|
1374
|
-
async function clearDialog(run) {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
1380
|
-
|
|
1677
|
+
async function clearDialog(run, expected = null, inspectionStalled = false) {
|
|
1678
|
+
// #629: the inspection already refused to answer once. Asking it again buys
|
|
1679
|
+
// nothing and costs the caller another deadline — go straight to the rung
|
|
1680
|
+
// that needs no inspection and proves itself with one addressed read.
|
|
1681
|
+
if (inspectionStalled)
|
|
1682
|
+
return semanticCancel(run, expected, {});
|
|
1683
|
+
const census = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1684
|
+
if (censusUnverifiable(census))
|
|
1685
|
+
return semanticCancel(run, expected, {});
|
|
1686
|
+
const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
|
|
1687
|
+
const readable = census !== null && census.inspectable;
|
|
1688
|
+
// 0. A clean, readable "no dialog" — but only trustworthy on a session whose
|
|
1689
|
+
// windows are AX-visible at all: a locked screen / full-screen Space
|
|
1690
|
+
// enumerates ZERO windows, so the census would report "no dialog" for a
|
|
1691
|
+
// sheet that is very much open (SESSGATE). Confirm before believing it.
|
|
1692
|
+
if (readable && census.sheetOpen === false) {
|
|
1693
|
+
const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
1694
|
+
if (reach.reachable)
|
|
1695
|
+
return { state: "none" };
|
|
1696
|
+
// AX-blind: System Events enumerates zero windows for EVERY app, so the
|
|
1697
|
+
// census cannot see a sheet that is open — and equally cannot confirm one
|
|
1698
|
+
// is gone. Run the blind-proof maneuver and report it as unconfirmed.
|
|
1699
|
+
return closeReopenRung(run, expected, owner, true);
|
|
1700
|
+
}
|
|
1701
|
+
// 0b. A dialog someone else opened — never touched.
|
|
1702
|
+
if (readable && !oursToDismiss(census.sheetKind, expected)) {
|
|
1703
|
+
return { state: "foreign", sheetKind: census.sheetKind, ...owner };
|
|
1704
|
+
}
|
|
1705
|
+
const kind = readable ? { sheetKind: census.sheetKind } : {};
|
|
1706
|
+
// 1. Its own Cancel button — repeated while a STACK unwinds, because dialogs
|
|
1707
|
+
// nest and dismiss strictly LIFO (MODALX1 §6), re-reading between presses
|
|
1708
|
+
// so a dialog that is not ours stops the loop rather than being clicked.
|
|
1709
|
+
if (readable) {
|
|
1710
|
+
for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
|
|
1711
|
+
if (!(await pressCancel(run)))
|
|
1712
|
+
break;
|
|
1713
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1714
|
+
if (after === null || !after.inspectable)
|
|
1715
|
+
break;
|
|
1716
|
+
if (!after.sheetOpen)
|
|
1717
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
|
|
1718
|
+
if (!oursToDismiss(after.sheetKind, expected)) {
|
|
1719
|
+
return { state: "foreign", sheetKind: after.sheetKind, ...owner };
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
// 2. Escape — from a state where Things owns the screen, re-activating and
|
|
1724
|
+
// RE-AUDITING first when it does not (never a blind key into the unknown).
|
|
1725
|
+
let front = readable && census.thingsFrontmost;
|
|
1726
|
+
if (readable && !front) {
|
|
1381
1727
|
await run({
|
|
1382
|
-
primitive: "
|
|
1383
|
-
label: "
|
|
1384
|
-
script:
|
|
1728
|
+
primitive: "activate",
|
|
1729
|
+
label: "bring Things forward to dismiss its dialog",
|
|
1730
|
+
script: axActivateScript(),
|
|
1385
1731
|
}, STEP_TIMEOUT_MS);
|
|
1386
|
-
|
|
1732
|
+
const reaudit = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1733
|
+
if (reaudit !== null && reaudit.inspectable) {
|
|
1734
|
+
if (!reaudit.sheetOpen)
|
|
1735
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
|
|
1736
|
+
if (!oursToDismiss(reaudit.sheetKind, expected)) {
|
|
1737
|
+
return { state: "foreign", sheetKind: reaudit.sheetKind, ...owner };
|
|
1738
|
+
}
|
|
1739
|
+
front = reaudit.thingsFrontmost;
|
|
1740
|
+
}
|
|
1387
1741
|
}
|
|
1388
|
-
if (
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1742
|
+
if (front) {
|
|
1743
|
+
await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
|
|
1744
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1745
|
+
if (after !== null && after.inspectable && !after.sheetOpen) {
|
|
1746
|
+
return { state: "dismissed", how: "escape", ...kind, ...owner };
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
// 3. The blind-proof maneuver, last: it discards the half-entered dialog with
|
|
1750
|
+
// the window, and unwedges the app-wide AppleScript freeze with it.
|
|
1751
|
+
return closeReopenRung(run, expected, owner);
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* The final rung: close+reopen the Things window, then re-audit if we can.
|
|
1755
|
+
* `blind` says the session itself is AX-blind, in which case NOTHING the census
|
|
1756
|
+
* reports afterwards is evidence — the outcome is honestly unconfirmed.
|
|
1757
|
+
*/
|
|
1758
|
+
async function closeReopenRung(run, expected, owner, blind = false,
|
|
1759
|
+
/**
|
|
1760
|
+
* #629: the window-state inspection is not answering, so the outcome is
|
|
1761
|
+
* decided by the ADDRESSED sheet-open read instead of a fresh census. The
|
|
1762
|
+
* verdict is still proven — just proven by a narrower question.
|
|
1763
|
+
*/
|
|
1764
|
+
inspectionStalled = false) {
|
|
1765
|
+
await run({
|
|
1766
|
+
primitive: "resolve",
|
|
1767
|
+
label: "clear a stuck dialog (close the Things window and reopen it)",
|
|
1768
|
+
script: axCloseReopenActivateScript(),
|
|
1769
|
+
}, STEP_TIMEOUT_MS);
|
|
1770
|
+
if (blind)
|
|
1771
|
+
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1772
|
+
if (inspectionStalled) {
|
|
1773
|
+
const kind = expected === null ? {} : { sheetKind: expected };
|
|
1774
|
+
if (!(await sheetStillOpen(run))) {
|
|
1775
|
+
return { state: "dismissed", how: "window-close", ...kind, ...owner, unverified: true };
|
|
1776
|
+
}
|
|
1777
|
+
return { state: "may-remain", ...kind, ...owner, unverified: true };
|
|
1778
|
+
}
|
|
1779
|
+
const after = await readUiState(run, CENSUS_TIMEOUT_MS);
|
|
1780
|
+
if (after === null || !after.inspectable) {
|
|
1781
|
+
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1782
|
+
}
|
|
1783
|
+
if (!after.sheetOpen)
|
|
1784
|
+
return { state: "dismissed", how: "window-close", ...owner };
|
|
1785
|
+
return {
|
|
1786
|
+
state: oursToDismiss(after.sheetKind, expected) ? "may-remain" : "foreign",
|
|
1787
|
+
sheetKind: after.sheetKind,
|
|
1788
|
+
...owner,
|
|
1789
|
+
};
|
|
1392
1790
|
}
|
|
1393
1791
|
/**
|
|
1394
1792
|
* The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
|
|
@@ -1660,6 +2058,15 @@ async function defaultRun(command, timeoutMs) {
|
|
|
1660
2058
|
...(res.timedOut === true && { timedOut: true }),
|
|
1661
2059
|
};
|
|
1662
2060
|
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Read the live window/focus census through the shipped dispatch seam — the
|
|
2063
|
+
* transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
|
|
2064
|
+
* behind any injected runner. READ-ONLY: the census clicks nothing, types
|
|
2065
|
+
* nothing, and changes no state; see src/write/vectors/ui-state.ts.
|
|
2066
|
+
*/
|
|
2067
|
+
export function readLiveUiState(run = defaultRun) {
|
|
2068
|
+
return readUiState(run, CENSUS_TIMEOUT_MS);
|
|
2069
|
+
}
|
|
1663
2070
|
/**
|
|
1664
2071
|
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
1665
2072
|
* step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
|
|
@@ -1694,6 +2101,176 @@ function tracingRun(inner) {
|
|
|
1694
2101
|
return res;
|
|
1695
2102
|
};
|
|
1696
2103
|
}
|
|
2104
|
+
/**
|
|
2105
|
+
* PRIMITIVE CLASSIFICATION for the per-step guard (issue #620). What decides a
|
|
2106
|
+
* primitive's class is HOW macOS routes its effect, not what it looks like:
|
|
2107
|
+
*
|
|
2108
|
+
* - KEYSTROKE-CLASS — System Events `keystroke` / `key code`. The event is
|
|
2109
|
+
* handed to whatever application owns the screen, so these need Things
|
|
2110
|
+
* frontmost AND the dialog we opened still in front. The element half of
|
|
2111
|
+
* the guard (did the field actually take focus?) is asserted in-script, in
|
|
2112
|
+
* the same hop as the typing.
|
|
2113
|
+
* - POINTER-CLASS — mouse synthesis through the global HID event tap
|
|
2114
|
+
* (`CGEventPost(kCGHIDEventTap)`). It posts at the FOREGROUND surface
|
|
2115
|
+
* (NATIVE1-e: `CGEventPostToPid` is inert for Things' hit-testing), so a
|
|
2116
|
+
* click while another app is frontmost lands in that app's window. Frontmost
|
|
2117
|
+
* is required; focus is not (a click sets its own).
|
|
2118
|
+
* - Everything else is ELEMENT-ADDRESSED — `click <element>`, `set value`,
|
|
2119
|
+
* `set focused`, the ObjC `AXUIElementSetAttributeValue` date writes, and
|
|
2120
|
+
* every read. System Events delivers those to the element named, whether or
|
|
2121
|
+
* not the app is frontmost, so guarding them would only add a hop and
|
|
2122
|
+
* forbid perfectly good background work.
|
|
2123
|
+
* - `chord-post` is deliberately NOT guarded: it posts its key event with
|
|
2124
|
+
* `CGEventPostToPid`, which addresses the PROCESS rather than the focused
|
|
2125
|
+
* surface — the whole point of the heading-reorder gesture is that it runs
|
|
2126
|
+
* with Things in the background and the user's focus untouched (HEADORD1
|
|
2127
|
+
* 1h2a, CHORDMH1). A frontmost guard there would break a certified op.
|
|
2128
|
+
*/
|
|
2129
|
+
const KEYSTROKE_CLASS = new Set([
|
|
2130
|
+
"key",
|
|
2131
|
+
"type-text",
|
|
2132
|
+
"set-value",
|
|
2133
|
+
"set-group-number",
|
|
2134
|
+
"set-row-field",
|
|
2135
|
+
]);
|
|
2136
|
+
const POINTER_CLASS = new Set([
|
|
2137
|
+
"click-point",
|
|
2138
|
+
"sidebar-drag",
|
|
2139
|
+
"sidebar-held-drag",
|
|
2140
|
+
"sidebar-scroll",
|
|
2141
|
+
]);
|
|
2142
|
+
/**
|
|
2143
|
+
* Judge one guard reading. Returns null to proceed, or the refusal sentence —
|
|
2144
|
+
* which always names who owns the screen, because that is the one fact the
|
|
2145
|
+
* person reading it cannot recover after the fact.
|
|
2146
|
+
*
|
|
2147
|
+
* Exported for the unit matrix: every branch here is a fail-closed decision
|
|
2148
|
+
* about synthetic input, and each one is worth a test.
|
|
2149
|
+
*/
|
|
2150
|
+
export function judgeFocusGuard(state, expectedSheet, label) {
|
|
2151
|
+
const refuse = (why) => `refused to run "${label}": ${why}`;
|
|
2152
|
+
if (state === null) {
|
|
2153
|
+
return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
|
|
2154
|
+
"Things — nothing was sent");
|
|
2155
|
+
}
|
|
2156
|
+
// #629: a probe that did not come back is a DIAGNOSTIC, not a state. Say so
|
|
2157
|
+
// in those words, name what could not be established, and route the drive
|
|
2158
|
+
// straight to its cleanup — the caller must not read this as "retry".
|
|
2159
|
+
if (censusUnverifiable(state)) {
|
|
2160
|
+
return refuse(`the window state inspection timed out — treating the dialog as unverifiable (${describeUnprovenProbes(state)}). Nothing was sent, and the dialog this command opened is being closed. Check that Things ` +
|
|
2161
|
+
"is responding, then run the same command again");
|
|
2162
|
+
}
|
|
2163
|
+
if (!state.inspectable) {
|
|
2164
|
+
return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
|
|
2165
|
+
"sent. Answer or dismiss the system dialog, then run the same command again");
|
|
2166
|
+
}
|
|
2167
|
+
if (!state.thingsFrontmost) {
|
|
2168
|
+
return refuse(`${describeFocusOwner(state)}, so the input would go there instead of to Things — nothing ` +
|
|
2169
|
+
"was sent. Leave Things in front while it is being driven, then run the same command again");
|
|
2170
|
+
}
|
|
2171
|
+
if (expectedSheet !== null && state.sheetKind !== expectedSheet) {
|
|
2172
|
+
return refuse(`the dialog this command opened is no longer the one in front (expected ${expectedSheet}, ` +
|
|
2173
|
+
`found ${state.sheetKind}) — it was closed or replaced while the command was running, so ` +
|
|
2174
|
+
"nothing was sent");
|
|
2175
|
+
}
|
|
2176
|
+
return null;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Wrap the dispatch seam with the PER-STEP FOCUS GUARD (issue #620): before
|
|
2180
|
+
* every focus-routed hop, one cheap read-only census decides whether the input
|
|
2181
|
+
* can legitimately be delivered, and a violation ABORTS THE STEP rather than
|
|
2182
|
+
* typing into the void. Element-addressed hops pass straight through, so the
|
|
2183
|
+
* cost is paid only where it buys something.
|
|
2184
|
+
*
|
|
2185
|
+
* A closed loop, not a sleep: the census is a deterministic read of the live
|
|
2186
|
+
* state, taken immediately before the hop, and the in-script assertions close
|
|
2187
|
+
* the remaining milliseconds (UI-automation determinism doctrine; the #595
|
|
2188
|
+
* pre-commit audit and BEEP1 shape-settle are the same pattern).
|
|
2189
|
+
*/
|
|
2190
|
+
function guardedRun(inner, latch) {
|
|
2191
|
+
return async (command, timeoutMs) => {
|
|
2192
|
+
if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
|
|
2193
|
+
return inner(command, timeoutMs);
|
|
2194
|
+
}
|
|
2195
|
+
const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
|
|
2196
|
+
// An inspection that would not answer poisons every later inspection's
|
|
2197
|
+
// credibility, so the cleanup ladder is told once and never asks again
|
|
2198
|
+
// (issue #629).
|
|
2199
|
+
if (state === null || censusUnverifiable(state))
|
|
2200
|
+
latch.inspectionStalled = true;
|
|
2201
|
+
// The dialog invariant applies to keystroke-class hops only: a pointer hop
|
|
2202
|
+
// is aimed at a frame it resolved a moment ago and fails closed on its own
|
|
2203
|
+
// if that frame moved.
|
|
2204
|
+
const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
|
|
2205
|
+
const guardRefusal = judgeFocusGuard(state, expected, command.label);
|
|
2206
|
+
if (guardRefusal !== null) {
|
|
2207
|
+
trace(() => ({
|
|
2208
|
+
phase: "focus-guard",
|
|
2209
|
+
event: "refused",
|
|
2210
|
+
primitive: command.primitive,
|
|
2211
|
+
label: command.label,
|
|
2212
|
+
frontmost: state?.frontmostApp ?? null,
|
|
2213
|
+
sheetKind: state?.sheetKind ?? null,
|
|
2214
|
+
inspectable: state?.inspectable ?? false,
|
|
2215
|
+
stalled: state?.stalledProbes ?? null,
|
|
2216
|
+
}));
|
|
2217
|
+
return { ok: false, stdout: "", stderr: guardRefusal };
|
|
2218
|
+
}
|
|
2219
|
+
if (state !== null && state.sheetOpen && latch.sheet === null)
|
|
2220
|
+
latch.sheet = state.sheetKind;
|
|
2221
|
+
return inner(command, timeoutMs);
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/** How a dialog is named in a disclosure — behavior, not chrome. */
|
|
2225
|
+
function dialogNoun(kind) {
|
|
2226
|
+
switch (kind) {
|
|
2227
|
+
case "repeat":
|
|
2228
|
+
return "the repeat dialog";
|
|
2229
|
+
case "move-picker":
|
|
2230
|
+
return "the move-to-project chooser";
|
|
2231
|
+
default:
|
|
2232
|
+
return "a dialog";
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* The cleanup disclosure (issue #620). Every branch states what is TRUE of the
|
|
2237
|
+
* app right now, and — whenever a dialog may still be open — that Things Cloud
|
|
2238
|
+
* sync is held until someone dismisses it, which is the consequence a caller
|
|
2239
|
+
* cannot see and would otherwise discover hours later on another device.
|
|
2240
|
+
*/
|
|
2241
|
+
export function describeCleanup(clear) {
|
|
2242
|
+
const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
|
|
2243
|
+
// #629: when the window-state inspection stalled, the cleanup still ran and
|
|
2244
|
+
// still proved its outcome — through the narrower addressed read. Say which
|
|
2245
|
+
// it was, so nobody reads "confirmed closed" as more than it is.
|
|
2246
|
+
const how = clear.unverified === true ? " (the window state could not be inspected)" : "";
|
|
2247
|
+
switch (clear.state) {
|
|
2248
|
+
case "none":
|
|
2249
|
+
return "No dialog was left open in Things.";
|
|
2250
|
+
case "dismissed":
|
|
2251
|
+
return `${clear.how === "cancel-button"
|
|
2252
|
+
? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
|
|
2253
|
+
: clear.how === "escape"
|
|
2254
|
+
? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
|
|
2255
|
+
: `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${how}${owner}.`;
|
|
2256
|
+
case "cleared-blind":
|
|
2257
|
+
return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
2258
|
+
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
2259
|
+
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
2260
|
+
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
2261
|
+
" before retrying.");
|
|
2262
|
+
case "foreign":
|
|
2263
|
+
return (`A dialog is open in Things that this command did not open (${dialogNoun(clear.sheetKind)}),` +
|
|
2264
|
+
` so it was left exactly as it is${owner}. Dismiss it yourself when you are ready — and note` +
|
|
2265
|
+
` that ${SYNC_GATE_WARNING}.`);
|
|
2266
|
+
case "may-remain":
|
|
2267
|
+
return (`WARNING: ${dialogNoun(clear.sheetKind)} may still be open in Things${owner} — neither its` +
|
|
2268
|
+
" Cancel button, nor Escape, nor closing and reopening the window would clear it. Dismiss" +
|
|
2269
|
+
" it in Things (click Cancel, or press Escape with Things in front) before retrying: a" +
|
|
2270
|
+
` leftover dialog also disables the menu bar, so the next attempt would refuse. Also note` +
|
|
2271
|
+
` that ${SYNC_GATE_WARNING}.`);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
1697
2274
|
/** The element paths the preflight canary resolves (static steps only). */
|
|
1698
2275
|
function canaryPaths(recipe) {
|
|
1699
2276
|
const out = [];
|
|
@@ -2088,7 +2665,16 @@ function auditFailureText(res) {
|
|
|
2088
2665
|
return "the pre-commit dialog audit timed out; nothing was committed";
|
|
2089
2666
|
return "the Repeat dialog could not be re-read before committing; nothing was committed";
|
|
2090
2667
|
}
|
|
2091
|
-
async function drive(recipe,
|
|
2668
|
+
async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
2669
|
+
// Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
|
|
2670
|
+
// the latch records the dialog this drive is observed driving, so the cleanup
|
|
2671
|
+
// ladder can tell our own half-open dialog from one the user opened after us.
|
|
2672
|
+
const latch = { sheet: null, inspectionStalled: false };
|
|
2673
|
+
const run = guardedRun(rawRun, latch);
|
|
2674
|
+
// The cleanup ladder audits for itself (it is what decides whether a keystroke
|
|
2675
|
+
// may be sent at all), so it runs OUTSIDE the guard — and it is told when the
|
|
2676
|
+
// inspection has already stalled, so it never re-runs it (issue #629).
|
|
2677
|
+
const clearNow = () => clearDialog(rawRun, latch.sheet, latch.inspectionStalled);
|
|
2092
2678
|
const done = [];
|
|
2093
2679
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
2094
2680
|
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
@@ -2106,7 +2692,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2106
2692
|
// modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
|
|
2107
2693
|
// whose OK press was mid-commit could still land — the pipeline re-verifies
|
|
2108
2694
|
// and shapes the final result accordingly.
|
|
2109
|
-
const clear = await
|
|
2695
|
+
const clear = await clearNow();
|
|
2110
2696
|
trace(() => ({
|
|
2111
2697
|
phase: "watchdog",
|
|
2112
2698
|
budgetMs,
|
|
@@ -2145,19 +2731,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2145
2731
|
*/
|
|
2146
2732
|
stepTimedOut = false) => {
|
|
2147
2733
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
2148
|
-
const cleanup = clear === undefined
|
|
2149
|
-
? ""
|
|
2150
|
-
: clear.state === "dismissed"
|
|
2151
|
-
? " The open sheet/popover was dismissed (Escape, confirmed gone)."
|
|
2152
|
-
: clear.state === "cleared-blind"
|
|
2153
|
-
? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
2154
|
-
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
2155
|
-
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
2156
|
-
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
2157
|
-
" before retrying."
|
|
2158
|
-
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
2159
|
-
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
2160
|
-
" make the next drive's preflight fail).";
|
|
2734
|
+
const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
|
|
2161
2735
|
// #512: name an environment failure as one. A cleanup that had to run BLIND
|
|
2162
2736
|
// is direct evidence the session went AX-blind mid-drive; a step killed by
|
|
2163
2737
|
// its own deadline is the window not answering. Either way the app was not
|
|
@@ -2217,6 +2791,26 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2217
2791
|
"to open the dialog. ";
|
|
2218
2792
|
}
|
|
2219
2793
|
}
|
|
2794
|
+
// 0¾. OPEN-DIALOG PRECONDITION (MODALX1 §3/§4, issue #620). A dialog already
|
|
2795
|
+
// standing when a drive starts is not ours and cannot be driven around:
|
|
2796
|
+
// it disables the menu bar (so a menu recipe's canary would miss and
|
|
2797
|
+
// guess at why), and it SWALLOWS the chord recipes' key events, which
|
|
2798
|
+
// pass their canary happily and then move nothing. The census says so
|
|
2799
|
+
// directly, for every recipe, before anything is pressed — and a dialog
|
|
2800
|
+
// standing here also means the app is ignoring scripted changes app-wide
|
|
2801
|
+
// and holding Things Cloud sync, which is the operator's real problem.
|
|
2802
|
+
const startState = await readUiState(rawRun, CENSUS_TIMEOUT_MS);
|
|
2803
|
+
// An inspection that stalls at the very first hop is remembered, so a later
|
|
2804
|
+
// failure's cleanup does not go asking it again (issue #629). The preflight
|
|
2805
|
+
// itself stays permissive — only a POSITIVE sighting refuses (MODALX1 §7).
|
|
2806
|
+
if (censusUnverifiable(startState))
|
|
2807
|
+
latch.inspectionStalled = true;
|
|
2808
|
+
if (startState !== null && startState.inspectable && startState.sheetOpen) {
|
|
2809
|
+
return refusal(`ui preflight refused: a dialog is already open in Things (${startState.sheetKind}${startState.sheetDepth > 1 ? `, on top of ${startState.sheetDepth - 1} more` : ""}), most likely left over from an earlier command or opened by hand. While one is open the ` +
|
|
2810
|
+
"app disables its menu bar, ignores keyboard input aimed at anything else, and " +
|
|
2811
|
+
`${SYNC_GATE_WARNING}. Dismiss it in Things (click Cancel, or press Escape with Things in ` +
|
|
2812
|
+
"front), then run the same command again. Nothing was pressed.");
|
|
2813
|
+
}
|
|
2220
2814
|
// 1. Recipe canary: resolve every statically-reachable element (now that the
|
|
2221
2815
|
// target is selected). A miss refuses the whole drive before anything is
|
|
2222
2816
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
@@ -2256,7 +2850,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2256
2850
|
// control whose CLASS changed, and only the matching one runs.
|
|
2257
2851
|
if (step.onlyShape !== undefined) {
|
|
2258
2852
|
if (dialogShape === null) {
|
|
2259
|
-
const clear = await
|
|
2853
|
+
const clear = await clearNow();
|
|
2260
2854
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
2261
2855
|
}
|
|
2262
2856
|
if (step.onlyShape !== dialogShape)
|
|
@@ -2266,12 +2860,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2266
2860
|
// introduced, and the weekday-row base index).
|
|
2267
2861
|
if (step.shaped !== undefined) {
|
|
2268
2862
|
if (dialogShape === null) {
|
|
2269
|
-
const clear = await
|
|
2863
|
+
const clear = await clearNow();
|
|
2270
2864
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
2271
2865
|
}
|
|
2272
2866
|
const override = step.shaped[dialogShape];
|
|
2273
2867
|
if (override === undefined) {
|
|
2274
|
-
const clear = await
|
|
2868
|
+
const clear = await clearNow();
|
|
2275
2869
|
return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
|
|
2276
2870
|
}
|
|
2277
2871
|
step = { ...step, ...override };
|
|
@@ -2288,7 +2882,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2288
2882
|
const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
|
|
2289
2883
|
if (!ok) {
|
|
2290
2884
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
2291
|
-
const clear = await
|
|
2885
|
+
const clear = await clearNow();
|
|
2292
2886
|
return partial(step.label, "the expected element never appeared within the timeout", clear);
|
|
2293
2887
|
}
|
|
2294
2888
|
done.push(step.label);
|
|
@@ -2330,7 +2924,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2330
2924
|
const effective = await resolveStepPath(step, run);
|
|
2331
2925
|
if (effective === null) {
|
|
2332
2926
|
// dismiss whatever opened (and verify) before reporting
|
|
2333
|
-
const clear = await
|
|
2927
|
+
const clear = await clearNow();
|
|
2334
2928
|
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
2335
2929
|
"detached repeat editor window)", clear);
|
|
2336
2930
|
}
|
|
@@ -2346,7 +2940,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2346
2940
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
2347
2941
|
const verdict = res.stdout.trim();
|
|
2348
2942
|
if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
|
|
2349
|
-
const clear = await
|
|
2943
|
+
const clear = await clearNow();
|
|
2350
2944
|
return partial(step.label, res.ok
|
|
2351
2945
|
? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
|
|
2352
2946
|
"field, so the dialog matched neither known shape — a Things update has redesigned " +
|
|
@@ -2367,7 +2961,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2367
2961
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
2368
2962
|
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2369
2963
|
// clear any transient state (and verify) before reporting
|
|
2370
|
-
const clear = await
|
|
2964
|
+
const clear = await clearNow();
|
|
2371
2965
|
const noMatch = step.primitive === "select-heading-row"
|
|
2372
2966
|
? "the project view exposed no selectable heading row at the target position — the " +
|
|
2373
2967
|
"heading may have been converted/deleted already, or the project's headings changed"
|
|
@@ -2392,7 +2986,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2392
2986
|
const verdict = res.stdout.trim();
|
|
2393
2987
|
if (!res.ok || verdict !== "OK") {
|
|
2394
2988
|
// clear any transient state (and verify) before reporting
|
|
2395
|
-
const clear = await
|
|
2989
|
+
const clear = await clearNow();
|
|
2396
2990
|
return partial(step.label, res.ok
|
|
2397
2991
|
? verdict !== ""
|
|
2398
2992
|
? verdict
|
|
@@ -2413,7 +3007,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2413
3007
|
// the dialog through the standard clean-abort path, so nothing is committed.
|
|
2414
3008
|
const outcome = await driveDialogAudit(step, run, dialogShape);
|
|
2415
3009
|
if (!outcome.ok) {
|
|
2416
|
-
const clear = await
|
|
3010
|
+
const clear = await clearNow();
|
|
2417
3011
|
return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
|
|
2418
3012
|
}
|
|
2419
3013
|
done.push(step.label);
|
|
@@ -2428,7 +3022,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2428
3022
|
const outcome = await driveClickElement(step, run);
|
|
2429
3023
|
if (!outcome.ok) {
|
|
2430
3024
|
// clear whatever the click opened (honest cleanup) before reporting
|
|
2431
|
-
const clear = outcome.needsAbort === true ? await
|
|
3025
|
+
const clear = outcome.needsAbort === true ? await clearNow() : undefined;
|
|
2432
3026
|
return partial(step.label, outcome.why ?? "the click failed", clear);
|
|
2433
3027
|
}
|
|
2434
3028
|
done.push(step.label);
|
|
@@ -2439,12 +3033,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2439
3033
|
if (!res.ok) {
|
|
2440
3034
|
// clear the half-open sheet/popover (honest — never claim an unconfirmed
|
|
2441
3035
|
// dismissal) before reporting partial state
|
|
2442
|
-
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
2443
|
-
? await clearDialog(run)
|
|
2444
|
-
: undefined;
|
|
3036
|
+
const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
|
|
2445
3037
|
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
|
|
2446
3038
|
}
|
|
2447
|
-
|
|
3039
|
+
// A typing primitive that found the field ALREADY holding the requested
|
|
3040
|
+
// value typed nothing at all (issue #620 item 7) — disclosed, so the trail
|
|
3041
|
+
// says what the drive did rather than what it intended.
|
|
3042
|
+
done.push(res.stdout.trim() === OK_ALREADY ? `${step.label} (already set)` : step.label);
|
|
2448
3043
|
}
|
|
2449
3044
|
return {
|
|
2450
3045
|
exitCode: 0,
|
|
@@ -2555,6 +3150,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
2555
3150
|
}
|
|
2556
3151
|
return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
|
|
2557
3152
|
},
|
|
3153
|
+
// Pre-seed dialog seam for the promote orchestrators (MODALX1, #620): a
|
|
3154
|
+
// composite's FIRST leg mints a row through the URL scheme, which sails
|
|
3155
|
+
// straight past an open dialog — and every AppleScript leg after it then
|
|
3156
|
+
// fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
|
|
3157
|
+
probeUiState: () => readUiState(tracedRun, CENSUS_TIMEOUT_MS),
|
|
2558
3158
|
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
2559
3159
|
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
2560
3160
|
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|