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/dist/deputy/install.js
CHANGED
|
@@ -13,18 +13,38 @@
|
|
|
13
13
|
* bookmark grant and must never change.
|
|
14
14
|
*/
|
|
15
15
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
16
|
-
import {
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
17
18
|
import { homedir } from "node:os";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { loadConfig, saveConfigKey } from "../config.js";
|
|
20
22
|
import { THINGS_GROUP_CONTAINER } from "../db/locate.js";
|
|
23
|
+
import { readRendezvousToken, rendezvousExists } from "../host-access.js";
|
|
24
|
+
import { createWizard, withDefaultInterrupts } from "../wizard.js";
|
|
25
|
+
import { EXPECTED_PROXIES } from "../write/availability.js";
|
|
21
26
|
import { DeputySyncBridge } from "./bridge.js";
|
|
22
|
-
import { DEPUTY_LAUNCHD_LABEL, HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL, DEPUTY_PROTOCOL_VERSION, deputyInstalledBinaryPath, deputySocketPath, deputyStateDir, deputyTokenPath, helpersInstallDir, helpersInstalledBundlePath, readerInstalledAppPath, readerSocketPath, readerTokenPath, } from "./protocol.js";
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
import { DEPUTY_LAUNCHD_LABEL, HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL, DEPUTY_PROTOCOL_VERSION, DeputyRequestError, deputyInstalledBinaryPath, deputySocketPath, deputyStateDir, deputyTokenPath, EXPECTED_HELPERS_VERSION, helpersInstallDir, helpersInstalledBundlePath, readerInstalledAppPath, readerRendezvousDir, readerSandboxContainerDir, readerSocketPath, READER_SOCKET_ENV, readerTokenPath, READER_TOKEN_ENV, } from "./protocol.js";
|
|
28
|
+
import { wakeSystemEvents, wakeThings } from "./wake.js";
|
|
29
|
+
/**
|
|
30
|
+
* Where the LaunchAgent plists live. THINGS_API_LAUNCH_AGENTS_DIR overrides
|
|
31
|
+
* for tests — WITHOUT it, every suite that exercises install/uninstall/reset
|
|
32
|
+
* mutates the DEVELOPER MACHINE's real ~/Library/LaunchAgents. That is not
|
|
33
|
+
* hypothetical: it deleted the live helpers' plists mid-`npm run check`
|
|
34
|
+
* twice on 2026-08-24 before this seam existed. Test setup must always set
|
|
35
|
+
* it alongside THINGS_API_STATE_DIR.
|
|
36
|
+
*/
|
|
37
|
+
export function launchAgentsDir(env = process.env) {
|
|
38
|
+
const explicit = env["THINGS_API_LAUNCH_AGENTS_DIR"];
|
|
39
|
+
if (explicit !== undefined && explicit !== "")
|
|
40
|
+
return explicit;
|
|
41
|
+
return join(homedir(), "Library/LaunchAgents");
|
|
25
42
|
}
|
|
26
|
-
export function
|
|
27
|
-
return join(
|
|
43
|
+
export function deputyPlistPath(env = process.env) {
|
|
44
|
+
return join(launchAgentsDir(env), `${DEPUTY_LAUNCHD_LABEL}.plist`);
|
|
45
|
+
}
|
|
46
|
+
export function readerPlistPath(env = process.env) {
|
|
47
|
+
return join(launchAgentsDir(env), `${READER_LAUNCHD_LABEL}.plist`);
|
|
28
48
|
}
|
|
29
49
|
/**
|
|
30
50
|
* Where an install looks for the bundle when the caller names no path, in
|
|
@@ -74,7 +94,23 @@ function readerLaunchTarget() {
|
|
|
74
94
|
function readerExecPath(appPath) {
|
|
75
95
|
return join(appPath, "Contents/MacOS/things-reader");
|
|
76
96
|
}
|
|
77
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The reader's LaunchAgent. Two keys carry the whole host-universal rendezvous:
|
|
99
|
+
*
|
|
100
|
+
* - **`Sockets`** — launchd creates, binds, listens on and chmods the socket at
|
|
101
|
+
* `socketPath`, which is OUR state directory, and hands the sandboxed reader
|
|
102
|
+
* the already-listening fd at activation (`launch_activate_socket`). The
|
|
103
|
+
* reader cannot bind outside its container, so launchd binding on its behalf
|
|
104
|
+
* is the only way the rendezvous escapes it — and with the socket outside
|
|
105
|
+
* every container, a client reaching it crosses no consent class.
|
|
106
|
+
* - **`EnvironmentVariables`** — the access token, minted by the installer and
|
|
107
|
+
* written to a sibling file for clients. Delivering it through the plist is
|
|
108
|
+
* what lets the reader keep its expected token out of the container too.
|
|
109
|
+
*
|
|
110
|
+
* The plist therefore CARRIES A SECRET and is written 0600 — the same
|
|
111
|
+
* same-user trust class as the token file itself.
|
|
112
|
+
*/
|
|
113
|
+
export function renderReaderPlist(appPath, socketPath, token) {
|
|
78
114
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
79
115
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
80
116
|
<plist version="1.0">
|
|
@@ -90,6 +126,21 @@ function renderReaderPlist(appPath) {
|
|
|
90
126
|
<true/>
|
|
91
127
|
<key>KeepAlive</key>
|
|
92
128
|
<true/>
|
|
129
|
+
<key>StandardErrorPath</key>
|
|
130
|
+
<string>${dirname(socketPath)}/reader.stderr.log</string>
|
|
131
|
+
<!-- The rendezvous path and access token, minted/chosen by install. The
|
|
132
|
+
reader BINDS the socket itself at this host-neutral path — its sandbox
|
|
133
|
+
entitlement covers exactly this directory. launchd Sockets activation
|
|
134
|
+
is NOT usable: launch_activate_socket fails with 159 "Sandbox
|
|
135
|
+
restriction" inside the App Sandbox (measured 2026-08-24). This file is
|
|
136
|
+
written 0600 because of the token. -->
|
|
137
|
+
<key>EnvironmentVariables</key>
|
|
138
|
+
<dict>
|
|
139
|
+
<key>${READER_SOCKET_ENV}</key>
|
|
140
|
+
<string>${socketPath}</string>
|
|
141
|
+
<key>${READER_TOKEN_ENV}</key>
|
|
142
|
+
<string>${token}</string>
|
|
143
|
+
</dict>
|
|
93
144
|
<!-- Background Task Management groups the login item under the helper
|
|
94
145
|
bundle's display name ("Things API Helper") instead of the signing
|
|
95
146
|
certificate's personal name. -->
|
|
@@ -101,10 +152,52 @@ function renderReaderPlist(appPath) {
|
|
|
101
152
|
</plist>
|
|
102
153
|
`;
|
|
103
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Mint the reader's access token and lay down the client half of the
|
|
157
|
+
* rendezvous: `<state>/reader`, 0700, holding a 0600 token file. Returns the
|
|
158
|
+
* token so the same value can be injected into the LaunchAgent.
|
|
159
|
+
*
|
|
160
|
+
* The installer mints it — not the reader — because the reader can only write
|
|
161
|
+
* inside its container, which is the placement this whole arrangement exists
|
|
162
|
+
* to leave. A fresh token per install is deliberate: install owns the pair
|
|
163
|
+
* wholesale, and rotating the secret costs nothing when both ends are rewritten
|
|
164
|
+
* in the same breath.
|
|
165
|
+
*/
|
|
166
|
+
export function mintReaderRendezvous(env) {
|
|
167
|
+
const dir = readerRendezvousDir(env);
|
|
168
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
169
|
+
chmodSync(dir, 0o700);
|
|
170
|
+
const token = randomBytes(32).toString("hex");
|
|
171
|
+
const tokenPath = readerTokenPath(env);
|
|
172
|
+
writeFileSync(tokenPath, token, { mode: 0o600 });
|
|
173
|
+
// writeFileSync's mode applies on CREATE only — an existing file keeps
|
|
174
|
+
// whatever it had, so the chmod is the one that actually holds the line.
|
|
175
|
+
chmodSync(tokenPath, 0o600);
|
|
176
|
+
// launchd binds the socket itself and refuses when something already sits at
|
|
177
|
+
// the path (a plain file left by an older layout, or a socket from a job it
|
|
178
|
+
// no longer tracks). Clearing it is safe: we booted the reader out above.
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(readerSocketPath(env));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// ENOENT is the ordinary case.
|
|
184
|
+
}
|
|
185
|
+
return token;
|
|
186
|
+
}
|
|
104
187
|
function launchTarget() {
|
|
105
188
|
return `gui/${process.getuid?.() ?? 501}/${DEPUTY_LAUNCHD_LABEL}`;
|
|
106
189
|
}
|
|
107
190
|
function launchctl(args) {
|
|
191
|
+
// The other half of the test blast-shield: with the LaunchAgents dir
|
|
192
|
+
// redirected, real launchctl calls would still act on the REAL jobs while
|
|
193
|
+
// the plists are fakes — tests booted the live helpers out mid-check on
|
|
194
|
+
// 2026-08-24 exactly this way. Under the override, launchctl is inert.
|
|
195
|
+
if ((process.env["THINGS_API_LAUNCH_AGENTS_DIR"] ?? "") !== "") {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
output: "launchctl disabled under THINGS_API_LAUNCH_AGENTS_DIR (test shield)",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
108
201
|
try {
|
|
109
202
|
// stderr must be captured, never inherited: a routine negative probe
|
|
110
203
|
// ("Could not find service … in domain") is a state we REPORT, not noise
|
|
@@ -181,9 +274,11 @@ export function deputySigningInfo(binaryPath) {
|
|
|
181
274
|
function readerHelloProbe(env) {
|
|
182
275
|
const socketPath = readerSocketPath(env);
|
|
183
276
|
const tokenPath = readerTokenPath(env);
|
|
184
|
-
if (!
|
|
277
|
+
if (!rendezvousExists(socketPath) || !rendezvousExists(tokenPath))
|
|
278
|
+
return null;
|
|
279
|
+
const token = readRendezvousToken(tokenPath);
|
|
280
|
+
if (token === null)
|
|
185
281
|
return null;
|
|
186
|
-
const token = readFileSync(tokenPath, "utf8").trim();
|
|
187
282
|
const bridge = new DeputySyncBridge(socketPath);
|
|
188
283
|
try {
|
|
189
284
|
const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
|
|
@@ -222,7 +317,7 @@ export function installHelpers(options = {}, env = process.env) {
|
|
|
222
317
|
rmSync(installDir, { recursive: true, force: true });
|
|
223
318
|
mkdirSync(installDir, { recursive: true, mode: 0o700 });
|
|
224
319
|
cpSync(source, bundlePath, { recursive: true });
|
|
225
|
-
const plistPath = deputyPlistPath();
|
|
320
|
+
const plistPath = deputyPlistPath(env);
|
|
226
321
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
227
322
|
writeFileSync(plistPath, renderPlist(deputyInstalledBinaryPath(env), stateDir));
|
|
228
323
|
const warnings = [];
|
|
@@ -237,8 +332,12 @@ export function installHelpers(options = {}, env = process.env) {
|
|
|
237
332
|
}
|
|
238
333
|
let readerGranted = null;
|
|
239
334
|
if (readerInBuild) {
|
|
240
|
-
|
|
241
|
-
|
|
335
|
+
// The rendezvous is minted BEFORE the plist is written: the plist carries
|
|
336
|
+
// the same token, so the two must be laid down from one value.
|
|
337
|
+
const readerToken = mintReaderRendezvous(env);
|
|
338
|
+
const readerPlist = readerPlistPath(env);
|
|
339
|
+
writeFileSync(readerPlist, renderReaderPlist(readerInstalledAppPath(env), readerSocketPath(env), readerToken), { mode: 0o600 });
|
|
340
|
+
chmodSync(readerPlist, 0o600);
|
|
242
341
|
const readerBoot = launchctl(["bootstrap", `gui/${process.getuid?.() ?? 501}`, readerPlist]);
|
|
243
342
|
if (!readerBoot.ok) {
|
|
244
343
|
warnings.push(`reader launchctl bootstrap failed: ${readerBoot.output.trim()}`);
|
|
@@ -258,7 +357,7 @@ export function installHelpers(options = {}, env = process.env) {
|
|
|
258
357
|
}
|
|
259
358
|
}
|
|
260
359
|
else {
|
|
261
|
-
warnings.push("the bundle was built without things-reader (no Apple-issued signing identity?) — file reads run direct. Build with an Apple-chain identity, reinstall, then run `things helpers
|
|
360
|
+
warnings.push("the bundle was built without things-reader (no Apple-issued signing identity?) — file reads run direct. Build with an Apple-chain identity, reinstall, then run `things helpers setup`.");
|
|
262
361
|
}
|
|
263
362
|
return {
|
|
264
363
|
bundlePath,
|
|
@@ -270,12 +369,116 @@ export function installHelpers(options = {}, env = process.env) {
|
|
|
270
369
|
warnings,
|
|
271
370
|
};
|
|
272
371
|
}
|
|
273
|
-
/**
|
|
274
|
-
|
|
372
|
+
/**
|
|
373
|
+
* The LaunchServices registration tool. `tccutil` addresses grants by BUNDLE
|
|
374
|
+
* IDENTIFIER and resolves that identifier through LaunchServices, so on a
|
|
375
|
+
* machine where the bundle is already gone it refuses with -10814 and the
|
|
376
|
+
* dormant grant rows stay put. Handing LaunchServices the PACKAGED bundle
|
|
377
|
+
* (`lsregister -f -R "<bundle>"`) makes both identities — the helper and the
|
|
378
|
+
* reader nested inside it — resolvable again, after which the resets succeed.
|
|
379
|
+
* Measured on a live host, 2026-08-24.
|
|
380
|
+
*/
|
|
381
|
+
const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
382
|
+
function runToolDefault(bin, args) {
|
|
383
|
+
try {
|
|
384
|
+
const output = execFileSync(bin, args, {
|
|
385
|
+
encoding: "utf8",
|
|
386
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
387
|
+
timeout: 30_000,
|
|
388
|
+
});
|
|
389
|
+
return { ok: true, output };
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
const e = err;
|
|
393
|
+
return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}` || (e.message ?? "failed") };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Revoke both helper identities' TCC grants (`tccutil reset All` per bundle
|
|
398
|
+
* identity — Automation, Accessibility, and every other class keyed to them).
|
|
399
|
+
*
|
|
400
|
+
* Called BEFORE anything is torn down, because `tccutil` resolves the
|
|
401
|
+
* identifier through LaunchServices and refuses with -10814
|
|
402
|
+
* (kLSApplicationNotFoundErr) once no app on disk carries it. When the
|
|
403
|
+
* installed bundle is already gone, the packaged one is registered first
|
|
404
|
+
* ({@link LSREGISTER}) and left registered — the file legitimately exists, so
|
|
405
|
+
* un-registering it would be a lie about the machine. With no bundle anywhere
|
|
406
|
+
* the resets still run (LaunchServices may hold an older registration) and a
|
|
407
|
+
* -10814 is reported as the honest limit it is.
|
|
408
|
+
*/
|
|
409
|
+
function revokeGrants(env, deps, warnings) {
|
|
410
|
+
const runTool = deps.runTool ?? runToolDefault;
|
|
411
|
+
const packagedBundlePath = deps.packagedBundlePath ?? helpersDefaultBuildPath;
|
|
412
|
+
let resolvedVia = "installed";
|
|
413
|
+
let registeredBundle = null;
|
|
414
|
+
if (!existsSync(deputyInstalledBinaryPath(env))) {
|
|
415
|
+
const packaged = packagedBundlePath();
|
|
416
|
+
if (packaged === null) {
|
|
417
|
+
resolvedVia = "none";
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const res = runTool(LSREGISTER, ["-f", "-R", packaged]);
|
|
421
|
+
if (res.ok) {
|
|
422
|
+
resolvedVia = "packaged";
|
|
423
|
+
registeredBundle = packaged;
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
resolvedVia = "none";
|
|
427
|
+
warnings.push(`could not register ${packaged} with LaunchServices: ${res.output.trim() || "unknown"}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const tccResets = [];
|
|
432
|
+
for (const target of [HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL]) {
|
|
433
|
+
const res = runTool("/usr/bin/tccutil", ["reset", "All", target]);
|
|
434
|
+
const noApp = !res.ok && /No such bundle identifier|-10814/.test(res.output);
|
|
435
|
+
tccResets.push({
|
|
436
|
+
target,
|
|
437
|
+
ok: res.ok || noApp,
|
|
438
|
+
detail: res.ok
|
|
439
|
+
? res.output.trim() || "reset"
|
|
440
|
+
: noApp
|
|
441
|
+
? resolvedVia === "none"
|
|
442
|
+
? "no app carries this identifier and none is packaged here — reinstall the helpers, or clear the grants in System Settings ▸ Privacy & Security"
|
|
443
|
+
: "no app registered under this identifier — nothing to revoke"
|
|
444
|
+
: res.output.trim() || "failed",
|
|
445
|
+
});
|
|
446
|
+
if (!res.ok && !noApp) {
|
|
447
|
+
warnings.push(`tccutil reset All ${target} failed: ${res.output.trim() || "unknown"}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
tccResets,
|
|
452
|
+
resolvedVia,
|
|
453
|
+
registeredBundle,
|
|
454
|
+
shortcutsNote: "the bundled things-proxy-* shortcuts cannot be removed by any tool — delete them by hand in Shortcuts.app if a truly fresh machine is wanted (`things setup` re-imports them)",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Stop both halves and remove their LaunchAgents + the installed bundle.
|
|
459
|
+
*
|
|
460
|
+
* By default the macOS grants and the local state (tokens, logs, the reader's
|
|
461
|
+
* bookmark) are KEPT: the TCC rows are keyed to the two signing identities and
|
|
462
|
+
* simply go dormant, so a later reinstall picks them straight back up with no
|
|
463
|
+
* second ceremony. `revoke` turns this into the ceremony's full inverse —
|
|
464
|
+
* grants revoked first (see {@link revokeGrants}), then the uninstall, then the
|
|
465
|
+
* reader's container and the deputy's state dir deleted (the read grant is a
|
|
466
|
+
* bookmark FILE, not a TCC row, so revoking it means deleting it).
|
|
467
|
+
*
|
|
468
|
+
* Every leg is independent and IDEMPOTENT: an already-uninstalled helper, an
|
|
469
|
+
* empty permission store, and absent directories are all fine — each leg does
|
|
470
|
+
* whatever is still outstanding, so this works from any partial state and a
|
|
471
|
+
* second run is an all-no-op.
|
|
472
|
+
*/
|
|
473
|
+
export function uninstallHelpers(options = {}, env = process.env, deps = {}) {
|
|
474
|
+
const warnings = [];
|
|
475
|
+
const revoke = options.revoke === true;
|
|
476
|
+
// Revocation runs while the installed bundle (if any) still resolves.
|
|
477
|
+
const revocation = revoke ? revokeGrants(env, deps, warnings) : null;
|
|
275
478
|
const removed = [];
|
|
276
479
|
launchctl(["bootout", launchTarget()]);
|
|
277
480
|
launchctl(["bootout", readerLaunchTarget()]);
|
|
278
|
-
for (const path of [deputyPlistPath(), readerPlistPath()]) {
|
|
481
|
+
for (const path of [deputyPlistPath(env), readerPlistPath(env)]) {
|
|
279
482
|
if (existsSync(path)) {
|
|
280
483
|
rmSync(path);
|
|
281
484
|
removed.push(path);
|
|
@@ -286,13 +489,32 @@ export function uninstallHelpers(env = process.env) {
|
|
|
286
489
|
rmSync(installDir, { recursive: true });
|
|
287
490
|
removed.push(installDir);
|
|
288
491
|
}
|
|
289
|
-
|
|
492
|
+
if (revoke) {
|
|
493
|
+
// The reader's container holds the bookmark that IS the read grant; the
|
|
494
|
+
// rendezvous holds the access token; the deputy's state dir holds its own.
|
|
495
|
+
for (const dir of [
|
|
496
|
+
readerSandboxContainerDir(env),
|
|
497
|
+
readerRendezvousDir(env),
|
|
498
|
+
deputyStateDir(env),
|
|
499
|
+
]) {
|
|
500
|
+
if (existsSync(dir)) {
|
|
501
|
+
try {
|
|
502
|
+
rmSync(dir, { recursive: true, force: true });
|
|
503
|
+
removed.push(dir);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
warnings.push(`could not remove ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { removed, revocation, warnings };
|
|
290
512
|
}
|
|
291
513
|
/** Restart the launchd-managed helpers (picks up a rebuilt installed bundle). */
|
|
292
514
|
export function restartHelpers() {
|
|
293
515
|
const res = launchctl(["kickstart", "-k", launchTarget()]);
|
|
294
516
|
if (!res.ok) {
|
|
295
|
-
throw new Error(`launchctl kickstart failed (${res.output.trim() || "unknown"}) — are the helpers installed? Run: things helpers
|
|
517
|
+
throw new Error(`launchctl kickstart failed (${res.output.trim() || "unknown"}) — are the helpers installed? Run: things helpers setup`);
|
|
296
518
|
}
|
|
297
519
|
// Reader restart is best-effort: it may legitimately not be installed.
|
|
298
520
|
launchctl(["kickstart", "-k", readerLaunchTarget()]);
|
|
@@ -311,7 +533,7 @@ export function grantReader(env = process.env) {
|
|
|
311
533
|
if (!existsSync(appPath)) {
|
|
312
534
|
return {
|
|
313
535
|
granted: false,
|
|
314
|
-
detail: "things-reader is not installed — run `things helpers
|
|
536
|
+
detail: "things-reader is not installed — run `things helpers setup` first",
|
|
315
537
|
};
|
|
316
538
|
}
|
|
317
539
|
const thingsContainer = join(homedir(), THINGS_GROUP_CONTAINER);
|
|
@@ -336,8 +558,8 @@ export function grantReader(env = process.env) {
|
|
|
336
558
|
const deadline = Date.now() + 15_000;
|
|
337
559
|
let detail = "the reader is not running — `things helpers status`";
|
|
338
560
|
while (Date.now() < deadline) {
|
|
339
|
-
|
|
340
|
-
|
|
561
|
+
const token = rendezvousExists(socketPath) ? readRendezvousToken(tokenPath) : null;
|
|
562
|
+
if (token !== null) {
|
|
341
563
|
const bridge = new DeputySyncBridge(socketPath);
|
|
342
564
|
try {
|
|
343
565
|
const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
|
|
@@ -348,10 +570,10 @@ export function grantReader(env = process.env) {
|
|
|
348
570
|
return { granted: true, detail: "granted" };
|
|
349
571
|
return {
|
|
350
572
|
granted: false,
|
|
351
|
-
detail: "the grant landed, but the Things database was not found inside the granted folder — rerun `things helpers
|
|
573
|
+
detail: "the grant landed, but the Things database was not found inside the granted folder — rerun `things helpers setup` and grant the Things data folder",
|
|
352
574
|
};
|
|
353
575
|
}
|
|
354
|
-
detail = "the panel closed but no grant landed (canceled?) — rerun `things helpers
|
|
576
|
+
detail = "the panel closed but no grant landed (canceled?) — rerun `things helpers setup`";
|
|
355
577
|
}
|
|
356
578
|
catch {
|
|
357
579
|
detail = "the reader socket is not answering — `things helpers status`";
|
|
@@ -367,6 +589,591 @@ export function grantReader(env = process.env) {
|
|
|
367
589
|
function syncSleepMs(ms) {
|
|
368
590
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
369
591
|
}
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// The onboarding ceremony (docs/design/helpers-onboarding.md)
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
/** How long a consent dialog may stay unanswered before the leg is left pending. */
|
|
596
|
+
const AUTOMATION_PROMPT_TIMEOUT_MS = 120_000;
|
|
597
|
+
/** How long the Accessibility toggle is waited for, and how often it is re-read. */
|
|
598
|
+
const AX_WAIT_TIMEOUT_MS = 120_000;
|
|
599
|
+
const AX_POLL_INTERVAL_MS = 2000;
|
|
600
|
+
/** The deputy kills its child at timeoutMs; the client deadline adds grace. */
|
|
601
|
+
const CLIENT_GRACE_MS = 5000;
|
|
602
|
+
/**
|
|
603
|
+
* How long the ceremony waits for the deputy's socket. `setup` installs first,
|
|
604
|
+
* which boots the launchd jobs out and back in, so the socket is legitimately
|
|
605
|
+
* absent for a moment when the ceremony opens its channel.
|
|
606
|
+
*/
|
|
607
|
+
const DEPUTY_SOCKET_WAIT_MS = 15_000;
|
|
608
|
+
const AX_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
|
|
609
|
+
/** Which tier each leg belongs to. */
|
|
610
|
+
const LEG_TIER = {
|
|
611
|
+
"reader-read-grant": "base",
|
|
612
|
+
"automation-things": "base",
|
|
613
|
+
"automation-system-events": "gui",
|
|
614
|
+
accessibility: "gui",
|
|
615
|
+
shortcuts: "base",
|
|
616
|
+
};
|
|
617
|
+
/** A live sync bridge to the deputy socket, independent of the routing config. */
|
|
618
|
+
function openDeputyChannel(env, waitMs) {
|
|
619
|
+
const socketPath = deputySocketPath(env);
|
|
620
|
+
const tokenPath = deputyTokenPath(env);
|
|
621
|
+
const up = () => existsSync(socketPath) && existsSync(tokenPath);
|
|
622
|
+
const deadline = Date.now() + waitMs;
|
|
623
|
+
while (!up() && Date.now() < deadline)
|
|
624
|
+
syncSleepMs(250);
|
|
625
|
+
if (!up()) {
|
|
626
|
+
throw new Error(`the deputy is not running (no socket at ${socketPath}) — \`things helpers status\` to inspect`);
|
|
627
|
+
}
|
|
628
|
+
const token = readFileSync(tokenPath, "utf8").trim();
|
|
629
|
+
const bridge = new DeputySyncBridge(socketPath);
|
|
630
|
+
const request = (fields, timeoutMs) => {
|
|
631
|
+
const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, ...fields }, timeoutMs);
|
|
632
|
+
if (res["ok"] === true)
|
|
633
|
+
return res;
|
|
634
|
+
const err = res["error"];
|
|
635
|
+
throw new DeputyRequestError(err?.code ?? "internal", err?.message ?? "the deputy refused");
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
638
|
+
hello: () => request({ verb: "hello" }, 5000),
|
|
639
|
+
request,
|
|
640
|
+
close: () => {
|
|
641
|
+
bridge.close();
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
/** Reader handshake + a `locate` inside the granted scope, on one connection. */
|
|
646
|
+
function readerProbeDefault(env) {
|
|
647
|
+
const socketPath = readerSocketPath(env);
|
|
648
|
+
const tokenPath = readerTokenPath(env);
|
|
649
|
+
if (!rendezvousExists(socketPath) || !rendezvousExists(tokenPath))
|
|
650
|
+
return null;
|
|
651
|
+
const token = readRendezvousToken(tokenPath);
|
|
652
|
+
if (token === null)
|
|
653
|
+
return null;
|
|
654
|
+
const bridge = new DeputySyncBridge(socketPath);
|
|
655
|
+
try {
|
|
656
|
+
const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
|
|
657
|
+
if (res["ok"] !== true)
|
|
658
|
+
return null;
|
|
659
|
+
const granted = res.granted === true;
|
|
660
|
+
if (!granted)
|
|
661
|
+
return { granted: false, locates: false };
|
|
662
|
+
const locate = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "locate" }, 5000);
|
|
663
|
+
return { granted: true, locates: locate["ok"] === true };
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
bridge.close();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function firstLine(text) {
|
|
673
|
+
return text.trim().split("\n")[0]?.trim() ?? "";
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Both ways out of a denial, always named together. macOS will not show the
|
|
677
|
+
* Automation dialog again once it has been refused, so the choices are the
|
|
678
|
+
* System Settings switch or re-arming the dialog by clearing the recorded
|
|
679
|
+
* refusal. The ceremony NEVER clears a denial itself — that is the user's call.
|
|
680
|
+
*/
|
|
681
|
+
function deniedRemediation(settingsName) {
|
|
682
|
+
return (`turn on Things API Helper under System Settings ▸ Privacy & Security ▸ Automation ▸ ${settingsName}, ` +
|
|
683
|
+
`or re-arm the dialog with \`tccutil reset AppleEvents ${HELPERS_BUNDLE_ID}\`, then rerun \`things helpers setup\``);
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* One Automation leg: skipped when the deputy already reports the target
|
|
687
|
+
* granted, otherwise a benign AppleEvent sent THROUGH the deputy — the request
|
|
688
|
+
* blocks while the consent dialog is up, so answering it right there is what
|
|
689
|
+
* completes the leg. The event auto-launches its target, which is intended:
|
|
690
|
+
* macOS has no consent record to hand out while the target is down.
|
|
691
|
+
*
|
|
692
|
+
* The probe script MUST dispatch a real Apple event. A handful of
|
|
693
|
+
* application-object properties (`version`, `name`, `id`, `running`) are
|
|
694
|
+
* answered locally by the AppleScript runtime from the target's bundle —
|
|
695
|
+
* no event leaves the process, so no consent dialog is raised and no grant
|
|
696
|
+
* is minted, while the script still exits 0. That exact false positive
|
|
697
|
+
* shipped in the first ceremony (probe was `version`; the "granted" leg had
|
|
698
|
+
* granted nothing), which is also why a 0 exit alone is no longer believed:
|
|
699
|
+
* the leg re-reads the deputy's own AEDeterminePermission verdict afterwards
|
|
700
|
+
* and only reports what macOS reports. An old deputy whose hello carries no
|
|
701
|
+
* automation fields cannot be re-read — there the 0 exit stands, best effort.
|
|
702
|
+
*/
|
|
703
|
+
function automationLeg(channel, spec, known, recheck, timeoutMs, progress) {
|
|
704
|
+
const base = { leg: spec.leg, label: spec.label, tier: LEG_TIER[spec.leg] };
|
|
705
|
+
if (known === "granted") {
|
|
706
|
+
progress(`${spec.label}: already granted`);
|
|
707
|
+
return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
|
|
708
|
+
}
|
|
709
|
+
if (known === "denied") {
|
|
710
|
+
progress(`${spec.label}: denied — the dialog cannot be raised again`);
|
|
711
|
+
return {
|
|
712
|
+
...base,
|
|
713
|
+
state: "denied",
|
|
714
|
+
alreadyGranted: false,
|
|
715
|
+
detail: deniedRemediation(spec.settingsName),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
progress(`${spec.label}: asking now — answer the dialog if one appears`);
|
|
719
|
+
let res;
|
|
720
|
+
try {
|
|
721
|
+
res = channel.request({ verb: "osascript", script: spec.script, lang: "applescript", timeoutMs }, timeoutMs + CLIENT_GRACE_MS);
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
725
|
+
progress(`${spec.label}: no answer (${why})`);
|
|
726
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: why };
|
|
727
|
+
}
|
|
728
|
+
if (res.timedOut === true) {
|
|
729
|
+
progress(`${spec.label}: still waiting on the dialog`);
|
|
730
|
+
return {
|
|
731
|
+
...base,
|
|
732
|
+
state: "pending",
|
|
733
|
+
alreadyGranted: false,
|
|
734
|
+
detail: `no answer within ${Math.round(timeoutMs / 1000)}s — answer the dialog, then rerun`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
if (res.exitCode === 0) {
|
|
738
|
+
const after = recheck();
|
|
739
|
+
if (after === undefined || after === "granted") {
|
|
740
|
+
progress(`${spec.label}: granted`);
|
|
741
|
+
return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
|
|
742
|
+
}
|
|
743
|
+
progress(`${spec.label}: the probe ran but macOS reports no grant (${after})`);
|
|
744
|
+
return {
|
|
745
|
+
...base,
|
|
746
|
+
state: "pending",
|
|
747
|
+
alreadyGranted: false,
|
|
748
|
+
detail: `probe succeeded yet AEDeterminePermission reports "${after}" — rerun, or turn on Things API Helper under System Settings ▸ Privacy & Security ▸ Automation ▸ ${spec.settingsName}`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (res.stderr.includes("-1743")) {
|
|
752
|
+
progress(`${spec.label}: denied`);
|
|
753
|
+
return {
|
|
754
|
+
...base,
|
|
755
|
+
state: "denied",
|
|
756
|
+
alreadyGranted: false,
|
|
757
|
+
detail: deniedRemediation(spec.settingsName),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const why = firstLine(res.stderr) || `exit ${res.exitCode}`;
|
|
761
|
+
progress(`${spec.label}: no grant yet (${why})`);
|
|
762
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: why };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The Accessibility leg. Unlike Automation, the grant is not a dialog answer
|
|
766
|
+
* but a switch in System Settings, so the prompt is fire-and-forget and the
|
|
767
|
+
* ceremony polls the deputy's own trust bit until it flips.
|
|
768
|
+
*/
|
|
769
|
+
function accessibilityLeg(channel, axTrusted, deps) {
|
|
770
|
+
const base = { leg: "accessibility", label: "accessibility", tier: "gui" };
|
|
771
|
+
if (axTrusted === true) {
|
|
772
|
+
deps.progress("accessibility: already granted");
|
|
773
|
+
return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const res = channel.request({ verb: "prime-ax" }, 10_000);
|
|
777
|
+
if (res["axTrusted"] === true) {
|
|
778
|
+
deps.progress("accessibility: already granted");
|
|
779
|
+
return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
784
|
+
deps.progress(`accessibility: could not raise the prompt (${why})`);
|
|
785
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: why };
|
|
786
|
+
}
|
|
787
|
+
deps.progress('accessibility: System Settings ▸ Privacy & Security ▸ Accessibility — turn on "Things API Helper"');
|
|
788
|
+
deps.openUrl(AX_SETTINGS_URL);
|
|
789
|
+
deps.progress("accessibility: waiting for the toggle — Ctrl-C and rerun anytime");
|
|
790
|
+
const deadline = deps.now() + deps.timeoutMs;
|
|
791
|
+
while (deps.now() < deadline) {
|
|
792
|
+
deps.sleep(deps.intervalMs);
|
|
793
|
+
try {
|
|
794
|
+
if (channel.hello().axTrusted === true) {
|
|
795
|
+
deps.progress("accessibility: granted");
|
|
796
|
+
return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// A restart mid-wait (or a momentary hiccup) is not an answer — keep
|
|
801
|
+
// asking until the deadline; the state is whatever the last read said.
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
deps.progress("accessibility: not toggled yet");
|
|
805
|
+
return {
|
|
806
|
+
...base,
|
|
807
|
+
state: "pending",
|
|
808
|
+
alreadyGranted: false,
|
|
809
|
+
detail: `still off after ${Math.round(deps.timeoutMs / 1000)}s — turn on "Things API Helper" under System Settings ▸ Privacy & Security ▸ Accessibility, then rerun`,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/** The bundled proxy shortcuts, counted through the deputy's `shortcuts list`. */
|
|
813
|
+
function shortcutsLeg(channel, progress) {
|
|
814
|
+
const base = { leg: "shortcuts", label: "shortcuts", tier: "base" };
|
|
815
|
+
let installed;
|
|
816
|
+
try {
|
|
817
|
+
const res = channel.request({ verb: "shortcuts", op: "list", timeoutMs: 20_000 }, 20_000 + CLIENT_GRACE_MS);
|
|
818
|
+
if (res.exitCode !== 0 || res.timedOut === true) {
|
|
819
|
+
const why = firstLine(res.stderr) || `exit ${res.exitCode}`;
|
|
820
|
+
progress(`shortcuts: could not list them (${why})`);
|
|
821
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: why };
|
|
822
|
+
}
|
|
823
|
+
installed = new Set(res.stdout
|
|
824
|
+
.split("\n")
|
|
825
|
+
.map((line) => line.trim())
|
|
826
|
+
.filter((line) => line !== ""));
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
829
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
830
|
+
progress(`shortcuts: could not list them (${why})`);
|
|
831
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: why };
|
|
832
|
+
}
|
|
833
|
+
const missing = EXPECTED_PROXIES.filter((name) => !installed.has(name));
|
|
834
|
+
if (missing.length === 0) {
|
|
835
|
+
progress(`shortcuts: all ${EXPECTED_PROXIES.length} installed`);
|
|
836
|
+
return {
|
|
837
|
+
...base,
|
|
838
|
+
state: "granted",
|
|
839
|
+
alreadyGranted: true,
|
|
840
|
+
detail: `all ${EXPECTED_PROXIES.length} installed`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
progress(`shortcuts: ${missing.length} missing — run \`things setup\``);
|
|
844
|
+
return {
|
|
845
|
+
...base,
|
|
846
|
+
state: "skipped-not-installed",
|
|
847
|
+
alreadyGranted: false,
|
|
848
|
+
detail: `missing ${missing.join(", ")} — run \`things setup\` (it opens the import screen for each)`,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function readerStanding(env, readerProbe) {
|
|
852
|
+
if (!existsSync(readerInstalledAppPath(env)))
|
|
853
|
+
return "not-installed";
|
|
854
|
+
const probe = readerProbe();
|
|
855
|
+
return probe !== null && probe.granted && probe.locates ? "granted" : "needs-panel";
|
|
856
|
+
}
|
|
857
|
+
/** The reader's durable read grant — skipped when a database already resolves inside it. */
|
|
858
|
+
function readerLeg(standing, deps) {
|
|
859
|
+
const base = {
|
|
860
|
+
leg: "reader-read-grant",
|
|
861
|
+
label: "reader read grant",
|
|
862
|
+
tier: "base",
|
|
863
|
+
};
|
|
864
|
+
if (standing === "not-installed") {
|
|
865
|
+
deps.progress("reader read grant: the reader is not part of the installed bundle");
|
|
866
|
+
return {
|
|
867
|
+
...base,
|
|
868
|
+
state: "skipped-not-installed",
|
|
869
|
+
alreadyGranted: false,
|
|
870
|
+
detail: "the bundle was built without things-reader (no Apple-issued signing identity) — database reads run direct",
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (standing === "granted") {
|
|
874
|
+
deps.progress("reader read grant: already granted");
|
|
875
|
+
return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
|
|
876
|
+
}
|
|
877
|
+
deps.progress("reader read grant: accept the folder panel the reader is opening");
|
|
878
|
+
const result = deps.grant();
|
|
879
|
+
if (result.granted) {
|
|
880
|
+
deps.progress("reader read grant: granted");
|
|
881
|
+
return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
|
|
882
|
+
}
|
|
883
|
+
deps.progress(`reader read grant: not granted (${result.detail})`);
|
|
884
|
+
return { ...base, state: "pending", alreadyGranted: false, detail: result.detail };
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* The hint the BASE tier closes with (Article V, tiered). A machine that never
|
|
888
|
+
* asked for GUI-driving should still learn that the capability exists and what
|
|
889
|
+
* it buys, exactly once, at the moment it has just finished setting up.
|
|
890
|
+
*/
|
|
891
|
+
const GUI_TIER_HINT = "GUI-driving is not set up — some features drive the app window (repeat-rule edits, area " +
|
|
892
|
+
"reorder); run `things helpers setup --gui` to enable.";
|
|
893
|
+
function closingLine(steps, mode, tier) {
|
|
894
|
+
const denied = steps.filter((s) => s.state === "denied");
|
|
895
|
+
if (denied.length > 0) {
|
|
896
|
+
return (`setup did not finish — ${denied.map((s) => s.label).join(" and ")} ${denied.length === 1 ? "is" : "are"} denied. ` +
|
|
897
|
+
`Turn Things API Helper on under System Settings ▸ Privacy & Security ▸ Automation, or re-arm the dialog with ` +
|
|
898
|
+
`\`tccutil reset AppleEvents ${HELPERS_BUNDLE_ID}\`, then rerun \`things helpers setup\`.`);
|
|
899
|
+
}
|
|
900
|
+
const pending = steps.filter((s) => s.state === "pending");
|
|
901
|
+
if (pending.length > 0) {
|
|
902
|
+
return (`setup did not finish — ${pending.map((s) => s.label).join(", ")} still needs you. ` +
|
|
903
|
+
`Rerun \`things helpers setup\` to resume exactly there; everything already granted is skipped.`);
|
|
904
|
+
}
|
|
905
|
+
const shortcutsMissing = steps.some((s) => s.leg === "shortcuts" && s.state === "skipped-not-installed");
|
|
906
|
+
const guiHint = tier === "base" ? ` ${GUI_TIER_HINT}` : "";
|
|
907
|
+
if (mode === "false") {
|
|
908
|
+
return ("everything asked for is granted, but routing is off — `things config set helpers-enabled auto` " +
|
|
909
|
+
`to send reads and writes through the helpers.${guiHint}`);
|
|
910
|
+
}
|
|
911
|
+
const base = "you're done — writes and reads route through the helpers with no further prompts.";
|
|
912
|
+
const shortcuts = shortcutsMissing
|
|
913
|
+
? " The bundled shortcuts are still missing; run `things setup` if you want that path too."
|
|
914
|
+
: "";
|
|
915
|
+
return `${base}${shortcuts}${guiHint}`;
|
|
916
|
+
}
|
|
917
|
+
/** What each leg puts on screen, for the upfront banner. */
|
|
918
|
+
const PROMPT_LABELS = {
|
|
919
|
+
"reader-read-grant": "the reader's folder panel",
|
|
920
|
+
"automation-things": "app control for Things",
|
|
921
|
+
"automation-system-events": "app control for System Events",
|
|
922
|
+
accessibility: "the Accessibility switch",
|
|
923
|
+
// The census asks the deputy, never the user.
|
|
924
|
+
shortcuts: "",
|
|
925
|
+
};
|
|
926
|
+
/**
|
|
927
|
+
* What the human is about to see, in the words macOS will actually use. The
|
|
928
|
+
* wizard prints these one leg ahead of the dialog (Article V, mode-aware); in
|
|
929
|
+
* strict mode they are never printed and the upfront banner's count stands
|
|
930
|
+
* alone.
|
|
931
|
+
*/
|
|
932
|
+
const PROMPT_EXPLAINERS = {
|
|
933
|
+
"reader-read-grant": [
|
|
934
|
+
"Next: read access to your Things data.",
|
|
935
|
+
" A file panel opens, already inside the Things data folder — click Grant Access.",
|
|
936
|
+
" Nothing else in that panel needs changing; do not navigate elsewhere.",
|
|
937
|
+
],
|
|
938
|
+
"automation-things": [
|
|
939
|
+
"Next: permission for the helper to control Things.",
|
|
940
|
+
' A macOS dialog will appear: "Things API Helper" wants access to control "Things" —',
|
|
941
|
+
" click Allow. Things opens if it was closed; that is expected.",
|
|
942
|
+
],
|
|
943
|
+
"automation-system-events": [
|
|
944
|
+
"Next: permission for the helper to control System Events (the GUI-driving tier).",
|
|
945
|
+
' A macOS dialog will appear: "Things API Helper" wants access to control',
|
|
946
|
+
' "System Events" — click Allow. System Events is the macOS component that reads and',
|
|
947
|
+
" presses the Things window's own controls.",
|
|
948
|
+
],
|
|
949
|
+
accessibility: [
|
|
950
|
+
"Next: the Accessibility switch (the GUI-driving tier).",
|
|
951
|
+
" System Settings opens at Privacy & Security ▸ Accessibility — turn on",
|
|
952
|
+
' "Things API Helper" in that list. This one is a switch you flip, not a dialog you',
|
|
953
|
+
" answer, so setup waits and watches for it.",
|
|
954
|
+
],
|
|
955
|
+
shortcuts: [],
|
|
956
|
+
};
|
|
957
|
+
/**
|
|
958
|
+
* Which legs are about to put something on screen, surveyed prompt-free from
|
|
959
|
+
* the deputy's handshake and the reader's bookmark state. A leg macOS already
|
|
960
|
+
* records as `denied` raises nothing (the dialog is spent), so it is not
|
|
961
|
+
* counted here even though it will be reported as a failure.
|
|
962
|
+
*/
|
|
963
|
+
function willRaiseAutomationDialog(state) {
|
|
964
|
+
return state !== "granted" && state !== "denied";
|
|
965
|
+
}
|
|
966
|
+
function outstandingPrompts(hello, reader, tier,
|
|
967
|
+
/** Things' standing AFTER any wake — never the raw handshake value. */
|
|
968
|
+
automationThings,
|
|
969
|
+
/** System Events' standing AFTER any wake — never the raw handshake value. */
|
|
970
|
+
systemEvents) {
|
|
971
|
+
const outstanding = [];
|
|
972
|
+
if (reader === "needs-panel")
|
|
973
|
+
outstanding.push("reader-read-grant");
|
|
974
|
+
if (willRaiseAutomationDialog(automationThings))
|
|
975
|
+
outstanding.push("automation-things");
|
|
976
|
+
if (tier === "gui") {
|
|
977
|
+
if (willRaiseAutomationDialog(systemEvents)) {
|
|
978
|
+
outstanding.push("automation-system-events");
|
|
979
|
+
}
|
|
980
|
+
if (hello.axTrusted !== true)
|
|
981
|
+
outstanding.push("accessibility");
|
|
982
|
+
}
|
|
983
|
+
return outstanding;
|
|
984
|
+
}
|
|
985
|
+
/** The interactive tier question, asked only when nothing else decided it. */
|
|
986
|
+
const GUI_TIER_QUESTION = "Some Things features have no programmatic surface and are driven through the app's own " +
|
|
987
|
+
"window (editing repeat rules, reordering areas). Enable GUI-driving permissions?";
|
|
988
|
+
/**
|
|
989
|
+
* The full onboarding ceremony behind `things helpers setup`: fire every
|
|
990
|
+
* consent macOS will ever ask for while a human is sitting there, then report
|
|
991
|
+
* where each one landed. Every leg is IDEMPOTENT — an already-granted leg is
|
|
992
|
+
* detected prompt-free (the deputy's own `hello` carries its TCC standing) and
|
|
993
|
+
* skipped — so a rerun on a fully onboarded machine raises nothing and reports
|
|
994
|
+
* all-green. Interactive by design: run it at the machine.
|
|
995
|
+
*
|
|
996
|
+
* Throws when the helpers are not installed or the deputy does not answer;
|
|
997
|
+
* every other outcome is reported per leg. See docs/design/helpers-onboarding.md.
|
|
998
|
+
*
|
|
999
|
+
* Runs under `withDefaultInterrupts` for its whole synchronous span, so Ctrl-C
|
|
1000
|
+
* stops it at a gate AND inside the Accessibility wait (../wizard.ts, "Why a
|
|
1001
|
+
* ceremony runs with the DEFAULT signal disposition"). Throws `CeremonyStopped`
|
|
1002
|
+
* when the human stops at a gate.
|
|
1003
|
+
*/
|
|
1004
|
+
export function onboardHelpers(options, env = process.env, deps = {}) {
|
|
1005
|
+
return withDefaultInterrupts(() => runOnboardCeremony(options, env, deps));
|
|
1006
|
+
}
|
|
1007
|
+
function runOnboardCeremony(options, env, deps) {
|
|
1008
|
+
const mode = options.mode;
|
|
1009
|
+
const progress = deps.progress ??
|
|
1010
|
+
((line) => {
|
|
1011
|
+
process.stdout.write(`${line}\n`);
|
|
1012
|
+
});
|
|
1013
|
+
const wizard = deps.wizard ?? createWizard();
|
|
1014
|
+
if (deps.channel === undefined && !existsSync(deputyInstalledBinaryPath(env))) {
|
|
1015
|
+
throw new Error("the helpers are not installed — run `things helpers setup` first");
|
|
1016
|
+
}
|
|
1017
|
+
// Which tier, decided BEFORE anything is raised so the banner can size the
|
|
1018
|
+
// sitting honestly. The flag wins; `ui-enabled` already on implies the tier
|
|
1019
|
+
// without it (a machine that opted into GUI-driving is asking for the grants
|
|
1020
|
+
// by definition); otherwise a TTY sitting is asked and a strict run is not.
|
|
1021
|
+
const uiEnabled = (deps.uiEnabled ?? (() => loadConfig(env).ui.enabled))();
|
|
1022
|
+
let guiRequestedBy = null;
|
|
1023
|
+
if (options.gui === true) {
|
|
1024
|
+
guiRequestedBy = "flag";
|
|
1025
|
+
}
|
|
1026
|
+
else if (uiEnabled) {
|
|
1027
|
+
guiRequestedBy = "config";
|
|
1028
|
+
progress("ui.enabled is on — including GUI-driving permissions.");
|
|
1029
|
+
}
|
|
1030
|
+
else if (wizard.ask(GUI_TIER_QUESTION, false)) {
|
|
1031
|
+
guiRequestedBy = "wizard";
|
|
1032
|
+
}
|
|
1033
|
+
const tier = guiRequestedBy === null ? "base" : "gui";
|
|
1034
|
+
const channel = deps.channel ?? openDeputyChannel(env, deps.deputyWaitMs ?? DEPUTY_SOCKET_WAIT_MS);
|
|
1035
|
+
const steps = [];
|
|
1036
|
+
let outstanding = [];
|
|
1037
|
+
try {
|
|
1038
|
+
let hello;
|
|
1039
|
+
try {
|
|
1040
|
+
hello = channel.hello();
|
|
1041
|
+
}
|
|
1042
|
+
catch (err) {
|
|
1043
|
+
throw new Error(`the deputy is installed but did not answer (${err instanceof Error ? err.message : String(err)}) — \`things helpers restart\`, then rerun`, { cause: err });
|
|
1044
|
+
}
|
|
1045
|
+
if (hello.deputyVersion !== EXPECTED_HELPERS_VERSION) {
|
|
1046
|
+
progress(`note: the installed helpers are v${hello.deputyVersion}, this package expects v${EXPECTED_HELPERS_VERSION} — rebuild with \`bash scripts/build-helpers.sh\` and rerun \`things helpers setup\` for the full ceremony`);
|
|
1047
|
+
}
|
|
1048
|
+
// A dormant target is settled BEFORE the count, never counted as a dialog
|
|
1049
|
+
// (#610 for System Events, #617 for Things). macOS reaps the agent when it
|
|
1050
|
+
// is idle and the user closes their own app, and the ask-false
|
|
1051
|
+
// determination has no answer for a target that is down, so `not-running`
|
|
1052
|
+
// says nothing about the grant — starting it (a background launch, not an
|
|
1053
|
+
// Apple event) is what makes the standing readable, and only then can the
|
|
1054
|
+
// banner promise the right number of dialogs. For Things the pre-launch
|
|
1055
|
+
// also keeps the leg's own Apple Event from yanking the app frontmost
|
|
1056
|
+
// (A40/A41).
|
|
1057
|
+
let automationThings = hello.automation?.things;
|
|
1058
|
+
if (automationThings === "not-running") {
|
|
1059
|
+
progress("automation → Things: the app was closed — starting it to read its standing");
|
|
1060
|
+
const wake = (deps.wakeThings ?? ((probe) => wakeThings(env, { probe })))(() => {
|
|
1061
|
+
try {
|
|
1062
|
+
return channel.hello().automation?.things;
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
automationThings = wake.standing;
|
|
1069
|
+
}
|
|
1070
|
+
let systemEvents = hello.automation?.systemEvents;
|
|
1071
|
+
if (tier === "gui" && systemEvents === "not-running") {
|
|
1072
|
+
progress("automation → System Events: the target was asleep — starting it to read its standing");
|
|
1073
|
+
const wake = (deps.wakeSystemEvents ?? ((probe) => wakeSystemEvents(env, { probe })))(() => {
|
|
1074
|
+
try {
|
|
1075
|
+
return channel.hello().automation?.systemEvents;
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
systemEvents = wake.standing;
|
|
1082
|
+
}
|
|
1083
|
+
// Size the sitting BEFORE raising anything, so whoever started this knows
|
|
1084
|
+
// whether they have to stay at the screen.
|
|
1085
|
+
const reader = readerStanding(env, deps.readerProbe ?? (() => readerProbeDefault(env)));
|
|
1086
|
+
outstanding = outstandingPrompts(hello, reader, tier, automationThings, systemEvents);
|
|
1087
|
+
progress(outstanding.length === 0
|
|
1088
|
+
? "nothing to raise — every permission the helpers need is already on record"
|
|
1089
|
+
: `about to raise ${outstanding.length} macOS consent dialog${outstanding.length === 1 ? "" : "s"} — ` +
|
|
1090
|
+
`${outstanding.map((leg) => PROMPT_LABELS[leg]).join(", ")}. Someone must be at the screen.`);
|
|
1091
|
+
const willRaise = new Set(outstanding);
|
|
1092
|
+
/** Explain a leg's dialog and let the human pace it — wizard mode only. */
|
|
1093
|
+
const brief = (leg) => {
|
|
1094
|
+
if (willRaise.has(leg))
|
|
1095
|
+
wizard.explain(PROMPT_EXPLAINERS[leg]);
|
|
1096
|
+
};
|
|
1097
|
+
brief("reader-read-grant");
|
|
1098
|
+
steps.push(readerLeg(reader, {
|
|
1099
|
+
progress,
|
|
1100
|
+
grant: deps.grant ?? (() => grantReader(env)),
|
|
1101
|
+
}));
|
|
1102
|
+
const automationTimeoutMs = deps.automationTimeoutMs ?? AUTOMATION_PROMPT_TIMEOUT_MS;
|
|
1103
|
+
// Fresh AEDeterminePermission read for one target, off a new hello.
|
|
1104
|
+
const refreshAutomation = (key) => () => {
|
|
1105
|
+
try {
|
|
1106
|
+
return channel.hello().automation?.[key];
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
brief("automation-things");
|
|
1113
|
+
steps.push(automationLeg(channel, {
|
|
1114
|
+
leg: "automation-things",
|
|
1115
|
+
label: "automation → Things",
|
|
1116
|
+
// `count of areas` dispatches a REAL Apple event; `version` and its
|
|
1117
|
+
// kin are answered locally and would grant nothing (see automationLeg).
|
|
1118
|
+
script: 'tell application "Things3" to count of areas',
|
|
1119
|
+
settingsName: "Things3",
|
|
1120
|
+
}, automationThings, refreshAutomation("things"), automationTimeoutMs, progress));
|
|
1121
|
+
// The GUI tier. Skipped entirely under the base tier — Accessibility and
|
|
1122
|
+
// System Events are the two widest grants the pair ever holds, so they are
|
|
1123
|
+
// gathered only when GUI-driving was actually asked for (Article V).
|
|
1124
|
+
if (tier === "gui") {
|
|
1125
|
+
brief("automation-system-events");
|
|
1126
|
+
steps.push(automationLeg(channel, {
|
|
1127
|
+
leg: "automation-system-events",
|
|
1128
|
+
label: "automation → System Events",
|
|
1129
|
+
script: 'tell application "System Events" to name of first process',
|
|
1130
|
+
settingsName: "System Events",
|
|
1131
|
+
}, systemEvents, refreshAutomation("systemEvents"), automationTimeoutMs, progress));
|
|
1132
|
+
brief("accessibility");
|
|
1133
|
+
steps.push(accessibilityLeg(channel, hello.axTrusted, {
|
|
1134
|
+
progress,
|
|
1135
|
+
openUrl: deps.openUrl ?? openUrlBestEffort,
|
|
1136
|
+
sleep: deps.sleep ?? syncSleepMs,
|
|
1137
|
+
now: deps.now ?? Date.now,
|
|
1138
|
+
timeoutMs: deps.axTimeoutMs ?? AX_WAIT_TIMEOUT_MS,
|
|
1139
|
+
intervalMs: deps.axIntervalMs ?? AX_POLL_INTERVAL_MS,
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
steps.push(shortcutsLeg(channel, progress));
|
|
1143
|
+
}
|
|
1144
|
+
finally {
|
|
1145
|
+
channel.close();
|
|
1146
|
+
}
|
|
1147
|
+
// A GUI tier that actually landed turns the config key on, so the capability
|
|
1148
|
+
// the user just granted is the capability the engine will use. Only on full
|
|
1149
|
+
// success: switching the key on over a half-granted tier would produce
|
|
1150
|
+
// exactly the "enabled but refuses" state Article IV exists to prevent.
|
|
1151
|
+
const guiGranted = tier === "gui" && steps.every((s) => s.tier !== "gui" || s.state === "granted");
|
|
1152
|
+
let uiEnabledSet = false;
|
|
1153
|
+
if (guiGranted && !uiEnabled) {
|
|
1154
|
+
(deps.setUiEnabled ?? ((value) => saveConfigKey("uiEnabled", value, env)))(true);
|
|
1155
|
+
uiEnabledSet = true;
|
|
1156
|
+
progress("GUI-driving turned on in config (`ui-enabled` is now true).");
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
tier,
|
|
1160
|
+
guiRequestedBy,
|
|
1161
|
+
uiEnabledSet,
|
|
1162
|
+
steps,
|
|
1163
|
+
outstanding,
|
|
1164
|
+
denied: steps.some((s) => s.state === "denied"),
|
|
1165
|
+
pending: steps.some((s) => s.state === "pending"),
|
|
1166
|
+
closing: closingLine(steps, mode, tier),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
function openUrlBestEffort(url) {
|
|
1170
|
+
try {
|
|
1171
|
+
execFileSync("open", [url], { stdio: "ignore", timeout: 10_000 });
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
// The deep link is a convenience; the written path works without it.
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
370
1177
|
/**
|
|
371
1178
|
* Prompt-free status for both halves: launchd load state, a live handshake
|
|
372
1179
|
* when each socket answers, and the installed bundle's signing facts. Works
|
|
@@ -406,7 +1213,7 @@ export function helpersStatus(mode, env = process.env) {
|
|
|
406
1213
|
bundleInstalled: binaryInstalled,
|
|
407
1214
|
installedVersion: installedHelpersVersion(env),
|
|
408
1215
|
deputy: {
|
|
409
|
-
plistInstalled: existsSync(deputyPlistPath()),
|
|
1216
|
+
plistInstalled: existsSync(deputyPlistPath(env)),
|
|
410
1217
|
loaded: launchctl(["print", launchTarget()]).ok,
|
|
411
1218
|
running: hello !== null,
|
|
412
1219
|
socketPath,
|
|
@@ -427,10 +1234,15 @@ function readerStatus(env) {
|
|
|
427
1234
|
let running = false;
|
|
428
1235
|
let granted = false;
|
|
429
1236
|
let hello = null;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
1237
|
+
// The rendezvous is `<state>/reader`, ours and outside every sandbox
|
|
1238
|
+
// container, so `helpers status` answers this identically from any host app.
|
|
1239
|
+
let detail = "not registered with launchd (no rendezvous)";
|
|
1240
|
+
const socketPresent = rendezvousExists(socketPath) && rendezvousExists(tokenPath);
|
|
1241
|
+
const token = socketPresent ? readRendezvousToken(tokenPath) : null;
|
|
1242
|
+
if (socketPresent && token === null) {
|
|
1243
|
+
detail = `the reader's access token could not be read (${tokenPath}) — \`things helpers setup\` mints a fresh one`;
|
|
1244
|
+
}
|
|
1245
|
+
if (token !== null) {
|
|
434
1246
|
const bridge = new DeputySyncBridge(socketPath);
|
|
435
1247
|
try {
|
|
436
1248
|
const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
|
|
@@ -438,7 +1250,7 @@ function readerStatus(env) {
|
|
|
438
1250
|
running = true;
|
|
439
1251
|
hello = res;
|
|
440
1252
|
granted = res.granted === true;
|
|
441
|
-
detail = granted ? "running, granted" : "running, NOT granted (things helpers
|
|
1253
|
+
detail = granted ? "running, granted" : "running, NOT granted (things helpers setup)";
|
|
442
1254
|
}
|
|
443
1255
|
else {
|
|
444
1256
|
detail = `handshake refused: ${JSON.stringify(res["error"])}`;
|
|
@@ -453,12 +1265,12 @@ function readerStatus(env) {
|
|
|
453
1265
|
}
|
|
454
1266
|
return {
|
|
455
1267
|
installed,
|
|
456
|
-
plistInstalled: existsSync(readerPlistPath()),
|
|
1268
|
+
plistInstalled: existsSync(readerPlistPath(env)),
|
|
457
1269
|
loaded,
|
|
458
1270
|
running,
|
|
459
1271
|
granted,
|
|
460
1272
|
socketPath,
|
|
461
|
-
hungSocket: socketPresent && !running,
|
|
1273
|
+
hungSocket: socketPresent && token !== null && !running,
|
|
462
1274
|
hello,
|
|
463
1275
|
signing: installed ? deputySigningInfo(readerExecPath(appPath)) : null,
|
|
464
1276
|
detail,
|