things-api 0.17.0 → 0.19.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 +16 -9
- package/deputy/AppIcon.icns +0 -0
- package/deputy/VERSION +1 -0
- package/deputy/helpers-Info.plist +27 -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 +22 -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/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +128 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +137 -0
- package/deputy/reader/Info.plist +22 -0
- package/deputy/reader/entitlements.plist +45 -0
- package/deputy/reader/main.swift +580 -0
- package/deputy/src/main.swift +133 -0
- package/deputy/src/osascript.swift +102 -0
- package/deputy/src/server.swift +373 -0
- package/deputy/src/sqlite.swift +121 -0
- package/deputy/src/tcc.swift +58 -0
- package/dist/audit/schema.d.ts +13 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +160 -0
- package/dist/capability.js +416 -0
- package/dist/capability.js.map +1 -0
- package/dist/cli/commands/doctor.js +181 -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 +275 -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 +45 -0
- package/dist/cli/commands/op-result.js.map +1 -0
- package/dist/cli/commands/reads.js +47 -1
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +9 -0
- package/dist/cli/commands/setup.js +63 -83
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/writes.d.ts +1 -1
- package/dist/cli/commands/writes.js +262 -109
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/dry-run.d.ts +1 -1
- package/dist/cli/dry-run.js +1 -1
- package/dist/cli/glyphs.d.ts +23 -4
- package/dist/cli/glyphs.js +46 -19
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +53 -27
- 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/main.js +22 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +3 -1
- package/dist/cli/period.js +1 -1
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.js +26 -1
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +31 -4
- package/dist/cli/render.js +127 -15
- package/dist/cli/render.js.map +1 -1
- package/dist/client.d.ts +22 -2
- package/dist/client.js +52 -15
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +31 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +6 -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 +319 -0
- package/dist/deputy/install.js +1239 -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 +140 -0
- package/dist/deputy/protocol.js +143 -0
- package/dist/deputy/protocol.js.map +1 -0
- package/dist/deputy/routing.d.ts +84 -0
- package/dist/deputy/routing.js +422 -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 +57 -2
- package/dist/diagnose.js +105 -3
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +78 -0
- package/dist/direct-setup.js +484 -0
- package/dist/direct-setup.js.map +1 -0
- package/dist/host-access.d.ts +63 -0
- package/dist/host-access.js +165 -0
- package/dist/host-access.js.map +1 -0
- package/dist/index.d.ts +31 -5
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +27 -1
- package/dist/mcp/server.js +300 -97
- 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/recurrence.d.ts +5 -1
- package/dist/model/recurrence.js +19 -1
- 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 +55 -0
- package/dist/op-result.js +161 -0
- package/dist/op-result.js.map +1 -0
- package/dist/read/detail.js +7 -1
- package/dist/read/detail.js.map +1 -1
- package/dist/read/filter-contract.d.ts +1 -1
- package/dist/read/filter-contract.js +4 -0
- package/dist/read/filter-contract.js.map +1 -1
- package/dist/read/queries.js +8 -1
- package/dist/read/queries.js.map +1 -1
- package/dist/read/shape.js +4 -0
- package/dist/read/shape.js.map +1 -1
- package/dist/read/views.d.ts +35 -4
- package/dist/read/views.js +120 -18
- package/dist/read/views.js.map +1 -1
- package/dist/session-grant.d.ts +48 -0
- package/dist/session-grant.js +170 -0
- package/dist/session-grant.js.map +1 -0
- package/dist/surface-copy.d.ts +8 -0
- package/dist/surface-copy.js +8 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.js +6 -2
- package/dist/sync-health.js.map +1 -1
- package/dist/wizard.d.ts +58 -0
- package/dist/wizard.js +167 -0
- package/dist/wizard.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.d.ts +1 -1
- package/dist/write/availability.js +10 -6
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.d.ts +20 -0
- package/dist/write/batch.js +94 -15
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +9 -0
- package/dist/write/capabilities.js +2 -0
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/clear-reminder.js +1 -1
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.d.ts +12 -1
- package/dist/write/commands.js +170 -237
- package/dist/write/commands.js.map +1 -1
- 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 +3 -1
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +19 -2
- package/dist/write/guards.js.map +1 -1
- package/dist/write/lock.d.ts +16 -13
- package/dist/write/lock.js +55 -0
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.js +9 -13
- package/dist/write/make-repeating-project.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 +64 -25
- package/dist/write/operations.js +7 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +14 -0
- package/dist/write/opid.js +22 -0
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +124 -0
- package/dist/write/param-schema.js +747 -0
- package/dist/write/param-schema.js.map +1 -0
- package/dist/write/pipeline.d.ts +78 -2
- package/dist/write/pipeline.js +249 -4
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +8 -0
- package/dist/write/pre-state.js +62 -17
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +635 -367
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +95 -29
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-anchor.d.ts +30 -0
- package/dist/write/repeat-anchor.js +166 -1
- package/dist/write/repeat-anchor.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +28 -1
- package/dist/write/repeat-rule.js +64 -4
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +11 -0
- package/dist/write/resolution-timestamps.js +83 -38
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +4 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/template-mutation.d.ts +137 -0
- package/dist/write/template-mutation.js +451 -0
- package/dist/write/template-mutation.js.map +1 -0
- package/dist/write/undo.js +10 -20
- package/dist/write/undo.js.map +1 -1
- package/dist/write/update-fields.d.ts +151 -0
- package/dist/write/update-fields.js +427 -0
- package/dist/write/update-fields.js.map +1 -0
- package/dist/write/vectors/applescript.js +10 -16
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +1 -1
- package/dist/write/vectors/registry.js +10 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/session-reachability.d.ts +55 -0
- package/dist/write/vectors/session-reachability.js +56 -8
- package/dist/write/vectors/session-reachability.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +7 -16
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +16 -1
- package/dist/write/vectors/simulator.js +68 -12
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +124 -1
- package/dist/write/vectors/ui-recipes.d.ts +19 -0
- package/dist/write/vectors/ui-recipes.js +158 -38
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +89 -0
- package/dist/write/vectors/ui.js +504 -49
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +21 -0
- 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 +9 -1
- package/schema/envelope.schema.json +5 -3
- package/scripts/build-helpers.sh +109 -0
- package/scripts/deputy-cert-setup.sh +60 -0
- package/skills/things-cli/SKILL.md +12 -5
- package/skills/things-cli/references/contracts.md +1 -0
- package/skills/things-cli/references/data-model.md +3 -0
- package/skills/things-cli/references/errors.md +5 -1
- package/skills/things-cli/references/gui.md +1 -0
- package/skills/things-cli/references/repeating.md +75 -0
- package/skills/things-cli/references/tag-cleanup.md +64 -0
package/dist/write/vectors/ui.js
CHANGED
|
@@ -27,15 +27,22 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { execFile } from "node:child_process";
|
|
29
29
|
import { DEFAULT_UI_DRIVE_BUDGET_MS } from "../../config.js";
|
|
30
|
+
import { osaExec } from "../../deputy/osa.js";
|
|
30
31
|
import { noteInflightStep, trace, traceActive, tracePath } from "../../trace/tracer.js";
|
|
31
32
|
import { UI_DRIVE_OPS } from "../operations.js";
|
|
32
33
|
import { escapeAppleScript } from "./applescript.js";
|
|
33
|
-
import { H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
34
|
+
import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachability, } from "./session-reachability.js";
|
|
34
35
|
import { certificationOf } from "./ui-certification.js";
|
|
35
36
|
import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
|
|
36
37
|
/** GUI driving can stall on an unanswered sheet; give each step headroom. */
|
|
37
38
|
const STEP_TIMEOUT_MS = 15_000;
|
|
38
|
-
/**
|
|
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
|
+
*/
|
|
39
46
|
const WAIT_POLL_MS = 300;
|
|
40
47
|
/**
|
|
41
48
|
* How long `resolveStepPath` polls a candidate-addressed control before failing
|
|
@@ -48,9 +55,22 @@ const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
|
|
|
48
55
|
* Settle after the reveal/activate preamble so the menu bar repopulates for the
|
|
49
56
|
* newly-selected target before the canary reads it (UIC1: the Items ▸ Repeat
|
|
50
57
|
* submenu appears only once a repeating item is selected, and the update is not
|
|
51
|
-
* 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.
|
|
65
|
+
*/
|
|
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.
|
|
52
72
|
*/
|
|
53
|
-
const
|
|
73
|
+
const SHAPE_UNPROBED = "the Repeat dialog's shape was never measured, so this control's address is unknown (recipe bug)";
|
|
54
74
|
const SE = `tell application "System Events" to tell process "Things3"`;
|
|
55
75
|
/** resolve-element: does the element exist right now? Returns "true"/"false". */
|
|
56
76
|
export function axResolveScript(path) {
|
|
@@ -178,6 +198,270 @@ export function axSelectPopupCandidatesScript(path, values) {
|
|
|
178
198
|
error "none of the candidate menu items exist: " & {${list}}
|
|
179
199
|
end tell`;
|
|
180
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
|
+
}
|
|
181
465
|
/**
|
|
182
466
|
* select-row: select a PROJECT row by title, purely via AX (UIC4-a). Walks the
|
|
183
467
|
* content table's rows, issues the row `select` action on each (which REPLACES
|
|
@@ -189,6 +473,19 @@ end tell`;
|
|
|
189
473
|
* selection-landed verification, so a match guarantees the intended row is
|
|
190
474
|
* selected. One stable command shape per primitive.
|
|
191
475
|
*
|
|
476
|
+
* VMRES1 correction (2026-08-23, golden-v4 / Things 3.23): the readback LAGS the
|
|
477
|
+
* `select` action, so reading `name of selected to dos` immediately after it can
|
|
478
|
+
* return the PREVIOUS iteration's selection. A row whose `select` lands nothing
|
|
479
|
+
* (the blank spacer that follows the project rows) then matched the prior row's
|
|
480
|
+
* title, the loop returned "OK" one row LATE, and the table was left with NOTHING
|
|
481
|
+
* selected — `Items ▸ Repeat…` never materialized and the drive died at its wait
|
|
482
|
+
* (`verify-failed:silent-noop`). It reproduced 3/3 on the second project-repeat
|
|
483
|
+
* drive of a Things session and 0/2 on the first, which is the signature of a
|
|
484
|
+
* race, not of app state. Fixed the way the heading sibling below already does
|
|
485
|
+
* it: settle after `select`, then require `selected of (row i)` — the row THIS
|
|
486
|
+
* iteration targeted must itself hold the selection — before trusting the title
|
|
487
|
+
* readback. Evidence: [docs/lab/vmres1-residuals.md](../../../docs/lab/vmres1-residuals.md) §2.
|
|
488
|
+
*
|
|
192
489
|
* UIC5 correction: the shipped form set the TABLE's `AXSelectedRows` attribute
|
|
193
490
|
* to a one-row list, which is a SILENT NO-OP on Things' content table via System
|
|
194
491
|
* Events (no error, selection never lands). The row `select` action is the
|
|
@@ -204,9 +501,12 @@ export function axSelectRowScript(tablePath, title) {
|
|
|
204
501
|
repeat with i from 1 to n
|
|
205
502
|
try
|
|
206
503
|
select (row i of theTable)
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
504
|
+
delay 0.25
|
|
505
|
+
if (selected of (row i of theTable)) then
|
|
506
|
+
tell application "Things3" to set selNames to (name of selected to dos)
|
|
507
|
+
if (count of selNames) is 1 and ((item 1 of selNames) as text) is "${t}" then
|
|
508
|
+
return "OK"
|
|
509
|
+
end if
|
|
210
510
|
end if
|
|
211
511
|
end try
|
|
212
512
|
end repeat
|
|
@@ -400,8 +700,11 @@ async function clearDialog(run) {
|
|
|
400
700
|
* Reachable now → proceed (disclosed); still not →
|
|
401
701
|
* block with the Space remediation.
|
|
402
702
|
*/
|
|
403
|
-
async function ensureWindowReachable(run) {
|
|
404
|
-
|
|
703
|
+
async function ensureWindowReachable(run, reachCache) {
|
|
704
|
+
// First probe MAY be served from the pre-seed gate's memo (PERF1) — but only a
|
|
705
|
+
// reachable verdict is ever memoized, so every refusal/relocation below is still
|
|
706
|
+
// decided on a fresh probe (see ReachabilityProbeCache).
|
|
707
|
+
const first = await reachCache.probe(run, STEP_TIMEOUT_MS);
|
|
405
708
|
if (first.reachable)
|
|
406
709
|
return { ok: true, relocated: false };
|
|
407
710
|
if (first.scope === "session")
|
|
@@ -413,6 +716,9 @@ async function ensureWindowReachable(run) {
|
|
|
413
716
|
label: "move the Things window to the current desktop",
|
|
414
717
|
script: axCloseReopenActivateScript(),
|
|
415
718
|
}, STEP_TIMEOUT_MS);
|
|
719
|
+
// The relocation just changed window state — drop any memo and re-probe LIVE
|
|
720
|
+
// (a closed-loop verify of the maneuver, never a cached verdict).
|
|
721
|
+
reachCache.invalidate();
|
|
416
722
|
const second = await probeSessionReachability(run, STEP_TIMEOUT_MS);
|
|
417
723
|
if (second.reachable)
|
|
418
724
|
return { ok: true, relocated: true };
|
|
@@ -506,6 +812,24 @@ function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(n
|
|
|
506
812
|
function rolestr(el){ var v=attr(el,'AXRole'); return v? v.js : ''; }
|
|
507
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; }
|
|
508
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
|
+
}
|
|
509
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; }
|
|
510
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); }
|
|
511
835
|
function pick(areas,target){
|
|
@@ -528,11 +852,15 @@ function run(){
|
|
|
528
852
|
var app=$.AXUIElementCreateApplication(apps.objectAtIndex(0).processIdentifier);
|
|
529
853
|
var target=${JSON.stringify(target)};
|
|
530
854
|
var spec=${JSON.stringify(spec)};
|
|
531
|
-
// Poll for the addressed area
|
|
532
|
-
//
|
|
533
|
-
//
|
|
855
|
+
// Poll for the addressed area WITHIN THE DIALOG SHELL (PERF2): resolve the sheet
|
|
856
|
+
// / detached editor first, then collect only its subtree — the app-root descent
|
|
857
|
+
// this replaced cost ~4.4s on the busy host by walking the main window's list
|
|
858
|
+
// content. collect is wrapped so a stale-element ObjC exception during traversal
|
|
859
|
+
// cannot bubble as a raw -2700; pick guards the empty set, so dt is null (never a
|
|
860
|
+
// crash) when the target is absent. When no shell resolves (dialog absent), areas
|
|
861
|
+
// stays empty and the loop falls through to the SAME named error below.
|
|
534
862
|
var areas=[]; var dt=null;
|
|
535
|
-
for(var t=0;t<20 && !dt;t++){ areas=[]; try{ collect(
|
|
863
|
+
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); }
|
|
536
864
|
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');
|
|
537
865
|
var cal=$.NSCalendar.currentCalendar;
|
|
538
866
|
var d;
|
|
@@ -566,6 +894,20 @@ function run(){
|
|
|
566
894
|
return 'OK';
|
|
567
895
|
}`;
|
|
568
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* The converge-weekdays step encodes both of its inputs in `value` as
|
|
899
|
+
* `"<base>|<Weekday>,<Weekday>…"`: the base is the group pop-up index of the
|
|
900
|
+
* FIRST weekday row, which the dialog SHAPE decides (2 legacy / 3 next-popup),
|
|
901
|
+
* so it rides the same shape-selected `value` the driver merges in.
|
|
902
|
+
*/
|
|
903
|
+
export function weekdayBaseOf(value) {
|
|
904
|
+
const base = Number(value.split("|", 1)[0]);
|
|
905
|
+
return Number.isFinite(base) && base > 0 ? Math.trunc(base) : 2;
|
|
906
|
+
}
|
|
907
|
+
export function weekdayTitlesOf(value) {
|
|
908
|
+
const rest = value.slice(value.indexOf("|") + 1);
|
|
909
|
+
return rest.split(",").filter((t) => t !== "");
|
|
910
|
+
}
|
|
569
911
|
/** Parse a resolve-frame "x y w h" line into the frame's center point. */
|
|
570
912
|
export function parseFrameCenter(stdout) {
|
|
571
913
|
const nums = stdout.trim().split(/\s+/).map(Number);
|
|
@@ -577,30 +919,30 @@ export function parseFrameCenter(stdout) {
|
|
|
577
919
|
function revealUrl(uuid) {
|
|
578
920
|
return `things:///show?id=${encodeURIComponent(uuid)}`;
|
|
579
921
|
}
|
|
580
|
-
function defaultRun(command, timeoutMs) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
[bin, args] = ["osascript", ["-e", command.script ?? ""]];
|
|
593
|
-
}
|
|
594
|
-
execFile(bin, [...args], { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
595
|
-
const timedOut = err !== null && err.killed === true;
|
|
596
|
-
resolve({
|
|
597
|
-
ok: err === null,
|
|
598
|
-
stdout: String(stdout),
|
|
599
|
-
stderr: String(stderr),
|
|
600
|
-
...(timedOut && { timedOut: true }),
|
|
922
|
+
async function defaultRun(command, timeoutMs) {
|
|
923
|
+
if (command.primitive === "reveal") {
|
|
924
|
+
// `open` is consent-free (LaunchServices, no AppleEvent) — never routed.
|
|
925
|
+
return new Promise((resolve) => {
|
|
926
|
+
execFile("open", [command.url ?? ""], { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
927
|
+
const timedOut = err !== null && err.killed === true;
|
|
928
|
+
resolve({
|
|
929
|
+
ok: err === null,
|
|
930
|
+
stdout: String(stdout),
|
|
931
|
+
stderr: String(stderr),
|
|
932
|
+
...(timedOut && { timedOut: true }),
|
|
933
|
+
});
|
|
601
934
|
});
|
|
602
935
|
});
|
|
603
|
-
}
|
|
936
|
+
}
|
|
937
|
+
// JXA (ObjC bridge) for the mouse-synthesis primitive; one stable shape.
|
|
938
|
+
const lang = command.lang === "javascript" ? "javascript" : "applescript";
|
|
939
|
+
const res = await osaExec(command.script ?? "", { lang, timeoutMs });
|
|
940
|
+
return {
|
|
941
|
+
ok: res.exitCode === 0 && res.timedOut !== true,
|
|
942
|
+
stdout: res.stdout,
|
|
943
|
+
stderr: res.stderr,
|
|
944
|
+
...(res.timedOut === true && { timedOut: true }),
|
|
945
|
+
};
|
|
604
946
|
}
|
|
605
947
|
/**
|
|
606
948
|
* Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
|
|
@@ -730,6 +1072,26 @@ export function commandForStep(step, targetUuid) {
|
|
|
730
1072
|
label: step.label,
|
|
731
1073
|
script: axEnsureCheckboxScript(step.path ?? "", step.checkboxTarget === true),
|
|
732
1074
|
};
|
|
1075
|
+
case "probe-dialog-shape":
|
|
1076
|
+
return {
|
|
1077
|
+
primitive: "probe-dialog-shape",
|
|
1078
|
+
label: step.label,
|
|
1079
|
+
script: axProbeDialogShapeScript(step.path ?? ""),
|
|
1080
|
+
};
|
|
1081
|
+
case "select-next-occurrence":
|
|
1082
|
+
return {
|
|
1083
|
+
primitive: "select-next-occurrence",
|
|
1084
|
+
label: step.label,
|
|
1085
|
+
script: axSelectNextOccurrenceScript(step.path ?? "", step.value ?? ""),
|
|
1086
|
+
};
|
|
1087
|
+
case "converge-weekdays":
|
|
1088
|
+
return {
|
|
1089
|
+
primitive: "converge-weekdays",
|
|
1090
|
+
label: step.label,
|
|
1091
|
+
// `value` is "<base index>|<Weekday>,<Weekday>…" — the base index is the
|
|
1092
|
+
// shape-selected group pop-up index of the first weekday row (RDLG2).
|
|
1093
|
+
script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
|
|
1094
|
+
};
|
|
733
1095
|
case "wait":
|
|
734
1096
|
return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
|
|
735
1097
|
case "select-row":
|
|
@@ -816,7 +1178,7 @@ async function driveClickElement(step, run) {
|
|
|
816
1178
|
}
|
|
817
1179
|
return { ok: true };
|
|
818
1180
|
}
|
|
819
|
-
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
1181
|
+
async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS, reachCache = createReachabilityCache()) {
|
|
820
1182
|
const done = [];
|
|
821
1183
|
// The overall-drive WATCHDOG (TRACE1 #487). A drive can outlast the caller's
|
|
822
1184
|
// own timeout on a slow production database (large + Things-Cloud syncing
|
|
@@ -864,7 +1226,14 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
864
1226
|
// `clear`: how a half-open sheet was cleaned up after a failure (honest — never
|
|
865
1227
|
// claim a dismissal we could not see, SESSGATE #480); undefined = no sheet was
|
|
866
1228
|
// opened / no cleanup ran (a benign preamble/canary failure).
|
|
867
|
-
const partial = (failed, why, clear
|
|
1229
|
+
const partial = (failed, why, clear,
|
|
1230
|
+
/**
|
|
1231
|
+
* The failing step's own transport outcome: `true` when its osascript was
|
|
1232
|
+
* killed by its deadline rather than answering. Together with a blind cleanup
|
|
1233
|
+
* this is what separates "the Things window stopped answering" from "the app
|
|
1234
|
+
* answered and refused/did nothing" (#512) — see {@link ExecuteResult.uiUnreachable}.
|
|
1235
|
+
*/
|
|
1236
|
+
stepTimedOut = false) => {
|
|
868
1237
|
const base = `ui drive stopped at "${failed}" (${why}). Completed: ${done.join(" → ") || "nothing"}.`;
|
|
869
1238
|
const cleanup = clear === undefined
|
|
870
1239
|
? ""
|
|
@@ -879,7 +1248,28 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
879
1248
|
: " WARNING: a sheet or popover may still be open in Things — Escape did not dismiss it." +
|
|
880
1249
|
" Dismiss it manually before retrying (a leftover sheet disables the menu bar and will" +
|
|
881
1250
|
" make the next drive's preflight fail).";
|
|
882
|
-
|
|
1251
|
+
// #512: name an environment failure as one. A cleanup that had to run BLIND
|
|
1252
|
+
// is direct evidence the session went AX-blind mid-drive; a step killed by
|
|
1253
|
+
// its own deadline is the window not answering. Either way the app was not
|
|
1254
|
+
// reachable to be driven — which is not the app accepting a command and
|
|
1255
|
+
// changing nothing, and must not be reported as that.
|
|
1256
|
+
const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
|
|
1257
|
+
const res = refusal(base + cleanup);
|
|
1258
|
+
if (cause === null)
|
|
1259
|
+
return res;
|
|
1260
|
+
return {
|
|
1261
|
+
...res,
|
|
1262
|
+
uiUnreachable: {
|
|
1263
|
+
step: failed,
|
|
1264
|
+
cause,
|
|
1265
|
+
...(clear !== undefined && { clear: clear.state }),
|
|
1266
|
+
remediation: cause === "unreachable"
|
|
1267
|
+
? "unlock the Mac, or leave the full-screen app so a Things window is visible on the " +
|
|
1268
|
+
"desktop you're viewing, then run the same command again"
|
|
1269
|
+
: "bring Things to the front and check that it is responding, then run the same " +
|
|
1270
|
+
"command again",
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
883
1273
|
};
|
|
884
1274
|
// 0. Run the leading reveal/activate preamble BEFORE the canary. The Items
|
|
885
1275
|
// menu is context-dependent — its Repeat submenu (and the plain "Repeat…"
|
|
@@ -893,7 +1283,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
893
1283
|
// the preamble steps are strictly sequential (select, then foreground) and each must land before the next
|
|
894
1284
|
const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
|
|
895
1285
|
if (!res.ok) {
|
|
896
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed");
|
|
1286
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", undefined, res.timedOut === true);
|
|
897
1287
|
}
|
|
898
1288
|
done.push(step.label);
|
|
899
1289
|
idx += 1;
|
|
@@ -908,7 +1298,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
908
1298
|
// full-screen session REFUSES (blocked, zero mutation); a window merely on
|
|
909
1299
|
// another Space is RELOCATED back and disclosed. Menu-only recipes skip this.
|
|
910
1300
|
if (recipe.needsWindowReachability === true) {
|
|
911
|
-
const reach = await ensureWindowReachable(run);
|
|
1301
|
+
const reach = await ensureWindowReachable(run, reachCache);
|
|
912
1302
|
if (!reach.ok)
|
|
913
1303
|
return blockedReachability(reach.verdict);
|
|
914
1304
|
if (reach.relocated) {
|
|
@@ -943,8 +1333,39 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
943
1333
|
}
|
|
944
1334
|
}
|
|
945
1335
|
// 2. Execute the remaining steps in order; a dynamic element is waited-for.
|
|
1336
|
+
//
|
|
1337
|
+
// `dialogShape` is the Repeat dialog's MEASURED structure (RDLG2), set by the
|
|
1338
|
+
// recipe's `probe-dialog-shape` step and consumed by the steps that address a
|
|
1339
|
+
// control the 3.23 redesign moved or replaced. It stays null on every recipe
|
|
1340
|
+
// that never probes (no shape-dependent step), and any step that needs it while
|
|
1341
|
+
// it is null fails closed rather than guessing an index.
|
|
1342
|
+
let dialogShape = null;
|
|
946
1343
|
for (let i = idx; i < recipe.steps.length; i += 1) {
|
|
947
1344
|
let step = recipe.steps[i];
|
|
1345
|
+
// Shape-gated step: the recipe emits BOTH the legacy and the 3.23 drive for a
|
|
1346
|
+
// control whose CLASS changed, and only the matching one runs.
|
|
1347
|
+
if (step.onlyShape !== undefined) {
|
|
1348
|
+
if (dialogShape === null) {
|
|
1349
|
+
const clear = await clearDialog(run);
|
|
1350
|
+
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1351
|
+
}
|
|
1352
|
+
if (step.onlyShape !== dialogShape)
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
// Shape-selected paths/values (the +1 index shift the 3.23 "Next:" pop-up
|
|
1356
|
+
// introduced, and the weekday-row base index).
|
|
1357
|
+
if (step.shaped !== undefined) {
|
|
1358
|
+
if (dialogShape === null) {
|
|
1359
|
+
const clear = await clearDialog(run);
|
|
1360
|
+
return partial(step.label, SHAPE_UNPROBED, clear);
|
|
1361
|
+
}
|
|
1362
|
+
const override = step.shaped[dialogShape];
|
|
1363
|
+
if (override === undefined) {
|
|
1364
|
+
const clear = await clearDialog(run);
|
|
1365
|
+
return partial(step.label, `this step has no drive for the "${dialogShape}" Repeat dialog (recipe bug)`, clear);
|
|
1366
|
+
}
|
|
1367
|
+
step = { ...step, ...override };
|
|
1368
|
+
}
|
|
948
1369
|
// Overall-drive watchdog: if the budget is spent, stop at THIS step boundary
|
|
949
1370
|
// (the per-step execFile timeouts keep the boundary close), clear any open
|
|
950
1371
|
// dialog, and return the honest uncertain-outcome timeout (TRACE1 #487).
|
|
@@ -991,6 +1412,28 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
991
1412
|
step = { ...step, path: effective };
|
|
992
1413
|
}
|
|
993
1414
|
const command = commandForStep(step, recipe.targetUuid);
|
|
1415
|
+
if (step.primitive === "probe-dialog-shape") {
|
|
1416
|
+
// MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
|
|
1417
|
+
// A shape we do not recognize refuses the drive with the dialog cleared —
|
|
1418
|
+
// the same fail-closed posture as a canary miss, and for the same reason:
|
|
1419
|
+
// pressing a structural index into an unknown tree is how a GUI driver
|
|
1420
|
+
// writes the wrong rule.
|
|
1421
|
+
const res = await run(command, STEP_TIMEOUT_MS);
|
|
1422
|
+
const verdict = res.stdout.trim();
|
|
1423
|
+
if (!res.ok || (verdict !== "next-popup" && verdict !== "legacy")) {
|
|
1424
|
+
const clear = await clearDialog(run);
|
|
1425
|
+
return partial(step.label, res.ok
|
|
1426
|
+
? 'its first-occurrence row ("Next:") holds neither an occurrence pop-up nor a date ' +
|
|
1427
|
+
"field, so the dialog matched neither known shape — a Things update has redesigned " +
|
|
1428
|
+
"it again; nothing was entered into the rule"
|
|
1429
|
+
: res.timedOut === true
|
|
1430
|
+
? "the dialog-shape probe timed out"
|
|
1431
|
+
: res.stderr.trim() || "the dialog-shape probe failed", clear, res.timedOut === true);
|
|
1432
|
+
}
|
|
1433
|
+
dialogShape = verdict;
|
|
1434
|
+
done.push(`${step.label} (${verdict})`);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
994
1437
|
if (step.primitive === "select-row" || step.primitive === "select-heading-row") {
|
|
995
1438
|
// Pure-AX row selection with readback verification (UIC4-a / HEADCERT1):
|
|
996
1439
|
// "OK" only when the intended row selected (title readback for a project
|
|
@@ -1009,7 +1452,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
1009
1452
|
? noMatch
|
|
1010
1453
|
: res.timedOut === true
|
|
1011
1454
|
? "the row-selection step timed out"
|
|
1012
|
-
: res.stderr.trim() || "the row-selection step failed", clear);
|
|
1455
|
+
: res.stderr.trim() || "the row-selection step failed", clear, res.timedOut === true);
|
|
1013
1456
|
}
|
|
1014
1457
|
done.push(step.label);
|
|
1015
1458
|
continue;
|
|
@@ -1031,7 +1474,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
1031
1474
|
: "the target to-do was not confirmed selected/eligible after the reveal"
|
|
1032
1475
|
: res.timedOut === true
|
|
1033
1476
|
? "the eligibility check timed out"
|
|
1034
|
-
: res.stderr.trim() || "the eligibility check failed", clear);
|
|
1477
|
+
: res.stderr.trim() || "the eligibility check failed", clear, res.timedOut === true);
|
|
1035
1478
|
}
|
|
1036
1479
|
done.push(step.label);
|
|
1037
1480
|
continue;
|
|
@@ -1057,7 +1500,7 @@ async function drive(recipe, run, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS) {
|
|
|
1057
1500
|
const clear = step.primitive !== "reveal" && step.primitive !== "activate"
|
|
1058
1501
|
? await clearDialog(run)
|
|
1059
1502
|
: undefined;
|
|
1060
|
-
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear);
|
|
1503
|
+
return partial(step.label, res.timedOut === true ? "the step timed out" : res.stderr.trim() || "the step failed", clear, res.timedOut === true);
|
|
1061
1504
|
}
|
|
1062
1505
|
done.push(step.label);
|
|
1063
1506
|
}
|
|
@@ -1128,8 +1571,9 @@ function disabledMatrix() {
|
|
|
1128
1571
|
disruption: 3,
|
|
1129
1572
|
validation: "validated",
|
|
1130
1573
|
notes: "the Accessibility GUI vector is off on this machine — enable it with `things config " +
|
|
1131
|
-
"set ui-enabled true`, then
|
|
1132
|
-
"
|
|
1574
|
+
"set ui-enabled true`, then run `things helpers setup --gui`, which grants GUI-driving " +
|
|
1575
|
+
"to the helper pair (the only identity it is granted to). It drives the local Things " +
|
|
1576
|
+
"GUI and is intended for a dedicated always-on Mac.",
|
|
1133
1577
|
};
|
|
1134
1578
|
}
|
|
1135
1579
|
return matrix;
|
|
@@ -1147,23 +1591,34 @@ export function createUiVector(config, run = defaultRun, aux = {}) {
|
|
|
1147
1591
|
// in-flight marker (for the signal handler) and, when tracing is on, records a
|
|
1148
1592
|
// start/end pair with timing/outcome (TRACE1 #487).
|
|
1149
1593
|
const tracedRun = tracingRun(run);
|
|
1594
|
+
// Intra-invocation reachability memo (PERF1), shared between the pre-seed gate
|
|
1595
|
+
// (probeReachability) and the in-drive gate (ensureWindowReachable) so a promote
|
|
1596
|
+
// composite does not probe the session — seconds-long on a busy desktop — twice.
|
|
1597
|
+
// The vector is rebuilt per client-open, so this is naturally scoped to one CLI
|
|
1598
|
+
// invocation; the memo's own TTL bounds reuse for a long-lived programmatic client.
|
|
1599
|
+
const reachCache = createReachabilityCache();
|
|
1150
1600
|
return {
|
|
1151
1601
|
id: "ui",
|
|
1602
|
+
// Article IV: the pipeline's GUI gate keys on this declaration, never on
|
|
1603
|
+
// the id — see WriteVector.drivesGui.
|
|
1604
|
+
drivesGui: true,
|
|
1152
1605
|
matrix: enabled ? enabledMatrix() : disabledMatrix(),
|
|
1153
1606
|
async execute(invocation) {
|
|
1154
1607
|
if (!enabled) {
|
|
1155
|
-
return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it
|
|
1608
|
+
return refusal("the ui vector is disabled (`things config set ui-enabled true` to enable it, then " +
|
|
1609
|
+
"`things helpers setup --gui`).");
|
|
1156
1610
|
}
|
|
1157
1611
|
if (invocation.recipe === undefined) {
|
|
1158
1612
|
return refusal("ui invocation carried no recipe (compile bug).");
|
|
1159
1613
|
}
|
|
1160
|
-
return drive(invocation.recipe, tracedRun, aux, budgetMs);
|
|
1614
|
+
return drive(invocation.recipe, tracedRun, aux, budgetMs, reachCache);
|
|
1161
1615
|
},
|
|
1162
1616
|
// Pre-seed gate seam for the promote orchestrators (SESSGATE, #480): probe the
|
|
1163
1617
|
// live session BEFORE they seed a row, so a locked/full-screen session refuses
|
|
1164
1618
|
// with zero mutation. Present regardless of `enabled` (the orchestrator has
|
|
1165
|
-
// already cleared the H-UI-DRIVE ack by the time it consults this).
|
|
1166
|
-
|
|
1619
|
+
// already cleared the H-UI-DRIVE ack by the time it consults this). Populates
|
|
1620
|
+
// the memo the in-drive gate reuses (PERF1).
|
|
1621
|
+
probeReachability: () => reachCache.probe(tracedRun, STEP_TIMEOUT_MS),
|
|
1167
1622
|
};
|
|
1168
1623
|
}
|
|
1169
1624
|
//# sourceMappingURL=ui.js.map
|