ServiceChecker: background refresh + Preferences reload; PF crash fix; Prefs Save commit inputs

- ServiceChecker now runs as a background timer task (30s), skips overlapping runs, and caches per-endpoint state from Preferences (services + kubernetes). Legacy UI flags are kept in sync for StatusView/Overlay.
- Auto-reload on Preferences save: ServiceChecker listens to Config.didChange, clears caches, invalidates freshness, and triggers an immediate refresh.
- Preferences: ensure in-progress text edits are committed before saving (endEditing) for Kubernetes, Services, and Ports tabs.
- PortForwardManager: fix crash in termination handler by reading terminationStatus from the provided Process instance, then updating state on the manager queue.
- Minor: keep existing UI wiring; logging intact for diagnostics.

TODO: remove (list unused code paths)
- Config.primaryKube (unused in prole-app)
- Config.string(_:, default:) (unused in prole-app)
- Config.int(_:, default:) (unused in prole-app)
- PortForwardManager.parsePFCommand tuple parts `modeBackground` and `keepAlive` (never read by callers)
- Config.pfModeBackground and Config.pfKeepAlive (currently ineffective because their values aren’t used downstream)
This commit is contained in:
chrisfu 2025-12-09 18:16:14 -08:00
parent 565d91571b
commit b2cbe72c4a
5 changed files with 296 additions and 41 deletions

View File

@ -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() {

View File

@ -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<T>(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) }
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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: "")
}