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() } dlog("ServiceChecker: starting timer, interval=\(interval)s") t.resume() timer = t } @objc private func forceRefresh() { queue.async { self.refreshAll() } } private func refreshAll() { dlog("ServiceChecker: begin refresh round") let group = DispatchGroup() // clear previous errors before a new round svcError = nil; raspberryError = nil; piError = nil; localError = nil let cfg = Config.shared group.enter(); tcpPing(host: cfg.svcHost, port: UInt16(cfg.svcPort)) { [weak self] ok, ms, err in self?.svcReachable = ok; self?.svcLatency = ms; self?.svcError = err; group.leave() } group.enter(); tcpPing(host: cfg.retropieHost, port: UInt16(cfg.retropiePort)) { [weak self] ok, ms, err in self?.raspberryReachable = ok; self?.raspberryLatency = ms; self?.raspberryError = err; group.leave() } group.enter(); tcpPing(host: cfg.localHost, port: UInt16(cfg.localPort)) { [weak self] ok, ms, err in self?.localReachable = ok; self?.localLatency = ms; self?.localError = err; group.leave() } group.notify(queue: .main) { dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification") 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) { dlog("tcpPing: attempting \(host):\(port) timeout=\(timeout)s") 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) // Ensure completion is invoked exactly once var finished = false func finishOnce(_ ok: Bool, _ ms: Int, _ err: String?, reason: String) { // All state updates and timeout run on `queue` so this is serialized if finished { return } finished = true dlog("tcpPing: finishOnce(\(host):\(port)) reason=\(reason) ok=\(ok) ms=\(ms) err=\(err ?? "nil")") completion(ok, ms, err) } // Prepare timeout work item so we can cancel it on success/failure let timeoutWork = DispatchWorkItem { [weak conn] in if finished { return } dlog("tcpPing: timeout reached for \(host):\(port); cancelling connection") conn?.cancel() finishOnce(false, -1, "connection timed out", reason: "timeout") } conn.stateUpdateHandler = { state in switch state { case .ready: let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds let ms = Int(Double(elapsed) / 1_000_000.0) dlog("tcpPing: READY \(host):\(port) in \(ms) ms") timeoutWork.cancel() finishOnce(true, ms, nil, reason: "ready") conn.cancel() // will emit .cancelled; ignored due to finished=true case .failed(let error): let msg = Self.describeNWError(error) dlog("tcpPing: FAILED \(host):\(port) — \(msg)") timeoutWork.cancel() finishOnce(false, -1, msg, reason: "failed") conn.cancel() case .cancelled: // If we already finished (e.g., due to .ready), this is expected; ignore. if finished { dlog("tcpPing: CANCELLED \(host):\(port) after finish — ignoring") } else { // Cancel without prior .ready/.failed implies timeout or external cancel finishOnce(false, -1, "connection cancelled (possible timeout)", reason: "cancelled-before-finish") } default: break } } conn.start(queue: queue) // Schedule timeout on the same queue queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork) } // Tooltips func tooltipForSvc() -> String { let host = Config.shared.svcHost let port = Config.shared.svcPort if svcReachable { return "\(host):\(port) — reachable (\(svcLatency) ms)" } var s = "\(host):\(port) — unreachable" if let e = svcError { s += "\nError: \(e)" } return s } func tooltipForAggregateK3s() -> String { let rHost = Config.shared.retropieHost var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost) ✗" var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost) ✗" 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 { let host = Config.shared.localHost let port = Config.shared.localPort if localReachable { return "k3d \(host):\(port) — reachable (\(localLatency) ms)" } var s = "k3d \(host):\(port) — 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" } } }