things-api 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -9
- package/deputy/AppIcon.icns +0 -0
- package/deputy/VERSION +1 -1
- package/deputy/helpers-Info.plist +4 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
- package/deputy/reader/Info.plist +2 -0
- package/deputy/reader/entitlements.plist +30 -0
- package/deputy/reader/main.swift +129 -40
- package/deputy/src/server.swift +25 -2
- package/deputy/src/tcc.swift +58 -0
- package/dist/audit/schema.d.ts +30 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/capability.d.ts +322 -0
- package/dist/capability.js +706 -0
- package/dist/capability.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +13 -1
- package/dist/cli/commands/doctor.js +141 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.js +189 -51
- package/dist/cli/commands/helpers.js.map +1 -1
- package/dist/cli/commands/mcp.js +8 -0
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/op-result.js +6 -0
- package/dist/cli/commands/op-result.js.map +1 -1
- package/dist/cli/commands/reads.js +47 -1
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.js +34 -2
- package/dist/cli/commands/repeat-flags.js.map +1 -1
- package/dist/cli/commands/setup.d.ts +9 -0
- package/dist/cli/commands/setup.js +76 -83
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/writes.d.ts +1 -1
- package/dist/cli/commands/writes.js +311 -142
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/dry-run.d.ts +1 -1
- package/dist/cli/dry-run.js +1 -1
- package/dist/cli/glyphs.d.ts +23 -4
- package/dist/cli/glyphs.js +46 -19
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +49 -27
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/interrupt.d.ts +54 -6
- package/dist/cli/interrupt.js +78 -12
- package/dist/cli/interrupt.js.map +1 -1
- package/dist/cli/main.js +8 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/period.d.ts +3 -1
- package/dist/cli/period.js +1 -1
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.js +26 -1
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +31 -4
- package/dist/cli/render.js +127 -15
- package/dist/cli/render.js.map +1 -1
- package/dist/client.d.ts +23 -3
- package/dist/client.js +36 -13
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +8 -4
- package/dist/contracts.js +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/deputy/install.d.ts +234 -6
- package/dist/deputy/install.js +843 -31
- package/dist/deputy/install.js.map +1 -1
- package/dist/deputy/notices.js +2 -2
- package/dist/deputy/notices.js.map +1 -1
- package/dist/deputy/protocol.d.ts +59 -5
- package/dist/deputy/protocol.js +48 -11
- package/dist/deputy/protocol.js.map +1 -1
- package/dist/deputy/routing.d.ts +37 -0
- package/dist/deputy/routing.js +181 -27
- package/dist/deputy/routing.js.map +1 -1
- package/dist/deputy/wake.d.ts +61 -0
- package/dist/deputy/wake.js +149 -0
- package/dist/deputy/wake.js.map +1 -0
- package/dist/diagnose.d.ts +19 -4
- package/dist/diagnose.js +64 -8
- package/dist/diagnose.js.map +1 -1
- package/dist/direct-setup.d.ts +104 -0
- package/dist/direct-setup.js +658 -0
- package/dist/direct-setup.js.map +1 -0
- package/dist/host-access.d.ts +75 -0
- package/dist/host-access.js +177 -0
- package/dist/host-access.js.map +1 -0
- package/dist/index.d.ts +20 -5
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +27 -1
- package/dist/mcp/server.js +308 -109
- package/dist/mcp/server.js.map +1 -1
- package/dist/op-result.d.ts +9 -0
- package/dist/op-result.js +13 -1
- package/dist/op-result.js.map +1 -1
- package/dist/read/filter-contract.d.ts +1 -1
- package/dist/read/filter-contract.js +4 -0
- package/dist/read/filter-contract.js.map +1 -1
- package/dist/read/shape.js +4 -0
- package/dist/read/shape.js.map +1 -1
- package/dist/read/views.d.ts +29 -0
- package/dist/read/views.js +60 -0
- package/dist/read/views.js.map +1 -1
- package/dist/session-grant.d.ts +48 -0
- package/dist/session-grant.js +185 -0
- package/dist/session-grant.js.map +1 -0
- package/dist/surface-copy.d.ts +19 -3
- package/dist/surface-copy.js +19 -3
- package/dist/surface-copy.js.map +1 -1
- package/dist/wizard.d.ts +58 -0
- package/dist/wizard.js +174 -0
- package/dist/wizard.js.map +1 -0
- package/dist/write/availability.d.ts +1 -16
- package/dist/write/availability.js +10 -65
- package/dist/write/availability.js.map +1 -1
- package/dist/write/batch.d.ts +20 -0
- package/dist/write/batch.js +111 -21
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +9 -0
- package/dist/write/capabilities.js +2 -0
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/clear-reminder.js +1 -1
- package/dist/write/clear-reminder.js.map +1 -1
- package/dist/write/commands.d.ts +22 -7
- package/dist/write/commands.js +231 -254
- package/dist/write/commands.js.map +1 -1
- package/dist/write/failure-hints.d.ts +32 -7
- package/dist/write/failure-hints.js +9 -14
- package/dist/write/failure-hints.js.map +1 -1
- package/dist/write/guards.js +22 -4
- package/dist/write/guards.js.map +1 -1
- package/dist/write/lock.d.ts +16 -13
- package/dist/write/lock.js +55 -0
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.js +9 -13
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.js +9 -12
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +51 -29
- package/dist/write/operations.js +16 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +72 -14
- package/dist/write/opid.js +152 -8
- package/dist/write/opid.js.map +1 -1
- package/dist/write/param-schema.d.ts +80 -0
- package/dist/write/param-schema.js +754 -0
- package/dist/write/param-schema.js.map +1 -0
- package/dist/write/pipeline.d.ts +110 -5
- package/dist/write/pipeline.js +288 -14
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +38 -16
- package/dist/write/pre-state.js +117 -22
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +658 -444
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +25 -8
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-collateral.d.ts +31 -0
- package/dist/write/repeat-collateral.js +153 -0
- package/dist/write/repeat-collateral.js.map +1 -0
- package/dist/write/repeat-rule.d.ts +28 -1
- package/dist/write/repeat-rule.js +54 -4
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/resolution-timestamps.d.ts +11 -0
- package/dist/write/resolution-timestamps.js +108 -40
- package/dist/write/resolution-timestamps.js.map +1 -1
- package/dist/write/reversibility.js +4 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/template-mutation.d.ts +137 -0
- package/dist/write/template-mutation.js +598 -0
- package/dist/write/template-mutation.js.map +1 -0
- package/dist/write/undo.js +10 -20
- package/dist/write/undo.js.map +1 -1
- package/dist/write/update-fields.d.ts +151 -0
- package/dist/write/update-fields.js +427 -0
- package/dist/write/update-fields.js.map +1 -0
- package/dist/write/vectors/applescript.js +6 -10
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +1 -1
- package/dist/write/vectors/registry.js +10 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +2 -2
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +15 -4
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +268 -1
- package/dist/write/vectors/ui-certification.js +63 -13
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-chord.d.ts +144 -0
- package/dist/write/vectors/ui-chord.js +428 -0
- package/dist/write/vectors/ui-chord.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +8 -4
- package/dist/write/vectors/ui-drag.js +2 -0
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +70 -3
- package/dist/write/vectors/ui-recipes.js +482 -44
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +280 -10
- package/dist/write/vectors/ui.js +1068 -77
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/vectors/url-scheme.js +3 -0
- package/dist/write/vectors/url-scheme.js.map +1 -1
- package/dist/write/verify/delta.d.ts +62 -0
- package/dist/write/verify/delta.js +36 -2
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +4 -1
- package/dist/write/verify/poller.js +14 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +2 -1
- package/schema/envelope.schema.json +6 -4
- package/scripts/build-helpers.sh +5 -0
- package/skills/things-cli/SKILL.md +5 -3
- package/skills/things-cli/references/contracts.md +1 -0
- package/skills/things-cli/references/data-model.md +3 -0
- package/skills/things-cli/references/errors.md +1 -1
- package/skills/things-cli/references/gui.md +1 -0
- package/skills/things-cli/references/repeating.md +6 -0
- package/skills/things-cli/references/tag-cleanup.md +64 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `things setup` — the DIRECT-path onboarding ceremony
|
|
3
|
+
* (docs/design/permissions-doctrine.md, Article V).
|
|
4
|
+
*
|
|
5
|
+
* One of exactly two places in the package allowed to put a macOS consent
|
|
6
|
+
* dialog on screen (the other is `things helpers setup`). Everywhere else
|
|
7
|
+
* detects capability prompt-free and refuses; here we deliberately raise what
|
|
8
|
+
* has to be raised, while a human is sitting at the machine, and report where
|
|
9
|
+
* each grant landed.
|
|
10
|
+
*
|
|
11
|
+
* The legs, in order:
|
|
12
|
+
*
|
|
13
|
+
* a. READ ACCESS. An explicit two-way choice, because the two answers differ in
|
|
14
|
+
* kind rather than in quality. The DEFAULT — a bare Enter — is the session
|
|
15
|
+
* grant: one deliberate container open, which raises the "would like to
|
|
16
|
+
* access data from other apps" modal, and if the open then succeeds the
|
|
17
|
+
* grant is witnessed (./session-grant.ts). It lasts only while the host app
|
|
18
|
+
* stays open, and the copy says so. Typing `f` takes Full Disk Access
|
|
19
|
+
* instead: durable, but it hands the whole disk to a general-purpose host
|
|
20
|
+
* app, so it is offered, never assumed.
|
|
21
|
+
*
|
|
22
|
+
* The provoking open runs in a BOUNDED CHILD process, because macOS parks
|
|
23
|
+
* the requesting syscall in the kernel until someone answers the dialog —
|
|
24
|
+
* an in-process open would park the ceremony itself for as long as the
|
|
25
|
+
* dialog stands. MEASURED (APDP1, docs/lab/apdp1-grant-pinning.md): the
|
|
26
|
+
* app-data grant is keyed to the RESPONSIBLE APP INSTANCE, not to the pid
|
|
27
|
+
* that opened the file, so what a child provokes belongs to the host app
|
|
28
|
+
* and outlives that child — every later process under the same host app
|
|
29
|
+
* reads without a dialog. Killing the child at the deadline takes neither
|
|
30
|
+
* the dialog nor the grant away: a human who answers Allow afterwards still
|
|
31
|
+
* grants the host app, and the next run witnesses it.
|
|
32
|
+
*
|
|
33
|
+
* MEASURED: FDA does NOT take effect for a running app. macOS says so in
|
|
34
|
+
* the Settings sheet itself — "…will not have full disk access until it is
|
|
35
|
+
* quit", with Later / Quit & Reopen — and the responsible process keeps its
|
|
36
|
+
* old answer for its whole life, so every child it spawns, including a
|
|
37
|
+
* rerun of `things` in the same window, still sees no FDA. There is
|
|
38
|
+
* therefore nothing for this leg to wait for: it deep-links Settings, says
|
|
39
|
+
* what to flip and that the app must relaunch, leaves the leg PENDING, and
|
|
40
|
+
* lets the remaining legs run — Automation and the shortcuts land fine in
|
|
41
|
+
* this session, so the rerun after the relaunch has only this leg left.
|
|
42
|
+
* b. APP CONTROL. When macOS has no Automation record for the host app, the
|
|
43
|
+
* only way to mint one is to send a real Apple Event, which is what raises
|
|
44
|
+
* the dialog. Inside a ceremony that is exactly right. A recorded refusal
|
|
45
|
+
* is NOT re-asked (macOS will not show it again); the copy names both the
|
|
46
|
+
* Settings toggle and the `tccutil` re-arm and leaves the choice to the human.
|
|
47
|
+
* c. SHORTCUTS. The bundled proxies that carry the operations no other surface
|
|
48
|
+
* can perform. An install sheet per missing shortcut; no macOS consent.
|
|
49
|
+
* d. THINGS URLS. The odd one out: not a macOS grant at all, but Things' own
|
|
50
|
+
* Settings ▸ General ▸ "Enable Things URLs". It raises nothing and cannot be
|
|
51
|
+
* flipped from here, so this leg only DETECTS and instructs — and it earns
|
|
52
|
+
* its place because the off state is otherwise invisible: the app parks
|
|
53
|
+
* every URL command in an alert on its own window rather than running it
|
|
54
|
+
* (URLEN1, #611), which a fresh install hits on its very first write.
|
|
55
|
+
*
|
|
56
|
+
* There is deliberately NO Accessibility leg: GUI-driving is helpers-only
|
|
57
|
+
* (Article IV), so when `ui.enabled` is set this ceremony says so and points at
|
|
58
|
+
* `things helpers setup --gui` rather than raising an AX prompt against the
|
|
59
|
+
* terminal.
|
|
60
|
+
*
|
|
61
|
+
* Every leg is IDEMPOTENT and RESUMABLE: an already-satisfied leg is detected
|
|
62
|
+
* prompt-free and skipped, so a rerun on a settled machine raises nothing and
|
|
63
|
+
* reports all-green. A leg left outstanding exits nonzero naming what remains.
|
|
64
|
+
*
|
|
65
|
+
* Mode-aware (Article V): off a TTY this is STRICT mode — an upfront banner
|
|
66
|
+
* counting the dialogs, bounded waits, and an unanswered leg failing the run.
|
|
67
|
+
* At a TTY the same legs run as a guided WIZARD: each dialog is explained in
|
|
68
|
+
* the words macOS will use before it is raised, and the ceremony waits for the
|
|
69
|
+
* human between legs (./wizard.ts). TTY-ness is the only signal, and it is read
|
|
70
|
+
* inside this ceremony only.
|
|
71
|
+
*/
|
|
72
|
+
import { execFileSync } from "node:child_process";
|
|
73
|
+
import { existsSync } from "node:fs";
|
|
74
|
+
import { join } from "node:path";
|
|
75
|
+
import { fileURLToPath } from "node:url";
|
|
76
|
+
import { hostApp, hostDisplayName, readCapability, urlSchemeCapability, writeCapability, } from "./capability.js";
|
|
77
|
+
import { loadConfig } from "./config.js";
|
|
78
|
+
import { locateThingsDb } from "./db/locate.js";
|
|
79
|
+
import { clearSessionGrant, witnessSessionGrant } from "./session-grant.js";
|
|
80
|
+
import { createWizard, withDefaultInterrupts } from "./wizard.js";
|
|
81
|
+
import { readShortcutProxies } from "./write/availability.js";
|
|
82
|
+
/** Deep link to the Full Disk Access pane. */
|
|
83
|
+
const FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
|
84
|
+
/** Deep link to the Automation pane. */
|
|
85
|
+
const AUTOMATION_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation";
|
|
86
|
+
/** Package root — one level above src/ AND dist/, so both layouts resolve. */
|
|
87
|
+
const SHORTCUTS_DIR = fileURLToPath(new URL("../shortcuts", import.meta.url));
|
|
88
|
+
const AUTOMATION_TIMEOUT_MS = 60_000;
|
|
89
|
+
/** How long the read leg waits for the app-data dialog before giving up. */
|
|
90
|
+
const CONTAINER_OPEN_TIMEOUT_MS = 60_000;
|
|
91
|
+
/**
|
|
92
|
+
* The bounded child's whole job: one `open(2)` against the container, then
|
|
93
|
+
* exit. A dynamic import so the same source runs whether node evaluates `-e`
|
|
94
|
+
* as CommonJS or as an ES module; the failure path writes ONLY the errno
|
|
95
|
+
* message, because an unhandled rejection would dump a stack trace whose first
|
|
96
|
+
* line is a node internal rather than the reason.
|
|
97
|
+
*/
|
|
98
|
+
const CONTAINER_OPEN_CHILD = 'import("node:fs").then((fs) => fs.closeSync(fs.openSync(process.argv[1], "r")))' +
|
|
99
|
+
".catch((e) => { process.stderr.write(String((e && e.message) || e)); process.exit(1); });";
|
|
100
|
+
/**
|
|
101
|
+
* The deadline passed with the app-data dialog still unanswered. Distinct from
|
|
102
|
+
* a refusal: nothing was decided, the dialog is still on screen, and answering
|
|
103
|
+
* it later still lands the grant on the host app (APDP1 stage A).
|
|
104
|
+
*/
|
|
105
|
+
export class ContainerOpenTimedOut extends Error {
|
|
106
|
+
timeoutMs;
|
|
107
|
+
constructor(timeoutMs) {
|
|
108
|
+
super(`the app-data dialog was not answered within ${Math.round(timeoutMs / 1000)}s`);
|
|
109
|
+
this.name = "ContainerOpenTimedOut";
|
|
110
|
+
this.timeoutMs = timeoutMs;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** The key that picks Full Disk Access over the session grant at the read leg. */
|
|
114
|
+
const FDA_CHOICE_KEY = "f";
|
|
115
|
+
function openUrlBestEffort(url) {
|
|
116
|
+
try {
|
|
117
|
+
execFileSync("open", [url], { stdio: "ignore", timeout: 10_000 });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// The deep link is a convenience; the written path works without it.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** First line of whatever a failed child wrote to stderr. */
|
|
124
|
+
function firstStderrLine(err) {
|
|
125
|
+
const e = err;
|
|
126
|
+
const text = typeof e.stderr === "string"
|
|
127
|
+
? e.stderr
|
|
128
|
+
: Buffer.isBuffer(e.stderr)
|
|
129
|
+
? e.stderr.toString("utf8")
|
|
130
|
+
: err instanceof Error
|
|
131
|
+
? err.message
|
|
132
|
+
: String(err);
|
|
133
|
+
return text.trim().split("\n")[0] ?? "";
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The container open the ceremony performs on purpose. This is the ONE place
|
|
137
|
+
* in the package permitted to do it blind — everywhere else the doctrine's
|
|
138
|
+
* Article I corollary forbids it, because the open is what raises the modal.
|
|
139
|
+
*
|
|
140
|
+
* It runs in a CHILD process so the wait has a deadline (see the read-access
|
|
141
|
+
* note at the top of this file): macOS holds the open in the kernel for as long
|
|
142
|
+
* as the dialog stands, and the grant it lands belongs to the host app rather
|
|
143
|
+
* than to the pid that asked, so nothing is lost by giving up on the child.
|
|
144
|
+
*/
|
|
145
|
+
function openContainerDefault(timeoutMs) {
|
|
146
|
+
const path = locateThingsDb().path;
|
|
147
|
+
try {
|
|
148
|
+
execFileSync(process.execPath, ["-e", CONTAINER_OPEN_CHILD, path], {
|
|
149
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
150
|
+
timeout: timeoutMs,
|
|
151
|
+
killSignal: "SIGKILL",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const e = err;
|
|
156
|
+
// spawnSync reports a deadline kill as ETIMEDOUT, as `killed`, or as the
|
|
157
|
+
// kill signal itself, depending on where it noticed — all three mean the
|
|
158
|
+
// dialog outlived the wait.
|
|
159
|
+
if (e.code === "ETIMEDOUT" || e.killed === true || e.signal === "SIGKILL") {
|
|
160
|
+
throw new ContainerOpenTimedOut(timeoutMs);
|
|
161
|
+
}
|
|
162
|
+
throw new Error(firstStderrLine(err) || "the container could not be opened", { cause: err });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function sendAutomationProbeDefault(timeoutMs) {
|
|
166
|
+
// `count of areas` dispatches a REAL Apple event. Properties like `version`
|
|
167
|
+
// are answered locally from the target's bundle, mint no grant, and still
|
|
168
|
+
// exit 0 — a false positive that has shipped here before (see the helpers'
|
|
169
|
+
// automationLeg). Never weaken this script.
|
|
170
|
+
execFileSync("osascript", ["-e", 'tell application "Things3" to count of areas'], {
|
|
171
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
172
|
+
timeout: timeoutMs,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// ── Leg (a): read access ─────────────────────────────────────────────────────
|
|
176
|
+
/**
|
|
177
|
+
* The two ways to read, offered as a choice rather than a ranking. Enter takes
|
|
178
|
+
* the session grant; `f` takes Full Disk Access, which is the wider grant and
|
|
179
|
+
* the one that costs the human a relaunch.
|
|
180
|
+
*
|
|
181
|
+
* The Enter line states what the session grant actually buys, as MEASURED
|
|
182
|
+
* (APDP1): one Allow covers every process under this host app — this window,
|
|
183
|
+
* other tabs and windows, and anything they spawn — until the app quits. Under
|
|
184
|
+
* tmux that app is the one that first started the tmux server rather than
|
|
185
|
+
* whatever window you are attached from, because macOS fixes responsibility at
|
|
186
|
+
* spawn and it survives re-parenting; the caveat is printed only when `TMUX`
|
|
187
|
+
* says so, and nothing is detected to produce it.
|
|
188
|
+
*/
|
|
189
|
+
function readAccessChoice(hostName, env) {
|
|
190
|
+
const lines = [
|
|
191
|
+
"Next: read access to your Things data — two ways:",
|
|
192
|
+
` Enter allow while ${hostName} runs: one dialog now, then every command under`,
|
|
193
|
+
` ${hostName} — any tab, window, or agent it spawns — reads without asking,`,
|
|
194
|
+
` until ${hostName} quits`,
|
|
195
|
+
` f Full Disk Access: durable, but grants ${hostName} broad file access —`,
|
|
196
|
+
` flip it in System Settings, then ${hostName} must quit and reopen`,
|
|
197
|
+
];
|
|
198
|
+
if ((env["TMUX"] ?? "") !== "") {
|
|
199
|
+
lines.push(` note: inside tmux the grant belongs to the app that started the tmux server, and`, ` lasts until THAT app quits — not the window you are attached from`);
|
|
200
|
+
}
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The `f` branch. Nothing here waits: FDA reaches a process only through a
|
|
205
|
+
* relaunch, so the leg hands over the three steps and goes pending. The
|
|
206
|
+
* remaining legs still run — their grants land in THIS session — so the rerun
|
|
207
|
+
* after the relaunch has only this one left.
|
|
208
|
+
*/
|
|
209
|
+
function fdaBranch(base, hostName, deps) {
|
|
210
|
+
const progress = deps.progress ?? (() => { });
|
|
211
|
+
(deps.openUrl ?? openUrlBestEffort)(FDA_SETTINGS_URL);
|
|
212
|
+
progress(`read access: 1. turn on ${hostName} under System Settings ▸ Privacy & Security ▸ Full Disk Access`);
|
|
213
|
+
progress(`read access: 2. click "Quit & Reopen" when macOS offers it, or quit ${hostName} yourself`);
|
|
214
|
+
progress("read access: 3. run `things setup` again in the new window to confirm it");
|
|
215
|
+
progress("read access: the rest of the setup continues now — those grants land in this session");
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
state: "pending",
|
|
219
|
+
alreadySatisfied: false,
|
|
220
|
+
detail: `Full Disk Access takes effect after ${hostName} relaunches — turn it on, quit and ` +
|
|
221
|
+
`reopen ${hostName}, then rerun \`things setup\``,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The default branch: provoke the app-data modal on purpose and record the
|
|
226
|
+
* grant only if the open then actually succeeded. The wait is BOUNDED — the
|
|
227
|
+
* open runs in a child process the ceremony can give up on, and giving up
|
|
228
|
+
* costs nothing, because a dialog answered afterwards still grants the host
|
|
229
|
+
* app (APDP1).
|
|
230
|
+
*/
|
|
231
|
+
function sessionGrantBranch(base, hostName, deps) {
|
|
232
|
+
const progress = deps.progress ?? (() => { });
|
|
233
|
+
progress(`read access: asking now — a dialog asks whether ${hostName} may access data from ` +
|
|
234
|
+
"other apps; click Allow");
|
|
235
|
+
const host = hostApp(deps);
|
|
236
|
+
try {
|
|
237
|
+
(deps.openContainer ?? openContainerDefault)(deps.containerOpenTimeoutMs ?? CONTAINER_OPEN_TIMEOUT_MS);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
clearSessionGrant(deps.env ?? process.env);
|
|
241
|
+
if (err instanceof ContainerOpenTimedOut) {
|
|
242
|
+
// Nothing was decided and the dialog is still up: say so, because the
|
|
243
|
+
// human's next click still lands the grant on this same host app.
|
|
244
|
+
progress("read access: no answer yet — the dialog is still on screen");
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
state: "pending",
|
|
248
|
+
alreadySatisfied: false,
|
|
249
|
+
detail: `the dialog is still waiting — click Allow and rerun \`things setup\` to confirm it, ` +
|
|
250
|
+
"choose Full Disk Access at the read step instead, or run `things helpers setup` to " +
|
|
251
|
+
"let a helper hold the grant",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
255
|
+
progress(`read access: still no access — ${why}`);
|
|
256
|
+
return {
|
|
257
|
+
...base,
|
|
258
|
+
state: "pending",
|
|
259
|
+
alreadySatisfied: false,
|
|
260
|
+
// The open failed. Usually that is a Don't Allow — which then stands for
|
|
261
|
+
// the whole run of this app, every later open failing instantly with no
|
|
262
|
+
// second dialog (APDP1 stage B) — but the errno could also be something
|
|
263
|
+
// else entirely, so the copy hedges the cause and states the remedy.
|
|
264
|
+
detail: `no read access yet — if the dialog was refused, that answer stands for the rest of ` +
|
|
265
|
+
`this ${hostName} run and macOS will not ask again, so quit and reopen ${hostName} to ` +
|
|
266
|
+
"be asked; or choose Full Disk Access at the read step, or run `things helpers setup` " +
|
|
267
|
+
"to let a helper hold the grant",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const witnessed = witnessSessionGrant(host.bundleId ?? "", deps);
|
|
271
|
+
if (witnessed === null) {
|
|
272
|
+
return {
|
|
273
|
+
...base,
|
|
274
|
+
state: "pending",
|
|
275
|
+
alreadySatisfied: false,
|
|
276
|
+
detail: "the folder opened, but this process has no host application whose lifetime the " +
|
|
277
|
+
"grant could be tied to — choose Full Disk Access instead, or run `things helpers setup`",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
progress(`read access: granted — every command under ${hostName}, in any tab or window, reads ` +
|
|
281
|
+
`without asking until ${hostName} quits`);
|
|
282
|
+
return {
|
|
283
|
+
...base,
|
|
284
|
+
state: "granted",
|
|
285
|
+
alreadySatisfied: false,
|
|
286
|
+
detail: `every command running under ${hostName} — any tab, window, or agent it spawns — may ` +
|
|
287
|
+
`read the Things data folder until ${hostName} quits. Full Disk Access makes it ` +
|
|
288
|
+
"permanent; `things helpers setup` moves it onto a helper that keeps it across restarts",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function readAccessLeg(capability, choice, deps) {
|
|
292
|
+
const base = { leg: "read-access", label: "read access" };
|
|
293
|
+
const progress = deps.progress ?? (() => { });
|
|
294
|
+
const hostName = hostDisplayName(deps);
|
|
295
|
+
if (capability.mode === "helpers") {
|
|
296
|
+
progress("read access: already served by the helpers");
|
|
297
|
+
return {
|
|
298
|
+
...base,
|
|
299
|
+
state: "granted",
|
|
300
|
+
alreadySatisfied: true,
|
|
301
|
+
detail: "the helpers' reader holds the read grant",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (capability.mode === "direct-fda") {
|
|
305
|
+
progress("read access: already granted (Full Disk Access)");
|
|
306
|
+
return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
|
|
307
|
+
}
|
|
308
|
+
if (capability.mode === "session-grant") {
|
|
309
|
+
progress(`read access: already granted — every command under ${hostName} reads without asking ` +
|
|
310
|
+
`until ${hostName} quits`);
|
|
311
|
+
return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
|
|
312
|
+
}
|
|
313
|
+
// Nothing on record: the human's choice decides which grant this leg gathers.
|
|
314
|
+
return choice === FDA_CHOICE_KEY
|
|
315
|
+
? fdaBranch(base, hostName, deps)
|
|
316
|
+
: sessionGrantBranch(base, hostName, deps);
|
|
317
|
+
}
|
|
318
|
+
// ── Leg (b): app control ─────────────────────────────────────────────────────
|
|
319
|
+
function appControlLeg(deps) {
|
|
320
|
+
const base = { leg: "app-control", label: "app control" };
|
|
321
|
+
const progress = deps.progress ?? (() => { });
|
|
322
|
+
// DISPATCH intent: this leg sends Things a real Apple Event a few lines down,
|
|
323
|
+
// and an Apple Event to a closed Things launches it WITH focus steal
|
|
324
|
+
// (A40/A41). Starting it first is both the honest way to read the standing
|
|
325
|
+
// (#617) and the quieter way to raise the dialog.
|
|
326
|
+
const capability = writeCapability({ purpose: "dispatch" }, deps);
|
|
327
|
+
const hostName = hostDisplayName(deps);
|
|
328
|
+
if (capability.mode === "deputy") {
|
|
329
|
+
progress("app control: already held by the helpers");
|
|
330
|
+
return {
|
|
331
|
+
...base,
|
|
332
|
+
state: "granted",
|
|
333
|
+
alreadySatisfied: true,
|
|
334
|
+
detail: "the deputy holds app control for Things",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (capability.mode === "direct-granted") {
|
|
338
|
+
progress("app control: already granted");
|
|
339
|
+
return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
|
|
340
|
+
}
|
|
341
|
+
if (capability.mode === "direct-denied") {
|
|
342
|
+
// macOS will not show this dialog again — re-asking is not an option, so
|
|
343
|
+
// the leg reports the two things a human can actually do.
|
|
344
|
+
progress("app control: refused earlier — macOS will not ask again");
|
|
345
|
+
return {
|
|
346
|
+
...base,
|
|
347
|
+
state: "denied",
|
|
348
|
+
alreadySatisfied: false,
|
|
349
|
+
detail: capability.remediation.join("; "),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
progress("app control: asking now — answer the dialog if one appears");
|
|
353
|
+
try {
|
|
354
|
+
(deps.sendAutomationProbe ?? sendAutomationProbeDefault)(deps.automationTimeoutMs ?? AUTOMATION_TIMEOUT_MS);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
const e = err;
|
|
358
|
+
const stderr = typeof e.stderr === "string"
|
|
359
|
+
? e.stderr
|
|
360
|
+
: Buffer.isBuffer(e.stderr)
|
|
361
|
+
? e.stderr.toString("utf8")
|
|
362
|
+
: err instanceof Error
|
|
363
|
+
? err.message
|
|
364
|
+
: String(err);
|
|
365
|
+
if (/-1743/.test(stderr)) {
|
|
366
|
+
progress("app control: refused");
|
|
367
|
+
(deps.openUrl ?? openUrlBestEffort)(AUTOMATION_SETTINGS_URL);
|
|
368
|
+
return {
|
|
369
|
+
...base,
|
|
370
|
+
state: "denied",
|
|
371
|
+
alreadySatisfied: false,
|
|
372
|
+
detail: `turn on Things3 for ${hostName} under System Settings ▸ Privacy & Security ▸ ` +
|
|
373
|
+
`Automation, or re-arm the request with \`tccutil reset AppleEvents ${hostApp(deps).bundleId ?? "<host app>"}\`, then rerun`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (e.killed === true || /-1712|event timed out/i.test(stderr)) {
|
|
377
|
+
progress("app control: still waiting on the dialog");
|
|
378
|
+
return {
|
|
379
|
+
...base,
|
|
380
|
+
state: "pending",
|
|
381
|
+
alreadySatisfied: false,
|
|
382
|
+
detail: "the dialog was not answered — answer it and rerun",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
progress(`app control: no grant yet (${stderr.trim().split("\n")[0] ?? "unknown error"})`);
|
|
386
|
+
return {
|
|
387
|
+
...base,
|
|
388
|
+
state: "pending",
|
|
389
|
+
alreadySatisfied: false,
|
|
390
|
+
detail: stderr.trim().split("\n")[0] ?? "the request did not complete",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// A zero exit is not believed on its own: re-read what macOS actually records.
|
|
394
|
+
const after = writeCapability({ purpose: "dispatch" }, deps);
|
|
395
|
+
if (after.mode === "direct-granted" || after.mode === "deputy") {
|
|
396
|
+
progress("app control: granted");
|
|
397
|
+
return { ...base, state: "granted", alreadySatisfied: false, detail: "granted" };
|
|
398
|
+
}
|
|
399
|
+
progress("app control: the request ran but macOS records no grant yet");
|
|
400
|
+
return {
|
|
401
|
+
...base,
|
|
402
|
+
state: "pending",
|
|
403
|
+
alreadySatisfied: false,
|
|
404
|
+
detail: `${after.detail} — rerun, or turn Things3 on for ${hostName} under System Settings ▸ Privacy & Security ▸ Automation`,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// ── Leg (c): shortcuts ───────────────────────────────────────────────────────
|
|
408
|
+
function shortcutsLeg(deps) {
|
|
409
|
+
const base = { leg: "shortcuts", label: "shortcuts" };
|
|
410
|
+
const progress = deps.progress ?? (() => { });
|
|
411
|
+
const state = (deps.shortcutProxies ?? readShortcutProxies)();
|
|
412
|
+
if (state.missing.length === 0) {
|
|
413
|
+
progress("shortcuts: all installed");
|
|
414
|
+
return {
|
|
415
|
+
...base,
|
|
416
|
+
state: "granted",
|
|
417
|
+
alreadySatisfied: true,
|
|
418
|
+
detail: `${state.present.length} installed`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const opened = [];
|
|
422
|
+
const failures = [];
|
|
423
|
+
for (const name of state.missing) {
|
|
424
|
+
const file = join(SHORTCUTS_DIR, `${name}.shortcut`);
|
|
425
|
+
if (!existsSync(file)) {
|
|
426
|
+
failures.push(`${name} (file missing from the package)`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
(deps.openShortcut ?? ((f) => execFileSync("open", [f], { timeout: 10_000 })))(file);
|
|
431
|
+
opened.push(name);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
failures.push(name);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (opened.length > 0) {
|
|
438
|
+
progress(`shortcuts: opened ${opened.length} install sheet${opened.length === 1 ? "" : "s"} — ` +
|
|
439
|
+
'click "Add Shortcut" on each, then "Always Allow" the first time each one runs');
|
|
440
|
+
}
|
|
441
|
+
if (failures.length > 0)
|
|
442
|
+
progress(`shortcuts: could not open ${failures.join(", ")}`);
|
|
443
|
+
return {
|
|
444
|
+
...base,
|
|
445
|
+
state: "pending",
|
|
446
|
+
alreadySatisfied: false,
|
|
447
|
+
detail: opened.length > 0
|
|
448
|
+
? `${opened.length} install sheet${opened.length === 1 ? "" : "s"} opened — click “Add Shortcut” on each, then rerun`
|
|
449
|
+
: `could not open: ${failures.join(", ")}`,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
// ── Leg (d): Things' own URL authorization ───────────────────────────────────
|
|
453
|
+
/**
|
|
454
|
+
* The one leg that raises NOTHING. Things' "Enable Things URLs" is the app's own
|
|
455
|
+
* switch, not a macOS grant: there is no dialog for this ceremony to provoke and
|
|
456
|
+
* nothing it can flip on the human's behalf. What it can do is SAY SO — because
|
|
457
|
+
* the state is silent from every other angle. MEASURED (URLEN1): with the
|
|
458
|
+
* setting off, or never answered, the app puts a "Things URL Scheme" alert on
|
|
459
|
+
* its own window and PARKS the command behind it, so an unattended write reads
|
|
460
|
+
* as a silent no-op minutes later (#611). A fresh install has never answered it,
|
|
461
|
+
* which is precisely when someone is running this ceremony.
|
|
462
|
+
*
|
|
463
|
+
* So the leg reports `pending` and names the toggle; the closing line then
|
|
464
|
+
* carries it as outstanding work and a rerun re-checks it. Settings panes are
|
|
465
|
+
* not deep-linkable the way System Settings panes are — `things:///preferences`
|
|
466
|
+
* is not a route (RESID1) — so the copy names the path instead of opening it.
|
|
467
|
+
*/
|
|
468
|
+
function urlSchemeLeg(deps) {
|
|
469
|
+
const base = { leg: "url-scheme", label: "Things URLs" };
|
|
470
|
+
const progress = deps.progress ?? (() => { });
|
|
471
|
+
const standing = (deps.urlSchemeStanding ?? (() => urlSchemeCapability(deps)))();
|
|
472
|
+
if (standing.mode === "enabled") {
|
|
473
|
+
progress("things urls: on");
|
|
474
|
+
return { ...base, state: "granted", alreadySatisfied: true, detail: standing.detail };
|
|
475
|
+
}
|
|
476
|
+
if (standing.mode === "unreadable") {
|
|
477
|
+
// Nothing to report and nothing to ask for: this machine cannot see the
|
|
478
|
+
// setting, so claiming it is unset would be a guess.
|
|
479
|
+
return { ...base, state: "skipped", alreadySatisfied: false, detail: standing.detail };
|
|
480
|
+
}
|
|
481
|
+
progress("things urls: OFF — turn on Things ▸ Settings ▸ General ▸ Enable Things URLs, then rerun. " +
|
|
482
|
+
"Until it is on, Things holds URL commands in an alert on its own window instead of " +
|
|
483
|
+
"running them");
|
|
484
|
+
return { ...base, state: "pending", alreadySatisfied: false, detail: standing.detail };
|
|
485
|
+
}
|
|
486
|
+
// ── The ceremony ─────────────────────────────────────────────────────────────
|
|
487
|
+
/**
|
|
488
|
+
* What each leg puts on screen, for the upfront banner. Named flatly, in the
|
|
489
|
+
* order the ceremony raises them — one clause each, no parenthetical asides.
|
|
490
|
+
*/
|
|
491
|
+
function promptLabel(leg, hostName) {
|
|
492
|
+
switch (leg) {
|
|
493
|
+
case "read-access":
|
|
494
|
+
return `data access for ${hostName}`;
|
|
495
|
+
case "app-control":
|
|
496
|
+
return "app control of Things";
|
|
497
|
+
case "shortcuts":
|
|
498
|
+
return "one install sheet per missing shortcut";
|
|
499
|
+
case "url-scheme":
|
|
500
|
+
// Unreachable in practice: this leg raises nothing, so it is never put in
|
|
501
|
+
// `outstanding` — the list this function labels. The case exists because
|
|
502
|
+
// the union must be covered, not because anything prints it.
|
|
503
|
+
return "nothing — the Things URLs setting is flipped by hand";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** "a", "a and b", "a, b, and c" — the banner reads as a sentence. */
|
|
507
|
+
function listPhrase(items) {
|
|
508
|
+
if (items.length <= 1)
|
|
509
|
+
return items[0] ?? "";
|
|
510
|
+
if (items.length === 2)
|
|
511
|
+
return `${items[0]} and ${items[1]}`;
|
|
512
|
+
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* What the human is about to see, in the words macOS will actually use — the
|
|
516
|
+
* wizard prints these one leg ahead of the dialog (Article V, mode-aware). In
|
|
517
|
+
* strict mode they are never printed; the upfront banner's count stands alone.
|
|
518
|
+
* `{host}` is filled with the detected host app's display name. The read leg is
|
|
519
|
+
* absent because it is a CHOICE rather than an announcement — see
|
|
520
|
+
* {@link readAccessChoice}; the url-scheme leg is absent because it raises
|
|
521
|
+
* nothing there is anything to explain in advance of.
|
|
522
|
+
*/
|
|
523
|
+
const PROMPT_EXPLAINERS = {
|
|
524
|
+
"app-control": [
|
|
525
|
+
"Next: permission to control the Things app.",
|
|
526
|
+
' A macOS dialog will appear: "{host}" wants access to control "Things" — click Allow.',
|
|
527
|
+
" Things opens if it was closed; that is expected.",
|
|
528
|
+
],
|
|
529
|
+
shortcuts: [
|
|
530
|
+
"Next: the bundled shortcuts.",
|
|
531
|
+
' One Shortcuts install sheet opens per missing shortcut — click "Add Shortcut" on each.',
|
|
532
|
+
' The first time each one runs, Shortcuts asks once more; choose "Always Allow".',
|
|
533
|
+
],
|
|
534
|
+
};
|
|
535
|
+
function closingLine(steps, uiEnabled) {
|
|
536
|
+
const denied = steps.filter((s) => s.state === "denied");
|
|
537
|
+
const pending = steps.filter((s) => s.state === "pending");
|
|
538
|
+
const guiHint = uiEnabled
|
|
539
|
+
? " Some features drive the Things window; that needs the helpers — `things helpers setup --gui`."
|
|
540
|
+
: "";
|
|
541
|
+
if (denied.length > 0) {
|
|
542
|
+
return (`${denied.map((s) => s.label).join(", ")} ${denied.length === 1 ? "was" : "were"} refused — ` +
|
|
543
|
+
`macOS will not ask again, so the remedy above has to be done by hand.${guiHint}`);
|
|
544
|
+
}
|
|
545
|
+
if (pending.length > 0) {
|
|
546
|
+
return `still outstanding: ${pending.map((s) => s.label).join(", ")}. Finish those and rerun \`things setup\` — it resumes exactly here.${guiHint}`;
|
|
547
|
+
}
|
|
548
|
+
return `Everything this machine needs is in place.${guiHint}`;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* What the ceremony would do, established entirely prompt-free. This is the
|
|
552
|
+
* idempotence check (Article V): it is what lets a rerun skip settled legs, and
|
|
553
|
+
* it is what `--dry-run` reports. Raises nothing, ever.
|
|
554
|
+
*
|
|
555
|
+
* `purpose` is the one side effect the survey can be asked for: the ceremony
|
|
556
|
+
* itself passes `dispatch` so a dormant Things is started before the dialogs
|
|
557
|
+
* are COUNTED (#617) — otherwise a machine whose app happens to be closed is
|
|
558
|
+
* told "nothing to raise" and then shown a dialog anyway. `--dry-run` keeps the
|
|
559
|
+
* default and starts nothing at all, at the cost of not knowing where a closed
|
|
560
|
+
* app's grant stands; it says so through the write verdict it reports.
|
|
561
|
+
*/
|
|
562
|
+
export function surveySetup(deps = {}, options = {}) {
|
|
563
|
+
const host = hostApp(deps);
|
|
564
|
+
const read = readCapability({}, deps);
|
|
565
|
+
const write = writeCapability(options, deps);
|
|
566
|
+
const shortcuts = (deps.shortcutProxies ?? readShortcutProxies)();
|
|
567
|
+
const urlScheme = (deps.urlSchemeStanding ?? (() => urlSchemeCapability(deps)))();
|
|
568
|
+
const outstanding = [];
|
|
569
|
+
if (read.mode !== "direct-fda" && read.mode !== "session-grant" && read.mode !== "helpers") {
|
|
570
|
+
outstanding.push("read-access");
|
|
571
|
+
}
|
|
572
|
+
// A recorded refusal raises nothing (the dialog is spent), so it is not counted.
|
|
573
|
+
if (write.mode === "direct-unknown")
|
|
574
|
+
outstanding.push("app-control");
|
|
575
|
+
if (shortcuts.missing.length > 0)
|
|
576
|
+
outstanding.push("shortcuts");
|
|
577
|
+
// The URL-scheme leg is deliberately NOT added to `outstanding`: that list is
|
|
578
|
+
// "dialogs this ceremony is about to raise", and this leg raises none. It is
|
|
579
|
+
// reported as a step and carried by the closing line instead.
|
|
580
|
+
return {
|
|
581
|
+
host: { bundleId: host.bundleId, name: hostDisplayName(deps) },
|
|
582
|
+
read,
|
|
583
|
+
write,
|
|
584
|
+
urlScheme,
|
|
585
|
+
shortcutsMissing: [...shortcuts.missing],
|
|
586
|
+
outstanding,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Run the direct-path ceremony. Strict mode: the banner counts what is about
|
|
591
|
+
* to appear, waits are bounded, and an unanswered leg leaves the run nonzero.
|
|
592
|
+
*
|
|
593
|
+
* Runs under {@link withDefaultInterrupts} for its whole synchronous span, so
|
|
594
|
+
* a Ctrl-C at a gate — or during a leg's bounded wait — actually stops it
|
|
595
|
+
* (./wizard.ts, "Why a ceremony runs with the DEFAULT signal disposition").
|
|
596
|
+
* Throws {@link CeremonyStopped} when the human stops at a gate.
|
|
597
|
+
*/
|
|
598
|
+
export function directSetup(deps = {}) {
|
|
599
|
+
return withDefaultInterrupts(() => runCeremony(deps));
|
|
600
|
+
}
|
|
601
|
+
function runCeremony(deps) {
|
|
602
|
+
const progress = deps.progress ?? ((line) => process.stdout.write(`${line}\n`));
|
|
603
|
+
const withProgress = { ...deps, progress };
|
|
604
|
+
const env = deps.env ?? process.env;
|
|
605
|
+
const host = hostApp(withProgress);
|
|
606
|
+
const hostName = hostDisplayName(withProgress);
|
|
607
|
+
// Survey prompt-free BEFORE raising anything, so whoever started this knows
|
|
608
|
+
// whether they must stay at the screen (Article V).
|
|
609
|
+
const survey = surveySetup(withProgress, { purpose: "dispatch" });
|
|
610
|
+
const readBefore = survey.read;
|
|
611
|
+
const outstanding = survey.outstanding;
|
|
612
|
+
progress(`setting up direct access for ${hostName}${host.bundleId !== null ? ` (${host.bundleId})` : ""}`);
|
|
613
|
+
progress(outstanding.length === 0
|
|
614
|
+
? "nothing to raise — every permission this machine needs is already on record"
|
|
615
|
+
: `about to raise ${outstanding.length} dialog${outstanding.length === 1 ? "" : "s"} — ` +
|
|
616
|
+
`${listPhrase(outstanding.map((leg) => promptLabel(leg, hostName)))}. ` +
|
|
617
|
+
"Someone must be at the screen.");
|
|
618
|
+
const wizard = deps.wizard ?? createWizard();
|
|
619
|
+
const willRaise = new Set(outstanding);
|
|
620
|
+
/** Explain a leg's dialog and let the human pace it — wizard mode only. */
|
|
621
|
+
const brief = (leg) => {
|
|
622
|
+
if (willRaise.has(leg)) {
|
|
623
|
+
wizard.explain(PROMPT_EXPLAINERS[leg].map((line) => line.replaceAll("{host}", hostName)));
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
// The read leg is the one CHOICE in the ceremony: Enter takes the session
|
|
627
|
+
// grant, `f` takes Full Disk Access. Strict mode answers "" without asking,
|
|
628
|
+
// which is the session grant — the only one an absent human can still get.
|
|
629
|
+
const readChoice = willRaise.has("read-access")
|
|
630
|
+
? wizard.choose(readAccessChoice(hostName, env), [FDA_CHOICE_KEY])
|
|
631
|
+
: "";
|
|
632
|
+
const readStep = readAccessLeg(readBefore, readChoice, withProgress);
|
|
633
|
+
brief("app-control");
|
|
634
|
+
const appControlStep = appControlLeg(withProgress);
|
|
635
|
+
brief("shortcuts");
|
|
636
|
+
const steps = [
|
|
637
|
+
readStep,
|
|
638
|
+
appControlStep,
|
|
639
|
+
shortcutsLeg(withProgress),
|
|
640
|
+
// Last, and never briefed: it raises nothing, so there is no dialog to
|
|
641
|
+
// explain one leg ahead (Article V) — it only reports and instructs.
|
|
642
|
+
urlSchemeLeg(withProgress),
|
|
643
|
+
];
|
|
644
|
+
const uiEnabled = loadConfig(env).ui.enabled;
|
|
645
|
+
if (uiEnabled) {
|
|
646
|
+
progress("note: GUI-driving is enabled in config, and it is granted only to the helpers — " +
|
|
647
|
+
"run `things helpers setup --gui` to onboard it");
|
|
648
|
+
}
|
|
649
|
+
return {
|
|
650
|
+
host: { bundleId: host.bundleId, name: hostName },
|
|
651
|
+
steps,
|
|
652
|
+
outstanding,
|
|
653
|
+
denied: steps.some((s) => s.state === "denied"),
|
|
654
|
+
pending: steps.some((s) => s.state === "pending"),
|
|
655
|
+
closing: closingLine(steps, uiEnabled),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
//# sourceMappingURL=direct-setup.js.map
|