import Foundation import SwiftUI enum DaemonConnectionState: Equatable { case unknown case connecting case connected(latencyMs: Int) case disconnected(reason: String) case reconnecting(attempt: Int) } @MainActor final class DaemonHealthMonitor: ObservableObject { @Published var state: DaemonConnectionState = .unknown @Published var consecutiveFailures = 0 var onReconnected: (() async -> Void)? var onDisconnected: (() -> Void)? var config: DaemonConfiguration private var pollTask: Task? private let pollInterval: TimeInterval = 8 private let timeout: TimeInterval = 4 init(config: DaemonConfiguration) { self.config = config } func startPolling() { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { await self?.poll() try? await Task.sleep(for: .seconds(self?.pollInterval ?? 8)) } } } func stopPolling() { pollTask?.cancel(); pollTask = nil } func reconnectNow() async { consecutiveFailures = 0; await poll() } private func poll() async { guard let url = URL(string: "\(config.baseURL)/health") else { return } var req = URLRequest(url: url); req.timeoutInterval = timeout let start = Date() do { let wasDown: Bool if case .disconnected = state { wasDown = true } else if case .reconnecting = state { wasDown = true } else { wasDown = false } state = consecutiveFailures > 0 ? .reconnecting(attempt: consecutiveFailures) : .connecting let (_, resp) = try await URLSession.shared.data(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 200 else { handleFailure("Bad status"); return } let ms = Int(Date().timeIntervalSince(start) * 1000) consecutiveFailures = 0 state = .connected(latencyMs: ms) if wasDown { await onReconnected?() } } catch { handleFailure(error.localizedDescription) } } private func handleFailure(_ reason: String) { consecutiveFailures += 1 state = .disconnected(reason: reason) onDisconnected?() } } // MARK: - Status Indicator View struct DaemonStatusIndicator: View { @ObservedObject var monitor: DaemonHealthMonitor @State private var showDetail = false var body: some View { Button { showDetail.toggle() } label: { HStack(spacing: 4) { Circle().fill(indicatorColor).frame(width: 8, height: 8) Text(shortLabel) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) } } .buttonStyle(.plain) .popover(isPresented: $showDetail) { VStack(alignment: .leading, spacing: 8) { Label("Mac Daemon", systemImage: "desktopcomputer").font(.headline) Divider() HStack { Text("Status").foregroundStyle(.secondary); Spacer() Text(monitor.state.statusLabel).bold() } if case .connected(let ms) = monitor.state { HStack { Text("Latency").foregroundStyle(.secondary); Spacer() Text("\(ms) ms").bold() } } if monitor.consecutiveFailures > 0 { HStack { Text("Failures").foregroundStyle(.secondary); Spacer() Text("\(monitor.consecutiveFailures)").foregroundStyle(.red).bold() } } if case .disconnected = monitor.state { Button("Reconnect Now") { Task { await monitor.reconnectNow() } } .buttonStyle(.borderedProminent).controlSize(.small).padding(.top, 4) } } .padding(16).frame(width: 220) } } private var indicatorColor: Color { switch monitor.state { case .connected: return .green case .connecting: return .yellow case .reconnecting: return .orange case .disconnected: return .red default: return .secondary } } private var shortLabel: String { switch monitor.state { case .connected(let ms): return "\(ms)ms" case .connecting: return "…" case .reconnecting(let n): return "retry \(n)" case .disconnected: return "offline" default: return "—" } } } extension DaemonConnectionState { var statusLabel: String { switch self { case .connected: return "Connected" case .connecting: return "Connecting…" case .reconnecting(let n): return "Reconnecting (\(n))" case .disconnected(let r): return "Disconnected: \(r)" case .unknown: return "Unknown" } } }