things-api 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/deputy/VERSION +1 -0
- package/deputy/helpers-Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -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/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
- package/deputy/reader/Info.plist +20 -0
- package/deputy/reader/entitlements.plist +15 -0
- package/deputy/reader/main.swift +491 -0
- package/deputy/src/main.swift +133 -0
- package/deputy/src/osascript.swift +102 -0
- package/deputy/src/server.swift +350 -0
- package/deputy/src/sqlite.swift +121 -0
- package/dist/cli/commands/doctor.js +55 -2
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.d.ts +17 -0
- package/dist/cli/commands/helpers.js +148 -0
- package/dist/cli/commands/helpers.js.map +1 -0
- package/dist/cli/commands/op-result.d.ts +15 -0
- package/dist/cli/commands/op-result.js +39 -0
- package/dist/cli/commands/op-result.js.map +1 -0
- package/dist/cli/commands/repeat-flags.d.ts +3 -3
- package/dist/cli/commands/repeat-flags.js +90 -33
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/writes.js +79 -6
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +13 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/helpers-check.d.ts +10 -0
- package/dist/cli/helpers-check.js +59 -0
- package/dist/cli/helpers-check.js.map +1 -0
- package/dist/cli/interrupt.d.ts +42 -0
- package/dist/cli/interrupt.js +105 -0
- package/dist/cli/interrupt.js.map +1 -0
- package/dist/cli/main.js +27 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +70 -0
- package/dist/config.js +25 -1
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +9 -2
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/baselines/db-v26.js +8 -4
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/db/baselines/db-v27.d.ts +42 -0
- package/dist/db/baselines/db-v27.js +47 -0
- package/dist/db/baselines/db-v27.js.map +1 -0
- package/dist/db/baselines/index.js +2 -1
- package/dist/db/baselines/index.js.map +1 -1
- package/dist/db/locate.d.ts +4 -1
- package/dist/db/locate.js +3 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +9 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/deputy/bridge-worker.d.ts +1 -0
- package/dist/deputy/bridge-worker.js +79 -0
- package/dist/deputy/bridge-worker.js.map +1 -0
- package/dist/deputy/bridge.d.ts +14 -0
- package/dist/deputy/bridge.js +70 -0
- package/dist/deputy/bridge.js.map +1 -0
- package/dist/deputy/client.d.ts +19 -0
- package/dist/deputy/client.js +104 -0
- package/dist/deputy/client.js.map +1 -0
- package/dist/deputy/db-facade.d.ts +18 -0
- package/dist/deputy/db-facade.js +71 -0
- package/dist/deputy/db-facade.js.map +1 -0
- package/dist/deputy/files.d.ts +1 -0
- package/dist/deputy/files.js +16 -0
- package/dist/deputy/files.js.map +1 -0
- package/dist/deputy/install.d.ts +105 -0
- package/dist/deputy/install.js +467 -0
- package/dist/deputy/install.js.map +1 -0
- package/dist/deputy/notice.d.ts +6 -0
- package/dist/deputy/notice.js +28 -0
- package/dist/deputy/notice.js.map +1 -0
- package/dist/deputy/notices.d.ts +31 -0
- package/dist/deputy/notices.js +94 -0
- package/dist/deputy/notices.js.map +1 -0
- package/dist/deputy/osa.d.ts +21 -0
- package/dist/deputy/osa.js +72 -0
- package/dist/deputy/osa.js.map +1 -0
- package/dist/deputy/protocol.d.ts +86 -0
- package/dist/deputy/protocol.js +106 -0
- package/dist/deputy/protocol.js.map +1 -0
- package/dist/deputy/routing.d.ts +63 -0
- package/dist/deputy/routing.js +338 -0
- package/dist/deputy/routing.js.map +1 -0
- package/dist/deputy/shortcuts-exec.d.ts +10 -0
- package/dist/deputy/shortcuts-exec.js +50 -0
- package/dist/deputy/shortcuts-exec.js.map +1 -0
- package/dist/diagnose.d.ts +43 -1
- package/dist/diagnose.js +64 -2
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +25 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/mappers.d.ts +12 -3
- package/dist/model/mappers.js +6 -1
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/occurrences.d.ts +14 -1
- package/dist/model/occurrences.js +20 -4
- package/dist/model/occurrences.js.map +1 -1
- package/dist/model/recurrence.d.ts +64 -9
- package/dist/model/recurrence.js +94 -19
- package/dist/model/recurrence.js.map +1 -1
- package/dist/model/template-projection.d.ts +26 -0
- package/dist/model/template-projection.js +113 -0
- package/dist/model/template-projection.js.map +1 -0
- package/dist/op-result.d.ts +46 -0
- package/dist/op-result.js +156 -0
- package/dist/op-result.js.map +1 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +10 -0
- package/dist/paths.js.map +1 -1
- package/dist/read/detail.js +7 -1
- package/dist/read/detail.js.map +1 -1
- package/dist/read/queries.js +8 -1
- package/dist/read/queries.js.map +1 -1
- package/dist/read/views.d.ts +6 -4
- package/dist/read/views.js +60 -18
- package/dist/read/views.js.map +1 -1
- package/dist/sync-health.js +8 -3
- package/dist/sync-health.js.map +1 -1
- package/dist/trace/tracer.d.ts +87 -0
- package/dist/trace/tracer.js +167 -0
- package/dist/trace/tracer.js.map +1 -0
- package/dist/write/accessibility-probe.js +4 -2
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.js +5 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/availability.js +7 -3
- package/dist/write/availability.js.map +1 -1
- package/dist/write/clear-reminder.js +24 -21
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.js +95 -45
- package/dist/write/commands.js.map +1 -1
- package/dist/write/completion-context.d.ts +57 -0
- package/dist/write/completion-context.js +49 -0
- package/dist/write/completion-context.js.map +1 -0
- package/dist/write/experimental.d.ts +19 -0
- package/dist/write/experimental.js +50 -0
- package/dist/write/experimental.js.map +1 -1
- package/dist/write/failure-hints.d.ts +10 -1
- package/dist/write/failure-hints.js +8 -0
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +10 -0
- package/dist/write/guards.js.map +1 -1
- package/dist/write/move.js +14 -6
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +40 -7
- package/dist/write/pipeline.d.ts +41 -0
- package/dist/write/pipeline.js +289 -19
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +1 -1
- package/dist/write/pre-state.js +35 -17
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +434 -19
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +76 -27
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-anchor.d.ts +140 -0
- package/dist/write/repeat-anchor.js +403 -0
- package/dist/write/repeat-anchor.js.map +1 -0
- package/dist/write/repeat-asserts.d.ts +28 -0
- package/dist/write/repeat-asserts.js +169 -0
- package/dist/write/repeat-asserts.js.map +1 -0
- package/dist/write/repeat-rule.js +37 -12
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/vectors/applescript.js +7 -16
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/session-reachability.d.ts +133 -0
- package/dist/write/vectors/session-reachability.js +151 -0
- package/dist/write/vectors/session-reachability.js.map +1 -0
- package/dist/write/vectors/shortcuts.js +5 -14
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +16 -1
- package/dist/write/vectors/simulator.js +122 -32
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +183 -10
- package/dist/write/vectors/ui-recipes.d.ts +8 -0
- package/dist/write/vectors/ui-recipes.js +246 -58
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +156 -14
- package/dist/write/vectors/ui.js +846 -84
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +12 -14
- package/dist/write/verify/delta.js +35 -34
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +13 -0
- package/dist/write/verify/poller.js +38 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +8 -1
- package/schema/envelope.schema.json +22 -0
- package/scripts/build-helpers.sh +104 -0
- package/scripts/deputy-cert-setup.sh +60 -0
- package/skills/things-cli/SKILL.md +11 -3
- package/skills/things-cli/references/errors.md +4 -0
- package/skills/things-cli/references/repeating.md +69 -0
package/dist/write/vectors/ui.js
CHANGED
|
@@ -26,13 +26,23 @@
|
|
|
26
26
|
* v1 shells out to `osascript` with ONE stable command shape per primitive.
|
|
27
27
|
*/
|
|
28
28
|
import { execFile } from "node:child_process";
|
|
29
|
+
import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
|
|
30
|
+
import { osaExec } from "../../deputy/osa.js";
|
|
31
|
+
import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
|
|
29
32
|
import { UI_DRIVE_OPS } from "../operations.js";
|
|
30
33
|
import { escapeAppleScript } from "./applescript.js";
|
|
34
|
+
import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
31
35
|
import { certificationOf } from "./ui-certification.js";
|
|
32
36
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
33
37
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
34
38
|
const STEP_TIMEOUT_MS = 15_000;
|
|
35
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Poll interval while waiting for a dynamic element (sheet/popover). KEPT at 300ms
|
|
41
|
+
* after the PERF2 audit: the control a mode switch reveals takes ~462ms to appear
|
|
42
|
+
* on the golden (S5b, [docs/lab/perf2-step-latency.md]), which EXCEEDS this
|
|
43
|
+
* interval — so a 300ms poll catches it on its second round; a finer interval
|
|
44
|
+
* would only add osascript hops for a marginal detection gain (UIC6 confirmed).
|
|
45
|
+
*/
|
|
36
46
|
const WAIT_POLL_MS = 300;
|
|
37
47
|
/**
|
|
38
48
|
* How long `resolveStepPath` polls a candidate-addressed control before failing
|
|
@@ -45,9 +55,22 @@ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
|
|
|
45
55
|
* Settle after the reveal/activate preamble so the menu bar repopulates for the
|
|
46
56
|
* newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
|
|
47
57
|
* submenu appears only once a repeating item is selected, and the update is not
|
|
48
|
-
* instantaneous).
|
|
58
|
+
* instantaneous). TRIMMED 1500 → 1000 by the PERF2 audit (S5a,
|
|
59
|
+
* [docs/lab/perf2-step-latency.md]): on a warm running app under DEFAULT macOS
|
|
60
|
+
* animations the menu repopulates in ~92ms median / 116ms max (N=10) — a ~13×
|
|
61
|
+
* margin at 1500. Menu-bar repopulation is a LOCAL UI operation (not a DB-commit /
|
|
62
|
+
* sync-bound one), so it does not scale with DB size the way the OK commit does;
|
|
63
|
+
* 1000ms keeps ~8.6× the golden max as host headroom. Under-margining only ever
|
|
64
|
+
* costs a fail-closed spurious drive refusal (the canary miss), never a bad write.
|
|
49
65
|
*/
|
|
50
|
-
const SETTLE_AFTER_REVEAL_MS =
|
|
66
|
+
const SETTLE_AFTER_REVEAL_MS = 1000;
|
|
67
|
+
/**
|
|
68
|
+
* A shape-dependent step reached without the dialog having been measured — a
|
|
69
|
+
* recipe bug (the `probe-dialog-shape` step is missing or ran after its
|
|
70
|
+
* dependants). Refused, never guessed: the two shapes address DIFFERENT controls
|
|
71
|
+
* at the same index.
|
|
72
|
+
*/
|
|
73
|
+
const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
|
|
51
74
|
const SE = `tell application "System Events" to tell process "Things3"`;
|
|
52
75
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
53
76
|
export function axResolveScript(path) {
|
|
@@ -98,6 +121,39 @@ export function axSetValueScript(path, value, attempts = 3) {
|
|
|
98
121
|
error "field did not hold value \\"${v}\\" after ${n} attempt(s); last shown: " & ((value of tf) as text)
|
|
99
122
|
end tell`;
|
|
100
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* ensure-checkbox: converge a dialog checkbox to a target state through a
|
|
126
|
+
* DETERMINISTIC CLOSED LOOP (RRD1, determinism doctrine) — never a blind toggle.
|
|
127
|
+
* It reads the checkbox's `AXValue` (0 = unchecked, 1 = checked), presses it ONLY
|
|
128
|
+
* when the observed value differs from `target`, then RE-READS to confirm the new
|
|
129
|
+
* value equals `target`. A press that did not register (or a value that has not
|
|
130
|
+
* settled) is retried a bounded number of times; if it never converges the script
|
|
131
|
+
* FAILS CLOSED (an `error`, i.e. a transport failure the pipeline re-verifies).
|
|
132
|
+
*
|
|
133
|
+
* This is what makes the "Add deadlines" / "Add reminders" checkboxes safe on a
|
|
134
|
+
* PRE-POPULATED reschedule dialog: the dialog opens with the item's CURRENT
|
|
135
|
+
* deadline/reminder state already ticked, so the old unconditional `click`
|
|
136
|
+
* FLIPPED an already-correct box the wrong way — the live #493-adjacent bug where
|
|
137
|
+
* a blind "Add deadlines" press UNCHECKED an already-deadlined rule and hid the
|
|
138
|
+
* "start N days earlier" field, collapsing the drive. Reading before pressing
|
|
139
|
+
* makes an already-correct box a no-op. One stable command shape per primitive.
|
|
140
|
+
*/
|
|
141
|
+
export function axEnsureCheckboxScript(path, target, attempts = 3) {
|
|
142
|
+
const want = target ? 1 : 0;
|
|
143
|
+
const n = Math.max(1, Math.trunc(attempts));
|
|
144
|
+
return `${SE}
|
|
145
|
+
set cb to (${path})
|
|
146
|
+
repeat ${n} times
|
|
147
|
+
set cur to (value of cb) as integer
|
|
148
|
+
if cur is ${want} then return "OK"
|
|
149
|
+
click cb
|
|
150
|
+
delay 0.2
|
|
151
|
+
end repeat
|
|
152
|
+
set cur to (value of cb) as integer
|
|
153
|
+
if cur is ${want} then return "OK"
|
|
154
|
+
error "checkbox did not converge to ${want} after ${n} attempt(s); still " & cur
|
|
155
|
+
end tell`;
|
|
156
|
+
}
|
|
101
157
|
/**
|
|
102
158
|
* select-popup: choose an item in a pop-up button by NAME. Setting `value` on a
|
|
103
159
|
* Things pop-up button is a silent no-op (UIC1 / UI2-i) — the control must be
|
|
@@ -142,6 +198,270 @@ export function axSelectPopupCandidatesScript(path, values) {
|
|
|
142
198
|
error "none of the candidate menu items exist: " & {${list}}
|
|
143
199
|
end tell`;
|
|
144
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* probe-dialog-shape: MEASURE which Repeat dialog is open (RDLG2) — the whole
|
|
203
|
+
* version fork, decided by STRUCTURE rather than by the app version, so the
|
|
204
|
+
* recipe self-selects on any host and a future redesign refuses instead of
|
|
205
|
+
* silently pressing the wrong control:
|
|
206
|
+
*
|
|
207
|
+
* - `next-popup` (Things 3.23+) — the first occurrence is an `AXPopUpButton`
|
|
208
|
+
* listing the rule's own upcoming occurrences. It sits between Ends and every
|
|
209
|
+
* per-frequency control, so weekday / monthly / yearly pop-ups are one index
|
|
210
|
+
* further along.
|
|
211
|
+
* - `legacy` (Things ≤ 3.22) — the first occurrence is a free-form
|
|
212
|
+
* `AXDateTimeArea`, and the per-frequency controls follow Ends directly.
|
|
213
|
+
*
|
|
214
|
+
* The discriminator is the CONTROL CLASS on the `Next:` row, not the presence of
|
|
215
|
+
* the label: RDLG2d measured Things 3.22.14 and found it carries the same
|
|
216
|
+
* `Next:` static text (and the same occurrence-preview line) as 3.23 — only the
|
|
217
|
+
* control beside it changed. So the probe reads the label's row position and asks
|
|
218
|
+
* what kind of control shares that row, which also keeps it independent of the
|
|
219
|
+
* dialog's Ends state (an `Ends: on date` bound adds a SECOND date area, on a
|
|
220
|
+
* different row, in both shapes). Each branch is a POSITIVE match, so an
|
|
221
|
+
* unrecognized third dialog returns "unknown" and the driver refuses. Labels are
|
|
222
|
+
* pinned English, exactly like every other selector here.
|
|
223
|
+
*/
|
|
224
|
+
export function axProbeDialogShapeScript(groupPath, rowTolerance = 8) {
|
|
225
|
+
const tol = Math.max(1, Math.trunc(rowTolerance));
|
|
226
|
+
return `${SE}
|
|
227
|
+
set g to (${groupPath})
|
|
228
|
+
set nextY to missing value
|
|
229
|
+
set nStatic to (count of static texts of g)
|
|
230
|
+
repeat with i from 1 to nStatic
|
|
231
|
+
set v to ""
|
|
232
|
+
try
|
|
233
|
+
set v to (value of static text i of g) as text
|
|
234
|
+
end try
|
|
235
|
+
if v is "Next:" then
|
|
236
|
+
set p to position of static text i of g
|
|
237
|
+
set nextY to item 2 of p
|
|
238
|
+
end if
|
|
239
|
+
end repeat
|
|
240
|
+
if nextY is missing value then return "unknown"
|
|
241
|
+
set nPop to (count of pop up buttons of g)
|
|
242
|
+
repeat with i from 1 to nPop
|
|
243
|
+
set p to position of pop up button i of g
|
|
244
|
+
set dy to (item 2 of p) - nextY
|
|
245
|
+
if dy < 0 then set dy to -dy
|
|
246
|
+
if dy <= ${tol} then return "next-popup"
|
|
247
|
+
end repeat
|
|
248
|
+
try
|
|
249
|
+
set areas to (every UI element of g whose role is "AXDateTimeArea")
|
|
250
|
+
repeat with i from 1 to (count of areas)
|
|
251
|
+
set p to position of (item i of areas)
|
|
252
|
+
set dy to (item 2 of p) - nextY
|
|
253
|
+
if dy < 0 then set dy to -dy
|
|
254
|
+
if dy <= ${tol} then return "legacy"
|
|
255
|
+
end repeat
|
|
256
|
+
end try
|
|
257
|
+
return "unknown"
|
|
258
|
+
end tell`;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* select-next-occurrence: set the first occurrence through the Things 3.23
|
|
262
|
+
* `Next:` POP-UP (RDLG2). 3.23 replaced the free-form first-occurrence date area
|
|
263
|
+
* with a bounded MENU — `Today`, then the rule's own upcoming occurrences, then a
|
|
264
|
+
* `More…` item whose submenu carries the next hundred, cascading further the same
|
|
265
|
+
* way. Two consequences this script encodes:
|
|
266
|
+
*
|
|
267
|
+
* - a requested date is reachable ONLY if the rule itself produces it (or it is
|
|
268
|
+
* today): the menu offers nothing else, so an OFF-RULE first occurrence — free
|
|
269
|
+
* to set on ≤3.22 — is UNEXPRESSIBLE in this dialog and must fail closed with
|
|
270
|
+
* a named reason rather than land some neighbouring date;
|
|
271
|
+
* - item titles are localized (`Sun, Jul 12, 2026`), so the match is made by
|
|
272
|
+
* PARSING each title to a date (with a leading-weekday retry) and comparing
|
|
273
|
+
* calendar components — never by rebuilding the app's display string.
|
|
274
|
+
*
|
|
275
|
+
* The cascade is walked to a bounded depth; the click is verified by reading the
|
|
276
|
+
* pop-up's value back and requiring it to equal the clicked item's own title
|
|
277
|
+
* (the fail-closed read-back the ANCH2/YANCH1 date drives established).
|
|
278
|
+
*/
|
|
279
|
+
export function axSelectNextOccurrenceScript(popupPath, isoDate, maxLevels = 6) {
|
|
280
|
+
const [y, m, d] = isoDate.split("-").map((part) => Number(part));
|
|
281
|
+
const levels = Math.max(1, Math.trunc(maxLevels));
|
|
282
|
+
return `on parsedYMD(t)
|
|
283
|
+
set s to t as text
|
|
284
|
+
try
|
|
285
|
+
set theDate to date s
|
|
286
|
+
return {year of theDate, (month of theDate) as integer, day of theDate}
|
|
287
|
+
end try
|
|
288
|
+
try
|
|
289
|
+
set ofs to offset of ", " in s
|
|
290
|
+
if ofs > 0 then
|
|
291
|
+
set theDate to date (text (ofs + 2) thru -1 of s)
|
|
292
|
+
return {year of theDate, (month of theDate) as integer, day of theDate}
|
|
293
|
+
end if
|
|
294
|
+
end try
|
|
295
|
+
return missing value
|
|
296
|
+
end parsedYMD
|
|
297
|
+
|
|
298
|
+
set wantY to ${y}
|
|
299
|
+
set wantM to ${m}
|
|
300
|
+
set wantD to ${d}
|
|
301
|
+
set rightNow to current date
|
|
302
|
+
set isToday to ((year of rightNow) is wantY and ((month of rightNow) as integer) is wantM and (day of rightNow) is wantD)
|
|
303
|
+
${SE}
|
|
304
|
+
set pu to (${popupPath})
|
|
305
|
+
repeat 20 times
|
|
306
|
+
if (exists menu 1 of pu) then exit repeat
|
|
307
|
+
click pu
|
|
308
|
+
delay 0.3
|
|
309
|
+
end repeat
|
|
310
|
+
set theMenu to menu 1 of pu
|
|
311
|
+
set clickedTitle to ""
|
|
312
|
+
set levelsSeen to 0
|
|
313
|
+
if isToday then
|
|
314
|
+
set nms to name of every menu item of theMenu
|
|
315
|
+
if (count of nms) > 0 then
|
|
316
|
+
set t1 to item 1 of nms
|
|
317
|
+
if t1 is not missing value then
|
|
318
|
+
if (my parsedYMD(t1)) is missing value then
|
|
319
|
+
set clickedTitle to t1 as text
|
|
320
|
+
click menu item 1 of theMenu
|
|
321
|
+
end if
|
|
322
|
+
end if
|
|
323
|
+
end if
|
|
324
|
+
end if
|
|
325
|
+
repeat ${levels} times
|
|
326
|
+
if clickedTitle is not "" then exit repeat
|
|
327
|
+
set levelsSeen to levelsSeen + 1
|
|
328
|
+
set nms to name of every menu item of theMenu
|
|
329
|
+
set hit to 0
|
|
330
|
+
repeat with i from 1 to (count of nms)
|
|
331
|
+
set nm to item i of nms
|
|
332
|
+
if nm is not missing value then
|
|
333
|
+
set ymd to my parsedYMD(nm)
|
|
334
|
+
if ymd is not missing value then
|
|
335
|
+
if (item 1 of ymd) is wantY and (item 2 of ymd) is wantM and (item 3 of ymd) is wantD then
|
|
336
|
+
set hit to i
|
|
337
|
+
exit repeat
|
|
338
|
+
end if
|
|
339
|
+
end if
|
|
340
|
+
end if
|
|
341
|
+
end repeat
|
|
342
|
+
if hit > 0 then
|
|
343
|
+
set clickedTitle to (item hit of nms) as text
|
|
344
|
+
click menu item hit of theMenu
|
|
345
|
+
exit repeat
|
|
346
|
+
end if
|
|
347
|
+
set lastI to (count of nms)
|
|
348
|
+
if lastI is 0 then exit repeat
|
|
349
|
+
if (item lastI of nms) is missing value then exit repeat
|
|
350
|
+
set deeper to missing value
|
|
351
|
+
try
|
|
352
|
+
set deeper to menu 1 of menu item lastI of theMenu
|
|
353
|
+
end try
|
|
354
|
+
if deeper is missing value then
|
|
355
|
+
try
|
|
356
|
+
click menu item lastI of theMenu
|
|
357
|
+
delay 0.5
|
|
358
|
+
set deeper to menu 1 of menu item lastI of theMenu
|
|
359
|
+
end try
|
|
360
|
+
end if
|
|
361
|
+
if deeper is missing value then exit repeat
|
|
362
|
+
set theMenu to deeper
|
|
363
|
+
end repeat
|
|
364
|
+
if clickedTitle is "" then
|
|
365
|
+
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."
|
|
367
|
+
end if
|
|
368
|
+
delay 0.4
|
|
369
|
+
set shown to (value of pu) as text
|
|
370
|
+
if shown is not clickedTitle then
|
|
371
|
+
error "select-next-occurrence: the Next: pop-up committed \\"" & shown & "\\", not the requested \\"" & clickedTitle & "\\" — the selection did not take"
|
|
372
|
+
end if
|
|
373
|
+
return "OK"
|
|
374
|
+
end tell`;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* converge-weekdays: drive the weekly dialog's weekday ROWS onto an exact target
|
|
378
|
+
* set through a deterministic closed loop (RDLG2 — the RRD1 fix).
|
|
379
|
+
*
|
|
380
|
+
* The shipped drive set the FIRST weekday row and then pressed "+" and re-drove
|
|
381
|
+
* THE SAME row index per extra weekday, which on a PRE-POPULATED reschedule
|
|
382
|
+
* dialog left the rule's existing weekdays untouched: `{mon,wed}` retargeted to
|
|
383
|
+
* `{tue,thu,sat}` committed `{mon,tue,thu,sat}` (VMQ1 cell 2Tb — caught only by
|
|
384
|
+
* the write pipeline's verify). It also had no way to SHRINK a set.
|
|
385
|
+
*
|
|
386
|
+
* The loop instead: (1) read the live row count; (2) press the row-add button —
|
|
387
|
+
* the smaller-x button of a weekday row, resolved from live geometry rather than
|
|
388
|
+
* a pinned index, because the row buttons enumerate in an unstable order — until
|
|
389
|
+
* there are at least as many rows as target weekdays; (3) assign EVERY row from
|
|
390
|
+
* the target set, cycling, so a surplus row duplicates a target weekday instead
|
|
391
|
+
* of keeping a stale one (the app stores the weekdays as a SET, so duplicates
|
|
392
|
+
* collapse on commit — this is what makes shrinking possible without the
|
|
393
|
+
* remove button); (4) read every row back and require the set to match exactly.
|
|
394
|
+
* Anything else errors — the pipeline re-verifies against the DB regardless.
|
|
395
|
+
*
|
|
396
|
+
* `base` is the 1-based group pop-up index of the first weekday row (2 on the
|
|
397
|
+
* legacy dialog, 3 once the 3.23 `Next:` pop-up sits in front of them).
|
|
398
|
+
*/
|
|
399
|
+
export function axConvergeWeekdaysScript(groupPath, base, titles) {
|
|
400
|
+
const list = titles.map((t) => `"${escapeAppleScript(t)}"`).join(", ");
|
|
401
|
+
const b = Math.max(1, Math.trunc(base));
|
|
402
|
+
return `set wantList to {${list}}
|
|
403
|
+
set baseIx to ${b}
|
|
404
|
+
${SE}
|
|
405
|
+
set g to (${groupPath})
|
|
406
|
+
set k to (count of wantList)
|
|
407
|
+
repeat 14 times
|
|
408
|
+
set n to (count of pop up buttons of g) - baseIx + 1
|
|
409
|
+
if n >= k then exit repeat
|
|
410
|
+
set nb to (count of buttons of g)
|
|
411
|
+
if nb is 0 then error "converge-weekdays: the dialog exposes no weekday row button, so a second weekday cannot be added"
|
|
412
|
+
set bestI to 0
|
|
413
|
+
set bestX to 1000000
|
|
414
|
+
repeat with i from 1 to nb
|
|
415
|
+
set p to position of button i of g
|
|
416
|
+
set px to item 1 of p
|
|
417
|
+
if px < bestX then
|
|
418
|
+
set bestX to px
|
|
419
|
+
set bestI to i
|
|
420
|
+
end if
|
|
421
|
+
end repeat
|
|
422
|
+
click button bestI of g
|
|
423
|
+
delay 0.5
|
|
424
|
+
end repeat
|
|
425
|
+
set n to (count of pop up buttons of g) - baseIx + 1
|
|
426
|
+
if n < k then error "converge-weekdays: the dialog would not grow to " & k & " weekday row(s) — it stopped at " & n
|
|
427
|
+
repeat with i from 1 to n
|
|
428
|
+
set wi to ((i - 1) mod k) + 1
|
|
429
|
+
set wantVal to item wi of wantList
|
|
430
|
+
set pu to pop up button (baseIx + i - 1) of g
|
|
431
|
+
if ((value of pu) as text) is not wantVal then
|
|
432
|
+
repeat 20 times
|
|
433
|
+
if (exists menu 1 of pu) then exit repeat
|
|
434
|
+
click pu
|
|
435
|
+
delay 0.3
|
|
436
|
+
end repeat
|
|
437
|
+
if not (exists menu item wantVal of menu 1 of pu) then
|
|
438
|
+
key code 53
|
|
439
|
+
error "converge-weekdays: the weekday pop-up offers no item \\"" & wantVal & "\\" (the app may not be in English)"
|
|
440
|
+
end if
|
|
441
|
+
click menu item wantVal of menu 1 of pu
|
|
442
|
+
delay 0.4
|
|
443
|
+
end if
|
|
444
|
+
end repeat
|
|
445
|
+
set absent to ""
|
|
446
|
+
repeat with wi from 1 to k
|
|
447
|
+
set wantVal to item wi of wantList
|
|
448
|
+
set seen to false
|
|
449
|
+
repeat with i from 1 to n
|
|
450
|
+
if ((value of pop up button (baseIx + i - 1) of g) as text) is wantVal then set seen to true
|
|
451
|
+
end repeat
|
|
452
|
+
if not seen then set absent to absent & wantVal & " "
|
|
453
|
+
end repeat
|
|
454
|
+
set strays to ""
|
|
455
|
+
repeat with i from 1 to n
|
|
456
|
+
set v to (value of pop up button (baseIx + i - 1) of g) as text
|
|
457
|
+
if wantList does not contain v then set strays to strays & v & " "
|
|
458
|
+
end repeat
|
|
459
|
+
if absent is not "" or strays is not "" then
|
|
460
|
+
error "converge-weekdays: the weekday rows did not converge — missing: " & absent & "| unexpected: " & strays
|
|
461
|
+
end if
|
|
462
|
+
return "OK"
|
|
463
|
+
end tell`;
|
|
464
|
+
}
|
|
145
465
|
/**
|
|
146
466
|
* select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
|
|
147
467
|
* content table's rows, issues the row `select` action on each (which REPLACES
|
|
@@ -213,6 +533,41 @@ export function axSelectHeadingRowScript(tablePath, ordinal) {
|
|
|
213
533
|
end tell
|
|
214
534
|
return "NOMATCH"`;
|
|
215
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* assert-eligible: after a `things:///show?id=` reveal, VERIFY the target to-do
|
|
538
|
+
* is genuinely the sole selection AND that the menu item that acts on it is
|
|
539
|
+
* enabled — before the menu is pressed (ADR1, issue #480). The reveal is assumed
|
|
540
|
+
* to select the row, but on some surfaces it can navigate without selecting; an
|
|
541
|
+
* AXPress on the resulting DISABLED `Items ▸ Repeat…` is a silent no-op, so the
|
|
542
|
+
* dialog never opens and the drive dies far downstream at the dialog-wait timeout
|
|
543
|
+
* with no hint of the real cause. Reading `Things3 → id of selected to dos` is
|
|
544
|
+
* uuid-precise (never a fuzzy title match), so a match GUARANTEES the intended row
|
|
545
|
+
* is selected. Returns "OK" only when exactly the target is selected and the menu
|
|
546
|
+
* item is enabled; otherwise a diagnostic (`NOTSEL…`/`WRONGSEL…`/`DISABLED…`)
|
|
547
|
+
* naming expected vs observed. Pure System Events + Things scripting, background-
|
|
548
|
+
* capable. One stable command shape per primitive.
|
|
549
|
+
*/
|
|
550
|
+
export function axAssertEligibleScript(targetUuid, menuItemPath) {
|
|
551
|
+
const u = escapeAppleScript(targetUuid);
|
|
552
|
+
return `set selIds to {}
|
|
553
|
+
tell application "Things3"
|
|
554
|
+
try
|
|
555
|
+
set selIds to id of selected to dos
|
|
556
|
+
end try
|
|
557
|
+
end tell
|
|
558
|
+
if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) — the show URL navigated without selecting an eligible row"
|
|
559
|
+
if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
|
|
560
|
+
set theId to (item 1 of selIds) as text
|
|
561
|
+
if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
|
|
562
|
+
set repEnabled to false
|
|
563
|
+
tell application "System Events" to tell process "Things3"
|
|
564
|
+
try
|
|
565
|
+
set repEnabled to enabled of ${menuItemPath}
|
|
566
|
+
end try
|
|
567
|
+
end tell
|
|
568
|
+
if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
|
|
569
|
+
return "OK"`;
|
|
570
|
+
}
|
|
216
571
|
/** activate: foreground Things (the fallback preamble step). */
|
|
217
572
|
export function axActivateScript() {
|
|
218
573
|
return `tell application "Things3" to activate`;
|
|
@@ -232,6 +587,29 @@ export function axKeyScript(keys) {
|
|
|
232
587
|
export function axAbortScript() {
|
|
233
588
|
return `tell application "System Events" to key code 53`; // Escape
|
|
234
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* The PROVEN app-level clearance / relocation maneuver (SESSGATE, #480, live-host
|
|
592
|
+
* recovery): close the front Things window — which takes an attached modal sheet
|
|
593
|
+
* with it — then reopen and activate. Runs entirely through Things' own
|
|
594
|
+
* AppleScript dictionary, so it works WITHOUT the Accessibility tree (the exact
|
|
595
|
+
* property needed when the session is AX-blind). Two uses:
|
|
596
|
+
* - CLEANUP: clear a stuck modal sheet a failed drive left open (unblocks the
|
|
597
|
+
* app-wide AppleScript-mutation freeze that sheet imposes);
|
|
598
|
+
* - RELOCATION: pull a window that was on another Space back to the current one
|
|
599
|
+
* so its dialog can open AX-reachably (the wrong-Space recovery branch).
|
|
600
|
+
* `reopen` restores the default window on the CURRENT Space; `activate` foregrounds
|
|
601
|
+
* it. Returns "OK".
|
|
602
|
+
*/
|
|
603
|
+
export function axCloseReopenActivateScript() {
|
|
604
|
+
return `tell application "Things3"
|
|
605
|
+
try
|
|
606
|
+
close window 1
|
|
607
|
+
end try
|
|
608
|
+
reopen
|
|
609
|
+
activate
|
|
610
|
+
end tell
|
|
611
|
+
return "OK"`;
|
|
612
|
+
}
|
|
235
613
|
/**
|
|
236
614
|
* sheet-open probe: is a modal SHEET attached to the Things standard window, OR
|
|
237
615
|
* a detached repeat-editor / popover window (an `AXUnknown` that is not the
|
|
@@ -262,19 +640,86 @@ async function sheetStillOpen(run) {
|
|
|
262
640
|
return !(res.ok && res.stdout.trim() === "false");
|
|
263
641
|
}
|
|
264
642
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
643
|
+
* Clear a half-open sheet/popover a failed drive left behind — HONESTLY (SESSGATE
|
|
644
|
+
* #480 fix; supersedes the old verifiedAbort, whose AX-blind sheet probe returned
|
|
645
|
+
* "gone" it could not actually see, letting the still-open modal freeze the
|
|
646
|
+
* app-wide AppleScript mutations the caller then attempted — the auto-trash
|
|
647
|
+
* silent-noop). Escape first; then:
|
|
648
|
+
* - AX-BLIND (a not-reachable probe): Escape may never have reached the sheet
|
|
649
|
+
* and the sheet probe cannot see it, so run the app-level close+reopen that
|
|
650
|
+
* works blind (it takes the stuck sheet with the window). Reported as
|
|
651
|
+
* "cleared-blind" — never falsely "confirmed gone".
|
|
652
|
+
* - REACHABLE: the sheet probe is trustworthy — confirm the dismissal (retry
|
|
653
|
+
* Escape once), and if it will not go, warn "may remain".
|
|
270
654
|
*/
|
|
271
|
-
async function
|
|
655
|
+
async function clearDialog(run) {
|
|
272
656
|
const escape = () => run({ primitive: "key", label: "abort (Escape)", script: axAbortScript() }, STEP_TIMEOUT_MS);
|
|
273
657
|
await escape();
|
|
658
|
+
const reach = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
659
|
+
if (!reach.reachable) {
|
|
660
|
+
// Cannot trust Escape or the sheet probe while AX-blind — use the proven
|
|
661
|
+
// app-level maneuver, which clears a stuck sheet without the Accessibility tree.
|
|
662
|
+
await run({
|
|
663
|
+
primitive: "resolve",
|
|
664
|
+
label: "clear a stuck dialog (close the Things window and reopen it)",
|
|
665
|
+
script: axCloseReopenActivateScript(),
|
|
666
|
+
}, STEP_TIMEOUT_MS);
|
|
667
|
+
return { state: "cleared-blind" };
|
|
668
|
+
}
|
|
274
669
|
if (!(await sheetStillOpen(run)))
|
|
275
|
-
return {
|
|
670
|
+
return { state: "dismissed" };
|
|
276
671
|
await escape(); // one retry
|
|
277
|
-
return {
|
|
672
|
+
return { state: (await sheetStillOpen(run)) ? "may-remain" : "dismissed" };
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The dialog-class reachability GATE (SESSGATE, #480), run AFTER the reveal/
|
|
676
|
+
* activate preamble (which surfaces a window in a healthy session) and BEFORE any
|
|
677
|
+
* menu press. Three outcomes matched to the live session state:
|
|
678
|
+
* - reachable → proceed;
|
|
679
|
+
* - not reachable, "session" → REFUSE (locked screen / full-screen Space —
|
|
680
|
+
* the certain-failure case): block, zero mutation;
|
|
681
|
+
* - not reachable, "window" → RELOCATE: only Things' window is off the
|
|
682
|
+
* current Space, so run the app-level close+reopen
|
|
683
|
+
* that pulls it back, then RE-PROBE closed-loop.
|
|
684
|
+
* Reachable now → proceed (disclosed); still not →
|
|
685
|
+
* block with the Space remediation.
|
|
686
|
+
*/
|
|
687
|
+
async function ensureWindowReachable(run, reachCache) {
|
|
688
|
+
// First probe MAY be served from the pre-seed gate's memo (PERF1) — but only a
|
|
689
|
+
// reachable verdict is ever memoized, so every refusal/relocation below is still
|
|
690
|
+
// decided on a fresh probe (see ReachabilityProbeCache).
|
|
691
|
+
const first = await reachCache.probe(run, STEP_TIMEOUT_MS);
|
|
692
|
+
if (first.reachable)
|
|
693
|
+
return { ok: true, relocated: false };
|
|
694
|
+
if (first.scope === "session")
|
|
695
|
+
return { ok: false, verdict: first };
|
|
696
|
+
// scope "window": Things' window is on another Space (or absent) while the
|
|
697
|
+
// session is otherwise fine — try to bring it to the current Space, then re-probe.
|
|
698
|
+
await run({
|
|
699
|
+
primitive: "resolve",
|
|
700
|
+
label: "move the Things window to the current desktop",
|
|
701
|
+
script: axCloseReopenActivateScript(),
|
|
702
|
+
}, STEP_TIMEOUT_MS);
|
|
703
|
+
// The relocation just changed window state — drop any memo and re-probe LIVE
|
|
704
|
+
// (a closed-loop verify of the maneuver, never a cached verdict).
|
|
705
|
+
reachCache.invalidate();
|
|
706
|
+
const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
707
|
+
if (second.reachable)
|
|
708
|
+
return { ok: true, relocated: true };
|
|
709
|
+
return { ok: false, verdict: second.reachable ? first : second };
|
|
710
|
+
}
|
|
711
|
+
/** The blocked ExecuteResult a dialog-class op returns when the session is unreachable. */
|
|
712
|
+
function blockedReachability(verdict) {
|
|
713
|
+
return {
|
|
714
|
+
exitCode: 4,
|
|
715
|
+
stdout: "",
|
|
716
|
+
stderr: `${verdict.detail} ${verdict.remediation}`,
|
|
717
|
+
blocked: {
|
|
718
|
+
hazard: H_UI_SESSION_UNREACHABLE,
|
|
719
|
+
detail: verdict.detail,
|
|
720
|
+
remediation: verdict.remediation,
|
|
721
|
+
},
|
|
722
|
+
};
|
|
278
723
|
}
|
|
279
724
|
/**
|
|
280
725
|
* resolve-frame: read the element's on-screen frame (top-left origin, points)
|
|
@@ -314,40 +759,100 @@ function clickPointCommand(x, y, label) {
|
|
|
314
759
|
return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
|
|
315
760
|
}
|
|
316
761
|
/**
|
|
317
|
-
* set-datetime: set the Repeat dialog's `AXDateTimeArea`
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* `
|
|
327
|
-
*
|
|
762
|
+
* set-datetime: set ONE of the Repeat dialog's `AXDateTimeArea` controls via the
|
|
763
|
+
* ObjC AX bridge. Things' date/time controls hold an NSDate, and System Events
|
|
764
|
+
* cannot write them (`set value … to <date>` → -10000, UIC6), so — like the
|
|
765
|
+
* mouse-synthesis primitive — this runs in JXA and calls
|
|
766
|
+
* `AXUIElementSetAttributeValue(…, AXValue, <NSDate>)` directly.
|
|
767
|
+
*
|
|
768
|
+
* A fixed rule can expose up to THREE date areas at once — "Next:" (first
|
|
769
|
+
* occurrence), "Ends: on date", and the reminder time (ANCH2 census). Targeting
|
|
770
|
+
* "the first AXDateTimeArea by role" is therefore AMBIGUOUS — that ambiguity
|
|
771
|
+
* collapsed the series when `--ends-on` added a second area (oddities §8v, now
|
|
772
|
+
* retracted) and made the reminder look undrivable (UIC6-g, now retracted). This
|
|
773
|
+
* driver selects DETERMINISTICALLY by `target` (ANCH2, docs/lab/anch2-next-field.md):
|
|
774
|
+
* - `reminder` — the only area carrying a time-of-day (the date pickers sit at
|
|
775
|
+
* midnight); falls back to the bottom-most area if none carry a time.
|
|
776
|
+
* - `next` — the TOP (smallest-y) midnight date picker.
|
|
777
|
+
* - `ends` — the BOTTOM (largest-y) midnight date picker (present only once
|
|
778
|
+
* "Ends: on date" is selected).
|
|
779
|
+
* The areas are polled briefly (revealed a beat after the checkbox/pop-up), and
|
|
780
|
+
* the script THROWS a NAMED, structured error when the addressed control is
|
|
781
|
+
* absent — reporting which target was sought and the FULL date-area inventory of
|
|
782
|
+
* the current dialog state (count + per-area y / time-of-day) — so a dialog shape
|
|
783
|
+
* that does not present the target (e.g. the deadline-mode variant, YANCH1 #493)
|
|
784
|
+
* fails closed with an actionable message, never an uncaught `-[__NSArray0
|
|
785
|
+
* objectAtIndex:]` (-2700) from indexing an empty collection. It then READS THE
|
|
786
|
+
* CONTROL BACK after the write and throws if the committed value differs from the
|
|
787
|
+
* request: a control that silently rejects the write (the macOS error beep the
|
|
788
|
+
* user hears) must fail the step loudly, never leave a garbled/default value to be
|
|
789
|
+
* verified as ok (YANCH1; UIC6-g refuse-rather-than-commit precedent). `spec` is
|
|
790
|
+
* `time:HH:mm` (keep the date, set the time-of-day) or `date:YYYY-MM-DD` (set the
|
|
791
|
+
* calendar date at midnight). One stable JXA shape.
|
|
328
792
|
*/
|
|
329
|
-
export function axSetDateTimeScript(spec) {
|
|
793
|
+
export function axSetDateTimeScript(spec, target) {
|
|
330
794
|
return `ObjC.import('Foundation'); ObjC.import('AppKit'); ObjC.import('ApplicationServices');
|
|
331
795
|
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]); }
|
|
332
796
|
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
333
797
|
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; }
|
|
334
|
-
function
|
|
798
|
+
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); }
|
|
799
|
+
function subrole(el){ var v=attr(el,'AXSubrole'); return v? v.js : ''; }
|
|
800
|
+
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; }
|
|
801
|
+
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; }
|
|
802
|
+
// Resolve the Repeat-dialog SHELL so the AXDateTimeArea collect walks only its
|
|
803
|
+
// small subtree — never the app-wide tree, whose main-window list content is the
|
|
804
|
+
// 4.4s app-root descent PERF2 removed (docs/lab/perf2-step-latency.md). The dialog
|
|
805
|
+
// presents in TWO shapes (ui-recipes DIALOG_SHELLS, UIC4-a), tried in the SAME
|
|
806
|
+
// priority order the System-Events pathCandidates use: an attached AXSheet on the
|
|
807
|
+
// standard window (Things frontmost), then a detached top-level AXUnknown window
|
|
808
|
+
// that is not the 40x40 utility window (Things backgrounded). null when neither is
|
|
809
|
+
// present — the caller then falls through to the same named "presents 0 date
|
|
810
|
+
// area(s)" error the app-root walk threw when the dialog was absent.
|
|
811
|
+
function findShell(app){
|
|
812
|
+
var wins=windowsOf(app);
|
|
813
|
+
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]; } }
|
|
814
|
+
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]; } }
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
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; }
|
|
818
|
+
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); }
|
|
819
|
+
function pick(areas,target){
|
|
820
|
+
if(areas.length===0) return null;
|
|
821
|
+
var sorted=areas.slice().sort(function(a,b){ return posY(a)-posY(b); });
|
|
822
|
+
if(target==='reminder'){
|
|
823
|
+
var timed=sorted.filter(function(a){ return timeOfDay(a)>0; });
|
|
824
|
+
return timed.length? timed[timed.length-1] : sorted[sorted.length-1];
|
|
825
|
+
}
|
|
826
|
+
var midnight=sorted.filter(function(a){ return timeOfDay(a)===0; });
|
|
827
|
+
if(midnight.length===0) midnight=sorted;
|
|
828
|
+
return target==='ends' ? midnight[midnight.length-1] : midnight[0];
|
|
829
|
+
}
|
|
830
|
+
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)'; }
|
|
831
|
+
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); }
|
|
832
|
+
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); }
|
|
335
833
|
function run(){
|
|
336
834
|
var apps=$.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.culturedcode.ThingsMac');
|
|
337
835
|
if(!apps || apps.count===0) throw new Error('Things not running');
|
|
338
|
-
var
|
|
339
|
-
var
|
|
340
|
-
var dt=null;
|
|
341
|
-
for(var t=0;t<20 && !dt;t++){ dt=find(app,'AXDateTimeArea',16); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
|
|
342
|
-
if(!dt) throw new Error('no AXDateTimeArea in the Repeat dialog');
|
|
836
|
+
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
837
|
+
var target=${JSON.stringify(target)};
|
|
343
838
|
var spec=${JSON.stringify(spec)};
|
|
839
|
+
// Poll for the addressed area WITHIN THE DIALOG SHELL (PERF2): resolve the sheet
|
|
840
|
+
// / detached editor first, then collect only its subtree — the app-root descent
|
|
841
|
+
// this replaced cost ~4.4s on the busy host by walking the main window's list
|
|
842
|
+
// content. collect is wrapped so a stale-element ObjC exception during traversal
|
|
843
|
+
// cannot bubble as a raw -2700; pick guards the empty set, so dt is null (never a
|
|
844
|
+
// crash) when the target is absent. When no shell resolves (dialog absent), areas
|
|
845
|
+
// stays empty and the loop falls through to the SAME named error below.
|
|
846
|
+
var areas=[]; var dt=null;
|
|
847
|
+
for(var t=0;t<20 && !dt;t++){ areas=[]; try{ var shell=findShell(app); if(shell) collect(shell,'AXDateTimeArea',16,areas); }catch(e){ areas=[]; } dt=pick(areas,target); if(!dt) $.NSThread.sleepForTimeInterval(0.1); }
|
|
848
|
+
if(!dt) throw new Error('set-datetime '+target+': this Repeat-dialog state presents '+areas.length+' date area(s) ['+inv(areas)+'] but none is the '+target+' control — the requested first occurrence / bound cannot be set in this dialog shape');
|
|
344
849
|
var cal=$.NSCalendar.currentCalendar;
|
|
345
850
|
var d;
|
|
346
851
|
if(spec.indexOf('time:')===0){
|
|
347
|
-
// Set the time-of-day on the control's own
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
var cur=attr(dt,'AXValue'); if(!cur) throw new Error('date/time control has no value');
|
|
852
|
+
// Set the time-of-day on the control's own date via the purpose-built
|
|
853
|
+
// calendar API — component-bag mutation via JXA silently drops the hour,
|
|
854
|
+
// leaking the current wall-clock hour into the reminder (UIC6).
|
|
855
|
+
var cur=attr(dt,'AXValue'); if(!cur) throw new Error('set-datetime '+target+': the date/time control has no value to anchor the time on');
|
|
351
856
|
var hm=spec.slice(5).split(':');
|
|
352
857
|
d=cal.dateBySettingHourMinuteSecondOfDateOptions(+hm[0], +hm[1], 0, cur, 0);
|
|
353
858
|
} else if(spec.indexOf('date:')===0){
|
|
@@ -358,11 +863,35 @@ function run(){
|
|
|
358
863
|
} else { throw new Error('bad datetime spec: '+spec); }
|
|
359
864
|
if(!d) throw new Error('could not build date from '+spec);
|
|
360
865
|
var err=$.AXUIElementSetAttributeValue(dt,$('AXValue'),d);
|
|
361
|
-
if(err!==0) throw new Error('
|
|
866
|
+
if(err!==0) throw new Error('set-datetime '+target+': the control refused the write (AX err='+err+')');
|
|
362
867
|
$.NSThread.sleepForTimeInterval(0.2);
|
|
868
|
+
// READ-BACK: a control can accept the AX write (err 0) yet reject the value —
|
|
869
|
+
// the macOS error beep — leaving its prior/default value. Fail the step loudly
|
|
870
|
+
// rather than let a garbled commit verify as ok (YANCH1 #493).
|
|
871
|
+
if(spec.indexOf('date:')===0){
|
|
872
|
+
var got=ymdStr(dt,cal); var want=spec.slice(5);
|
|
873
|
+
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');
|
|
874
|
+
} else {
|
|
875
|
+
var gott=hmStr(dt,cal); var wanth=spec.slice(5).split(':'); var wantt=(+wanth[0])+':'+('0'+(+wanth[1])).slice(-2);
|
|
876
|
+
if(gott!==wantt) throw new Error('set-datetime '+target+' rejected: the control committed '+(gott||'(no value)')+', not the requested '+wantt+' — the write did not take');
|
|
877
|
+
}
|
|
363
878
|
return 'OK';
|
|
364
879
|
}`;
|
|
365
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* The converge-weekdays step encodes both of its inputs in `value` as
|
|
883
|
+
* `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
|
|
884
|
+
* FIRST weekday row, which the dialog SHAPE decides (2 legacy / 3 next-popup),
|
|
885
|
+
* so it rides the same shape-selected `value` the driver merges in.
|
|
886
|
+
*/
|
|
887
|
+
export function weekdayBaseOf(value) {
|
|
888
|
+
const base = Number(value.split("|", 1)[0]);
|
|
889
|
+
return Number.isFinite(base) && base > 0 ? Math.trunc(base) : 2;
|
|
890
|
+
}
|
|
891
|
+
export function weekdayTitlesOf(value) {
|
|
892
|
+
const rest = value.slice(value.indexOf("|") + 1);
|
|
893
|
+
return rest.split(",").filter((t) => t !== "");
|
|
894
|
+
}
|
|
366
895
|
/** Parse a resolve-frame "x y w h" line into the frame's center point. */
|
|
367
896
|
export function parseFrameCenter(stdout) {
|
|
368
897
|
const nums = stdout.trim().split(/\s+/).map(Number);
|
|
@@ -374,30 +903,64 @@ export function parseFrameCenter(stdout) {
|
|
|
374
903
|
function revealUrl(uuid) {
|
|
375
904
|
return `things:///show?id=${encodeURIComponent(uuid)}`;
|
|
376
905
|
}
|
|
377
|
-
function defaultRun(command, timeoutMs) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
[bin, args] = ["osascript", ["-e", command.script ?? ""]];
|
|
390
|
-
}
|
|
391
|
-
execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
392
|
-
const timedOut = err !== null && err.killed === true;
|
|
393
|
-
resolve({
|
|
394
|
-
ok: err === null,
|
|
395
|
-
stdout: String(stdout),
|
|
396
|
-
stderr: String(stderr),
|
|
397
|
-
...(timedOut && { timedOut: true }),
|
|
906
|
+
async function defaultRun(command, timeoutMs) {
|
|
907
|
+
if (command.primitive === "reveal") {
|
|
908
|
+
// `open` is consent-free (LaunchServices, no AppleEvent) — never routed.
|
|
909
|
+
return new Promise((resolve) => {
|
|
910
|
+
execFile("open", [command.url ?? ""], { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
911
|
+
const timedOut = err !== null && err.killed === true;
|
|
912
|
+
resolve({
|
|
913
|
+
ok: err === null,
|
|
914
|
+
stdout: String(stdout),
|
|
915
|
+
stderr: String(stderr),
|
|
916
|
+
...(timedOut && { timedOut: true }),
|
|
917
|
+
});
|
|
398
918
|
});
|
|
399
919
|
});
|
|
400
|
-
}
|
|
920
|
+
}
|
|
921
|
+
// JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
|
|
922
|
+
const lang = command.lang === "javascript" ? "javascript" : "applescript";
|
|
923
|
+
const res = await osaExec(command.script ?? "", { lang, timeoutMs });
|
|
924
|
+
return {
|
|
925
|
+
ok: res.exitCode === 0 && res.timedOut !== true,
|
|
926
|
+
stdout: res.stdout,
|
|
927
|
+
stderr: res.stderr,
|
|
928
|
+
...(res.timedOut === true && { timedOut: true }),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
933
|
+
* step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
|
|
934
|
+
* even with tracing off), and — when tracing is on — a `ui-dispatch` start/end
|
|
935
|
+
* pair lands in the trace carrying the hop's duration and outcome. This
|
|
936
|
+
* per-osascript granularity is exactly what reconstructs a hang: the timeline
|
|
937
|
+
* shows which step's osascript was in flight, and for how long, when it stopped
|
|
938
|
+
* (TRACE1 #487). Overhead when tracing is off is one boolean check + a field write.
|
|
939
|
+
*/
|
|
940
|
+
function tracingRun(inner) {
|
|
941
|
+
return async (command, timeoutMs) => {
|
|
942
|
+
noteInflightStep(command.label);
|
|
943
|
+
if (!traceActive())
|
|
944
|
+
return inner(command, timeoutMs);
|
|
945
|
+
const started = Date.now();
|
|
946
|
+
trace(() => ({
|
|
947
|
+
phase: "ui-dispatch",
|
|
948
|
+
event: "start",
|
|
949
|
+
primitive: command.primitive,
|
|
950
|
+
label: command.label,
|
|
951
|
+
}));
|
|
952
|
+
const res = await inner(command, timeoutMs);
|
|
953
|
+
trace(() => ({
|
|
954
|
+
phase: "ui-dispatch",
|
|
955
|
+
event: "end",
|
|
956
|
+
primitive: command.primitive,
|
|
957
|
+
label: command.label,
|
|
958
|
+
durationMs: Date.now() - started,
|
|
959
|
+
ok: res.ok,
|
|
960
|
+
timedOut: res.timedOut === true,
|
|
961
|
+
}));
|
|
962
|
+
return res;
|
|
963
|
+
};
|
|
401
964
|
}
|
|
402
965
|
/** The element paths the preflight canary resolves (static steps only). */
|
|
403
966
|
function canaryPaths(recipe) {
|
|
@@ -485,7 +1048,33 @@ export function commandForStep(step, targetUuid) {
|
|
|
485
1048
|
primitive: "set-datetime",
|
|
486
1049
|
label: step.label,
|
|
487
1050
|
lang: "javascript",
|
|
488
|
-
script: axSetDateTimeScript(step.value ?? ""),
|
|
1051
|
+
script: axSetDateTimeScript(step.value ?? "", step.dtTarget ?? "next"),
|
|
1052
|
+
};
|
|
1053
|
+
case "ensure-checkbox":
|
|
1054
|
+
return {
|
|
1055
|
+
primitive: "ensure-checkbox",
|
|
1056
|
+
label: step.label,
|
|
1057
|
+
script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
|
|
1058
|
+
};
|
|
1059
|
+
case "probe-dialog-shape":
|
|
1060
|
+
return {
|
|
1061
|
+
primitive: "probe-dialog-shape",
|
|
1062
|
+
label: step.label,
|
|
1063
|
+
script: axProbeDialogShapeScript(step.path ?? ""),
|
|
1064
|
+
};
|
|
1065
|
+
case "select-next-occurrence":
|
|
1066
|
+
return {
|
|
1067
|
+
primitive: "select-next-occurrence",
|
|
1068
|
+
label: step.label,
|
|
1069
|
+
script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
|
|
1070
|
+
};
|
|
1071
|
+
case "converge-weekdays":
|
|
1072
|
+
return {
|
|
1073
|
+
primitive: "converge-weekdays",
|
|
1074
|
+
label: step.label,
|
|
1075
|
+
// `value` is "<base index>|<Weekday>,<Weekday>…" — the base index is the
|
|
1076
|
+
// shape-selected group pop-up index of the first weekday row (RDLG2).
|
|
1077
|
+
script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
|
|
489
1078
|
};
|
|
490
1079
|
case "wait":
|
|
491
1080
|
return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
|
|
@@ -501,6 +1090,12 @@ export function commandForStep(step, targetUuid) {
|
|
|
501
1090
|
label: step.label,
|
|
502
1091
|
script: axSelectHeadingRowScript(step.path ?? "", Number(step.value ?? "0")),
|
|
503
1092
|
};
|
|
1093
|
+
case "assert-eligible":
|
|
1094
|
+
return {
|
|
1095
|
+
primitive: "assert-eligible",
|
|
1096
|
+
label: step.label,
|
|
1097
|
+
script: axAssertEligibleScript(step.value ?? targetUuid, step.path ?? ""),
|
|
1098
|
+
};
|
|
504
1099
|
case "key":
|
|
505
1100
|
return { primitive: "key", label: step.label, script: axKeyScript(step.keys ?? "") };
|
|
506
1101
|
case "click-element":
|
|
@@ -567,20 +1162,69 @@ async function driveClickElement(step, run) {
|
|
|
567
1162
|
}
|
|
568
1163
|
return { ok: true };
|
|
569
1164
|
}
|
|
570
|
-
async function drive(recipe, run, aux) {
|
|
1165
|
+
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
571
1166
|
const done = [];
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
|
|
1167
|
+
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
1168
|
+
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
1169
|
+
// commits the Repeat dialog several times slower than the lab golden), which
|
|
1170
|
+
// is how #487 fired: the caller's 30s kill left empty stdout and no retained
|
|
1171
|
+
// exit code. This budget lets the CLI give up FIRST — clearing any open dialog
|
|
1172
|
+
// and returning an honest, uncertain-outcome timeout — so the caller always
|
|
1173
|
+
// receives structured output. Checked between steps (per-step execFile
|
|
1174
|
+
// timeouts bound each osascript, so a step boundary is never far off).
|
|
1175
|
+
const driveStart = Date.now();
|
|
1176
|
+
const driveDeadline = driveStart + budgetMs;
|
|
1177
|
+
const overBudget = () => Date.now() >= driveDeadline;
|
|
1178
|
+
const watchdogResult = async (lastStep) => {
|
|
1179
|
+
// Attempt the SESSGATE dialog clearance so the watchdog never leaves a stuck
|
|
1180
|
+
// modal behind (#485), then report honestly. The outcome is UNCERTAIN: a rule
|
|
1181
|
+
// whose OK press was mid-commit could still land — the pipeline re-verifies
|
|
1182
|
+
// and shapes the final result accordingly.
|
|
1183
|
+
const clear = await clearDialog(run);
|
|
1184
|
+
trace(() => ({
|
|
1185
|
+
phase: "watchdog",
|
|
1186
|
+
budgetMs,
|
|
1187
|
+
elapsedMs: Date.now() - driveStart,
|
|
1188
|
+
lastStep,
|
|
1189
|
+
clear: clear.state,
|
|
1190
|
+
completed: done,
|
|
1191
|
+
}));
|
|
1192
|
+
return {
|
|
1193
|
+
exitCode: 1,
|
|
1194
|
+
stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
|
|
1195
|
+
stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
|
|
1196
|
+
timedOut: true,
|
|
1197
|
+
watchdog: {
|
|
1198
|
+
budgetMs,
|
|
1199
|
+
elapsedMs: Date.now() - driveStart,
|
|
1200
|
+
lastStep,
|
|
1201
|
+
clear: clear.state,
|
|
1202
|
+
tracePath: tracePath(),
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
};
|
|
1206
|
+
// A note prepended to the success summary when the drive had to RELOCATE the
|
|
1207
|
+
// Things window to the current Space to open its dialog (SESSGATE wrong-Space
|
|
1208
|
+
// recovery) — surfaced to the caller as a disclosure warning.
|
|
1209
|
+
let relocationNote = "";
|
|
1210
|
+
// `clear`: how a half-open sheet was cleaned up after a failure (honest — never
|
|
1211
|
+
// claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
|
|
1212
|
+
// opened / no cleanup ran (a benign preamble/canary failure).
|
|
1213
|
+
const partial = (failed, why, clear) => {
|
|
576
1214
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
577
|
-
const cleanup =
|
|
578
|
-
? "
|
|
579
|
-
:
|
|
580
|
-
? "
|
|
581
|
-
|
|
582
|
-
"
|
|
583
|
-
|
|
1215
|
+
const cleanup = clear === undefined
|
|
1216
|
+
? ""
|
|
1217
|
+
: clear.state === "dismissed"
|
|
1218
|
+
? " The open sheet/popover was dismissed (Escape, confirmed gone)."
|
|
1219
|
+
: clear.state === "cleared-blind"
|
|
1220
|
+
? " Things had no window reachable on the current screen (the Mac may be locked, or a" +
|
|
1221
|
+
" full-screen app is covering the desktop), so the open dialog could not be confirmed" +
|
|
1222
|
+
" through the on-screen layer — the Things window was closed and reopened to clear it," +
|
|
1223
|
+
" discarding any partially-entered rule. Unlock the Mac or leave the full-screen app" +
|
|
1224
|
+
" before retrying."
|
|
1225
|
+
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
1226
|
+
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
1227
|
+
" make the next drive's preflight fail).";
|
|
584
1228
|
return refusal(base + cleanup);
|
|
585
1229
|
};
|
|
586
1230
|
// 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
|
|
@@ -603,6 +1247,22 @@ async function drive(recipe, run, aux) {
|
|
|
603
1247
|
// Let the selection settle so the menu bar repopulates before the canary reads it.
|
|
604
1248
|
if (idx > 0)
|
|
605
1249
|
await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
|
|
1250
|
+
// 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
|
|
1251
|
+
// that opens a sheet on the main window needs that window AX-reachable on
|
|
1252
|
+
// the current Space. Probed AFTER the preamble (which surfaces a window in a
|
|
1253
|
+
// healthy session) and BEFORE the canary/press (no mutation yet): a locked /
|
|
1254
|
+
// full-screen session REFUSES (blocked, zero mutation); a window merely on
|
|
1255
|
+
// another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
|
|
1256
|
+
if (recipe.needsWindowReachability === true) {
|
|
1257
|
+
const reach = await ensureWindowReachable(run, reachCache);
|
|
1258
|
+
if (!reach.ok)
|
|
1259
|
+
return blockedReachability(reach.verdict);
|
|
1260
|
+
if (reach.relocated) {
|
|
1261
|
+
relocationNote =
|
|
1262
|
+
"the Things window was on another desktop, so it was moved to the desktop you're viewing " +
|
|
1263
|
+
"to open the dialog. ";
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
606
1266
|
// 1. Recipe canary: resolve every statically-reachable element (now that the
|
|
607
1267
|
// target is selected). A miss refuses the whole drive before anything is
|
|
608
1268
|
// pressed. (This is also the localization check: English titles must resolve.)
|
|
@@ -629,8 +1289,44 @@ async function drive(recipe, run, aux) {
|
|
|
629
1289
|
}
|
|
630
1290
|
}
|
|
631
1291
|
// 2. Execute the remaining steps in order; a dynamic element is waited-for.
|
|
1292
|
+
//
|
|
1293
|
+
// `dialogShape` is the Repeat dialog's MEASURED structure (RDLG2), set by the
|
|
1294
|
+
// recipe's `probe-dialog-shape` step and consumed by the steps that address a
|
|
1295
|
+
// control the 3.23 redesign moved or replaced. It stays null on every recipe
|
|
1296
|
+
// that never probes (no shape-dependent step), and any step that needs it while
|
|
1297
|
+
// it is null fails closed rather than guessing an index.
|
|
1298
|
+
let dialogShape = null;
|
|
632
1299
|
for (let i = idx; i < recipe.steps.length; i += 1) {
|
|
633
1300
|
let step = recipe.steps[i];
|
|
1301
|
+
// Shape-gated step: the recipe emits BOTH the legacy and the 3.23 drive for a
|
|
1302
|
+
// control whose CLASS changed, and only the matching one runs.
|
|
1303
|
+
if (step.onlyShape !== undefined) {
|
|
1304
|
+
if (dialogShape === null) {
|
|
1305
|
+
const clear = await clearDialog(run);
|
|
1306
|
+
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1307
|
+
}
|
|
1308
|
+
if (step.onlyShape !== dialogShape)
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
// Shape-selected paths/values (the +1 index shift the 3.23 "Next:" pop-up
|
|
1312
|
+
// introduced, and the weekday-row base index).
|
|
1313
|
+
if (step.shaped !== undefined) {
|
|
1314
|
+
if (dialogShape === null) {
|
|
1315
|
+
const clear = await clearDialog(run);
|
|
1316
|
+
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1317
|
+
}
|
|
1318
|
+
const override = step.shaped[dialogShape];
|
|
1319
|
+
if (override === undefined) {
|
|
1320
|
+
const clear = await clearDialog(run);
|
|
1321
|
+
return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
|
|
1322
|
+
}
|
|
1323
|
+
step = { ...step, ...override };
|
|
1324
|
+
}
|
|
1325
|
+
// Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
|
|
1326
|
+
// (the per-step execFile timeouts keep the boundary close), clear any open
|
|
1327
|
+
// dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
|
|
1328
|
+
if (overBudget())
|
|
1329
|
+
return watchdogResult(step.label);
|
|
634
1330
|
if (step.primitive === "wait") {
|
|
635
1331
|
// A candidate-addressed wait polls for ANY of its shapes to appear (the
|
|
636
1332
|
// dialog opening as an attached sheet OR a detached AXUnknown window).
|
|
@@ -638,8 +1334,8 @@ async function drive(recipe, run, aux) {
|
|
|
638
1334
|
const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
|
|
639
1335
|
if (!ok) {
|
|
640
1336
|
// the abort keystroke must land (and be verified) before returning the partial-state report
|
|
641
|
-
const
|
|
642
|
-
return partial(step.label, "the expected element never appeared within the timeout",
|
|
1337
|
+
const clear = await clearDialog(run);
|
|
1338
|
+
return partial(step.label, "the expected element never appeared within the timeout", clear);
|
|
643
1339
|
}
|
|
644
1340
|
done.push(step.label);
|
|
645
1341
|
continue;
|
|
@@ -665,13 +1361,35 @@ async function drive(recipe, run, aux) {
|
|
|
665
1361
|
const effective = await resolveStepPath(step, run);
|
|
666
1362
|
if (effective === null) {
|
|
667
1363
|
// dismiss whatever opened (and verify) before reporting
|
|
668
|
-
const
|
|
1364
|
+
const clear = await clearDialog(run);
|
|
669
1365
|
return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
|
|
670
|
-
"detached repeat editor window)",
|
|
1366
|
+
"detached repeat editor window)", clear);
|
|
671
1367
|
}
|
|
672
1368
|
step = { ...step, path: effective };
|
|
673
1369
|
}
|
|
674
1370
|
const command = commandForStep(step, recipe.targetUuid);
|
|
1371
|
+
if (step.primitive === "probe-dialog-shape") {
|
|
1372
|
+
// MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
|
|
1373
|
+
// A shape we do not recognize refuses the drive with the dialog cleared —
|
|
1374
|
+
// the same fail-closed posture as a canary miss, and for the same reason:
|
|
1375
|
+
// pressing a structural index into an unknown tree is how a GUI driver
|
|
1376
|
+
// writes the wrong rule.
|
|
1377
|
+
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1378
|
+
const verdict = res.stdout.trim();
|
|
1379
|
+
if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
|
|
1380
|
+
const clear = await clearDialog(run);
|
|
1381
|
+
return partial(step.label, res.ok
|
|
1382
|
+
? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
|
|
1383
|
+
"field, so the dialog matched neither known shape — a Things update has redesigned " +
|
|
1384
|
+
"it again; nothing was entered into the rule"
|
|
1385
|
+
: res.timedOut === true
|
|
1386
|
+
? "the dialog-shape probe timed out"
|
|
1387
|
+
: res.stderr.trim() || "the dialog-shape probe failed", clear);
|
|
1388
|
+
}
|
|
1389
|
+
dialogShape = verdict;
|
|
1390
|
+
done.push(`${step.label} (${verdict})`);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
675
1393
|
if (step.primitive === "select-row" || step.primitive === "select-heading-row") {
|
|
676
1394
|
// Pure-AX row selection with readback verification (UIC4-a / HEADCERT1):
|
|
677
1395
|
// "OK" only when the intended row selected (title readback for a project
|
|
@@ -680,7 +1398,7 @@ async function drive(recipe, run, aux) {
|
|
|
680
1398
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
681
1399
|
if (!res.ok || res.stdout.trim() !== "OK") {
|
|
682
1400
|
// clear any transient state (and verify) before reporting
|
|
683
|
-
const
|
|
1401
|
+
const clear = await clearDialog(run);
|
|
684
1402
|
const noMatch = step.primitive === "select-heading-row"
|
|
685
1403
|
? "the project view exposed no selectable heading row at the target position — the " +
|
|
686
1404
|
"heading may have been converted/deleted already, or the project's headings changed"
|
|
@@ -690,7 +1408,29 @@ async function drive(recipe, run, aux) {
|
|
|
690
1408
|
? noMatch
|
|
691
1409
|
: res.timedOut === true
|
|
692
1410
|
? "the row-selection step timed out"
|
|
693
|
-
: res.stderr.trim() || "the row-selection step failed",
|
|
1411
|
+
: res.stderr.trim() || "the row-selection step failed", clear);
|
|
1412
|
+
}
|
|
1413
|
+
done.push(step.label);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (step.primitive === "assert-eligible") {
|
|
1417
|
+
// ADR1 (#480): fail EARLY + NAMED when the reveal did not land an eligible
|
|
1418
|
+
// selection, rather than letting a disabled-menu no-op surface downstream
|
|
1419
|
+
// as an opaque dialog-wait timeout. The script returns "OK" or a diagnostic
|
|
1420
|
+
// (NOTSEL…/WRONGSEL…/DISABLED…) that IS the human-readable failure reason.
|
|
1421
|
+
// the selection/enabled state must be confirmed before the menu is pressed
|
|
1422
|
+
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1423
|
+
const verdict = res.stdout.trim();
|
|
1424
|
+
if (!res.ok || verdict !== "OK") {
|
|
1425
|
+
// clear any transient state (and verify) before reporting
|
|
1426
|
+
const clear = await clearDialog(run);
|
|
1427
|
+
return partial(step.label, res.ok
|
|
1428
|
+
? verdict !== ""
|
|
1429
|
+
? verdict
|
|
1430
|
+
: "the target to-do was not confirmed selected/eligible after the reveal"
|
|
1431
|
+
: res.timedOut === true
|
|
1432
|
+
? "the eligibility check timed out"
|
|
1433
|
+
: res.stderr.trim() || "the eligibility check failed", clear);
|
|
694
1434
|
}
|
|
695
1435
|
done.push(step.label);
|
|
696
1436
|
continue;
|
|
@@ -701,9 +1441,9 @@ async function drive(recipe, run, aux) {
|
|
|
701
1441
|
// the click depends on the UI state the previous step produced
|
|
702
1442
|
const outcome = await driveClickElement(step, run);
|
|
703
1443
|
if (!outcome.ok) {
|
|
704
|
-
//
|
|
705
|
-
const
|
|
706
|
-
return partial(step.label, outcome.why ?? "the click failed",
|
|
1444
|
+
// clear whatever the click opened (honest cleanup) before reporting
|
|
1445
|
+
const clear = outcome.needsAbort === true ? await clearDialog(run) : undefined;
|
|
1446
|
+
return partial(step.label, outcome.why ?? "the click failed", clear);
|
|
707
1447
|
}
|
|
708
1448
|
done.push(step.label);
|
|
709
1449
|
continue;
|
|
@@ -711,15 +1451,20 @@ async function drive(recipe, run, aux) {
|
|
|
711
1451
|
// each recipe step depends on the UI state the previous step produced; they cannot be parallelized
|
|
712
1452
|
const res = await run(command, STEP_TIMEOUT_MS);
|
|
713
1453
|
if (!res.ok) {
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
1454
|
+
// clear the half-open sheet/popover (honest — never claim an unconfirmed
|
|
1455
|
+
// dismissal) before reporting partial state
|
|
1456
|
+
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
1457
|
+
? await clearDialog(run)
|
|
717
1458
|
: undefined;
|
|
718
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed",
|
|
1459
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
|
|
719
1460
|
}
|
|
720
1461
|
done.push(step.label);
|
|
721
1462
|
}
|
|
722
|
-
return {
|
|
1463
|
+
return {
|
|
1464
|
+
exitCode: 0,
|
|
1465
|
+
stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
|
|
1466
|
+
stderr: "",
|
|
1467
|
+
};
|
|
723
1468
|
}
|
|
724
1469
|
async function waitForElement(command, timeoutMs, run) {
|
|
725
1470
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -796,6 +1541,17 @@ function disabledMatrix() {
|
|
|
796
1541
|
*/
|
|
797
1542
|
export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
798
1543
|
const enabled = config.ui.enabled;
|
|
1544
|
+
const budgetMs = config.ui.driveBudgetMs ?? DEFAULT_UI_DRIVE_BUDGET_MS;
|
|
1545
|
+
// Every osascript hop runs through the tracing seam: it notes the step on the
|
|
1546
|
+
// in-flight marker (for the signal handler) and, when tracing is on, records a
|
|
1547
|
+
// start/end pair with timing/outcome (TRACE1 #487).
|
|
1548
|
+
const tracedRun = tracingRun(run);
|
|
1549
|
+
// Intra-invocation reachability memo (PERF1), shared between the pre-seed gate
|
|
1550
|
+
// (probeReachability) and the in-drive gate (ensureWindowReachable) so a promote
|
|
1551
|
+
// composite does not probe the session — seconds-long on a busy desktop — twice.
|
|
1552
|
+
// The vector is rebuilt per client-open, so this is naturally scoped to one CLI
|
|
1553
|
+
// invocation; the memo's own TTL bounds reuse for a long-lived programmatic client.
|
|
1554
|
+
const reachCache = createReachabilityCache();
|
|
799
1555
|
return {
|
|
800
1556
|
id: "ui",
|
|
801
1557
|
matrix: enabled ? enabledMatrix() : disabledMatrix(),
|
|
@@ -806,8 +1562,14 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
806
1562
|
if (invocation.recipe === undefined) {
|
|
807
1563
|
return refusal("ui invocation carried no recipe (compile bug).");
|
|
808
1564
|
}
|
|
809
|
-
return drive(invocation.recipe,
|
|
1565
|
+
return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
|
|
810
1566
|
},
|
|
1567
|
+
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
1568
|
+
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
1569
|
+
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|
|
1570
|
+
// already cleared the H-UI-DRIVE ack by the time it consults this). Populates
|
|
1571
|
+
// the memo the in-drive gate reuses (PERF1).
|
|
1572
|
+
probeReachability: () => reachCache.probe(tracedRun, STEP_TIMEOUT_MS),
|
|
811
1573
|
};
|
|
812
1574
|
}
|
|
813
1575
|
//# sourceMappingURL=ui.js.map
|