Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - iPhone plan, step 7: approve or deny from the iPhone
|
||||
//
|
||||
// While Claude Code (or Cursor, Codex) waits for a permission:
|
||||
// 1. an `ApprovalRequest` record goes to iCloud; the iPhone's query subscription
|
||||
// turns it into a notification;
|
||||
// 2. the Mac looks for a `Decision` record every 2 s — only while a request is
|
||||
// pending, so nothing runs at rest;
|
||||
// 3. a decision is applied only if its fingerprint matches the request still
|
||||
// pending (same session, tool, command and input). A late decision, or one
|
||||
// meant for another command, is ignored. Only "allow" and "deny" exist: no
|
||||
// "always" from the phone.
|
||||
// The request record is deleted as soon as the approval is resolved, on either side.
|
||||
|
||||
@MainActor
|
||||
final class ApprovalRelay {
|
||||
static let shared = ApprovalRelay()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var zoneID: CKRecordZone.ID { SessionSnapshot.zoneID }
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
private var current: (fingerprint: String, since: Date)?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var changeToken: CKServerChangeToken?
|
||||
|
||||
/// Stable identifier of one pending request.
|
||||
nonisolated static func fingerprint(_ approval: ApprovalInfo) -> String {
|
||||
let raw = [approval.pillId, approval.sessionId, approval.tool, approval.command, approval.inputKey]
|
||||
.joined(separator: "\u{1F}")
|
||||
return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
// @Published sends the new value before the property changes, so the
|
||||
// value is passed along rather than read back from AppState.
|
||||
cancellable = AppState.shared.$pendingApproval
|
||||
.removeDuplicates { $0.map(Self.fingerprint) == $1.map(Self.fingerprint) }
|
||||
.sink { [weak self] approval in
|
||||
MainActor.assumeIsolated { self?.pendingChanged(to: approval) }
|
||||
}
|
||||
log("relay on")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
pendingChanged(to: nil)
|
||||
}
|
||||
|
||||
// MARK: Request lifecycle
|
||||
|
||||
private func pendingChanged(to approval: ApprovalInfo?) {
|
||||
let fingerprint = approval.map(Self.fingerprint)
|
||||
if let old = current, old.fingerprint != fingerprint {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
current = nil
|
||||
let id = requestID(old.fingerprint)
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: [id]) }
|
||||
}
|
||||
guard let fingerprint, let approval, current?.fingerprint != fingerprint else { return }
|
||||
current = (fingerprint, Date())
|
||||
Task { await publishRequest(approval, fingerprint: fingerprint) }
|
||||
pollTask = Task { [weak self] in
|
||||
// The Mac dismisses the request after 115 s anyway.
|
||||
for _ in 0..<60 {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if await self.checkDecisions(for: fingerprint) { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestID(_ fingerprint: String) -> CKRecord.ID {
|
||||
CKRecord.ID(recordName: "approval-\(fingerprint.prefix(32))", zoneID: zoneID)
|
||||
}
|
||||
|
||||
private func publishRequest(_ approval: ApprovalInfo, fingerprint: String) async {
|
||||
let record = CKRecord(recordType: "ApprovalRequest", recordID: requestID(fingerprint))
|
||||
record["pillId"] = approval.pillId
|
||||
record["fingerprint"] = fingerprint
|
||||
record["createdAt"] = Date()
|
||||
record.encryptedValues["tool"] = approval.tool
|
||||
record.encryptedValues["command"] = approval.command
|
||||
do {
|
||||
_ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys)
|
||||
log("approval request sent to the iPhone (\(approval.tool))")
|
||||
} catch {
|
||||
log("approval request failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Decisions
|
||||
|
||||
/// Returns true once a matching decision was applied.
|
||||
private func checkDecisions(for fingerprint: String) async -> Bool {
|
||||
guard current?.fingerprint == fingerprint, let since = current?.since else { return true }
|
||||
var found: [(CKRecord.ID, String, String)] = [] // id, fingerprint, decision
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: zoneID, since: changeToken)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == "Decision" else { continue }
|
||||
let record = mod.record
|
||||
let decidedAt = record["decidedAt"] as? Date ?? .distantPast
|
||||
guard decidedAt >= since.addingTimeInterval(-5) else { continue }
|
||||
found.append((id, record["fingerprint"] as? String ?? "", record["decision"] as? String ?? ""))
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
return false
|
||||
} catch {
|
||||
log("decision check failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
guard !found.isEmpty else { return false }
|
||||
// Decisions are single use.
|
||||
let ids = found.map(\.0)
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids) }
|
||||
|
||||
guard let match = found.first(where: { $0.1 == fingerprint }) else {
|
||||
log("ignored \(found.count) decision(s) for another request")
|
||||
return false
|
||||
}
|
||||
apply(match.2, fingerprint: fingerprint)
|
||||
return true
|
||||
}
|
||||
|
||||
private func apply(_ decision: String, fingerprint: String) {
|
||||
// Check again on the main actor, right before answering the hook.
|
||||
guard let pending = AppState.shared.pendingApproval,
|
||||
Self.fingerprint(pending) == fingerprint else {
|
||||
log("decision arrived after the request was resolved, ignored")
|
||||
return
|
||||
}
|
||||
switch decision {
|
||||
case "allow":
|
||||
log("allowed from the iPhone: \(pending.tool)")
|
||||
HookServer.shared.sendApprovalDecision("allow")
|
||||
case "deny":
|
||||
log("denied from the iPhone: \(pending.tool)")
|
||||
HookServer.shared.sendApprovalDecision("deny")
|
||||
default:
|
||||
log("unknown decision '\(decision)', ignored")
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[approval] \(message)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,277 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
|
||||
// MARK: - iPhone link
|
||||
//
|
||||
// Starts and stops the iPhone sync (Settings → General → iPhone, off by
|
||||
// default): push registration and SessionPublisher. Nothing runs and nothing
|
||||
// is sent to iCloud while it is off.
|
||||
//
|
||||
// Step 1 spike, kept for testing: when the phoneLinkPing default is on, proves
|
||||
// that the Mac and the iPhone app share a private CloudKit database:
|
||||
// writes a `Ping` every 60 s, waits for the iPhone's `Pong`, and logs the
|
||||
// round trip to ~/Library/Logs/NotchBuddy/nb.log. Pongs arrive both through a
|
||||
// silent push (CKDatabaseSubscription) and a 5 s poll, so the log shows which
|
||||
// path is faster. Compiled only with PHONE_LINK; normal builds never see it.
|
||||
|
||||
@MainActor
|
||||
final class CloudProbe {
|
||||
static let shared = CloudProbe()
|
||||
|
||||
static let containerID = "iCloud.fr.louisraille.Coucou"
|
||||
static let zoneID = CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName)
|
||||
private static let subscriptionID = "coucou-zone-mac"
|
||||
|
||||
private let container = CKContainer(identifier: CloudProbe.containerID)
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
|
||||
private let launchDate = Date()
|
||||
private var ready = false
|
||||
private var pingCount = 0
|
||||
private var changeToken: CKServerChangeToken?
|
||||
private var seenPongs = Set<String>()
|
||||
private var fetching = false
|
||||
private var lastPushAt: Date?
|
||||
private var started = false
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
private var appLabel: String {
|
||||
#if APPSTORE
|
||||
"CoucouAppStore"
|
||||
#else
|
||||
"NotchBuddy"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var macName: String {
|
||||
Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
}
|
||||
|
||||
static let enabledKey = "iPhoneSyncEnabled"
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
|
||||
|
||||
/// Called at launch: starts only if the user turned the iPhone sync on.
|
||||
func startIfEnabled() {
|
||||
if Self.isEnabled { start() }
|
||||
}
|
||||
|
||||
func setEnabled(_ on: Bool) {
|
||||
UserDefaults.standard.set(on, forKey: Self.enabledKey)
|
||||
if on { start() } else { stop() }
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
pingTask?.cancel(); pingTask = nil
|
||||
pollTask?.cancel(); pollTask = nil
|
||||
NSApplication.shared.unregisterForRemoteNotifications()
|
||||
SessionPublisher.shared.stop()
|
||||
ApprovalRelay.shared.stop()
|
||||
QuestionRelay.shared.stop()
|
||||
ServiceDetailRunner.shared.stop()
|
||||
ServicePublisher.shared.stop()
|
||||
TurnRecorder.shared.stop()
|
||||
#if !APPSTORE
|
||||
InstructionRunner.shared.stop()
|
||||
#endif
|
||||
LiveActivityRelay.shared.stop()
|
||||
log("iPhone sync off")
|
||||
}
|
||||
|
||||
private func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
#if DEBUG
|
||||
let build = "debug"
|
||||
#else
|
||||
let build = "release"
|
||||
#endif
|
||||
log("starting (\(appLabel), \(build) build, container \(Self.containerID))")
|
||||
NSApplication.shared.registerForRemoteNotifications()
|
||||
SessionPublisher.shared.start()
|
||||
ApprovalRelay.shared.start()
|
||||
QuestionRelay.shared.start()
|
||||
ServiceDetailRunner.shared.start()
|
||||
ServicePublisher.shared.start()
|
||||
TurnRecorder.shared.start()
|
||||
#if !APPSTORE
|
||||
InstructionRunner.shared.startIfEnabled()
|
||||
#endif
|
||||
LiveActivityRelay.shared.startIfEnabled()
|
||||
// The silent database subscription, so the iPhone's requests (services) wake this Mac.
|
||||
Task { _ = await prepare() }
|
||||
|
||||
// Step 1 Ping/Pong test: off unless asked for, so the Mac stays idle at rest
|
||||
// (defaults write fr.louisraille.NotchBuddy phoneLinkPing -bool YES).
|
||||
guard UserDefaults.standard.bool(forKey: "phoneLinkPing") else {
|
||||
log("ping test off (phoneLinkPing)")
|
||||
return
|
||||
}
|
||||
pingTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.pingTick()
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
}
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
await self?.fetchChanges(source: "poll")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
/// Checks the iCloud account, creates the zone and the subscription.
|
||||
/// Returns false (and logs why) when iCloud isn't usable yet; retried on the next tick.
|
||||
private func prepare() async -> Bool {
|
||||
if ready { return true }
|
||||
do {
|
||||
let status = try await container.accountStatus()
|
||||
guard status == .available else {
|
||||
log("iCloud account not available (status \(describe(status))), will retry in 60 s")
|
||||
return false
|
||||
}
|
||||
_ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: Self.zoneID)], deleting: [])
|
||||
log("zone Coucou ready")
|
||||
|
||||
let sub = CKDatabaseSubscription(subscriptionID: Self.subscriptionID)
|
||||
let info = CKSubscription.NotificationInfo()
|
||||
info.shouldSendContentAvailable = true // silent push
|
||||
sub.notificationInfo = info
|
||||
_ = try await database.modifySubscriptions(saving: [sub], deleting: [])
|
||||
log("database subscription saved")
|
||||
let subs = try await database.allSubscriptions()
|
||||
log("subscriptions on this iCloud account: \(subs.map(\.subscriptionID).sorted().joined(separator: ", "))")
|
||||
ready = true
|
||||
return true
|
||||
} catch {
|
||||
log("setup failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Ping
|
||||
|
||||
private func pingTick() async {
|
||||
guard await prepare() else { return }
|
||||
pingCount += 1
|
||||
let record = CKRecord(recordType: "Ping",
|
||||
recordID: CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID))
|
||||
let sentAt = Date()
|
||||
record["macName"] = macName
|
||||
record["app"] = appLabel
|
||||
record["sentAt"] = sentAt
|
||||
record.encryptedValues["message"] = "Ping #\(pingCount) from \(appLabel)"
|
||||
do {
|
||||
_ = try await database.save(record)
|
||||
log("Ping #\(pingCount) saved in \(String(format: "%.1f", Date().timeIntervalSince(sentAt))) s")
|
||||
} catch {
|
||||
log("Ping #\(pingCount) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Pong
|
||||
|
||||
func handleRemoteNotification(_ userInfo: [String: Any]) {
|
||||
guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else {
|
||||
log("remote notification received, not from CloudKit (keys: \(userInfo.keys.sorted().joined(separator: ", ")))")
|
||||
return
|
||||
}
|
||||
lastPushAt = Date()
|
||||
// A request from the iPhone (a service to read, an action) may be waiting.
|
||||
Task { await ServiceDetailRunner.shared.checkNow() }
|
||||
guard pingTask != nil else { return } // the Pong fetch is only for the ping test
|
||||
log("push received")
|
||||
Task { await fetchChanges(source: "push") }
|
||||
}
|
||||
|
||||
private func fetchChanges(source: String) async {
|
||||
guard ready, !fetching else { return }
|
||||
fetching = true
|
||||
defer { fetching = false }
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: Self.zoneID, since: changeToken)
|
||||
for (_, result) in changes.modificationResultsByID {
|
||||
if case .success(let mod) = result { handle(mod.record, source: source) }
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
} catch let error as CKError where error.code == .zoneNotFound {
|
||||
log("zone Coucou missing, recreating")
|
||||
ready = false
|
||||
} catch {
|
||||
log("fetch (\(source)) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ record: CKRecord, source: String) {
|
||||
guard record.recordType == "Pong" else { return }
|
||||
let id = record.recordID.recordName
|
||||
guard !seenPongs.contains(id) else { return }
|
||||
seenPongs.insert(id)
|
||||
// Older pongs from previous runs: remember them, don't log them.
|
||||
guard let created = record.creationDate, created >= launchDate else { return }
|
||||
|
||||
let message = record.encryptedValues["message"] as? String ?? "?"
|
||||
var line = "Pong from iPhone: \(message)"
|
||||
if let pingSentAt = record["pingSentAt"] as? Date {
|
||||
line += String(format: ", round trip %.1f s", Date().timeIntervalSince(pingSentAt))
|
||||
}
|
||||
if let repliedAt = record["repliedAt"] as? Date {
|
||||
line += String(format: ", %.1f s after the tap", Date().timeIntervalSince(repliedAt))
|
||||
}
|
||||
line += " (via \(source)"
|
||||
if source == "poll", let push = lastPushAt, Date().timeIntervalSince(push) < 10 {
|
||||
line += ", push came \(String(format: "%.1f", Date().timeIntervalSince(push))) s ago"
|
||||
}
|
||||
line += ")"
|
||||
log(line)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func describe(_ status: CKAccountStatus) -> String {
|
||||
switch status {
|
||||
case .available: "available"
|
||||
case .noAccount: "no account"
|
||||
case .restricted: "restricted"
|
||||
case .couldNotDetermine: "could not determine"
|
||||
case .temporarilyUnavailable: "temporarily unavailable"
|
||||
@unknown default: "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes to nb.log and to the Xcode console (the sandboxed build's nb.log
|
||||
/// sits in its container, which Terminal can't read).
|
||||
func log(_ message: String) {
|
||||
appendAppLog("nb.log", "[PhoneLink] \(message)")
|
||||
print("[PhoneLink] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
@objc func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
let token = deviceToken.prefix(4).map { String(format: "%02x", $0) }.joined()
|
||||
CloudProbe.shared.log("registered for remote notifications (token \(token)…)")
|
||||
}
|
||||
|
||||
@objc func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
CloudProbe.shared.log("remote notification registration failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@objc func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) {
|
||||
CloudProbe.shared.handleRemoteNotification(userInfo)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,182 @@
|
||||
#if PHONE_LINK && !APPSTORE
|
||||
import AppKit
|
||||
import CloudKit
|
||||
|
||||
// MARK: - Instructions from the iPhone
|
||||
//
|
||||
// The iPhone writes an `Instruction` record (text encrypted) for a session.
|
||||
// When the user turned it on (Settings → General → iPhone, off by default),
|
||||
// this Mac checks every 15 s, takes each instruction once (the record is
|
||||
// deleted on read), and continues that same Claude Code conversation in the
|
||||
// background: `claude -p <text> --resume <session id>`, in the session's own
|
||||
// folder. The folder and the session come from what this Mac saw in the
|
||||
// hooks, never from the iPhone. Hooks keep working, so the notch, the iPhone
|
||||
// and the permission requests (approved by hand, with Face ID on the phone)
|
||||
// follow the run like any other turn.
|
||||
// GitHub build only: the App Store build is sandboxed and can't start `claude`.
|
||||
|
||||
@MainActor
|
||||
final class InstructionRunner {
|
||||
static let shared = InstructionRunner()
|
||||
|
||||
nonisolated static let enabledKey = "iPhoneInstructionsEnabled"
|
||||
nonisolated static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
|
||||
|
||||
private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase }
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var changeToken: CKServerChangeToken?
|
||||
private var running: [String: Process] = [:] // by session id
|
||||
|
||||
/// Instructions older than this are dropped instead of run.
|
||||
private let maxAge: TimeInterval = 10 * 60
|
||||
|
||||
func setEnabled(_ on: Bool) {
|
||||
UserDefaults.standard.set(on, forKey: Self.enabledKey)
|
||||
if on && CloudProbe.isEnabled { start() } else { stop() }
|
||||
}
|
||||
|
||||
func startIfEnabled() {
|
||||
if Self.isEnabled { start() }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard pollTask == nil else { return }
|
||||
log("on: checking for instructions every 15 s")
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.check()
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard pollTask != nil else { return }
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
log("off")
|
||||
}
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
private func check() async {
|
||||
var found: [CKRecord] = []
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken)
|
||||
for (_, result) in changes.modificationResultsByID {
|
||||
if case .success(let mod) = result, mod.record.recordType == "Instruction" { found.append(mod.record) }
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !found.isEmpty else { return }
|
||||
// Single use: gone from iCloud before anything runs.
|
||||
_ = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID))
|
||||
for record in found.sorted(by: { ($0["createdAt"] as? Date ?? .distantPast) < ($1["createdAt"] as? Date ?? .distantPast) }) {
|
||||
handle(record)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ record: CKRecord) {
|
||||
let pillId = record["pillId"] as? String ?? ""
|
||||
let createdAt = record["createdAt"] as? Date ?? .distantPast
|
||||
let text = (record.encryptedValues["text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard Self.isEnabled else { log("ignored: instructions are off"); return }
|
||||
guard Date().timeIntervalSince(createdAt) < maxAge else { log("ignored: older than 10 min"); return }
|
||||
guard !text.isEmpty, text.count <= 8000 else { log("ignored: empty or too long"); return }
|
||||
guard pillId == "integration_claude" || pillId == "agent_cursor" else { log("ignored: \(pillId) can't take instructions"); return }
|
||||
guard let session = TurnRecorder.shared.lastSession(for: pillId) else {
|
||||
log("ignored: no Claude Code session seen for \(pillId) yet")
|
||||
return
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory), isDirectory.boolValue else {
|
||||
log("ignored: the session's folder is gone")
|
||||
return
|
||||
}
|
||||
guard running[session.sessionId] == nil else {
|
||||
log("ignored: an instruction is already running for this session")
|
||||
return
|
||||
}
|
||||
guard let claude = Self.claudeExecutable() else {
|
||||
log("can't find the claude command (looked in ~/.claude/local, Homebrew, /usr/local/bin, ~/.npm-global/bin)")
|
||||
return
|
||||
}
|
||||
run(claude: claude, text: text, sessionId: session.sessionId, cwd: session.cwd, pillId: pillId)
|
||||
}
|
||||
|
||||
// MARK: Running
|
||||
|
||||
private func run(claude: String, text: String, sessionId: String, cwd: String, pillId: String) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: claude)
|
||||
process.arguments = ["-p", text, "--resume", sessionId]
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: cwd)
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
env["PATH"] = [URL(fileURLWithPath: claude).deletingLastPathComponent().path, "/opt/homebrew/bin", "/usr/local/bin",
|
||||
"/usr/bin", "/bin", "/usr/sbin", "/sbin", "\(home)/.local/bin", env["PATH"] ?? ""].joined(separator: ":")
|
||||
// The hooks route events by editor: keep them on the same pill.
|
||||
if pillId == "agent_cursor" {
|
||||
env["__CFBundleIdentifier"] = "com.todesktop.230313mzl4w4u92"
|
||||
} else {
|
||||
env["TERM_PROGRAM"] = "vscode"
|
||||
}
|
||||
process.environment = env
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
// Output goes to a file (a pipe could fill up and stall claude); its
|
||||
// end is logged if the run fails.
|
||||
let logURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Logs/NotchBuddy/instruction-last.log")
|
||||
try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
||||
let output = try? FileHandle(forWritingTo: logURL)
|
||||
process.standardOutput = output ?? FileHandle.nullDevice
|
||||
process.standardError = output ?? FileHandle.nullDevice
|
||||
process.terminationHandler = { [weak self] finished in
|
||||
try? output?.close()
|
||||
let data = (try? Data(contentsOf: logURL)) ?? Data()
|
||||
let tail = String(decoding: data.suffix(400), as: UTF8.self)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
let status = finished.terminationStatus
|
||||
Task { @MainActor in
|
||||
self?.running[sessionId] = nil
|
||||
self?.log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)")
|
||||
}
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
running[sessionId] = process
|
||||
log("running in \(URL(fileURLWithPath: cwd).lastPathComponent) (\(sessionId.prefix(8))): \(text.count) chars")
|
||||
} catch {
|
||||
log("couldn't start claude: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `claude` usually lives; the app doesn't get the shell's PATH.
|
||||
static func claudeExecutable() -> String? {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let candidates = [
|
||||
"\(home)/.claude/local/claude",
|
||||
"\(home)/.local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"\(home)/.npm-global/bin/claude",
|
||||
"\(home)/.bun/bin/claude",
|
||||
]
|
||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[instruction] \(message)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,413 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - iPhone plan, step 8: Mochi leaves for the iPhone
|
||||
//
|
||||
// When the Mac locks while an agent is working, Mochi moves to the iPhone's
|
||||
// Dynamic Island and Lock Screen (a Live Activity), and comes back when the
|
||||
// Mac unlocks. Live Activity pushes need the APNs key, which can't ship in the
|
||||
// app, so they go through the user's relay (relay/ in this repo, a stateless
|
||||
// Cloudflare Worker). The iPhone's push tokens come from iCloud (`PhoneToken`
|
||||
// records, written by LiveActivityLink on the iPhone).
|
||||
//
|
||||
// Sent to the relay: the iPhone's token and MochiActivityState (agent, state,
|
||||
// counts). Never a project name, a step, a command or a path.
|
||||
// Runs only while the Mac is locked with an agent going: lock notifications
|
||||
// and AppState changes, no timer at rest.
|
||||
|
||||
@MainActor
|
||||
final class LiveActivityRelay {
|
||||
static let shared = LiveActivityRelay()
|
||||
|
||||
static let enabledKey = "iPhoneLiveActivityEnabled"
|
||||
/// Overrides the relay address (defaults write fr.louisraille.NotchBuddy phoneRelayURL <url>).
|
||||
static let relayURLKey = "phoneRelayURL"
|
||||
/// The deployed relay (relay/README.md).
|
||||
static let defaultRelayURL = "https://coucou-relay.raillelouis.workers.dev"
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
|
||||
|
||||
private struct Phone {
|
||||
var env: String
|
||||
var startToken: String
|
||||
var updateToken: String
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase }
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var cancellable: AnyCancellable?
|
||||
private var phones: [String: Phone] = [:]
|
||||
private var changeToken: CKServerChangeToken?
|
||||
|
||||
private var locked = false
|
||||
private var latest: MochiActivityState?
|
||||
/// Set while a Live Activity runs on the iPhone(s).
|
||||
private var startedAt: Date?
|
||||
/// Sent with every push of an activity, so the iPhone shows the time since.
|
||||
private var activitySince: Int?
|
||||
/// The startedAt whose start push actually went out.
|
||||
private var startSent: Date?
|
||||
private var sent: MochiActivityState?
|
||||
private var sending = false
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retries = 0
|
||||
/// The phase a start was sent again for, so it is only tried once each.
|
||||
private var restartedFor: String?
|
||||
/// A start waiting to see if the Mac stays locked, and an end waiting to
|
||||
/// see if it stays unlocked: iOS allows only so many starts an hour, so a
|
||||
/// quick lock and unlock doesn't spend one.
|
||||
private var lockTask: Task<Void, Never>?
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
/// Ends the activity 10 minutes after the agents are done, if nothing restarts.
|
||||
private var doneTask: Task<Void, Never>?
|
||||
|
||||
private var relayURL: URL? {
|
||||
let value = UserDefaults.standard.string(forKey: Self.relayURLKey) ?? Self.defaultRelayURL
|
||||
return value.isEmpty ? nil : URL(string: value)?.appendingPathComponent("v1/live-activity")
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
func setEnabled(_ on: Bool) {
|
||||
UserDefaults.standard.set(on, forKey: Self.enabledKey)
|
||||
if on && CloudProbe.isEnabled { start() } else { stop() }
|
||||
}
|
||||
|
||||
func startIfEnabled() {
|
||||
if Self.isEnabled { start() }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
let center = DistributedNotificationCenter.default()
|
||||
observers = [
|
||||
center.addObserver(forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.lockChanged(true) }
|
||||
},
|
||||
center.addObserver(forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.lockChanged(false) }
|
||||
},
|
||||
]
|
||||
let state = AppState.shared
|
||||
cancellable = state.$tasks
|
||||
.combineLatest(state.$pendingApproval)
|
||||
.map { tasks, approval in Self.leadState(tasks: tasks, approval: approval) }
|
||||
.removeDuplicates()
|
||||
.debounce(for: .seconds(1), scheduler: RunLoop.main)
|
||||
.sink { [weak self] lead in
|
||||
MainActor.assumeIsolated { self?.stateChanged(lead) }
|
||||
}
|
||||
log(relayURL == nil ? "on, but no relay address yet (relay/README.md)" : "on")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard cancellable != nil else { return }
|
||||
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
|
||||
observers = []
|
||||
cancellable = nil
|
||||
lockTask?.cancel(); lockTask = nil
|
||||
unlockTask?.cancel(); unlockTask = nil
|
||||
if startedAt != nil { finish(dismissAfter: 0) }
|
||||
log("off")
|
||||
}
|
||||
|
||||
// MARK: Events
|
||||
|
||||
private func lockChanged(_ isLocked: Bool) {
|
||||
locked = isLocked
|
||||
if isLocked {
|
||||
log("Mac locked")
|
||||
unlockTask?.cancel(); unlockTask = nil
|
||||
if startedAt != nil {
|
||||
// Locked again before the activity left: it carries on.
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if let latest, latest.isActive { beginSoon(latest) }
|
||||
} else {
|
||||
log("Mac unlocked")
|
||||
lockTask?.cancel(); lockTask = nil
|
||||
guard startedAt != nil else { return }
|
||||
// Mochi comes back to the notch, unless the Mac locks again within 30 s.
|
||||
unlockTask = Task {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
guard !Task.isCancelled, !locked else { return }
|
||||
unlockTask = nil
|
||||
finish(dismissAfter: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts right away when an agent needs you; otherwise once the Mac has
|
||||
/// stayed locked for 20 s.
|
||||
private func beginSoon(_ state: MochiActivityState) {
|
||||
if state.tone == "waiting" || state.tone == "question" {
|
||||
lockTask?.cancel(); lockTask = nil
|
||||
begin(state)
|
||||
return
|
||||
}
|
||||
guard lockTask == nil else { return }
|
||||
lockTask = Task {
|
||||
try? await Task.sleep(for: .seconds(20))
|
||||
lockTask = nil
|
||||
guard !Task.isCancelled, locked, startedAt == nil, let latest, latest.isActive else { return }
|
||||
begin(latest)
|
||||
}
|
||||
}
|
||||
|
||||
private func stateChanged(_ lead: MochiActivityState?) {
|
||||
if lead?.tone != latest?.tone, let lead { log("phase: \(lead.agent) \(lead.statusText)\(locked ? "" : " (Mac unlocked, stays on the Mac)")") }
|
||||
latest = lead
|
||||
guard locked else { return }
|
||||
if startedAt == nil {
|
||||
if let lead, lead.isActive { beginSoon(lead) }
|
||||
return
|
||||
}
|
||||
if let lead, lead.isActive {
|
||||
// Back to work: the same activity carries on.
|
||||
doneTask?.cancel(); doneTask = nil
|
||||
flush()
|
||||
} else if doneTask == nil {
|
||||
// Nothing going any more: show "done" for 10 minutes, then leave,
|
||||
// unless an agent starts again meanwhile.
|
||||
if lead != nil { flush() }
|
||||
doneTask = Task {
|
||||
try? await Task.sleep(for: .seconds(10 * 60))
|
||||
guard !Task.isCancelled else { return }
|
||||
doneTask = nil
|
||||
finish(dismissAfter: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sending
|
||||
|
||||
private func begin(_ state: MochiActivityState) {
|
||||
let start = Date()
|
||||
activitySince = Int(start.timeIntervalSince1970)
|
||||
startedAt = start
|
||||
sent = state
|
||||
retries = 0
|
||||
Task {
|
||||
await refreshPhones()
|
||||
// The Mac unlocked meanwhile: don't leave.
|
||||
guard startedAt == start, locked else { return }
|
||||
startSent = start
|
||||
let targets = phones.filter { !$0.value.startToken.isEmpty }
|
||||
if targets.isEmpty {
|
||||
log("no iPhone token yet: open Coucou on the iPhone once, with Live Activities allowed")
|
||||
return
|
||||
}
|
||||
var reached = 0
|
||||
for (id, phone) in targets {
|
||||
if await post(event: "start", token: phone.startToken, env: phone.env, state: state, phoneID: id) {
|
||||
reached += 1
|
||||
}
|
||||
}
|
||||
if reached > 0 {
|
||||
log("Mochi left for the iPhone (\(state.agent), \(state.statusText))")
|
||||
} else {
|
||||
log("Mochi couldn't reach the iPhone (see the relay line above)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the latest state if it changed, one request at a time.
|
||||
private func flush() {
|
||||
guard !sending, let startedAt, let state = latest, state != sent else { return }
|
||||
sending = true
|
||||
Task {
|
||||
let targets = await updateTargets(since: startedAt)
|
||||
guard !targets.isEmpty else {
|
||||
sending = false
|
||||
// No update token: iOS didn't bring the activity up (it can hold
|
||||
// back starts after many in a row). When an agent needs you, start
|
||||
// it again with that state, once per request.
|
||||
let key = state.approval ?? "\(state.tone)|\(state.statusText)"
|
||||
if state.tone == "waiting" || state.tone == "question", restartedFor != key,
|
||||
let sentAt = startSent, Date().timeIntervalSince(sentAt) > 15 {
|
||||
restartedFor = key
|
||||
log("the Live Activity isn't on the iPhone: starting it again for \(state.statusText)")
|
||||
begin(state)
|
||||
return
|
||||
}
|
||||
log("update \(state.tone) waits: no update token from the iPhone yet")
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
retries = 0
|
||||
let urgent = state.tone == "waiting" || state.tone == "question"
|
||||
for (id, phone) in targets {
|
||||
let ok = await post(event: "update", token: phone.updateToken, env: phone.env, state: state,
|
||||
urgent: urgent, phoneID: id)
|
||||
// Every change of phase is logged; step counts only when they fail.
|
||||
if ok && (urgent || sent?.tone != state.tone) {
|
||||
log("update sent: \(state.agent) \(state.statusText)\(state.approval == nil ? "" : " (with Allow / Deny)")")
|
||||
}
|
||||
}
|
||||
sent = state
|
||||
sending = false
|
||||
// Something changed while this one was on its way.
|
||||
if latest != sent { flush() }
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(dismissAfter: Int) {
|
||||
guard let startedAt else { return }
|
||||
self.startedAt = nil
|
||||
retryTask?.cancel(); retryTask = nil
|
||||
doneTask?.cancel(); doneTask = nil
|
||||
let last = latest ?? sent ?? .placeholder
|
||||
sent = nil
|
||||
guard startSent == startedAt else {
|
||||
// The start never went out: nothing to end on the iPhone.
|
||||
log("Mochi is back on the Mac")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
// A start sent a moment ago: its update token is still on its way.
|
||||
var targets = await updateTargets(since: startedAt)
|
||||
for _ in 0..<6 where targets.isEmpty {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
targets = await updateTargets(since: startedAt)
|
||||
}
|
||||
if targets.isEmpty { log("can't end the Live Activity: no update token from the iPhone") }
|
||||
for (id, phone) in targets {
|
||||
await post(event: "end", token: phone.updateToken, env: phone.env, state: last,
|
||||
dismissAfter: dismissAfter, phoneID: id)
|
||||
}
|
||||
log(dismissAfter == 0 ? "Mochi is back on the Mac" : "agents done, the iPhone shows it for \(dismissAfter / 60) min")
|
||||
}
|
||||
}
|
||||
|
||||
/// The update token arrives a moment after the start (the iPhone gets it
|
||||
/// from iOS, then writes it to iCloud): retry a few times.
|
||||
private func scheduleRetry() {
|
||||
guard retryTask == nil, retries < 10 else { return }
|
||||
retries += 1
|
||||
retryTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
retryTask = nil
|
||||
guard !Task.isCancelled else { return }
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTargets(since date: Date) async -> [String: Phone] {
|
||||
func fresh() -> [String: Phone] {
|
||||
phones.filter { !$0.value.updateToken.isEmpty && $0.value.updatedAt >= date.addingTimeInterval(-2) }
|
||||
}
|
||||
if fresh().isEmpty { await refreshPhones() }
|
||||
return fresh()
|
||||
}
|
||||
|
||||
/// Sends one push through the relay. Returns true when Apple accepted it.
|
||||
@discardableResult
|
||||
private func post(event: String, token: String, env: String, state: MochiActivityState,
|
||||
urgent: Bool = false, dismissAfter: Int? = nil, phoneID: String) async -> Bool {
|
||||
guard let url = relayURL else { return false }
|
||||
var state = state
|
||||
if state.since == nil { state.since = activitySince }
|
||||
var body: [String: Any] = ["token": token, "env": env, "event": event, "urgent": urgent]
|
||||
if let dismissAfter { body["dismissAfter"] = dismissAfter }
|
||||
guard let stateData = try? JSONEncoder().encode(state),
|
||||
let stateObject = try? JSONSerialization.jsonObject(with: stateData) else { return false }
|
||||
body["state"] = stateObject
|
||||
var request = URLRequest(url: url, timeoutInterval: 10)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status == 200 { return true }
|
||||
let reason = String(data: data, encoding: .utf8) ?? ""
|
||||
if reason.contains("BadDeviceToken") {
|
||||
// A token only works on one of Apple's two push servers (sandbox for
|
||||
// builds run from Xcode, production for TestFlight and the App Store).
|
||||
// Try the other one once and keep it if it works.
|
||||
let other = env == "production" ? "development" : "production"
|
||||
if phones[phoneID]?.env == env {
|
||||
phones[phoneID]?.env = other
|
||||
log("relay \(event): token not valid on \(env), trying \(other)")
|
||||
return await post(event: event, token: token, env: other, state: state,
|
||||
urgent: urgent, dismissAfter: dismissAfter, phoneID: phoneID)
|
||||
}
|
||||
}
|
||||
if status == 410 {
|
||||
// That token is gone (activity ended on the iPhone, app deleted…).
|
||||
if event == "start" { phones[phoneID]?.startToken = "" } else { phones[phoneID]?.updateToken = "" }
|
||||
}
|
||||
log("relay \(event) on \(env) → \(status) \(reason.prefix(120))")
|
||||
} catch {
|
||||
log("relay \(event) failed: \(error.localizedDescription)")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: Tokens
|
||||
|
||||
private func refreshPhones() async {
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: changeToken)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == "PhoneToken" else { continue }
|
||||
let record = mod.record
|
||||
phones[id.recordName] = Phone(
|
||||
env: record["env"] as? String ?? "production",
|
||||
startToken: record.encryptedValues["startToken"] as? String ?? "",
|
||||
updateToken: record.encryptedValues["updateToken"] as? String ?? "",
|
||||
updatedAt: record["updatedAt"] as? Date ?? .distantPast)
|
||||
}
|
||||
for deletion in changes.deletions where deletion.recordType == "PhoneToken" {
|
||||
phones[deletion.recordID.recordName] = nil
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
} catch {
|
||||
log("token fetch failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// The most urgent session, as the iPhone's Live Activity shows it.
|
||||
nonisolated static func leadState(tasks: [AgentTask], approval: ApprovalInfo?) -> MochiActivityState? {
|
||||
let ranked = tasks
|
||||
.filter { $0.source != .n8n && PillCatalog.isSession($0.id) } // same sessions as SessionPublisher
|
||||
.map { task -> (AgentTask, Int) in
|
||||
let urgency = MochiActivityState.urgency(state: task.state,
|
||||
waitingForOK: approval?.pillId == task.id,
|
||||
hasQuestion: task.state == .question)
|
||||
return (task, urgency)
|
||||
}
|
||||
guard let lead = ranked.min(by: { $0.1 < $1.1 }) else { return nil }
|
||||
let (task, urgency) = lead
|
||||
let others = ranked.filter { $0.0.id != task.id && $0.1 <= 3 }.count
|
||||
return MochiActivityState(
|
||||
pillId: task.id,
|
||||
agent: PillCatalog.definition(for: task.id)?.name ?? "Agent",
|
||||
color: task.color,
|
||||
state: (urgency == 0 ? BotState.approval : task.state).rawValue,
|
||||
statusText: MochiActivityState.statusText(state: task.state, urgency: urgency,
|
||||
stepIndex: task.stepIndex, stepCount: task.steps.count),
|
||||
tone: MochiActivityState.tone(urgency: urgency),
|
||||
stepIndex: max(0, task.stepIndex),
|
||||
stepCount: task.steps.count,
|
||||
others: others,
|
||||
approval: approval?.pillId == task.id ? approval.map(ApprovalRelay.fingerprint) : nil)
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[live] \(message)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,132 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - Answer Claude's questions from the iPhone
|
||||
//
|
||||
// While Claude Code waits on an AskUserQuestion, the session record carries
|
||||
// the question and its choices (SessionPublisher). The iPhone answers with an
|
||||
// `Answer` record: the question's fingerprint and the labels picked. This Mac
|
||||
// looks for answers every 2 s, only while a question waits, and applies one
|
||||
// only if it matches the question still waiting and every label is one of its
|
||||
// choices. Answers are single use: deleted as soon as they are read.
|
||||
|
||||
@MainActor
|
||||
final class QuestionRelay {
|
||||
static let shared = QuestionRelay()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var zoneID: CKRecordZone.ID { SessionSnapshot.zoneID }
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
private var current: (fingerprint: String, since: Date)?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var changeToken: CKServerChangeToken?
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
// @Published sends the new value before the property changes: use the value passed along.
|
||||
cancellable = AppState.shared.$pendingQuestion
|
||||
.map { $0.map(QuestionPayload.init(ask:)) }
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] payload in
|
||||
MainActor.assumeIsolated { self?.pendingChanged(to: payload) }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
pendingChanged(to: nil)
|
||||
}
|
||||
|
||||
private func pendingChanged(to payload: QuestionPayload?) {
|
||||
let fingerprint = payload?.fingerprint
|
||||
guard current?.fingerprint != fingerprint else { return }
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
current = nil
|
||||
guard let fingerprint else { return }
|
||||
current = (fingerprint, Date())
|
||||
pollTask = Task { [weak self] in
|
||||
// The Mac gives the question back to the terminal after 120 s.
|
||||
for _ in 0..<62 {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
if await self.checkAnswers(for: fingerprint) { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true once a matching answer was applied.
|
||||
private func checkAnswers(for fingerprint: String) async -> Bool {
|
||||
guard current?.fingerprint == fingerprint, let since = current?.since else { return true }
|
||||
var found: [(id: CKRecord.ID, fingerprint: String, selections: String)] = []
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: zoneID, since: changeToken)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == "Answer" else { continue }
|
||||
let record = mod.record
|
||||
let answeredAt = record["answeredAt"] as? Date ?? .distantPast
|
||||
guard answeredAt >= since.addingTimeInterval(-5) else { continue }
|
||||
found.append((id, record["fingerprint"] as? String ?? "",
|
||||
record.encryptedValues["selections"] as? String ?? ""))
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
return false
|
||||
} catch {
|
||||
log("answer check failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
guard !found.isEmpty else { return false }
|
||||
let ids = found.map { $0.id }
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids) }
|
||||
guard let match = found.first(where: { $0.fingerprint == fingerprint }) else {
|
||||
log("ignored \(found.count) answer(s) for another question")
|
||||
return false
|
||||
}
|
||||
apply(match.selections, fingerprint: fingerprint)
|
||||
return true
|
||||
}
|
||||
|
||||
private func apply(_ json: String, fingerprint: String) {
|
||||
// Check again right before answering the hook.
|
||||
guard let pending = AppState.shared.pendingQuestion else {
|
||||
log("answer arrived after the question was resolved, ignored")
|
||||
return
|
||||
}
|
||||
let payload = QuestionPayload(ask: pending)
|
||||
guard payload.fingerprint == fingerprint else {
|
||||
log("answer for an older question, ignored")
|
||||
return
|
||||
}
|
||||
guard let selections = QuestionPayload.decodeSelections(json), payload.accepts(selections) else {
|
||||
log("answer with unknown choices, ignored")
|
||||
return
|
||||
}
|
||||
log("answered from the iPhone")
|
||||
HookServer.shared.sendQuestionAnswers(AskQuestion.buildAnswers(questions: pending.questions, selections: selections))
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[question] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
extension QuestionPayload {
|
||||
init(ask: AskQuestion) {
|
||||
self.init(items: ask.questions.map { item in
|
||||
Item(question: item.question, header: item.header,
|
||||
options: item.options.map { Option(label: $0.label, description: $0.description) },
|
||||
multiSelect: item.multiSelect)
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,802 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
|
||||
// MARK: - Services up close, for the iPhone
|
||||
//
|
||||
// When a service's screen opens on the iPhone, it writes a `ServiceAction`
|
||||
// record of kind "refresh"; to act (redeploy, re-run, merge…) it writes one
|
||||
// with that action's kind and target. The CloudKit push wakes this Mac (with
|
||||
// a check every minute in case a push is missed); it takes each request once
|
||||
// (deleted on read, and only those it could delete), reads the service's API
|
||||
// with the key in its Keychain and writes a `ServiceDetail` record,
|
||||
// encrypted. An action runs only if it was offered on an item of the last
|
||||
// detail sent for that service, and was asked for in the last 5 minutes.
|
||||
// Nothing that moves money or sends an email is ever offered.
|
||||
|
||||
@MainActor
|
||||
final class ServiceDetailRunner {
|
||||
static let shared = ServiceDetailRunner()
|
||||
|
||||
private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase }
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private var changeToken: CKServerChangeToken?
|
||||
/// The last detail sent per service: actions are checked against it.
|
||||
private var lastDetails: [String: ServiceDetail] = [:]
|
||||
private let maxAge: TimeInterval = 5 * 60
|
||||
private var checking = false
|
||||
private var again = false
|
||||
|
||||
func start() {
|
||||
guard pollTask == nil else { return }
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.checkNow()
|
||||
// In case a push is missed.
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops and deletes the details this Mac wrote to iCloud.
|
||||
func stop() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
lastDetails = [:]
|
||||
let ids = PillCatalog.phoneServices.map {
|
||||
CKRecord.ID(recordName: ServiceDetail.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) }
|
||||
}
|
||||
|
||||
/// From the CloudKit push and the fallback check. One check at a time: two
|
||||
/// overlapping checks would share the change token and could run an action twice.
|
||||
func checkNow() async {
|
||||
guard pollTask != nil else { return }
|
||||
guard !checking else { again = true; return }
|
||||
checking = true
|
||||
repeat {
|
||||
again = false
|
||||
await check()
|
||||
} while again
|
||||
checking = false
|
||||
}
|
||||
|
||||
// MARK: Requests
|
||||
|
||||
private func check() async {
|
||||
var found: [CKRecord] = []
|
||||
do {
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken,
|
||||
desiredKeys: ["pillId", "kind", "requestedAt", "target"])
|
||||
for (_, result) in changes.modificationResultsByID {
|
||||
if case .success(let mod) = result, mod.record.recordType == ServiceDetail.requestType {
|
||||
found.append(mod.record)
|
||||
}
|
||||
}
|
||||
changeToken = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
} catch let error as CKError where error.code == .changeTokenExpired {
|
||||
changeToken = nil
|
||||
return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !found.isEmpty else { return }
|
||||
// Single use: act only on the requests this Mac actually removed from iCloud.
|
||||
guard let removed = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID),
|
||||
savePolicy: .changedKeys, atomically: false) else {
|
||||
log("couldn't take \(found.count) request(s) off iCloud; skipped")
|
||||
return
|
||||
}
|
||||
let taken = found.filter {
|
||||
guard case .success? = removed.deleteResults[$0.recordID] else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// Several refreshes for one service count once.
|
||||
var refreshed: Set<String> = []
|
||||
for record in taken.sorted(by: { ($0["requestedAt"] as? Date ?? .distantPast) < ($1["requestedAt"] as? Date ?? .distantPast) }) {
|
||||
let pillId = record["pillId"] as? String ?? ""
|
||||
let kind = record["kind"] as? String ?? ""
|
||||
let target = record.encryptedValues["target"] as? String ?? ""
|
||||
let requestedAt = record["requestedAt"] as? Date ?? .distantPast
|
||||
guard Date().timeIntervalSince(requestedAt) < maxAge, PillCatalog.phoneServices.contains(pillId) else { continue }
|
||||
if kind == ServiceDetail.refreshKind {
|
||||
guard refreshed.insert(pillId).inserted else { continue }
|
||||
await publish(pillId: pillId, lastAction: lastDetails[pillId]?.lastAction)
|
||||
} else {
|
||||
await run(kind: kind, target: target, pillId: pillId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func run(kind: String, target: String, pillId: String) async {
|
||||
guard let action = lastDetails[pillId]?.offered(kind: kind, target: target),
|
||||
ServiceAPI.allowedKinds.contains(kind), kind.hasPrefix(ServiceAPI.prefix(of: pillId)) else {
|
||||
log("ignored an action that wasn't offered: \(kind)")
|
||||
await publish(pillId: pillId, lastAction: ServiceActionResult(
|
||||
title: "Not done", ok: false,
|
||||
message: "This action isn't offered any more. The list was refreshed.", date: Date()))
|
||||
return
|
||||
}
|
||||
log("\(action.title) (\(pillId)) asked from the iPhone")
|
||||
let result: ServiceActionResult
|
||||
do {
|
||||
let message = try await ServiceAPI.perform(kind: kind, target: target)
|
||||
result = ServiceActionResult(title: action.title, ok: true, message: message, date: Date())
|
||||
} catch {
|
||||
result = ServiceActionResult(title: action.title, ok: false, message: ServiceAPI.describe(error), date: Date())
|
||||
}
|
||||
log("\(action.title): \(result.ok ? "done" : "failed, \(result.message)")")
|
||||
// Give the service a moment, then show where things are.
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await publish(pillId: pillId, lastAction: result)
|
||||
}
|
||||
|
||||
private func publish(pillId: String, lastAction: ServiceActionResult?) async {
|
||||
var detail = await ServiceAPI.detail(for: pillId)
|
||||
// The iPhone sync was turned off while the API was read: write nothing.
|
||||
guard !Task.isCancelled, pollTask != nil else { return }
|
||||
detail.lastAction = lastAction
|
||||
lastDetails[pillId] = detail
|
||||
guard let json = try? JSONEncoder().encode(detail), let payload = String(data: json, encoding: .utf8) else { return }
|
||||
let record = CKRecord(recordType: ServiceDetail.recordType,
|
||||
recordID: CKRecord.ID(recordName: ServiceDetail.recordName(for: pillId), zoneID: SessionSnapshot.zoneID))
|
||||
record["pillId"] = pillId
|
||||
record["fetchedAt"] = detail.fetchedAt
|
||||
record.encryptedValues["payload"] = payload
|
||||
do {
|
||||
_ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys)
|
||||
} catch {
|
||||
log("detail for \(pillId) not saved: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[services] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The services' APIs
|
||||
|
||||
enum ServiceAPI {
|
||||
struct Failure: Error {
|
||||
let status: Int
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// Every action the iPhone can ask for. Nothing moves money or sends an email.
|
||||
static let allowedKinds: Set<String> = [
|
||||
"vercel.redeploy", "vercel.promote", "vercel.cancel",
|
||||
"github.rerun", "github.approve", "github.merge",
|
||||
"n8n.activate", "n8n.deactivate", "n8n.retry",
|
||||
]
|
||||
|
||||
static func prefix(of pillId: String) -> String {
|
||||
pillId.replacingOccurrences(of: "integration_", with: "") + "."
|
||||
}
|
||||
|
||||
static func describe(_ error: Error) -> String {
|
||||
if let failure = error as? Failure {
|
||||
switch failure.status {
|
||||
case 401:
|
||||
if failure.message.localizedCaseInsensitiveContains("restricted") {
|
||||
// Resend `restricted_api_key`: a Sending-access key can't read emails or domains.
|
||||
return "This key can only send emails. Use a Full access key in Coucou's Settings on your Mac to see them here."
|
||||
}
|
||||
return "The key was refused (401). Check it in Coucou's Settings on your Mac."
|
||||
case 403: return "Not allowed with this key (403). \(failure.message)"
|
||||
case 404: return "Not found (404)."
|
||||
default: return failure.message.isEmpty ? "Error \(failure.status)" : String(failure.message.prefix(160))
|
||||
}
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
// MARK: HTTP
|
||||
|
||||
private static func request(_ urlString: String, method: String = "GET", headers: [String: String],
|
||||
body: [String: Any]? = nil) async throws -> Any {
|
||||
guard let url = URL(string: urlString) else { throw Failure(status: 0, message: "Bad address") }
|
||||
var request = URLRequest(url: url, timeoutInterval: 15)
|
||||
request.httpMethod = method
|
||||
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200..<300).contains(status) else {
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let message = (json?["message"] as? String)
|
||||
?? ((json?["error"] as? [String: Any])?["message"] as? String)
|
||||
?? (json?["error"] as? String)
|
||||
?? String(decoding: data.prefix(200), as: UTF8.self)
|
||||
throw Failure(status: status, message: message)
|
||||
}
|
||||
if data.isEmpty { return [String: Any]() }
|
||||
return try JSONSerialization.jsonObject(with: data)
|
||||
}
|
||||
|
||||
private static func date(_ value: Any?) -> Date? {
|
||||
if let ms = value as? Double { return Date(timeIntervalSince1970: ms > 1e11 ? ms / 1000 : ms) }
|
||||
if let ms = value as? Int { return Date(timeIntervalSince1970: ms > 100_000_000_000 ? Double(ms) / 1000 : Double(ms)) }
|
||||
guard let text = value as? String else { return nil }
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: text) { return date }
|
||||
let plain = ISO8601DateFormatter()
|
||||
if let date = plain.date(from: text) { return date }
|
||||
// Resend: "2024-05-01 10:00:00.123456+00"
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss.SSSSSSZ", "yyyy-MM-dd HH:mm:ss.SSSZ", "yyyy-MM-dd HH:mm:ssZ"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: text.count > 3 && text.hasSuffix("+00") ? text + "00" : text) { return date }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func money(_ cents: Int, _ currency: String) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = currency.uppercased()
|
||||
// Stripe zero-decimal currencies (docs.stripe.com/currencies#zero-decimal). UGX and ISK stay ×100
|
||||
// for backward compatibility; HUF and TWD charges are two-decimal.
|
||||
let zeroDecimal: Set<String> = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf",
|
||||
"vnd", "vuv", "xaf", "xof", "xpf"]
|
||||
let amount = zeroDecimal.contains(currency.lowercased()) ? Double(cents) : Double(cents) / 100
|
||||
return formatter.string(from: NSNumber(value: amount)) ?? "\(amount) \(currency.uppercased())"
|
||||
}
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
static func detail(for pillId: String) async -> ServiceDetail {
|
||||
do {
|
||||
switch pillId {
|
||||
case "integration_vercel": return try await vercel()
|
||||
case "integration_github": return try await github()
|
||||
case "integration_stripe": return try await stripe()
|
||||
case "integration_resend": return try await resend()
|
||||
case "integration_calcom": return try await calcom()
|
||||
case "integration_n8n": return try await n8n()
|
||||
case "integration_notion": return try await notion()
|
||||
default: return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: "Not available from the iPhone yet.")
|
||||
}
|
||||
} catch {
|
||||
return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
private static func secret(_ name: String, _ service: String) throws -> String {
|
||||
guard let value = KeychainStore.shared.get(name), !value.isEmpty else {
|
||||
throw Failure(status: 0, message: "No \(service) key on your Mac. Add it in Coucou's Settings.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Vercel: deployments (redeploy, promote to production, cancel) and projects.
|
||||
private static func vercel() async throws -> ServiceDetail {
|
||||
let token = try secret("vercel-token", "Vercel")
|
||||
let headers = ["Authorization": "Bearer \(token)", "Accept": "application/json"]
|
||||
let deploymentsJSON = try await request("https://api.vercel.com/v6/deployments?limit=20", headers: headers)
|
||||
let deployments = (deploymentsJSON as? [String: Any])?["deployments"] as? [[String: Any]] ?? []
|
||||
let projectsJSON = try? await request("https://api.vercel.com/v9/projects?limit=20", headers: headers)
|
||||
let projects = (projectsJSON as? [String: Any])?["projects"] as? [[String: Any]] ?? []
|
||||
|
||||
var items: [DetailItem] = []
|
||||
var ready = 0, failed = 0, building = 0
|
||||
for d in deployments {
|
||||
guard let uid = d["uid"] as? String else { continue }
|
||||
let name = d["name"] as? String ?? "Deployment"
|
||||
let state = (d["state"] as? String ?? d["readyState"] as? String ?? "").uppercased()
|
||||
let target = d["target"] as? String
|
||||
let meta = d["meta"] as? [String: Any] ?? [:]
|
||||
let message = meta["githubCommitMessage"] as? String ?? ""
|
||||
let branch = meta["githubCommitRef"] as? String ?? ""
|
||||
let creator = (d["creator"] as? [String: Any])?["username"] as? String ?? ""
|
||||
var tone: ServiceTone = .idle
|
||||
var actions: [ServiceActionDef] = []
|
||||
switch state {
|
||||
case "READY":
|
||||
ready += 1
|
||||
tone = .ok
|
||||
actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise",
|
||||
target: "\(uid)|\(name)|\(target ?? "")"))
|
||||
if target != "production" {
|
||||
actions.append(ServiceActionDef(kind: "vercel.promote", title: "Promote to production",
|
||||
symbol: "arrow.up.circle", target: "\(uid)|\(name)",
|
||||
confirm: "A new production build starts from this deployment, using your production environment variables."))
|
||||
}
|
||||
case "ERROR":
|
||||
failed += 1
|
||||
tone = .error
|
||||
actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise",
|
||||
target: "\(uid)|\(name)|\(target ?? "")"))
|
||||
case "BUILDING", "QUEUED", "INITIALIZING":
|
||||
building += 1
|
||||
tone = .warning
|
||||
actions.append(ServiceActionDef(kind: "vercel.cancel", title: "Cancel build", symbol: "xmark.circle",
|
||||
target: uid, destructive: true, confirm: "Stop this build?"))
|
||||
case "CANCELED": tone = .idle
|
||||
default: break
|
||||
}
|
||||
let subtitle = [branch, message.split(separator: "\n").first.map(String.init) ?? "", creator]
|
||||
.filter { !$0.isEmpty }.joined(separator: " · ")
|
||||
items.append(DetailItem(title: name, subtitle: subtitle.isEmpty ? state.capitalized : subtitle, tone: tone,
|
||||
date: date(d["created"] ?? d["createdAt"]),
|
||||
url: (d["url"] as? String).map { "https://\($0)" },
|
||||
badge: target == "production" ? "Production" : (state == "READY" ? "Preview" : state.capitalized),
|
||||
actions: actions))
|
||||
}
|
||||
let projectItems = projects.prefix(20).map { p in
|
||||
DetailItem(title: p["name"] as? String ?? "Project",
|
||||
subtitle: (p["framework"] as? String).map { $0.capitalized } ?? "",
|
||||
date: date(p["updatedAt"]))
|
||||
}
|
||||
var sections = [DetailSection(title: "Deployments", items: items)]
|
||||
if !projectItems.isEmpty { sections.append(DetailSection(title: "Projects · \(projectItems.count)", items: Array(projectItems))) }
|
||||
return ServiceDetail(pillId: "integration_vercel",
|
||||
stats: [DetailStat(label: "Ready", value: "\(ready)", tone: .ok),
|
||||
DetailStat(label: "Building", value: "\(building)", tone: building > 0 ? .warning : .idle),
|
||||
DetailStat(label: "Failed", value: "\(failed)", tone: failed > 0 ? .error : .idle),
|
||||
DetailStat(label: "Projects", value: "\(projects.count)")],
|
||||
sections: sections, fetchedAt: Date())
|
||||
}
|
||||
|
||||
// GitHub: your pull requests (merge), reviews asked of you (approve), CI runs (re-run failed jobs).
|
||||
private static func github() async throws -> ServiceDetail {
|
||||
let token = try secret("github-token", "GitHub")
|
||||
let headers = ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"]
|
||||
let user = try await request("https://api.github.com/user", headers: headers) as? [String: Any] ?? [:]
|
||||
let login = user["login"] as? String ?? ""
|
||||
|
||||
func search(_ query: String) async -> [[String: Any]] {
|
||||
var components = URLComponents(string: "https://api.github.com/search/issues")!
|
||||
components.queryItems = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "per_page", value: "10"),
|
||||
URLQueryItem(name: "sort", value: "updated")]
|
||||
let json = try? await request(components.url!.absoluteString, headers: headers)
|
||||
return (json as? [String: Any])?["items"] as? [[String: Any]] ?? []
|
||||
}
|
||||
/// "owner/repo#12" from a search result.
|
||||
func pullRef(_ item: [String: Any]) -> String? {
|
||||
guard let repoURL = item["repository_url"] as? String, let number = item["number"] as? Int else { return nil }
|
||||
let parts = repoURL.split(separator: "/").suffix(2).joined(separator: "/")
|
||||
return "\(parts)#\(number)"
|
||||
}
|
||||
|
||||
let mine = await search("is:pr is:open author:\(login) archived:false")
|
||||
let asked = await search("is:pr is:open review-requested:\(login) archived:false")
|
||||
|
||||
let mineItems = mine.compactMap { item -> DetailItem? in
|
||||
guard let ref = pullRef(item) else { return nil }
|
||||
let draft = item["draft"] as? Bool ?? false
|
||||
return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: ref,
|
||||
tone: draft ? .idle : .info, date: date(item["updated_at"]),
|
||||
url: item["html_url"] as? String, badge: draft ? "Draft" : nil,
|
||||
actions: draft ? [] : [ServiceActionDef(kind: "github.merge", title: "Squash and merge",
|
||||
symbol: "arrow.triangle.merge", target: ref,
|
||||
confirm: "Merge \(ref) into its base branch?")])
|
||||
}
|
||||
let askedItems = asked.compactMap { item -> DetailItem? in
|
||||
guard let ref = pullRef(item) else { return nil }
|
||||
let author = (item["user"] as? [String: Any])?["login"] as? String ?? ""
|
||||
return DetailItem(title: item["title"] as? String ?? "Pull request",
|
||||
subtitle: author.isEmpty ? ref : "\(ref) · \(author)", tone: .warning,
|
||||
date: date(item["updated_at"]), url: item["html_url"] as? String,
|
||||
actions: [ServiceActionDef(kind: "github.approve", title: "Approve", symbol: "checkmark.seal",
|
||||
target: ref)])
|
||||
}
|
||||
|
||||
// CI on the repos you pushed to last.
|
||||
let reposJSON = try? await request("https://api.github.com/user/repos?sort=pushed&per_page=5&affiliation=owner,collaborator",
|
||||
headers: headers)
|
||||
let repos = (reposJSON as? [[String: Any]] ?? []).compactMap { $0["full_name"] as? String }
|
||||
var runItems: [DetailItem] = []
|
||||
var failing = 0
|
||||
for repo in repos {
|
||||
let runsJSON = try? await request("https://api.github.com/repos/\(repo)/actions/runs?per_page=3", headers: headers)
|
||||
let runs = (runsJSON as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? []
|
||||
for run in runs {
|
||||
guard let id = run["id"] as? Int else { continue }
|
||||
let status = run["status"] as? String ?? ""
|
||||
let conclusion = run["conclusion"] as? String ?? ""
|
||||
var tone: ServiceTone = .idle
|
||||
var actions: [ServiceActionDef] = []
|
||||
if status != "completed" {
|
||||
tone = .warning
|
||||
} else if conclusion == "success" {
|
||||
tone = .ok
|
||||
} else if conclusion == "failure" || conclusion == "timed_out" {
|
||||
tone = .error
|
||||
failing += 1
|
||||
actions.append(ServiceActionDef(kind: "github.rerun", title: "Re-run failed jobs",
|
||||
symbol: "arrow.clockwise", target: "\(repo)/\(id)"))
|
||||
}
|
||||
let label = status == "completed" ? conclusion.replacingOccurrences(of: "_", with: " ") : status.replacingOccurrences(of: "_", with: " ")
|
||||
runItems.append(DetailItem(title: run["name"] as? String ?? "Workflow",
|
||||
subtitle: "\(repo) · \(run["head_branch"] as? String ?? "")",
|
||||
tone: tone, date: date(run["created_at"]),
|
||||
url: run["html_url"] as? String, badge: label.capitalized, actions: actions))
|
||||
}
|
||||
}
|
||||
|
||||
var sections: [DetailSection] = []
|
||||
if !askedItems.isEmpty { sections.append(DetailSection(title: "Reviews asked of you", items: askedItems)) }
|
||||
sections.append(DetailSection(title: "Your pull requests", items: mineItems,
|
||||
footer: mineItems.isEmpty ? "No open pull request." : nil))
|
||||
if !runItems.isEmpty { sections.append(DetailSection(title: "CI on your latest repos", items: runItems)) }
|
||||
return ServiceDetail(pillId: "integration_github",
|
||||
stats: [DetailStat(label: "Your PRs", value: "\(mineItems.count)", tone: .info),
|
||||
DetailStat(label: "Reviews", value: "\(askedItems.count)", tone: askedItems.isEmpty ? .idle : .warning),
|
||||
DetailStat(label: "CI failing", value: "\(failing)", tone: failing > 0 ? .error : .ok)],
|
||||
sections: sections, fetchedAt: Date())
|
||||
}
|
||||
|
||||
// Stripe: balance, payments, payouts, subscriptions. Read only.
|
||||
private static func stripe() async throws -> ServiceDetail {
|
||||
let key = try secret("stripe-api-key", "Stripe")
|
||||
let headers = ["Authorization": "Bearer \(key)"]
|
||||
let balance = try await request("https://api.stripe.com/v1/balance", headers: headers) as? [String: Any] ?? [:]
|
||||
// One entry per currency in each list, in no set order: pair them by currency.
|
||||
let availableList = balance["available"] as? [[String: Any]] ?? []
|
||||
let pendingList = balance["pending"] as? [[String: Any]] ?? []
|
||||
let currency = ((availableList.first ?? pendingList.first)?["currency"] as? String ?? "eur").lowercased()
|
||||
func amount(_ list: [[String: Any]]) -> Int {
|
||||
list.first { ($0["currency"] as? String)?.lowercased() == currency }?["amount"] as? Int ?? 0
|
||||
}
|
||||
let available = amount(availableList)
|
||||
let pending = amount(pendingList)
|
||||
|
||||
/// A list, or why it couldn't be read (shown instead of a made-up zero).
|
||||
func list(_ path: String) async -> ([[String: Any]], [String: Any], String?) {
|
||||
do {
|
||||
let json = try await request("https://api.stripe.com/v1/\(path)", headers: headers) as? [String: Any] ?? [:]
|
||||
return (json["data"] as? [[String: Any]] ?? [], json, nil)
|
||||
} catch {
|
||||
return ([], [:], describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
let (charges, _, chargesError) = await list("charges?limit=15")
|
||||
let chargeItems = charges.map { c -> DetailItem in
|
||||
let status = c["status"] as? String ?? ""
|
||||
let refunded = c["refunded"] as? Bool ?? false
|
||||
let partlyRefunded = !refunded && (c["amount_refunded"] as? Int ?? 0) > 0
|
||||
let uncaptured = !refunded && status == "succeeded" && (c["captured"] as? Bool) == false
|
||||
let who = ((c["billing_details"] as? [String: Any])?["name"] as? String)
|
||||
?? (c["receipt_email"] as? String) ?? (c["description"] as? String) ?? ""
|
||||
let tone: ServiceTone = refunded ? .idle
|
||||
: (uncaptured || partlyRefunded) ? .warning
|
||||
: status == "succeeded" ? .ok : status == "failed" ? .error : .warning
|
||||
let badge = refunded ? "Refunded" : uncaptured ? "Uncaptured"
|
||||
: partlyRefunded ? "Partly refunded" : status.capitalized
|
||||
return DetailItem(title: money(c["amount"] as? Int ?? 0, c["currency"] as? String ?? currency),
|
||||
subtitle: who, tone: tone, date: date(c["created"]), badge: badge)
|
||||
}
|
||||
let (payouts, _, payoutsError) = await list("payouts?limit=5")
|
||||
let payoutItems = payouts.map { p in
|
||||
DetailItem(title: money(p["amount"] as? Int ?? 0, p["currency"] as? String ?? currency),
|
||||
subtitle: "Arrives", tone: (p["status"] as? String) == "failed" ? .error : .info,
|
||||
date: date(p["arrival_date"]), badge: (p["status"] as? String)?.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
}
|
||||
|
||||
// Active subscriptions, every page (up to 1 000).
|
||||
var subs: [[String: Any]] = []
|
||||
var moreSubs = false
|
||||
var subsError: String?
|
||||
var cursor: String?
|
||||
for _ in 0..<10 {
|
||||
let (page, json, error) = await list("subscriptions?status=active&limit=100" + (cursor.map { "&starting_after=\($0)" } ?? ""))
|
||||
if let error { subsError = error; break }
|
||||
subs += page
|
||||
moreSubs = json["has_more"] as? Bool ?? false
|
||||
guard moreSubs, let last = subs.last?["id"] as? String else { break }
|
||||
cursor = last
|
||||
}
|
||||
// Estimated monthly revenue, one total per currency. Metered and tiered
|
||||
// prices have no fixed amount per period and discounts aren't applied.
|
||||
var monthlyByCurrency: [String: Double] = [:]
|
||||
for sub in subs {
|
||||
let subCurrency = (sub["currency"] as? String)?.lowercased()
|
||||
let items = (sub["items"] as? [String: Any])?["data"] as? [[String: Any]] ?? []
|
||||
for item in items {
|
||||
let price = item["price"] as? [String: Any] ?? [:]
|
||||
let recurring = price["recurring"] as? [String: Any] ?? [:]
|
||||
if recurring["usage_type"] as? String == "metered" || price["billing_scheme"] as? String == "tiered" { continue }
|
||||
let perUnit = (price["unit_amount"] as? Int).map { Double($0) }
|
||||
?? Double(price["unit_amount_decimal"] as? String ?? "") ?? 0
|
||||
let unit = perUnit * Double(item["quantity"] as? Int ?? 1)
|
||||
let count = Double(recurring["interval_count"] as? Int ?? 1)
|
||||
let perMonth: Double
|
||||
switch recurring["interval"] as? String {
|
||||
case "year": perMonth = unit / (12 * count)
|
||||
case "week": perMonth = unit * 4.345 / count
|
||||
case "day": perMonth = unit * 30.4 / count
|
||||
default: perMonth = unit / count
|
||||
}
|
||||
let itemCurrency = subCurrency ?? (price["currency"] as? String)?.lowercased() ?? currency
|
||||
monthlyByCurrency[itemCurrency, default: 0] += perMonth
|
||||
}
|
||||
}
|
||||
let largest = monthlyByCurrency.max { $0.value < $1.value }
|
||||
let mrrCurrency = monthlyByCurrency[currency] != nil ? currency : (largest?.key ?? currency)
|
||||
let monthly = monthlyByCurrency[mrrCurrency] ?? 0
|
||||
|
||||
var sections = [DetailSection(title: "Payments", items: chargeItems, footer: chargesError)]
|
||||
if !payoutItems.isEmpty || payoutsError != nil {
|
||||
sections.append(DetailSection(title: "Payouts", items: payoutItems, footer: payoutsError))
|
||||
}
|
||||
if let subsError { sections.append(DetailSection(title: "Subscriptions", items: [], footer: subsError)) }
|
||||
return ServiceDetail(pillId: "integration_stripe",
|
||||
stats: [DetailStat(label: "Available", value: money(available, currency), tone: .ok),
|
||||
DetailStat(label: "Pending", value: money(pending, currency)),
|
||||
DetailStat(label: "Subscriptions",
|
||||
value: subsError != nil ? "—" : (moreSubs ? "\(subs.count)+" : "\(subs.count)"),
|
||||
tone: .info),
|
||||
DetailStat(label: "Est. MRR",
|
||||
value: subsError != nil ? "—" : money(Int(monthly.rounded()), mrrCurrency),
|
||||
tone: .info)],
|
||||
sections: sections, fetchedAt: Date())
|
||||
}
|
||||
|
||||
// Resend: the latest emails and how they went, and the domains. Read only.
|
||||
private static func resend() async throws -> ServiceDetail {
|
||||
let key = try secret("resend-api-key", "Resend")
|
||||
let headers = ["Authorization": "Bearer \(key)"]
|
||||
let emailsJSON = try await request("https://api.resend.com/emails?limit=20", headers: headers)
|
||||
let emails = (emailsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? []
|
||||
var bounced = 0, delivered = 0
|
||||
let emailItems = emails.map { e -> DetailItem in
|
||||
let event = e["last_event"] as? String ?? "sent"
|
||||
let tone: ServiceTone
|
||||
switch event {
|
||||
case "delivered", "opened", "clicked": tone = .ok; delivered += 1
|
||||
case "bounced", "complained", "failed", "suppressed": tone = .error; bounced += 1
|
||||
case "delivery_delayed": tone = .warning
|
||||
default: tone = .info
|
||||
}
|
||||
let to = (e["to"] as? [String])?.joined(separator: ", ") ?? ""
|
||||
return DetailItem(title: e["subject"] as? String ?? "(no subject)", subtitle: to, tone: tone,
|
||||
date: date(e["created_at"]), badge: event.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
}
|
||||
let domainsJSON = try? await request("https://api.resend.com/domains", headers: headers)
|
||||
let domains = (domainsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? []
|
||||
let domainItems = domains.map { d in
|
||||
let status = d["status"] as? String ?? ""
|
||||
return DetailItem(title: d["name"] as? String ?? "Domain", subtitle: d["region"] as? String ?? "",
|
||||
tone: status == "verified" ? .ok : (status == "failed" ? .error : .warning),
|
||||
badge: status.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
}
|
||||
var sections = [DetailSection(title: "Latest emails", items: emailItems)]
|
||||
if !domainItems.isEmpty { sections.append(DetailSection(title: "Domains", items: domainItems)) }
|
||||
return ServiceDetail(pillId: "integration_resend",
|
||||
stats: [DetailStat(label: "Delivered", value: "\(delivered)", tone: .ok),
|
||||
DetailStat(label: "Bounced", value: "\(bounced)", tone: bounced > 0 ? .error : .idle),
|
||||
DetailStat(label: "Domains", value: "\(domains.count)")],
|
||||
sections: sections, fetchedAt: Date())
|
||||
}
|
||||
|
||||
// Cal.com: upcoming bookings. Read only: cancelling emails the guest and can refund or charge them.
|
||||
private static func calcom() async throws -> ServiceDetail {
|
||||
let key = try secret("calcom-api-key", "Cal.com")
|
||||
let headers = ["Authorization": "Bearer \(key)", "cal-api-version": "2024-08-13"]
|
||||
let iso = ISO8601DateFormatter()
|
||||
let now = Date()
|
||||
var components = URLComponents(string: "https://api.cal.com/v2/bookings")!
|
||||
components.queryItems = [URLQueryItem(name: "status", value: "upcoming"),
|
||||
URLQueryItem(name: "afterStart", value: iso.string(from: now)),
|
||||
URLQueryItem(name: "take", value: "30")]
|
||||
let json = try await request(components.url!.absoluteString, headers: headers)
|
||||
let bookings = (json as? [String: Any])?["data"] as? [[String: Any]] ?? []
|
||||
let calendar = Calendar.current
|
||||
var today = 0, week = 0
|
||||
let items = bookings.compactMap { b -> DetailItem? in
|
||||
guard let start = date(b["start"] ?? b["startTime"]) else { return nil }
|
||||
if calendar.isDateInToday(start) { today += 1 }
|
||||
if start.timeIntervalSince(now) < 7 * 24 * 3600 { week += 1 }
|
||||
let attendee = (b["attendees"] as? [[String: Any]])?.first
|
||||
let who = attendee?["name"] as? String ?? attendee?["email"] as? String ?? ""
|
||||
let when = start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
|
||||
return DetailItem(title: b["title"] as? String ?? "Meeting",
|
||||
subtitle: who.isEmpty ? when : "\(when) · \(who)",
|
||||
tone: calendar.isDateInToday(start) ? .warning : .info,
|
||||
date: start, url: (b["meetingUrl"] as? String) ?? (b["location"] as? String).flatMap { $0.hasPrefix("https://") ? $0 : nil },
|
||||
badge: (b["status"] as? String)?.capitalized)
|
||||
}
|
||||
return ServiceDetail(pillId: "integration_calcom",
|
||||
stats: [DetailStat(label: "Today", value: "\(today)", tone: today > 0 ? .warning : .idle),
|
||||
DetailStat(label: "Next 7 days", value: "\(week)", tone: .info),
|
||||
DetailStat(label: "Upcoming", value: "\(items.count)")],
|
||||
sections: [DetailSection(title: "Upcoming bookings", items: items,
|
||||
footer: items.isEmpty ? "Nothing booked." : nil)],
|
||||
fetchedAt: Date())
|
||||
}
|
||||
|
||||
// n8n: workflows (activate, deactivate) and executions (retry a failed one).
|
||||
private static func n8n() async throws -> ServiceDetail {
|
||||
let key = try secret("n8n-api-key", "n8n")
|
||||
let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"]
|
||||
// The list comes in pages (at most 250, then `cursor` = the previous `nextCursor`): read
|
||||
// them all so the counts are right.
|
||||
var workflows: [[String: Any]] = []
|
||||
var cursor: String?
|
||||
repeat {
|
||||
var url = "\(base)/api/v1/workflows?limit=250"
|
||||
if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) {
|
||||
url += "&cursor=\(encoded)"
|
||||
}
|
||||
let page = try await request(url, headers: headers) as? [String: Any]
|
||||
workflows += page?["data"] as? [[String: Any]] ?? []
|
||||
cursor = page?["nextCursor"] as? String
|
||||
} while !(cursor ?? "").isEmpty && workflows.count < 5_000
|
||||
var names: [String: String] = [:]
|
||||
var active = 0
|
||||
let workflowItems = workflows.compactMap { w -> DetailItem? in
|
||||
guard let id = (w["id"] as? String) ?? (w["id"] as? Int).map(String.init) else { return nil }
|
||||
let name = w["name"] as? String ?? "Workflow"
|
||||
names[id] = name
|
||||
// Archived workflows are listed but can't be turned on (n8n answers 400).
|
||||
if w["isArchived"] as? Bool ?? false { return nil }
|
||||
let isActive = w["active"] as? Bool ?? false
|
||||
if isActive { active += 1 }
|
||||
return DetailItem(title: name, tone: isActive ? .ok : .idle, date: date(w["updatedAt"]),
|
||||
url: "\(base)/workflow/\(id)", badge: isActive ? "Active" : "Off",
|
||||
actions: [isActive
|
||||
? ServiceActionDef(kind: "n8n.deactivate", title: "Turn off", symbol: "pause.circle",
|
||||
target: id, destructive: true, confirm: "Stop \(name) from running?")
|
||||
: ServiceActionDef(kind: "n8n.activate", title: "Turn on", symbol: "play.circle", target: id)])
|
||||
}
|
||||
let executionsJSON = try? await request("\(base)/api/v1/executions?limit=15", headers: headers)
|
||||
let executions = (executionsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? []
|
||||
var failed = 0
|
||||
let executionItems = executions.compactMap { e -> DetailItem? in
|
||||
guard let id = (e["id"] as? String) ?? (e["id"] as? Int).map(String.init) else { return nil }
|
||||
let workflowId = (e["workflowId"] as? String) ?? (e["workflowId"] as? Int).map(String.init) ?? ""
|
||||
let status = e["status"] as? String ?? ((e["finished"] as? Bool ?? false) ? "success" : "error")
|
||||
let isFailure = status == "error" || status == "crashed" || status == "failed"
|
||||
if isFailure { failed += 1 }
|
||||
return DetailItem(title: names[workflowId] ?? "Workflow \(workflowId)", subtitle: "Execution \(id)",
|
||||
tone: isFailure ? .error : (status == "success" ? .ok : .warning),
|
||||
date: date(e["startedAt"]), url: "\(base)/workflow/\(workflowId)/executions/\(id)",
|
||||
badge: status.capitalized,
|
||||
actions: isFailure ? [ServiceActionDef(kind: "n8n.retry", title: "Retry", symbol: "arrow.clockwise", target: id)] : [])
|
||||
}
|
||||
return ServiceDetail(pillId: "integration_n8n",
|
||||
stats: [DetailStat(label: "Workflows", value: "\(workflowItems.count)"),
|
||||
DetailStat(label: "Active", value: "\(active)", tone: .ok),
|
||||
DetailStat(label: "Failed runs", value: "\(failed)", tone: failed > 0 ? .error : .idle)],
|
||||
sections: [DetailSection(title: "Latest runs", items: executionItems),
|
||||
DetailSection(title: "Workflows",
|
||||
items: Array(workflowItems.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }.prefix(50)))],
|
||||
fetchedAt: Date())
|
||||
}
|
||||
|
||||
// Notion: the pages and databases edited last. Read only.
|
||||
private static func notion() async throws -> ServiceDetail {
|
||||
let key = try secret("notion-api-key", "Notion")
|
||||
let headers = ["Authorization": "Bearer \(key)", "Notion-Version": "2022-06-28"]
|
||||
let json = try await request("https://api.notion.com/v1/search", method: "POST", headers: headers,
|
||||
body: ["sort": ["direction": "descending", "timestamp": "last_edited_time"], "page_size": 25])
|
||||
let results = (json as? [String: Any])?["results"] as? [[String: Any]] ?? []
|
||||
var pages: [DetailItem] = []
|
||||
var databases: [DetailItem] = []
|
||||
for object in results {
|
||||
let kind = object["object"] as? String ?? "page"
|
||||
var title = ""
|
||||
if kind == "database" {
|
||||
title = ((object["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? ""
|
||||
} else if let properties = object["properties"] as? [String: Any] {
|
||||
for case let property as [String: Any] in properties.values where property["type"] as? String == "title" {
|
||||
title = ((property["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? ""
|
||||
}
|
||||
}
|
||||
var emoji = ""
|
||||
if let icon = object["icon"] as? [String: Any], icon["type"] as? String == "emoji" { emoji = (icon["emoji"] as? String ?? "") + " " }
|
||||
let item = DetailItem(title: emoji + (title.isEmpty ? "Untitled" : title),
|
||||
date: date(object["last_edited_time"]), url: object["url"] as? String,
|
||||
badge: (object["archived"] as? Bool ?? false) ? "Archived" : nil)
|
||||
if kind == "database" { databases.append(item) } else { pages.append(item) }
|
||||
}
|
||||
var sections = [DetailSection(title: "Edited lately", items: pages)]
|
||||
if !databases.isEmpty { sections.append(DetailSection(title: "Databases", items: databases)) }
|
||||
let editedToday = results.filter { date($0["last_edited_time"]).map(Calendar.current.isDateInToday) ?? false }.count
|
||||
return ServiceDetail(pillId: "integration_notion",
|
||||
stats: [DetailStat(label: "Edited today", value: "\(editedToday)", tone: editedToday > 0 ? .info : .idle),
|
||||
DetailStat(label: "Pages", value: "\(pages.count)"),
|
||||
DetailStat(label: "Databases", value: "\(databases.count)")],
|
||||
sections: sections, fetchedAt: Date())
|
||||
}
|
||||
|
||||
// MARK: Acting
|
||||
|
||||
/// Runs one action. Returns a short line for the iPhone.
|
||||
static func perform(kind: String, target: String) async throws -> String {
|
||||
switch kind {
|
||||
case "vercel.redeploy":
|
||||
let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown deployment") }
|
||||
var body: [String: Any] = ["name": parts[1], "deploymentId": parts[0]]
|
||||
if !parts[2].isEmpty { body["target"] = parts[2] }
|
||||
let json = try await request("https://api.vercel.com/v13/deployments", method: "POST",
|
||||
headers: try vercelHeaders(), body: body)
|
||||
let url = (json as? [String: Any])?["url"] as? String
|
||||
return url.map { "Building \($0)" } ?? "New deployment started"
|
||||
case "vercel.promote":
|
||||
// Vercel only promotes production builds in place: a preview is rebuilt for
|
||||
// production, as `vercel promote` does.
|
||||
let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count == 2 else { throw Failure(status: 0, message: "Unknown deployment") }
|
||||
let json = try await request("https://api.vercel.com/v13/deployments", method: "POST",
|
||||
headers: try vercelHeaders(),
|
||||
body: ["deploymentId": parts[0], "name": parts[1], "target": "production",
|
||||
"meta": ["action": "promote"]])
|
||||
let url = (json as? [String: Any])?["url"] as? String
|
||||
return url.map { "Building \($0) for production" } ?? "Production build started"
|
||||
case "vercel.cancel":
|
||||
_ = try await request("https://api.vercel.com/v12/deployments/\(target)/cancel", method: "PATCH",
|
||||
headers: try vercelHeaders())
|
||||
return "Build canceled"
|
||||
case "github.rerun":
|
||||
// "owner/repo/123"
|
||||
let parts = target.split(separator: "/").map(String.init)
|
||||
guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown run") }
|
||||
_ = try await request("https://api.github.com/repos/\(parts[0])/\(parts[1])/actions/runs/\(parts[2])/rerun-failed-jobs",
|
||||
method: "POST", headers: try githubHeaders())
|
||||
return "Failed jobs started again"
|
||||
case "github.approve", "github.merge":
|
||||
// "owner/repo#12"
|
||||
let pieces = target.split(separator: "#").map(String.init)
|
||||
guard pieces.count == 2 else { throw Failure(status: 0, message: "Unknown pull request") }
|
||||
if kind == "github.approve" {
|
||||
_ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/reviews", method: "POST",
|
||||
headers: try githubHeaders(), body: ["event": "APPROVE"])
|
||||
return "Approved"
|
||||
}
|
||||
_ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/merge", method: "PUT",
|
||||
headers: try githubHeaders(), body: ["merge_method": "squash"])
|
||||
return "Merged"
|
||||
case "n8n.activate", "n8n.deactivate", "n8n.retry":
|
||||
let key = try secret("n8n-api-key", "n8n")
|
||||
let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"]
|
||||
switch kind {
|
||||
case "n8n.activate":
|
||||
_ = try await request("\(base)/api/v1/workflows/\(target)/activate", method: "POST", headers: headers)
|
||||
return "Turned on"
|
||||
case "n8n.deactivate":
|
||||
_ = try await request("\(base)/api/v1/workflows/\(target)/deactivate", method: "POST", headers: headers)
|
||||
return "Turned off"
|
||||
default:
|
||||
do {
|
||||
_ = try await request("\(base)/api/v1/executions/\(target)/retry", method: "POST", headers: headers)
|
||||
} catch let failure as Failure where failure.status == 404 || failure.status == 405 {
|
||||
// POST /api/v1/executions/{id}/retry only exists from n8n 1.112.0.
|
||||
throw Failure(status: 0, message: "Couldn't retry this run. It may have been deleted, or your n8n is older than 1.112, which is needed to retry from the iPhone.")
|
||||
}
|
||||
return "Running again"
|
||||
}
|
||||
default:
|
||||
throw Failure(status: 0, message: "This action isn't available")
|
||||
}
|
||||
}
|
||||
|
||||
private static func vercelHeaders() throws -> [String: String] {
|
||||
let token = try secret("vercel-token", "Vercel")
|
||||
return ["Authorization": "Bearer \(token)", "Accept": "application/json"]
|
||||
}
|
||||
|
||||
private static func githubHeaders() throws -> [String: String] {
|
||||
let token = try secret("github-token", "GitHub")
|
||||
return ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,395 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - Services on the iPhone
|
||||
//
|
||||
// One `Service` record per service Mochi (GitHub, Stripe, Vercel, Resend,
|
||||
// Cal.com, n8n, Notion), built from the data the Mac's pollers already keep in
|
||||
// AppState: no extra API call, no key sent anywhere. The whole snapshot
|
||||
// (amounts, titles, emails, links) is one field encrypted with the user's
|
||||
// iCloud keys; only the pill ID, the state dot and the date are in clear.
|
||||
// Driven by AppState changes (debounced), never by a timer.
|
||||
|
||||
@MainActor
|
||||
final class ServicePublisher {
|
||||
static let shared = ServicePublisher()
|
||||
|
||||
private let container = CKContainer(identifier: CloudProbe.containerID)
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var cancellable: AnyCancellable?
|
||||
private var published: [String: ServiceSnapshot] = [:]
|
||||
private var cleanedUp = false
|
||||
private var writing = false
|
||||
private var again = false
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
let s = AppState.shared
|
||||
let changes: [AnyPublisher<Void, Never>] = [
|
||||
s.$stripePayments.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$stripeBalance.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$stripeError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$githubPulse.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$githubActivity.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$vercelDeployments.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$resendEmails.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$calcomBookings.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$calcomError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$notionPages.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$notionError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$n8nRuns.map { _ in () }.eraseToAnyPublisher(),
|
||||
]
|
||||
cancellable = Publishers.MergeMany(changes)
|
||||
.debounce(for: .seconds(2), scheduler: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
MainActor.assumeIsolated { self?.publish() }
|
||||
}
|
||||
log("service publisher on")
|
||||
}
|
||||
|
||||
/// Stops and deletes this Mac's services from iCloud.
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
let ids = PillCatalog.phoneServices.map {
|
||||
CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
published = [:]
|
||||
cleanedUp = false
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) }
|
||||
log("service publisher off")
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard !writing else { again = true; return }
|
||||
writing = true
|
||||
let snapshots = ServiceSnapshots.all(from: AppState.shared)
|
||||
Task {
|
||||
await write(snapshots)
|
||||
writing = false
|
||||
if again { again = false; publish() }
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ snapshots: [String: ServiceSnapshot]) async {
|
||||
var deletions: [CKRecord.ID] = []
|
||||
if !cleanedUp {
|
||||
// Services this Mac no longer has (key removed since the last run).
|
||||
deletions = PillCatalog.phoneServices.filter { snapshots[$0] == nil }.map {
|
||||
CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
cleanedUp = true
|
||||
}
|
||||
let changed = snapshots.values.filter { snapshot in
|
||||
guard let old = published[snapshot.pillId] else { return true }
|
||||
return !old.sameContent(as: snapshot)
|
||||
}
|
||||
let removed = published.keys.filter { snapshots[$0] == nil }
|
||||
deletions += removed.map { CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) }
|
||||
guard !changed.isEmpty || !deletions.isEmpty else { return }
|
||||
|
||||
let records = changed.compactMap { record(for: $0) }
|
||||
do {
|
||||
let result = try await database.modifyRecords(saving: records, deleting: deletions,
|
||||
savePolicy: .changedKeys, atomically: false)
|
||||
for (id, outcome) in result.saveResults {
|
||||
guard let pillId = ServiceSnapshot.pillId(fromRecordName: id.recordName) else { continue }
|
||||
if case .success = outcome { published[pillId] = snapshots[pillId] }
|
||||
}
|
||||
for id in removed { published[id] = nil }
|
||||
let names = changed.map { "\($0.pillId.replacingOccurrences(of: "integration_", with: ""))=\($0.tone.rawValue)" }
|
||||
log("published \(changed.count) service(s) [\(names.sorted().joined(separator: ", "))], removed \(deletions.count)")
|
||||
} catch {
|
||||
log("publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func record(for snapshot: ServiceSnapshot) -> CKRecord? {
|
||||
guard let json = try? JSONEncoder().encode(snapshot), let payload = String(data: json, encoding: .utf8) else { return nil }
|
||||
let record = CKRecord(recordType: ServiceSnapshot.recordType,
|
||||
recordID: CKRecord.ID(recordName: ServiceSnapshot.recordName(for: snapshot.pillId),
|
||||
zoneID: SessionSnapshot.zoneID))
|
||||
record["pillId"] = snapshot.pillId
|
||||
record["tone"] = snapshot.tone.rawValue
|
||||
record["updatedAt"] = snapshot.updatedAt
|
||||
record["macName"] = Host.current().localizedName ?? ""
|
||||
record.encryptedValues["payload"] = payload
|
||||
return record
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[services] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Building the snapshots
|
||||
|
||||
/// One snapshot per service that has data on this Mac. A service without its
|
||||
/// key (nothing loaded) is left out, and the iPhone shows "connect it on your Mac".
|
||||
@MainActor
|
||||
enum ServiceSnapshots {
|
||||
static func all(from s: AppState) -> [String: ServiceSnapshot] {
|
||||
let built = [github(s), stripe(s), vercel(s), resend(s), calcom(s), n8n(s), notion(s)].compactMap { $0 }
|
||||
return Dictionary(uniqueKeysWithValues: built.map { ($0.pillId, $0) })
|
||||
}
|
||||
|
||||
// MARK: GitHub
|
||||
|
||||
static func github(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let pulse = s.githubPulse else { return nil }
|
||||
func tone(_ ci: CIState) -> ServiceTone {
|
||||
switch ci {
|
||||
case .success: .ok
|
||||
case .failure: .error
|
||||
case .pending: .warning
|
||||
case .unknown: .idle
|
||||
}
|
||||
}
|
||||
func ciLabel(_ ci: CIState) -> String {
|
||||
switch ci {
|
||||
case .success: "checks passed"
|
||||
case .failure: "checks failing"
|
||||
case .pending: "checks running"
|
||||
case .unknown: "no checks"
|
||||
}
|
||||
}
|
||||
func reviewLabel(_ review: ReviewState) -> String {
|
||||
switch review {
|
||||
case .approved: " · approved"
|
||||
case .changesRequested: " · changes requested"
|
||||
case .pending: " · review required"
|
||||
case .unknown: ""
|
||||
}
|
||||
}
|
||||
let failingPR = pulse.myPRs.first { $0.ci == .failure }
|
||||
let failingMain = pulse.mainCI.first { $0.ci == .failure }
|
||||
let pending = pulse.myPRs.contains { $0.ci == .pending } || pulse.mainCI.contains { $0.ci == .pending }
|
||||
let anyGreen = pulse.myPRs.contains { $0.ci == .success } || pulse.mainCI.contains { $0.ci == .success }
|
||||
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let main = failingMain {
|
||||
overall = .error
|
||||
reason = "CI failing on \(main.repo) · \(main.branch)"
|
||||
} else if let pr = failingPR {
|
||||
overall = .error
|
||||
reason = "Checks failing on \(pr.repo) #\(pr.number): \(pr.title)"
|
||||
} else if pending {
|
||||
overall = .warning
|
||||
reason = "Checks are running"
|
||||
} else if !pulse.toReview.isEmpty {
|
||||
overall = .warning
|
||||
reason = pulse.toReview.count == 1
|
||||
? "1 pull request waits for your review"
|
||||
: "\(pulse.toReview.count) pull requests wait for your review"
|
||||
} else if anyGreen {
|
||||
overall = .ok
|
||||
reason = "All checks passed"
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "Nothing going on"
|
||||
}
|
||||
|
||||
var sections: [ServiceSection] = []
|
||||
if !pulse.myPRs.isEmpty {
|
||||
sections.append(ServiceSection(title: "Your pull requests", items: pulse.myPRs.prefix(8).map {
|
||||
ServiceItem(title: "#\($0.number) \($0.title)",
|
||||
detail: "\($0.repo) · \(ciLabel($0.ci))\(reviewLabel($0.review))\($0.isDraft ? " · draft" : "")",
|
||||
tone: tone($0.ci), url: $0.url)
|
||||
}))
|
||||
}
|
||||
if !pulse.toReview.isEmpty {
|
||||
sections.append(ServiceSection(title: "Waiting for your review", items: pulse.toReview.prefix(8).map {
|
||||
ServiceItem(title: "#\($0.number) \($0.title)", detail: $0.repo, tone: .warning, url: $0.url)
|
||||
}))
|
||||
}
|
||||
if !pulse.mainCI.isEmpty {
|
||||
sections.append(ServiceSection(title: "Default branches", items: pulse.mainCI.prefix(8).map {
|
||||
ServiceItem(title: $0.repo, detail: "\($0.branch) · \(ciLabel($0.ci))", tone: tone($0.ci), url: $0.url)
|
||||
}))
|
||||
}
|
||||
var headline = "\(pulse.myPRs.count) open pull request\(pulse.myPRs.count == 1 ? "" : "s")"
|
||||
if let total = s.githubActivity?.total { headline += " · \(total) contributions this year" }
|
||||
return ServiceSnapshot(pillId: "integration_github", tone: overall, headline: headline, reason: reason,
|
||||
sections: sections, updatedAt: pulse.fetchedAt)
|
||||
}
|
||||
|
||||
// MARK: Stripe
|
||||
|
||||
static func stripe(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.stripeError, !s.stripeLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_stripe", tone: .error, headline: "Can't reach Stripe",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.stripeLoaded else { return nil }
|
||||
let currency = s.stripeCurrency.uppercased()
|
||||
func money(_ cents: Int, _ code: String) -> String {
|
||||
String(format: "%.2f %@", Double(cents) / 100, code.uppercased())
|
||||
}
|
||||
let latest = s.stripePayments.first
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let error = s.stripeError {
|
||||
overall = .error
|
||||
reason = "Stripe answered with an error: \(error)"
|
||||
} else if let latest {
|
||||
let what = latest.description.map { " · \($0)" } ?? ""
|
||||
switch latest.status {
|
||||
case "succeeded":
|
||||
overall = Date().timeIntervalSince(latest.createdAt) < 86_400 ? .ok : .idle
|
||||
reason = "Last payment received: \(money(latest.amount, latest.currency))\(what)"
|
||||
case "failed":
|
||||
overall = .error
|
||||
reason = "Last payment failed: \(money(latest.amount, latest.currency))\(what)"
|
||||
default:
|
||||
overall = .warning
|
||||
reason = "Last payment pending: \(money(latest.amount, latest.currency))\(what)"
|
||||
}
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "No payment yet"
|
||||
}
|
||||
let items = s.stripePayments.prefix(10).map {
|
||||
ServiceItem(title: money($0.amount, $0.currency),
|
||||
detail: [$0.description, $0.status].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: $0.isSuccess ? .ok : ($0.status == "failed" ? .error : .warning),
|
||||
date: $0.createdAt)
|
||||
}
|
||||
return ServiceSnapshot(pillId: "integration_stripe", tone: overall,
|
||||
headline: "Balance \(money(s.stripeBalance, currency))", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Recent payments", items: items)],
|
||||
updatedAt: latest?.createdAt ?? Date())
|
||||
}
|
||||
|
||||
// MARK: Vercel
|
||||
|
||||
static func vercel(_ s: AppState) -> ServiceSnapshot? {
|
||||
let filter = s.vercelProjectFilter
|
||||
let deployments = filter.isEmpty ? s.vercelDeployments : s.vercelDeployments.filter { filter.contains($0.projectName) }
|
||||
guard let latest = deployments.first else { return nil }
|
||||
func tone(_ d: VercelDeployment) -> ServiceTone {
|
||||
d.isSuccess ? .ok : (d.state == "CANCELED" ? .warning : .error)
|
||||
}
|
||||
let branch = latest.branch.map { " · \($0)" } ?? ""
|
||||
let reason: String
|
||||
switch latest.state {
|
||||
case "READY": reason = "Last deploy is live: \(latest.projectName)\(branch)"
|
||||
case "CANCELED": reason = "Last deploy was canceled: \(latest.projectName)\(branch)"
|
||||
default: reason = "Last deploy failed: \(latest.projectName)\(branch)"
|
||||
}
|
||||
let items = deployments.prefix(10).map { d in
|
||||
ServiceItem(title: d.projectName,
|
||||
detail: [d.statusLabel, d.branch, d.commitMessage].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: tone(d), date: d.createdAt,
|
||||
url: d.url.isEmpty ? nil : "https://\(d.url)")
|
||||
}
|
||||
return ServiceSnapshot(pillId: "integration_vercel", tone: tone(latest),
|
||||
headline: "\(latest.projectName) · \(latest.statusLabel)", reason: reason,
|
||||
sections: [ServiceSection(title: "Deployments", items: items)],
|
||||
updatedAt: latest.createdAt)
|
||||
}
|
||||
|
||||
// MARK: Resend
|
||||
|
||||
static func resend(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let latest = s.resendEmails.first else { return nil }
|
||||
func tone(_ event: String) -> ServiceTone {
|
||||
switch event {
|
||||
case "bounced", "complained", "failed": .error
|
||||
case "delivered", "opened", "clicked": .ok
|
||||
case "delivery_delayed": .warning
|
||||
default: .info
|
||||
}
|
||||
}
|
||||
let reason: String
|
||||
switch tone(latest.lastEvent) {
|
||||
case .error: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
case .warning: reason = "Last email is delayed: \(latest.subject)"
|
||||
case .ok: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
default: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
}
|
||||
let items = s.resendEmails.prefix(10).map {
|
||||
ServiceItem(title: $0.subject, detail: "to \($0.to.joined(separator: ", ")) · \($0.lastEvent)",
|
||||
tone: tone($0.lastEvent), date: $0.createdAt)
|
||||
}
|
||||
let total = s.resendTotal.map { "\($0) emails sent" } ?? "Emails"
|
||||
return ServiceSnapshot(pillId: "integration_resend", tone: tone(latest.lastEvent), headline: total,
|
||||
reason: reason, sections: [ServiceSection(title: "Recent emails", items: items)],
|
||||
updatedAt: latest.createdAt)
|
||||
}
|
||||
|
||||
// MARK: Cal.com
|
||||
|
||||
static func calcom(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.calcomError, !s.calcomLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_calcom", tone: .error, headline: "Can't reach Cal.com",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.calcomLoaded else { return nil }
|
||||
let upcoming = s.calcomBookings.filter { $0.endTime > Date() }.sorted { $0.startTime < $1.startTime }
|
||||
let next = upcoming.first { $0.isActive }
|
||||
let time = Date.FormatStyle(date: .abbreviated, time: .shortened)
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let next {
|
||||
let soon = next.startTime.timeIntervalSinceNow < 3600
|
||||
overall = soon ? .warning : .info
|
||||
let with = next.attendeeName.map { " with \($0)" } ?? ""
|
||||
reason = "\(soon ? "Starting soon" : "Next"): \(next.title)\(with), \(next.startTime.formatted(time))"
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "No upcoming booking"
|
||||
}
|
||||
let items = upcoming.prefix(10).map {
|
||||
ServiceItem(title: $0.title,
|
||||
detail: [$0.attendeeName, $0.status.lowercased()].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: $0.isActive ? .info : .idle, date: $0.startTime)
|
||||
}
|
||||
let count = upcoming.filter(\.isActive).count
|
||||
return ServiceSnapshot(pillId: "integration_calcom", tone: overall,
|
||||
headline: "\(count) upcoming booking\(count == 1 ? "" : "s")", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Upcoming", items: items)],
|
||||
updatedAt: Date())
|
||||
}
|
||||
|
||||
// MARK: n8n
|
||||
|
||||
static func n8n(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let latest = s.n8nRuns.first else { return nil }
|
||||
let reason = latest.success
|
||||
? "Last run succeeded: \(latest.workflow)"
|
||||
: "Last run failed: \(latest.workflow)\(latest.detail.map { " · \($0)" } ?? "")"
|
||||
let items = s.n8nRuns.map {
|
||||
ServiceItem(title: $0.workflow, detail: $0.detail ?? ($0.success ? "succeeded" : "failed"),
|
||||
tone: $0.success ? .ok : .error, date: $0.date)
|
||||
}
|
||||
let failed = s.n8nRuns.filter { !$0.success }.count
|
||||
return ServiceSnapshot(pillId: "integration_n8n", tone: latest.success ? .ok : .error,
|
||||
headline: failed == 0 ? "All recent runs succeeded" : "\(failed) failed run\(failed == 1 ? "" : "s")",
|
||||
reason: reason, sections: [ServiceSection(title: "Recent runs", items: items)],
|
||||
updatedAt: latest.date)
|
||||
}
|
||||
|
||||
// MARK: Notion
|
||||
|
||||
static func notion(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.notionError, !s.notionLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_notion", tone: .error, headline: "Can't reach Notion",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.notionLoaded else { return nil }
|
||||
let pages = s.notionPages.sorted { $0.lastEditedAt > $1.lastEditedAt }
|
||||
let items = pages.prefix(10).map {
|
||||
ServiceItem(title: [$0.emoji, $0.title].compactMap { $0 }.joined(separator: " "),
|
||||
tone: .info, date: $0.lastEditedAt, url: $0.url)
|
||||
}
|
||||
let reason = pages.first.map { "Last edited: \($0.title)" } ?? "No recent page"
|
||||
let recent = (pages.first?.lastEditedAt.timeIntervalSinceNow ?? -.infinity) > -3600
|
||||
return ServiceSnapshot(pillId: "integration_notion", tone: recent ? .info : .idle,
|
||||
headline: "\(pages.count) recent page\(pages.count == 1 ? "" : "s")", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Recently edited", items: items)],
|
||||
updatedAt: pages.first?.lastEditedAt ?? Date())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,257 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - iPhone plan, step 4: publish agent sessions to iCloud
|
||||
//
|
||||
// One `Session` record per agent pill (VS Code / Claude Code, Cursor, Codex,
|
||||
// Antigravity, Gemini CLI…) in the private zone `Coucou`, so the iPhone can show
|
||||
// them. Driven only by AppState changes (debounced), never by a timer: nothing
|
||||
// runs while no agent changes. Integrations (Stripe, Vercel…) are not sessions.
|
||||
//
|
||||
// In clear (needed for sorting and widgets): pillId, state, step counts, flags,
|
||||
// dates. Encrypted with the user's iCloud keys: project name, steps, cwd,
|
||||
// last message, the pending command and question.
|
||||
|
||||
@MainActor
|
||||
final class SessionPublisher {
|
||||
static let shared = SessionPublisher()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
/// What was last written to iCloud, by pill ID.
|
||||
private var published: [String: SessionSnapshot] = [:]
|
||||
private var zoneReady = false
|
||||
private var cleanedUp = false
|
||||
private var publishing = false
|
||||
private var pending: [String: SessionSnapshot]?
|
||||
|
||||
/// Stops publishing and deletes this Mac's sessions from iCloud.
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
pending = nil
|
||||
Task {
|
||||
do {
|
||||
let existing = try await existingSessions()
|
||||
let ids = existing.keys.map { SessionSnapshot.recordID(for: $0) }
|
||||
if !ids.isEmpty {
|
||||
_ = try await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false)
|
||||
}
|
||||
log("publisher off, removed \(ids.count) session(s) from iCloud")
|
||||
} catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone {
|
||||
log("publisher off, nothing in iCloud")
|
||||
} catch {
|
||||
log("publisher off, cleanup failed: \(error.localizedDescription)")
|
||||
}
|
||||
published = [:]
|
||||
cleanedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
let state = AppState.shared
|
||||
cancellable = state.$tasks
|
||||
.combineLatest(state.$pendingApproval, state.$pendingQuestion)
|
||||
.map { tasks, approval, question in
|
||||
SessionSnapshot.all(tasks: tasks, approval: approval, question: question)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] snapshots in
|
||||
MainActor.assumeIsolated { self?.publish(snapshots) }
|
||||
}
|
||||
log("session publisher on")
|
||||
}
|
||||
|
||||
// MARK: Publishing
|
||||
|
||||
private func publish(_ snapshots: [String: SessionSnapshot]) {
|
||||
guard !publishing else { pending = snapshots; return }
|
||||
publishing = true
|
||||
Task {
|
||||
await write(snapshots)
|
||||
publishing = false
|
||||
if let next = pending {
|
||||
pending = nil
|
||||
publish(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ snapshots: [String: SessionSnapshot]) async {
|
||||
do {
|
||||
if !zoneReady {
|
||||
_ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: SessionSnapshot.zoneID)], deleting: [])
|
||||
zoneReady = true
|
||||
}
|
||||
if !cleanedUp {
|
||||
// Sessions left by a previous run (the Mac quit or crashed).
|
||||
published = try await existingSessions()
|
||||
cleanedUp = true
|
||||
}
|
||||
|
||||
let changed = snapshots.values.filter { published[$0.pillId] != $0 }
|
||||
let removed = published.keys.filter { snapshots[$0] == nil }
|
||||
guard !changed.isEmpty || !removed.isEmpty else { return }
|
||||
|
||||
let records = changed.map { $0.record() }
|
||||
let deletions = removed.map { SessionSnapshot.recordID(for: $0) }
|
||||
let result = try await database.modifyRecords(saving: records, deleting: deletions,
|
||||
savePolicy: .changedKeys, atomically: false)
|
||||
for (id, outcome) in result.saveResults {
|
||||
guard let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
switch outcome {
|
||||
case .success: published[pillId] = snapshots[pillId]
|
||||
case .failure(let error): log("save \(pillId) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
for (id, outcome) in result.deleteResults {
|
||||
guard let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
switch outcome {
|
||||
case .success: published[pillId] = nil
|
||||
case .failure(let error): log("delete \(pillId) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
let summary = changed.map { "\($0.pillId)=\($0.state)" }.sorted().joined(separator: ", ")
|
||||
log("published \(changed.count) session(s)\(summary.isEmpty ? "" : " [\(summary)]"), removed \(removed.count)")
|
||||
} catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone {
|
||||
zoneReady = false
|
||||
log("zone missing, will recreate on the next change")
|
||||
} catch {
|
||||
log("publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Session records already in the zone, so the first write can delete stale ones.
|
||||
private func existingSessions() async throws -> [String: SessionSnapshot] {
|
||||
var found: [String: SessionSnapshot] = [:]
|
||||
var token: CKServerChangeToken?
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: token)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == SessionSnapshot.recordType,
|
||||
let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
// Unknown content: a placeholder that never equals a real snapshot,
|
||||
// so it is rewritten if the session still exists, deleted otherwise.
|
||||
found[pillId] = SessionSnapshot.placeholder(pillId: pillId)
|
||||
}
|
||||
token = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[sessions] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
/// The part of an AgentTask the iPhone needs. Equatable so unchanged sessions
|
||||
/// are not rewritten.
|
||||
struct SessionSnapshot: Equatable {
|
||||
static let recordType = "Session"
|
||||
|
||||
let pillId: String
|
||||
let name: String
|
||||
let color: String
|
||||
let state: String
|
||||
let stepIndex: Int
|
||||
let steps: [String]
|
||||
let cwd: String
|
||||
let finalLine: String
|
||||
let approvalTool: String
|
||||
let approvalCommand: String
|
||||
let approvalFingerprint: String
|
||||
let question: String
|
||||
/// The question's choices (QuestionPayload JSON) and its fingerprint, for answering from the iPhone.
|
||||
var questionPayload: String = ""
|
||||
var questionFingerprint: String = ""
|
||||
|
||||
static func all(tasks: [AgentTask], approval: ApprovalInfo?,
|
||||
question: AskQuestion?) -> [String: SessionSnapshot] {
|
||||
var result: [String: SessionSnapshot] = [:]
|
||||
// Services (Stripe, GitHub…) go through ServicePublisher, with their data.
|
||||
for task in tasks where task.source != .n8n && PillCatalog.isSession(task.id) {
|
||||
let hasApproval = approval?.pillId == task.id
|
||||
let questionText = task.state == .question
|
||||
? (question?.questions.map(\.question).joined(separator: "\n") ?? "")
|
||||
: ""
|
||||
let payload = task.state == .question ? question.map(QuestionPayload.init(ask:)) : nil
|
||||
result[task.id] = SessionSnapshot(
|
||||
pillId: task.id,
|
||||
name: task.name,
|
||||
color: task.color,
|
||||
state: task.state.rawValue,
|
||||
stepIndex: task.stepIndex,
|
||||
steps: task.steps,
|
||||
cwd: task.sessionCwd ?? "",
|
||||
finalLine: task.finalLine ?? "",
|
||||
approvalTool: hasApproval ? (approval?.tool ?? "") : "",
|
||||
approvalCommand: hasApproval ? (approval?.command ?? "") : "",
|
||||
approvalFingerprint: hasApproval ? (approval.map(ApprovalRelay.fingerprint) ?? "") : "",
|
||||
question: questionText,
|
||||
questionPayload: payload?.json ?? "",
|
||||
questionFingerprint: payload?.fingerprint ?? "")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func placeholder(pillId: String) -> SessionSnapshot {
|
||||
SessionSnapshot(pillId: pillId, name: "", color: "", state: "", stepIndex: -1, steps: [],
|
||||
cwd: "", finalLine: "", approvalTool: "", approvalCommand: "", approvalFingerprint: "", question: "")
|
||||
}
|
||||
|
||||
/// Same zone as CloudProbe.zoneID, rebuilt here because that one is main-actor isolated.
|
||||
static var zoneID: CKRecordZone.ID {
|
||||
CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName)
|
||||
}
|
||||
|
||||
static func recordID(for pillId: String) -> CKRecord.ID {
|
||||
CKRecord.ID(recordName: "session-\(pillId)", zoneID: zoneID)
|
||||
}
|
||||
|
||||
static func pillId(from id: CKRecord.ID) -> String? {
|
||||
let name = id.recordName
|
||||
guard name.hasPrefix("session-") else { return nil }
|
||||
return String(name.dropFirst("session-".count))
|
||||
}
|
||||
|
||||
func record() -> CKRecord {
|
||||
let record = CKRecord(recordType: Self.recordType, recordID: Self.recordID(for: pillId))
|
||||
record["pillId"] = pillId
|
||||
record["color"] = color
|
||||
record["state"] = state
|
||||
record["stepIndex"] = stepIndex
|
||||
record["stepCount"] = steps.count
|
||||
record["needsApproval"] = !approvalCommand.isEmpty || !approvalTool.isEmpty
|
||||
// Identifies the exact request; the iPhone sends it back with its decision.
|
||||
record["approvalFingerprint"] = approvalFingerprint
|
||||
record["needsAnswer"] = !question.isEmpty
|
||||
record["questionFingerprint"] = questionFingerprint
|
||||
record["updatedAt"] = Date()
|
||||
record["macName"] = Host.current().localizedName ?? ""
|
||||
// Whether this Mac runs instructions sent from the iPhone (GitHub build, switch on).
|
||||
#if APPSTORE
|
||||
record["acceptsInstructions"] = false
|
||||
#else
|
||||
record["acceptsInstructions"] = InstructionRunner.isEnabled && (pillId == "integration_claude" || pillId == "agent_cursor")
|
||||
#endif
|
||||
record.encryptedValues["name"] = name
|
||||
record.encryptedValues["steps"] = steps
|
||||
record.encryptedValues["cwd"] = cwd
|
||||
record.encryptedValues["finalLine"] = finalLine
|
||||
record.encryptedValues["approvalTool"] = approvalTool
|
||||
record.encryptedValues["approvalCommand"] = approvalCommand
|
||||
record.encryptedValues["question"] = question
|
||||
record.encryptedValues["questionPayload"] = questionPayload
|
||||
return record
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,217 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
|
||||
// MARK: - The last turn, for the iPhone
|
||||
//
|
||||
// Built from the hook events HookServer already receives: UserPromptSubmit
|
||||
// starts a turn, Pre/PostToolUse add actions (commands with their output,
|
||||
// edits with their diff), Stop adds the final answer. One encrypted `Turn`
|
||||
// record per session pill, replaced at each turn; nothing else is kept.
|
||||
// Runs only while the iPhone sync is on; no timer.
|
||||
|
||||
@MainActor
|
||||
final class TurnRecorder {
|
||||
static let shared = TurnRecorder()
|
||||
|
||||
private let container = CKContainer(identifier: CloudProbe.containerID)
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var running = false
|
||||
private var turns: [String: TurnSnapshot] = [:]
|
||||
private var dirty: Set<String> = []
|
||||
private var flushTask: Task<Void, Never>?
|
||||
|
||||
// Size limits: a turn stays well under CloudKit's 1 MB per record.
|
||||
private let maxActions = 200
|
||||
private let maxLinesPerFile = 600
|
||||
private let maxTotalLines = 4000
|
||||
private let maxOutput = 1500
|
||||
private let maxFinal = 12_000
|
||||
|
||||
/// The Claude Code session last seen on a pill and its folder, for
|
||||
/// instructions sent from the iPhone (InstructionRunner).
|
||||
func lastSession(for pillId: String) -> (sessionId: String, cwd: String)? {
|
||||
guard let info = sessions[pillId], !info.sessionId.isEmpty, !info.cwd.isEmpty else { return nil }
|
||||
return info
|
||||
}
|
||||
private var sessions: [String: (sessionId: String, cwd: String)] = [:]
|
||||
|
||||
func start() {
|
||||
running = true
|
||||
log("turn recorder on")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
running = false
|
||||
flushTask?.cancel(); flushTask = nil
|
||||
let ids = turns.keys.map {
|
||||
CKRecord.ID(recordName: TurnSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
turns = [:]
|
||||
dirty = []
|
||||
if !ids.isEmpty {
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by HookServer for every event of a session pill.
|
||||
func record(event: String, payload: [String: Any], pillId: String) {
|
||||
guard running, PillCatalog.isSession(pillId) else { return }
|
||||
let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? ""
|
||||
let cwd = payload["cwd"] as? String ?? ""
|
||||
let now = Date()
|
||||
if !sessionId.isEmpty, !cwd.isEmpty { sessions[pillId] = (sessionId, cwd) }
|
||||
|
||||
switch event {
|
||||
case "UserPromptSubmit":
|
||||
let prompt = payload["prompt"] as? String ?? ""
|
||||
turns[pillId] = TurnSnapshot(pillId: pillId, sessionId: sessionId,
|
||||
project: URL(fileURLWithPath: cwd).lastPathComponent,
|
||||
prompt: String(prompt.prefix(8000)), actions: [], files: [],
|
||||
finalMessage: "", startedAt: now, endedAt: nil)
|
||||
|
||||
case "PreToolUse":
|
||||
let tool = payload["tool_name"] as? String ?? "Tool"
|
||||
guard tool != "AskUserQuestion" else { return }
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
guard turn.actions.count < maxActions else { return }
|
||||
let input = payload["tool_input"] as? [String: Any] ?? [:]
|
||||
turn.actions.append(TurnAction(tool: tool, summary: Self.summary(tool: tool, input: input), date: now))
|
||||
turns[pillId] = turn
|
||||
|
||||
case "PostToolUse", "PostToolUseFailure":
|
||||
let tool = payload["tool_name"] as? String ?? "Tool"
|
||||
guard tool != "AskUserQuestion" else { return }
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
let input = payload["tool_input"] as? [String: Any] ?? [:]
|
||||
let summary = Self.summary(tool: tool, input: input)
|
||||
// The matching PreToolUse action, latest first; one is added if it was missed.
|
||||
var index = turn.actions.lastIndex { $0.tool == tool && $0.summary == summary && $0.output.isEmpty && $0.fileIndex == nil }
|
||||
if index == nil, turn.actions.count < maxActions {
|
||||
turn.actions.append(TurnAction(tool: tool, summary: summary, date: now))
|
||||
index = turn.actions.count - 1
|
||||
}
|
||||
guard let index else { return }
|
||||
turn.actions[index].failed = event == "PostToolUseFailure"
|
||||
turn.actions[index].output = Self.output(payload, limit: maxOutput)
|
||||
if event == "PostToolUse", let diff = Self.fileDiff(tool: tool, input: input) {
|
||||
let used = turn.files.reduce(0) { $0 + $1.lines.count }
|
||||
let budget = max(0, min(maxLinesPerFile, maxTotalLines - used))
|
||||
turn.files.append(TurnFile(diff: diff, maxLines: budget))
|
||||
turn.actions[index].fileIndex = turn.files.count - 1
|
||||
}
|
||||
turns[pillId] = turn
|
||||
|
||||
case "Stop", "StopFailure":
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
let final = (payload["last_assistant_message"] as? String) ?? (payload["message"] as? String) ?? ""
|
||||
turn.finalMessage = String(final.prefix(maxFinal))
|
||||
turn.endedAt = now
|
||||
turns[pillId] = turn
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
dirty.insert(pillId)
|
||||
scheduleFlush(soon: event == "Stop" || event == "StopFailure" || event == "UserPromptSubmit")
|
||||
}
|
||||
|
||||
/// The turn in progress, or a new one when the prompt wasn't seen (Codex, app started mid-turn).
|
||||
private func current(_ pillId: String, sessionId: String, cwd: String) -> TurnSnapshot {
|
||||
if let turn = turns[pillId], turn.endedAt == nil { return turn }
|
||||
return TurnSnapshot(pillId: pillId, sessionId: sessionId,
|
||||
project: URL(fileURLWithPath: cwd).lastPathComponent,
|
||||
prompt: "", actions: [], files: [], finalMessage: "", startedAt: Date(), endedAt: nil)
|
||||
}
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
private func scheduleFlush(soon: Bool) {
|
||||
if soon { flushTask?.cancel(); flushTask = nil }
|
||||
guard flushTask == nil else { return }
|
||||
flushTask = Task {
|
||||
try? await Task.sleep(for: .seconds(soon ? 0.5 : 2))
|
||||
guard !Task.isCancelled else { return }
|
||||
flushTask = nil
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
|
||||
private func flush() async {
|
||||
let ids = dirty
|
||||
dirty = []
|
||||
let records = ids.compactMap { id -> CKRecord? in
|
||||
guard let turn = turns[id], let json = try? JSONEncoder().encode(turn),
|
||||
let payload = String(data: json, encoding: .utf8) else { return nil }
|
||||
let record = CKRecord(recordType: TurnSnapshot.recordType,
|
||||
recordID: CKRecord.ID(recordName: TurnSnapshot.recordName(for: id),
|
||||
zoneID: SessionSnapshot.zoneID))
|
||||
record["pillId"] = id
|
||||
record["updatedAt"] = Date()
|
||||
record.encryptedValues["payload"] = payload
|
||||
return record
|
||||
}
|
||||
guard !records.isEmpty else { return }
|
||||
do {
|
||||
_ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .allKeys, atomically: false)
|
||||
} catch {
|
||||
log("turn publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reading the hook payloads
|
||||
|
||||
static func summary(tool: String, input: [String: Any]) -> String {
|
||||
let keys = ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"]
|
||||
for key in keys {
|
||||
if let value = input[key] as? String, !value.isEmpty { return String(value.prefix(600)) }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// What a command printed, or the error.
|
||||
static func output(_ payload: [String: Any], limit: Int) -> String {
|
||||
var text = ""
|
||||
if let error = payload["error"] as? String {
|
||||
text = error
|
||||
} else if let response = payload["tool_response"] as? [String: Any] {
|
||||
let stdout = response["stdout"] as? String ?? ""
|
||||
let stderr = response["stderr"] as? String ?? ""
|
||||
text = [stdout, stderr].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
} else if let response = payload["tool_response"] as? String {
|
||||
text = response
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.count > limit ? String(trimmed.prefix(limit)) + "\n…" : trimmed
|
||||
}
|
||||
|
||||
static func fileDiff(tool: String, input: [String: Any]) -> FileDiff? {
|
||||
guard let path = input["file_path"] as? String else { return nil }
|
||||
switch tool {
|
||||
case "Edit":
|
||||
guard let old = input["old_string"] as? String, let new = input["new_string"] as? String else { return nil }
|
||||
return DiffEngine.fromEdit(old: old, new: new, path: path)
|
||||
case "MultiEdit":
|
||||
guard let edits = input["edits"] as? [[String: Any]] else { return nil }
|
||||
var added = 0, removed = 0, hunks: [DiffHunk] = [], tooLarge = false
|
||||
for edit in edits {
|
||||
guard let old = edit["old_string"] as? String, let new = edit["new_string"] as? String else { continue }
|
||||
let d = DiffEngine.fromEdit(old: old, new: new, path: path)
|
||||
added += d.added; removed += d.removed
|
||||
hunks += d.hunks
|
||||
tooLarge = tooLarge || d.tooLarge
|
||||
}
|
||||
return FileDiff(path: path, added: added, removed: removed, hunks: hunks, tooLarge: tooLarge, isNewFile: false)
|
||||
case "Write":
|
||||
guard let content = input["content"] as? String else { return nil }
|
||||
return DiffEngine.fromNew(content: content, path: path)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[turn] \(message)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user