things-api 0.19.1 → 0.19.2
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 +82 -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 +157 -0
- package/dist/write/vectors/ui-state.js +237 -0
- package/dist/write/vectors/ui-state.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +120 -4
- package/dist/write/vectors/ui.js +542 -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, describeFocusOwner, 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,45 @@ 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`;
|
|
1303
1485
|
}
|
|
1304
1486
|
/**
|
|
1305
1487
|
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
@@ -1359,36 +1541,152 @@ async function sheetStillOpen(run) {
|
|
|
1359
1541
|
return !(res.ok && res.stdout.trim() === "false");
|
|
1360
1542
|
}
|
|
1361
1543
|
/**
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
* Escape once), and if it will not go, warn "may remain".
|
|
1544
|
+
* Is the dialog the census found OURS to dismiss? `expected` is the kind this
|
|
1545
|
+
* drive was observed driving (latched from the census the drive itself ran). A
|
|
1546
|
+
* kind that does not match is left strictly alone: between our failure and this
|
|
1547
|
+
* cleanup, the person at the keyboard may have opened something of their own,
|
|
1548
|
+
* and dismissing it would be a mutation nobody asked for.
|
|
1549
|
+
*
|
|
1550
|
+
* With nothing latched (a drive that failed before any dialog was observed) the
|
|
1551
|
+
* two kinds this vector's recipes actually open are still treated as ours —
|
|
1552
|
+
* they are the dialogs our own steps would have opened — while an unrecognized
|
|
1553
|
+
* modal never is.
|
|
1373
1554
|
*/
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1555
|
+
function oursToDismiss(kind, expected) {
|
|
1556
|
+
if (kind === "none")
|
|
1557
|
+
return false;
|
|
1558
|
+
if (expected !== null)
|
|
1559
|
+
return kind === expected;
|
|
1560
|
+
return kind === "repeat" || kind === "move-picker";
|
|
1561
|
+
}
|
|
1562
|
+
/** How many stacked dialogs the cleanup will unwind before falling to the next rung. */
|
|
1563
|
+
const MAX_DISMISS_ROUNDS = 4;
|
|
1564
|
+
/** Press the dialog's own Cancel button (element-addressed, background-safe). */
|
|
1565
|
+
async function pressCancel(run) {
|
|
1566
|
+
const res = await run({
|
|
1567
|
+
primitive: "dismiss-dialog",
|
|
1568
|
+
label: "dismiss the open dialog (its Cancel button)",
|
|
1569
|
+
script: axCancelDialogScript(),
|
|
1570
|
+
}, STEP_TIMEOUT_MS);
|
|
1571
|
+
return res.ok && res.stdout.trim() === "OK";
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Clear a half-open dialog a failed drive left behind — AUDITED at every rung
|
|
1575
|
+
* (issue #620; supersedes the unconditional Escape, which was measured firing
|
|
1576
|
+
* into a foreign application's modal while the Things sheet it was meant for
|
|
1577
|
+
* stayed open all night).
|
|
1578
|
+
*
|
|
1579
|
+
* The ladder, cheapest and least disruptive first, re-reading the census after
|
|
1580
|
+
* every rung so nothing is ever CLAIMED to be dismissed:
|
|
1581
|
+
*
|
|
1582
|
+
* 0. census. No dialog + a reachable session → nothing to do. A dialog that
|
|
1583
|
+
* is not ours → left alone, reported.
|
|
1584
|
+
* 1. press the dialog's own CANCEL button — element-addressed, so it needs
|
|
1585
|
+
* neither focus nor the frontmost slot, and it cannot leak into another
|
|
1586
|
+
* app. This is the rung that clears the ordinary case.
|
|
1587
|
+
* 2. Escape, but only from a state where Things demonstrably owns the screen:
|
|
1588
|
+
* if it does not, RE-ACTIVATE Things, RE-AUDIT, and only then send it.
|
|
1589
|
+
* 3. the app-level close+reopen (SESSGATE) — the maneuver that works with no
|
|
1590
|
+
* Accessibility tree at all, and the documented recovery for the app-wide
|
|
1591
|
+
* AppleScript freeze a stuck sheet imposes (oddities §9cc), which is what
|
|
1592
|
+
* makes a caller's follow-up cleanup mutations land again.
|
|
1593
|
+
*
|
|
1594
|
+
* `expected` is the dialog kind this drive was observed driving; see
|
|
1595
|
+
* {@link oursToDismiss}.
|
|
1596
|
+
*/
|
|
1597
|
+
async function clearDialog(run, expected = null) {
|
|
1598
|
+
const census = await readUiState(run, STEP_TIMEOUT_MS);
|
|
1599
|
+
const owner = census !== null && !census.thingsFrontmost ? { focusOwner: describeFocusOwner(census) } : {};
|
|
1600
|
+
const readable = census !== null && census.inspectable;
|
|
1601
|
+
// 0. A clean, readable "no dialog" — but only trustworthy on a session whose
|
|
1602
|
+
// windows are AX-visible at all: a locked screen / full-screen Space
|
|
1603
|
+
// enumerates ZERO windows, so the census would report "no dialog" for a
|
|
1604
|
+
// sheet that is very much open (SESSGATE). Confirm before believing it.
|
|
1605
|
+
if (readable && census.sheetOpen === false) {
|
|
1606
|
+
const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
1607
|
+
if (reach.reachable)
|
|
1608
|
+
return { state: "none" };
|
|
1609
|
+
// AX-blind: System Events enumerates zero windows for EVERY app, so the
|
|
1610
|
+
// census cannot see a sheet that is open — and equally cannot confirm one
|
|
1611
|
+
// is gone. Run the blind-proof maneuver and report it as unconfirmed.
|
|
1612
|
+
return closeReopenRung(run, expected, owner, true);
|
|
1613
|
+
}
|
|
1614
|
+
// 0b. A dialog someone else opened — never touched.
|
|
1615
|
+
if (readable && !oursToDismiss(census.sheetKind, expected)) {
|
|
1616
|
+
return { state: "foreign", sheetKind: census.sheetKind, ...owner };
|
|
1617
|
+
}
|
|
1618
|
+
const kind = readable ? { sheetKind: census.sheetKind } : {};
|
|
1619
|
+
// 1. Its own Cancel button — repeated while a STACK unwinds, because dialogs
|
|
1620
|
+
// nest and dismiss strictly LIFO (MODALX1 §6), re-reading between presses
|
|
1621
|
+
// so a dialog that is not ours stops the loop rather than being clicked.
|
|
1622
|
+
if (readable) {
|
|
1623
|
+
for (let i = 0; i < MAX_DISMISS_ROUNDS; i += 1) {
|
|
1624
|
+
if (!(await pressCancel(run)))
|
|
1625
|
+
break;
|
|
1626
|
+
const after = await readUiState(run, STEP_TIMEOUT_MS);
|
|
1627
|
+
if (after === null || !after.inspectable)
|
|
1628
|
+
break;
|
|
1629
|
+
if (!after.sheetOpen)
|
|
1630
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
|
|
1631
|
+
if (!oursToDismiss(after.sheetKind, expected)) {
|
|
1632
|
+
return { state: "foreign", sheetKind: after.sheetKind, ...owner };
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
// 2. Escape — from a state where Things owns the screen, re-activating and
|
|
1637
|
+
// RE-AUDITING first when it does not (never a blind key into the unknown).
|
|
1638
|
+
let front = readable && census.thingsFrontmost;
|
|
1639
|
+
if (readable && !front) {
|
|
1381
1640
|
await run({
|
|
1382
|
-
primitive: "
|
|
1383
|
-
label: "
|
|
1384
|
-
script:
|
|
1641
|
+
primitive: "activate",
|
|
1642
|
+
label: "bring Things forward to dismiss its dialog",
|
|
1643
|
+
script: axActivateScript(),
|
|
1385
1644
|
}, STEP_TIMEOUT_MS);
|
|
1386
|
-
|
|
1645
|
+
const reaudit = await readUiState(run, STEP_TIMEOUT_MS);
|
|
1646
|
+
if (reaudit !== null && reaudit.inspectable) {
|
|
1647
|
+
if (!reaudit.sheetOpen)
|
|
1648
|
+
return { state: "dismissed", how: "cancel-button", ...kind, ...owner };
|
|
1649
|
+
if (!oursToDismiss(reaudit.sheetKind, expected)) {
|
|
1650
|
+
return { state: "foreign", sheetKind: reaudit.sheetKind, ...owner };
|
|
1651
|
+
}
|
|
1652
|
+
front = reaudit.thingsFrontmost;
|
|
1653
|
+
}
|
|
1387
1654
|
}
|
|
1388
|
-
if (
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1655
|
+
if (front) {
|
|
1656
|
+
await run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
|
|
1657
|
+
const after = await readUiState(run, STEP_TIMEOUT_MS);
|
|
1658
|
+
if (after !== null && after.inspectable && !after.sheetOpen) {
|
|
1659
|
+
return { state: "dismissed", how: "escape", ...kind, ...owner };
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// 3. The blind-proof maneuver, last: it discards the half-entered dialog with
|
|
1663
|
+
// the window, and unwedges the app-wide AppleScript freeze with it.
|
|
1664
|
+
return closeReopenRung(run, expected, owner);
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* The final rung: close+reopen the Things window, then re-audit if we can.
|
|
1668
|
+
* `blind` says the session itself is AX-blind, in which case NOTHING the census
|
|
1669
|
+
* reports afterwards is evidence — the outcome is honestly unconfirmed.
|
|
1670
|
+
*/
|
|
1671
|
+
async function closeReopenRung(run, expected, owner, blind = false) {
|
|
1672
|
+
await run({
|
|
1673
|
+
primitive: "resolve",
|
|
1674
|
+
label: "clear a stuck dialog (close the Things window and reopen it)",
|
|
1675
|
+
script: axCloseReopenActivateScript(),
|
|
1676
|
+
}, STEP_TIMEOUT_MS);
|
|
1677
|
+
if (blind)
|
|
1678
|
+
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1679
|
+
const after = await readUiState(run, STEP_TIMEOUT_MS);
|
|
1680
|
+
if (after === null || !after.inspectable) {
|
|
1681
|
+
return { state: "cleared-blind", how: "window-close", ...owner };
|
|
1682
|
+
}
|
|
1683
|
+
if (!after.sheetOpen)
|
|
1684
|
+
return { state: "dismissed", how: "window-close", ...owner };
|
|
1685
|
+
return {
|
|
1686
|
+
state: oursToDismiss(after.sheetKind, expected) ? "may-remain" : "foreign",
|
|
1687
|
+
sheetKind: after.sheetKind,
|
|
1688
|
+
...owner,
|
|
1689
|
+
};
|
|
1392
1690
|
}
|
|
1393
1691
|
/**
|
|
1394
1692
|
* The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
|
|
@@ -1660,6 +1958,15 @@ async function defaultRun(command, timeoutMs) {
|
|
|
1660
1958
|
...(res.timedOut === true && { timedOut: true }),
|
|
1661
1959
|
};
|
|
1662
1960
|
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Read the live window/focus census through the shipped dispatch seam — the
|
|
1963
|
+
* transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
|
|
1964
|
+
* behind any injected runner. READ-ONLY: the census clicks nothing, types
|
|
1965
|
+
* nothing, and changes no state; see src/write/vectors/ui-state.ts.
|
|
1966
|
+
*/
|
|
1967
|
+
export function readLiveUiState(run = defaultRun) {
|
|
1968
|
+
return readUiState(run, STEP_TIMEOUT_MS);
|
|
1969
|
+
}
|
|
1663
1970
|
/**
|
|
1664
1971
|
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
1665
1972
|
* step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
|
|
@@ -1694,6 +2001,159 @@ function tracingRun(inner) {
|
|
|
1694
2001
|
return res;
|
|
1695
2002
|
};
|
|
1696
2003
|
}
|
|
2004
|
+
/**
|
|
2005
|
+
* PRIMITIVE CLASSIFICATION for the per-step guard (issue #620). What decides a
|
|
2006
|
+
* primitive's class is HOW macOS routes its effect, not what it looks like:
|
|
2007
|
+
*
|
|
2008
|
+
* - KEYSTROKE-CLASS — System Events `keystroke` / `key code`. The event is
|
|
2009
|
+
* handed to whatever application owns the screen, so these need Things
|
|
2010
|
+
* frontmost AND the dialog we opened still in front. The element half of
|
|
2011
|
+
* the guard (did the field actually take focus?) is asserted in-script, in
|
|
2012
|
+
* the same hop as the typing.
|
|
2013
|
+
* - POINTER-CLASS — mouse synthesis through the global HID event tap
|
|
2014
|
+
* (`CGEventPost(kCGHIDEventTap)`). It posts at the FOREGROUND surface
|
|
2015
|
+
* (NATIVE1-e: `CGEventPostToPid` is inert for Things' hit-testing), so a
|
|
2016
|
+
* click while another app is frontmost lands in that app's window. Frontmost
|
|
2017
|
+
* is required; focus is not (a click sets its own).
|
|
2018
|
+
* - Everything else is ELEMENT-ADDRESSED — `click <element>`, `set value`,
|
|
2019
|
+
* `set focused`, the ObjC `AXUIElementSetAttributeValue` date writes, and
|
|
2020
|
+
* every read. System Events delivers those to the element named, whether or
|
|
2021
|
+
* not the app is frontmost, so guarding them would only add a hop and
|
|
2022
|
+
* forbid perfectly good background work.
|
|
2023
|
+
* - `chord-post` is deliberately NOT guarded: it posts its key event with
|
|
2024
|
+
* `CGEventPostToPid`, which addresses the PROCESS rather than the focused
|
|
2025
|
+
* surface — the whole point of the heading-reorder gesture is that it runs
|
|
2026
|
+
* with Things in the background and the user's focus untouched (HEADORD1
|
|
2027
|
+
* 1h2a, CHORDMH1). A frontmost guard there would break a certified op.
|
|
2028
|
+
*/
|
|
2029
|
+
const KEYSTROKE_CLASS = new Set([
|
|
2030
|
+
"key",
|
|
2031
|
+
"type-text",
|
|
2032
|
+
"set-value",
|
|
2033
|
+
"set-group-number",
|
|
2034
|
+
"set-row-field",
|
|
2035
|
+
]);
|
|
2036
|
+
const POINTER_CLASS = new Set([
|
|
2037
|
+
"click-point",
|
|
2038
|
+
"sidebar-drag",
|
|
2039
|
+
"sidebar-held-drag",
|
|
2040
|
+
"sidebar-scroll",
|
|
2041
|
+
]);
|
|
2042
|
+
/**
|
|
2043
|
+
* Judge one guard reading. Returns null to proceed, or the refusal sentence —
|
|
2044
|
+
* which always names who owns the screen, because that is the one fact the
|
|
2045
|
+
* person reading it cannot recover after the fact.
|
|
2046
|
+
*
|
|
2047
|
+
* Exported for the unit matrix: every branch here is a fail-closed decision
|
|
2048
|
+
* about synthetic input, and each one is worth a test.
|
|
2049
|
+
*/
|
|
2050
|
+
export function judgeFocusGuard(state, expectedSheet, label) {
|
|
2051
|
+
const refuse = (why) => `refused to run "${label}": ${why}`;
|
|
2052
|
+
if (state === null) {
|
|
2053
|
+
return refuse("the window and focus state could not be read, so there is no proof the input would reach " +
|
|
2054
|
+
"Things — nothing was sent");
|
|
2055
|
+
}
|
|
2056
|
+
if (!state.inspectable) {
|
|
2057
|
+
return refuse(`${describeFocusOwner(state)}. Input sent now would go to it, not to Things — nothing was ` +
|
|
2058
|
+
"sent. Answer or dismiss the system dialog, then run the same command again");
|
|
2059
|
+
}
|
|
2060
|
+
if (!state.thingsFrontmost) {
|
|
2061
|
+
return refuse(`${describeFocusOwner(state)}, so the input would go there instead of to Things — nothing ` +
|
|
2062
|
+
"was sent. Leave Things in front while it is being driven, then run the same command again");
|
|
2063
|
+
}
|
|
2064
|
+
if (expectedSheet !== null && state.sheetKind !== expectedSheet) {
|
|
2065
|
+
return refuse(`the dialog this command opened is no longer the one in front (expected ${expectedSheet}, ` +
|
|
2066
|
+
`found ${state.sheetKind}) — it was closed or replaced while the command was running, so ` +
|
|
2067
|
+
"nothing was sent");
|
|
2068
|
+
}
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Wrap the dispatch seam with the PER-STEP FOCUS GUARD (issue #620): before
|
|
2073
|
+
* every focus-routed hop, one cheap read-only census decides whether the input
|
|
2074
|
+
* can legitimately be delivered, and a violation ABORTS THE STEP rather than
|
|
2075
|
+
* typing into the void. Element-addressed hops pass straight through, so the
|
|
2076
|
+
* cost is paid only where it buys something.
|
|
2077
|
+
*
|
|
2078
|
+
* A closed loop, not a sleep: the census is a deterministic read of the live
|
|
2079
|
+
* state, taken immediately before the hop, and the in-script assertions close
|
|
2080
|
+
* the remaining milliseconds (UI-automation determinism doctrine; the #595
|
|
2081
|
+
* pre-commit audit and BEEP1 shape-settle are the same pattern).
|
|
2082
|
+
*/
|
|
2083
|
+
function guardedRun(inner, latch) {
|
|
2084
|
+
return async (command, timeoutMs) => {
|
|
2085
|
+
if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
|
|
2086
|
+
return inner(command, timeoutMs);
|
|
2087
|
+
}
|
|
2088
|
+
const state = await readUiState(inner, STEP_TIMEOUT_MS);
|
|
2089
|
+
// The dialog invariant applies to keystroke-class hops only: a pointer hop
|
|
2090
|
+
// is aimed at a frame it resolved a moment ago and fails closed on its own
|
|
2091
|
+
// if that frame moved.
|
|
2092
|
+
const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
|
|
2093
|
+
const guardRefusal = judgeFocusGuard(state, expected, command.label);
|
|
2094
|
+
if (guardRefusal !== null) {
|
|
2095
|
+
trace(() => ({
|
|
2096
|
+
phase: "focus-guard",
|
|
2097
|
+
event: "refused",
|
|
2098
|
+
primitive: command.primitive,
|
|
2099
|
+
label: command.label,
|
|
2100
|
+
frontmost: state?.frontmostApp ?? null,
|
|
2101
|
+
sheetKind: state?.sheetKind ?? null,
|
|
2102
|
+
inspectable: state?.inspectable ?? false,
|
|
2103
|
+
}));
|
|
2104
|
+
return { ok: false, stdout: "", stderr: guardRefusal };
|
|
2105
|
+
}
|
|
2106
|
+
if (state !== null && state.sheetOpen && latch.sheet === null)
|
|
2107
|
+
latch.sheet = state.sheetKind;
|
|
2108
|
+
return inner(command, timeoutMs);
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
/** How a dialog is named in a disclosure — behavior, not chrome. */
|
|
2112
|
+
function dialogNoun(kind) {
|
|
2113
|
+
switch (kind) {
|
|
2114
|
+
case "repeat":
|
|
2115
|
+
return "the repeat dialog";
|
|
2116
|
+
case "move-picker":
|
|
2117
|
+
return "the move-to-project chooser";
|
|
2118
|
+
default:
|
|
2119
|
+
return "a dialog";
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* The cleanup disclosure (issue #620). Every branch states what is TRUE of the
|
|
2124
|
+
* app right now, and — whenever a dialog may still be open — that Things Cloud
|
|
2125
|
+
* sync is held until someone dismisses it, which is the consequence a caller
|
|
2126
|
+
* cannot see and would otherwise discover hours later on another device.
|
|
2127
|
+
*/
|
|
2128
|
+
export function describeCleanup(clear) {
|
|
2129
|
+
const owner = clear.focusOwner === undefined ? "" : ` (${clear.focusOwner} when cleanup started)`;
|
|
2130
|
+
switch (clear.state) {
|
|
2131
|
+
case "none":
|
|
2132
|
+
return "No dialog was left open in Things.";
|
|
2133
|
+
case "dismissed":
|
|
2134
|
+
return `${clear.how === "cancel-button"
|
|
2135
|
+
? `${dialogNoun(clear.sheetKind)} was closed with its own Cancel button`
|
|
2136
|
+
: clear.how === "escape"
|
|
2137
|
+
? `${dialogNoun(clear.sheetKind)} was dismissed with Escape`
|
|
2138
|
+
: `${dialogNoun(clear.sheetKind)} was cleared by closing and reopening the Things window`}, confirmed closed${owner}.`;
|
|
2139
|
+
case "cleared-blind":
|
|
2140
|
+
return ("Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
2141
|
+
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
2142
|
+
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
2143
|
+
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
2144
|
+
" before retrying.");
|
|
2145
|
+
case "foreign":
|
|
2146
|
+
return (`A dialog is open in Things that this command did not open (${dialogNoun(clear.sheetKind)}),` +
|
|
2147
|
+
` so it was left exactly as it is${owner}. Dismiss it yourself when you are ready — and note` +
|
|
2148
|
+
` that ${SYNC_GATE_WARNING}.`);
|
|
2149
|
+
case "may-remain":
|
|
2150
|
+
return (`WARNING: ${dialogNoun(clear.sheetKind)} may still be open in Things${owner} — neither its` +
|
|
2151
|
+
" Cancel button, nor Escape, nor closing and reopening the window would clear it. Dismiss" +
|
|
2152
|
+
" it in Things (click Cancel, or press Escape with Things in front) before retrying: a" +
|
|
2153
|
+
` leftover dialog also disables the menu bar, so the next attempt would refuse. Also note` +
|
|
2154
|
+
` that ${SYNC_GATE_WARNING}.`);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
1697
2157
|
/** The element paths the preflight canary resolves (static steps only). */
|
|
1698
2158
|
function canaryPaths(recipe) {
|
|
1699
2159
|
const out = [];
|
|
@@ -2088,7 +2548,15 @@ function auditFailureText(res) {
|
|
|
2088
2548
|
return "the pre-commit dialog audit timed out; nothing was committed";
|
|
2089
2549
|
return "the Repeat dialog could not be re-read before committing; nothing was committed";
|
|
2090
2550
|
}
|
|
2091
|
-
async function drive(recipe,
|
|
2551
|
+
async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
2552
|
+
// Every step below dispatches through the PER-STEP FOCUS GUARD (issue #620);
|
|
2553
|
+
// the latch records the dialog this drive is observed driving, so the cleanup
|
|
2554
|
+
// ladder can tell our own half-open dialog from one the user opened after us.
|
|
2555
|
+
const latch = { sheet: null };
|
|
2556
|
+
const run = guardedRun(rawRun, latch);
|
|
2557
|
+
// The cleanup ladder audits for itself (it is what decides whether a keystroke
|
|
2558
|
+
// may be sent at all), so it runs OUTSIDE the guard.
|
|
2559
|
+
const clearNow = () => clearDialog(rawRun, latch.sheet);
|
|
2092
2560
|
const done = [];
|
|
2093
2561
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
2094
2562
|
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
@@ -2106,7 +2574,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2106
2574
|
// modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
|
|
2107
2575
|
// whose OK press was mid-commit could still land — the pipeline re-verifies
|
|
2108
2576
|
// and shapes the final result accordingly.
|
|
2109
|
-
const clear = await
|
|
2577
|
+
const clear = await clearNow();
|
|
2110
2578
|
trace(() => ({
|
|
2111
2579
|
phase: "watchdog",
|
|
2112
2580
|
budgetMs,
|
|
@@ -2145,19 +2613,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2145
2613
|
*/
|
|
2146
2614
|
stepTimedOut = false) => {
|
|
2147
2615
|
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).";
|
|
2616
|
+
const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
|
|
2161
2617
|
// #512: name an environment failure as one. A cleanup that had to run BLIND
|
|
2162
2618
|
// is direct evidence the session went AX-blind mid-drive; a step killed by
|
|
2163
2619
|
// its own deadline is the window not answering. Either way the app was not
|
|
@@ -2217,6 +2673,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2217
2673
|
"to open the dialog. ";
|
|
2218
2674
|
}
|
|
2219
2675
|
}
|
|
2676
|
+
// 0¾. OPEN-DIALOG PRECONDITION (MODALX1 §3/§4, issue #620). A dialog already
|
|
2677
|
+
// standing when a drive starts is not ours and cannot be driven around:
|
|
2678
|
+
// it disables the menu bar (so a menu recipe's canary would miss and
|
|
2679
|
+
// guess at why), and it SWALLOWS the chord recipes' key events, which
|
|
2680
|
+
// pass their canary happily and then move nothing. The census says so
|
|
2681
|
+
// directly, for every recipe, before anything is pressed — and a dialog
|
|
2682
|
+
// standing here also means the app is ignoring scripted changes app-wide
|
|
2683
|
+
// and holding Things Cloud sync, which is the operator's real problem.
|
|
2684
|
+
const startState = await readUiState(rawRun, STEP_TIMEOUT_MS);
|
|
2685
|
+
if (startState !== null && startState.inspectable && startState.sheetOpen) {
|
|
2686
|
+
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 ` +
|
|
2687
|
+
"app disables its menu bar, ignores keyboard input aimed at anything else, and " +
|
|
2688
|
+
`${SYNC_GATE_WARNING}. Dismiss it in Things (click Cancel, or press Escape with Things in ` +
|
|
2689
|
+
"front), then run the same command again. Nothing was pressed.");
|
|
2690
|
+
}
|
|
2220
2691
|
// 1. Recipe canary: resolve every statically-reachable element (now that the
|
|
2221
2692
|
// target is selected). A miss refuses the whole drive before anything is
|
|
2222
2693
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
@@ -2256,7 +2727,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2256
2727
|
// control whose CLASS changed, and only the matching one runs.
|
|
2257
2728
|
if (step.onlyShape !== undefined) {
|
|
2258
2729
|
if (dialogShape === null) {
|
|
2259
|
-
const clear = await
|
|
2730
|
+
const clear = await clearNow();
|
|
2260
2731
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
2261
2732
|
}
|
|
2262
2733
|
if (step.onlyShape !== dialogShape)
|
|
@@ -2266,12 +2737,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2266
2737
|
// introduced, and the weekday-row base index).
|
|
2267
2738
|
if (step.shaped !== undefined) {
|
|
2268
2739
|
if (dialogShape === null) {
|
|
2269
|
-
const clear = await
|
|
2740
|
+
const clear = await clearNow();
|
|
2270
2741
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
2271
2742
|
}
|
|
2272
2743
|
const override = step.shaped[dialogShape];
|
|
2273
2744
|
if (override === undefined) {
|
|
2274
|
-
const clear = await
|
|
2745
|
+
const clear = await clearNow();
|
|
2275
2746
|
return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
|
|
2276
2747
|
}
|
|
2277
2748
|
step = { ...step, ...override };
|
|
@@ -2288,7 +2759,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2288
2759
|
const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
|
|
2289
2760
|
if (!ok) {
|
|
2290
2761
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
2291
|
-
const clear = await
|
|
2762
|
+
const clear = await clearNow();
|
|
2292
2763
|
return partial(step.label, "the expected element never appeared within the timeout", clear);
|
|
2293
2764
|
}
|
|
2294
2765
|
done.push(step.label);
|
|
@@ -2330,7 +2801,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2330
2801
|
const effective = await resolveStepPath(step, run);
|
|
2331
2802
|
if (effective === null) {
|
|
2332
2803
|
// dismiss whatever opened (and verify) before reporting
|
|
2333
|
-
const clear = await
|
|
2804
|
+
const clear = await clearNow();
|
|
2334
2805
|
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
2335
2806
|
"detached repeat editor window)", clear);
|
|
2336
2807
|
}
|
|
@@ -2346,7 +2817,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2346
2817
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
2347
2818
|
const verdict = res.stdout.trim();
|
|
2348
2819
|
if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
|
|
2349
|
-
const clear = await
|
|
2820
|
+
const clear = await clearNow();
|
|
2350
2821
|
return partial(step.label, res.ok
|
|
2351
2822
|
? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
|
|
2352
2823
|
"field, so the dialog matched neither known shape — a Things update has redesigned " +
|
|
@@ -2367,7 +2838,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2367
2838
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
2368
2839
|
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2369
2840
|
// clear any transient state (and verify) before reporting
|
|
2370
|
-
const clear = await
|
|
2841
|
+
const clear = await clearNow();
|
|
2371
2842
|
const noMatch = step.primitive === "select-heading-row"
|
|
2372
2843
|
? "the project view exposed no selectable heading row at the target position — the " +
|
|
2373
2844
|
"heading may have been converted/deleted already, or the project's headings changed"
|
|
@@ -2392,7 +2863,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2392
2863
|
const verdict = res.stdout.trim();
|
|
2393
2864
|
if (!res.ok || verdict !== "OK") {
|
|
2394
2865
|
// clear any transient state (and verify) before reporting
|
|
2395
|
-
const clear = await
|
|
2866
|
+
const clear = await clearNow();
|
|
2396
2867
|
return partial(step.label, res.ok
|
|
2397
2868
|
? verdict !== ""
|
|
2398
2869
|
? verdict
|
|
@@ -2413,7 +2884,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2413
2884
|
// the dialog through the standard clean-abort path, so nothing is committed.
|
|
2414
2885
|
const outcome = await driveDialogAudit(step, run, dialogShape);
|
|
2415
2886
|
if (!outcome.ok) {
|
|
2416
|
-
const clear = await
|
|
2887
|
+
const clear = await clearNow();
|
|
2417
2888
|
return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
|
|
2418
2889
|
}
|
|
2419
2890
|
done.push(step.label);
|
|
@@ -2428,7 +2899,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2428
2899
|
const outcome = await driveClickElement(step, run);
|
|
2429
2900
|
if (!outcome.ok) {
|
|
2430
2901
|
// clear whatever the click opened (honest cleanup) before reporting
|
|
2431
|
-
const clear = outcome.needsAbort === true ? await
|
|
2902
|
+
const clear = outcome.needsAbort === true ? await clearNow() : undefined;
|
|
2432
2903
|
return partial(step.label, outcome.why ?? "the click failed", clear);
|
|
2433
2904
|
}
|
|
2434
2905
|
done.push(step.label);
|
|
@@ -2439,12 +2910,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
2439
2910
|
if (!res.ok) {
|
|
2440
2911
|
// clear the half-open sheet/popover (honest — never claim an unconfirmed
|
|
2441
2912
|
// dismissal) before reporting partial state
|
|
2442
|
-
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
2443
|
-
? await clearDialog(run)
|
|
2444
|
-
: undefined;
|
|
2913
|
+
const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
|
|
2445
2914
|
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
|
|
2446
2915
|
}
|
|
2447
|
-
|
|
2916
|
+
// A typing primitive that found the field ALREADY holding the requested
|
|
2917
|
+
// value typed nothing at all (issue #620 item 7) — disclosed, so the trail
|
|
2918
|
+
// says what the drive did rather than what it intended.
|
|
2919
|
+
done.push(res.stdout.trim() === OK_ALREADY ? `${step.label} (already set)` : step.label);
|
|
2448
2920
|
}
|
|
2449
2921
|
return {
|
|
2450
2922
|
exitCode: 0,
|
|
@@ -2555,6 +3027,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
2555
3027
|
}
|
|
2556
3028
|
return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
|
|
2557
3029
|
},
|
|
3030
|
+
// Pre-seed dialog seam for the promote orchestrators (MODALX1, #620): a
|
|
3031
|
+
// composite's FIRST leg mints a row through the URL scheme, which sails
|
|
3032
|
+
// straight past an open dialog — and every AppleScript leg after it then
|
|
3033
|
+
// fails, leaving a copy behind. The orchestrator asks this BEFORE it seeds.
|
|
3034
|
+
probeUiState: () => readUiState(tracedRun, STEP_TIMEOUT_MS),
|
|
2558
3035
|
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
2559
3036
|
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
2560
3037
|
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|