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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* things-deputy — the TCC permission broker for the things CLI.
|
|
3
|
+
*
|
|
4
|
+
* A deliberately dumb, launchd-supervised proxy: it executes read-only SQL
|
|
5
|
+
* against the Things database, runs osascript with fixed argument shapes, and
|
|
6
|
+
* reads files inside the Things group container — nothing else. All product
|
|
7
|
+
* logic (validation, guards, verification, audit) stays in the TypeScript
|
|
8
|
+
* library; the deputy exists solely so macOS TCC grants (Automation,
|
|
9
|
+
* Accessibility, group-container reads) attach to ONE stable signed identity
|
|
10
|
+
* instead of whichever agent harness happens to invoke the CLI this week.
|
|
11
|
+
*
|
|
12
|
+
* Security posture (docs/design/agent-daemon.md §β1):
|
|
13
|
+
* - UNIX socket + token file live in a 0700 state dir; socket and token are 0600.
|
|
14
|
+
* - Every connection is peer-checked (same-UID) and every request carries the token.
|
|
15
|
+
* - SQL runs on a SQLITE_OPEN_READONLY connection with ATTACH denied by an
|
|
16
|
+
* authorizer — the connection cannot write and cannot reach other files.
|
|
17
|
+
* - osascript is invoked at a fixed absolute path with exactly two argv shapes
|
|
18
|
+
* (`-e <script>` / `-l JavaScript -e <script>`); the client never supplies argv.
|
|
19
|
+
* - File reads are confined to the resolved Things group container subtree.
|
|
20
|
+
* - Every request lands in a local JSONL audit log (hashes, never content).
|
|
21
|
+
*
|
|
22
|
+
* The deputy never daemonizes itself: launchd (or a test harness) owns the
|
|
23
|
+
* process lifecycle. There is exactly one run mode — foreground.
|
|
24
|
+
*/
|
|
25
|
+
import Foundation
|
|
26
|
+
|
|
27
|
+
let PROTOCOL_VERSION = 1
|
|
28
|
+
let MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
|
29
|
+
let MAX_FILE_READ_BYTES = 64 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
struct DeputyPaths {
|
|
32
|
+
let stateDir: String
|
|
33
|
+
var socket: String { stateDir + "/deputy.sock" }
|
|
34
|
+
var token: String { stateDir + "/token" }
|
|
35
|
+
var log: String { stateDir + "/deputy.log" }
|
|
36
|
+
var config: String { stateDir + "/deputy.json" }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Deputy-local overrides, read once at startup from `<state-dir>/deputy.json`.
|
|
40
|
+
/// The state dir is 0700, so anything able to write this file already owns the
|
|
41
|
+
/// user account; the overrides exist for tests and non-standard installs.
|
|
42
|
+
struct DeputyConfig {
|
|
43
|
+
var osascriptPath: String?
|
|
44
|
+
var shortcutsPath: String?
|
|
45
|
+
|
|
46
|
+
static func load(path: String) -> DeputyConfig {
|
|
47
|
+
var cfg = DeputyConfig()
|
|
48
|
+
guard let data = FileManager.default.contents(atPath: path),
|
|
49
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
50
|
+
else { return cfg }
|
|
51
|
+
cfg.osascriptPath = obj["osascriptPath"] as? String
|
|
52
|
+
cfg.shortcutsPath = obj["shortcutsPath"] as? String
|
|
53
|
+
return cfg
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func defaultStateDir() -> String {
|
|
58
|
+
let env = ProcessInfo.processInfo.environment
|
|
59
|
+
if let explicit = env["THINGS_API_STATE_DIR"], !explicit.isEmpty {
|
|
60
|
+
return explicit + "/deputy"
|
|
61
|
+
}
|
|
62
|
+
let base =
|
|
63
|
+
(env["XDG_STATE_HOME"]).flatMap { $0.isEmpty ? nil : $0 }
|
|
64
|
+
?? NSHomeDirectory() + "/.local/state"
|
|
65
|
+
return base + "/things-api/deputy"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func loadOrCreateToken(path: String) -> String {
|
|
69
|
+
if let data = FileManager.default.contents(atPath: path) {
|
|
70
|
+
let tok = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
71
|
+
if !tok.isEmpty { return tok }
|
|
72
|
+
}
|
|
73
|
+
var bytes = [UInt8](repeating: 0, count: 32)
|
|
74
|
+
for i in 0..<bytes.count { bytes[i] = UInt8.random(in: 0...255) }
|
|
75
|
+
let tok = bytes.map { String(format: "%02x", $0) }.joined()
|
|
76
|
+
FileManager.default.createFile(
|
|
77
|
+
atPath: path, contents: Data(tok.utf8), attributes: [.posixPermissions: 0o600])
|
|
78
|
+
return tok
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- entry ---
|
|
82
|
+
|
|
83
|
+
var stateDirArg: String? = nil
|
|
84
|
+
var argIter = CommandLine.arguments.dropFirst().makeIterator()
|
|
85
|
+
while let arg = argIter.next() {
|
|
86
|
+
switch arg {
|
|
87
|
+
case "--state-dir":
|
|
88
|
+
stateDirArg = argIter.next()
|
|
89
|
+
case "--version":
|
|
90
|
+
print(DEPUTY_VERSION)
|
|
91
|
+
exit(0)
|
|
92
|
+
default:
|
|
93
|
+
FileHandle.standardError.write(Data("things-deputy: unknown argument \(arg)\n".utf8))
|
|
94
|
+
exit(2)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let paths = DeputyPaths(stateDir: stateDirArg ?? defaultStateDir())
|
|
99
|
+
do {
|
|
100
|
+
try FileManager.default.createDirectory(
|
|
101
|
+
atPath: paths.stateDir, withIntermediateDirectories: true,
|
|
102
|
+
attributes: [.posixPermissions: 0o700])
|
|
103
|
+
} catch {
|
|
104
|
+
FileHandle.standardError.write(
|
|
105
|
+
Data("things-deputy: cannot create state dir \(paths.stateDir): \(error)\n".utf8))
|
|
106
|
+
exit(1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let config = DeputyConfig.load(path: paths.config)
|
|
110
|
+
let token = loadOrCreateToken(path: paths.token)
|
|
111
|
+
let server = Server(paths: paths, config: config, token: token)
|
|
112
|
+
|
|
113
|
+
signal(SIGPIPE, SIG_IGN)
|
|
114
|
+
signal(SIGTERM, SIG_IGN)
|
|
115
|
+
signal(SIGINT, SIG_IGN)
|
|
116
|
+
let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
117
|
+
let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
118
|
+
for source in [termSource, intSource] {
|
|
119
|
+
source.setEventHandler {
|
|
120
|
+
// Graceful drain: stop accepting, let in-flight requests finish (bounded),
|
|
121
|
+
// then remove the socket and exit cleanly. An upgrade boots the old
|
|
122
|
+
// process out mid-flight, and a request in progress must not die with it.
|
|
123
|
+
server.drainAndShutdown()
|
|
124
|
+
exit(0)
|
|
125
|
+
}
|
|
126
|
+
source.resume()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The accept loop runs on a background thread; the MAIN thread must sit in
|
|
130
|
+
// dispatchMain() so the signal sources above (queued on .main) actually fire —
|
|
131
|
+
// a main thread blocked in accept() would ignore SIGTERM forever.
|
|
132
|
+
Thread.detachNewThread { server.run() }
|
|
133
|
+
dispatchMain()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* osascript execution with a hard timeout. The deputy owns the child process:
|
|
3
|
+
* a caller (or its whole harness) dying mid-request never orphans an
|
|
4
|
+
* osascript — the deputy's timer terminates it at the requested deadline
|
|
5
|
+
* (SIGTERM, then SIGKILL after a 2s grace). Argv shapes are fixed here; the
|
|
6
|
+
* client supplies only the script text, language, and timeout.
|
|
7
|
+
*/
|
|
8
|
+
import CryptoKit
|
|
9
|
+
import Foundation
|
|
10
|
+
|
|
11
|
+
private final class Flag {
|
|
12
|
+
private let lock = NSLock()
|
|
13
|
+
private var value = false
|
|
14
|
+
func set() {
|
|
15
|
+
lock.lock()
|
|
16
|
+
value = true
|
|
17
|
+
lock.unlock()
|
|
18
|
+
}
|
|
19
|
+
func get() -> Bool {
|
|
20
|
+
lock.lock()
|
|
21
|
+
defer { lock.unlock() }
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func runOsascript(script: String, lang: String, timeoutMs: Int, binPath: String, id: Any?)
|
|
27
|
+
-> [String: Any]
|
|
28
|
+
{
|
|
29
|
+
runChildTool(
|
|
30
|
+
binPath: binPath,
|
|
31
|
+
args: lang == "javascript" ? ["-l", "JavaScript", "-e", script] : ["-e", script],
|
|
32
|
+
timeoutMs: timeoutMs, id: id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Generic deadline-bounded child runner shared by the osascript and
|
|
36
|
+
/// shortcuts verbs. Argv is always assembled deputy-side from a fixed shape.
|
|
37
|
+
func runChildTool(binPath: String, args: [String], timeoutMs: Int, id: Any?) -> [String: Any] {
|
|
38
|
+
let process = Process()
|
|
39
|
+
process.executableURL = URL(fileURLWithPath: binPath)
|
|
40
|
+
process.arguments = args
|
|
41
|
+
let outPipe = Pipe()
|
|
42
|
+
let errPipe = Pipe()
|
|
43
|
+
process.standardOutput = outPipe
|
|
44
|
+
process.standardError = errPipe
|
|
45
|
+
process.standardInput = FileHandle.nullDevice
|
|
46
|
+
|
|
47
|
+
do {
|
|
48
|
+
try process.run()
|
|
49
|
+
} catch {
|
|
50
|
+
return [
|
|
51
|
+
"id": id ?? NSNull(), "ok": false,
|
|
52
|
+
"error": ["code": "internal", "message": "cannot launch \(binPath): \(error.localizedDescription)"],
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let timedOut = Flag()
|
|
57
|
+
let pid = process.processIdentifier
|
|
58
|
+
let killer = DispatchWorkItem {
|
|
59
|
+
timedOut.set()
|
|
60
|
+
process.terminate()
|
|
61
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) {
|
|
62
|
+
if process.isRunning { kill(pid, SIGKILL) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
DispatchQueue.global().asyncAfter(
|
|
66
|
+
deadline: .now() + .milliseconds(max(1, timeoutMs)), execute: killer)
|
|
67
|
+
|
|
68
|
+
// Drain both pipes off-thread so a chatty script never deadlocks the wait.
|
|
69
|
+
var outData = Data()
|
|
70
|
+
var errData = Data()
|
|
71
|
+
let group = DispatchGroup()
|
|
72
|
+
group.enter()
|
|
73
|
+
DispatchQueue.global().async {
|
|
74
|
+
outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
75
|
+
group.leave()
|
|
76
|
+
}
|
|
77
|
+
group.enter()
|
|
78
|
+
DispatchQueue.global().async {
|
|
79
|
+
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
|
80
|
+
group.leave()
|
|
81
|
+
}
|
|
82
|
+
process.waitUntilExit()
|
|
83
|
+
killer.cancel()
|
|
84
|
+
group.wait()
|
|
85
|
+
|
|
86
|
+
var result: [String: Any] = [
|
|
87
|
+
"id": id ?? NSNull(),
|
|
88
|
+
"ok": true,
|
|
89
|
+
"exitCode": Int(process.terminationStatus),
|
|
90
|
+
"stdout": String(decoding: outData, as: UTF8.self),
|
|
91
|
+
"stderr": String(decoding: errData, as: UTF8.self),
|
|
92
|
+
]
|
|
93
|
+
if process.terminationReason == .uncaughtSignal {
|
|
94
|
+
result["signal"] = Int(process.terminationStatus)
|
|
95
|
+
}
|
|
96
|
+
if timedOut.get() { result["timedOut"] = true }
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func sha256Hex(_ text: String) -> String {
|
|
101
|
+
SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
102
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket server: accept loop, peer verification, JSON-lines framing, request
|
|
3
|
+
* dispatch, and the local JSONL audit log. One thread per connection;
|
|
4
|
+
* osascript/shortcuts execution is funneled through one serial queue.
|
|
5
|
+
*
|
|
6
|
+
* MUTATIONS ONLY: the deputy serves the automation verbs (osascript,
|
|
7
|
+
* shortcuts). File verbs (sql / read-file / locate) live exclusively on the
|
|
8
|
+
* sandboxed things-reader — the deputy's own container access would ride the
|
|
9
|
+
* per-process AppData consent class (a prompt per process instance, measured
|
|
10
|
+
* 2026-08-21), which is precisely the churn this pair exists to end.
|
|
11
|
+
*/
|
|
12
|
+
import Foundation
|
|
13
|
+
|
|
14
|
+
/// How long a shutting-down deputy waits for requests already in flight. An
|
|
15
|
+
/// upgrade (`things helpers setup` / `restart`) boots the old process out
|
|
16
|
+
/// mid-flight; without a drain the in-flight request dies with it. launchd's
|
|
17
|
+
/// own bootout wait, and the CLI's `launchctl` timeout, both clear this bound.
|
|
18
|
+
let DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
|
|
19
|
+
|
|
20
|
+
final class Server {
|
|
21
|
+
let paths: DeputyPaths
|
|
22
|
+
let config: DeputyConfig
|
|
23
|
+
let token: String
|
|
24
|
+
let startedAt = Date()
|
|
25
|
+
private let osaQueue = DispatchQueue(label: "things-deputy.osa")
|
|
26
|
+
private let logQueue = DispatchQueue(label: "things-deputy.log")
|
|
27
|
+
private var listenFd: Int32 = -1
|
|
28
|
+
/// Requests between dispatch and their written response. Guarded by
|
|
29
|
+
/// `inflightCond`, which the drain waits on.
|
|
30
|
+
private let inflightCond = NSCondition()
|
|
31
|
+
private var inflight = 0
|
|
32
|
+
|
|
33
|
+
init(paths: DeputyPaths, config: DeputyConfig, token: String) {
|
|
34
|
+
self.paths = paths
|
|
35
|
+
self.config = config
|
|
36
|
+
self.token = token
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func run() {
|
|
40
|
+
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
41
|
+
guard fd >= 0 else { fatalDie("socket() failed: errno \(errno)") }
|
|
42
|
+
listenFd = fd
|
|
43
|
+
|
|
44
|
+
let sockPath = paths.socket
|
|
45
|
+
guard sockPath.utf8.count <= 103 else {
|
|
46
|
+
fatalDie("socket path too long for sockaddr_un (\(sockPath.utf8.count) bytes): \(sockPath)")
|
|
47
|
+
}
|
|
48
|
+
unlink(sockPath)
|
|
49
|
+
var addr = sockaddr_un()
|
|
50
|
+
addr.sun_family = sa_family_t(AF_UNIX)
|
|
51
|
+
let pathCapacity = MemoryLayout.size(ofValue: addr.sun_path)
|
|
52
|
+
_ = withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
|
53
|
+
sockPath.withCString { cstr in
|
|
54
|
+
strncpy(
|
|
55
|
+
UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), cstr,
|
|
56
|
+
pathCapacity - 1)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
|
60
|
+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
61
|
+
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
guard bindResult == 0 else { fatalDie("bind(\(sockPath)) failed: errno \(errno)") }
|
|
65
|
+
chmod(sockPath, 0o600)
|
|
66
|
+
guard listen(fd, 16) == 0 else { fatalDie("listen() failed: errno \(errno)") }
|
|
67
|
+
|
|
68
|
+
audit(["event": "started", "pid": Int(getpid()), "version": DEPUTY_VERSION])
|
|
69
|
+
|
|
70
|
+
while true {
|
|
71
|
+
let conn = accept(fd, nil, nil)
|
|
72
|
+
if conn < 0 {
|
|
73
|
+
if errno == EINTR { continue }
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
var uid: uid_t = 0
|
|
77
|
+
var gid: gid_t = 0
|
|
78
|
+
if getpeereid(conn, &uid, &gid) != 0 || uid != getuid() {
|
|
79
|
+
audit(["event": "rejected-peer", "uid": Int(uid)])
|
|
80
|
+
close(conn)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
let thread = Thread { [weak self] in self?.handleConnection(conn) }
|
|
84
|
+
thread.name = "things-deputy.conn"
|
|
85
|
+
thread.start()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Graceful drain, then teardown. Order matters:
|
|
91
|
+
*
|
|
92
|
+
* 1. unlink the socket path FIRST — a new client cannot even find us, which
|
|
93
|
+
* is deterministic where waking a thread blocked in accept() is not.
|
|
94
|
+
* 2. close the listener so no further connection is accepted.
|
|
95
|
+
* 3. wait (bounded) for requests already dispatched to write their response.
|
|
96
|
+
* 4. flush the audit log and return; the caller exits 0.
|
|
97
|
+
*
|
|
98
|
+
* SIGKILL remains the hard stop — nothing here tries to survive it.
|
|
99
|
+
*/
|
|
100
|
+
func drainAndShutdown(timeout: TimeInterval = DRAIN_TIMEOUT_SECONDS) {
|
|
101
|
+
unlink(paths.socket)
|
|
102
|
+
if listenFd >= 0 {
|
|
103
|
+
Darwin.shutdown(listenFd, SHUT_RDWR)
|
|
104
|
+
close(listenFd)
|
|
105
|
+
listenFd = -1
|
|
106
|
+
}
|
|
107
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
108
|
+
inflightCond.lock()
|
|
109
|
+
while inflight > 0 && Date() < deadline {
|
|
110
|
+
_ = inflightCond.wait(until: deadline)
|
|
111
|
+
}
|
|
112
|
+
let remaining = inflight
|
|
113
|
+
inflightCond.unlock()
|
|
114
|
+
audit(["event": "stopped", "drained": remaining == 0, "inflight": remaining])
|
|
115
|
+
// The audit log is written asynchronously; a barrier makes "stopped" the
|
|
116
|
+
// last thing on disk instead of a line lost to exit().
|
|
117
|
+
logQueue.sync {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func beginRequest() {
|
|
121
|
+
inflightCond.lock()
|
|
122
|
+
inflight += 1
|
|
123
|
+
inflightCond.unlock()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func endRequest() {
|
|
127
|
+
inflightCond.lock()
|
|
128
|
+
inflight -= 1
|
|
129
|
+
if inflight == 0 { inflightCond.broadcast() }
|
|
130
|
+
inflightCond.unlock()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func fatalDie(_ message: String) -> Never {
|
|
134
|
+
FileHandle.standardError.write(Data("things-deputy: \(message)\n".utf8))
|
|
135
|
+
exit(1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- connection handling ---
|
|
139
|
+
|
|
140
|
+
private func handleConnection(_ conn: Int32) {
|
|
141
|
+
defer { close(conn) }
|
|
142
|
+
var peerPid: pid_t = 0
|
|
143
|
+
var pidLen = socklen_t(MemoryLayout<pid_t>.size)
|
|
144
|
+
_ = getsockopt(conn, SOL_LOCAL, LOCAL_PEERPID, &peerPid, &pidLen)
|
|
145
|
+
|
|
146
|
+
var buffer = Data()
|
|
147
|
+
var chunk = [UInt8](repeating: 0, count: 65536)
|
|
148
|
+
while true {
|
|
149
|
+
let n = read(conn, &chunk, chunk.count)
|
|
150
|
+
if n <= 0 { return }
|
|
151
|
+
buffer.append(contentsOf: chunk[0..<n])
|
|
152
|
+
if buffer.count > MAX_REQUEST_BYTES {
|
|
153
|
+
writeLine(conn, errorResponse(id: nil, code: "too-large", message: "request exceeds \(MAX_REQUEST_BYTES) bytes"))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
while let nl = buffer.firstIndex(of: 0x0A) {
|
|
157
|
+
let line = buffer.subdata(in: buffer.startIndex..<nl)
|
|
158
|
+
buffer.removeSubrange(buffer.startIndex...nl)
|
|
159
|
+
// In flight from dispatch until the response is on the wire — that
|
|
160
|
+
// span is exactly what a drain must not cut short.
|
|
161
|
+
beginRequest()
|
|
162
|
+
let response = dispatch(line: line, peerPid: peerPid)
|
|
163
|
+
let written = writeLine(conn, response)
|
|
164
|
+
endRequest()
|
|
165
|
+
if !written { return }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@discardableResult
|
|
171
|
+
private func writeLine(_ conn: Int32, _ obj: [String: Any]) -> Bool {
|
|
172
|
+
guard var data = try? JSONSerialization.data(withJSONObject: obj) else { return false }
|
|
173
|
+
data.append(0x0A)
|
|
174
|
+
return data.withUnsafeBytes { raw in
|
|
175
|
+
var offset = 0
|
|
176
|
+
while offset < raw.count {
|
|
177
|
+
let n = write(conn, raw.baseAddress!.advanced(by: offset), raw.count - offset)
|
|
178
|
+
if n <= 0 { return false }
|
|
179
|
+
offset += n
|
|
180
|
+
}
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func errorResponse(id: Any?, code: String, message: String) -> [String: Any] {
|
|
186
|
+
["id": id ?? NSNull(), "ok": false, "error": ["code": code, "message": message]]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- dispatch ---
|
|
190
|
+
|
|
191
|
+
private func dispatch(line: Data, peerPid: pid_t) -> [String: Any] {
|
|
192
|
+
guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] else {
|
|
193
|
+
return errorResponse(id: nil, code: "bad-request", message: "request is not a JSON object")
|
|
194
|
+
}
|
|
195
|
+
let id = obj["id"]
|
|
196
|
+
guard let v = obj["v"] as? Int, v == PROTOCOL_VERSION else {
|
|
197
|
+
return errorResponse(
|
|
198
|
+
id: id, code: "unsupported-protocol",
|
|
199
|
+
message: "deputy speaks protocol \(PROTOCOL_VERSION); restart or rebuild the deputy (things helpers restart)")
|
|
200
|
+
}
|
|
201
|
+
guard let reqToken = obj["token"] as? String, reqToken == token else {
|
|
202
|
+
audit(["event": "rejected-token", "peerPid": Int(peerPid)])
|
|
203
|
+
return errorResponse(id: id, code: "bad-token", message: "request token does not match the deputy token file")
|
|
204
|
+
}
|
|
205
|
+
guard let verb = obj["verb"] as? String else {
|
|
206
|
+
return errorResponse(id: id, code: "bad-request", message: "missing verb")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let started = Date()
|
|
210
|
+
var result: [String: Any]
|
|
211
|
+
var auditExtra: [String: Any] = [:]
|
|
212
|
+
switch verb {
|
|
213
|
+
case "hello":
|
|
214
|
+
result = handleHello(id: id)
|
|
215
|
+
case "prime-ax":
|
|
216
|
+
result = handlePrimeAx(id: id)
|
|
217
|
+
case "osascript":
|
|
218
|
+
guard let script = obj["script"] as? String else {
|
|
219
|
+
result = errorResponse(id: id, code: "bad-request", message: "osascript verb requires a script string")
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
let lang = obj["lang"] as? String ?? "applescript"
|
|
223
|
+
guard lang == "applescript" || lang == "javascript" else {
|
|
224
|
+
result = errorResponse(id: id, code: "bad-request", message: "lang must be applescript or javascript")
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
let timeoutMs = obj["timeoutMs"] as? Int ?? 30_000
|
|
228
|
+
if let guardError = scriptGuard(script) {
|
|
229
|
+
audit(["event": "rejected-script", "reason": guardError, "peerPid": Int(peerPid)])
|
|
230
|
+
result = errorResponse(id: id, code: "script-denied", message: guardError)
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
auditExtra["scriptSha256"] = sha256Hex(script)
|
|
234
|
+
auditExtra["scriptBytes"] = script.utf8.count
|
|
235
|
+
result = osaQueue.sync {
|
|
236
|
+
runOsascript(
|
|
237
|
+
script: script, lang: lang, timeoutMs: timeoutMs,
|
|
238
|
+
binPath: config.osascriptPath ?? "/usr/bin/osascript", id: id)
|
|
239
|
+
}
|
|
240
|
+
case "shortcuts":
|
|
241
|
+
let op = obj["op"] as? String
|
|
242
|
+
let timeoutMs = obj["timeoutMs"] as? Int ?? 30_000
|
|
243
|
+
let bin = config.shortcutsPath ?? "/usr/bin/shortcuts"
|
|
244
|
+
if op == "list" {
|
|
245
|
+
result = osaQueue.sync { runChildTool(binPath: bin, args: ["list"], timeoutMs: timeoutMs, id: id) }
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
guard op == "run", let name = obj["name"] as? String,
|
|
249
|
+
let inputPath = obj["inputPath"] as? String,
|
|
250
|
+
let outputPath = obj["outputPath"] as? String
|
|
251
|
+
else {
|
|
252
|
+
result = errorResponse(
|
|
253
|
+
id: id, code: "bad-request",
|
|
254
|
+
message: "shortcuts verb requires op=list, or op=run with name/inputPath/outputPath")
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
// The deputy only runs this library's bundled proxy shortcuts — it is a
|
|
258
|
+
// paired tool, not a general shortcut runner. (Same spirit as the
|
|
259
|
+
// osascript shell-execution lint: a same-user process could run
|
|
260
|
+
// arbitrary shortcuts itself; it just cannot do so AS the deputy.)
|
|
261
|
+
guard name.hasPrefix("things-proxy-"), !inputPath.hasPrefix("-"), !outputPath.hasPrefix("-")
|
|
262
|
+
else {
|
|
263
|
+
audit(["event": "rejected-shortcut", "name": name, "peerPid": Int(peerPid)])
|
|
264
|
+
result = errorResponse(
|
|
265
|
+
id: id, code: "shortcut-denied",
|
|
266
|
+
message: "the deputy only runs bundled things-proxy-* shortcuts")
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
auditExtra["shortcut"] = name
|
|
270
|
+
result = osaQueue.sync {
|
|
271
|
+
runChildTool(
|
|
272
|
+
binPath: bin,
|
|
273
|
+
args: ["run", name, "--input-path", inputPath, "--output-path", outputPath],
|
|
274
|
+
timeoutMs: timeoutMs, id: id)
|
|
275
|
+
}
|
|
276
|
+
case "sql", "read-file", "locate":
|
|
277
|
+
// File verbs live exclusively on the sandboxed things-reader.
|
|
278
|
+
result = errorResponse(
|
|
279
|
+
id: id, code: "unsupported-verb",
|
|
280
|
+
message: "the deputy serves automation verbs only — file access rides things-reader (things helpers setup)")
|
|
281
|
+
default:
|
|
282
|
+
result = errorResponse(id: id, code: "bad-request", message: "unknown verb \(verb)")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
var entry: [String: Any] = [
|
|
286
|
+
"ts": iso8601(Date()),
|
|
287
|
+
"verb": verb,
|
|
288
|
+
"peerPid": Int(peerPid),
|
|
289
|
+
"ok": (result["ok"] as? Bool) ?? false,
|
|
290
|
+
"ms": Int(Date().timeIntervalSince(started) * 1000),
|
|
291
|
+
]
|
|
292
|
+
for (k, v) in auditExtra { entry[k] = v }
|
|
293
|
+
audit(entry)
|
|
294
|
+
return result
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Defense-in-depth lint, not a security boundary (a same-user process that
|
|
299
|
+
* can read the token can also run osascript itself — what the deputy adds is
|
|
300
|
+
* only its OWN TCC grants). The lint refuses the constructs that would turn
|
|
301
|
+
* "drive the Things GUI" into "run arbitrary shell": every legitimate script
|
|
302
|
+
* this library generates talks AppleScript/JXA to Things and System Events
|
|
303
|
+
* and never shells out.
|
|
304
|
+
*/
|
|
305
|
+
private func scriptGuard(_ script: String) -> String? {
|
|
306
|
+
let lowered = script.lowercased()
|
|
307
|
+
for banned in ["do shell script", "do script"] {
|
|
308
|
+
if lowered.contains(banned) {
|
|
309
|
+
return "script rejected: contains \"\(banned)\" — the deputy only brokers GUI/AppleEvent scripts, never shell execution"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return nil
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- verb handlers ---
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The handshake, plus the deputy's own TCC standing — Accessibility trust
|
|
319
|
+
* and Automation permission for each target it drives. Every field here is
|
|
320
|
+
* PROMPT-FREE by construction (see tcc.swift), which is what lets a status
|
|
321
|
+
* report and the onboarding ceremony ask "is this leg already granted?"
|
|
322
|
+
* without raising a dialog at whoever happens to run `things` next.
|
|
323
|
+
*/
|
|
324
|
+
private func handleHello(id: Any?) -> [String: Any] {
|
|
325
|
+
[
|
|
326
|
+
"id": id ?? NSNull(),
|
|
327
|
+
"ok": true,
|
|
328
|
+
"protocol": PROTOCOL_VERSION,
|
|
329
|
+
"deputyVersion": DEPUTY_VERSION,
|
|
330
|
+
"role": "deputy",
|
|
331
|
+
"pid": Int(getpid()),
|
|
332
|
+
"uptimeMs": Int(Date().timeIntervalSince(startedAt) * 1000),
|
|
333
|
+
"axTrusted": accessibilityTrusted(),
|
|
334
|
+
"automation": [
|
|
335
|
+
"things": automationStatus(bundleID: "com.culturedcode.ThingsMac"),
|
|
336
|
+
"systemEvents": automationStatus(bundleID: "com.apple.systemevents"),
|
|
337
|
+
],
|
|
338
|
+
]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Raise the Accessibility consent dialog for the deputy's own identity. The
|
|
343
|
+
* grant itself is a toggle in System Settings, so the answer can only arrive
|
|
344
|
+
* later — the caller polls `hello`'s axTrusted. Returns the state as of now.
|
|
345
|
+
*/
|
|
346
|
+
private func handlePrimeAx(id: Any?) -> [String: Any] {
|
|
347
|
+
["id": id ?? NSNull(), "ok": true, "axTrusted": primeAccessibility()]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- audit log ---
|
|
351
|
+
|
|
352
|
+
private func audit(_ entry: [String: Any]) {
|
|
353
|
+
var withTs = entry
|
|
354
|
+
if withTs["ts"] == nil { withTs["ts"] = iso8601(Date()) }
|
|
355
|
+
guard var data = try? JSONSerialization.data(withJSONObject: withTs) else { return }
|
|
356
|
+
data.append(0x0A)
|
|
357
|
+
let path = paths.log
|
|
358
|
+
logQueue.async {
|
|
359
|
+
if let handle = FileHandle(forWritingAtPath: path) {
|
|
360
|
+
handle.seekToEndOfFile()
|
|
361
|
+
handle.write(data)
|
|
362
|
+
try? handle.close()
|
|
363
|
+
} else {
|
|
364
|
+
FileManager.default.createFile(
|
|
365
|
+
atPath: path, contents: data, attributes: [.posixPermissions: 0o600])
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
func iso8601(_ date: Date) -> String {
|
|
372
|
+
ISO8601DateFormatter().string(from: date)
|
|
373
|
+
}
|