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
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-free capability detection (docs/design/permissions-doctrine.md,
|
|
3
|
+
* Articles I–III).
|
|
4
|
+
*
|
|
5
|
+
* Every entry point must know what it is allowed to do BEFORE it touches the
|
|
6
|
+
* live library, and it must find out without putting a macOS consent dialog on
|
|
7
|
+
* screen. This module is the single place that answers that question, so the
|
|
8
|
+
* read gate, the write gate, `things doctor`, and the `things setup` ceremony
|
|
9
|
+
* all quote the same verdict.
|
|
10
|
+
*
|
|
11
|
+
* Read capability is GROUND TRUTH per invocation, never a stored flag. Nothing
|
|
12
|
+
* here is memoized across calls and nothing is persisted except the one marker
|
|
13
|
+
* that records a live-instance fact (./session-grant.ts). The whole preflight
|
|
14
|
+
* is a single `open(2)` in the common case.
|
|
15
|
+
*
|
|
16
|
+
* The paths, in the order they are consulted:
|
|
17
|
+
*
|
|
18
|
+
* 1. an explicit database path — outside the doctrine entirely (Article VI);
|
|
19
|
+
* 2. the helpers — when the reader is serving this process's reads, the READ
|
|
20
|
+
* ITSELF is the check. The reader resolves its security-scoped bookmark on
|
|
21
|
+
* every verb and answers `not-granted` / `not-found` as typed errors, so
|
|
22
|
+
* there is nothing to pre-probe and no hello round-trip is added here. When
|
|
23
|
+
* the helpers are expected but cannot serve, the read FAILS LOUDLY — a
|
|
24
|
+
* silent fall-through to direct would move consent back onto the terminal,
|
|
25
|
+
* which is the exact churn the helpers exist to end;
|
|
26
|
+
* 3. Full Disk Access — one read-open of the user TCC database. FDA-class
|
|
27
|
+
* files never raise a dialog: the open either works or fails with a silent
|
|
28
|
+
* EPERM;
|
|
29
|
+
* 4. a witnessed session app-data grant — see ./session-grant.ts;
|
|
30
|
+
* 5. otherwise: refuse, naming both setup ceremonies.
|
|
31
|
+
*
|
|
32
|
+
* Step 2 asks nothing of this host's grants. Since helpers 1.3.0 the reader's
|
|
33
|
+
* socket and token live in `<state>/reader` — launchd owns the socket, install
|
|
34
|
+
* mints the token, and both are ordinary files this user owns — so "is the
|
|
35
|
+
* reader serving?" is answerable identically from every host app, with no
|
|
36
|
+
* consent class in play. (Before 1.3.0 they sat in the reader's App Sandbox
|
|
37
|
+
* container and that question was itself a cross-app container access.)
|
|
38
|
+
*
|
|
39
|
+
* The Things group container is NEVER opened as a probe (Article I corollary):
|
|
40
|
+
* the open is itself what raises the app-data consent, so "try it and see" is
|
|
41
|
+
* forbidden — except inside `things setup`, which provokes it deliberately.
|
|
42
|
+
*
|
|
43
|
+
* On the direct WRITE probe — why TCC introspection and not the AppleEvents
|
|
44
|
+
* API. The doctrine names `AEDeterminePermissionToAutomateTarget(askUserIfNeeded:
|
|
45
|
+
* false)` as the direct-path Automation probe, which is exactly what the deputy
|
|
46
|
+
* calls (deputy/src/tcc.swift). From a JXA/ObjC host that call is NOT reachable:
|
|
47
|
+
* JavaScriptCore's bridge cannot marshal an `AEDesc` struct. MEASURED 2026-08-24
|
|
48
|
+
* on macOS 24.6 — `AECreateDesc` fills an untyped `Ref()`, but passing that Ref
|
|
49
|
+
* to any function taking `^{AEDesc=…}` throws "Ref has incompatible type", and
|
|
50
|
+
* the one spelling the bridge accepts (`ref[0]`, a dereferenced copy) arrives
|
|
51
|
+
* zeroed: `AEGetDescDataSize` reports 0 bytes for a 36-byte bundle id, and every
|
|
52
|
+
* target then answers -50 paramErr. A probe that returns the same wrong answer
|
|
53
|
+
* for every input is worse than no probe, so it is not shipped. Reading the
|
|
54
|
+
* Automation row out of TCC.db is the other introspection the Article I
|
|
55
|
+
* corollary names, it is exactly as prompt-free, and it costs nothing extra:
|
|
56
|
+
* direct mode's floor is FDA anyway, so a process that can act at all can
|
|
57
|
+
* already read that file. Where the row cannot be read the verdict is an honest
|
|
58
|
+
* "unknown", which the write gate refuses on rather than resolving with a dialog.
|
|
59
|
+
*/
|
|
60
|
+
import { execFileSync } from "node:child_process";
|
|
61
|
+
import { homedir } from "node:os";
|
|
62
|
+
import { join } from "node:path";
|
|
63
|
+
import { DatabaseSync } from "node:sqlite";
|
|
64
|
+
import { loadConfig } from "./config.js";
|
|
65
|
+
import { readContainerFileSync } from "./deputy/files.js";
|
|
66
|
+
import { deputyRouting, deputyRoutesDb, helpersExpected, settleDeputyAutomation, } from "./deputy/routing.js";
|
|
67
|
+
import { THINGS_BUNDLE_ID, wakeSystemEvents, wakeThings } from "./deputy/wake.js";
|
|
68
|
+
import { fdaGranted, hostApp, hostDisplayName, resetHostAccessForTests, tccDbPath, } from "./host-access.js";
|
|
69
|
+
import { sessionGrantValid } from "./session-grant.js";
|
|
70
|
+
export { fdaGranted, hostApp, hostDisplayName, tccDbPath, } from "./host-access.js";
|
|
71
|
+
/** The Things application's bundle identifier — the Automation grant's target. */
|
|
72
|
+
export { THINGS_BUNDLE_ID } from "./deputy/wake.js";
|
|
73
|
+
/** True when this verdict permits opening the live container. */
|
|
74
|
+
export function readAllowed(capability) {
|
|
75
|
+
return capability.mode !== "none" && capability.mode !== "helpers-unavailable";
|
|
76
|
+
}
|
|
77
|
+
/** True when this verdict permits driving the Things window. */
|
|
78
|
+
export function uiAllowed(capability) {
|
|
79
|
+
return capability.mode === "helpers" || capability.mode === "direct-escape";
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* True when a URL-scheme MUTATION may be dispatched.
|
|
83
|
+
*
|
|
84
|
+
* `unreadable` is permissive, and deliberately so — the asymmetry with the
|
|
85
|
+
* write gate is the point. There, an unknown standing is refused because
|
|
86
|
+
* resolving it means sending the Apple Event that IS the dialog. Here,
|
|
87
|
+
* dispatching costs nothing on a machine whose answer is already "enabled",
|
|
88
|
+
* which is every settled machine; only the two states we have positively READ
|
|
89
|
+
* as not-enabled are refused. What catches the unreadable case instead is the
|
|
90
|
+
* read-after-write verify plus its likely-cause hint (src/write/failure-hints.ts).
|
|
91
|
+
*/
|
|
92
|
+
export function urlSchemeAllowed(capability) {
|
|
93
|
+
return capability.mode === "enabled" || capability.mode === "unreadable";
|
|
94
|
+
}
|
|
95
|
+
/** True when app automation may be dispatched. */
|
|
96
|
+
export function writeAllowed(capability) {
|
|
97
|
+
return (capability.mode === "deputy" ||
|
|
98
|
+
capability.mode === "direct-granted" ||
|
|
99
|
+
capability.mode === "direct-escape");
|
|
100
|
+
}
|
|
101
|
+
// ── The prompt-free probes ───────────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* One Automation row from TCC.db: the `auth_value` macOS records for `client`
|
|
104
|
+
* driving `target`, or null when there is no row (never asked) or the file is
|
|
105
|
+
* unreadable (no FDA). Read-only, prompt-free, and defensive — the schema is
|
|
106
|
+
* Apple's private business, so ANY failure degrades to null rather than
|
|
107
|
+
* throwing into a caller that asked a yes/no question.
|
|
108
|
+
*/
|
|
109
|
+
function automationAuthValueDefault(env, client, target) {
|
|
110
|
+
let db;
|
|
111
|
+
try {
|
|
112
|
+
db = new DatabaseSync(tccDbPath(env), { readOnly: true });
|
|
113
|
+
const row = db
|
|
114
|
+
.prepare("SELECT auth_value FROM access WHERE service = 'kTCCServiceAppleEvents' " +
|
|
115
|
+
"AND client = ? AND indirect_object_identifier = ? LIMIT 1")
|
|
116
|
+
.get(client, target);
|
|
117
|
+
const value = row?.auth_value;
|
|
118
|
+
return typeof value === "number" ? value : null;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
try {
|
|
125
|
+
db?.close();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A close failure cannot change the answer we already have.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── The lab escapes (not consumer surface) ───────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Is one of the lab's documented escapes set? ({@link UI_DIRECT_ESCAPE_ENV},
|
|
135
|
+
* {@link WRITE_DIRECT_ESCAPE_ENV}.)
|
|
136
|
+
*
|
|
137
|
+
* Both are read in exactly one place each — the capability function for the
|
|
138
|
+
* vector they cover — and both spell "on" the same way, as the literal `1`.
|
|
139
|
+
* Anything else is off, deliberately: an escape that answered to `true`, `yes`
|
|
140
|
+
* or a stray empty string would be a config surface, and these are neither
|
|
141
|
+
* config nor consumer surface. They are documented in docs/lab/harness.md and
|
|
142
|
+
* exported by the lab's guest environment; nothing shipped mentions them.
|
|
143
|
+
*/
|
|
144
|
+
function labEscapeSet(env, name) {
|
|
145
|
+
return (env[name] ?? "") === "1";
|
|
146
|
+
}
|
|
147
|
+
// ── Read capability (Article I + II) ─────────────────────────────────────────
|
|
148
|
+
/**
|
|
149
|
+
* Errnos that mean a plain "no" rather than something worth telling the user
|
|
150
|
+
* about. EPERM is the FDA denial proper; EACCES is its sandboxed cousin; ENOENT
|
|
151
|
+
* simply means this account has no TCC database yet. Anything else is an
|
|
152
|
+
* anomaly and gets named, so a genuinely odd failure is never silently folded
|
|
153
|
+
* into "you lack permission".
|
|
154
|
+
*/
|
|
155
|
+
const ORDINARY_DENIALS = new Set(["EPERM", "EACCES", "ENOENT"]);
|
|
156
|
+
/**
|
|
157
|
+
* The ways a machine can earn read capability, phrased for a human.
|
|
158
|
+
*
|
|
159
|
+
* The ask-again line is deliberately CONDITIONAL, because of what this process
|
|
160
|
+
* can and cannot know. A container that will not open carries no cause: "this
|
|
161
|
+
* host app was never asked" and "this host app was asked and the human clicked
|
|
162
|
+
* Don't Allow" are the same observation from here, and the app-data class
|
|
163
|
+
* cannot be told apart without opening the container — which is the very act
|
|
164
|
+
* that raises the dialog (Article I corollary). What IS measured is the price
|
|
165
|
+
* of a refusal (APDP1, docs/lab/apdp1-grant-pinning.md): it stands for the
|
|
166
|
+
* whole life of that host-app instance, every later open failing instantly and
|
|
167
|
+
* silently, and macOS never re-asks inside it. So the line names the relaunch
|
|
168
|
+
* as the way to be asked again without asserting that anyone refused anything.
|
|
169
|
+
*/
|
|
170
|
+
function readRemediation(hostName) {
|
|
171
|
+
return [
|
|
172
|
+
"run `things helpers setup` — reads then flow through a helper that holds its own durable grant",
|
|
173
|
+
"or run `things setup` — it asks for read access once, while you are at the machine",
|
|
174
|
+
`if that dialog was already refused, quit and reopen ${hostName} first — macOS does not ask a second time inside one run of an app`,
|
|
175
|
+
`or grant Full Disk Access to ${hostName} in System Settings ▸ Privacy & Security ▸ Full Disk Access`,
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Can this process read the live Things library, and on whose authority?
|
|
180
|
+
*
|
|
181
|
+
* Stateless: every call re-derives the verdict, because a grant can appear or
|
|
182
|
+
* vanish between one invocation and the next and a cached "yes" would be a
|
|
183
|
+
* stored onboarding flag by another name. The common case costs one `open(2)`.
|
|
184
|
+
*/
|
|
185
|
+
export function readCapability(options = {}, deps = {}) {
|
|
186
|
+
const env = deps.env ?? process.env;
|
|
187
|
+
const host = hostApp(deps);
|
|
188
|
+
// Article VI — a path the caller already owns gets plain file semantics.
|
|
189
|
+
if (options.dbPath !== undefined || (env["THINGS_DB"] ?? "") !== "") {
|
|
190
|
+
return {
|
|
191
|
+
mode: "explicit-db",
|
|
192
|
+
detail: "an explicit database path was supplied — plain file semantics apply",
|
|
193
|
+
remediation: [],
|
|
194
|
+
host,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// The helpers path. Reads that ride the reader are not pre-checked here: the
|
|
198
|
+
// reader resolves its bookmark on every verb, so the read IS the check and a
|
|
199
|
+
// probe would only add latency and a second answer to disagree with.
|
|
200
|
+
const serving = (deps.helpersServing ?? (() => deputyRoutesDb(options, env)))();
|
|
201
|
+
if (serving) {
|
|
202
|
+
return {
|
|
203
|
+
mode: "helpers",
|
|
204
|
+
detail: "database reads are served by the sandboxed reader",
|
|
205
|
+
remediation: [],
|
|
206
|
+
host,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// No silent fall-through: a machine that asked for the helpers and cannot
|
|
210
|
+
// have them is refused, not quietly downgraded onto the terminal's own grants.
|
|
211
|
+
// Nothing gates this question any more — the rendezvous is ours, so whether
|
|
212
|
+
// the helpers are expected and whether they are serving are both answerable
|
|
213
|
+
// from every host app alike.
|
|
214
|
+
const expected = (deps.helpersExpected ?? (() => helpersExpected(env)))();
|
|
215
|
+
if (expected) {
|
|
216
|
+
const why = (deps.helpersReason ?? (() => null))();
|
|
217
|
+
return {
|
|
218
|
+
mode: "helpers-unavailable",
|
|
219
|
+
detail: `the helpers are enabled on this machine but are not serving reads${why !== null ? ` (${why})` : ""}`,
|
|
220
|
+
remediation: [
|
|
221
|
+
"run `things helpers setup` to finish onboarding them",
|
|
222
|
+
"or `things helpers status` to see which half is unhealthy",
|
|
223
|
+
"or `things --no-helpers …` to run this one invocation directly",
|
|
224
|
+
],
|
|
225
|
+
host,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const fda = fdaGranted(deps);
|
|
229
|
+
if (fda.granted) {
|
|
230
|
+
return {
|
|
231
|
+
mode: "direct-fda",
|
|
232
|
+
detail: `Full Disk Access is held by ${hostDisplayName(deps)}${host.bundleId !== null ? ` (${host.bundleId})` : ""}`,
|
|
233
|
+
remediation: [],
|
|
234
|
+
host,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// The sub-FDA tier: a grant a ceremony witnessed, still live for this very
|
|
238
|
+
// app instance. Only consulted when FDA has already said no.
|
|
239
|
+
const session = sessionGrantValid(host.bundleId, deps);
|
|
240
|
+
if (session.valid) {
|
|
241
|
+
const name = hostDisplayName(deps);
|
|
242
|
+
return {
|
|
243
|
+
mode: "session-grant",
|
|
244
|
+
// MEASURED (APDP1): the grant belongs to the host app INSTANCE, so it
|
|
245
|
+
// covers every process under it — this command, other tabs and windows,
|
|
246
|
+
// anything they spawn — and it ends when that app quits. The copy states
|
|
247
|
+
// both halves, because the reach is the part that is worth knowing and
|
|
248
|
+
// the expiry is the part that must never read as durable.
|
|
249
|
+
detail: `${name} holds access to the Things data folder — every command running under ${name}, in any tab or window, reads it without a dialog until ${name} quits`,
|
|
250
|
+
remediation: [],
|
|
251
|
+
host,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
mode: "none",
|
|
256
|
+
detail: `${hostDisplayName(deps)} cannot open the Things data folder — ${session.reason}` +
|
|
257
|
+
(fda.code !== null && !ORDINARY_DENIALS.has(fda.code)
|
|
258
|
+
? ` (the access check ended in ${fda.code})`
|
|
259
|
+
: ""),
|
|
260
|
+
remediation: readRemediation(hostDisplayName(deps)),
|
|
261
|
+
host,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** Thrown when a read is refused for want of capability (Article II). */
|
|
265
|
+
export class ReadCapabilityError extends Error {
|
|
266
|
+
remediation;
|
|
267
|
+
capability;
|
|
268
|
+
constructor(capability) {
|
|
269
|
+
super(`the Things database cannot be read: ${capability.detail}. ` +
|
|
270
|
+
`${capability.remediation.join("; ")}.`);
|
|
271
|
+
this.name = "ReadCapabilityError";
|
|
272
|
+
this.remediation = capability.remediation;
|
|
273
|
+
this.capability = capability;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ── Write capability (Article I + II) ────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* macOS `auth_value` for an Automation record. 0 is a refusal; 2 (allowed) and
|
|
279
|
+
* 3 both mean the event will be delivered.
|
|
280
|
+
*/
|
|
281
|
+
function classifyAuthValue(value) {
|
|
282
|
+
if (value === null)
|
|
283
|
+
return "unknown";
|
|
284
|
+
if (value === 0)
|
|
285
|
+
return "denied";
|
|
286
|
+
if (value >= 2)
|
|
287
|
+
return "granted";
|
|
288
|
+
return "unknown";
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The LAB's escape hatch for the AppleScript vector — the write-side twin of
|
|
292
|
+
* {@link UI_DIRECT_ESCAPE_ENV}, and just as deliberately not consumer surface.
|
|
293
|
+
*
|
|
294
|
+
* A guest shell in a golden clone descends from sshd, not from an application
|
|
295
|
+
* bundle, so `hostApp()` finds no `__CFBundleIdentifier` and macOS has no
|
|
296
|
+
* identity to have recorded an Automation grant against. The verdict is
|
|
297
|
+
* therefore `direct-unknown` in every clone, which blocks every AppleScript-
|
|
298
|
+
* vector verb and every composite carrying an AppleScript leg. What the clone
|
|
299
|
+
* actually has is an in-guest Automation grant on the runner's own processes
|
|
300
|
+
* (the same AXVM1 layer the ui escape leans on), so the block is an artefact of
|
|
301
|
+
* UNKNOWABILITY, not of a missing grant. Setting this to `1` says so.
|
|
302
|
+
*
|
|
303
|
+
* Bounded, and the bound is the point: it is consulted ONLY on the
|
|
304
|
+
* bundle-id-less path. A host that has an identity is answered from its TCC row
|
|
305
|
+
* as it always was, so the escape can never mask a recorded refusal
|
|
306
|
+
* (`direct-denied`) or manufacture a grant for a real user's terminal.
|
|
307
|
+
* Documented in docs/lab/harness.md and exported by the lab's guest
|
|
308
|
+
* environment; nothing consumer-facing mentions it.
|
|
309
|
+
*/
|
|
310
|
+
export const WRITE_DIRECT_ESCAPE_ENV = "THINGS_API_WRITE_DIRECT";
|
|
311
|
+
/**
|
|
312
|
+
* May this process drive Things over Apple Events, and on whose authority?
|
|
313
|
+
*
|
|
314
|
+
* The deputy wins when it is onboarded (its own handshake reports the grant it
|
|
315
|
+
* holds). Otherwise the host app's own Automation record is read out of TCC —
|
|
316
|
+
* granted, denied, or, when there is no record at all, `direct-unknown`. That
|
|
317
|
+
* last state is deliberately NOT resolved here: resolving it means sending a
|
|
318
|
+
* real Apple Event, which is what raises the dialog, and Article I reserves
|
|
319
|
+
* that for `things setup`.
|
|
320
|
+
*
|
|
321
|
+
* LIVENESS BEFORE AUTHORIZATION (#617). The deputy's `not-running` is the
|
|
322
|
+
* ask-false determination having no answer for a CLOSED Things — a fact about
|
|
323
|
+
* the app's process, not about the grant. Two rules follow, and both matter:
|
|
324
|
+
*
|
|
325
|
+
* - while the deputy is standing, that value NEVER falls through to the direct
|
|
326
|
+
* host branch. A silent direct engagement would put consent back on the
|
|
327
|
+
* terminal (the routing doctrine's no-fallback rule), and on a machine with
|
|
328
|
+
* no host record it would refuse a fully onboarded user with "run
|
|
329
|
+
* `things setup`" — the #610 false-onboarding loop, one vector over;
|
|
330
|
+
* - a `dispatch` caller resolves it by STARTING Things (a background
|
|
331
|
+
* LaunchServices dispatch, never an Apple Event) and re-reading the
|
|
332
|
+
* standing. Only what comes back is an authorization fact.
|
|
333
|
+
*/
|
|
334
|
+
export function writeCapability(options = {}, deps = {}) {
|
|
335
|
+
const env = deps.env ?? process.env;
|
|
336
|
+
const host = hostApp(deps);
|
|
337
|
+
const handshake = (deps.deputyAutomation ?? (() => deputyRouting(env).hello?.automation?.things))();
|
|
338
|
+
let deputyThings = handshake;
|
|
339
|
+
if (handshake === "not-running") {
|
|
340
|
+
let wake = null;
|
|
341
|
+
if (options.purpose === "dispatch") {
|
|
342
|
+
wake = (deps.wakeThings ?? (() => wakeThings(env)))();
|
|
343
|
+
deputyThings = wake.standing;
|
|
344
|
+
}
|
|
345
|
+
if (deputyThings === undefined || deputyThings === "not-running") {
|
|
346
|
+
return {
|
|
347
|
+
mode: "deputy-target-dormant",
|
|
348
|
+
detail: wake === null
|
|
349
|
+
? "Things is not running, so app control for it cannot be read — macOS answers for a " +
|
|
350
|
+
"running app only, and whatever the helpers hold is unreadable while it is down"
|
|
351
|
+
: `Things is not running and ${wake.detail} — app control for it cannot be read while it is down`,
|
|
352
|
+
remediation: [
|
|
353
|
+
"open Things, then rerun this command",
|
|
354
|
+
`or start it in the background with \`open -g -b ${THINGS_BUNDLE_ID}\``,
|
|
355
|
+
],
|
|
356
|
+
host,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// A real standing at last: hand it to the routing layer, whose own
|
|
360
|
+
// onboarding gate deferred on the same non-answer (./deputy/routing.ts).
|
|
361
|
+
(deps.settleDeputyAutomation ?? settleDeputyAutomation)(deputyThings);
|
|
362
|
+
}
|
|
363
|
+
if (deputyThings === "granted") {
|
|
364
|
+
return {
|
|
365
|
+
mode: "deputy",
|
|
366
|
+
detail: "the deputy is onboarded and holds app control for Things",
|
|
367
|
+
remediation: [],
|
|
368
|
+
host,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (host.bundleId === null) {
|
|
372
|
+
if (labEscapeSet(env, WRITE_DIRECT_ESCAPE_ENV)) {
|
|
373
|
+
return {
|
|
374
|
+
mode: "direct-escape",
|
|
375
|
+
detail: `${WRITE_DIRECT_ESCAPE_ENV}=1 — Apple Events are sent directly under this process (lab escape)`,
|
|
376
|
+
remediation: [],
|
|
377
|
+
host,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
mode: "direct-unknown",
|
|
382
|
+
detail: "this process does not descend from an application bundle, so macOS has no identity " +
|
|
383
|
+
"to record app control against",
|
|
384
|
+
remediation: [
|
|
385
|
+
"run `things helpers setup` — app control then attaches to a helper that always has an identity",
|
|
386
|
+
],
|
|
387
|
+
host,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const authValue = (deps.automationAuthValue ??
|
|
391
|
+
((client, target) => automationAuthValueDefault(env, client, target)))(host.bundleId, THINGS_BUNDLE_ID);
|
|
392
|
+
const hostName = hostDisplayName(deps);
|
|
393
|
+
switch (classifyAuthValue(authValue)) {
|
|
394
|
+
case "granted":
|
|
395
|
+
return {
|
|
396
|
+
mode: "direct-granted",
|
|
397
|
+
detail: `${hostName} (${host.bundleId}) holds app control for Things`,
|
|
398
|
+
remediation: [],
|
|
399
|
+
host,
|
|
400
|
+
};
|
|
401
|
+
case "denied":
|
|
402
|
+
return {
|
|
403
|
+
mode: "direct-denied",
|
|
404
|
+
detail: `macOS records a refusal of app control for ${hostName} (${host.bundleId}) — it will not ask again`,
|
|
405
|
+
remediation: [
|
|
406
|
+
`turn on Things3 for ${hostName} under System Settings ▸ Privacy & Security ▸ Automation`,
|
|
407
|
+
`or re-arm the request with \`tccutil reset AppleEvents ${host.bundleId}\`, then run \`things setup\``,
|
|
408
|
+
],
|
|
409
|
+
host,
|
|
410
|
+
};
|
|
411
|
+
default:
|
|
412
|
+
return {
|
|
413
|
+
mode: "direct-unknown",
|
|
414
|
+
detail: `macOS has no app-control record for ${hostName} (${host.bundleId}) yet`,
|
|
415
|
+
remediation: [
|
|
416
|
+
"run `things setup` — it asks for app control once, while you are at the machine",
|
|
417
|
+
"or run `things helpers setup` to attach the grant to a helper instead",
|
|
418
|
+
],
|
|
419
|
+
host,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Thrown when app automation is refused for want of capability (Article II). */
|
|
424
|
+
export class WriteCapabilityError extends Error {
|
|
425
|
+
remediation;
|
|
426
|
+
capability;
|
|
427
|
+
constructor(capability) {
|
|
428
|
+
super(`Things cannot be driven: ${capability.detail}. ${capability.remediation.join("; ")}.`);
|
|
429
|
+
this.name = "WriteCapabilityError";
|
|
430
|
+
this.remediation = capability.remediation;
|
|
431
|
+
this.capability = capability;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// ── UI capability (Article IV) ───────────────────────────────────────────────
|
|
435
|
+
/**
|
|
436
|
+
* The LAB's escape hatch for the ui vector, and deliberately not consumer
|
|
437
|
+
* surface (its write-side twin is {@link WRITE_DIRECT_ESCAPE_ENV}). The VM lab and
|
|
438
|
+
* the guest e2e bundle drive the UI vector DIRECT — the in-guest Accessibility
|
|
439
|
+
* grant is held by the runner's own processes (the AXVM1 layer), there is no
|
|
440
|
+
* helper bundle in a disposable clone, and there is nobody to answer a dialog
|
|
441
|
+
* either. Setting this to `1` restores direct UI-vector availability for that
|
|
442
|
+
* one situation. It is documented in docs/lab/harness.md and exported by the
|
|
443
|
+
* lab's guest environment; nothing consumer-facing mentions it, and it does not
|
|
444
|
+
* bypass `ui.enabled` — a lab clone still sets that key explicitly.
|
|
445
|
+
*/
|
|
446
|
+
export const UI_DIRECT_ESCAPE_ENV = "THINGS_API_UI_DIRECT";
|
|
447
|
+
/** The command that gathers the GUI-driving tier, named in every UI refusal. */
|
|
448
|
+
const GUI_SETUP_COMMAND = "run `things helpers setup --gui` to grant GUI-driving to the helpers";
|
|
449
|
+
function deputyGuiStandingDefault(env) {
|
|
450
|
+
const hello = deputyRouting(env).hello;
|
|
451
|
+
if (hello === undefined || hello === null)
|
|
452
|
+
return null;
|
|
453
|
+
return { axTrusted: hello.axTrusted, systemEvents: hello.automation?.systemEvents };
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* May this process drive the Things WINDOW, and on whose authority?
|
|
457
|
+
*
|
|
458
|
+
* Article IV admits exactly one provenance: the helper pair. Accessibility on a
|
|
459
|
+
* general-purpose host app (a terminal, an agent harness, an MCP host) has a
|
|
460
|
+
* blast radius far beyond Things, churns with every host update, and has no
|
|
461
|
+
* sane story at all over ssh — so direct AX is unsupported, and a refusal here
|
|
462
|
+
* names the config knob and `things helpers setup --gui` rather than raising an
|
|
463
|
+
* Accessibility prompt against whatever happens to be running us.
|
|
464
|
+
*
|
|
465
|
+
* Every answer is prompt-free: the config key is a file read, and the deputy's
|
|
466
|
+
* `hello` carries `AXIsProcessTrusted()` plus its own `AEDeterminePermission`
|
|
467
|
+
* verdict for System Events. The one state that needs more than a read is a
|
|
468
|
+
* DORMANT System Events, which macOS reaps whenever it has been idle: the
|
|
469
|
+
* target is started in the background — never by sending it an event — and the
|
|
470
|
+
* determination re-read, so `not-running` resolves to the truth instead of
|
|
471
|
+
* masquerading as a missing grant (./deputy/wake.ts).
|
|
472
|
+
*/
|
|
473
|
+
export function uiCapability(deps = {}) {
|
|
474
|
+
const env = deps.env ?? process.env;
|
|
475
|
+
const host = hostApp(deps);
|
|
476
|
+
const enabled = (deps.uiEnabled ?? (() => loadConfig(env).ui.enabled))();
|
|
477
|
+
if (!enabled) {
|
|
478
|
+
return {
|
|
479
|
+
mode: "config-disabled",
|
|
480
|
+
detail: "GUI-driving is switched off on this machine (`ui-enabled` is false)",
|
|
481
|
+
remediation: [
|
|
482
|
+
"run `things config set ui-enabled true` to opt in",
|
|
483
|
+
`then ${GUI_SETUP_COMMAND}`,
|
|
484
|
+
],
|
|
485
|
+
host,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (labEscapeSet(env, UI_DIRECT_ESCAPE_ENV)) {
|
|
489
|
+
return {
|
|
490
|
+
mode: "direct-escape",
|
|
491
|
+
detail: `${UI_DIRECT_ESCAPE_ENV}=1 — GUI-driving runs directly under this process (lab escape)`,
|
|
492
|
+
remediation: [],
|
|
493
|
+
host,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const standing = (deps.deputyGuiStanding ?? (() => deputyGuiStandingDefault(env)))();
|
|
497
|
+
if (standing === null) {
|
|
498
|
+
return {
|
|
499
|
+
mode: "helpers-missing",
|
|
500
|
+
detail: "GUI-driving is granted only to the helpers, and no helper is answering on this machine",
|
|
501
|
+
remediation: [GUI_SETUP_COMMAND, "or `things helpers status` to see which half is unhealthy"],
|
|
502
|
+
host,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
// LIVENESS BEFORE AUTHORIZATION (#610). System Events is an on-demand agent
|
|
506
|
+
// macOS reaps when idle, and the ask-false determination cannot answer for a
|
|
507
|
+
// target that is down — so the deputy's `not-running` describes the PROCESS,
|
|
508
|
+
// not the grant, and treating it as a missing grant sends a fully onboarded
|
|
509
|
+
// machine back through onboarding. Start it (a background launch raises no
|
|
510
|
+
// dialog; waking it with an Apple event would), then re-read the
|
|
511
|
+
// determination. Only after that is the standing an authorization fact.
|
|
512
|
+
let systemEvents = standing.systemEvents;
|
|
513
|
+
let wake = null;
|
|
514
|
+
if (systemEvents === "not-running") {
|
|
515
|
+
wake = (deps.wakeSystemEvents ?? (() => wakeSystemEvents(env)))();
|
|
516
|
+
systemEvents = wake.standing;
|
|
517
|
+
}
|
|
518
|
+
if (wake !== null && (systemEvents === undefined || systemEvents === "not-running")) {
|
|
519
|
+
return {
|
|
520
|
+
mode: "target-unreachable",
|
|
521
|
+
detail: `System Events is not running and ${wake.detail} — the Things window is driven through it`,
|
|
522
|
+
remediation: [
|
|
523
|
+
'start it with `open -g -a "System Events"`, then rerun this command',
|
|
524
|
+
"or log out and back in — System Events is a macOS component of your login session",
|
|
525
|
+
],
|
|
526
|
+
host,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
const missing = [];
|
|
530
|
+
if (standing.axTrusted !== true) {
|
|
531
|
+
missing.push(standing.axTrusted === undefined
|
|
532
|
+
? "Accessibility (these helpers predate the permission handshake — rebuild)"
|
|
533
|
+
: "Accessibility");
|
|
534
|
+
}
|
|
535
|
+
if (systemEvents !== "granted") {
|
|
536
|
+
missing.push(`automation → System Events (${systemEvents ?? "unknown"})`);
|
|
537
|
+
}
|
|
538
|
+
if (missing.length === 0) {
|
|
539
|
+
return {
|
|
540
|
+
mode: "helpers",
|
|
541
|
+
detail: "the helpers hold Accessibility and app control for System Events",
|
|
542
|
+
remediation: [],
|
|
543
|
+
host,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
mode: "tier-incomplete",
|
|
548
|
+
detail: `the helpers are onboarded but the GUI-driving tier is incomplete — missing ${missing.join("; ")}`,
|
|
549
|
+
remediation: [GUI_SETUP_COMMAND],
|
|
550
|
+
host,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
// ── URL-scheme capability (Things' own in-app authorization) ─────────────────
|
|
554
|
+
/**
|
|
555
|
+
* Where the app records the answer. MEASURED (URLEN1, Things 3.23): this is the
|
|
556
|
+
* ONLY home — the un-TCC'd user domain `~/Library/Preferences/
|
|
557
|
+
* com.culturedcode.ThingsMac.plist` carries window frames and Sparkle keys and
|
|
558
|
+
* nothing else, under any spelling of the name, so there is no consent-free
|
|
559
|
+
* copy to prefer. The authoritative one lives inside the group container.
|
|
560
|
+
*/
|
|
561
|
+
const THINGS_PREFS_PLIST = join("Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac", "Library/Preferences/JLMPQHK86H.com.culturedcode.ThingsMac.plist");
|
|
562
|
+
/**
|
|
563
|
+
* Read standings that ALREADY cover the Things group container, so opening a
|
|
564
|
+
* file inside it adds no consent class that is not already settled.
|
|
565
|
+
*
|
|
566
|
+
* `explicit-db` is deliberately absent: a caller-supplied database path says
|
|
567
|
+
* nothing about the container, and opening it to find out is the "try it and
|
|
568
|
+
* see" the Article I corollary forbids. Such a caller gets `unreadable`, which
|
|
569
|
+
* is permissive — exactly the right direction for a path outside the doctrine.
|
|
570
|
+
*/
|
|
571
|
+
const CONTAINER_REACHABLE = new Set([
|
|
572
|
+
"helpers",
|
|
573
|
+
"direct-fda",
|
|
574
|
+
"session-grant",
|
|
575
|
+
]);
|
|
576
|
+
function extractUriSchemeEnabledDefault(plistBytes) {
|
|
577
|
+
return execFileSync("plutil", ["-extract", "uriSchemeEnabled", "raw", "-o", "-", "--", "-"], {
|
|
578
|
+
input: plistBytes,
|
|
579
|
+
encoding: "utf8",
|
|
580
|
+
timeout: 5000,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
/** The one line every not-enabled verdict ends with. */
|
|
584
|
+
const URL_SETTINGS_PATH = "Things ▸ Settings ▸ General ▸ Enable Things URLs";
|
|
585
|
+
/**
|
|
586
|
+
* Has Things been authorized to act on `things:///` commands, and how do we know?
|
|
587
|
+
*
|
|
588
|
+
* Stateless per invocation, like every other verdict here: the setting is the
|
|
589
|
+
* user's to flip at any moment, and a cached "yes" would be a stored onboarding
|
|
590
|
+
* flag by another name. The common case costs one file read plus one `plutil`.
|
|
591
|
+
*
|
|
592
|
+
* WHY THIS IS GATED ON THE READ STANDING. The plist lives inside the Things
|
|
593
|
+
* group container, which is the same `kTCCServiceSystemPolicyAppData` class as
|
|
594
|
+
* the database — so the open is itself what would raise the app-data modal on a
|
|
595
|
+
* machine that holds no standing. This function therefore never opens it
|
|
596
|
+
* speculatively: it asks {@link readCapability} first and reports `unreadable`
|
|
597
|
+
* unless the container is already reachable (helpers, FDA, or a live session
|
|
598
|
+
* grant). Where the helpers are serving, the read rides the reader's own
|
|
599
|
+
* security-scoped bookmark over the container, so the prefs plist is inside the
|
|
600
|
+
* granted subtree and no host grant is involved at all.
|
|
601
|
+
*
|
|
602
|
+
* MEASURED, all three states (URLEN1, golden-v4 / Things 3.23):
|
|
603
|
+
*
|
|
604
|
+
* - `1` — URL mutations execute.
|
|
605
|
+
* - `0` — every mutating verb (`add`, token-bearing `update`, the `json`
|
|
606
|
+
* batch) is dropped in TOTAL SILENCE: zero row delta, no dialog, no window
|
|
607
|
+
* of any kind, and nothing to wait for. Navigation URLs (`things:///show`)
|
|
608
|
+
* still work, so this gate covers mutations only.
|
|
609
|
+
* - absent — nobody has answered the app's own first-use "Things URL Scheme"
|
|
610
|
+
* dialog (Cancel / Enable). The dispatched request PARKS behind that dialog
|
|
611
|
+
* rather than being dropped, which is what #611 saw: with nobody at the
|
|
612
|
+
* machine, the verify window expired and the write reported a silent no-op.
|
|
613
|
+
*/
|
|
614
|
+
export function urlSchemeCapability(deps = {}) {
|
|
615
|
+
const host = hostApp(deps);
|
|
616
|
+
const standing = (deps.readStanding ?? (() => readCapability({}, deps)))();
|
|
617
|
+
if (!CONTAINER_REACHABLE.has(standing.mode)) {
|
|
618
|
+
return {
|
|
619
|
+
mode: "unreadable",
|
|
620
|
+
detail: "the app's preferences are inside the Things data folder, which this process has no " +
|
|
621
|
+
`standing to open (${standing.mode}) — whether ${URL_SETTINGS_PATH} is on is unknown`,
|
|
622
|
+
remediation: [],
|
|
623
|
+
host,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
let bytes;
|
|
627
|
+
try {
|
|
628
|
+
bytes = (deps.readPrefsPlist ?? (() => readContainerFileSync(join(homedir(), THINGS_PREFS_PLIST))))();
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return {
|
|
632
|
+
mode: "unreadable",
|
|
633
|
+
detail: `the app's preferences file could not be read — whether ${URL_SETTINGS_PATH} is on is unknown`,
|
|
634
|
+
remediation: [],
|
|
635
|
+
host,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
let raw;
|
|
639
|
+
try {
|
|
640
|
+
raw = (deps.extractUriSchemeEnabled ?? extractUriSchemeEnabledDefault)(bytes).trim();
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// The file was readable and carries no such key. An unparseable file lands
|
|
644
|
+
// here too, and that is the safe direction: the refusal names a setting the
|
|
645
|
+
// human can flip, and flipping it rewrites the file and clears the verdict.
|
|
646
|
+
return {
|
|
647
|
+
mode: "never-asked",
|
|
648
|
+
detail: "nobody has answered Things' own 'Things URL Scheme' dialog on this machine — the app " +
|
|
649
|
+
"holds the first URL command behind it, and a command dispatched now waits there " +
|
|
650
|
+
"instead of running",
|
|
651
|
+
remediation: [
|
|
652
|
+
`turn on ${URL_SETTINGS_PATH}, then retry`,
|
|
653
|
+
"or send one `things:///` command while you are at the machine and click Enable",
|
|
654
|
+
],
|
|
655
|
+
host,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (raw === "1" || raw === "true") {
|
|
659
|
+
return { mode: "enabled", detail: `${URL_SETTINGS_PATH} is on`, remediation: [], host };
|
|
660
|
+
}
|
|
661
|
+
if (raw === "0" || raw === "false") {
|
|
662
|
+
return {
|
|
663
|
+
mode: "disabled",
|
|
664
|
+
detail: `${URL_SETTINGS_PATH} is off — the app puts URL commands in an alert on its own ` +
|
|
665
|
+
"window and holds them there instead of running them",
|
|
666
|
+
remediation: [`turn on ${URL_SETTINGS_PATH}, then retry`],
|
|
667
|
+
host,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// A shape we have never seen. Reporting it verbatim beats guessing which way
|
|
671
|
+
// the app would read it, and `unreadable` lets the write proceed and be judged
|
|
672
|
+
// by the verify rather than refused on a value nobody has measured.
|
|
673
|
+
return {
|
|
674
|
+
mode: "unreadable",
|
|
675
|
+
detail: `the app records an unrecognized value for ${URL_SETTINGS_PATH} (${raw})`,
|
|
676
|
+
remediation: [],
|
|
677
|
+
host,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
/** Thrown when GUI-driving is refused for want of capability (Article IV). */
|
|
681
|
+
export class UiCapabilityError extends Error {
|
|
682
|
+
remediation;
|
|
683
|
+
capability;
|
|
684
|
+
constructor(capability) {
|
|
685
|
+
super(`the Things window cannot be driven: ${capability.detail}. ${capability.remediation.join("; ")}.`);
|
|
686
|
+
this.name = "UiCapabilityError";
|
|
687
|
+
this.remediation = capability.remediation;
|
|
688
|
+
this.capability = capability;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** Thrown when a URL-scheme mutation is refused for want of the app's own authorization. */
|
|
692
|
+
export class UrlSchemeCapabilityError extends Error {
|
|
693
|
+
remediation;
|
|
694
|
+
capability;
|
|
695
|
+
constructor(capability) {
|
|
696
|
+
super(`Things will not act on URL commands: ${capability.detail}. ${capability.remediation.join("; ")}.`);
|
|
697
|
+
this.name = "UrlSchemeCapabilityError";
|
|
698
|
+
this.remediation = capability.remediation;
|
|
699
|
+
this.capability = capability;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/** Test seam: forget the one memo this module keeps (the host's display name). */
|
|
703
|
+
export function resetCapabilityForTests() {
|
|
704
|
+
resetHostAccessForTests();
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=capability.js.map
|