things-api 0.18.0 → 0.19.1
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 +18 -9
- package/deputy/AppIcon.icns +0 -0
- package/deputy/VERSION +1 -1
- package/deputy/helpers-Info.plist +4 -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 +3 -1
- 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 +15 -2
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
- 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 +14 -2
- package/deputy/reader/Info.plist +2 -0
- package/deputy/reader/entitlements.plist +30 -0
- package/deputy/reader/main.swift +129 -40
- package/deputy/src/server.swift +25 -2
- package/deputy/src/tcc.swift +58 -0
- package/dist/audit/schema.d.ts +30 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +322 -0
- package/dist/capability.js +706 -0
- package/dist/capability.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +141 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +189 -51
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/op-result.js +6 -0
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/reads.js +47 -1
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +9 -0
- package/dist/cli/commands/setup.js +76 -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 +311 -142
- 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 +49 -27
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +8 -5
- 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 +23 -3
- package/dist/client.js +36 -13
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +8 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +234 -6
- package/dist/deputy/install.js +843 -31
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/notices.js +2 -2
- package/dist/deputy/notices.js.map +1 -1
- package/dist/deputy/protocol.d.ts +59 -5
- package/dist/deputy/protocol.js +48 -11
- package/dist/deputy/protocol.js.map +1 -1
- package/dist/deputy/routing.d.ts +37 -0
- package/dist/deputy/routing.js +181 -27
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +19 -4
- package/dist/diagnose.js +64 -8
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +104 -0
- package/dist/direct-setup.js +658 -0
- package/dist/direct-setup.js.map +1 -0
- package/dist/host-access.d.ts +75 -0
- package/dist/host-access.js +177 -0
- package/dist/host-access.js.map +1 -0
- package/dist/index.d.ts +20 -5
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +27 -1
- package/dist/mcp/server.js +308 -109
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.d.ts +9 -0
- package/dist/op-result.js +13 -1
- package/dist/op-result.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/shape.js +4 -0
- package/dist/read/shape.js.map +1 -1
- package/dist/read/views.d.ts +29 -0
- package/dist/read/views.js +60 -0
- package/dist/read/views.js.map +1 -1
- package/dist/session-grant.d.ts +48 -0
- package/dist/session-grant.js +185 -0
- package/dist/session-grant.js.map +1 -0
- package/dist/surface-copy.d.ts +19 -3
- package/dist/surface-copy.js +19 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/wizard.d.ts +58 -0
- package/dist/wizard.js +174 -0
- package/dist/wizard.js.map +1 -0
- package/dist/write/availability.d.ts +1 -16
- package/dist/write/availability.js +10 -65
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.d.ts +20 -0
- package/dist/write/batch.js +111 -21
- 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 +22 -7
- package/dist/write/commands.js +231 -254
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +32 -7
- package/dist/write/failure-hints.js +9 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +22 -4
- 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 +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +51 -29
- package/dist/write/operations.js +16 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +72 -14
- package/dist/write/opid.js +152 -8
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +80 -0
- package/dist/write/param-schema.js +754 -0
- package/dist/write/param-schema.js.map +1 -0
- package/dist/write/pipeline.d.ts +110 -5
- package/dist/write/pipeline.js +288 -14
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +38 -16
- package/dist/write/pre-state.js +117 -22
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +658 -444
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +25 -8
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/repeat-rule.d.ts +28 -1
- package/dist/write/repeat-rule.js +54 -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 +108 -40
- 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 +598 -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 +6 -10
- 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/shortcuts.js +2 -2
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +15 -4
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +268 -1
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +70 -3
- package/dist/write/vectors/ui-recipes.js +482 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +280 -10
- package/dist/write/vectors/ui.js +1068 -77
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +62 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +2 -1
- package/schema/envelope.schema.json +6 -4
- package/scripts/build-helpers.sh +5 -0
- package/skills/things-cli/SKILL.md +5 -3
- 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 +1 -1
- package/skills/things-cli/references/gui.md +1 -0
- package/skills/things-cli/references/repeating.md +6 -0
- package/skills/things-cli/references/tag-cleanup.md +64 -0
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
<img src="assets/things-api-icon-160x160.png" alt="" width="80" height="80">
|
|
2
|
+
|
|
1
3
|
# things-api
|
|
2
4
|
|
|
3
5
|
A typed TypeScript library + CLI (`things`) for programmatic interaction with [Things 3](https://culturedcode.com/things/) by Cultured Code.
|
|
4
6
|
|
|
5
|
-
**Status: read + write + MCP layers live and published to npm (v0.
|
|
7
|
+
**Status: read + write + MCP layers live and published to npm (v0.19.1 — see [CHANGELOG.md](CHANGELOG.md)).** Reads go straight to the local SQLite database (UI-exact Today ordering, sidebar-grouped Anytime/Someday with container-status cascade, decoded repeat rules, occurrence projections); writes run a verified pipeline over four write vectors — two lab-validated headless surfaces (the URL scheme + AppleScript) and two opt-in surfaces (Apple Shortcuts and an Accessibility-driven GUI vector) — with hazard guards, disruption-tier policy, a JSONL audit trail, batch mode, audit-replay undo (transactional across compound operations), full project lifecycle (complete/cancel/reopen/restore), heading rename/archive/unarchive with children policies, container detach, granular stateful checklists, tag hierarchy management incl. un-nesting, completion/creation backdating, Logbook imports, tiered fuzzy name resolution (uuid prefixes, `things:///show?id=` share links), and kind-neutral in-place reordering within any container or view (one `things reorder <refs…> [--start|--end|--before|--after] --in <target>` verb — Today/Evening, a project, an area, a heading's children, someday, inbox — plus `project move-heading` for the headings themselves and `area reorder` for the sidebar). The Apple Shortcuts surface is wired for the two headless capabilities nothing else can do: creating a heading in an existing project (`things project add-heading`) and clearing a reminder from a date-scheduled item (`things todo clear-reminder`). Both run through bundled proxy shortcuts (`things setup`) and are gated on their presence. The fourth vector — the Accessibility-driven GUI vector — is off by default and drives the local Things app to reach transforms that exist on no scriptable surface (repeat-rule editing on existing to-dos and projects, convert-to-project, sidebar area reorder); see [Accessibility GUI vector](#accessibility-gui-vector-optional-off-by-default) below. Single-item permanent delete stays interactive-only (its macOS consent has no always-allow) and is out of the headless pipeline. See [docs/design/](docs/design/) for the architecture and VM-lab design, [docs/lab/](docs/lab/harness.md) for the probe harness and campaign results the write layer is grounded in, and [docs/atlas/](docs/atlas/schema-v26.md) for the database↔UI map.
|
|
6
8
|
|
|
7
9
|
```sh
|
|
8
10
|
things today --json # read: your Today list, Evening split, UI order
|
|
@@ -24,19 +26,26 @@ Most ways to script Things fire a URL or an AppleScript command and hope. This p
|
|
|
24
26
|
|
|
25
27
|
## Requirements & first-run setup
|
|
26
28
|
|
|
27
|
-
Things 3 installed and launched once, Node ≥ 24, and a handful of one-time macOS consents / Things settings depending on what you use
|
|
28
|
-
|
|
29
|
-
### Shortcuts setup (optional)
|
|
29
|
+
Things 3 installed and launched once, Node ≥ 24, and a handful of one-time macOS consents / Things settings depending on what you use. **See [docs/setup.md](docs/setup.md)** — including the dedicated-automation-Mac checklist.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Run **one** of the two setup ceremonies and you are done:
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
things setup
|
|
34
|
+
things setup # grants attach to this terminal (or agent harness)
|
|
35
|
+
things helpers setup # grants attach to a signed helper pair instead
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
Both settle everything in one sitting — read access to the Things data folder, permission to control the Things app, and the bundled shortcuts — skipping anything already granted, resuming exactly where they left off, and exiting nonzero while anything is outstanding. `things setup` is the quick path; `things helpers setup` is the durable one, because the grants stop being re-requested every time your terminal updates. Which one you have is reported by `things doctor` and summarized in `things --help`.
|
|
39
|
+
|
|
40
|
+
Outside those two commands **nothing in this package will ever put a macOS permission dialog on your screen**: every entry point works out what it is allowed to do using checks that cannot prompt, and refuses with the exact remediation instead. That is a hard contract, described in [docs/design/permissions-doctrine.md](docs/design/permissions-doctrine.md) — a dialog raised anywhere else is a bug. Handing an explicit `--db <path>` (a Desktop copy, a backup) opts out of all of it: plain file semantics, ordinary errors.
|
|
41
|
+
|
|
42
|
+
### The bundled Shortcuts
|
|
43
|
+
|
|
44
|
+
A few operations exist on no other app surface: creating a heading in an **existing** project, clearing a reminder from a date-scheduled item, and permanently deleting a single item. These run through six bundled Apple Shortcuts (signed `.shortcut` files shipped with the package), installed as one leg of `things setup` — it opens an install sheet per missing shortcut; click "Add Shortcut" on each.
|
|
45
|
+
|
|
46
|
+
On each shortcut's first run macOS asks for permission — choose **Always Allow** so later runs are unattended (the two delete shortcuts re-ask on every run by design; Apple offers no always-allow for deletion). `things setup --dry-run` and `things doctor` report installation state without asking for anything.
|
|
38
47
|
|
|
39
|
-
Once installed, these Shortcuts-only operations become available as ordinary commands: `things project add-heading <project> <title>` (create a heading in an existing project) and `things todo clear-reminder <uuid>` (clear a date-scheduled to-do's reminder while keeping its date). Both are also exposed over MCP (the `heading` tool's add-heading action, and `clear_reminder`). If a required shortcut is missing, the command is blocked up front with a pointer back to `things setup
|
|
48
|
+
Once installed, these Shortcuts-only operations become available as ordinary commands: `things project add-heading <project> <title>` (create a heading in an existing project) and `things todo clear-reminder <uuid>` (clear a date-scheduled to-do's reminder while keeping its date). Both are also exposed over MCP (the `heading` tool's add-heading action, and `clear_reminder`). If a required shortcut is missing, the command is blocked up front with a pointer back to `things setup` — nothing is dispatched.
|
|
40
49
|
|
|
41
50
|
### Accessibility GUI vector (optional, off by default)
|
|
42
51
|
|
|
@@ -141,6 +150,6 @@ The catalog is consolidated around a few verb-parameterized tools (a discriminat
|
|
|
141
150
|
- **Create**: `add_area`, `add_tag`.
|
|
142
151
|
- **Headings & reminders** (Shortcuts-backed where headless-impossible): `heading` (`action` create / rename / archive / unarchive / convert_to_project), `clear_reminder`.
|
|
143
152
|
- **Recurrence via the Accessibility GUI ("ui") vector** (two-key gated — `ui-enabled` config + `dangerously_drive_gui` per call): `repeat` (`scope` todo / project; `action` start / reschedule / pause / resume / create). The `heading` convert_to_project action and the `reorder` areas scope ride the same gated GUI surface.
|
|
144
|
-
- **Generic & discovery**: `run_operation` (the full
|
|
153
|
+
- **Generic & discovery**: `run_operation` (the full 57-op catalog), `batch`, `reorder` (item ordering across scopes, plus `scope=areas` for the sidebar area order), `undo`, `capabilities`, `doctor`.
|
|
145
154
|
|
|
146
155
|
Every write tool takes `dry_run`; the GUI-driven variants additionally require `dangerously_drive_gui`; tools carry read-only/destructive annotations; hazard blocks come back as structured tool errors carrying the same remediation text the CLI prints. Tool descriptions follow the consumer-voice contract in [docs/design/surface-copy.md](docs/design/surface-copy.md).
|
|
Binary file
|
package/deputy/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.0
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
<string>APPL</string>
|
|
18
18
|
<key>CFBundleShortVersionString</key>
|
|
19
19
|
<string>0.0.0</string>
|
|
20
|
+
<!-- Original artwork, drawn by scripts/generate-helper-icon.jxa — the icon
|
|
21
|
+
System Settings shows next to the Automation/Accessibility toggles. -->
|
|
22
|
+
<key>CFBundleIconFile</key>
|
|
23
|
+
<string>AppIcon</string>
|
|
20
24
|
<key>LSUIElement</key>
|
|
21
25
|
<true/>
|
|
22
26
|
</dict>
|
|
Binary file
|
package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
<string>things-reader</string>
|
|
13
13
|
<key>CFBundlePackageType</key>
|
|
14
14
|
<string>APPL</string>
|
|
15
|
+
<key>CFBundleIconFile</key>
|
|
16
|
+
<string>AppIcon</string>
|
|
15
17
|
<key>CFBundleShortVersionString</key>
|
|
16
|
-
<string>1.
|
|
18
|
+
<string>1.3.0</string>
|
|
17
19
|
<key>LSUIElement</key>
|
|
18
20
|
<true/>
|
|
19
21
|
</dict>
|
|
Binary file
|
|
Binary file
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
<plist version="1.0">
|
|
4
4
|
<dict>
|
|
5
5
|
<key>files</key>
|
|
6
|
-
<dict
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
9VX5ZsSdgBEnhGRQvzKyEjHvzeM=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
7
12
|
<key>files2</key>
|
|
8
|
-
<dict
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
li4eVDJT2l1+UncvB0nWNrYjSQliN87HTz3w1j2kG3A=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
21
|
+
</dict>
|
|
9
22
|
<key>rules</key>
|
|
10
23
|
<dict>
|
|
11
24
|
<key>^Resources/</key>
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
<key>CFBundlePackageType</key>
|
|
17
17
|
<string>APPL</string>
|
|
18
18
|
<key>CFBundleShortVersionString</key>
|
|
19
|
-
<string>1.
|
|
19
|
+
<string>1.3.0</string>
|
|
20
|
+
<!-- Original artwork, drawn by scripts/generate-helper-icon.jxa — the icon
|
|
21
|
+
System Settings shows next to the Automation/Accessibility toggles. -->
|
|
22
|
+
<key>CFBundleIconFile</key>
|
|
23
|
+
<string>AppIcon</string>
|
|
20
24
|
<key>LSUIElement</key>
|
|
21
25
|
<true/>
|
|
22
26
|
</dict>
|
|
Binary file
|
|
@@ -3,18 +3,30 @@
|
|
|
3
3
|
<plist version="1.0">
|
|
4
4
|
<dict>
|
|
5
5
|
<key>files</key>
|
|
6
|
-
<dict
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
9VX5ZsSdgBEnhGRQvzKyEjHvzeM=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
7
12
|
<key>files2</key>
|
|
8
13
|
<dict>
|
|
9
14
|
<key>Helpers/things-reader.app</key>
|
|
10
15
|
<dict>
|
|
11
16
|
<key>cdhash</key>
|
|
12
17
|
<data>
|
|
13
|
-
|
|
18
|
+
DCwtQKeK2+xh4Jh2U4b6UU9KZv4=
|
|
14
19
|
</data>
|
|
15
20
|
<key>requirement</key>
|
|
16
21
|
<string>identifier "com.pixelcog.things-reader" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = VNJWARH2W7</string>
|
|
17
22
|
</dict>
|
|
23
|
+
<key>Resources/AppIcon.icns</key>
|
|
24
|
+
<dict>
|
|
25
|
+
<key>hash2</key>
|
|
26
|
+
<data>
|
|
27
|
+
li4eVDJT2l1+UncvB0nWNrYjSQliN87HTz3w1j2kG3A=
|
|
28
|
+
</data>
|
|
29
|
+
</dict>
|
|
18
30
|
</dict>
|
|
19
31
|
<key>rules</key>
|
|
20
32
|
<dict>
|
package/deputy/reader/Info.plist
CHANGED
|
@@ -11,5 +11,35 @@
|
|
|
11
11
|
<true/>
|
|
12
12
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
|
13
13
|
<true/>
|
|
14
|
+
<!-- The ONE path outside the container the reader may touch: its rendezvous
|
|
15
|
+
directory, where it binds the client-facing socket. launchd Sockets
|
|
16
|
+
activation is unusable from inside the sandbox (error 159, measured
|
|
17
|
+
2026-08-24), so the reader binds the socket itself under this exception.
|
|
18
|
+
Scoped to a single dot-directory subtree; notarization accepts
|
|
19
|
+
temporary-exceptions on Developer ID builds. -->
|
|
20
|
+
<!-- MEASURED 2026-08-24: with the file exception alone, plain file writes in
|
|
21
|
+
the rendezvous dir SUCCEED while bind(2) is denied EPERM — unix-socket
|
|
22
|
+
binding is the sandbox's network-bind operation class, path-filtered to
|
|
23
|
+
the container, and network.server does not extend it. The raw-SBPL
|
|
24
|
+
temporary exception below is the one public lever that does. -->
|
|
25
|
+
<!-- Unix-socket bind is the sandbox's network-bind operation class, path-
|
|
26
|
+
filtered to the container — MEASURED 2026-08-24: with the file exception
|
|
27
|
+
alone, file writes in the rendezvous dir succeed while bind(2) is EPERM,
|
|
28
|
+
and `network.server` does not extend it. This raw-SBPL exception is the
|
|
29
|
+
one public lever that does. Grammar is picky (also measured): a
|
|
30
|
+
`(subpath ...)` filter compiles; a regex filter and the bare
|
|
31
|
+
`(allow network-bind network-listen)` form both make sandbox
|
|
32
|
+
initialization TRAP at spawn (SIGTRAP, and launchd bootstrap fails 5).
|
|
33
|
+
"/Users" because entitlements are baked at signing and must serve any
|
|
34
|
+
user's home; the reader's practical reach stays scoped by the file
|
|
35
|
+
exception above (it can only create the socket where it can write). -->
|
|
36
|
+
<key>com.apple.security.temporary-exception.sbpl</key>
|
|
37
|
+
<array>
|
|
38
|
+
<string>(allow network-bind (subpath "/Users"))</string>
|
|
39
|
+
</array>
|
|
40
|
+
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
|
|
41
|
+
<array>
|
|
42
|
+
<string>/.local/state/things-api/reader/</string>
|
|
43
|
+
</array>
|
|
14
44
|
</dict>
|
|
15
45
|
</plist>
|
package/deputy/reader/main.swift
CHANGED
|
@@ -18,11 +18,35 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Ships as a minimal .app bundle (secinit refuses bare executables) signed
|
|
20
20
|
* with a real certificate chain (amfid refuses ad-hoc on sandboxed code).
|
|
21
|
-
*
|
|
21
|
+
*
|
|
22
|
+
* ── The rendezvous lives OUTSIDE this container (helpers 1.3.0) ─────────────
|
|
23
|
+
*
|
|
24
|
+
* A sandboxed process can only bind a socket inside its own container home, so
|
|
25
|
+
* for as long as this process bound its own socket, every CLIENT stat/open of
|
|
26
|
+
* that socket was a cross-app container access — the
|
|
27
|
+
* kTCCServiceSystemPolicyAppData consent class, silent under a Full-Disk-Access
|
|
28
|
+
* host and a modal from anywhere else. That made reader routing per-host, which
|
|
29
|
+
* is the opposite of what the helper pair exists for.
|
|
30
|
+
*
|
|
31
|
+
* So the rendezvous lives OUTSIDE the container, in the user's own state dir:
|
|
32
|
+
*
|
|
33
|
+
* • the SOCKET is bound by THIS process at the host-neutral path injected via
|
|
34
|
+
* THINGS_READER_SOCKET — the sandbox permits it through a
|
|
35
|
+
* home-relative-path temporary-exception entitlement scoped to that one
|
|
36
|
+
* directory. (launchd `Sockets` activation was tried first and FAILS from
|
|
37
|
+
* inside the App Sandbox with error 159 "Sandbox restriction", measured
|
|
38
|
+
* 2026-08-24 — do not re-attempt it.) No fallback paths: without the env
|
|
39
|
+
* this process exits, loudly;
|
|
40
|
+
* • the TOKEN it expects arrives in THINGS_READER_TOKEN, injected into the
|
|
41
|
+
* same plist by the installer, which writes the matching file for clients.
|
|
42
|
+
*
|
|
43
|
+
* What stays in the container is what was always right to keep there: the
|
|
44
|
+
* security-scoped BOOKMARK (the read grant itself, reader-internal, never read
|
|
45
|
+
* by a client) and the audit log.
|
|
22
46
|
*
|
|
23
47
|
* Modes:
|
|
24
48
|
* --grant <startDir> present the NSOpenPanel ceremony, save the bookmark
|
|
25
|
-
* --serve serve the socket (
|
|
49
|
+
* --serve bind and serve the rendezvous socket (default)
|
|
26
50
|
* --version print the version
|
|
27
51
|
*/
|
|
28
52
|
import AppKit
|
|
@@ -34,17 +58,48 @@ let READER_PROTOCOL_VERSION = 1
|
|
|
34
58
|
let MAX_LINE_BYTES = 8 * 1024 * 1024
|
|
35
59
|
let MAX_FILE_READ_BYTES = 64 * 1024 * 1024
|
|
36
60
|
|
|
61
|
+
/// The env var the LaunchAgent carries the expected access token in.
|
|
62
|
+
let READER_TOKEN_ENV = "THINGS_READER_TOKEN"
|
|
63
|
+
|
|
37
64
|
// Sandboxed home == the container Data dir — the durable private store.
|
|
38
65
|
let home = NSHomeDirectory()
|
|
39
66
|
let bookmarkFile = home + "/things-reader.bookmark"
|
|
40
|
-
let socketPath = home + "/reader.sock"
|
|
41
|
-
let tokenFile = home + "/token"
|
|
42
67
|
let logFile = home + "/reader.log"
|
|
68
|
+
// Pre-1.3.0 the socket and token lived here. Only this process can delete them
|
|
69
|
+
// (they are inside its container), so it does — see cleanUpLegacyRendezvous().
|
|
70
|
+
let legacySocketPath = home + "/reader.sock"
|
|
71
|
+
let legacyTokenFile = home + "/token"
|
|
43
72
|
|
|
44
73
|
func stderrLine(_ message: String) {
|
|
45
74
|
FileHandle.standardError.write(Data("things-reader: \(message)\n".utf8))
|
|
46
75
|
}
|
|
47
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The rendezvous socket path, injected by the installer into the LaunchAgent.
|
|
79
|
+
* launchd socket activation is NOT usable here — MEASURED 2026-08-24 on this
|
|
80
|
+
* exact bundle: `launch_activate_socket` fails with 159 ("Sandbox restriction")
|
|
81
|
+
* from inside the App Sandbox, so the reader binds the socket ITSELF at a
|
|
82
|
+
* host-neutral path its entitlements grant
|
|
83
|
+
* (the home-relative-path file exception for the rendezvous dir, plus the
|
|
84
|
+
* raw-SBPL `(allow network-bind (subpath "/Users"))` — unix bind is the
|
|
85
|
+
* sandbox's network-bind class, which file exceptions alone do not grant;
|
|
86
|
+
* see entitlements.plist for the measured grammar). Clients reach the socket
|
|
87
|
+
* with no TCC class.
|
|
88
|
+
*/
|
|
89
|
+
let READER_SOCKET_ENV = "THINGS_READER_SOCKET"
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delete the pre-1.3.0 in-container rendezvous. Those files are dead the moment
|
|
93
|
+
* this build runs — nothing binds or reads them any more — and this process is
|
|
94
|
+
* the ONLY one that can remove them, since they sit in its sandbox container
|
|
95
|
+
* and a client touching that path is the very consent class 1.3.0 escaped.
|
|
96
|
+
* Idempotent; a fresh install finds nothing.
|
|
97
|
+
*/
|
|
98
|
+
func cleanUpLegacyRendezvous() {
|
|
99
|
+
unlink(legacySocketPath)
|
|
100
|
+
unlink(legacyTokenFile)
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
// --- grant ceremony ---
|
|
49
104
|
|
|
50
105
|
func runGrant(startDir: String) -> Never {
|
|
@@ -126,19 +181,6 @@ let scope = Scope()
|
|
|
126
181
|
|
|
127
182
|
// --- server (file verbs only) ---
|
|
128
183
|
|
|
129
|
-
func loadOrCreateToken() -> String {
|
|
130
|
-
if let data = FileManager.default.contents(atPath: tokenFile) {
|
|
131
|
-
let tok = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
132
|
-
if !tok.isEmpty { return tok }
|
|
133
|
-
}
|
|
134
|
-
var bytes = [UInt8](repeating: 0, count: 32)
|
|
135
|
-
for i in 0..<bytes.count { bytes[i] = UInt8.random(in: 0...255) }
|
|
136
|
-
let tok = bytes.map { String(format: "%02x", $0) }.joined()
|
|
137
|
-
FileManager.default.createFile(
|
|
138
|
-
atPath: tokenFile, contents: Data(tok.utf8), attributes: [.posixPermissions: 0o600])
|
|
139
|
-
return tok
|
|
140
|
-
}
|
|
141
|
-
|
|
142
184
|
/// How long a shutting-down reader waits for requests already in flight (the
|
|
143
185
|
/// deputy's DRAIN_TIMEOUT_SECONDS; the two halves are booted out together).
|
|
144
186
|
let READER_DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
|
|
@@ -173,17 +215,41 @@ final class ReaderServer {
|
|
|
173
215
|
cacheLock.unlock()
|
|
174
216
|
}
|
|
175
217
|
|
|
176
|
-
|
|
218
|
+
private var socketPath: String = ""
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bind and serve the rendezvous socket at the host-neutral path the
|
|
222
|
+
* installer injected. The sandbox permits the bind through the reader's
|
|
223
|
+
* home-relative-path entitlement; launchd activation is unusable from
|
|
224
|
+
* inside the sandbox (error 159, measured — see the header).
|
|
225
|
+
*/
|
|
226
|
+
func run(socketPath path: String) {
|
|
227
|
+
socketPath = path
|
|
228
|
+
// Boot diagnostic: discriminate a file-rule failure (entitlement form/path
|
|
229
|
+
// wrong) from a bind-rule failure (operation class) when the bind below
|
|
230
|
+
// fails. Removed file, one line, only on failure.
|
|
231
|
+
let probePath = (path as NSString).deletingLastPathComponent + "/.write-probe"
|
|
232
|
+
if FileManager.default.createFile(atPath: probePath, contents: Data("x".utf8)) {
|
|
233
|
+
unlink(probePath)
|
|
234
|
+
} else {
|
|
235
|
+
stderrLine("write-probe FAILED in rendezvous dir — the file entitlement is not covering it")
|
|
236
|
+
}
|
|
177
237
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
178
|
-
guard fd >= 0 else {
|
|
238
|
+
guard fd >= 0 else {
|
|
239
|
+
stderrLine("socket() errno \(errno)")
|
|
240
|
+
exit(78)
|
|
241
|
+
}
|
|
179
242
|
listenFd = fd
|
|
180
|
-
unlink(
|
|
243
|
+
unlink(path)
|
|
181
244
|
var addr = sockaddr_un()
|
|
182
245
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
183
246
|
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
|
184
|
-
guard
|
|
247
|
+
guard path.utf8.count < capacity else {
|
|
248
|
+
stderrLine("socket path too long: \(path)")
|
|
249
|
+
exit(78)
|
|
250
|
+
}
|
|
185
251
|
_ = withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
186
|
-
|
|
252
|
+
path.withCString { cstr in
|
|
187
253
|
strncpy(UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self), cstr, capacity - 1)
|
|
188
254
|
}
|
|
189
255
|
}
|
|
@@ -192,9 +258,16 @@ final class ReaderServer {
|
|
|
192
258
|
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
193
259
|
}
|
|
194
260
|
}
|
|
195
|
-
guard bound == 0 else {
|
|
196
|
-
|
|
197
|
-
|
|
261
|
+
guard bound == 0 else {
|
|
262
|
+
stderrLine(
|
|
263
|
+
"bind(\(path)) errno \(errno) — the reader's sandbox entitlement must cover this path; run `things helpers setup`")
|
|
264
|
+
exit(78)
|
|
265
|
+
}
|
|
266
|
+
chmod(path, 0o600)
|
|
267
|
+
guard listen(fd, 16) == 0 else {
|
|
268
|
+
stderrLine("listen() errno \(errno)")
|
|
269
|
+
exit(78)
|
|
270
|
+
}
|
|
198
271
|
audit(["event": "started", "pid": Int(getpid()), "version": DEPUTY_VERSION])
|
|
199
272
|
while true {
|
|
200
273
|
let conn = accept(fd, nil, nil)
|
|
@@ -214,18 +287,14 @@ final class ReaderServer {
|
|
|
214
287
|
}
|
|
215
288
|
}
|
|
216
289
|
|
|
217
|
-
private func fatalDie(_ message: String) -> Never {
|
|
218
|
-
stderrLine(message)
|
|
219
|
-
exit(1)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
290
|
/**
|
|
223
|
-
* Graceful drain, then teardown
|
|
224
|
-
*
|
|
225
|
-
* requests within
|
|
291
|
+
* Graceful drain, then teardown — the deputy's semantics: unlink the socket
|
|
292
|
+
* FIRST (the deterministic accept-wake, and it stops new clients dialing a
|
|
293
|
+
* dying process), finish in-flight requests within the bound, flush the log.
|
|
294
|
+
* SIGKILL is the hard stop.
|
|
226
295
|
*/
|
|
227
296
|
func drainAndShutdown(timeout: TimeInterval = READER_DRAIN_TIMEOUT_SECONDS) {
|
|
228
|
-
unlink(socketPath)
|
|
297
|
+
if !socketPath.isEmpty { unlink(socketPath) }
|
|
229
298
|
if listenFd >= 0 {
|
|
230
299
|
Darwin.shutdown(listenFd, SHUT_RDWR)
|
|
231
300
|
close(listenFd)
|
|
@@ -303,7 +372,7 @@ final class ReaderServer {
|
|
|
303
372
|
return errorResponse(id: id, code: "unsupported-protocol", message: "reader speaks protocol \(READER_PROTOCOL_VERSION)")
|
|
304
373
|
}
|
|
305
374
|
guard let reqToken = obj["token"] as? String, reqToken == token else {
|
|
306
|
-
return errorResponse(id: id, code: "bad-token", message: "request token does not match the reader token
|
|
375
|
+
return errorResponse(id: id, code: "bad-token", message: "request token does not match the reader's access token")
|
|
307
376
|
}
|
|
308
377
|
guard let verb = obj["verb"] as? String else {
|
|
309
378
|
return errorResponse(id: id, code: "bad-request", message: "missing verb")
|
|
@@ -354,7 +423,7 @@ final class ReaderServer {
|
|
|
354
423
|
nil,
|
|
355
424
|
errorResponse(
|
|
356
425
|
id: id, code: "not-granted",
|
|
357
|
-
message: "no security-scoped grant yet — run `things helpers
|
|
426
|
+
message: "no security-scoped grant yet — run `things helpers setup` and select the Things data folder")
|
|
358
427
|
)
|
|
359
428
|
}
|
|
360
429
|
return (root, nil)
|
|
@@ -467,7 +536,27 @@ case "--grant":
|
|
|
467
536
|
}
|
|
468
537
|
runGrant(startDir: arguments[2])
|
|
469
538
|
case "--serve":
|
|
470
|
-
|
|
539
|
+
// No fallbacks, deliberately: both the token and the listening socket come
|
|
540
|
+
// from the LaunchAgent, so a --serve outside it is a misconfiguration to
|
|
541
|
+
// surface, never a second code path to keep working. EX_CONFIG (78).
|
|
542
|
+
guard let token = ProcessInfo.processInfo.environment[READER_TOKEN_ENV], !token.isEmpty else {
|
|
543
|
+
stderrLine(
|
|
544
|
+
"\(READER_TOKEN_ENV) is not set — the access token is injected by the reader's LaunchAgent; run `things helpers setup`")
|
|
545
|
+
exit(78)
|
|
546
|
+
}
|
|
547
|
+
guard let socketPath = ProcessInfo.processInfo.environment[READER_SOCKET_ENV],
|
|
548
|
+
!socketPath.isEmpty
|
|
549
|
+
else {
|
|
550
|
+
stderrLine(
|
|
551
|
+
"\(READER_SOCKET_ENV) is not set — the rendezvous path is injected by the reader's LaunchAgent; run `things helpers setup`")
|
|
552
|
+
exit(78)
|
|
553
|
+
}
|
|
554
|
+
// 1.3.0 migration, and only on a real boot — the refusals above must stay
|
|
555
|
+
// side-effect-free, so nothing is deleted until launchd has actually
|
|
556
|
+
// activated us. Idempotent, so paying it every boot costs two failed
|
|
557
|
+
// unlink(2)s.
|
|
558
|
+
cleanUpLegacyRendezvous()
|
|
559
|
+
let server = ReaderServer(token: token)
|
|
471
560
|
signal(SIGPIPE, SIG_IGN)
|
|
472
561
|
signal(SIGTERM, SIG_IGN)
|
|
473
562
|
signal(SIGINT, SIG_IGN)
|
|
@@ -476,14 +565,14 @@ case "--serve":
|
|
|
476
565
|
for source in [termSource, intSource] {
|
|
477
566
|
source.setEventHandler {
|
|
478
567
|
// Graceful drain: stop accepting, finish in-flight reads within a bound,
|
|
479
|
-
// then
|
|
480
|
-
//
|
|
568
|
+
// then exit cleanly (an upgrade boots both halves out mid-flight). The
|
|
569
|
+
// socket itself is launchd's and outlives us.
|
|
481
570
|
server.drainAndShutdown()
|
|
482
571
|
exit(0)
|
|
483
572
|
}
|
|
484
573
|
source.resume()
|
|
485
574
|
}
|
|
486
|
-
Thread.detachNewThread { server.run() }
|
|
575
|
+
Thread.detachNewThread { server.run(socketPath: socketPath) }
|
|
487
576
|
dispatchMain()
|
|
488
577
|
default:
|
|
489
578
|
stderrLine("usage: things-reader --serve | --grant <dir> | --version")
|
package/deputy/src/server.swift
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import Foundation
|
|
13
13
|
|
|
14
14
|
/// How long a shutting-down deputy waits for requests already in flight. An
|
|
15
|
-
/// upgrade (`things helpers
|
|
15
|
+
/// upgrade (`things helpers setup` / `restart`) boots the old process out
|
|
16
16
|
/// mid-flight; without a drain the in-flight request dies with it. launchd's
|
|
17
17
|
/// own bootout wait, and the CLI's `launchctl` timeout, both clear this bound.
|
|
18
18
|
let DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
|
|
@@ -212,6 +212,8 @@ final class Server {
|
|
|
212
212
|
switch verb {
|
|
213
213
|
case "hello":
|
|
214
214
|
result = handleHello(id: id)
|
|
215
|
+
case "prime-ax":
|
|
216
|
+
result = handlePrimeAx(id: id)
|
|
215
217
|
case "osascript":
|
|
216
218
|
guard let script = obj["script"] as? String else {
|
|
217
219
|
result = errorResponse(id: id, code: "bad-request", message: "osascript verb requires a script string")
|
|
@@ -275,7 +277,7 @@ final class Server {
|
|
|
275
277
|
// File verbs live exclusively on the sandboxed things-reader.
|
|
276
278
|
result = errorResponse(
|
|
277
279
|
id: id, code: "unsupported-verb",
|
|
278
|
-
message: "the deputy serves automation verbs only — file access rides things-reader (things helpers
|
|
280
|
+
message: "the deputy serves automation verbs only — file access rides things-reader (things helpers setup)")
|
|
279
281
|
default:
|
|
280
282
|
result = errorResponse(id: id, code: "bad-request", message: "unknown verb \(verb)")
|
|
281
283
|
}
|
|
@@ -312,6 +314,13 @@ final class Server {
|
|
|
312
314
|
|
|
313
315
|
// --- verb handlers ---
|
|
314
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The handshake, plus the deputy's own TCC standing — Accessibility trust
|
|
319
|
+
* and Automation permission for each target it drives. Every field here is
|
|
320
|
+
* PROMPT-FREE by construction (see tcc.swift), which is what lets a status
|
|
321
|
+
* report and the onboarding ceremony ask "is this leg already granted?"
|
|
322
|
+
* without raising a dialog at whoever happens to run `things` next.
|
|
323
|
+
*/
|
|
315
324
|
private func handleHello(id: Any?) -> [String: Any] {
|
|
316
325
|
[
|
|
317
326
|
"id": id ?? NSNull(),
|
|
@@ -321,9 +330,23 @@ final class Server {
|
|
|
321
330
|
"role": "deputy",
|
|
322
331
|
"pid": Int(getpid()),
|
|
323
332
|
"uptimeMs": Int(Date().timeIntervalSince(startedAt) * 1000),
|
|
333
|
+
"axTrusted": accessibilityTrusted(),
|
|
334
|
+
"automation": [
|
|
335
|
+
"things": automationStatus(bundleID: "com.culturedcode.ThingsMac"),
|
|
336
|
+
"systemEvents": automationStatus(bundleID: "com.apple.systemevents"),
|
|
337
|
+
],
|
|
324
338
|
]
|
|
325
339
|
}
|
|
326
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Raise the Accessibility consent dialog for the deputy's own identity. The
|
|
343
|
+
* grant itself is a toggle in System Settings, so the answer can only arrive
|
|
344
|
+
* later — the caller polls `hello`'s axTrusted. Returns the state as of now.
|
|
345
|
+
*/
|
|
346
|
+
private func handlePrimeAx(id: Any?) -> [String: Any] {
|
|
347
|
+
["id": id ?? NSNull(), "ok": true, "axTrusted": primeAccessibility()]
|
|
348
|
+
}
|
|
349
|
+
|
|
327
350
|
// --- audit log ---
|
|
328
351
|
|
|
329
352
|
private func audit(_ entry: [String: Any]) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-free TCC introspection, plus the one call that deliberately DOES
|
|
3
|
+
* prompt (Accessibility).
|
|
4
|
+
*
|
|
5
|
+
* The deputy is the process macOS attributes every AppleEvent and every
|
|
6
|
+
* Accessibility action to, so it — and only it — can answer "am I allowed to
|
|
7
|
+
* drive Things?" truthfully. Both answers ride the `hello` response, which
|
|
8
|
+
* makes `things helpers status` and the onboarding ceremony able to SKIP a leg
|
|
9
|
+
* that is already granted instead of re-firing a dialog at the user.
|
|
10
|
+
*
|
|
11
|
+
* `AEDeterminePermissionToAutomateTarget(…, askUserIfNeeded: false)` is the
|
|
12
|
+
* documented non-prompting probe: it answers from the existing TCC record and
|
|
13
|
+
* returns errAEEventWouldRequireUserConsent (-1744) when no record exists yet
|
|
14
|
+
* — reported here as "unknown", which is exactly the state the ceremony then
|
|
15
|
+
* resolves by sending a real (prompting) AppleEvent.
|
|
16
|
+
*/
|
|
17
|
+
import ApplicationServices
|
|
18
|
+
import Foundation
|
|
19
|
+
|
|
20
|
+
/// One target's Automation state: "granted" | "denied" | "not-running" | "unknown".
|
|
21
|
+
func automationStatus(bundleID: String) -> String {
|
|
22
|
+
var target = AEAddressDesc()
|
|
23
|
+
let bytes = Array(bundleID.utf8)
|
|
24
|
+
let created = bytes.withUnsafeBufferPointer { buf in
|
|
25
|
+
AECreateDesc(typeApplicationBundleID, buf.baseAddress, buf.count, &target)
|
|
26
|
+
}
|
|
27
|
+
guard created == noErr else { return "unknown" }
|
|
28
|
+
defer { AEDisposeDesc(&target) }
|
|
29
|
+
// askUserIfNeeded: false — a status probe must never raise a dialog.
|
|
30
|
+
let status = AEDeterminePermissionToAutomateTarget(&target, typeWildCard, typeWildCard, false)
|
|
31
|
+
switch status {
|
|
32
|
+
case noErr:
|
|
33
|
+
return "granted"
|
|
34
|
+
case -1743: // errAEEventNotPermitted — the user (or MDM) said no.
|
|
35
|
+
return "denied"
|
|
36
|
+
case -600: // procNotFound — the target is not running, so TCC has no answer.
|
|
37
|
+
return "not-running"
|
|
38
|
+
default:
|
|
39
|
+
// -1744 errAEEventWouldRequireUserConsent lands here: never asked yet.
|
|
40
|
+
return "unknown"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Accessibility trust for THIS process, prompt-free.
|
|
45
|
+
func accessibilityTrusted() -> Bool {
|
|
46
|
+
AXIsProcessTrusted()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ask macOS to show the Accessibility prompt for this process. The dialog is
|
|
51
|
+
* fire-and-forget (it offers a Settings deep-link; the actual grant is a
|
|
52
|
+
* toggle the user flips in System Settings), so this returns the CURRENT trust
|
|
53
|
+
* state immediately rather than waiting for one that can only arrive later.
|
|
54
|
+
*/
|
|
55
|
+
func primeAccessibility() -> Bool {
|
|
56
|
+
let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
|
57
|
+
return AXIsProcessTrustedWithOptions([key: true] as CFDictionary)
|
|
58
|
+
}
|