iterate 0.3.0 → 0.4.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 +164 -82
- package/dist/{next/api.d.ts → api.d.ts} +197 -33
- package/dist/{next/app-server.d.ts → app-server.d.ts} +7 -0
- package/dist/{next/app-server.mjs → app-server.mjs} +17 -15
- package/dist/app-server.mjs.map +1 -0
- package/dist/{next/app-session.mjs → app-session.mjs} +12 -15
- package/dist/app-session.mjs.map +1 -0
- package/dist/{next/app.mjs → app.mjs} +53 -14
- package/dist/app.mjs.map +1 -0
- package/dist/{next/client → client}/live-state.d.ts +11 -11
- package/dist/client/oauth.d.ts +17 -0
- package/dist/{next/client → client}/react.d.ts +10 -42
- package/dist/{next/client → client}/socket.d.ts +1 -0
- package/dist/client.mjs +156 -4
- package/dist/client.mjs.map +1 -0
- package/dist/{next/expression.d.ts → expression.d.ts} +11 -69
- package/dist/{next/expression.mjs → expression.mjs} +11 -109
- package/dist/expression.mjs.map +1 -0
- package/dist/lib-BWr-5mFO.mjs +36 -0
- package/dist/lib-BWr-5mFO.mjs.map +1 -0
- package/dist/{next/lib.d.ts → lib.d.ts} +16 -2
- package/dist/{next/lib.mjs → lib.mjs} +32 -3
- package/dist/lib.mjs.map +1 -0
- package/dist/node.d.ts +15 -3
- package/dist/node.mjs +36 -174
- package/dist/node.mjs.map +1 -1
- package/dist/{next/oauth-scopes.mjs → oauth-scopes.mjs} +1 -1
- package/dist/oauth-scopes.mjs.map +1 -0
- package/dist/{next/oauth.mjs → oauth.mjs} +14 -2
- package/dist/oauth.mjs.map +1 -0
- package/dist/principal.d.ts +8 -0
- package/dist/principal.mjs +8 -0
- package/dist/principal.mjs.map +1 -0
- package/dist/project-ingress.d.ts +58 -0
- package/dist/project-ingress.mjs +104 -0
- package/dist/project-ingress.mjs.map +1 -0
- package/dist/{next/react.mjs → react.mjs} +12 -12
- package/dist/react.mjs.map +1 -0
- package/dist/sdk/auth.d.ts +25 -0
- package/dist/sdk/index.d.ts +155 -0
- package/dist/sdk/record-pipelined-steps.d.ts +19 -0
- package/dist/sdk.mjs +245 -2
- package/dist/sdk.mjs.map +1 -0
- package/dist/{next/stream → stream}/processor.d.ts +28 -23
- package/dist/{next/stream → stream}/processor.mjs +48 -25
- package/dist/stream/processor.mjs.map +1 -0
- package/dist/{next/stream → stream}/run.d.ts +9 -6
- package/dist/{next/stream → stream}/run.mjs +13 -8
- package/dist/stream/run.mjs.map +1 -0
- package/dist/stream/test-support.d.ts +45 -0
- package/dist/stream/test-support.mjs +196 -0
- package/dist/stream/test-support.mjs.map +1 -0
- package/package.json +65 -219
- package/THIRD_PARTY_NOTICES.md +0 -55
- package/bin/iterate.js +0 -94
- package/dist/api-url-B6404M82.mjs +0 -17
- package/dist/api-url-B6404M82.mjs.map +0 -1
- package/dist/app-ref-BipL0feU.mjs +0 -35
- package/dist/app-ref-BipL0feU.mjs.map +0 -1
- package/dist/app-ref-C1CrgXqX.mjs +0 -7
- package/dist/app-ref-C1CrgXqX.mjs.map +0 -1
- package/dist/app-ref-DYai_om1.mjs +0 -7
- package/dist/app-ref-DYai_om1.mjs.map +0 -1
- package/dist/cli-D0c-pDL_.mjs +0 -1010
- package/dist/cli-D0c-pDL_.mjs.map +0 -1
- package/dist/client.d.ts +0 -3
- package/dist/cloudflare-BTm90gQ4.mjs +0 -951
- package/dist/cloudflare-BTm90gQ4.mjs.map +0 -1
- package/dist/contract-s4FW4eES.mjs +0 -309
- package/dist/contract-s4FW4eES.mjs.map +0 -1
- package/dist/document-review/index.d.ts +0 -5
- package/dist/document-review/types.d.ts +0 -107
- package/dist/document-review.mjs +0 -7015
- package/dist/document-review.mjs.map +0 -1
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs +0 -205
- package/dist/durable-object-processor-durability-CNsTjAJS.mjs.map +0 -1
- package/dist/idempotency-DleloJNt.mjs +0 -28
- package/dist/idempotency-DleloJNt.mjs.map +0 -1
- package/dist/index.d.mts +0 -5
- package/dist/index.mjs +0 -8
- package/dist/index.mjs.map +0 -1
- package/dist/itx/api-url.d.ts +0 -6
- package/dist/itx/itx-node-client.d.ts +0 -65
- package/dist/itx/itx-session.d.ts +0 -215
- package/dist/itx/owned-rpc-session.d.ts +0 -14
- package/dist/itx/query-client.d.ts +0 -10
- package/dist/itx-api.generated.d.ts +0 -6195
- package/dist/itx-session-sjud8GiT.mjs +0 -534
- package/dist/itx-session-sjud8GiT.mjs.map +0 -1
- package/dist/live-state-BJNqOwFw.mjs +0 -299
- package/dist/live-state-BJNqOwFw.mjs.map +0 -1
- package/dist/next/app-server.mjs.map +0 -1
- package/dist/next/app-session.mjs.map +0 -1
- package/dist/next/app.mjs.map +0 -1
- package/dist/next/client/oauth.d.ts +0 -12
- package/dist/next/client.mjs +0 -156
- package/dist/next/client.mjs.map +0 -1
- package/dist/next/expression.mjs.map +0 -1
- package/dist/next/lib.mjs.map +0 -1
- package/dist/next/oauth-scopes.mjs.map +0 -1
- package/dist/next/oauth.mjs.map +0 -1
- package/dist/next/principal.d.ts +0 -64
- package/dist/next/principal.mjs +0 -98
- package/dist/next/principal.mjs.map +0 -1
- package/dist/next/project-ingress.d.ts +0 -37
- package/dist/next/project-ingress.mjs +0 -75
- package/dist/next/project-ingress.mjs.map +0 -1
- package/dist/next/react.mjs.map +0 -1
- package/dist/next/sdk/auth.d.ts +0 -5
- package/dist/next/sdk/index.d.ts +0 -112
- package/dist/next/sdk.mjs +0 -139
- package/dist/next/sdk.mjs.map +0 -1
- package/dist/next/stream/processor.mjs.map +0 -1
- package/dist/next/stream/run.mjs.map +0 -1
- package/dist/next-node.d.ts +0 -15
- package/dist/next-node.mjs +0 -51
- package/dist/next-node.mjs.map +0 -1
- package/dist/processor-host-capabilities-BMFH3KTM.mjs +0 -56
- package/dist/processor-host-capabilities-BMFH3KTM.mjs.map +0 -1
- package/dist/processors/cloudflare.d.ts +0 -3
- package/dist/processors/durable-object-processor-durability.d.ts +0 -79
- package/dist/processors/event-consumption-metrics.d.ts +0 -82
- package/dist/processors/idempotency.d.ts +0 -13
- package/dist/processors/index.d.ts +0 -12
- package/dist/processors/processor-contracts.d.ts +0 -342
- package/dist/processors/processor-facet.d.ts +0 -186
- package/dist/processors/processor-host-capabilities.d.ts +0 -60
- package/dist/processors/prompt-sections.d.ts +0 -17
- package/dist/processors/rpc-types.d.ts +0 -515
- package/dist/processors/schemas.d.ts +0 -102
- package/dist/processors/stream-handle.d.ts +0 -45
- package/dist/processors/stream-processor-keepalive.d.ts +0 -95
- package/dist/processors/stream-processor-registry.d.ts +0 -233
- package/dist/processors/stream-processor-runner.d.ts +0 -289
- package/dist/processors/stream-processor.d.ts +0 -339
- package/dist/processors/stream-runtime-metrics.d.ts +0 -107
- package/dist/processors/testing.d.ts +0 -302
- package/dist/processors-BoNyeBfQ.mjs +0 -10
- package/dist/processors-BoNyeBfQ.mjs.map +0 -1
- package/dist/processors-cloudflare.mjs +0 -3
- package/dist/processors-testing.mjs +0 -435
- package/dist/processors-testing.mjs.map +0 -1
- package/dist/processors.mjs +0 -52
- package/dist/processors.mjs.map +0 -1
- package/dist/protocol-DnK_f2m6.mjs +0 -251
- package/dist/protocol-DnK_f2m6.mjs.map +0 -1
- package/dist/sdk/capnweb/index.d.ts +0 -2
- package/dist/sdk/capnweb/live-state/compact.d.ts +0 -5
- package/dist/sdk/capnweb/live-state/diff.d.ts +0 -41
- package/dist/sdk/capnweb/live-state/engine.d.ts +0 -44
- package/dist/sdk/capnweb/live-state/index.d.ts +0 -41
- package/dist/sdk/capnweb/live-state/protocol.d.ts +0 -87
- package/dist/sdk/capnweb/live-state/retain.d.ts +0 -23
- package/dist/sdk/capnweb/live-state/store.d.ts +0 -20
- package/dist/sdk/capnweb/live-state/types.d.ts +0 -11
- package/dist/sdk/capnweb/react.d.ts +0 -45
- package/dist/sdk/capnweb/react.mjs +0 -316
- package/dist/sdk/capnweb/react.mjs.map +0 -1
- package/dist/sdk/capnweb.mjs +0 -4
- package/dist/sdk/itx/react.d.ts +0 -191
- package/dist/sdk/itx/react.mjs +0 -383
- package/dist/sdk/itx/react.mjs.map +0 -1
- package/dist/sdk-DMB-IM11.mjs +0 -933
- package/dist/sdk-DMB-IM11.mjs.map +0 -1
- package/dist/sdk.d.ts +0 -339
- package/dist/serve-itx.d.ts +0 -46
- package/dist/starter-apps/flake-dashboard/app-ref.d.ts +0 -31
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs +0 -1055
- package/dist/starter-apps/flake-dashboard/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/flake-dashboard/contract.d.ts +0 -4839
- package/dist/starter-apps/flake-dashboard/contract.mjs +0 -2
- package/dist/starter-apps/flake-dashboard/index.d.ts +0 -17
- package/dist/starter-apps/flake-dashboard/index.mjs +0 -56
- package/dist/starter-apps/flake-dashboard/index.mjs.map +0 -1
- package/dist/starter-apps/flake-dashboard/worker.d.ts +0 -4607
- package/dist/starter-apps/github-ai-linter/ai-linter.d.ts +0 -8914
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs +0 -17987
- package/dist/starter-apps/github-ai-linter/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/github-ai-linter/contract.d.ts +0 -9193
- package/dist/starter-apps/github-ai-linter/index.d.ts +0 -10
- package/dist/starter-apps/github-ai-linter/index.mjs +0 -36
- package/dist/starter-apps/github-ai-linter/index.mjs.map +0 -1
- package/dist/starter-apps/github-ai-linter/prompt.d.ts +0 -13
- package/dist/starter-apps/github-ai-linter/review-bot.d.ts +0 -808
- package/dist/starter-apps/github-ai-linter/rules.d.ts +0 -34
- package/dist/starter-apps/github-ai-linter/worker-ref.d.ts +0 -19
- package/dist/starter-apps/github-ai-linter/worker.d.ts +0 -19
- package/dist/starter-apps/github-ai-linter/worker.mjs +0 -947
- package/dist/starter-apps/github-ai-linter/worker.mjs.map +0 -1
- package/dist/starter-apps/guestbook/app-ref.d.ts +0 -27
- package/dist/starter-apps/guestbook/client.d.ts +0 -7
- package/dist/starter-apps/guestbook/client.mjs +0 -59
- package/dist/starter-apps/guestbook/configured-worker.mjs +0 -205
- package/dist/starter-apps/guestbook/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/guestbook/index.d.ts +0 -9
- package/dist/starter-apps/guestbook/index.mjs +0 -31
- package/dist/starter-apps/guestbook/index.mjs.map +0 -1
- package/dist/starter-apps/guestbook/processor.d.ts +0 -2267
- package/dist/starter-apps/guestbook/worker.d.ts +0 -26
- package/dist/starter-apps/guestbook/worker.mjs +0 -191
- package/dist/starter-apps/guestbook/worker.mjs.map +0 -1
- package/dist/starter-apps/media/configured-worker.mjs +0 -577
- package/dist/starter-apps/media/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/media/index.mjs +0 -36
- package/dist/starter-apps/media/index.mjs.map +0 -1
- package/dist/starter-apps/media/ref.mjs +0 -20
- package/dist/starter-apps/media/ref.mjs.map +0 -1
- package/dist/starter-apps/media/worker.mjs +0 -579
- package/dist/starter-apps/media/worker.mjs.map +0 -1
- package/dist/starter-apps/notes/configured-worker.mjs +0 -6134
- package/dist/starter-apps/notes/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/notes/index.mjs +0 -23
- package/dist/starter-apps/notes/index.mjs.map +0 -1
- package/dist/starter-apps/notes/ref.mjs +0 -21
- package/dist/starter-apps/notes/ref.mjs.map +0 -1
- package/dist/starter-apps/notes/worker.mjs +0 -427
- package/dist/starter-apps/notes/worker.mjs.map +0 -1
- package/dist/starter-apps/todo/client.mjs +0 -59
- package/dist/starter-apps/todo/configured-worker.mjs +0 -2864
- package/dist/starter-apps/todo/configured-worker.mjs.map +0 -1
- package/dist/starter-apps/todo/index.d.ts +0 -8
- package/dist/starter-apps/todo/index.mjs +0 -29
- package/dist/starter-apps/todo/index.mjs.map +0 -1
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs +0 -2082
- package/dist/stream-processor-keepalive-DAQTP6m3.mjs.map +0 -1
- package/dist/usingCtx-mZx5nsAW.mjs +0 -11800
- package/dist/usingCtx-mZx5nsAW.mjs.map +0 -1
- package/dist/worker-ref-DZxPDmb_.mjs +0 -390
- package/dist/worker-ref-DZxPDmb_.mjs.map +0 -1
- package/dist/worker.d.mts +0 -33
- package/dist/worker.mjs +0 -18
- package/dist/worker.mjs.map +0 -1
- package/menubar/Iterate.entitlements +0 -12
- package/menubar/Iterate.swift +0 -914
- package/menubar/IterateIcon.swift +0 -145
- package/menubar/README.md +0 -28
- package/menubar/build-menubar-app.sh +0 -59
- /package/dist/{next/api.mjs → api.mjs} +0 -0
- /package/dist/{next/app-session.d.ts → app-session.d.ts} +0 -0
- /package/dist/{next/app.d.ts → app.d.ts} +0 -0
- /package/dist/{next/oauth-scopes.d.ts → oauth-scopes.d.ts} +0 -0
package/menubar/Iterate.swift
DELETED
|
@@ -1,914 +0,0 @@
|
|
|
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
|
-
}
|