import Foundation import Network final class ServiceChecker { static let statusDidChangeNotification = Notification.Name("ServiceChecker.statusDidChange") static let forceRefreshNotification = Notification.Name("ServiceChecker.forceRefresh") private let queue = DispatchQueue(label: "prole.status.checker") private var timer: DispatchSourceTimer? // Public reachability flags private(set) var svcReachable = false private(set) var raspberryReachable = false private(set) var piReachable = false private(set) var localReachable = false // Latency measurements (ms) private(set) var svcLatency: Int = -1 private(set) var raspberryLatency: Int = -1 private(set) var piLatency: Int = -1 private(set) var localLatency: Int = -1 // Last error messages (for tooltips when red) private(set) var svcError: String? = nil private(set) var raspberryError: String? = nil private(set) var piError: String? = nil private(set) var localError: String? = nil init() { NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil) } func start(interval: TimeInterval = 15) { timer?.cancel() let t = DispatchSource.makeTimerSource(queue: queue) t.schedule(deadline: .now(), repeating: interval) t.setEventHandler { [weak self] in self?.refreshAll() } t.resume() timer = t } @objc private func forceRefresh() { queue.async { self.refreshAll() } } private func refreshAll() { let group = DispatchGroup() // clear previous errors before a new round svcError = nil; raspberryError = nil; piError = nil; localError = nil group.enter(); tcpPing(host: "svc.prole.org", port: 443) { [weak self] ok, ms, err in self?.svcReachable = ok; self?.svcLatency = ms; self?.svcError = err; group.leave() } group.enter(); tcpPing(host: "raspberry.prole.org", port: 6443) { [weak self] ok, ms, err in self?.raspberryReachable = ok; self?.raspberryLatency = ms; self?.raspberryError = err; group.leave() } group.enter(); tcpPing(host: "pi.prole.org", port: 6443) { [weak self] ok, ms, err in self?.piReachable = ok; self?.piLatency = ms; self?.piError = err; group.leave() } group.enter(); tcpPing(host: "localhost", port: 6443) { [weak self] ok, ms, err in self?.localReachable = ok; self?.localLatency = ms; self?.localError = err; group.leave() } group.notify(queue: .main) { NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self) } } private func tcpPing(host: String, port: UInt16, timeout: TimeInterval = 2.0, completion: @escaping (Bool, Int, String?) -> Void) { let start = DispatchTime.now() let params = NWParameters.tcp params.allowLocalEndpointReuse = true let endpoint = NWEndpoint.hostPort(host: .name(host, nil), port: .init(integerLiteral: port)) let conn = NWConnection(to: endpoint, using: params) conn.stateUpdateHandler = { state in switch state { case .ready: let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds completion(true, Int(Double(elapsed) / 1_000_000.0), nil) conn.cancel() case .failed(let error): completion(false, -1, Self.describeNWError(error)) case .cancelled: // If cancelled before ready, likely timed out completion(false, -1, "connection cancelled (possible timeout)") default: break } } conn.start(queue: queue) // Timeout queue.asyncAfter(deadline: .now() + timeout) { conn.cancel() } } // Tooltips func tooltipForSvc() -> String { if svcReachable { return "svc.prole.org:443 — reachable (\(svcLatency) ms)" } var s = "svc.prole.org:443 — unreachable" if let e = svcError { s += "\nError: \(e)" } return s } func tooltipForAggregateK3s() -> String { var r = raspberryReachable ? "raspberry ✓ (\(raspberryLatency) ms)" : "raspberry ✗" var p = piReachable ? "pi ✓ (\(piLatency) ms)" : "pi ✗" if !raspberryReachable, let e = raspberryError { r += " — \(e)" } if !piReachable, let e = piError { p += " — \(e)" } let overall: String switch (raspberryReachable, piReachable) { case (true, true): overall = "overall: green" case (true, false), (false, true): overall = "overall: yellow" default: overall = "overall: red" } return "k3s aggregate — \(overall)\n\(r)\n\(p)" } func tooltipForLocal() -> String { if localReachable { return "k3d localhost:6443 — reachable (\(localLatency) ms)" } var s = "k3d localhost:6443 — unreachable" if let e = localError { s += "\nError: \(e)" } return s } private static func describeNWError(_ error: NWError) -> String { switch error { case .posix(let code): return "POSIX \(code.rawValue): \(code)" case .dns(let code): return "DNS \(code): \(code)" case .tls(let status): return "TLS/OSStatus \(status)" case .wifiAware(let reason): return "Wi-Fi Aware: \(reason)" @unknown default: return "Unknown network error" } } }