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.
Files changed (277) hide show
  1. package/README.md +16 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -0
  4. package/deputy/helpers-Info.plist +27 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +22 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +128 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +137 -0
  14. package/deputy/reader/Info.plist +22 -0
  15. package/deputy/reader/entitlements.plist +45 -0
  16. package/deputy/reader/main.swift +580 -0
  17. package/deputy/src/main.swift +133 -0
  18. package/deputy/src/osascript.swift +102 -0
  19. package/deputy/src/server.swift +373 -0
  20. package/deputy/src/sqlite.swift +121 -0
  21. package/deputy/src/tcc.swift +58 -0
  22. package/dist/audit/schema.d.ts +13 -1
  23. package/dist/audit/schema.js.map +1 -1
  24. package/dist/capability.d.ts +160 -0
  25. package/dist/capability.js +416 -0
  26. package/dist/capability.js.map +1 -0
  27. package/dist/cli/commands/doctor.js +181 -2
  28. package/dist/cli/commands/doctor.js.map +1 -1
  29. package/dist/cli/commands/helpers.d.ts +17 -0
  30. package/dist/cli/commands/helpers.js +275 -0
  31. package/dist/cli/commands/helpers.js.map +1 -0
  32. package/dist/cli/commands/op-result.d.ts +15 -0
  33. package/dist/cli/commands/op-result.js +45 -0
  34. package/dist/cli/commands/op-result.js.map +1 -0
  35. package/dist/cli/commands/reads.js +47 -1
  36. package/dist/cli/commands/reads.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +63 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +262 -109
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +53 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/helpers-check.d.ts +10 -0
  52. package/dist/cli/helpers-check.js +59 -0
  53. package/dist/cli/helpers-check.js.map +1 -0
  54. package/dist/cli/main.js +22 -1
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +22 -2
  65. package/dist/client.js +52 -15
  66. package/dist/client.js.map +1 -1
  67. package/dist/config.d.ts +31 -0
  68. package/dist/config.js +9 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/contracts.d.ts +6 -2
  71. package/dist/contracts.js +1 -1
  72. package/dist/contracts.js.map +1 -1
  73. package/dist/db/baselines/db-v26.js +8 -4
  74. package/dist/db/baselines/db-v26.js.map +1 -1
  75. package/dist/db/baselines/db-v27.d.ts +42 -0
  76. package/dist/db/baselines/db-v27.js +47 -0
  77. package/dist/db/baselines/db-v27.js.map +1 -0
  78. package/dist/db/baselines/index.js +2 -1
  79. package/dist/db/baselines/index.js.map +1 -1
  80. package/dist/db/locate.d.ts +4 -1
  81. package/dist/db/locate.js +3 -1
  82. package/dist/db/locate.js.map +1 -1
  83. package/dist/db/schema.d.ts +1 -1
  84. package/dist/db/schema.js +9 -0
  85. package/dist/db/schema.js.map +1 -1
  86. package/dist/deputy/bridge-worker.d.ts +1 -0
  87. package/dist/deputy/bridge-worker.js +79 -0
  88. package/dist/deputy/bridge-worker.js.map +1 -0
  89. package/dist/deputy/bridge.d.ts +14 -0
  90. package/dist/deputy/bridge.js +70 -0
  91. package/dist/deputy/bridge.js.map +1 -0
  92. package/dist/deputy/client.d.ts +19 -0
  93. package/dist/deputy/client.js +104 -0
  94. package/dist/deputy/client.js.map +1 -0
  95. package/dist/deputy/db-facade.d.ts +18 -0
  96. package/dist/deputy/db-facade.js +71 -0
  97. package/dist/deputy/db-facade.js.map +1 -0
  98. package/dist/deputy/files.d.ts +1 -0
  99. package/dist/deputy/files.js +16 -0
  100. package/dist/deputy/files.js.map +1 -0
  101. package/dist/deputy/install.d.ts +319 -0
  102. package/dist/deputy/install.js +1239 -0
  103. package/dist/deputy/install.js.map +1 -0
  104. package/dist/deputy/notice.d.ts +6 -0
  105. package/dist/deputy/notice.js +28 -0
  106. package/dist/deputy/notice.js.map +1 -0
  107. package/dist/deputy/notices.d.ts +31 -0
  108. package/dist/deputy/notices.js +94 -0
  109. package/dist/deputy/notices.js.map +1 -0
  110. package/dist/deputy/osa.d.ts +21 -0
  111. package/dist/deputy/osa.js +72 -0
  112. package/dist/deputy/osa.js.map +1 -0
  113. package/dist/deputy/protocol.d.ts +140 -0
  114. package/dist/deputy/protocol.js +143 -0
  115. package/dist/deputy/protocol.js.map +1 -0
  116. package/dist/deputy/routing.d.ts +84 -0
  117. package/dist/deputy/routing.js +422 -0
  118. package/dist/deputy/routing.js.map +1 -0
  119. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  120. package/dist/deputy/shortcuts-exec.js +50 -0
  121. package/dist/deputy/shortcuts-exec.js.map +1 -0
  122. package/dist/diagnose.d.ts +57 -2
  123. package/dist/diagnose.js +105 -3
  124. package/dist/diagnose.js.map +1 -1
  125. package/dist/direct-setup.d.ts +78 -0
  126. package/dist/direct-setup.js +484 -0
  127. package/dist/direct-setup.js.map +1 -0
  128. package/dist/host-access.d.ts +63 -0
  129. package/dist/host-access.js +165 -0
  130. package/dist/host-access.js.map +1 -0
  131. package/dist/index.d.ts +31 -5
  132. package/dist/index.js +23 -1
  133. package/dist/index.js.map +1 -1
  134. package/dist/mcp/server.d.ts +27 -1
  135. package/dist/mcp/server.js +300 -97
  136. package/dist/mcp/server.js.map +1 -1
  137. package/dist/model/mappers.d.ts +12 -3
  138. package/dist/model/mappers.js +6 -1
  139. package/dist/model/mappers.js.map +1 -1
  140. package/dist/model/recurrence.d.ts +5 -1
  141. package/dist/model/recurrence.js +19 -1
  142. package/dist/model/recurrence.js.map +1 -1
  143. package/dist/model/template-projection.d.ts +26 -0
  144. package/dist/model/template-projection.js +113 -0
  145. package/dist/model/template-projection.js.map +1 -0
  146. package/dist/op-result.d.ts +55 -0
  147. package/dist/op-result.js +161 -0
  148. package/dist/op-result.js.map +1 -0
  149. package/dist/read/detail.js +7 -1
  150. package/dist/read/detail.js.map +1 -1
  151. package/dist/read/filter-contract.d.ts +1 -1
  152. package/dist/read/filter-contract.js +4 -0
  153. package/dist/read/filter-contract.js.map +1 -1
  154. package/dist/read/queries.js +8 -1
  155. package/dist/read/queries.js.map +1 -1
  156. package/dist/read/shape.js +4 -0
  157. package/dist/read/shape.js.map +1 -1
  158. package/dist/read/views.d.ts +35 -4
  159. package/dist/read/views.js +120 -18
  160. package/dist/read/views.js.map +1 -1
  161. package/dist/session-grant.d.ts +48 -0
  162. package/dist/session-grant.js +170 -0
  163. package/dist/session-grant.js.map +1 -0
  164. package/dist/surface-copy.d.ts +8 -0
  165. package/dist/surface-copy.js +8 -0
  166. package/dist/surface-copy.js.map +1 -1
  167. package/dist/sync-health.js +6 -2
  168. package/dist/sync-health.js.map +1 -1
  169. package/dist/wizard.d.ts +58 -0
  170. package/dist/wizard.js +167 -0
  171. package/dist/wizard.js.map +1 -0
  172. package/dist/write/accessibility-probe.js +4 -2
  173. package/dist/write/accessibility-probe.js.map +1 -1
  174. package/dist/write/automation-probe.js +5 -1
  175. package/dist/write/automation-probe.js.map +1 -1
  176. package/dist/write/availability.d.ts +1 -1
  177. package/dist/write/availability.js +10 -6
  178. package/dist/write/availability.js.map +1 -1
  179. package/dist/write/batch.d.ts +20 -0
  180. package/dist/write/batch.js +94 -15
  181. package/dist/write/batch.js.map +1 -1
  182. package/dist/write/capabilities.d.ts +9 -0
  183. package/dist/write/capabilities.js +2 -0
  184. package/dist/write/capabilities.js.map +1 -1
  185. package/dist/write/clear-reminder.js +1 -1
  186. package/dist/write/clear-reminder.js.map +1 -1
  187. package/dist/write/commands.d.ts +12 -1
  188. package/dist/write/commands.js +170 -237
  189. package/dist/write/commands.js.map +1 -1
  190. package/dist/write/experimental.d.ts +19 -0
  191. package/dist/write/experimental.js +50 -0
  192. package/dist/write/experimental.js.map +1 -1
  193. package/dist/write/failure-hints.d.ts +3 -1
  194. package/dist/write/failure-hints.js.map +1 -1
  195. package/dist/write/guards.js +19 -2
  196. package/dist/write/guards.js.map +1 -1
  197. package/dist/write/lock.d.ts +16 -13
  198. package/dist/write/lock.js +55 -0
  199. package/dist/write/lock.js.map +1 -1
  200. package/dist/write/make-repeating-project.js +9 -13
  201. package/dist/write/make-repeating-project.js.map +1 -1
  202. package/dist/write/move.js +14 -6
  203. package/dist/write/move.js.map +1 -1
  204. package/dist/write/operations.d.ts +64 -25
  205. package/dist/write/operations.js +7 -0
  206. package/dist/write/operations.js.map +1 -1
  207. package/dist/write/opid.d.ts +14 -0
  208. package/dist/write/opid.js +22 -0
  209. package/dist/write/opid.js.map +1 -1
  210. package/dist/write/param-schema.d.ts +124 -0
  211. package/dist/write/param-schema.js +747 -0
  212. package/dist/write/param-schema.js.map +1 -0
  213. package/dist/write/pipeline.d.ts +78 -2
  214. package/dist/write/pipeline.js +249 -4
  215. package/dist/write/pipeline.js.map +1 -1
  216. package/dist/write/pre-state.d.ts +8 -0
  217. package/dist/write/pre-state.js +62 -17
  218. package/dist/write/pre-state.js.map +1 -1
  219. package/dist/write/promote-clone.js +635 -367
  220. package/dist/write/promote-clone.js.map +1 -1
  221. package/dist/write/reorder.js +95 -29
  222. package/dist/write/reorder.js.map +1 -1
  223. package/dist/write/repeat-anchor.d.ts +30 -0
  224. package/dist/write/repeat-anchor.js +166 -1
  225. package/dist/write/repeat-anchor.js.map +1 -1
  226. package/dist/write/repeat-rule.d.ts +28 -1
  227. package/dist/write/repeat-rule.js +64 -4
  228. package/dist/write/repeat-rule.js.map +1 -1
  229. package/dist/write/resolution-timestamps.d.ts +11 -0
  230. package/dist/write/resolution-timestamps.js +83 -38
  231. package/dist/write/resolution-timestamps.js.map +1 -1
  232. package/dist/write/reversibility.js +4 -0
  233. package/dist/write/reversibility.js.map +1 -1
  234. package/dist/write/template-mutation.d.ts +137 -0
  235. package/dist/write/template-mutation.js +451 -0
  236. package/dist/write/template-mutation.js.map +1 -0
  237. package/dist/write/undo.js +10 -20
  238. package/dist/write/undo.js.map +1 -1
  239. package/dist/write/update-fields.d.ts +151 -0
  240. package/dist/write/update-fields.js +427 -0
  241. package/dist/write/update-fields.js.map +1 -0
  242. package/dist/write/vectors/applescript.js +10 -16
  243. package/dist/write/vectors/applescript.js.map +1 -1
  244. package/dist/write/vectors/registry.d.ts +1 -1
  245. package/dist/write/vectors/registry.js +10 -2
  246. package/dist/write/vectors/registry.js.map +1 -1
  247. package/dist/write/vectors/session-reachability.d.ts +55 -0
  248. package/dist/write/vectors/session-reachability.js +56 -8
  249. package/dist/write/vectors/session-reachability.js.map +1 -1
  250. package/dist/write/vectors/shortcuts.js +7 -16
  251. package/dist/write/vectors/shortcuts.js.map +1 -1
  252. package/dist/write/vectors/simulator.d.ts +16 -1
  253. package/dist/write/vectors/simulator.js +68 -12
  254. package/dist/write/vectors/simulator.js.map +1 -1
  255. package/dist/write/vectors/types.d.ts +124 -1
  256. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  257. package/dist/write/vectors/ui-recipes.js +158 -38
  258. package/dist/write/vectors/ui-recipes.js.map +1 -1
  259. package/dist/write/vectors/ui.d.ts +89 -0
  260. package/dist/write/vectors/ui.js +504 -49
  261. package/dist/write/vectors/ui.js.map +1 -1
  262. package/dist/write/verify/delta.d.ts +21 -0
  263. package/dist/write/verify/delta.js.map +1 -1
  264. package/dist/write/verify/poller.d.ts +13 -0
  265. package/dist/write/verify/poller.js +38 -0
  266. package/dist/write/verify/poller.js.map +1 -1
  267. package/package.json +9 -1
  268. package/schema/envelope.schema.json +5 -3
  269. package/scripts/build-helpers.sh +109 -0
  270. package/scripts/deputy-cert-setup.sh +60 -0
  271. package/skills/things-cli/SKILL.md +12 -5
  272. package/skills/things-cli/references/contracts.md +1 -0
  273. package/skills/things-cli/references/data-model.md +3 -0
  274. package/skills/things-cli/references/errors.md +5 -1
  275. package/skills/things-cli/references/gui.md +1 -0
  276. package/skills/things-cli/references/repeating.md +75 -0
  277. 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
+ }