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