things-api 0.19.0 → 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 +3 -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/audit/schema.d.ts +19 -2
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +166 -4
- package/dist/capability.js +303 -13
- package/dist/capability.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +52 -22
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +14 -3
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.js +16 -3
- package/dist/cli/commands/setup.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/commands/writes.js +102 -67
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +2 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +10 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/contracts.d.ts +4 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +15 -1
- package/dist/deputy/install.js +46 -6
- package/dist/deputy/install.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 +34 -0
- package/dist/deputy/routing.js +95 -0
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +6 -4
- package/dist/diagnose.js +20 -4
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +31 -5
- package/dist/direct-setup.js +200 -26
- package/dist/direct-setup.js.map +1 -1
- package/dist/host-access.d.ts +12 -0
- package/dist/host-access.js +12 -0
- package/dist/host-access.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +23 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.js +8 -1
- package/dist/op-result.js.map +1 -1
- package/dist/session-grant.js +16 -1
- package/dist/session-grant.js.map +1 -1
- package/dist/surface-copy.d.ts +11 -3
- package/dist/surface-copy.js +11 -3
- package/dist/surface-copy.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/wizard.js +8 -1
- package/dist/wizard.js.map +1 -1
- 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/availability.d.ts +0 -15
- package/dist/write/availability.js +7 -62
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.js +17 -6
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.d.ts +10 -6
- package/dist/write/commands.js +120 -31
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +36 -7
- package/dist/write/failure-hints.js +33 -14
- 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/guards.js +3 -2
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.js +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +15 -8
- package/dist/write/operations.js +9 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +67 -23
- package/dist/write/opid.js +141 -19
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +17 -44
- package/dist/write/param-schema.js +74 -7
- package/dist/write/param-schema.js.map +1 -1
- package/dist/write/pipeline.d.ts +38 -7
- package/dist/write/pipeline.js +134 -18
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +30 -16
- package/dist/write/pre-state.js +88 -20
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.d.ts +26 -0
- package/dist/write/promote-clone.js +333 -41
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/resolution-timestamps.js +25 -2
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/template-mutation.js +163 -16
- package/dist/write/template-mutation.js.map +1 -1
- package/dist/write/vectors/applescript.js +3 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/simulator.js +8 -2
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +236 -4
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +51 -3
- package/dist/write/vectors/ui-recipes.js +452 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- 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 +386 -13
- package/dist/write/vectors/ui.js +1543 -124
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +41 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +1 -1
- package/schema/envelope.schema.json +2 -1
- package/skills/things-cli/SKILL.md +2 -2
package/dist/write/vectors/ui.js
CHANGED
|
@@ -33,7 +33,9 @@ import { UI_DRIVE_OPS } from "../operations.js";
|
|
|
33
33
|
import { escapeAppleScript } from "./applescript.js";
|
|
34
34
|
import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
35
35
|
import { certificationOf } from "./ui-certification.js";
|
|
36
|
+
import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
|
|
36
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";
|
|
37
39
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
38
40
|
const STEP_TIMEOUT_MS = 15_000;
|
|
39
41
|
/**
|
|
@@ -72,6 +74,227 @@ const SETTLE_AFTER_REVEAL_MS = 1000;
|
|
|
72
74
|
*/
|
|
73
75
|
const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
|
|
74
76
|
const SE = `tell application "System Events" to tell process "Things3"`;
|
|
77
|
+
/**
|
|
78
|
+
* The cadence group's re-layout settle budget (BEEP1) — how many times
|
|
79
|
+
* `set-group-number` re-reads the group's shape looking for two consecutive
|
|
80
|
+
* identical reads, and how long it waits between reads. ~4s of headroom against
|
|
81
|
+
* a re-layout measured to finish well inside 1.5s; the gate exits on the FIRST
|
|
82
|
+
* agreeing pair, so the common (already-settled) case costs one extra read.
|
|
83
|
+
*/
|
|
84
|
+
const SETTLE_READS = 40;
|
|
85
|
+
const SETTLE_POLL_S = 0.1;
|
|
86
|
+
/**
|
|
87
|
+
* Row tolerance (points) for every LABEL-ANCHORED field address here — how far a
|
|
88
|
+
* control's y may sit from its label's y and still count as the same row.
|
|
89
|
+
* Measured on Things 3.23 (CGRD1 §A census): `Every`@286 / interval@283,
|
|
90
|
+
* `Ends:`@375 / count@372, `days earlier`@413 / start-offset@409 — a 3–4pt
|
|
91
|
+
* baseline offset, so 8 is ~2× the observed worst case and well under the
|
|
92
|
+
* ~45pt row pitch of the cadence group.
|
|
93
|
+
*/
|
|
94
|
+
const ROW_TOLERANCE = 8;
|
|
95
|
+
/**
|
|
96
|
+
* How many of the Next: menu's own item titles a MISS reports back (NEXTPOP1).
|
|
97
|
+
* A refusal that names only the date the dialog lacked cannot distinguish "the
|
|
98
|
+
* rule genuinely does not produce that date" from "the menu was not the rule's"
|
|
99
|
+
* — the two failures read identically, and the second one cost a whole campaign
|
|
100
|
+
* to tell apart. Naming the pop-up's current value plus the first few options it
|
|
101
|
+
* offered makes the refusal self-diagnosing. Bounded so a 100-item level cannot
|
|
102
|
+
* flood the message.
|
|
103
|
+
*/
|
|
104
|
+
const SAMPLE_ITEMS = 5;
|
|
105
|
+
/**
|
|
106
|
+
* The `Next:` occurrence pop-up's asynchronous-recompute budget (NEXTPOP1) — how
|
|
107
|
+
* long {@link axSettleOccurrencesScript} waits for the dialog to absorb a rule
|
|
108
|
+
* change before the drive touches it again, and how often it looks. MEASURED at
|
|
109
|
+
* **0.4s** on golden-v4 / Things 3.23 (DIAG4: the control flipped from the seed's
|
|
110
|
+
* first occurrence to the anchor's between t+0.3s and t+0.4s), so the budget is
|
|
111
|
+
* ~3× the observed latency; the poll exits early the moment the control moves,
|
|
112
|
+
* which is the only case that costs anything.
|
|
113
|
+
*/
|
|
114
|
+
const OCCURRENCE_SETTLE_MS = 1200;
|
|
115
|
+
const OCCURRENCE_POLL_MS = 100;
|
|
116
|
+
/**
|
|
117
|
+
* The shared AppleScript handler prelude for every LABEL-ANCHORED field address
|
|
118
|
+
* in the Repeat dialog — the HXPC1 discrimination law in ONE place, so the
|
|
119
|
+
* pre-commit audit re-reads each field through the SAME address the drive wrote
|
|
120
|
+
* it through (a self-referential read-back is exactly what let #589's wrong-field
|
|
121
|
+
* write report OK).
|
|
122
|
+
*
|
|
123
|
+
* The laws, all measured on Things 3.23 / build 32300036 (CGRD1 §A census, and
|
|
124
|
+
* HXPC1 §A before it — docs/lab/cgrd1-precommit-audit.md, hxpc1-picker-assert.md):
|
|
125
|
+
*
|
|
126
|
+
* - The cadence group's numeric fields are identified by the LABEL ROW they sit
|
|
127
|
+
* on, never by index among the group's text fields. Selecting an "Ends: after"
|
|
128
|
+
* bound INSERTS the count AHEAD of the interval, so index 1 is a different
|
|
129
|
+
* control at different moments.
|
|
130
|
+
* - The interval is matched POSITIVELY, on the `Every` label's row. Every fixed
|
|
131
|
+
* frequency (daily/weekly/monthly/yearly) carries that label at y=286 with the
|
|
132
|
+
* interval at y=283. An AFTER-COMPLETION cadence group carries NEITHER an
|
|
133
|
+
* `Every` nor an `Ends:` label (census: its only static text is "after previous
|
|
134
|
+
* item is checked off.") and offers exactly ONE text field — so that shape falls
|
|
135
|
+
* to the sole-field rule, which is itself a uniqueness check, not an index.
|
|
136
|
+
* - Anything else FAILS CLOSED reporting the whole numeric-field inventory rather
|
|
137
|
+
* than typing a number into a field it cannot vouch for. An AX tree is an
|
|
138
|
+
* undocumented private surface: an unrecognized shape is a refusal, never a
|
|
139
|
+
* best guess.
|
|
140
|
+
*/
|
|
141
|
+
const AX_CADENCE_HANDLERS = `on cgLabelY(g, want)
|
|
142
|
+
tell application "System Events"
|
|
143
|
+
set outY to missing value
|
|
144
|
+
repeat with i from 1 to (count of static texts of g)
|
|
145
|
+
set sv to ""
|
|
146
|
+
try
|
|
147
|
+
set sv to (value of static text i of g) as text
|
|
148
|
+
end try
|
|
149
|
+
if sv is want then
|
|
150
|
+
-- bind the position, THEN index it: System Events refuses indexing a
|
|
151
|
+
-- position read inline from a specifier (-1700).
|
|
152
|
+
set labelPos to position of static text i of g
|
|
153
|
+
set outY to item 2 of labelPos
|
|
154
|
+
end if
|
|
155
|
+
end repeat
|
|
156
|
+
return outY
|
|
157
|
+
end tell
|
|
158
|
+
end cgLabelY
|
|
159
|
+
|
|
160
|
+
on cgInventory(g)
|
|
161
|
+
tell application "System Events"
|
|
162
|
+
set inv to ""
|
|
163
|
+
repeat with i from 1 to (count of text fields of g)
|
|
164
|
+
set fp to position of text field i of g
|
|
165
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of g) as text) & ")"
|
|
166
|
+
end repeat
|
|
167
|
+
if inv is "" then set inv to " (none)"
|
|
168
|
+
return inv
|
|
169
|
+
end tell
|
|
170
|
+
end cgInventory
|
|
171
|
+
|
|
172
|
+
on cgOnRow(g, y, tol, want)
|
|
173
|
+
tell application "System Events"
|
|
174
|
+
set hits to {}
|
|
175
|
+
repeat with i from 1 to (count of text fields of g)
|
|
176
|
+
set fp to position of text field i of g
|
|
177
|
+
set dy to (item 2 of fp) - y
|
|
178
|
+
if dy < 0 then set dy to -dy
|
|
179
|
+
set onRow to (dy <= tol)
|
|
180
|
+
if onRow is want then set end of hits to text field i of g
|
|
181
|
+
end repeat
|
|
182
|
+
return hits
|
|
183
|
+
end tell
|
|
184
|
+
end cgOnRow
|
|
185
|
+
|
|
186
|
+
on cgSettle(g)
|
|
187
|
+
-- SETTLE ON THE GROUP'S OWN SHAPE, never on a clock (determinism doctrine).
|
|
188
|
+
-- A frequency switch REBUILDS the cadence group. Two things go wrong when a
|
|
189
|
+
-- read starts too early: the row discrimination reads positions from controls
|
|
190
|
+
-- that are still moving, and keystrokes land on a field being torn down —
|
|
191
|
+
-- unhandled, so macOS beeps (BEEP1). Poll until two consecutive reads of the
|
|
192
|
+
-- group's label + field-position signature agree, then proceed.
|
|
193
|
+
tell application "System Events"
|
|
194
|
+
set sig to ""
|
|
195
|
+
set prevSig to "<none>"
|
|
196
|
+
repeat ${SETTLE_READS} times
|
|
197
|
+
set prevSig to sig
|
|
198
|
+
set sig to ""
|
|
199
|
+
repeat with i from 1 to (count of static texts of g)
|
|
200
|
+
set sv to ""
|
|
201
|
+
try
|
|
202
|
+
set sv to (value of static text i of g) as text
|
|
203
|
+
end try
|
|
204
|
+
set sig to sig & "|s:" & sv
|
|
205
|
+
end repeat
|
|
206
|
+
repeat with i from 1 to (count of text fields of g)
|
|
207
|
+
set fp to position of text field i of g
|
|
208
|
+
set sig to sig & "|f:" & (item 2 of fp)
|
|
209
|
+
end repeat
|
|
210
|
+
if sig is prevSig then return true
|
|
211
|
+
delay ${SETTLE_POLL_S}
|
|
212
|
+
end repeat
|
|
213
|
+
error "the Repeat dialog's cadence group is still re-laying out — its shape changed on every read; last seen" & sig
|
|
214
|
+
end tell
|
|
215
|
+
end cgSettle
|
|
216
|
+
|
|
217
|
+
on cgField(g, target, tol)
|
|
218
|
+
tell application "System Events"
|
|
219
|
+
set endsY to my cgLabelY(g, "Ends:")
|
|
220
|
+
set everyY to my cgLabelY(g, "Every")
|
|
221
|
+
set nf to (count of text fields of g)
|
|
222
|
+
if target is "ends-count" then
|
|
223
|
+
if endsY is missing value then error "the Repeat dialog's cadence group carries no \\"Ends:\\" label, so the ends-after count field cannot be identified — numeric fields:" & my cgInventory(g)
|
|
224
|
+
set hits to my cgOnRow(g, endsY, tol, true)
|
|
225
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
226
|
+
return item 1 of hits
|
|
227
|
+
end if
|
|
228
|
+
if everyY is not missing value then
|
|
229
|
+
set hits to my cgOnRow(g, everyY, tol, true)
|
|
230
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"Every\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
231
|
+
return item 1 of hits
|
|
232
|
+
end if
|
|
233
|
+
if endsY is not missing value then
|
|
234
|
+
set hits to my cgOnRow(g, endsY, tol, false)
|
|
235
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) off the \\"Ends:\\" row, expected exactly 1 — numeric fields:" & my cgInventory(g)
|
|
236
|
+
return item 1 of hits
|
|
237
|
+
end if
|
|
238
|
+
if nf is not 1 then error "the Repeat dialog's cadence group carries neither an \\"Every\\" nor an \\"Ends:\\" label and offers " & nf & " numeric field(s), so the interval cannot be identified — numeric fields:" & my cgInventory(g)
|
|
239
|
+
-- positional-ok: reached ONLY after the line above proved the group holds
|
|
240
|
+
-- exactly one text field, so this is a uniqueness statement, not an index.
|
|
241
|
+
-- The after-completion cadence group is that shape (MEASURED, CGRD1 §A: its
|
|
242
|
+
-- only static text is "after previous item is checked off.", one field).
|
|
243
|
+
return text field 1 of g
|
|
244
|
+
end tell
|
|
245
|
+
end cgField
|
|
246
|
+
|
|
247
|
+
on rfInventory(c)
|
|
248
|
+
tell application "System Events"
|
|
249
|
+
set inv to ""
|
|
250
|
+
repeat with i from 1 to (count of text fields of c)
|
|
251
|
+
set fp to position of text field i of c
|
|
252
|
+
set inv to inv & " #" & i & "(y=" & (item 2 of fp) & ",shows=" & ((value of text field i of c) as text) & ")"
|
|
253
|
+
end repeat
|
|
254
|
+
if inv is "" then set inv to " (none)"
|
|
255
|
+
return inv
|
|
256
|
+
end tell
|
|
257
|
+
end rfInventory
|
|
258
|
+
|
|
259
|
+
on rfField(c, rowLabel, tol)
|
|
260
|
+
tell application "System Events"
|
|
261
|
+
set labelY to my cgLabelY(c, rowLabel)
|
|
262
|
+
if labelY is missing value then error "the Repeat dialog shows no \\"" & rowLabel & "\\" label, so the field beside it cannot be identified — text fields:" & my rfInventory(c)
|
|
263
|
+
set hits to my cgOnRow(c, labelY, tol, true)
|
|
264
|
+
if (count of hits) is not 1 then error "the Repeat dialog offers " & (count of hits) & " field(s) on the \\"" & rowLabel & "\\" row, expected exactly 1 — text fields:" & my rfInventory(c)
|
|
265
|
+
return item 1 of hits
|
|
266
|
+
end tell
|
|
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`;
|
|
75
298
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
76
299
|
export function axResolveScript(path) {
|
|
77
300
|
return `${SE} to return (exists (${path}))`;
|
|
@@ -82,33 +305,64 @@ export function axPressScript(path) {
|
|
|
82
305
|
}
|
|
83
306
|
/**
|
|
84
307
|
* set-field-value: enter a value into the dialog's numeric text field (interval,
|
|
85
|
-
* ends-count, start-days-earlier)
|
|
86
|
-
* value, and Tabs to commit — because
|
|
87
|
-
*
|
|
88
|
-
* number (the field shows "5" but the
|
|
89
|
-
* exactly like `set value` on a pop-up,
|
|
90
|
-
* case used the default interval 1).
|
|
91
|
-
* needs; Tab (not Return, which would
|
|
92
|
-
*
|
|
93
|
-
* reveal/activate preamble puts Things
|
|
308
|
+
* ends-count, start-days-earlier) — and into the Move… picker's filter field. It
|
|
309
|
+
* FOCUSES the field, TYPES the value, and Tabs to commit — because
|
|
310
|
+
* `set value of <field>` writes the field's displayed text WITHOUT firing the
|
|
311
|
+
* edit, so the app's binding keeps the old number (the field shows "5" but the
|
|
312
|
+
* rule stays interval 1 — a silent no-op exactly like `set value` on a pop-up,
|
|
313
|
+
* UIC6; it went unnoticed while every base case used the default interval 1).
|
|
314
|
+
* Real keystrokes fire the change the binding needs; Tab (not Return, which would
|
|
315
|
+
* fire the default OK button) commits and moves focus. Foreground-bound
|
|
316
|
+
* (keystrokes reach the frontmost app) — the reveal/activate preamble puts Things
|
|
317
|
+
* there. One stable command shape.
|
|
318
|
+
*
|
|
319
|
+
* NO SELECT-ALL KEYSTROKE (BEEP1, 2026-08-25, docs/lab/beep1-numeric-field-beep.md).
|
|
320
|
+
* The primitive used to send ⌘A before typing, and that ONE keystroke was the
|
|
321
|
+
* audible macOS alert beep every numeric-field drive fired on the live host:
|
|
322
|
+
* Things' `Edit ▸ Select All` menu item exists and is DISABLED while the Repeat
|
|
323
|
+
* sheet is up, AppKit dispatches ⌘A as a menu key equivalent FIRST, the disabled
|
|
324
|
+
* item swallows it, nothing handles it → NSBeep. It is a menu-dispatch fact, not
|
|
325
|
+
* a focus race: the beep survives a verified first responder and a 1.5 s settle,
|
|
326
|
+
* while Tab and the digits themselves are silent. ⌘A was also REDUNDANT —
|
|
327
|
+
* `set focused of tf to true` installs the field editor with the ENTIRE content
|
|
328
|
+
* selected (measured: `AXSelectedTextRange` length goes 0 → the full value
|
|
329
|
+
* length, on both a 1- and a 2-character value), so typing replaces the old value
|
|
330
|
+
* outright, including the shrinking case (12 → 3) that a stale caret would have
|
|
331
|
+
* corrupted into "123". Dropping the keystroke is therefore silent AND correct on
|
|
332
|
+
* all three fields.
|
|
333
|
+
*
|
|
334
|
+
* CLOSED-LOOP (determinism doctrine): type, Tab-commit, then READ THE FIELD BACK
|
|
335
|
+
* and retry if it did not hold — the interval field, when it is the first numeric
|
|
336
|
+
* field after a frequency/type switch, races the dialog's group re-layout and
|
|
337
|
+
* reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a settle lands it
|
|
338
|
+
* once the re-layout has finished, and the re-focus re-selects the whole value,
|
|
339
|
+
* so a retry starts from a clean field without ⌘A. Fail-closed (an `error`, i.e.
|
|
340
|
+
* a transport failure the pipeline re-verifies) if it never holds — the
|
|
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.
|
|
94
352
|
*/
|
|
95
353
|
export function axSetValueScript(path, value, attempts = 3) {
|
|
96
354
|
const v = escapeAppleScript(value);
|
|
97
355
|
const n = Math.max(1, Math.trunc(attempts));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// re-layout and reverts to 1 (UIC7, oddities §8l). Re-focus + re-type after a
|
|
102
|
-
// settle lands it once the re-layout has finished. Fail-closed (an `error`,
|
|
103
|
-
// i.e. a transport failure the pipeline re-verifies) if it never holds — the
|
|
104
|
-
// create/reschedule delta's rule assertion is the final DB-level authority.
|
|
105
|
-
return `${SE}
|
|
356
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
357
|
+
|
|
358
|
+
${SE}
|
|
106
359
|
set tf to (${path})
|
|
360
|
+
${alreadyHoldsBlock("tf", v)}
|
|
107
361
|
repeat ${n} times
|
|
362
|
+
my fgAssertFront("type \\"${v}\\" into the field")
|
|
108
363
|
set focused of tf to true
|
|
109
364
|
delay 0.15
|
|
110
|
-
|
|
111
|
-
delay 0.1
|
|
365
|
+
${focusedAssertBlock("tf", v)}
|
|
112
366
|
keystroke "${v}"
|
|
113
367
|
delay 0.1
|
|
114
368
|
key code 48
|
|
@@ -121,6 +375,391 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
121
375
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
122
376
|
end tell`;
|
|
123
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
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* set-group-number: drive ONE of the Repeat dialog's two numeric fields —
|
|
418
|
+
* the cadence INTERVAL or the ENDS-AFTER COUNT — addressed by the LABEL ROW it
|
|
419
|
+
* sits on (HXPC1, docs/lab/hxpc1-picker-assert.md §A; hardened by CGRD1,
|
|
420
|
+
* docs/lab/cgrd1-precommit-audit.md §A).
|
|
421
|
+
*
|
|
422
|
+
* Both fields used to be spelled `text field 1 of group 1`, which is the same
|
|
423
|
+
* control at different moments. Measured on Things 3.23 (build 32300036):
|
|
424
|
+
*
|
|
425
|
+
* Ends: never → group text fields = 1 · tf1 = interval @[311,283]
|
|
426
|
+
* Ends: after N → group text fields = 2 · tf1 = COUNT @[402,372]
|
|
427
|
+
* tf2 = interval @[311,283]
|
|
428
|
+
*
|
|
429
|
+
* i.e. selecting the "after" bound INSERTS the count ahead of the interval. The
|
|
430
|
+
* create path got away with it by driving the interval while it was still the
|
|
431
|
+
* sole field, but a RESCHEDULE opens the dialog pre-populated: a rule that
|
|
432
|
+
* already ends after N presents both fields from the first step, so the interval
|
|
433
|
+
* drive wrote the requested interval into the count field, the count drive then
|
|
434
|
+
* overwrote it, and the interval silently never changed.
|
|
435
|
+
*
|
|
436
|
+
* The addresses are the {@link AX_CADENCE_HANDLERS} laws — POSITIVE label-row
|
|
437
|
+
* matches wherever the app offers a label to match, a uniqueness check where it
|
|
438
|
+
* does not, and a fail-closed refusal naming the whole numeric-field inventory
|
|
439
|
+
* otherwise. The write itself is the {@link axSetValueScript} closed loop —
|
|
440
|
+
* focus, type, Tab-commit, read back, bounded retries — and, like it, sends NO
|
|
441
|
+
* select-all keystroke: the ⌘A that used to open it is the macOS alert beep
|
|
442
|
+
* (BEEP1, docs/lab/beep1-numeric-field-beep.md), and focusing the field already
|
|
443
|
+
* selects its whole content.
|
|
444
|
+
*
|
|
445
|
+
* The read-back this loop performs is SELF-REFERENTIAL by construction — it
|
|
446
|
+
* re-reads the field it addressed, so it can only prove the keystrokes landed
|
|
447
|
+
* where they were aimed, never that they were aimed at the right control. The
|
|
448
|
+
* PRE-COMMIT AUDIT ({@link axAuditDialogScript}) is what closes that: it re-reads
|
|
449
|
+
* every control through these same handlers just before the OK press.
|
|
450
|
+
*/
|
|
451
|
+
export function axSetGroupNumberScript(groupPath, target, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
452
|
+
const v = escapeAppleScript(value);
|
|
453
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
454
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
455
|
+
return `${AX_CADENCE_HANDLERS}
|
|
456
|
+
|
|
457
|
+
${AX_FOCUS_GUARD_HANDLERS}
|
|
458
|
+
|
|
459
|
+
${SE}
|
|
460
|
+
set g to (${groupPath})
|
|
461
|
+
my cgSettle(g)
|
|
462
|
+
set tf to my cgField(g, "${target}", ${tol})
|
|
463
|
+
${alreadyHoldsBlock("tf", v)}
|
|
464
|
+
repeat ${n} times
|
|
465
|
+
my fgAssertFront("type \\"${v}\\" into the ${target} field")
|
|
466
|
+
set focused of tf to true
|
|
467
|
+
delay 0.15
|
|
468
|
+
${focusedAssertBlock("tf", v)}
|
|
469
|
+
keystroke "${v}"
|
|
470
|
+
delay 0.1
|
|
471
|
+
key code 48
|
|
472
|
+
delay 0.2
|
|
473
|
+
try
|
|
474
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
475
|
+
end try
|
|
476
|
+
delay 0.3
|
|
477
|
+
end repeat
|
|
478
|
+
error "the ${target} field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
479
|
+
end tell`;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* set-row-field: drive a Repeat-dialog text field addressed by the pinned English
|
|
483
|
+
* LABEL sharing its row — the same discrimination law as
|
|
484
|
+
* {@link axSetGroupNumberScript}, applied to a field that lives on the dialog
|
|
485
|
+
* SHELL rather than in the cadence group.
|
|
486
|
+
*
|
|
487
|
+
* Its one caller is the "and start [N] days earlier" offset the "Add deadlines"
|
|
488
|
+
* checkbox reveals, which shipped as `text field 1` of the shell. That address
|
|
489
|
+
* was the HXPC1 error class exactly: a value-bearing text field picked by index
|
|
490
|
+
* out of a STATE-DEPENDENT tree (the field does not exist at all until the
|
|
491
|
+
* checkbox is ticked), verified only by re-reading the same index it wrote. It
|
|
492
|
+
* happened to be right on 3.23 — measured, the shell carries 0 direct text fields
|
|
493
|
+
* with deadlines off and exactly 1 with them on, whether or not reminders are also
|
|
494
|
+
* on (CGRD1 §B census) — but nothing in the address said so, and an AX tree is an
|
|
495
|
+
* undocumented private surface that may add a second field in any release.
|
|
496
|
+
*
|
|
497
|
+
* The label anchor is `days earlier` (y=413 against the field's y=409, CGRD1 §B).
|
|
498
|
+
* A missing label, or anything other than exactly one field on its row, FAILS
|
|
499
|
+
* CLOSED naming the shell's whole text-field inventory. The write is the
|
|
500
|
+
* {@link axSetValueScript} closed loop.
|
|
501
|
+
*/
|
|
502
|
+
export function axSetRowFieldScript(containerPath, rowLabel, value, attempts = 3, rowTolerance = ROW_TOLERANCE) {
|
|
503
|
+
const v = escapeAppleScript(value);
|
|
504
|
+
const label = escapeAppleScript(rowLabel);
|
|
505
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
506
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
507
|
+
return `${AX_CADENCE_HANDLERS}
|
|
508
|
+
|
|
509
|
+
${AX_FOCUS_GUARD_HANDLERS}
|
|
510
|
+
|
|
511
|
+
${SE}
|
|
512
|
+
set c to (${containerPath})
|
|
513
|
+
set tf to my rfField(c, "${label}", ${tol})
|
|
514
|
+
${alreadyHoldsBlock("tf", v)}
|
|
515
|
+
repeat ${n} times
|
|
516
|
+
my fgAssertFront("type \\"${v}\\" into the \\"${label}\\" field")
|
|
517
|
+
set focused of tf to true
|
|
518
|
+
delay 0.15
|
|
519
|
+
${focusedAssertBlock("tf", v)}
|
|
520
|
+
keystroke "${v}"
|
|
521
|
+
delay 0.1
|
|
522
|
+
key code 48
|
|
523
|
+
delay 0.2
|
|
524
|
+
try
|
|
525
|
+
if ((value of tf) as text) is "${v}" then return "OK"
|
|
526
|
+
end try
|
|
527
|
+
delay 0.3
|
|
528
|
+
end repeat
|
|
529
|
+
error "the \\"${label}\\" field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
530
|
+
end tell`;
|
|
531
|
+
}
|
|
532
|
+
/** AppleScript list literal of quoted strings. */
|
|
533
|
+
function asList(values) {
|
|
534
|
+
return `{${values.map((v) => `"${escapeAppleScript(v)}"`).join(", ")}}`;
|
|
535
|
+
}
|
|
536
|
+
/** The intended value(s) as the mismatch report should read them. */
|
|
537
|
+
function intendedText(control) {
|
|
538
|
+
if (control.expectedLabel !== undefined)
|
|
539
|
+
return control.expectedLabel;
|
|
540
|
+
return control.expected.map((v) => `\\"${escapeAppleScript(v)}\\"`).join(" or ");
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* audit-dialog: RE-READ EVERY CONTROL THIS DRIVE SET, through each control's own
|
|
544
|
+
* discriminated address, and refuse to commit if any one of them does not hold
|
|
545
|
+
* the value the drive intended.
|
|
546
|
+
*
|
|
547
|
+
* This exists because a per-step read-back is SELF-REFERENTIAL. Every setter here
|
|
548
|
+
* confirms its write by re-reading the element it addressed, so it proves the
|
|
549
|
+
* keystrokes landed where they were aimed — and nothing else. The #589 wrong-field
|
|
550
|
+
* write reported OK for exactly that reason: the interval drive typed into the
|
|
551
|
+
* ends-count field, then read the ends-count field back and found its own number
|
|
552
|
+
* sitting there. The address was wrong; a read-back through the same address
|
|
553
|
+
* cannot see that.
|
|
554
|
+
*
|
|
555
|
+
* The audit is the outside view. It is assembled from the recipe's OWN step list
|
|
556
|
+
* (so no control the recipe drives can be left out of the audit by omission) and
|
|
557
|
+
* runs as the last step before the OK press, comparing the dialog's complete
|
|
558
|
+
* intended state against what the dialog actually shows: frequency, the
|
|
559
|
+
* after-completion cadence unit, interval, ends bound and its count, the
|
|
560
|
+
* deadline/reminder checkboxes, the start-days-earlier offset, the weekday set,
|
|
561
|
+
* the monthly/yearly anchor pop-ups and the 3.23 first-occurrence pop-up. A
|
|
562
|
+
* mismatch is an `error` naming EVERY differing control with both values, which
|
|
563
|
+
* aborts the drive fail-closed BEFORE the commit and runs the standard clean-abort
|
|
564
|
+
* path — nothing reaches the database.
|
|
565
|
+
*
|
|
566
|
+
* Deterministic throughout: the cadence group is settled on its own shape
|
|
567
|
+
* signature (the BEEP1 two-agreeing-reads gate), never on a sleep, and every field
|
|
568
|
+
* is found by its label row rather than by index ({@link AX_CADENCE_HANDLERS}).
|
|
569
|
+
*
|
|
570
|
+
* The dialog's three `AXDateTimeArea` controls are audited separately — their
|
|
571
|
+
* values are NSDates no System Events read can reach, so they ride
|
|
572
|
+
* {@link axAuditDateAreasScript} through the same ObjC bridge that writes them.
|
|
573
|
+
*/
|
|
574
|
+
export function axAuditDialogScript(spec, rowTolerance = ROW_TOLERANCE) {
|
|
575
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
576
|
+
const body = spec.controls
|
|
577
|
+
.map((c, i) => {
|
|
578
|
+
const name = escapeAppleScript(c.label);
|
|
579
|
+
const want = asList(c.expected);
|
|
580
|
+
const intended = intendedText(c);
|
|
581
|
+
const miss = `set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\")"`;
|
|
582
|
+
switch (c.kind) {
|
|
583
|
+
case "popup":
|
|
584
|
+
return ` set v${i} to "(unreadable)"
|
|
585
|
+
try
|
|
586
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
587
|
+
end try
|
|
588
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
589
|
+
case "occurrence-popup":
|
|
590
|
+
return ` set v${i} to "(unreadable)"
|
|
591
|
+
try
|
|
592
|
+
set v${i} to (value of (${c.path ?? ""})) as text
|
|
593
|
+
end try
|
|
594
|
+
set d${i} to my aqYMD(v${i})
|
|
595
|
+
if d${i} is missing value then
|
|
596
|
+
${miss}
|
|
597
|
+
else if not (my aqAny(d${i}, ${want})) then
|
|
598
|
+
set end of bad to "${name} (intended ${intended}, dialog shows \\"" & v${i} & "\\" = " & d${i} & ")"
|
|
599
|
+
end if`;
|
|
600
|
+
case "checkbox":
|
|
601
|
+
return ` set v${i} to "(unreadable)"
|
|
602
|
+
try
|
|
603
|
+
set v${i} to ((value of (${c.path ?? ""})) as integer) as text
|
|
604
|
+
end try
|
|
605
|
+
if not (my aqAny(v${i}, ${want})) then set end of bad to "${name} (intended ${intended}, dialog shows " & (my aqTick(v${i})) & ")"`;
|
|
606
|
+
case "group-number":
|
|
607
|
+
return ` set v${i} to "(unreadable)"
|
|
608
|
+
try
|
|
609
|
+
set v${i} to ((value of (my cgField(g, "${c.numberTarget ?? "interval"}", ${tol}))) as text)
|
|
610
|
+
end try
|
|
611
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
612
|
+
case "row-field":
|
|
613
|
+
return ` set v${i} to "(unreadable)"
|
|
614
|
+
try
|
|
615
|
+
set v${i} to ((value of (my rfField(sh, "${escapeAppleScript(c.rowLabel ?? "")}", ${tol}))) as text)
|
|
616
|
+
end try
|
|
617
|
+
if not (my aqAny(v${i}, ${want})) then ${miss}`;
|
|
618
|
+
case "weekdays":
|
|
619
|
+
return ` set got${i} to {}
|
|
620
|
+
repeat with k from ${Math.max(1, Math.trunc(c.weekdayBase ?? 2))} to (count of pop up buttons of g)
|
|
621
|
+
set end of got${i} to ((value of pop up button k of g) as text)
|
|
622
|
+
end repeat
|
|
623
|
+
set v${i} to my aqJoin(got${i}, ",")
|
|
624
|
+
set off${i} to false
|
|
625
|
+
repeat with w in ${want}
|
|
626
|
+
if not (my aqAny(w as text, got${i})) then set off${i} to true
|
|
627
|
+
end repeat
|
|
628
|
+
repeat with w in got${i}
|
|
629
|
+
if not (my aqAny(w as text, ${want})) then set off${i} to true
|
|
630
|
+
end repeat
|
|
631
|
+
if off${i} then ${miss}`;
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
.join("\n");
|
|
635
|
+
return `${AX_CADENCE_HANDLERS}
|
|
636
|
+
|
|
637
|
+
on aqAny(v, lst)
|
|
638
|
+
repeat with c in lst
|
|
639
|
+
if (v as text) is (c as text) then return true
|
|
640
|
+
end repeat
|
|
641
|
+
return false
|
|
642
|
+
end aqAny
|
|
643
|
+
|
|
644
|
+
on aqJoin(lst, sep)
|
|
645
|
+
set out to ""
|
|
646
|
+
repeat with x in lst
|
|
647
|
+
if out is not "" then set out to out & sep
|
|
648
|
+
set out to out & (x as text)
|
|
649
|
+
end repeat
|
|
650
|
+
return out
|
|
651
|
+
end aqJoin
|
|
652
|
+
|
|
653
|
+
on aqTick(v)
|
|
654
|
+
if (v as text) is "1" then return "checked"
|
|
655
|
+
if (v as text) is "0" then return "unchecked"
|
|
656
|
+
return "\\"" & (v as text) & "\\""
|
|
657
|
+
end aqTick
|
|
658
|
+
|
|
659
|
+
on aqPad2(n)
|
|
660
|
+
set s to (n as integer) as text
|
|
661
|
+
if (length of s) < 2 then set s to "0" & s
|
|
662
|
+
return s
|
|
663
|
+
end aqPad2
|
|
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
|
+
|
|
696
|
+
on aqYMD(t)
|
|
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).
|
|
701
|
+
set s to t as text
|
|
702
|
+
set rel to my aqRelative(s)
|
|
703
|
+
if rel is not missing value then return rel
|
|
704
|
+
try
|
|
705
|
+
set d to date s
|
|
706
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
707
|
+
end try
|
|
708
|
+
try
|
|
709
|
+
set ofs to offset of ", " in s
|
|
710
|
+
if ofs > 0 then
|
|
711
|
+
set d to date (text (ofs + 2) thru -1 of s)
|
|
712
|
+
return ((year of d) as text) & "-" & my aqPad2((month of d) as integer) & "-" & my aqPad2(day of d)
|
|
713
|
+
end if
|
|
714
|
+
end try
|
|
715
|
+
return missing value
|
|
716
|
+
end aqYMD
|
|
717
|
+
|
|
718
|
+
${SE}
|
|
719
|
+
set sh to (${spec.shell})
|
|
720
|
+
set g to (${spec.group})
|
|
721
|
+
my cgSettle(g)
|
|
722
|
+
set bad to {}
|
|
723
|
+
${body}
|
|
724
|
+
if (count of bad) is 0 then return "OK"
|
|
725
|
+
error "the Repeat dialog does not hold what this drive entered — " & (count of bad) & " control(s) differ: " & my aqJoin(bad, "; ")
|
|
726
|
+
end tell`;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The pre-commit audit's DATE-AREA leg. The dialog's first-occurrence, ends-on and
|
|
730
|
+
* reminder controls are `AXDateTimeArea`s whose value is an NSDate — unreachable
|
|
731
|
+
* from System Events — so they are re-read through the SAME ObjC bridge, the same
|
|
732
|
+
* shell-scoped walk and the SAME deterministic `pick` discriminator that
|
|
733
|
+
* {@link axSetDateTimeScript} writes them through. A control the audit cannot find,
|
|
734
|
+
* or one holding a different date/time, throws naming every area the dialog does
|
|
735
|
+
* present (y-position + time-of-day), so the drive aborts before the OK press.
|
|
736
|
+
*/
|
|
737
|
+
export function axAuditDateAreasScript(areas) {
|
|
738
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
739
|
+
function run(){
|
|
740
|
+
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
741
|
+
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
742
|
+
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
743
|
+
var wanted=${JSON.stringify(areas)};
|
|
744
|
+
var cal=$.NSCalendar.currentCalendar;
|
|
745
|
+
var found=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,found); }catch(e){ found=[]; }
|
|
746
|
+
var bad=[];
|
|
747
|
+
for(var i=0;i<wanted.length;i++){
|
|
748
|
+
var w=wanted[i];
|
|
749
|
+
var dt=pick(found,w.target);
|
|
750
|
+
if(!dt){ bad.push(w.label+' (intended '+w.spec+', but this dialog state presents no '+w.target+' control among ['+inv(found)+'])'); continue; }
|
|
751
|
+
if(w.spec.indexOf('date:')===0){
|
|
752
|
+
var got=ymdStr(dt,cal), want=w.spec.slice(5);
|
|
753
|
+
if(got!==want) bad.push(w.label+' (intended '+want+', dialog shows '+(got||'(no value)')+')');
|
|
754
|
+
} else {
|
|
755
|
+
var gott=hmStr(dt,cal), p=w.spec.slice(5).split(':'), wantt=(+p[0])+':'+('0'+(+p[1])).slice(-2);
|
|
756
|
+
if(gott!==wantt) bad.push(w.label+' (intended '+wantt+', dialog shows '+(gott||'(no value)')+')');
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if(bad.length) throw new Error('the Repeat dialog does not hold what this drive entered — '+bad.length+' control(s) differ: '+bad.join('; '));
|
|
760
|
+
return 'OK';
|
|
761
|
+
}`;
|
|
762
|
+
}
|
|
124
763
|
/**
|
|
125
764
|
* ensure-checkbox: converge a dialog checkbox to a target state through a
|
|
126
765
|
* DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
|
|
@@ -310,6 +949,9 @@ ${SE}
|
|
|
310
949
|
set theMenu to menu 1 of pu
|
|
311
950
|
set clickedTitle to ""
|
|
312
951
|
set levelsSeen to 0
|
|
952
|
+
set opener to (value of pu) as text
|
|
953
|
+
set sample to ""
|
|
954
|
+
set sampled to 0
|
|
313
955
|
if isToday then
|
|
314
956
|
set nms to name of every menu item of theMenu
|
|
315
957
|
if (count of nms) > 0 then
|
|
@@ -330,6 +972,13 @@ ${SE}
|
|
|
330
972
|
repeat with i from 1 to (count of nms)
|
|
331
973
|
set nm to item i of nms
|
|
332
974
|
if nm is not missing value then
|
|
975
|
+
-- Keep a short sample of what THIS menu actually offered, so a miss can
|
|
976
|
+
-- report the dates the dialog had rather than only the one it lacked.
|
|
977
|
+
if sampled < ${SAMPLE_ITEMS} then
|
|
978
|
+
if sample is not "" then set sample to sample & ", "
|
|
979
|
+
set sample to sample & (nm as text)
|
|
980
|
+
set sampled to sampled + 1
|
|
981
|
+
end if
|
|
333
982
|
set ymd to my parsedYMD(nm)
|
|
334
983
|
if ymd is not missing value then
|
|
335
984
|
if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
|
|
@@ -363,7 +1012,7 @@ ${SE}
|
|
|
363
1012
|
end repeat
|
|
364
1013
|
if clickedTitle is "" then
|
|
365
1014
|
key code 53
|
|
366
|
-
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu. Ask for a date the rule actually produces, or change the rule."
|
|
1015
|
+
error "select-next-occurrence: this Repeat dialog offers only the rule's own upcoming occurrences (and today) as the first occurrence, and ${isoDate} is not one of them — searched " & levelsSeen & " level(s) of the Next: menu, which opened on \\"" & opener & "\\" and led with: " & sample & ". Ask for a date the rule actually produces, or change the rule."
|
|
367
1016
|
end if
|
|
368
1017
|
delay 0.4
|
|
369
1018
|
set shown to (value of pu) as text
|
|
@@ -373,6 +1022,48 @@ ${SE}
|
|
|
373
1022
|
return "OK"
|
|
374
1023
|
end tell`;
|
|
375
1024
|
}
|
|
1025
|
+
/**
|
|
1026
|
+
* settle-occurrences: let the 3.23 `Next:` pop-up ABSORB the rule change the
|
|
1027
|
+
* preceding steps made, before the drive touches the dialog again (NEXTPOP1).
|
|
1028
|
+
*
|
|
1029
|
+
* MEASURED (golden-v4 / Things 3.23, `research-nextpop1.sh` DIAG3/DIAG4): the
|
|
1030
|
+
* dialog recomputes the first-occurrence pop-up — its displayed value AND the
|
|
1031
|
+
* menu of occurrences behind it — ASYNCHRONOUSLY. After the yearly anchor was
|
|
1032
|
+
* moved from Aug 6 to Aug 20 the control flipped at **t+0.4s** with nothing else
|
|
1033
|
+
* driven; when the very next step (the "Add deadlines" checkbox) was pressed
|
|
1034
|
+
* inside that window instead, the control NEVER caught up — it still read
|
|
1035
|
+
* `Thu, Aug 6, 2026`, and its menu still enumerated the Aug-6 series, six
|
|
1036
|
+
* seconds later. A cancelled recompute does not retry.
|
|
1037
|
+
*
|
|
1038
|
+
* That is what made every deadlined monthly/yearly promote fail closed on 3.23:
|
|
1039
|
+
* the anchor drive is followed immediately by the deadline controls, so by the
|
|
1040
|
+
* time `select-next-occurrence` opened the menu it was the SEED's series, and
|
|
1041
|
+
* the requested date — the rule's own first due date — was genuinely not in it
|
|
1042
|
+
* (VMRES1 §4.3, reproduced and explained in NEXTPOP1).
|
|
1043
|
+
*
|
|
1044
|
+
* The wait is closed-loop in the direction that matters: it exits the moment the
|
|
1045
|
+
* control MOVES, which is the case that needs waiting for. When the rule change
|
|
1046
|
+
* did not move the first occurrence there is nothing to observe, so the budget
|
|
1047
|
+
* bounds it — deliberately over-cautious, since the cost is a fraction of a
|
|
1048
|
+
* second and the alternative is a series that starts on the wrong date.
|
|
1049
|
+
*/
|
|
1050
|
+
export function axSettleOccurrencesScript(popupPath, budgetMs = OCCURRENCE_SETTLE_MS, pollMs = OCCURRENCE_POLL_MS) {
|
|
1051
|
+
const poll = Math.max(50, Math.trunc(pollMs)) / 1000;
|
|
1052
|
+
const reads = Math.max(1, Math.ceil(Math.max(1, Math.trunc(budgetMs)) / Math.max(50, pollMs)));
|
|
1053
|
+
// `before` and `after` are AppleScript's own positional keywords and `now` is
|
|
1054
|
+
// taken too — `set before to …` does not even COMPILE (osacompile: "Expected
|
|
1055
|
+
// expression but found “to”"), and osascript reports that as a drive failure at
|
|
1056
|
+
// run time, mid-dialog. Hence the deliberately dull variable names.
|
|
1057
|
+
return `${SE}
|
|
1058
|
+
set wasValue to (value of ${popupPath}) as text
|
|
1059
|
+
repeat ${reads} times
|
|
1060
|
+
delay ${poll}
|
|
1061
|
+
set curValue to (value of ${popupPath}) as text
|
|
1062
|
+
if curValue is not wasValue then return "moved: " & wasValue & " -> " & curValue
|
|
1063
|
+
end repeat
|
|
1064
|
+
return "unchanged: " & wasValue
|
|
1065
|
+
end tell`;
|
|
1066
|
+
}
|
|
376
1067
|
/**
|
|
377
1068
|
* converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
|
|
378
1069
|
* set through a deterministic closed loop (RDLG2 — the RRD1 fix).
|
|
@@ -435,7 +1126,10 @@ ${SE}
|
|
|
435
1126
|
delay 0.3
|
|
436
1127
|
end repeat
|
|
437
1128
|
if not (exists menu item wantVal of menu 1 of pu) then
|
|
438
|
-
|
|
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.
|
|
439
1133
|
error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
|
|
440
1134
|
end if
|
|
441
1135
|
click menu item wantVal of menu 1 of pu
|
|
@@ -588,20 +1282,206 @@ return "OK"`;
|
|
|
588
1282
|
export function axActivateScript() {
|
|
589
1283
|
return `tell application "Things3" to activate`;
|
|
590
1284
|
}
|
|
591
|
-
/**
|
|
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
|
+
*/
|
|
592
1292
|
export function axKeyScript(keys) {
|
|
593
1293
|
const KEY_CODES = { return: 36, escape: 53, down: 125, up: 126, tab: 48 };
|
|
1294
|
+
const spec = keys.trim();
|
|
594
1295
|
const lines = keys
|
|
595
1296
|
.split(/\s+/)
|
|
596
1297
|
.filter((k) => k !== "")
|
|
597
1298
|
.map((k) => KEY_CODES[k] !== undefined
|
|
598
1299
|
? `key code ${KEY_CODES[k]}`
|
|
599
1300
|
: `keystroke "${escapeAppleScript(k)}"`);
|
|
600
|
-
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`;
|
|
601
1307
|
}
|
|
602
|
-
/**
|
|
1308
|
+
/**
|
|
1309
|
+
* type-text: send literal text to whatever control holds focus (HXPC1). The
|
|
1310
|
+
* Move… picker focuses its own filter field the instant it opens, and that field
|
|
1311
|
+
* is NOT addressable as a direct child of the picker window — so there is no
|
|
1312
|
+
* element to hand `set-value`, whose select-all + Tab commit would be wrong for a
|
|
1313
|
+
* search field regardless (a popover filter has no next key view for Tab to move
|
|
1314
|
+
* to). Unlike {@link axKeyScript}, which splits its spec on whitespace and would
|
|
1315
|
+
* drop the spaces out of a multi-word project title, this sends the string as
|
|
1316
|
+
* ONE keystroke. It is deliberately not self-verifying: the `click-picker-row`
|
|
1317
|
+
* step that follows resolves the destination row by name and fails closed when
|
|
1318
|
+
* the filter did not produce it, so a keystroke that landed elsewhere can never
|
|
1319
|
+
* be committed. One stable command shape.
|
|
1320
|
+
*/
|
|
1321
|
+
export function axTypeTextScript(text) {
|
|
1322
|
+
return `${AX_FOCUS_GUARD_HANDLERS}
|
|
1323
|
+
|
|
1324
|
+
my fgAssertFront("type into the focused field")
|
|
1325
|
+
${SE}
|
|
1326
|
+
keystroke "${escapeAppleScript(text)}"
|
|
1327
|
+
end tell`;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* resolve-frame for a control nested inside a CONTENT-TABLE ROW: walk the
|
|
1331
|
+
* table's rows → cells → cell children and return the frame of the one whose
|
|
1332
|
+
* `AXDescription` equals `description` (HXPC1, docs/lab/hxpc1-picker-assert.md
|
|
1333
|
+
* §B0). Same "x y w h" contract as {@link axFrameScript}.
|
|
1334
|
+
*
|
|
1335
|
+
* This exists because the heading row's `…` button — the only content-row
|
|
1336
|
+
* control that carries its own title (`"More. <heading title>"`, the HEADXPROJ
|
|
1337
|
+
* enabler) — sits at `UI element N of cell 1 of row M of the table`, and
|
|
1338
|
+
* `first UI element of <table> whose description is …` searches the table's
|
|
1339
|
+
* DIRECT children only. Those are the rows, which carry no description, so the
|
|
1340
|
+
* shipped one-level spelling matched nothing and the ellipsis drives
|
|
1341
|
+
* (`project.move-heading-to-project`, `project.dissolve-heading`) died at their
|
|
1342
|
+
* own frame resolution before any click — measured on Things 3.23 against a
|
|
1343
|
+
* heading whose button the raw Accessibility API resolves at the same instant.
|
|
1344
|
+
* The row/cell indices are never guessed: every row is walked and the match is
|
|
1345
|
+
* exact, so a heading whose title changed under us fails closed by name.
|
|
1346
|
+
*/
|
|
1347
|
+
export function axRowCellFrameScript(tablePath, description) {
|
|
1348
|
+
const d = escapeAppleScript(description);
|
|
1349
|
+
return `${SE}
|
|
1350
|
+
set t to (${tablePath})
|
|
1351
|
+
repeat with r in rows of t
|
|
1352
|
+
repeat with c in UI elements of r
|
|
1353
|
+
repeat with e in UI elements of c
|
|
1354
|
+
try
|
|
1355
|
+
if ((description of e) as text) is "${d}" then
|
|
1356
|
+
set _p to position of e
|
|
1357
|
+
set _s to size of e
|
|
1358
|
+
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)
|
|
1359
|
+
end if
|
|
1360
|
+
end try
|
|
1361
|
+
end repeat
|
|
1362
|
+
end repeat
|
|
1363
|
+
end repeat
|
|
1364
|
+
error "no row of this project's list exposes \\"${d}\\" — the heading may have been renamed, moved or deleted since it was read"
|
|
1365
|
+
end tell`;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* resolve-frame for the Move… picker ROW carrying an exact project title — the
|
|
1369
|
+
* step that replaced the recipe's blind Return (HXPC1,
|
|
1370
|
+
* docs/lab/hxpc1-picker-assert.md §B).
|
|
1371
|
+
*
|
|
1372
|
+
* The picker exposes no `AXSelected` / `AXFocused` / `AXHighlighted` on any row
|
|
1373
|
+
* (measured — only its filter field is focused), so there is nothing to read
|
|
1374
|
+
* back from a keyboard commit and no way to assert what Return would take. What
|
|
1375
|
+
* it does expose is one `AXUnknown` per row whose `AXDescription` IS the project
|
|
1376
|
+
* title, and — whenever the filter holds text — a trailing
|
|
1377
|
+
* `New Project "<typed text>"` row that CREATES a project when committed. That
|
|
1378
|
+
* row is what the blind Return took whenever the destination was missing from
|
|
1379
|
+
* the picker, which an ordinary database-resolved destination reaches: a
|
|
1380
|
+
* COMPLETED or CANCELED project appears nowhere in the picker, so the drive
|
|
1381
|
+
* minted a second project of the same title and moved the heading into it
|
|
1382
|
+
* (measured 3.23: projects 14 → 15, heading re-parented to the new row).
|
|
1383
|
+
*
|
|
1384
|
+
* So the commit is addressed instead of guessed. The script requires:
|
|
1385
|
+
* - the picker to be the window it claims (its `AXIdentifier` begins
|
|
1386
|
+
* `MovePopUpDialog-`) — a positive identity check, so a different detached
|
|
1387
|
+
* window can never be clicked into;
|
|
1388
|
+
* - EXACTLY ONE row whose description equals the destination title (the
|
|
1389
|
+
* New-Project row's description is the quoted form, so an exact match cannot
|
|
1390
|
+
* hit it);
|
|
1391
|
+
* - that row's centre to lie inside the picker's own scroll area — the CNCAC1
|
|
1392
|
+
* off-screen hazard, where a row scrolled past the fold still resolves a
|
|
1393
|
+
* frame and a click at it lands on the desktop.
|
|
1394
|
+
* Any miss FAILS CLOSED naming the destination and listing every row the picker
|
|
1395
|
+
* actually offered, so the caller learns what the app was willing to move to.
|
|
1396
|
+
*/
|
|
1397
|
+
export function axPickerRowFrameScript(pickerPath, title) {
|
|
1398
|
+
const t = escapeAppleScript(title);
|
|
1399
|
+
return `${SE}
|
|
1400
|
+
set w to (${pickerPath})
|
|
1401
|
+
set pickerId to ""
|
|
1402
|
+
try
|
|
1403
|
+
set pickerId to (value of attribute "AXIdentifier" of w) as text
|
|
1404
|
+
end try
|
|
1405
|
+
if pickerId does not start with "MovePopUpDialog-" then
|
|
1406
|
+
error "the front dialog is not the Move… project picker (window id \\"" & pickerId & "\\") — nothing was committed"
|
|
1407
|
+
end if
|
|
1408
|
+
-- positional-ok: the picker window holds exactly one scroll area (MEASURED,
|
|
1409
|
+
-- HXPC1 §B2: "direct text fields=0 scroll areas=1"), and the window's own
|
|
1410
|
+
-- AXIdentifier was checked above, so this is a container handle inside an
|
|
1411
|
+
-- already-identified window — the ROW is addressed by exact title below.
|
|
1412
|
+
set sa to scroll area 1 of w
|
|
1413
|
+
set saPos to position of sa
|
|
1414
|
+
set saSize to size of sa
|
|
1415
|
+
set saTop to item 2 of saPos
|
|
1416
|
+
set saBottom to saTop + (item 2 of saSize)
|
|
1417
|
+
set hits to {}
|
|
1418
|
+
set offered to ""
|
|
1419
|
+
repeat with i from 1 to (count of UI elements of sa)
|
|
1420
|
+
set e to UI element i of sa
|
|
1421
|
+
set d to ""
|
|
1422
|
+
try
|
|
1423
|
+
set d to (description of e) as text
|
|
1424
|
+
end try
|
|
1425
|
+
if d is not "" and (role of e) is "AXUnknown" then
|
|
1426
|
+
set offered to offered & " [" & d & "]"
|
|
1427
|
+
if d is "${t}" then set end of hits to e
|
|
1428
|
+
end if
|
|
1429
|
+
end repeat
|
|
1430
|
+
if (count of hits) is 0 then
|
|
1431
|
+
error "the Move… picker offers no project named \\"${t}\\" — it offered:" & offered & ". Committing here would have created a new project with that name instead of moving into the existing one. A completed or canceled project is not offered by this picker."
|
|
1432
|
+
end if
|
|
1433
|
+
if (count of hits) > 1 then
|
|
1434
|
+
error "the Move… picker offers " & (count of hits) & " rows named \\"${t}\\" — it offered:" & offered
|
|
1435
|
+
end if
|
|
1436
|
+
set row1 to item 1 of hits
|
|
1437
|
+
set rp to position of row1
|
|
1438
|
+
set rs to size of row1
|
|
1439
|
+
set cy to (item 2 of rp) + ((item 2 of rs) / 2)
|
|
1440
|
+
if cy < saTop or cy > saBottom then
|
|
1441
|
+
error "the \\"${t}\\" row is scrolled out of the Move… picker's visible list, so clicking it would land outside the picker — narrow the destination or scroll it into view"
|
|
1442
|
+
end if
|
|
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)
|
|
1444
|
+
end tell`;
|
|
1445
|
+
}
|
|
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
|
+
*/
|
|
603
1453
|
export function axAbortScript() {
|
|
604
|
-
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`;
|
|
605
1485
|
}
|
|
606
1486
|
/**
|
|
607
1487
|
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
@@ -619,6 +1499,9 @@ export function axAbortScript() {
|
|
|
619
1499
|
export function axCloseReopenActivateScript() {
|
|
620
1500
|
return `tell application "Things3"
|
|
621
1501
|
try
|
|
1502
|
+
-- positional-ok: an APP-LEVEL command to the Things scripting dictionary, not
|
|
1503
|
+
-- an Accessibility element path — "close the front window", whichever it is,
|
|
1504
|
+
-- which is the whole intent of the maneuver (a stuck sheet goes with it).
|
|
622
1505
|
close window 1
|
|
623
1506
|
end try
|
|
624
1507
|
reopen
|
|
@@ -640,6 +1523,8 @@ export function axSheetOpenScript() {
|
|
|
640
1523
|
return `${SE}
|
|
641
1524
|
set sheetOpen to false
|
|
642
1525
|
try
|
|
1526
|
+
-- positional-ok: an EXISTENCE probe over the one attached sheet a window can
|
|
1527
|
+
-- present; no element is read or written through this path.
|
|
643
1528
|
if (exists sheet 1 of (first window whose subrole is "AXStandardWindow")) then set sheetOpen to true
|
|
644
1529
|
end try
|
|
645
1530
|
try
|
|
@@ -656,36 +1541,152 @@ async function sheetStillOpen(run) {
|
|
|
656
1541
|
return !(res.ok && res.stdout.trim() === "false");
|
|
657
1542
|
}
|
|
658
1543
|
/**
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
|
|
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.
|
|
1554
|
+
*/
|
|
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}.
|
|
670
1596
|
*/
|
|
671
|
-
async function clearDialog(run) {
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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) {
|
|
678
1640
|
await run({
|
|
679
|
-
primitive: "
|
|
680
|
-
label: "
|
|
681
|
-
script:
|
|
1641
|
+
primitive: "activate",
|
|
1642
|
+
label: "bring Things forward to dismiss its dialog",
|
|
1643
|
+
script: axActivateScript(),
|
|
682
1644
|
}, STEP_TIMEOUT_MS);
|
|
683
|
-
|
|
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
|
+
}
|
|
684
1654
|
}
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
+
};
|
|
689
1690
|
}
|
|
690
1691
|
/**
|
|
691
1692
|
* The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
|
|
@@ -807,45 +1808,7 @@ function clickPointCommand(x, y, label) {
|
|
|
807
1808
|
* calendar date at midnight). One stable JXA shape.
|
|
808
1809
|
*/
|
|
809
1810
|
export function axSetDateTimeScript(spec, target) {
|
|
810
|
-
return
|
|
811
|
-
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
812
|
-
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
813
|
-
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
814
|
-
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
815
|
-
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
816
|
-
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
817
|
-
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
818
|
-
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
819
|
-
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
820
|
-
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
821
|
-
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
822
|
-
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
823
|
-
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
824
|
-
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
825
|
-
// present — the caller then falls through to the same named "presents 0 date
|
|
826
|
-
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
827
|
-
function findShell(app){
|
|
828
|
-
var wins=windowsOf(app);
|
|
829
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
830
|
-
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
831
|
-
return null;
|
|
832
|
-
}
|
|
833
|
-
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
834
|
-
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
835
|
-
function pick(areas,target){
|
|
836
|
-
if(areas.length===0) return null;
|
|
837
|
-
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
838
|
-
if(target==='reminder'){
|
|
839
|
-
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
840
|
-
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
841
|
-
}
|
|
842
|
-
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
843
|
-
if(midnight.length===0) midnight=sorted;
|
|
844
|
-
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
845
|
-
}
|
|
846
|
-
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
847
|
-
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
848
|
-
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1811
|
+
return `${AX_DATE_AREA_PRELUDE}
|
|
849
1812
|
function run(){
|
|
850
1813
|
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
851
1814
|
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
@@ -883,7 +1846,9 @@ function run(){
|
|
|
883
1846
|
$.NSThread.sleepForTimeInterval(0.2);
|
|
884
1847
|
// READ-BACK: a control can accept the AX write (err 0) yet reject the value —
|
|
885
1848
|
// the macOS error beep — leaving its prior/default value. Fail the step loudly
|
|
886
|
-
// rather than let a garbled commit verify as ok (YANCH1 #493).
|
|
1849
|
+
// rather than let a garbled commit verify as ok (YANCH1 #493). Like every
|
|
1850
|
+
// per-step read-back here it is SELF-REFERENTIAL (it re-reads the area it just
|
|
1851
|
+
// picked), so the pre-commit audit re-checks it from the outside.
|
|
887
1852
|
if(spec.indexOf('date:')===0){
|
|
888
1853
|
var got=ymdStr(dt,cal); var want=spec.slice(5);
|
|
889
1854
|
if(got!==want) throw new Error('set-datetime '+target+' rejected: the control committed '+(got||'(no value)')+', not the requested '+want+' — the write did not take');
|
|
@@ -894,6 +1859,55 @@ function run(){
|
|
|
894
1859
|
return 'OK';
|
|
895
1860
|
}`;
|
|
896
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* The shared ObjC-bridge prelude for every `AXDateTimeArea` read or write: the
|
|
1864
|
+
* attribute helpers, the DIALOG-SHELL resolver (so the walk stays inside the
|
|
1865
|
+
* dialog's small subtree — the app-root descent PERF2 removed cost ~4.4s on a busy
|
|
1866
|
+
* host), and the deterministic {@link pick} target discriminator. The write
|
|
1867
|
+
* ({@link axSetDateTimeScript}) and the pre-commit read
|
|
1868
|
+
* ({@link axAuditDateAreasScript}) MUST agree on which area is which, so they
|
|
1869
|
+
* share one definition rather than two that can drift apart.
|
|
1870
|
+
*/
|
|
1871
|
+
const AX_DATE_AREA_PRELUDE = `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
|
|
1872
|
+
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
1873
|
+
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
1874
|
+
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
1875
|
+
function collect(el,role,depth,out){ if(depth<0) return; if(rolestr(el)===role) out.push(el); var ks=kids(el); for(var i=0;i<ks.length;i++) collect(ks[i],role,depth-1,out); }
|
|
1876
|
+
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
1877
|
+
function windowsOf(el){ var c=attr(el,'AXWindows'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a; }
|
|
1878
|
+
function sizeWH(el){ var s=attr(el,'AXSize'); if(!s) return null; var d=ObjC.castRefToObject($.CFCopyDescription(s)).js; var mw=String(d).match(/w:([-0-9.]+)/); var mh=String(d).match(/h:([-0-9.]+)/); return (mw&&mh)? {w:+mw[1], h:+mh[1]} : null; }
|
|
1879
|
+
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
1880
|
+
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
1881
|
+
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
1882
|
+
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
1883
|
+
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
1884
|
+
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
1885
|
+
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
1886
|
+
// present — the caller then falls through to the same named "presents 0 date
|
|
1887
|
+
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
1888
|
+
function findShell(app){
|
|
1889
|
+
var wins=windowsOf(app);
|
|
1890
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXStandardWindow'){ var sh=[]; collect(wins[i],'AXSheet',3,sh); if(sh.length) return sh[0]; } }
|
|
1891
|
+
for(var i=0;i<wins.length;i++){ if(subrole(wins[i])==='AXUnknown'){ var wh=sizeWH(wins[i]); if(!wh || !(wh.w===40 && wh.h===40)) return wins[i]; } }
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
function posY(el){ var p=attr(el,'AXPosition'); if(!p) return 0; var d=ObjC.castRefToObject($.CFCopyDescription(p)).js; var m=String(d).match(/y:([-0-9.]+)/); return m? +m[1] : 0; }
|
|
1895
|
+
function timeOfDay(el){ var v=attr(el,'AXValue'); if(!v) return -1; var cal=$.NSCalendar.currentCalendar; return cal.componentFromDate($.NSCalendarUnitHour,v)*60 + cal.componentFromDate($.NSCalendarUnitMinute,v); }
|
|
1896
|
+
function pick(areas,target){
|
|
1897
|
+
if(areas.length===0) return null;
|
|
1898
|
+
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
1899
|
+
if(target==='reminder'){
|
|
1900
|
+
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
1901
|
+
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
1902
|
+
}
|
|
1903
|
+
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
1904
|
+
if(midnight.length===0) midnight=sorted;
|
|
1905
|
+
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
1906
|
+
}
|
|
1907
|
+
function inv(areas){ var s=[]; for(var i=0;i<areas.length;i++){ s.push('#'+i+'(y='+Math.round(posY(areas[i]))+',tod='+timeOfDay(areas[i])+')'); } return areas.length? s.join(' ') : '(none)'; }
|
|
1908
|
+
function ymdStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var y=cal.componentFromDate($.NSCalendarUnitYear,v), m=cal.componentFromDate($.NSCalendarUnitMonth,v), dd=cal.componentFromDate($.NSCalendarUnitDay,v); return y+'-'+('0'+m).slice(-2)+'-'+('0'+dd).slice(-2); }
|
|
1909
|
+
function hmStr(el,cal){ var v=attr(el,'AXValue'); if(!v) return null; var h=cal.componentFromDate($.NSCalendarUnitHour,v), mi=cal.componentFromDate($.NSCalendarUnitMinute,v); return h+':'+('0'+mi).slice(-2); }
|
|
1910
|
+
`;
|
|
897
1911
|
/**
|
|
898
1912
|
* The converge-weekdays step encodes both of its inputs in `value` as
|
|
899
1913
|
* `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
|
|
@@ -944,6 +1958,15 @@ async function defaultRun(command, timeoutMs) {
|
|
|
944
1958
|
...(res.timedOut === true && { timedOut: true }),
|
|
945
1959
|
};
|
|
946
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
|
+
}
|
|
947
1970
|
/**
|
|
948
1971
|
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
949
1972
|
* step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
|
|
@@ -978,6 +2001,159 @@ function tracingRun(inner) {
|
|
|
978
2001
|
return res;
|
|
979
2002
|
};
|
|
980
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
|
+
}
|
|
981
2157
|
/** The element paths the preflight canary resolves (static steps only). */
|
|
982
2158
|
function canaryPaths(recipe) {
|
|
983
2159
|
const out = [];
|
|
@@ -1051,6 +2227,33 @@ export function commandForStep(step, targetUuid) {
|
|
|
1051
2227
|
label: step.label,
|
|
1052
2228
|
script: axSetValueScript(step.path ?? "", step.value ?? ""),
|
|
1053
2229
|
};
|
|
2230
|
+
case "set-group-number":
|
|
2231
|
+
return {
|
|
2232
|
+
primitive: "set-group-number",
|
|
2233
|
+
label: step.label,
|
|
2234
|
+
script: axSetGroupNumberScript(step.path ?? "", step.numberTarget ?? "interval", step.value ?? ""),
|
|
2235
|
+
};
|
|
2236
|
+
case "set-row-field":
|
|
2237
|
+
return {
|
|
2238
|
+
primitive: "set-row-field",
|
|
2239
|
+
label: step.label,
|
|
2240
|
+
script: axSetRowFieldScript(step.path ?? "", step.rowLabel ?? "", step.value ?? ""),
|
|
2241
|
+
};
|
|
2242
|
+
case "audit-dialog":
|
|
2243
|
+
// Compiled by driveDialogAudit, which resolves the live dialog shell and the
|
|
2244
|
+
// measured shape first (a control's path and its weekday base both depend on
|
|
2245
|
+
// them). This shape exists only so the step renders/compiles uniformly.
|
|
2246
|
+
return {
|
|
2247
|
+
primitive: "audit-dialog",
|
|
2248
|
+
label: step.label,
|
|
2249
|
+
script: "",
|
|
2250
|
+
};
|
|
2251
|
+
case "type-text":
|
|
2252
|
+
return {
|
|
2253
|
+
primitive: "type-text",
|
|
2254
|
+
label: step.label,
|
|
2255
|
+
script: axTypeTextScript(step.value ?? ""),
|
|
2256
|
+
};
|
|
1054
2257
|
case "select-popup":
|
|
1055
2258
|
return {
|
|
1056
2259
|
primitive: "select-popup",
|
|
@@ -1084,6 +2287,12 @@ export function commandForStep(step, targetUuid) {
|
|
|
1084
2287
|
label: step.label,
|
|
1085
2288
|
script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
|
|
1086
2289
|
};
|
|
2290
|
+
case "settle-occurrences":
|
|
2291
|
+
return {
|
|
2292
|
+
primitive: "settle-occurrences",
|
|
2293
|
+
label: step.label,
|
|
2294
|
+
script: axSettleOccurrencesScript(step.path ?? ""),
|
|
2295
|
+
};
|
|
1087
2296
|
case "converge-weekdays":
|
|
1088
2297
|
return {
|
|
1089
2298
|
primitive: "converge-weekdays",
|
|
@@ -1117,11 +2326,26 @@ export function commandForStep(step, targetUuid) {
|
|
|
1117
2326
|
case "click-element":
|
|
1118
2327
|
// Phase 1 of the click: read the target's frame. driveClickElement runs
|
|
1119
2328
|
// this, then posts the click at the resolved center and asserts the outcome.
|
|
2329
|
+
// A `rowCellDescription` step resolves its target by walking the addressed
|
|
2330
|
+
// content table's rows/cells instead (the heading `…` button, which sits
|
|
2331
|
+
// three levels below the table a `whose` clause can reach).
|
|
1120
2332
|
return {
|
|
1121
2333
|
primitive: "resolve-frame",
|
|
1122
2334
|
label: step.label,
|
|
1123
2335
|
lang: "applescript",
|
|
1124
|
-
script:
|
|
2336
|
+
script: step.rowCellDescription !== undefined
|
|
2337
|
+
? axRowCellFrameScript(step.path ?? "", step.rowCellDescription)
|
|
2338
|
+
: axFrameScript(step.path ?? ""),
|
|
2339
|
+
};
|
|
2340
|
+
case "click-picker-row":
|
|
2341
|
+
// Phase 1 of the picker commit: resolve the row carrying the destination's
|
|
2342
|
+
// exact title (identity-checked, uniqueness-checked, on-screen-checked).
|
|
2343
|
+
// driveClickElement then clicks it — the recipe never presses Return.
|
|
2344
|
+
return {
|
|
2345
|
+
primitive: "resolve-frame",
|
|
2346
|
+
label: step.label,
|
|
2347
|
+
lang: "applescript",
|
|
2348
|
+
script: axPickerRowFrameScript(step.path ?? "", step.value ?? ""),
|
|
1125
2349
|
};
|
|
1126
2350
|
case "drag-reorder":
|
|
1127
2351
|
// Composite step: drive() hands it to the sidebar drag driver, which
|
|
@@ -1133,8 +2357,32 @@ export function commandForStep(step, targetUuid) {
|
|
|
1133
2357
|
lang: "javascript",
|
|
1134
2358
|
script: jxaSidebarSnapshotScript(),
|
|
1135
2359
|
};
|
|
2360
|
+
case "chord-reorder":
|
|
2361
|
+
// Composite step: drive() hands it to the heading-chord driver, which
|
|
2362
|
+
// dispatches its own select/chord commands through `run` and asserts the
|
|
2363
|
+
// database between them. This shape only exists so the step renders and
|
|
2364
|
+
// compiles uniformly; the chord it names is the FIRST hop's, and the
|
|
2365
|
+
// driver recomputes every subsequent one from the live order.
|
|
2366
|
+
return chordCommand("up-one");
|
|
1136
2367
|
}
|
|
1137
2368
|
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Recover the message an AppleScript `error "…"` raised from osascript's stderr,
|
|
2371
|
+
* dropping the wrapper osascript adds around it (`<line>:<col>: execution error:
|
|
2372
|
+
* <message> (-1728)`). Returns null when stderr carries no such message, so a
|
|
2373
|
+
* caller can fall back to its own wording. This is what lets a resolver script
|
|
2374
|
+
* REFUSE with a sentence the operator can act on — "the Move… picker offers no
|
|
2375
|
+
* project named X — it offered […]" — instead of the driver's generic guess.
|
|
2376
|
+
*/
|
|
2377
|
+
function scriptErrorText(stderr) {
|
|
2378
|
+
const raw = stderr.trim();
|
|
2379
|
+
if (raw === "")
|
|
2380
|
+
return null;
|
|
2381
|
+
const marker = raw.lastIndexOf("execution error:");
|
|
2382
|
+
const body = marker >= 0 ? raw.slice(marker + "execution error:".length) : raw;
|
|
2383
|
+
const trimmed = body.replace(/\s*\(-?\d+\)\s*$/, "").trim();
|
|
2384
|
+
return trimmed === "" ? null : trimmed;
|
|
2385
|
+
}
|
|
1138
2386
|
/**
|
|
1139
2387
|
* Execute a `click-element` step: resolve the target's AX frame, synthesize a
|
|
1140
2388
|
* mouse click at its center, then verify the declared post-click outcome. Fails
|
|
@@ -1146,10 +2394,17 @@ async function driveClickElement(step, run) {
|
|
|
1146
2394
|
const frameRes = await run(commandForStep(step, ""), STEP_TIMEOUT_MS);
|
|
1147
2395
|
const center = frameRes.ok ? parseFrameCenter(frameRes.stdout) : null;
|
|
1148
2396
|
if (center === null) {
|
|
2397
|
+
// A resolver that REFUSED (rather than merely failing to find an element)
|
|
2398
|
+
// carries the diagnosis — which row it wanted, and what the surface offered
|
|
2399
|
+
// instead. Prefer it over the generic guess: that text is the whole point of
|
|
2400
|
+
// the picker-row and heading-button resolvers (HXPC1).
|
|
2401
|
+
const named = scriptErrorText(frameRes.stderr);
|
|
1149
2402
|
return {
|
|
1150
2403
|
ok: false,
|
|
1151
|
-
why:
|
|
1152
|
-
|
|
2404
|
+
why: named !== null
|
|
2405
|
+
? `${named} — no click was sent`
|
|
2406
|
+
: "its on-screen position did not resolve — a Things update may have moved the control, " +
|
|
2407
|
+
"or the app is not in the expected state; no click was sent",
|
|
1153
2408
|
};
|
|
1154
2409
|
}
|
|
1155
2410
|
const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
|
|
@@ -1178,7 +2433,130 @@ async function driveClickElement(step, run) {
|
|
|
1178
2433
|
}
|
|
1179
2434
|
return { ok: true };
|
|
1180
2435
|
}
|
|
1181
|
-
|
|
2436
|
+
/**
|
|
2437
|
+
* Execute the PRE-COMMIT FULL-DIALOG AUDIT step (CGRD1): re-read every control the
|
|
2438
|
+
* drive set — through each control's own discriminated address — and refuse the
|
|
2439
|
+
* commit if any of them does not hold the intended value.
|
|
2440
|
+
*
|
|
2441
|
+
* Three resolutions happen here rather than in the recipe, because only the driver
|
|
2442
|
+
* knows them: WHICH dialog shell is live (attached sheet vs detached editor), which
|
|
2443
|
+
* SHAPE the dialog measured (the +1 group-index fork, and which first-occurrence
|
|
2444
|
+
* control class exists), and therefore which candidate path each control has. A
|
|
2445
|
+
* shell that does not resolve, or a shape-dependent control with no shape probed,
|
|
2446
|
+
* fails closed exactly like every other address here — an unaudited commit is not
|
|
2447
|
+
* an option, because the audit is the only non-self-referential check the drive has.
|
|
2448
|
+
*
|
|
2449
|
+
* The System Events sweep and the ObjC date-area sweep are separate commands
|
|
2450
|
+
* because the dialog's date/time controls hold NSDates that System Events cannot
|
|
2451
|
+
* read; both must pass.
|
|
2452
|
+
*/
|
|
2453
|
+
async function driveDialogAudit(step, run, dialogShape) {
|
|
2454
|
+
const plan = step.audit;
|
|
2455
|
+
if (plan === undefined)
|
|
2456
|
+
return { ok: false, why: "no audit plan compiled (recipe bug)" };
|
|
2457
|
+
// Which of the two dialog shells is live, in the SAME priority order the drive's
|
|
2458
|
+
// own candidate resolution used — so the audit reads the dialog the drive wrote.
|
|
2459
|
+
let shellIndex = -1;
|
|
2460
|
+
for (let i = 0; i < plan.shells.length; i += 1) {
|
|
2461
|
+
const res = await run({
|
|
2462
|
+
primitive: "resolve",
|
|
2463
|
+
label: step.label,
|
|
2464
|
+
script: axResolveScript(plan.shells[i]),
|
|
2465
|
+
}, STEP_TIMEOUT_MS);
|
|
2466
|
+
if (res.ok && res.stdout.trim() === "true") {
|
|
2467
|
+
shellIndex = i;
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
if (shellIndex < 0) {
|
|
2472
|
+
return {
|
|
2473
|
+
ok: false,
|
|
2474
|
+
why: "the Repeat dialog could not be re-read before committing (neither the attached sheet " +
|
|
2475
|
+
"nor the detached repeat editor window resolved), so what it holds could not be checked",
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
const shell = plan.shells[shellIndex];
|
|
2479
|
+
const group = (plan.groups[shellIndex] ?? plan.groups[0]);
|
|
2480
|
+
const scriptControls = [];
|
|
2481
|
+
const dateAreas = [];
|
|
2482
|
+
for (const raw of plan.controls) {
|
|
2483
|
+
if (raw.onlyShape !== undefined || raw.shaped !== undefined) {
|
|
2484
|
+
if (dialogShape === null)
|
|
2485
|
+
return { ok: false, why: SHAPE_UNPROBED };
|
|
2486
|
+
if (raw.onlyShape !== undefined && raw.onlyShape !== dialogShape)
|
|
2487
|
+
continue;
|
|
2488
|
+
}
|
|
2489
|
+
const override = raw.shaped === undefined ? undefined : raw.shaped[dialogShape ?? "next-popup"];
|
|
2490
|
+
if (raw.shaped !== undefined && override === undefined) {
|
|
2491
|
+
return {
|
|
2492
|
+
ok: false,
|
|
2493
|
+
why: `the audit has no check for "${raw.label}" under the "${dialogShape ?? "unmeasured"}" Repeat dialog (recipe bug)`,
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
const control = { ...raw, ...override };
|
|
2497
|
+
if (control.kind === "date-area") {
|
|
2498
|
+
dateAreas.push({
|
|
2499
|
+
label: control.label,
|
|
2500
|
+
target: control.dtTarget ?? "next",
|
|
2501
|
+
spec: control.dtSpec ?? "",
|
|
2502
|
+
});
|
|
2503
|
+
continue;
|
|
2504
|
+
}
|
|
2505
|
+
const candidates = control.pathCandidates;
|
|
2506
|
+
scriptControls.push({
|
|
2507
|
+
label: control.label,
|
|
2508
|
+
kind: control.kind,
|
|
2509
|
+
...(candidates !== undefined && {
|
|
2510
|
+
path: (candidates[shellIndex] ?? candidates[0]),
|
|
2511
|
+
}),
|
|
2512
|
+
...(control.numberTarget !== undefined && { numberTarget: control.numberTarget }),
|
|
2513
|
+
...(control.rowLabel !== undefined && { rowLabel: control.rowLabel }),
|
|
2514
|
+
...(control.weekdayBase !== undefined && { weekdayBase: control.weekdayBase }),
|
|
2515
|
+
expected: control.expected ?? [],
|
|
2516
|
+
...(control.expectedLabel !== undefined && { expectedLabel: control.expectedLabel }),
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
if (scriptControls.length > 0) {
|
|
2520
|
+
const res = await run({
|
|
2521
|
+
primitive: "audit-dialog",
|
|
2522
|
+
label: step.label,
|
|
2523
|
+
script: axAuditDialogScript({ shell, group, controls: scriptControls }),
|
|
2524
|
+
}, STEP_TIMEOUT_MS);
|
|
2525
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2526
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
if (dateAreas.length > 0) {
|
|
2530
|
+
const res = await run({
|
|
2531
|
+
primitive: "audit-dialog",
|
|
2532
|
+
label: step.label,
|
|
2533
|
+
lang: "javascript",
|
|
2534
|
+
script: axAuditDateAreasScript(dateAreas),
|
|
2535
|
+
}, STEP_TIMEOUT_MS);
|
|
2536
|
+
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
2537
|
+
return { ok: false, why: auditFailureText(res) };
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
return { ok: true };
|
|
2541
|
+
}
|
|
2542
|
+
/** The audit's own refusal text, preferred over the driver's generic guess. */
|
|
2543
|
+
function auditFailureText(res) {
|
|
2544
|
+
const named = scriptErrorText(res.stderr);
|
|
2545
|
+
if (named !== null)
|
|
2546
|
+
return `${named} — nothing was committed`;
|
|
2547
|
+
if (res.timedOut === true)
|
|
2548
|
+
return "the pre-commit dialog audit timed out; nothing was committed";
|
|
2549
|
+
return "the Repeat dialog could not be re-read before committing; nothing was committed";
|
|
2550
|
+
}
|
|
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);
|
|
1182
2560
|
const done = [];
|
|
1183
2561
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
1184
2562
|
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
@@ -1196,7 +2574,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1196
2574
|
// modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
|
|
1197
2575
|
// whose OK press was mid-commit could still land — the pipeline re-verifies
|
|
1198
2576
|
// and shapes the final result accordingly.
|
|
1199
|
-
const clear = await
|
|
2577
|
+
const clear = await clearNow();
|
|
1200
2578
|
trace(() => ({
|
|
1201
2579
|
phase: "watchdog",
|
|
1202
2580
|
budgetMs,
|
|
@@ -1235,19 +2613,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1235
2613
|
*/
|
|
1236
2614
|
stepTimedOut = false) => {
|
|
1237
2615
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
1238
|
-
const cleanup = clear === undefined
|
|
1239
|
-
? ""
|
|
1240
|
-
: clear.state === "dismissed"
|
|
1241
|
-
? " The open sheet/popover was dismissed (Escape, confirmed gone)."
|
|
1242
|
-
: clear.state === "cleared-blind"
|
|
1243
|
-
? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
1244
|
-
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
1245
|
-
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
1246
|
-
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
1247
|
-
" before retrying."
|
|
1248
|
-
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
1249
|
-
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
1250
|
-
" make the next drive's preflight fail).";
|
|
2616
|
+
const cleanup = clear === undefined ? "" : ` ${describeCleanup(clear)}`;
|
|
1251
2617
|
// #512: name an environment failure as one. A cleanup that had to run BLIND
|
|
1252
2618
|
// is direct evidence the session went AX-blind mid-drive; a step killed by
|
|
1253
2619
|
// its own deadline is the window not answering. Either way the app was not
|
|
@@ -1307,6 +2673,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1307
2673
|
"to open the dialog. ";
|
|
1308
2674
|
}
|
|
1309
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
|
+
}
|
|
1310
2691
|
// 1. Recipe canary: resolve every statically-reachable element (now that the
|
|
1311
2692
|
// target is selected). A miss refuses the whole drive before anything is
|
|
1312
2693
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
@@ -1346,7 +2727,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1346
2727
|
// control whose CLASS changed, and only the matching one runs.
|
|
1347
2728
|
if (step.onlyShape !== undefined) {
|
|
1348
2729
|
if (dialogShape === null) {
|
|
1349
|
-
const clear = await
|
|
2730
|
+
const clear = await clearNow();
|
|
1350
2731
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1351
2732
|
}
|
|
1352
2733
|
if (step.onlyShape !== dialogShape)
|
|
@@ -1356,12 +2737,12 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1356
2737
|
// introduced, and the weekday-row base index).
|
|
1357
2738
|
if (step.shaped !== undefined) {
|
|
1358
2739
|
if (dialogShape === null) {
|
|
1359
|
-
const clear = await
|
|
2740
|
+
const clear = await clearNow();
|
|
1360
2741
|
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1361
2742
|
}
|
|
1362
2743
|
const override = step.shaped[dialogShape];
|
|
1363
2744
|
if (override === undefined) {
|
|
1364
|
-
const clear = await
|
|
2745
|
+
const clear = await clearNow();
|
|
1365
2746
|
return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
|
|
1366
2747
|
}
|
|
1367
2748
|
step = { ...step, ...override };
|
|
@@ -1378,7 +2759,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1378
2759
|
const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
|
|
1379
2760
|
if (!ok) {
|
|
1380
2761
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
1381
|
-
const clear = await
|
|
2762
|
+
const clear = await clearNow();
|
|
1382
2763
|
return partial(step.label, "the expected element never appeared within the timeout", clear);
|
|
1383
2764
|
}
|
|
1384
2765
|
done.push(step.label);
|
|
@@ -1398,6 +2779,21 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1398
2779
|
done.push(`${step.label} (${outcome.detail})`);
|
|
1399
2780
|
continue;
|
|
1400
2781
|
}
|
|
2782
|
+
if (step.primitive === "chord-reorder") {
|
|
2783
|
+
// The heading-chord driver runs its own select → chord → DB-assert loop
|
|
2784
|
+
// (ui-chord.ts): every chord is computed from the order it just read, and
|
|
2785
|
+
// a chord that moves nothing (or moves the wrong row) stops the drive
|
|
2786
|
+
// rather than being re-sent. No sheet is involved, so no dismissal clause.
|
|
2787
|
+
if (step.chord === undefined)
|
|
2788
|
+
return partial(step.label, "no chord spec compiled");
|
|
2789
|
+
const spec = step.chord;
|
|
2790
|
+
// the chord ladder depends on the UI state the reveal produced
|
|
2791
|
+
const outcome = await driveHeadingChordReorder(spec, run, aux.headingOrder, (ordinal) => axSelectHeadingRowScript(spec.tablePath, ordinal));
|
|
2792
|
+
if (!outcome.ok)
|
|
2793
|
+
return partial(step.label, outcome.detail);
|
|
2794
|
+
done.push(`${step.label} (${outcome.detail})`);
|
|
2795
|
+
continue;
|
|
2796
|
+
}
|
|
1401
2797
|
// Resolve a candidate-addressed step's effective element before dispatch
|
|
1402
2798
|
// (the sheet-vs-detached-window disjunction). A miss fails closed.
|
|
1403
2799
|
if (step.pathCandidates !== undefined) {
|
|
@@ -1405,7 +2801,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1405
2801
|
const effective = await resolveStepPath(step, run);
|
|
1406
2802
|
if (effective === null) {
|
|
1407
2803
|
// dismiss whatever opened (and verify) before reporting
|
|
1408
|
-
const clear = await
|
|
2804
|
+
const clear = await clearNow();
|
|
1409
2805
|
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
1410
2806
|
"detached repeat editor window)", clear);
|
|
1411
2807
|
}
|
|
@@ -1421,7 +2817,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1421
2817
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1422
2818
|
const verdict = res.stdout.trim();
|
|
1423
2819
|
if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
|
|
1424
|
-
const clear = await
|
|
2820
|
+
const clear = await clearNow();
|
|
1425
2821
|
return partial(step.label, res.ok
|
|
1426
2822
|
? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
|
|
1427
2823
|
"field, so the dialog matched neither known shape — a Things update has redesigned " +
|
|
@@ -1442,7 +2838,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1442
2838
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1443
2839
|
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
1444
2840
|
// clear any transient state (and verify) before reporting
|
|
1445
|
-
const clear = await
|
|
2841
|
+
const clear = await clearNow();
|
|
1446
2842
|
const noMatch = step.primitive === "select-heading-row"
|
|
1447
2843
|
? "the project view exposed no selectable heading row at the target position — the " +
|
|
1448
2844
|
"heading may have been converted/deleted already, or the project's headings changed"
|
|
@@ -1467,7 +2863,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1467
2863
|
const verdict = res.stdout.trim();
|
|
1468
2864
|
if (!res.ok || verdict !== "OK") {
|
|
1469
2865
|
// clear any transient state (and verify) before reporting
|
|
1470
|
-
const clear = await
|
|
2866
|
+
const clear = await clearNow();
|
|
1471
2867
|
return partial(step.label, res.ok
|
|
1472
2868
|
? verdict !== ""
|
|
1473
2869
|
? verdict
|
|
@@ -1479,14 +2875,31 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1479
2875
|
done.push(step.label);
|
|
1480
2876
|
continue;
|
|
1481
2877
|
}
|
|
1482
|
-
if (step.primitive === "
|
|
2878
|
+
if (step.primitive === "audit-dialog") {
|
|
2879
|
+
// The last thing before the commit: re-read EVERY control this drive set,
|
|
2880
|
+
// through each control's own discriminated address, and abort fail-closed if
|
|
2881
|
+
// any of them disagrees with what was requested (CGRD1). Every per-step
|
|
2882
|
+
// read-back before this point is self-referential — it re-reads the element it
|
|
2883
|
+
// addressed — so a wrong ADDRESS is invisible until here. A mismatch clears
|
|
2884
|
+
// the dialog through the standard clean-abort path, so nothing is committed.
|
|
2885
|
+
const outcome = await driveDialogAudit(step, run, dialogShape);
|
|
2886
|
+
if (!outcome.ok) {
|
|
2887
|
+
const clear = await clearNow();
|
|
2888
|
+
return partial(step.label, outcome.why ?? "the pre-commit dialog audit failed", clear);
|
|
2889
|
+
}
|
|
2890
|
+
done.push(step.label);
|
|
2891
|
+
continue;
|
|
2892
|
+
}
|
|
2893
|
+
if (step.primitive === "click-element" || step.primitive === "click-picker-row") {
|
|
1483
2894
|
// A mouse click at an AX-resolved frame center (the NATIVE1 primitive),
|
|
1484
|
-
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2895
|
+
// used only where AXPress is inert (Things' custom `…`/repeat-bar popover)
|
|
2896
|
+
// — and, for `click-picker-row`, where committing by keyboard would take
|
|
2897
|
+
// whatever the app highlighted, including the row that CREATES a project.
|
|
1485
2898
|
// the click depends on the UI state the previous step produced
|
|
1486
2899
|
const outcome = await driveClickElement(step, run);
|
|
1487
2900
|
if (!outcome.ok) {
|
|
1488
2901
|
// clear whatever the click opened (honest cleanup) before reporting
|
|
1489
|
-
const clear = outcome.needsAbort === true ? await
|
|
2902
|
+
const clear = outcome.needsAbort === true ? await clearNow() : undefined;
|
|
1490
2903
|
return partial(step.label, outcome.why ?? "the click failed", clear);
|
|
1491
2904
|
}
|
|
1492
2905
|
done.push(step.label);
|
|
@@ -1497,12 +2910,13 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, re
|
|
|
1497
2910
|
if (!res.ok) {
|
|
1498
2911
|
// clear the half-open sheet/popover (honest — never claim an unconfirmed
|
|
1499
2912
|
// dismissal) before reporting partial state
|
|
1500
|
-
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
1501
|
-
? await clearDialog(run)
|
|
1502
|
-
: undefined;
|
|
2913
|
+
const clear = step.primitive !== "reveal" && step.primitive !== "activate" ? await clearNow() : undefined;
|
|
1503
2914
|
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
|
|
1504
2915
|
}
|
|
1505
|
-
|
|
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);
|
|
1506
2920
|
}
|
|
1507
2921
|
return {
|
|
1508
2922
|
exitCode: 0,
|
|
@@ -1613,6 +3027,11 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
1613
3027
|
}
|
|
1614
3028
|
return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
|
|
1615
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),
|
|
1616
3035
|
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
1617
3036
|
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
1618
3037
|
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|