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,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* things-reader — the sandboxed file half of the deputy pair.
|
|
3
|
+
*
|
|
4
|
+
* Serves the wire protocol's FILE verbs only (hello / sql / read-file /
|
|
5
|
+
* locate) from inside the App Sandbox, reaching the Things group container
|
|
6
|
+
* exclusively through a user-granted security-scoped bookmark. The scope is
|
|
7
|
+
* enforced by the OS: without the bookmark the sandbox denies the container
|
|
8
|
+
* outright, and nothing in this process can read outside the granted
|
|
9
|
+
* directory (plus its own container home). The unsandboxed things-deputy
|
|
10
|
+
* keeps the automation verbs (osascript / shortcuts).
|
|
11
|
+
*
|
|
12
|
+
* Why this exists (SANDBOX1, docs/lab/sandbox1-scoped-reader.md): the
|
|
13
|
+
* kTCCServiceSystemPolicyAppData class issues allow-once-per-process grants —
|
|
14
|
+
* unusable headlessly — and the only unsandboxed durable alternative is Full
|
|
15
|
+
* Disk Access. A powerbox-granted bookmark is durable across processes,
|
|
16
|
+
* reboots, and rebuilds under the same signing identity, and is scoped to
|
|
17
|
+
* exactly one directory.
|
|
18
|
+
*
|
|
19
|
+
* Ships as a minimal .app bundle (secinit refuses bare executables) signed
|
|
20
|
+
* with a real certificate chain (amfid refuses ad-hoc on sandboxed code).
|
|
21
|
+
*
|
|
22
|
+
* ── The rendezvous lives OUTSIDE this container (helpers 1.3.0) ─────────────
|
|
23
|
+
*
|
|
24
|
+
* A sandboxed process can only bind a socket inside its own container home, so
|
|
25
|
+
* for as long as this process bound its own socket, every CLIENT stat/open of
|
|
26
|
+
* that socket was a cross-app container access — the
|
|
27
|
+
* kTCCServiceSystemPolicyAppData consent class, silent under a Full-Disk-Access
|
|
28
|
+
* host and a modal from anywhere else. That made reader routing per-host, which
|
|
29
|
+
* is the opposite of what the helper pair exists for.
|
|
30
|
+
*
|
|
31
|
+
* So the rendezvous lives OUTSIDE the container, in the user's own state dir:
|
|
32
|
+
*
|
|
33
|
+
* • the SOCKET is bound by THIS process at the host-neutral path injected via
|
|
34
|
+
* THINGS_READER_SOCKET — the sandbox permits it through a
|
|
35
|
+
* home-relative-path temporary-exception entitlement scoped to that one
|
|
36
|
+
* directory. (launchd `Sockets` activation was tried first and FAILS from
|
|
37
|
+
* inside the App Sandbox with error 159 "Sandbox restriction", measured
|
|
38
|
+
* 2026-08-24 — do not re-attempt it.) No fallback paths: without the env
|
|
39
|
+
* this process exits, loudly;
|
|
40
|
+
* • the TOKEN it expects arrives in THINGS_READER_TOKEN, injected into the
|
|
41
|
+
* same plist by the installer, which writes the matching file for clients.
|
|
42
|
+
*
|
|
43
|
+
* What stays in the container is what was always right to keep there: the
|
|
44
|
+
* security-scoped BOOKMARK (the read grant itself, reader-internal, never read
|
|
45
|
+
* by a client) and the audit log.
|
|
46
|
+
*
|
|
47
|
+
* Modes:
|
|
48
|
+
* --grant <startDir> present the NSOpenPanel ceremony, save the bookmark
|
|
49
|
+
* --serve bind and serve the rendezvous socket (default)
|
|
50
|
+
* --version print the version
|
|
51
|
+
*/
|
|
52
|
+
import AppKit
|
|
53
|
+
import Foundation
|
|
54
|
+
|
|
55
|
+
setvbuf(stdout, nil, _IONBF, 0)
|
|
56
|
+
|
|
57
|
+
let READER_PROTOCOL_VERSION = 1
|
|
58
|
+
let MAX_LINE_BYTES = 8 * 1024 * 1024
|
|
59
|
+
let MAX_FILE_READ_BYTES = 64 * 1024 * 1024
|
|
60
|
+
|
|
61
|
+
/// The env var the LaunchAgent carries the expected access token in.
|
|
62
|
+
let READER_TOKEN_ENV = "THINGS_READER_TOKEN"
|
|
63
|
+
|
|
64
|
+
// Sandboxed home == the container Data dir — the durable private store.
|
|
65
|
+
let home = NSHomeDirectory()
|
|
66
|
+
let bookmarkFile = home + "/things-reader.bookmark"
|
|
67
|
+
let logFile = home + "/reader.log"
|
|
68
|
+
// Pre-1.3.0 the socket and token lived here. Only this process can delete them
|
|
69
|
+
// (they are inside its container), so it does — see cleanUpLegacyRendezvous().
|
|
70
|
+
let legacySocketPath = home + "/reader.sock"
|
|
71
|
+
let legacyTokenFile = home + "/token"
|
|
72
|
+
|
|
73
|
+
func stderrLine(_ message: String) {
|
|
74
|
+
FileHandle.standardError.write(Data("things-reader: \(message)\n".utf8))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The rendezvous socket path, injected by the installer into the LaunchAgent.
|
|
79
|
+
* launchd socket activation is NOT usable here — MEASURED 2026-08-24 on this
|
|
80
|
+
* exact bundle: `launch_activate_socket` fails with 159 ("Sandbox restriction")
|
|
81
|
+
* from inside the App Sandbox, so the reader binds the socket ITSELF at a
|
|
82
|
+
* host-neutral path its entitlements grant
|
|
83
|
+
* (the home-relative-path file exception for the rendezvous dir, plus the
|
|
84
|
+
* raw-SBPL `(allow network-bind (subpath "/Users"))` — unix bind is the
|
|
85
|
+
* sandbox's network-bind class, which file exceptions alone do not grant;
|
|
86
|
+
* see entitlements.plist for the measured grammar). Clients reach the socket
|
|
87
|
+
* with no TCC class.
|
|
88
|
+
*/
|
|
89
|
+
let READER_SOCKET_ENV = "THINGS_READER_SOCKET"
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Delete the pre-1.3.0 in-container rendezvous. Those files are dead the moment
|
|
93
|
+
* this build runs — nothing binds or reads them any more — and this process is
|
|
94
|
+
* the ONLY one that can remove them, since they sit in its sandbox container
|
|
95
|
+
* and a client touching that path is the very consent class 1.3.0 escaped.
|
|
96
|
+
* Idempotent; a fresh install finds nothing.
|
|
97
|
+
*/
|
|
98
|
+
func cleanUpLegacyRendezvous() {
|
|
99
|
+
unlink(legacySocketPath)
|
|
100
|
+
unlink(legacyTokenFile)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- grant ceremony ---
|
|
104
|
+
|
|
105
|
+
func runGrant(startDir: String) -> Never {
|
|
106
|
+
let app = NSApplication.shared
|
|
107
|
+
app.setActivationPolicy(.regular)
|
|
108
|
+
let panel = NSOpenPanel()
|
|
109
|
+
panel.canChooseDirectories = true
|
|
110
|
+
panel.canChooseFiles = false
|
|
111
|
+
panel.allowsMultipleSelection = false
|
|
112
|
+
// The CLI passes the Things data folder itself when it exists, so the panel
|
|
113
|
+
// opens INSIDE it and the accept button (which returns the displayed
|
|
114
|
+
// directory when nothing is selected) is the only click needed.
|
|
115
|
+
panel.directoryURL = URL(fileURLWithPath: startDir)
|
|
116
|
+
panel.message =
|
|
117
|
+
"Click \u{201C}Grant read access\u{201D} to give things-reader read-only access to this folder — your Things data."
|
|
118
|
+
panel.prompt = "Grant read access"
|
|
119
|
+
app.activate(ignoringOtherApps: true)
|
|
120
|
+
guard panel.runModal() == .OK, let url = panel.url else {
|
|
121
|
+
print("GRANT-CANCELED")
|
|
122
|
+
exit(1)
|
|
123
|
+
}
|
|
124
|
+
do {
|
|
125
|
+
let bookmark = try url.bookmarkData(
|
|
126
|
+
options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
|
|
127
|
+
try bookmark.write(to: URL(fileURLWithPath: bookmarkFile))
|
|
128
|
+
print("GRANT-OK \(url.path)")
|
|
129
|
+
exit(0)
|
|
130
|
+
} catch {
|
|
131
|
+
print("GRANT-FAILED \(error.localizedDescription)")
|
|
132
|
+
exit(1)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- scope resolution (re-checked per request so a fresh grant — or a
|
|
137
|
+
// corrective RE-grant after selecting the wrong folder — needs no restart) ---
|
|
138
|
+
|
|
139
|
+
final class Scope {
|
|
140
|
+
private let lock = NSLock()
|
|
141
|
+
private var url: URL?
|
|
142
|
+
private var bookmarkBytes: Data?
|
|
143
|
+
|
|
144
|
+
/// Resolve the security scope from the bookmark file, re-resolving whenever
|
|
145
|
+
/// the file's bytes change (a re-grant replaces them). nil = no grant.
|
|
146
|
+
func current() -> URL? {
|
|
147
|
+
lock.lock()
|
|
148
|
+
defer { lock.unlock() }
|
|
149
|
+
guard let data = FileManager.default.contents(atPath: bookmarkFile) else {
|
|
150
|
+
if let old = url {
|
|
151
|
+
old.stopAccessingSecurityScopedResource()
|
|
152
|
+
url = nil
|
|
153
|
+
bookmarkBytes = nil
|
|
154
|
+
}
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
if data == bookmarkBytes { return url }
|
|
158
|
+
var stale = false
|
|
159
|
+
guard
|
|
160
|
+
let resolved = try? URL(
|
|
161
|
+
resolvingBookmarkData: data, options: [.withSecurityScope],
|
|
162
|
+
relativeTo: nil, bookmarkDataIsStale: &stale),
|
|
163
|
+
resolved.startAccessingSecurityScopedResource()
|
|
164
|
+
else { return url }
|
|
165
|
+
if let old = url { old.stopAccessingSecurityScopedResource() }
|
|
166
|
+
var current = data
|
|
167
|
+
if stale,
|
|
168
|
+
let fresh = try? resolved.bookmarkData(
|
|
169
|
+
options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
|
|
170
|
+
{
|
|
171
|
+
try? fresh.write(to: URL(fileURLWithPath: bookmarkFile))
|
|
172
|
+
current = fresh
|
|
173
|
+
}
|
|
174
|
+
url = resolved
|
|
175
|
+
bookmarkBytes = current
|
|
176
|
+
return resolved
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let scope = Scope()
|
|
181
|
+
|
|
182
|
+
// --- server (file verbs only) ---
|
|
183
|
+
|
|
184
|
+
/// How long a shutting-down reader waits for requests already in flight (the
|
|
185
|
+
/// deputy's DRAIN_TIMEOUT_SECONDS; the two halves are booted out together).
|
|
186
|
+
let READER_DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
|
|
187
|
+
|
|
188
|
+
final class ReaderServer {
|
|
189
|
+
let token: String
|
|
190
|
+
let startedAt = Date()
|
|
191
|
+
private let dbQueue = DispatchQueue(label: "things-reader.db")
|
|
192
|
+
private let logQueue = DispatchQueue(label: "things-reader.log")
|
|
193
|
+
private var listenFd: Int32 = -1
|
|
194
|
+
/// Requests between dispatch and their written response; the drain waits on it.
|
|
195
|
+
private let inflightCond = NSCondition()
|
|
196
|
+
private var inflight = 0
|
|
197
|
+
private var reader: SqliteReader? // guarded by dbQueue
|
|
198
|
+
private var readerRoot: String? // scope the open handle belongs to; guarded by dbQueue
|
|
199
|
+
private let cacheLock = NSLock()
|
|
200
|
+
private var cachedDbPath: String?
|
|
201
|
+
|
|
202
|
+
init(token: String) {
|
|
203
|
+
self.token = token
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func readCachedDbPath() -> String? {
|
|
207
|
+
cacheLock.lock()
|
|
208
|
+
defer { cacheLock.unlock() }
|
|
209
|
+
return cachedDbPath
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func storeCachedDbPath(_ path: String) {
|
|
213
|
+
cacheLock.lock()
|
|
214
|
+
cachedDbPath = path
|
|
215
|
+
cacheLock.unlock()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private var socketPath: String = ""
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bind and serve the rendezvous socket at the host-neutral path the
|
|
222
|
+
* installer injected. The sandbox permits the bind through the reader's
|
|
223
|
+
* home-relative-path entitlement; launchd activation is unusable from
|
|
224
|
+
* inside the sandbox (error 159, measured — see the header).
|
|
225
|
+
*/
|
|
226
|
+
func run(socketPath path: String) {
|
|
227
|
+
socketPath = path
|
|
228
|
+
// Boot diagnostic: discriminate a file-rule failure (entitlement form/path
|
|
229
|
+
// wrong) from a bind-rule failure (operation class) when the bind below
|
|
230
|
+
// fails. Removed file, one line, only on failure.
|
|
231
|
+
let probePath = (path as NSString).deletingLastPathComponent + "/.write-probe"
|
|
232
|
+
if FileManager.default.createFile(atPath: probePath, contents: Data("x".utf8)) {
|
|
233
|
+
unlink(probePath)
|
|
234
|
+
} else {
|
|
235
|
+
stderrLine("write-probe FAILED in rendezvous dir — the file entitlement is not covering it")
|
|
236
|
+
}
|
|
237
|
+
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
238
|
+
guard fd >= 0 else {
|
|
239
|
+
stderrLine("socket() errno \(errno)")
|
|
240
|
+
exit(78)
|
|
241
|
+
}
|
|
242
|
+
listenFd = fd
|
|
243
|
+
unlink(path)
|
|
244
|
+
var addr = sockaddr_un()
|
|
245
|
+
addr.sun_family = sa_family_t(AF_UNIX)
|
|
246
|
+
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
|
247
|
+
guard path.utf8.count < capacity else {
|
|
248
|
+
stderrLine("socket path too long: \(path)")
|
|
249
|
+
exit(78)
|
|
250
|
+
}
|
|
251
|
+
_ = withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
252
|
+
path.withCString { cstr in
|
|
253
|
+
strncpy(UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self), cstr, capacity - 1)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let bound = withUnsafePointer(to: &addr) { ptr in
|
|
257
|
+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
258
|
+
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
guard bound == 0 else {
|
|
262
|
+
stderrLine(
|
|
263
|
+
"bind(\(path)) errno \(errno) — the reader's sandbox entitlement must cover this path; run `things helpers setup`")
|
|
264
|
+
exit(78)
|
|
265
|
+
}
|
|
266
|
+
chmod(path, 0o600)
|
|
267
|
+
guard listen(fd, 16) == 0 else {
|
|
268
|
+
stderrLine("listen() errno \(errno)")
|
|
269
|
+
exit(78)
|
|
270
|
+
}
|
|
271
|
+
audit(["event": "started", "pid": Int(getpid()), "version": DEPUTY_VERSION])
|
|
272
|
+
while true {
|
|
273
|
+
let conn = accept(fd, nil, nil)
|
|
274
|
+
if conn < 0 {
|
|
275
|
+
if errno == EINTR { continue }
|
|
276
|
+
break
|
|
277
|
+
}
|
|
278
|
+
var uid: uid_t = 0
|
|
279
|
+
var gid: gid_t = 0
|
|
280
|
+
if getpeereid(conn, &uid, &gid) != 0 || uid != getuid() {
|
|
281
|
+
close(conn)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
let thread = Thread { [weak self] in self?.handleConnection(conn) }
|
|
285
|
+
thread.name = "things-reader.conn"
|
|
286
|
+
thread.start()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Graceful drain, then teardown — the deputy's semantics: unlink the socket
|
|
292
|
+
* FIRST (the deterministic accept-wake, and it stops new clients dialing a
|
|
293
|
+
* dying process), finish in-flight requests within the bound, flush the log.
|
|
294
|
+
* SIGKILL is the hard stop.
|
|
295
|
+
*/
|
|
296
|
+
func drainAndShutdown(timeout: TimeInterval = READER_DRAIN_TIMEOUT_SECONDS) {
|
|
297
|
+
if !socketPath.isEmpty { unlink(socketPath) }
|
|
298
|
+
if listenFd >= 0 {
|
|
299
|
+
Darwin.shutdown(listenFd, SHUT_RDWR)
|
|
300
|
+
close(listenFd)
|
|
301
|
+
listenFd = -1
|
|
302
|
+
}
|
|
303
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
304
|
+
inflightCond.lock()
|
|
305
|
+
while inflight > 0 && Date() < deadline {
|
|
306
|
+
_ = inflightCond.wait(until: deadline)
|
|
307
|
+
}
|
|
308
|
+
let remaining = inflight
|
|
309
|
+
inflightCond.unlock()
|
|
310
|
+
audit(["event": "stopped", "drained": remaining == 0, "inflight": remaining])
|
|
311
|
+
logQueue.sync {}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private func beginRequest() {
|
|
315
|
+
inflightCond.lock()
|
|
316
|
+
inflight += 1
|
|
317
|
+
inflightCond.unlock()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private func endRequest() {
|
|
321
|
+
inflightCond.lock()
|
|
322
|
+
inflight -= 1
|
|
323
|
+
if inflight == 0 { inflightCond.broadcast() }
|
|
324
|
+
inflightCond.unlock()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private func handleConnection(_ conn: Int32) {
|
|
328
|
+
defer { close(conn) }
|
|
329
|
+
var buffer = Data()
|
|
330
|
+
var chunk = [UInt8](repeating: 0, count: 65536)
|
|
331
|
+
while true {
|
|
332
|
+
let n = read(conn, &chunk, chunk.count)
|
|
333
|
+
if n <= 0 { return }
|
|
334
|
+
buffer.append(contentsOf: chunk[0..<n])
|
|
335
|
+
if buffer.count > MAX_LINE_BYTES { return }
|
|
336
|
+
while let nl = buffer.firstIndex(of: 0x0A) {
|
|
337
|
+
let line = buffer.subdata(in: buffer.startIndex..<nl)
|
|
338
|
+
buffer.removeSubrange(buffer.startIndex...nl)
|
|
339
|
+
// In flight from dispatch until the response is on the wire.
|
|
340
|
+
beginRequest()
|
|
341
|
+
let response = dispatch(line: line)
|
|
342
|
+
guard var data = try? JSONSerialization.data(withJSONObject: response) else {
|
|
343
|
+
endRequest()
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
data.append(0x0A)
|
|
347
|
+
let ok = data.withUnsafeBytes { raw -> Bool in
|
|
348
|
+
var offset = 0
|
|
349
|
+
while offset < raw.count {
|
|
350
|
+
let written = write(conn, raw.baseAddress!.advanced(by: offset), raw.count - offset)
|
|
351
|
+
if written <= 0 { return false }
|
|
352
|
+
offset += written
|
|
353
|
+
}
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
endRequest()
|
|
357
|
+
if !ok { return }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private func errorResponse(id: Any?, code: String, message: String) -> [String: Any] {
|
|
363
|
+
["id": id ?? NSNull(), "ok": false, "error": ["code": code, "message": message]]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private func dispatch(line: Data) -> [String: Any] {
|
|
367
|
+
guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] else {
|
|
368
|
+
return errorResponse(id: nil, code: "bad-request", message: "request is not a JSON object")
|
|
369
|
+
}
|
|
370
|
+
let id = obj["id"]
|
|
371
|
+
guard let v = obj["v"] as? Int, v == READER_PROTOCOL_VERSION else {
|
|
372
|
+
return errorResponse(id: id, code: "unsupported-protocol", message: "reader speaks protocol \(READER_PROTOCOL_VERSION)")
|
|
373
|
+
}
|
|
374
|
+
guard let reqToken = obj["token"] as? String, reqToken == token else {
|
|
375
|
+
return errorResponse(id: id, code: "bad-token", message: "request token does not match the reader's access token")
|
|
376
|
+
}
|
|
377
|
+
guard let verb = obj["verb"] as? String else {
|
|
378
|
+
return errorResponse(id: id, code: "bad-request", message: "missing verb")
|
|
379
|
+
}
|
|
380
|
+
let started = Date()
|
|
381
|
+
let result: [String: Any]
|
|
382
|
+
switch verb {
|
|
383
|
+
case "hello":
|
|
384
|
+
result = [
|
|
385
|
+
"id": id ?? NSNull(), "ok": true,
|
|
386
|
+
"protocol": READER_PROTOCOL_VERSION,
|
|
387
|
+
"deputyVersion": DEPUTY_VERSION,
|
|
388
|
+
"role": "reader",
|
|
389
|
+
"pid": Int(getpid()),
|
|
390
|
+
"granted": scope.current() != nil,
|
|
391
|
+
"dbPath": readCachedDbPath() ?? NSNull(),
|
|
392
|
+
"uptimeMs": Int(Date().timeIntervalSince(startedAt) * 1000),
|
|
393
|
+
]
|
|
394
|
+
case "locate":
|
|
395
|
+
result = handleLocate(id: id)
|
|
396
|
+
case "sql":
|
|
397
|
+
guard let sql = obj["sql"] as? String else {
|
|
398
|
+
result = errorResponse(id: id, code: "bad-request", message: "sql verb requires a sql string")
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
let params = obj["params"] as? [Any] ?? []
|
|
402
|
+
result = dbQueue.sync { handleSql(id: id, sql: sql, params: params) }
|
|
403
|
+
case "read-file":
|
|
404
|
+
guard let path = obj["path"] as? String else {
|
|
405
|
+
result = errorResponse(id: id, code: "bad-request", message: "read-file verb requires a path string")
|
|
406
|
+
break
|
|
407
|
+
}
|
|
408
|
+
result = handleReadFile(id: id, path: path)
|
|
409
|
+
default:
|
|
410
|
+
// The automation verbs live on things-deputy — never here, by design.
|
|
411
|
+
result = errorResponse(id: id, code: "unsupported-verb", message: "the reader serves file verbs only (hello/sql/read-file/locate)")
|
|
412
|
+
}
|
|
413
|
+
audit([
|
|
414
|
+
"ts": iso8601Now(), "verb": verb, "ok": (result["ok"] as? Bool) ?? false,
|
|
415
|
+
"ms": Int(Date().timeIntervalSince(started) * 1000),
|
|
416
|
+
])
|
|
417
|
+
return result
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private func scopedRoot(id: Any?) -> (URL?, [String: Any]?) {
|
|
421
|
+
guard let root = scope.current() else {
|
|
422
|
+
return (
|
|
423
|
+
nil,
|
|
424
|
+
errorResponse(
|
|
425
|
+
id: id, code: "not-granted",
|
|
426
|
+
message: "no security-scoped grant yet — run `things helpers setup` and select the Things data folder")
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
return (root, nil)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private func locateCandidates(root: URL) -> [String] {
|
|
433
|
+
let fm = FileManager.default
|
|
434
|
+
guard let entries = try? fm.contentsOfDirectory(atPath: root.path) else { return [] }
|
|
435
|
+
var found: [(String, Date)] = []
|
|
436
|
+
for entry in entries where entry.hasPrefix("ThingsData-") {
|
|
437
|
+
let dbPath = root.path + "/" + entry + "/Things Database.thingsdatabase/main.sqlite"
|
|
438
|
+
if let attrs = try? fm.attributesOfItem(atPath: dbPath),
|
|
439
|
+
let mtime = attrs[.modificationDate] as? Date
|
|
440
|
+
{
|
|
441
|
+
found.append((dbPath, mtime))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return found.sorted { $0.1 > $1.1 }.map { $0.0 }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private func handleLocate(id: Any?) -> [String: Any] {
|
|
448
|
+
let (root, err) = scopedRoot(id: id)
|
|
449
|
+
guard let r = root else { return err! }
|
|
450
|
+
let candidates = locateCandidates(root: r)
|
|
451
|
+
guard let first = candidates.first else {
|
|
452
|
+
return errorResponse(id: id, code: "not-found", message: "Things database not found under the granted folder (\(r.path))")
|
|
453
|
+
}
|
|
454
|
+
storeCachedDbPath(first)
|
|
455
|
+
return ["id": id ?? NSNull(), "ok": true, "path": first, "otherCandidates": Array(candidates.dropFirst())]
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private func handleSql(id: Any?, sql: String, params: [Any]) -> [String: Any] {
|
|
459
|
+
let (root, err) = scopedRoot(id: id)
|
|
460
|
+
guard let r = root else { return err! }
|
|
461
|
+
// A re-grant moves the scope: drop a handle opened under the old root so
|
|
462
|
+
// queries never keep answering from a folder the user has replaced.
|
|
463
|
+
if reader != nil && readerRoot != r.path { reader = nil }
|
|
464
|
+
if reader == nil {
|
|
465
|
+
guard let dbPath = locateCandidates(root: r).first else {
|
|
466
|
+
return errorResponse(id: id, code: "not-found", message: "no Things database under the granted folder")
|
|
467
|
+
}
|
|
468
|
+
do {
|
|
469
|
+
reader = try SqliteReader(path: dbPath)
|
|
470
|
+
readerRoot = r.path
|
|
471
|
+
storeCachedDbPath(dbPath)
|
|
472
|
+
} catch {
|
|
473
|
+
return errorResponse(id: id, code: "sql-error", message: "cannot open \(dbPath) read-only: \(error.localizedDescription)")
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
do {
|
|
477
|
+
let rows = try reader!.query(sql: sql, params: params)
|
|
478
|
+
return ["id": id ?? NSNull(), "ok": true, "rows": rows]
|
|
479
|
+
} catch let err as SqliteError {
|
|
480
|
+
return errorResponse(id: id, code: "sql-error", message: err.message)
|
|
481
|
+
} catch {
|
|
482
|
+
return errorResponse(id: id, code: "sql-error", message: error.localizedDescription)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private func handleReadFile(id: Any?, path: String) -> [String: Any] {
|
|
487
|
+
let (root, err) = scopedRoot(id: id)
|
|
488
|
+
guard let r = root else { return err! }
|
|
489
|
+
let canonical = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path
|
|
490
|
+
let canonicalRoot = r.resolvingSymlinksInPath().standardizedFileURL.path
|
|
491
|
+
guard canonical == canonicalRoot || canonical.hasPrefix(canonicalRoot + "/") else {
|
|
492
|
+
return errorResponse(id: id, code: "read-denied", message: "path is outside the granted folder (\(canonicalRoot))")
|
|
493
|
+
}
|
|
494
|
+
guard let data = FileManager.default.contents(atPath: canonical) else {
|
|
495
|
+
return errorResponse(id: id, code: "not-found", message: "cannot read \(canonical)")
|
|
496
|
+
}
|
|
497
|
+
guard data.count <= MAX_FILE_READ_BYTES else {
|
|
498
|
+
return errorResponse(id: id, code: "too-large", message: "file exceeds \(MAX_FILE_READ_BYTES) bytes")
|
|
499
|
+
}
|
|
500
|
+
return ["id": id ?? NSNull(), "ok": true, "b64": data.base64EncodedString()]
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private func audit(_ entry: [String: Any]) {
|
|
504
|
+
var withTs = entry
|
|
505
|
+
if withTs["ts"] == nil { withTs["ts"] = iso8601Now() }
|
|
506
|
+
guard var data = try? JSONSerialization.data(withJSONObject: withTs) else { return }
|
|
507
|
+
data.append(0x0A)
|
|
508
|
+
logQueue.async {
|
|
509
|
+
if let handle = FileHandle(forWritingAtPath: logFile) {
|
|
510
|
+
handle.seekToEndOfFile()
|
|
511
|
+
handle.write(data)
|
|
512
|
+
try? handle.close()
|
|
513
|
+
} else {
|
|
514
|
+
FileManager.default.createFile(
|
|
515
|
+
atPath: logFile, contents: data, attributes: [.posixPermissions: 0o600])
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
func iso8601Now() -> String {
|
|
522
|
+
ISO8601DateFormatter().string(from: Date())
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- entry ---
|
|
526
|
+
|
|
527
|
+
let arguments = CommandLine.arguments
|
|
528
|
+
switch arguments.count >= 2 ? arguments[1] : "--serve" {
|
|
529
|
+
case "--version":
|
|
530
|
+
print(DEPUTY_VERSION)
|
|
531
|
+
exit(0)
|
|
532
|
+
case "--grant":
|
|
533
|
+
guard arguments.count == 3 else {
|
|
534
|
+
stderrLine("usage: things-reader --grant <start-directory>")
|
|
535
|
+
exit(2)
|
|
536
|
+
}
|
|
537
|
+
runGrant(startDir: arguments[2])
|
|
538
|
+
case "--serve":
|
|
539
|
+
// No fallbacks, deliberately: both the token and the listening socket come
|
|
540
|
+
// from the LaunchAgent, so a --serve outside it is a misconfiguration to
|
|
541
|
+
// surface, never a second code path to keep working. EX_CONFIG (78).
|
|
542
|
+
guard let token = ProcessInfo.processInfo.environment[READER_TOKEN_ENV], !token.isEmpty else {
|
|
543
|
+
stderrLine(
|
|
544
|
+
"\(READER_TOKEN_ENV) is not set — the access token is injected by the reader's LaunchAgent; run `things helpers setup`")
|
|
545
|
+
exit(78)
|
|
546
|
+
}
|
|
547
|
+
guard let socketPath = ProcessInfo.processInfo.environment[READER_SOCKET_ENV],
|
|
548
|
+
!socketPath.isEmpty
|
|
549
|
+
else {
|
|
550
|
+
stderrLine(
|
|
551
|
+
"\(READER_SOCKET_ENV) is not set — the rendezvous path is injected by the reader's LaunchAgent; run `things helpers setup`")
|
|
552
|
+
exit(78)
|
|
553
|
+
}
|
|
554
|
+
// 1.3.0 migration, and only on a real boot — the refusals above must stay
|
|
555
|
+
// side-effect-free, so nothing is deleted until launchd has actually
|
|
556
|
+
// activated us. Idempotent, so paying it every boot costs two failed
|
|
557
|
+
// unlink(2)s.
|
|
558
|
+
cleanUpLegacyRendezvous()
|
|
559
|
+
let server = ReaderServer(token: token)
|
|
560
|
+
signal(SIGPIPE, SIG_IGN)
|
|
561
|
+
signal(SIGTERM, SIG_IGN)
|
|
562
|
+
signal(SIGINT, SIG_IGN)
|
|
563
|
+
let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
564
|
+
let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
565
|
+
for source in [termSource, intSource] {
|
|
566
|
+
source.setEventHandler {
|
|
567
|
+
// Graceful drain: stop accepting, finish in-flight reads within a bound,
|
|
568
|
+
// then exit cleanly (an upgrade boots both halves out mid-flight). The
|
|
569
|
+
// socket itself is launchd's and outlives us.
|
|
570
|
+
server.drainAndShutdown()
|
|
571
|
+
exit(0)
|
|
572
|
+
}
|
|
573
|
+
source.resume()
|
|
574
|
+
}
|
|
575
|
+
Thread.detachNewThread { server.run(socketPath: socketPath) }
|
|
576
|
+
dispatchMain()
|
|
577
|
+
default:
|
|
578
|
+
stderrLine("usage: things-reader --serve | --grant <dir> | --version")
|
|
579
|
+
exit(2)
|
|
580
|
+
}
|