diff --git a/prole-app/Sources/AppDelegate.swift b/prole-app/Sources/AppDelegate.swift index fca139a..9efaf0c 100644 --- a/prole-app/Sources/AppDelegate.swift +++ b/prole-app/Sources/AppDelegate.swift @@ -22,6 +22,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Splash screen removed — keep no reference private var appMenuToggleStatusBarItem: NSMenuItem? private var pfManager: PortForwardManager? + private var preferencesWindowController: PreferencesWindowController? // We keep the app in one of two simple modes. // - statusBar: shows the thin overlay near the macOS menu bar @@ -116,6 +117,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Build application menu (shown when activation policy is .regular) setupApplicationMenu() + // Observe config changes: refresh status on save + NotificationCenter.default.addObserver(self, selector: #selector(menuRefresh), name: Config.didChangeNotification, object: nil) + // Start in Application Window mode by default // Ensure main window is positioned at the left and visible immediately if let mainWin = mainWindowController.window { MainWindowController.positionWindowAtLeftEdge(mainWin) } @@ -223,6 +227,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appMenu.addItem(toggle) self.appMenuToggleStatusBarItem = toggle + // Preferences… + let prefs = NSMenuItem(title: "Preferences…", action: #selector(menuPreferences), keyEquivalent: ",") + prefs.keyEquivalentModifierMask = [.command] + prefs.target = self + appMenu.addItem(prefs) + appMenu.addItem(NSMenuItem.separator()) // Reset Port Forwards (Dev) @@ -259,6 +269,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func menuToggleMode() { toggleMode() } @objc private func menuRefresh() { refreshNow() } @objc private func menuResetPortForwards() { pfManager?.reset() } + @objc private func menuPreferences() { + if preferencesWindowController == nil { + preferencesWindowController = PreferencesWindowController() + } + preferencesWindowController?.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } // Invoked by overlay maximize button @objc private func showMainWindowRequested() { diff --git a/prole-app/Sources/Config.swift b/prole-app/Sources/Config.swift index 726f957..3d74d26 100644 --- a/prole-app/Sources/Config.swift +++ b/prole-app/Sources/Config.swift @@ -6,17 +6,27 @@ final class Config { static let shared = Config() private var props: [String: String] = [:] + static let didChangeNotification = Notification.Name("Config.didChange") + + struct KubernetesEndpoint: Equatable, Codable { + var host: String + var port: Int + } + struct ServiceEndpoint: Equatable, Codable { + var name: String + var host: String + var port: Int + } + struct PortMapping: Equatable, Codable { + var service: String + var namespace: String + var exposePort: Int + var internalPort: Int + } private init() { - // Defaults - props = [ - "svc.host": "svc.prole.org", - "svc.port": "443", - "k3s.retropie.host": "retropie.prole.org", - "k3s.retropie.port": "6443", - "k3d.local.host": "localhost", - "k3d.local.port": "6443" - ] + // Defaults kept minimal; lists below will ensure sane defaults + props = [:] // Load bundled defaults if present if let url = Bundle.main.url(forResource: "prole", withExtension: "properties") { @@ -54,15 +64,96 @@ final class Config { func string(_ key: String, default def: String) -> String { props[key] ?? def } func int(_ key: String, default def: Int) -> Int { Int(props[key] ?? "") ?? def } - // Convenience accessors used by ServiceChecker/StatusView - var svcHost: String { string("svc.host", default: "svc.prole.org") } - var svcPort: Int { int("svc.port", default: 443) } + // MARK: - New structured configuration + // Kubernetes endpoints list (hostname + port). Defaults to 1 entry as requested. + var kubernetes: [KubernetesEndpoint] { + get { + let items = loadIndexed(prefix: "kube") { idx in + if let host = props["kube.\(idx).host"], !host.isEmpty { + let port = Int(props["kube.\(idx).port"] ?? "") ?? 6443 + return KubernetesEndpoint(host: host, port: port) + } + return nil + } + if !items.isEmpty { return items } + // Defaults + return [KubernetesEndpoint(host: "retropie.prole.org", port: 6443)] + } + set { + clearIndexed(prefix: "kube") + for (i, it) in newValue.enumerated() { + let idx = i + 1 + props["kube.\(idx).host"] = it.host + props["kube.\(idx).port"] = String(it.port) + } + } + } - var retropieHost: String { string("k3s.retropie.host", default: "retropie.prole.org") } - var retropiePort: Int { int("k3s.retropie.port", default: 6443) } + // Services list (name + hostname + port). Defaults: k3d + svc.prole.org + var services: [ServiceEndpoint] { + get { + let items: [ServiceEndpoint] = loadIndexed(prefix: "svc") { idx in + guard let name = props["svc.\(idx).name"], !name.isEmpty else { return nil } + let host = props["svc.\(idx).host"] ?? name + let port = Int(props["svc.\(idx).port"] ?? "") ?? 443 + return ServiceEndpoint(name: name, host: host, port: port) + } + if !items.isEmpty { return items } + return [ + ServiceEndpoint(name: "k3d", host: "localhost", port: 6443), + ServiceEndpoint(name: "svc.prole.org", host: "svc.prole.org", port: 443) + ] + } + set { + clearIndexed(prefix: "svc") + for (i, it) in newValue.enumerated() { + let idx = i + 1 + props["svc.\(idx).name"] = it.name + props["svc.\(idx).host"] = it.host + props["svc.\(idx).port"] = String(it.port) + } + } + } - var localHost: String { string("k3d.local.host", default: "localhost") } - var localPort: Int { int("k3d.local.port", default: 6443) } + // Port mappings (service name, namespace, expose, internal) + var portMappings: [PortMapping] { + get { + let items: [PortMapping] = loadIndexed(prefix: "port") { idx in + guard let svc = props["port.\(idx).service"], !svc.isEmpty else { return nil } + let ns = props["port.\(idx).namespace"] ?? "default" + let expose = Int(props["port.\(idx).expose"] ?? "") ?? 0 + let internalP = Int(props["port.\(idx).internal"] ?? "") ?? 0 + return PortMapping(service: svc, namespace: ns, exposePort: expose, internalPort: internalP) + } + if !items.isEmpty { return items } + return [ + PortMapping(service: "svc/prometheus-community-kube-prometheus", namespace: "default", exposePort: 9090, internalPort: 9090), + PortMapping(service: "svc/kubernetes-dashboard-kong-proxy", namespace: "kubernetes-dashboard", exposePort: 8443, internalPort: 443), + PortMapping(service: "svc/prole-db-rw", namespace: "default", exposePort: 5432, internalPort: 5432), + PortMapping(service: "svc/prometheus-community-grafana", namespace: "default", exposePort: 3000, internalPort: 80) + ] + } + set { + clearIndexed(prefix: "port") + for (i, it) in newValue.enumerated() { + let idx = i + 1 + props["port.\(idx).service"] = it.service + props["port.\(idx).namespace"] = it.namespace + props["port.\(idx).expose"] = String(it.exposePort) + props["port.\(idx).internal"] = String(it.internalPort) + } + } + } + + // Convenience computed values used by Status/Checker + var primaryService: ServiceEndpoint? { + // Prefer "svc.prole.org" entry, else first + return services.first(where: { $0.name == "svc.prole.org" }) ?? services.first + } + var localService: ServiceEndpoint? { + return services.first(where: { $0.name.lowercased() == "k3d" }) + } + var primaryKube: KubernetesEndpoint? { kubernetes.first } // UI background config was removed along with the splash screen. @@ -132,4 +223,46 @@ final class Config { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } + + // MARK: - Save & helpers + func save() { + // Ensure Application Support/Prole exists + let fm = FileManager.default + guard let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { return } + let dir = appSupport.appendingPathComponent("Prole", isDirectory: true) + if !fm.fileExists(atPath: dir.path) { + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + let url = dir.appendingPathComponent("prole.properties") + var lines: [String] = [] + let sortedKeys = props.keys.sorted() + for k in sortedKeys { + if let v = props[k] { lines.append("\(k)=\(v)") } + } + let text = lines.joined(separator: "\n") + "\n" + try? text.data(using: .utf8)?.write(to: url) + NotificationCenter.default.post(name: Config.didChangeNotification, object: nil) + } + + // Collect 1..N until a gap of 3 is found + private func loadIndexed(prefix: String, map: (Int) -> T?) -> [T] { + var items: [T] = [] + var idx = 1 + var gaps = 0 + while gaps < 3 { + if let v = map(idx) { + items.append(v) + gaps = 0 + } else { + gaps += 1 + } + idx += 1 + } + return items + } + + private func clearIndexed(prefix: String) { + let keys = props.keys.filter { $0.hasPrefix("\(prefix).") } + for k in keys { props.removeValue(forKey: k) } + } } diff --git a/prole-app/Sources/PortForwardManager.swift b/prole-app/Sources/PortForwardManager.swift index bb8f17d..bfae75a 100644 --- a/prole-app/Sources/PortForwardManager.swift +++ b/prole-app/Sources/PortForwardManager.swift @@ -179,11 +179,16 @@ final class PortForwardManager: @unchecked Sendable { let task = Task.detached(priority: .utility) { [weak self] in guard let self = self else { return } let p = self.makeSimpleBashProcess(command: body, index: idx) - p.terminationHandler = { [weak self] _ in + p.terminationHandler = { [weak self] proc in guard let self = self else { return } + // Capture the terminationStatus from the actual Process instance + // provided by the handler to avoid races with self.processes[idx] + // potentially pointing at a different/non-launched Process. + let status = proc.terminationStatus + let when = Date() self.queue.async { - if idx < self.lastExitCodes.count { self.lastExitCodes[idx] = self.processes[safe: idx]?.terminationStatus ?? self.lastExitCodes[idx] } - if idx < self.lastExitTimes.count { self.lastExitTimes[idx] = Date() } + if idx < self.lastExitCodes.count { self.lastExitCodes[idx] = status } + if idx < self.lastExitTimes.count { self.lastExitTimes[idx] = when } DispatchQueue.main.async { NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self) } diff --git a/prole-app/Sources/ServiceChecker.swift b/prole-app/Sources/ServiceChecker.swift index d915898..16de4a1 100644 --- a/prole-app/Sources/ServiceChecker.swift +++ b/prole-app/Sources/ServiceChecker.swift @@ -8,6 +8,9 @@ final class ServiceChecker { private let queue = DispatchQueue(label: "prole.status.checker") private var timer: DispatchSourceTimer? + private var isRunning: Bool = false + private var lastRefreshAt: Date = .distantPast + private let refreshInterval: TimeInterval = 30 // Public reachability flags private(set) var svcReachable = false @@ -27,11 +30,27 @@ final class ServiceChecker { 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) + // Generic per-endpoint state so UI can query dynamically from Preferences + struct EndpointState: Equatable { + var reachable: Bool + var latencyMs: Int + var error: String? + var lastChecked: Date } - func start(interval: TimeInterval = 15) { + // Cached states (by logical keys) + // services: key = service name (Config.ServiceEndpoint.name) + // kubes: key = host:port string + private(set) var serviceStates: [String: EndpointState] = [:] + private(set) var kubeStates: [String: EndpointState] = [:] + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil) + // When configuration changes, invalidate cache and reload immediately + NotificationCenter.default.addObserver(self, selector: #selector(configDidChange), name: Config.didChangeNotification, object: nil) + } + + func start(interval: TimeInterval = 30) { timer?.cancel() let t = DispatchSource.makeTimerSource(queue: queue) t.schedule(deadline: .now(), repeating: interval) @@ -43,25 +62,89 @@ final class ServiceChecker { @objc private func forceRefresh() { queue.async { self.refreshAll() } } + @objc private func configDidChange() { + queue.async { + // Invalidate freshness so the next refresh runs immediately + self.lastRefreshAt = .distantPast + // Clear caches so UI doesn't briefly show stale dynamic entries + self.serviceStates.removeAll() + self.kubeStates.removeAll() + self.refreshAll() + } + } + private func refreshAll() { + // Ensure we don't overlap and we don't rerun faster than every 30s + if isRunning { + dlog("ServiceChecker: skip — previous refresh still running") + return + } + let now = Date() + if now.timeIntervalSince(lastRefreshAt) < refreshInterval { + dlog("ServiceChecker: skip — cache still fresh (< \(Int(refreshInterval))s)") + return + } + isRunning = true dlog("ServiceChecker: begin refresh round") let group = DispatchGroup() - // clear previous errors before a new round + // clear previous errors before a new round (legacy fields) 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() + // Iterate Services from Preferences + for svc in cfg.services { + group.enter() + tcpPing(host: svc.host, port: UInt16(svc.port)) { [weak self] ok, ms, err in + guard let self = self else { group.leave(); return } + let key = svc.name + let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date()) + self.serviceStates[key] = st + // Update legacy convenience fields for UI compatibility + if let primary = cfg.primaryService, primary.name == svc.name { + self.svcReachable = ok + self.svcLatency = ms + self.svcError = err + } + if let local = cfg.localService, local.name == svc.name { + self.localReachable = ok + self.localLatency = ms + self.localError = 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() + + // Iterate Kubernetes endpoints from Preferences + let kubes = cfg.kubernetes + if kubes.isEmpty { + raspberryReachable = false; raspberryLatency = -1; raspberryError = "no kubernetes endpoints configured" + piReachable = false; piLatency = -1; piError = nil } - 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() + for (idx, k) in kubes.enumerated() { + group.enter() + tcpPing(host: k.host, port: UInt16(k.port)) { [weak self] ok, ms, err in + guard let self = self else { group.leave(); return } + let key = "\(k.host):\(k.port)" + let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date()) + self.kubeStates[key] = st + // Maintain two-LED aggregate legacy fields for first two entries + if idx == 0 { + self.raspberryReachable = ok + self.raspberryLatency = ms + self.raspberryError = err + } else if idx == 1 { + self.piReachable = ok + self.piLatency = ms + self.piError = err + } + group.leave() + } } group.notify(queue: .main) { + self.lastRefreshAt = Date() + self.isRunning = false dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification") NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self) } @@ -127,15 +210,16 @@ final class ServiceChecker { // 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" + guard let svc = Config.shared.primaryService else { return "No primary service configured" } + if svcReachable { return "\(svc.host):\(svc.port) — reachable (\(svcLatency) ms)" } + var s = "\(svc.host):\(svc.port) — unreachable" if let e = svcError { s += "\nError: \(e)" } return s } func tooltipForAggregateK3s() -> String { - let rHost = Config.shared.retropieHost + let kubes = Config.shared.kubernetes + let rHost = kubes.first?.host ?? "-" + let pHost = kubes.count > 1 ? kubes[1].host : "-" var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost) ✗" var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost) ✗" if !raspberryReachable, let e = raspberryError { r += " — \(e)" } @@ -149,10 +233,9 @@ final class ServiceChecker { 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" + guard let local = Config.shared.localService else { return "No local service configured" } + if localReachable { return "k3d \(local.host):\(local.port) — reachable (\(localLatency) ms)" } + var s = "k3d \(local.host):\(local.port) — unreachable" if let e = localError { s += "\nError: \(e)" } return s } diff --git a/prole-app/Sources/StatusView.swift b/prole-app/Sources/StatusView.swift index 6093faa..8a40861 100644 --- a/prole-app/Sources/StatusView.swift +++ b/prole-app/Sources/StatusView.swift @@ -139,9 +139,26 @@ final class StatusView: NSView { func aggregateStatusSummary() -> String { guard let c = checker else { return "No status" } let cfg = Config.shared - let svc = c.svcReachable ? "\(cfg.svcHost):\(cfg.svcPort) ✓" : "\(cfg.svcHost):\(cfg.svcPort) ✗" - let agg = "k3s: \(cfg.retropieHost) \(c.raspberryReachable ? "✓" : "✗") " - let loc = c.localReachable ? "k3d \(cfg.localHost):\(cfg.localPort) ✓" : "k3d \(cfg.localHost):\(cfg.localPort) ✗" + let svcText: String = { + if let svc = cfg.primaryService { + return c.svcReachable ? "\(svc.host):\(svc.port) ✓" : "\(svc.host):\(svc.port) ✗" + } + return "svc (unset) ✗" + }() + let aggText: String = { + let kube = cfg.kubernetes.first + let name = kube?.host ?? "k3s" + return "k3s: \(name) \(c.raspberryReachable ? "✓" : "✗")" + }() + let locText: String = { + if let local = cfg.localService { + return c.localReachable ? "k3d \(local.host):\(local.port) ✓" : "k3d \(local.host):\(local.port) ✗" + } + return "k3d (unset) ✗" + }() + let svc = svcText + let agg = aggText + let loc = locText return [svc, agg, loc].joined(separator: " • ") }