iterate 0.2.7 → 0.3.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 (218) hide show
  1. package/README.md +86 -76
  2. package/THIRD_PARTY_NOTICES.md +55 -0
  3. package/bin/iterate.js +11 -3
  4. package/dist/api-url-B6404M82.mjs +17 -0
  5. package/dist/api-url-B6404M82.mjs.map +1 -0
  6. package/dist/app-ref-BipL0feU.mjs +35 -0
  7. package/dist/app-ref-BipL0feU.mjs.map +1 -0
  8. package/dist/app-ref-C1CrgXqX.mjs +7 -0
  9. package/dist/app-ref-C1CrgXqX.mjs.map +1 -0
  10. package/dist/app-ref-DYai_om1.mjs +7 -0
  11. package/dist/app-ref-DYai_om1.mjs.map +1 -0
  12. package/dist/cli-D0c-pDL_.mjs +1010 -0
  13. package/dist/cli-D0c-pDL_.mjs.map +1 -0
  14. package/dist/client.d.ts +3 -0
  15. package/dist/client.mjs +4 -0
  16. package/dist/cloudflare-BTm90gQ4.mjs +951 -0
  17. package/dist/cloudflare-BTm90gQ4.mjs.map +1 -0
  18. package/dist/contract-s4FW4eES.mjs +309 -0
  19. package/dist/contract-s4FW4eES.mjs.map +1 -0
  20. package/dist/document-review/index.d.ts +5 -0
  21. package/dist/document-review/types.d.ts +107 -0
  22. package/dist/document-review.mjs +7015 -0
  23. package/dist/document-review.mjs.map +1 -0
  24. package/dist/durable-object-processor-durability-CNsTjAJS.mjs +205 -0
  25. package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +1 -0
  26. package/dist/idempotency-DleloJNt.mjs +28 -0
  27. package/dist/idempotency-DleloJNt.mjs.map +1 -0
  28. package/dist/index.mjs +1 -1
  29. package/dist/itx/api-url.d.ts +6 -0
  30. package/dist/itx/itx-node-client.d.ts +65 -0
  31. package/dist/itx/itx-session.d.ts +215 -0
  32. package/dist/itx/owned-rpc-session.d.ts +14 -0
  33. package/dist/itx/query-client.d.ts +10 -0
  34. package/dist/itx-api.generated.d.ts +6195 -0
  35. package/dist/itx-session-sjud8GiT.mjs +534 -0
  36. package/dist/itx-session-sjud8GiT.mjs.map +1 -0
  37. package/dist/live-state-BJNqOwFw.mjs +299 -0
  38. package/dist/live-state-BJNqOwFw.mjs.map +1 -0
  39. package/dist/next/api.d.ts +479 -0
  40. package/dist/next/api.mjs +0 -0
  41. package/dist/next/app-server.d.ts +44 -0
  42. package/dist/next/app-server.mjs +479 -0
  43. package/dist/next/app-server.mjs.map +1 -0
  44. package/dist/next/app-session.d.ts +49 -0
  45. package/dist/next/app-session.mjs +238 -0
  46. package/dist/next/app-session.mjs.map +1 -0
  47. package/dist/next/app.d.ts +29 -0
  48. package/dist/next/app.mjs +141 -0
  49. package/dist/next/app.mjs.map +1 -0
  50. package/dist/next/client/live-state.d.ts +63 -0
  51. package/dist/next/client/oauth.d.ts +12 -0
  52. package/dist/next/client/react.d.ts +109 -0
  53. package/dist/next/client/socket.d.ts +6 -0
  54. package/dist/next/client.mjs +156 -0
  55. package/dist/next/client.mjs.map +1 -0
  56. package/dist/next/expression.d.ts +146 -0
  57. package/dist/next/expression.mjs +399 -0
  58. package/dist/next/expression.mjs.map +1 -0
  59. package/dist/next/lib.d.ts +56 -0
  60. package/dist/next/lib.mjs +199 -0
  61. package/dist/next/lib.mjs.map +1 -0
  62. package/dist/next/oauth-scopes.d.ts +32 -0
  63. package/dist/next/oauth-scopes.mjs +40 -0
  64. package/dist/next/oauth-scopes.mjs.map +1 -0
  65. package/dist/next/oauth.mjs +29 -0
  66. package/dist/next/oauth.mjs.map +1 -0
  67. package/dist/next/principal.d.ts +64 -0
  68. package/dist/next/principal.mjs +98 -0
  69. package/dist/next/principal.mjs.map +1 -0
  70. package/dist/next/project-ingress.d.ts +37 -0
  71. package/dist/next/project-ingress.mjs +75 -0
  72. package/dist/next/project-ingress.mjs.map +1 -0
  73. package/dist/next/react.mjs +285 -0
  74. package/dist/next/react.mjs.map +1 -0
  75. package/dist/next/sdk/auth.d.ts +5 -0
  76. package/dist/next/sdk/index.d.ts +112 -0
  77. package/dist/next/sdk.mjs +139 -0
  78. package/dist/next/sdk.mjs.map +1 -0
  79. package/dist/next/stream/processor.d.ts +378 -0
  80. package/dist/next/stream/processor.mjs +582 -0
  81. package/dist/next/stream/processor.mjs.map +1 -0
  82. package/dist/next/stream/run.d.ts +58 -0
  83. package/dist/next/stream/run.mjs +40 -0
  84. package/dist/next/stream/run.mjs.map +1 -0
  85. package/dist/next-node.d.ts +15 -0
  86. package/dist/next-node.mjs +51 -0
  87. package/dist/next-node.mjs.map +1 -0
  88. package/dist/node.d.ts +3 -0
  89. package/dist/node.mjs +185 -0
  90. package/dist/node.mjs.map +1 -0
  91. package/dist/processor-host-capabilities-BMFH3KTM.mjs +56 -0
  92. package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +1 -0
  93. package/dist/processors/cloudflare.d.ts +3 -0
  94. package/dist/processors/durable-object-processor-durability.d.ts +79 -0
  95. package/dist/processors/event-consumption-metrics.d.ts +82 -0
  96. package/dist/processors/idempotency.d.ts +13 -0
  97. package/dist/processors/index.d.ts +12 -0
  98. package/dist/processors/processor-contracts.d.ts +342 -0
  99. package/dist/processors/processor-facet.d.ts +186 -0
  100. package/dist/processors/processor-host-capabilities.d.ts +60 -0
  101. package/dist/processors/prompt-sections.d.ts +17 -0
  102. package/dist/processors/rpc-types.d.ts +515 -0
  103. package/dist/processors/schemas.d.ts +102 -0
  104. package/dist/processors/stream-handle.d.ts +45 -0
  105. package/dist/processors/stream-processor-keepalive.d.ts +95 -0
  106. package/dist/processors/stream-processor-registry.d.ts +233 -0
  107. package/dist/processors/stream-processor-runner.d.ts +289 -0
  108. package/dist/processors/stream-processor.d.ts +339 -0
  109. package/dist/processors/stream-runtime-metrics.d.ts +107 -0
  110. package/dist/processors/testing.d.ts +302 -0
  111. package/dist/processors-BoNyeBfQ.mjs +10 -0
  112. package/dist/processors-BoNyeBfQ.mjs.map +1 -0
  113. package/dist/processors-cloudflare.mjs +3 -0
  114. package/dist/processors-testing.mjs +435 -0
  115. package/dist/processors-testing.mjs.map +1 -0
  116. package/dist/processors.mjs +52 -0
  117. package/dist/processors.mjs.map +1 -0
  118. package/dist/protocol-DnK_f2m6.mjs +251 -0
  119. package/dist/protocol-DnK_f2m6.mjs.map +1 -0
  120. package/dist/sdk/capnweb/index.d.ts +2 -0
  121. package/dist/sdk/capnweb/live-state/compact.d.ts +5 -0
  122. package/dist/sdk/capnweb/live-state/diff.d.ts +41 -0
  123. package/dist/sdk/capnweb/live-state/engine.d.ts +44 -0
  124. package/dist/sdk/capnweb/live-state/index.d.ts +41 -0
  125. package/dist/sdk/capnweb/live-state/protocol.d.ts +87 -0
  126. package/dist/sdk/capnweb/live-state/retain.d.ts +23 -0
  127. package/dist/sdk/capnweb/live-state/store.d.ts +20 -0
  128. package/dist/sdk/capnweb/live-state/types.d.ts +11 -0
  129. package/dist/sdk/capnweb/react.d.ts +45 -0
  130. package/dist/sdk/capnweb/react.mjs +316 -0
  131. package/dist/sdk/capnweb/react.mjs.map +1 -0
  132. package/dist/sdk/capnweb.mjs +4 -0
  133. package/dist/sdk/itx/react.d.ts +191 -0
  134. package/dist/sdk/itx/react.mjs +383 -0
  135. package/dist/sdk/itx/react.mjs.map +1 -0
  136. package/dist/sdk-DMB-IM11.mjs +933 -0
  137. package/dist/sdk-DMB-IM11.mjs.map +1 -0
  138. package/dist/sdk.d.ts +339 -0
  139. package/dist/sdk.mjs +2 -0
  140. package/dist/serve-itx.d.ts +46 -0
  141. package/dist/starter-apps/flake-dashboard/app-ref.d.ts +31 -0
  142. package/dist/starter-apps/flake-dashboard/configured-worker.mjs +1055 -0
  143. package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +1 -0
  144. package/dist/starter-apps/flake-dashboard/contract.d.ts +4839 -0
  145. package/dist/starter-apps/flake-dashboard/contract.mjs +2 -0
  146. package/dist/starter-apps/flake-dashboard/index.d.ts +17 -0
  147. package/dist/starter-apps/flake-dashboard/index.mjs +56 -0
  148. package/dist/starter-apps/flake-dashboard/index.mjs.map +1 -0
  149. package/dist/starter-apps/flake-dashboard/worker.d.ts +4607 -0
  150. package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +8914 -0
  151. package/dist/starter-apps/github-ai-linter/configured-worker.mjs +17987 -0
  152. package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +1 -0
  153. package/dist/starter-apps/github-ai-linter/contract.d.ts +9193 -0
  154. package/dist/starter-apps/github-ai-linter/index.d.ts +10 -0
  155. package/dist/starter-apps/github-ai-linter/index.mjs +36 -0
  156. package/dist/starter-apps/github-ai-linter/index.mjs.map +1 -0
  157. package/dist/starter-apps/github-ai-linter/prompt.d.ts +13 -0
  158. package/dist/starter-apps/github-ai-linter/review-bot.d.ts +808 -0
  159. package/dist/starter-apps/github-ai-linter/rules.d.ts +34 -0
  160. package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +19 -0
  161. package/dist/starter-apps/github-ai-linter/worker.d.ts +19 -0
  162. package/dist/starter-apps/github-ai-linter/worker.mjs +947 -0
  163. package/dist/starter-apps/github-ai-linter/worker.mjs.map +1 -0
  164. package/dist/starter-apps/guestbook/app-ref.d.ts +27 -0
  165. package/dist/starter-apps/guestbook/client.d.ts +7 -0
  166. package/dist/starter-apps/guestbook/client.mjs +59 -0
  167. package/dist/starter-apps/guestbook/configured-worker.mjs +205 -0
  168. package/dist/starter-apps/guestbook/configured-worker.mjs.map +1 -0
  169. package/dist/starter-apps/guestbook/index.d.ts +9 -0
  170. package/dist/starter-apps/guestbook/index.mjs +31 -0
  171. package/dist/starter-apps/guestbook/index.mjs.map +1 -0
  172. package/dist/starter-apps/guestbook/processor.d.ts +2267 -0
  173. package/dist/starter-apps/guestbook/worker.d.ts +26 -0
  174. package/dist/starter-apps/guestbook/worker.mjs +191 -0
  175. package/dist/starter-apps/guestbook/worker.mjs.map +1 -0
  176. package/dist/starter-apps/media/configured-worker.mjs +577 -0
  177. package/dist/starter-apps/media/configured-worker.mjs.map +1 -0
  178. package/dist/starter-apps/media/index.mjs +36 -0
  179. package/dist/starter-apps/media/index.mjs.map +1 -0
  180. package/dist/starter-apps/media/ref.mjs +20 -0
  181. package/dist/starter-apps/media/ref.mjs.map +1 -0
  182. package/dist/starter-apps/media/worker.mjs +579 -0
  183. package/dist/starter-apps/media/worker.mjs.map +1 -0
  184. package/dist/starter-apps/notes/configured-worker.mjs +6134 -0
  185. package/dist/starter-apps/notes/configured-worker.mjs.map +1 -0
  186. package/dist/starter-apps/notes/index.mjs +23 -0
  187. package/dist/starter-apps/notes/index.mjs.map +1 -0
  188. package/dist/starter-apps/notes/ref.mjs +21 -0
  189. package/dist/starter-apps/notes/ref.mjs.map +1 -0
  190. package/dist/starter-apps/notes/worker.mjs +427 -0
  191. package/dist/starter-apps/notes/worker.mjs.map +1 -0
  192. package/dist/starter-apps/todo/client.mjs +59 -0
  193. package/dist/starter-apps/todo/configured-worker.mjs +2864 -0
  194. package/dist/starter-apps/todo/configured-worker.mjs.map +1 -0
  195. package/dist/starter-apps/todo/index.d.ts +8 -0
  196. package/dist/starter-apps/todo/index.mjs +29 -0
  197. package/dist/starter-apps/todo/index.mjs.map +1 -0
  198. package/dist/stream-processor-keepalive-DAQTP6m3.mjs +2082 -0
  199. package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +1 -0
  200. package/dist/usingCtx-inzbY1Qz.mjs +57 -0
  201. package/dist/usingCtx-mZx5nsAW.mjs +11800 -0
  202. package/dist/usingCtx-mZx5nsAW.mjs.map +1 -0
  203. package/dist/worker-ref-DZxPDmb_.mjs +390 -0
  204. package/dist/worker-ref-DZxPDmb_.mjs.map +1 -0
  205. package/menubar/Iterate.entitlements +12 -0
  206. package/menubar/Iterate.swift +914 -0
  207. package/menubar/IterateIcon.swift +145 -0
  208. package/menubar/README.md +28 -0
  209. package/menubar/build-menubar-app.sh +59 -0
  210. package/package.json +235 -18
  211. package/dist/cli-DMS4kJph.mjs +0 -868
  212. package/dist/cli-DMS4kJph.mjs.map +0 -1
  213. package/dist/config-DtnR7Lv7.mjs +0 -170
  214. package/dist/config-DtnR7Lv7.mjs.map +0 -1
  215. package/dist/index.d.mts.map +0 -1
  216. package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
  217. package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
  218. package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
@@ -0,0 +1,914 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Iterate — a small macOS menu-bar app. Today it's the human-in-the-loop
3
+ // companion for sign-in and computer sharing on OS Next.
4
+ // The legacy approval watcher and UI remain dormant.
5
+ //
6
+ // A thin shell over `iterate ping`, `iterate login`, and
7
+ // `iterate use-my-computer --json`. The CLI owns authentication and transport.
8
+ // Legacy approval models are retained below but are not started on OS Next.
9
+ //
10
+ // Single file, compiled with swiftc and wrapped in a minimal .app bundle by
11
+ // build-menubar-app.sh — no Xcode project, no asset catalog: the 𝑖 icon is
12
+ // drawn from the brand SVG's paths at runtime.
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ import AppKit
16
+ import Combine
17
+ import Foundation
18
+ import SwiftUI
19
+ import UserNotifications
20
+
21
+ // MARK: - Config
22
+
23
+ /// Where to find the CLI and which project to watch. Read from
24
+ /// ~/.config/iterate/menubar.json so the app needs no launch arguments.
25
+ struct MenuBarConfig: Codable {
26
+ var command: String // e.g. "iterate", or "bun"
27
+ var args: [String] // e.g. [] or ["/path/bin/iterate.js"]
28
+ var config: String? // iterate config name, e.g. "preview1"
29
+ var project: String? // project id or slug
30
+ var cwd: String? // working directory to spawn in
31
+ var xdgConfigHome: String? = nil
32
+
33
+ static func load() -> MenuBarConfig {
34
+ let path = ("~/.config/iterate/menubar.json" as NSString).expandingTildeInPath
35
+ guard let data = FileManager.default.contents(atPath: path),
36
+ let config = try? JSONDecoder().decode(MenuBarConfig.self, from: data)
37
+ else {
38
+ return MenuBarConfig(command: "iterate", args: [], config: nil, project: nil, cwd: nil)
39
+ }
40
+ return config
41
+ }
42
+
43
+ /// The argv for a subcommand under this config. `login` takes no `--project`
44
+ /// (it rejects the flag), so callers opt out of it there.
45
+ func argv(for subcommand: [String], includeProject: Bool = true) -> [String] {
46
+ var out = args
47
+ if let config { out += ["--config", config] }
48
+ out += subcommand
49
+ if includeProject, let project { out += ["--project", project] }
50
+ return out
51
+ }
52
+ }
53
+
54
+ // MARK: - Model
55
+
56
+ /// One held approval BATCH (a lone request is a batch of one), keyed by its
57
+ /// requested event's offset. `summary` comes from the CLI ("POST api.stripe.com"
58
+ /// or "12 requests (12x gmail.googleapis.com)"); one decision answers it all.
59
+ struct HeldRequest: Identifiable, Equatable {
60
+ let offset: Int
61
+ let summary: String
62
+ let count: Int
63
+ let secretPaths: [String]
64
+ let ruleKey: String
65
+ let body: String?
66
+ var submitting = false
67
+ var id: Int { offset }
68
+ }
69
+
70
+ final class ApprovalController: ObservableObject {
71
+ static let shared = ApprovalController()
72
+
73
+ @Published var loggedIn = false // is there a valid session (from the status line)?
74
+ @Published var connected = false // is a watcher live and serving right now?
75
+ @Published var principal: String?
76
+ @Published var project: String?
77
+ @Published var keyLabel: String? // "secure-enclave 9f2c…" or nil (unsigned)
78
+ @Published var requests: [HeldRequest] = []
79
+ @Published var lastError: String?
80
+
81
+ /// True once UserNotifications authorization succeeded — gates the rich,
82
+ /// actionable banners; otherwise notify() falls back to osascript.
83
+ var notificationsAuthorized = false
84
+
85
+ private var config = MenuBarConfig.load()
86
+ private var process: Process?
87
+ private var loginProcess: Process?
88
+ private var stdinHandle: FileHandle?
89
+ private var stdoutHandle: FileHandle?
90
+ private var buffer = Data()
91
+ private var sawStatus = false // did THIS watcher session emit a status line?
92
+ private var sessionStart = Date()
93
+ private var reconnectAttempts = 0
94
+ private var generation = 0 // bumped on every stop(); voids stale queued reconnects
95
+
96
+ func configure(_ next: MenuBarConfig) {
97
+ stop()
98
+ loginProcess?.terminationHandler = nil
99
+ loginProcess?.terminate()
100
+ loginProcess = nil
101
+ loggedIn = false
102
+ principal = nil
103
+ project = nil
104
+ lastError = nil
105
+ config = next
106
+ start()
107
+ }
108
+
109
+ // OS Next has no approval transport. Keep the legacy watcher below dormant;
110
+ // use a bounded CLI authentication check for the menu bar's sign-in state.
111
+ func start() {
112
+ stop()
113
+ let process = Process()
114
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
115
+ process.arguments = [config.command] + config.argv(for: ["ping"], includeProject: false)
116
+ if let cwd = config.cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
117
+ if let home = config.xdgConfigHome {
118
+ var environment = ProcessInfo.processInfo.environment
119
+ environment["XDG_CONFIG_HOME"] = home
120
+ process.environment = environment
121
+ }
122
+ process.standardOutput = FileHandle.nullDevice
123
+ let session = generation
124
+ process.terminationHandler = { [weak self] child in
125
+ DispatchQueue.main.async {
126
+ guard let self, self.generation == session else { return }
127
+ self.process = nil
128
+ self.loggedIn = child.terminationStatus == 0
129
+ self.connected = self.loggedIn
130
+ self.principal = self.loggedIn ? "Signed in" : nil
131
+ self.project = self.config.project
132
+ self.lastError = self.loggedIn ? nil : "Sign in or check your connection."
133
+ }
134
+ }
135
+ do {
136
+ try process.run()
137
+ self.process = process
138
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
139
+ guard let self, self.generation == session, process.isRunning else { return }
140
+ process.terminate()
141
+ }
142
+ } catch {
143
+ loggedIn = false
144
+ lastError = "Could not launch iterate: \(error.localizedDescription)"
145
+ }
146
+ }
147
+
148
+ private func startLegacyApprovalWatcher() {
149
+ stop()
150
+ sawStatus = false
151
+ sessionStart = Date()
152
+ let process = Process()
153
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
154
+ process.arguments = [config.command] + config.argv(for: ["approve", "--json"])
155
+ if let cwd = config.cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
156
+ if let home = config.xdgConfigHome {
157
+ var environment = ProcessInfo.processInfo.environment
158
+ environment["XDG_CONFIG_HOME"] = home
159
+ process.environment = environment
160
+ }
161
+
162
+ let stdout = Pipe()
163
+ let stdin = Pipe()
164
+ process.standardOutput = stdout
165
+ process.standardInput = stdin
166
+ self.stdinHandle = stdin.fileHandleForWriting
167
+ self.stdoutHandle = stdout.fileHandleForReading
168
+
169
+ // This session's identity: a chunk that lands after the session ends
170
+ // (generation moved on) is stale and must not touch state.
171
+ let session = generation
172
+ stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
173
+ let chunk = handle.availableData
174
+ // Empty data means EOF: clear the handler so it stops firing (otherwise
175
+ // it busy-loops on the closed pipe).
176
+ if chunk.isEmpty {
177
+ handle.readabilityHandler = nil
178
+ return
179
+ }
180
+ DispatchQueue.main.async {
181
+ guard let self, self.generation == session else { return } // stale chunk
182
+ self.ingest(chunk)
183
+ }
184
+ }
185
+ process.terminationHandler = { [weak self] _ in
186
+ DispatchQueue.main.async {
187
+ // Guard on the session: a late callback from a process we already
188
+ // replaced must not tear down the new one.
189
+ guard let self, self.generation == session else { return }
190
+ self.watcherExited()
191
+ }
192
+ }
193
+ do {
194
+ try process.run()
195
+ self.process = process
196
+ } catch {
197
+ // Launch failed — that's a broken CLI path, NOT a lost session. stop()
198
+ // already cleared `connected`/`requests`; leave `loggedIn` alone so a
199
+ // valid session shows Disconnected + Reconnect rather than routing to a
200
+ // needless browser login.
201
+ self.lastError = "Could not launch iterate: \(error.localizedDescription)"
202
+ }
203
+ }
204
+
205
+ /// The watcher process exited. Drop stale rows; don't touch `loggedIn` (the
206
+ /// status line is authoritative). Reconnect only if this session actually
207
+ /// connected (emitted a status) and still believes it's signed in — bounded,
208
+ /// so a watcher that never connects or keeps dying fast won't hot-loop.
209
+ private func watcherExited() {
210
+ generation += 1 // end this session so any late stdout chunk is ignored
211
+ detachIO() // drop the dead pipes/handle so a click can't write to a corpse
212
+ requests = []
213
+ connected = false // no live watcher until one reconnects and emits status
214
+ if Date().timeIntervalSince(sessionStart) > 10 { reconnectAttempts = 0 } // it was stable
215
+ guard loggedIn, sawStatus else { return } // never connected → no reconnect
216
+ if reconnectAttempts < 5 {
217
+ reconnectAttempts += 1
218
+ // Void this reconnect if the user meanwhile signs in / quits (which
219
+ // bumps the generation) so it can't overlap a login or a fresh watcher.
220
+ let scheduled = generation
221
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
222
+ guard let self, self.generation == scheduled else { return }
223
+ self.start()
224
+ }
225
+ } else {
226
+ lastError = "Approver keeps exiting — click Sign in to retry."
227
+ }
228
+ }
229
+
230
+ func stop() {
231
+ generation += 1 // any in-flight reconnect scheduled before now is void
232
+ connected = false
233
+ process?.terminationHandler = nil
234
+ process?.terminate()
235
+ process = nil
236
+ detachIO()
237
+ requests = []
238
+ }
239
+
240
+ /// Drop the current session's pipes/handles and any half-read line, so no
241
+ /// late chunk or stray write can touch the next session.
242
+ private func detachIO() {
243
+ stdoutHandle?.readabilityHandler = nil
244
+ stdoutHandle = nil
245
+ stdinHandle = nil
246
+ buffer = Data()
247
+ }
248
+
249
+ /// Kick off `iterate login` (browser OAuth), then restart the watcher. Stop
250
+ /// the current watcher first so login never overlaps a running approve.
251
+ func login() {
252
+ guard loginProcess == nil else { return } // one browser login at a time
253
+ stop()
254
+ reconnectAttempts = 0 // an explicit retry clears the give-up counter
255
+ let process = Process()
256
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
257
+ process.arguments = [config.command] + config.argv(for: ["login"], includeProject: false)
258
+ if let cwd = config.cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
259
+ if let home = config.xdgConfigHome {
260
+ var environment = ProcessInfo.processInfo.environment
261
+ environment["XDG_CONFIG_HOME"] = home
262
+ process.environment = environment
263
+ }
264
+ process.terminationHandler = { [weak self] _ in
265
+ Task { @MainActor in
266
+ self?.loginProcess = nil
267
+ self?.start()
268
+ }
269
+ }
270
+ do {
271
+ try process.run()
272
+ loginProcess = process
273
+ } catch {
274
+ // Login couldn't even launch — don't leave the app with no watcher.
275
+ lastError = "Could not start login: \(error.localizedDescription)"
276
+ start()
277
+ }
278
+ }
279
+
280
+ /// Relay a verdict for one held request — from the dropdown OR a notification
281
+ /// action. The CLI does the signing (Touch ID pops there); the row shows a
282
+ /// spinner until the settle/error comes back.
283
+ func decide(offset: Int, _ decision: String) {
284
+ setSubmitting(offset, true)
285
+ let line = #"{"offset":\#(offset),"decision":"\#(decision)"}"# + "\n"
286
+ stdinHandle?.write(Data(line.utf8))
287
+ }
288
+
289
+ // MARK: NDJSON ingestion
290
+
291
+ private func ingest(_ chunk: Data) {
292
+ buffer.append(chunk)
293
+ while let newline = buffer.firstIndex(of: 0x0A) {
294
+ let lineData = buffer.subdata(in: buffer.startIndex..<newline)
295
+ buffer.removeSubrange(buffer.startIndex...newline)
296
+ guard let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
297
+ continue
298
+ }
299
+ handle(object)
300
+ }
301
+ }
302
+
303
+ private func handle(_ event: [String: Any]) {
304
+ switch event["type"] as? String {
305
+ case "status":
306
+ sawStatus = true // this watcher connected and spoke
307
+ loggedIn = event["loggedIn"] as? Bool ?? false
308
+ connected = loggedIn // a live, authed watcher is serving
309
+ lastError = nil
310
+ principal = event["principal"] as? String
311
+ project = event["projectId"] as? String
312
+ if let key = event["key"] as? [String: Any] {
313
+ keyLabel = "\(key["kind"] as? String ?? "key") \((key["keyId"] as? String ?? "").prefix(8))"
314
+ } else {
315
+ keyLabel = nil
316
+ }
317
+ case "requested":
318
+ guard let offset = event["offset"] as? Int else { return }
319
+ let held = event["requests"] as? [[String: Any]] ?? []
320
+ // A body preview only makes sense for a batch of one.
321
+ var bodyContent: String?
322
+ if held.count == 1, let body = held[0]["body"] as? [String: Any] {
323
+ bodyContent = body["content"] as? String
324
+ if body["encoding"] as? String == "base64", let content = bodyContent {
325
+ bodyContent = "[base64] \(content)"
326
+ }
327
+ }
328
+ var request = HeldRequest(
329
+ offset: offset,
330
+ summary: event["summary"] as? String ?? "held egress request",
331
+ count: event["count"] as? Int ?? held.count,
332
+ secretPaths: event["secretPaths"] as? [String] ?? [],
333
+ ruleKey: event["ruleKey"] as? String ?? "",
334
+ body: bodyContent
335
+ )
336
+ // A backlog batch that already has a decision is shown awaiting the door
337
+ // (spinner), not as a fresh Approve prompt.
338
+ request.submitting = event["submitted"] as? Bool ?? false
339
+ if !requests.contains(where: { $0.offset == offset }) {
340
+ requests.append(request)
341
+ if !request.submitting { notify(request) }
342
+ }
343
+ case "submitted":
344
+ // A decision landed (this app's or another approver's): show the row
345
+ // awaiting the door rather than a fresh prompt, and pull any delivered
346
+ // banner — the door only honors the first decision anyway.
347
+ if let offset = event["offset"] as? Int {
348
+ setSubmitting(offset, true)
349
+ ApprovalNotifications.withdraw(offset)
350
+ }
351
+ case "settled":
352
+ if let offset = event["offset"] as? Int {
353
+ requests.removeAll { $0.offset == offset }
354
+ ApprovalNotifications.withdraw(offset)
355
+ }
356
+ case "unsettled":
357
+ // The door ignored the decision (key not enrolled / revoked) and the hold is
358
+ // still open — clear the spinner so Approve/Reject return, and say why.
359
+ if let offset = event["offset"] as? Int {
360
+ setSubmitting(offset, false)
361
+ }
362
+ lastError = "A decision wasn’t accepted — is this Mac’s approval key enrolled? (iterate approve --keys)"
363
+ case "error":
364
+ lastError = event["message"] as? String
365
+ // A signing/append failure (e.g. cancelled Touch ID) leaves the request
366
+ // pending — clear its spinner so Approve/Reject come back for a retry.
367
+ setSubmitting(event["offset"] as? Int, false)
368
+ default:
369
+ break
370
+ }
371
+ }
372
+
373
+ /// Set a row's spinner, reassigning the element so the @Published array
374
+ /// reliably republishes.
375
+ private func setSubmitting(_ offset: Int?, _ value: Bool) {
376
+ guard let offset, let index = requests.firstIndex(where: { $0.offset == offset }) else { return }
377
+ var updated = requests[index]
378
+ updated.submitting = value
379
+ requests[index] = updated
380
+ }
381
+
382
+ /// Ping the human when a request lands, even with the dropdown closed. When
383
+ /// UserNotifications authorization succeeded (needs a signed bundle — see
384
+ /// build-menubar-app.sh SIGN_IDENTITY), post a rich banner with the 𝑖 logo
385
+ /// and Approve/Reject actions that come back through `decide`. Otherwise fall
386
+ /// back to a plain osascript notification — zero setup, works everywhere.
387
+ private func notify(_ request: HeldRequest) {
388
+ let title = request.count == 1 ? "Approval needed" : "Approvals needed"
389
+ let secrets =
390
+ request.secretPaths.isEmpty ? "" : " · spends \(request.secretPaths.joined(separator: ", "))"
391
+ let body = "\(request.summary)\(secrets)"
392
+
393
+ if notificationsAuthorized {
394
+ let content = UNMutableNotificationContent()
395
+ content.title = title
396
+ content.body = body
397
+ content.categoryIdentifier = ApprovalNotifications.categoryId
398
+ content.userInfo = ["offset": request.offset]
399
+ if let url = IterateIcon.logoPNGURL,
400
+ let attachment = try? UNNotificationAttachment(identifier: "logo", url: url)
401
+ {
402
+ content.attachments = [attachment]
403
+ }
404
+ let notification = UNNotificationRequest(
405
+ identifier: ApprovalNotifications.identifier(request.offset), content: content, trigger: nil)
406
+ UNUserNotificationCenter.current().add(notification)
407
+ return
408
+ }
409
+
410
+ let script = "display notification \(quote(body)) with title \(quote(title))"
411
+ let process = Process()
412
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
413
+ process.arguments = ["-e", script]
414
+ try? process.run()
415
+ }
416
+
417
+ private func quote(_ text: String) -> String {
418
+ "\"" + text.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
419
+ }
420
+ }
421
+
422
+ // MARK: - Use my computer
423
+
424
+ /// One agent call to this computer, for the activity list. `running` flips off
425
+ /// when its `call-done` lands.
426
+ struct ComputerCall: Identifiable, Equatable {
427
+ let id: Int
428
+ let method: String
429
+ let summary: String
430
+ var running: Bool
431
+ var ok = true // set from call-done; a failed local call must not read as success
432
+ }
433
+
434
+ /// Drives `iterate use-my-computer --json`: lends this Mac to the project's
435
+ /// agents and surfaces each call they make, so the menu bar can show it in use.
436
+ ///
437
+ /// Deliberately simpler than ApprovalController: sharing is opt-in (a conscious
438
+ /// act), so if the watcher exits we just stop sharing and let the human
439
+ /// re-enable — no silent auto-reconnect that would re-lend the machine.
440
+ final class ComputerController: ObservableObject {
441
+ static let shared = ComputerController()
442
+
443
+ @Published var enabled = false // the human asked to share
444
+ @Published var sharing = false // the capability is mounted and live
445
+ @Published var reconnecting = false // mount dropped; the CLI is re-establishing it
446
+ @Published var computerName: String? // the itx.<name> agents call
447
+ @Published var recentCalls: [ComputerCall] = [] // capped display list
448
+ @Published private var activeCalls = 0 // in-flight count, independent of the cap
449
+ @Published var lastError: String?
450
+
451
+ /// A call is running right now — the menu bar's "in use" indicator. Counted
452
+ /// separately from `recentCalls` so a slow call that scrolls off the capped
453
+ /// display list still keeps the indicator honest.
454
+ var inUse: Bool { activeCalls > 0 }
455
+
456
+ private var config = MenuBarConfig.load()
457
+ private var process: Process?
458
+ private var stdinHandle: FileHandle?
459
+ private var stdoutHandle: FileHandle?
460
+ private var buffer = Data()
461
+ private var generation = 0 // bumped on every stop(); voids stale stdout chunks
462
+ private var cancellables = Set<AnyCancellable>()
463
+
464
+ private init() {
465
+ // If the app loses its session, stop sharing immediately — the mount is
466
+ // dead anyway, and otherwise the computer could stay lent while the toggle
467
+ // greys out with no way to revoke but quitting.
468
+ ApprovalController.shared.$loggedIn
469
+ .dropFirst()
470
+ .sink { [weak self] loggedIn in
471
+ if !loggedIn { self?.stop() }
472
+ }
473
+ .store(in: &cancellables)
474
+ }
475
+
476
+ func configure(_ next: MenuBarConfig) {
477
+ stop() // revoke the old project's share before accepting a new project
478
+ config = next
479
+ }
480
+
481
+ /// Turn sharing on or off — safe to drive straight from a Toggle binding.
482
+ func setEnabled(_ on: Bool) {
483
+ guard on != enabled else { return }
484
+ enabled = on
485
+ if on { start() } else { stop() }
486
+ }
487
+
488
+ private func start() {
489
+ stop()
490
+ enabled = true // stop() cleared it; we ARE (re)starting
491
+ lastError = nil
492
+ let process = Process()
493
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
494
+ process.arguments = [config.command] + config.argv(for: ["use-my-computer", "--json"])
495
+ if let cwd = config.cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) }
496
+ if let home = config.xdgConfigHome {
497
+ var environment = ProcessInfo.processInfo.environment
498
+ environment["XDG_CONFIG_HOME"] = home
499
+ process.environment = environment
500
+ }
501
+
502
+ let stdout = Pipe()
503
+ let stdin = Pipe() // held open so the child lives; closing it stops sharing
504
+ process.standardOutput = stdout
505
+ process.standardInput = stdin
506
+ self.stdoutHandle = stdout.fileHandleForReading
507
+ self.stdinHandle = stdin.fileHandleForWriting
508
+
509
+ let session = generation
510
+ stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in
511
+ let chunk = handle.availableData
512
+ if chunk.isEmpty { // EOF — the child closed stdout, i.e. it has exited.
513
+ handle.readabilityHandler = nil
514
+ DispatchQueue.main.async {
515
+ // Tear down HERE, on EOF, NOT in terminationHandler. EOF is delivered on
516
+ // this same handle after every data chunk, so a final `conflict` status
517
+ // line is ingested (and sets the takeover message) before we exit.
518
+ // Terminating off the process's death instead is a separate event that
519
+ // can win the race, bump `generation`, and drop that last line —
520
+ // leaving the generic "Stopped sharing" instead of the takeover error.
521
+ guard let self, self.generation == session else { return }
522
+ self.watcherExited()
523
+ }
524
+ return
525
+ }
526
+ DispatchQueue.main.async {
527
+ guard let self, self.generation == session else { return } // stale chunk
528
+ self.ingest(chunk)
529
+ }
530
+ }
531
+ process.terminationHandler = { [weak self] _ in
532
+ // Teardown is driven by stdout EOF (above), which is ordered after the
533
+ // child's final line; just release the finished process here. A late
534
+ // callback from a process we already replaced is voided by the guard.
535
+ DispatchQueue.main.async {
536
+ guard let self, self.generation == session else { return }
537
+ self.process = nil
538
+ }
539
+ }
540
+ do {
541
+ try process.run()
542
+ self.process = process
543
+ } catch {
544
+ enabled = false
545
+ detachIO()
546
+ lastError = "Could not share your computer: \(error.localizedDescription)"
547
+ }
548
+ }
549
+
550
+ func stop() {
551
+ generation += 1 // any late stdout chunk from this session is now stale
552
+ enabled = false
553
+ process?.terminationHandler = nil
554
+ process?.terminate()
555
+ process = nil
556
+ detachIO()
557
+ sharing = false
558
+ reconnecting = false
559
+ recentCalls = []
560
+ activeCalls = 0
561
+ }
562
+
563
+ /// The watcher exited on its own (lost socket, needs-login, crash). We're no
564
+ /// longer sharing — say so honestly rather than silently re-lending the Mac.
565
+ private func watcherExited() {
566
+ generation += 1
567
+ detachIO()
568
+ sharing = false
569
+ reconnecting = false
570
+ recentCalls = []
571
+ activeCalls = 0
572
+ if enabled {
573
+ enabled = false
574
+ if lastError == nil { lastError = "Stopped sharing your computer." }
575
+ }
576
+ }
577
+
578
+ /// Drop this session's pipes/handles and any half-read line.
579
+ private func detachIO() {
580
+ stdoutHandle?.readabilityHandler = nil
581
+ stdoutHandle = nil
582
+ stdinHandle = nil
583
+ buffer = Data()
584
+ }
585
+
586
+ private func ingest(_ chunk: Data) {
587
+ buffer.append(chunk)
588
+ while let newline = buffer.firstIndex(of: 0x0A) {
589
+ let lineData = buffer.subdata(in: buffer.startIndex..<newline)
590
+ buffer.removeSubrange(buffer.startIndex...newline)
591
+ guard let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
592
+ continue
593
+ }
594
+ handle(object)
595
+ }
596
+ }
597
+
598
+ private func handle(_ event: [String: Any]) {
599
+ switch event["type"] as? String {
600
+ case "status":
601
+ if event["conflict"] as? Bool == true {
602
+ // Another session took the name — the CLI is stopping; reflect that.
603
+ stop()
604
+ lastError = "Another session took over sharing this computer."
605
+ } else if event["loggedIn"] as? Bool == true {
606
+ // `reconnecting:true` = the mount dropped and the CLI is re-establishing
607
+ // it; the plain status (no flag) means we're live again.
608
+ reconnecting = event["reconnecting"] as? Bool == true
609
+ sharing = true
610
+ computerName = event["name"] as? String
611
+ } else {
612
+ // No session — login is the approver's job; just stop and say so.
613
+ stop()
614
+ lastError = "Sign in first, then share your computer."
615
+ }
616
+ case "call":
617
+ guard let id = event["id"] as? Int else { return }
618
+ activeCalls += 1
619
+ recentCalls.insert(
620
+ ComputerCall(
621
+ id: id,
622
+ method: event["method"] as? String ?? "?",
623
+ summary: event["summary"] as? String ?? "",
624
+ running: true),
625
+ at: 0)
626
+ if recentCalls.count > 5 { recentCalls.removeLast(recentCalls.count - 5) }
627
+ case "call-done":
628
+ guard let id = event["id"] as? Int else { return }
629
+ // Decrement the in-flight count even if the row already scrolled off the
630
+ // capped list, so `inUse` and the menu-bar dot don't stay stuck on.
631
+ if activeCalls > 0 { activeCalls -= 1 }
632
+ guard let index = recentCalls.firstIndex(where: { $0.id == id }) else { return }
633
+ // Reassign the whole element (not a nested mutation) so the @Published
634
+ // array reliably republishes. Same idiom as ApprovalController.setSubmitting.
635
+ var call = recentCalls[index]
636
+ call.running = false
637
+ call.ok = event["ok"] as? Bool ?? true // a failed local call is shown as failed, not done
638
+ recentCalls[index] = call
639
+ default:
640
+ break
641
+ }
642
+ }
643
+ }
644
+
645
+ // MARK: - Views
646
+
647
+ struct DropdownView: View {
648
+ @EnvironmentObject var controller: ApprovalController
649
+ @EnvironmentObject var computer: ComputerController
650
+
651
+ var body: some View {
652
+ VStack(alignment: .leading, spacing: 10) {
653
+ header
654
+ Divider()
655
+ if controller.requests.isEmpty {
656
+ Text("Approvals are not available on OS Next yet.")
657
+ .foregroundStyle(.secondary)
658
+ .font(.callout)
659
+ .padding(.vertical, 4)
660
+ } else {
661
+ ForEach(controller.requests) { request in
662
+ RequestRow(request: request)
663
+ }
664
+ }
665
+ Divider()
666
+ computerSection
667
+ Divider()
668
+ HStack {
669
+ if let error = controller.lastError ?? computer.lastError {
670
+ Text(error).font(.caption).foregroundStyle(.red).lineLimit(2)
671
+ }
672
+ Spacer()
673
+ Button("Quit") { NSApp.terminate(nil) }.buttonStyle(.borderless).foregroundStyle(.secondary)
674
+ }
675
+ }
676
+ .padding(14)
677
+ .frame(width: 340)
678
+ }
679
+
680
+ /// "Use my computer" — a toggle to lend this Mac to the project's agents, and,
681
+ /// while shared, a live list of the calls they make (the machine "in use").
682
+ @ViewBuilder private var computerSection: some View {
683
+ VStack(alignment: .leading, spacing: 6) {
684
+ HStack(alignment: .top) {
685
+ VStack(alignment: .leading, spacing: 2) {
686
+ Text("Use my computer").font(.callout).bold()
687
+ Text(computerStatusLine).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
688
+ }
689
+ Spacer()
690
+ Toggle("", isOn: Binding(get: { computer.enabled }, set: { computer.setEnabled($0) }))
691
+ .labelsHidden()
692
+ .toggleStyle(.switch)
693
+ // Need a session to START sharing, but never trap an ACTIVE share
694
+ // behind a greyed-out switch — always allow turning it off.
695
+ .disabled(!controller.loggedIn && !computer.enabled)
696
+ }
697
+ if computer.sharing {
698
+ if computer.recentCalls.isEmpty {
699
+ Text("Waiting for an agent to use it…").font(.caption).foregroundStyle(.secondary)
700
+ } else {
701
+ ForEach(computer.recentCalls) { call in
702
+ HStack(spacing: 6) {
703
+ if call.running {
704
+ ProgressView().controlSize(.small)
705
+ } else if call.ok {
706
+ Image(systemName: "checkmark.circle").foregroundStyle(.secondary)
707
+ } else {
708
+ Image(systemName: "xmark.circle").foregroundStyle(.orange)
709
+ }
710
+ Text("\(call.method) · \(call.summary)")
711
+ .font(.caption)
712
+ .foregroundStyle(call.running ? .primary : .secondary)
713
+ .lineLimit(1)
714
+ }
715
+ }
716
+ }
717
+ }
718
+ }
719
+ }
720
+
721
+ private var computerStatusLine: String {
722
+ if !controller.loggedIn { return "Sign in to lend this Mac to agents." }
723
+ if computer.sharing {
724
+ let name = computer.computerName.map { "itx.\($0)" } ?? "your computer"
725
+ if computer.reconnecting { return "Reconnecting \(name)…" }
726
+ return computer.inUse ? "In use now — \(name)" : "\(name) is live for this project."
727
+ }
728
+ if computer.enabled { return "Starting…" }
729
+ return "Let agents run dialogs, notifications and Swift here."
730
+ }
731
+
732
+ @ViewBuilder private var header: some View {
733
+ if !controller.loggedIn {
734
+ HStack {
735
+ Text("Not signed in").font(.headline)
736
+ Spacer()
737
+ Button("Sign in") { controller.login() }.buttonStyle(.borderedProminent)
738
+ }
739
+ } else if !controller.connected {
740
+ // Valid session, but no watcher is live (it died and is retrying or gave
741
+ // up) — say so honestly and offer a manual reconnect.
742
+ HStack {
743
+ VStack(alignment: .leading, spacing: 2) {
744
+ Text(controller.principal ?? "signed in").font(.headline)
745
+ Text("Disconnected").font(.caption).foregroundStyle(.orange)
746
+ }
747
+ Spacer()
748
+ Button("Reconnect") { controller.start() }.buttonStyle(.bordered)
749
+ }
750
+ } else {
751
+ VStack(alignment: .leading, spacing: 2) {
752
+ Text(controller.principal ?? "signed in").font(.headline)
753
+ if let project = controller.project {
754
+ Text(project).font(.caption).foregroundStyle(.secondary)
755
+ }
756
+ Text("OS Next")
757
+ .font(.caption2).foregroundStyle(.secondary)
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ struct RequestRow: View {
764
+ @EnvironmentObject var controller: ApprovalController
765
+ let request: HeldRequest
766
+
767
+ var body: some View {
768
+ VStack(alignment: .leading, spacing: 6) {
769
+ Text(request.summary).font(.system(.body, design: .monospaced)).bold()
770
+ if !request.secretPaths.isEmpty {
771
+ Text("spends \(request.secretPaths.joined(separator: ", "))")
772
+ .font(.caption).foregroundStyle(.orange)
773
+ }
774
+ if let body = request.body, !body.isEmpty {
775
+ Text(body).font(.caption).foregroundStyle(.secondary).lineLimit(2)
776
+ }
777
+ HStack {
778
+ Text("rule: \(request.ruleKey)").font(.caption2).foregroundStyle(.secondary)
779
+ Spacer()
780
+ if request.submitting {
781
+ ProgressView().controlSize(.small)
782
+ } else {
783
+ Button(request.count == 1 ? "Reject" : "Reject all") {
784
+ controller.decide(offset: request.offset, "reject")
785
+ }
786
+ .buttonStyle(.bordered)
787
+ Button(request.count == 1 ? "Approve" : "Approve all \(request.count)") {
788
+ controller.decide(offset: request.offset, "approve")
789
+ }
790
+ .buttonStyle(.borderedProminent)
791
+ }
792
+ }
793
+ }
794
+ .padding(10)
795
+ .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
796
+ }
797
+ }
798
+
799
+ // MARK: - Notifications
800
+
801
+ /// The one actionable-notification category: Approve / Reject, mapped back to
802
+ /// `decide` by the delegate. Delivery needs a signed bundle, so it's an
803
+ /// opt-in upgrade over the osascript fallback (see notify()).
804
+ enum ApprovalNotifications {
805
+ static let categoryId = "APPROVAL"
806
+
807
+ static func identifier(_ offset: Int) -> String { "approval-\(offset)" }
808
+
809
+ static func register(_ center: UNUserNotificationCenter) {
810
+ let approve = UNNotificationAction(identifier: "APPROVE", title: "Approve", options: [.foreground])
811
+ let reject = UNNotificationAction(
812
+ identifier: "REJECT", title: "Reject", options: [.destructive])
813
+ center.setNotificationCategories([
814
+ UNNotificationCategory(
815
+ identifier: categoryId, actions: [approve, reject], intentIdentifiers: [], options: [])
816
+ ])
817
+ }
818
+
819
+ /// Pull a delivered banner once its request is no longer answerable here — a
820
+ /// decision landed or it settled — so a stale Reject tap can't send a
821
+ /// dead-weight contradiction (the door only honors the first decision).
822
+ static func withdraw(_ offset: Int) {
823
+ UNUserNotificationCenter.current().removeDeliveredNotifications(
824
+ withIdentifiers: [identifier(offset)])
825
+ }
826
+ }
827
+
828
+ // MARK: - App
829
+
830
+ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
831
+ private var receivedConfiguration = false
832
+
833
+ // `open -a Iterate.app menubar.json` delivers this even to a running app.
834
+ // Unlike launch arguments it can safely retarget the existing menu bar.
835
+ func application(_ sender: NSApplication, openFiles filenames: [String]) {
836
+ do {
837
+ guard filenames.count == 1, let path = filenames.first else {
838
+ throw CocoaError(.fileReadInvalidFileName)
839
+ }
840
+ let data = try Data(contentsOf: URL(fileURLWithPath: path))
841
+ let config = try JSONDecoder().decode(MenuBarConfig.self, from: data)
842
+ ComputerController.shared.configure(config)
843
+ ApprovalController.shared.configure(config)
844
+ receivedConfiguration = true
845
+ sender.reply(toOpenOrPrint: .success)
846
+ } catch {
847
+ ApprovalController.shared.lastError = "Could not load configuration: \(error.localizedDescription)"
848
+ sender.reply(toOpenOrPrint: .failure)
849
+ }
850
+ }
851
+
852
+ func applicationDidFinishLaunching(_ notification: Notification) {
853
+ NSApp.setActivationPolicy(.accessory) // menu-bar only, no dock icon
854
+
855
+ // Approval notifications stay dormant until OS Next supports approvals.
856
+ if !receivedConfiguration { ApprovalController.shared.start() }
857
+ // Computer sharing is opt-in — it stays idle until the human flips it on.
858
+ }
859
+
860
+ /// Tear down both watchers on quit so we never leave the computer shared (or an
861
+ /// approver running) behind a closed menu bar.
862
+ func applicationWillTerminate(_ notification: Notification) {
863
+ ComputerController.shared.stop()
864
+ ApprovalController.shared.stop()
865
+ }
866
+
867
+ /// Show the banner even when the app is frontmost.
868
+ func userNotificationCenter(
869
+ _ center: UNUserNotificationCenter, willPresent notification: UNNotification,
870
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
871
+ ) {
872
+ completionHandler([.banner, .sound])
873
+ }
874
+
875
+ /// An Approve/Reject tap on a banner routes straight to `decide` (signing —
876
+ /// and Touch ID — happen in the CLI, exactly as from the dropdown).
877
+ func userNotificationCenter(
878
+ _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
879
+ withCompletionHandler completionHandler: @escaping () -> Void
880
+ ) {
881
+ if let offset = response.notification.request.content.userInfo["offset"] as? Int {
882
+ switch response.actionIdentifier {
883
+ case "APPROVE": ApprovalController.shared.decide(offset: offset, "approve")
884
+ case "REJECT": ApprovalController.shared.decide(offset: offset, "reject")
885
+ default: break
886
+ }
887
+ }
888
+ completionHandler()
889
+ }
890
+ }
891
+
892
+ @main
893
+ struct IterateApp: App {
894
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
895
+ @StateObject private var controller = ApprovalController.shared
896
+ @StateObject private var computer = ComputerController.shared
897
+
898
+ var body: some Scene {
899
+ MenuBarExtra {
900
+ DropdownView().environmentObject(controller).environmentObject(computer)
901
+ } label: {
902
+ // The 𝑖 template mark, a count when requests are waiting, and a green dot
903
+ // while an agent is actively using this computer.
904
+ Image(nsImage: IterateIcon.mark)
905
+ if controller.requests.count > 0 {
906
+ Text("\(controller.requests.count)")
907
+ }
908
+ if computer.inUse {
909
+ Image(systemName: "circle.fill").foregroundStyle(.green)
910
+ }
911
+ }
912
+ .menuBarExtraStyle(.window)
913
+ }
914
+ }