import AppKit import Foundation // Simple .properties loader with bundle defaults and user override support 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 kept minimal; lists below will ensure sane defaults props = [:] // Load bundled defaults if present if let url = Bundle.main.url(forResource: "prole", withExtension: "properties") { merge(loadProperties(url: url)) } // Load user override from Application Support let fm = FileManager.default if let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { let dir = appSupport.appendingPathComponent("Prole", isDirectory: true) let url = dir.appendingPathComponent("prole.properties") if fm.fileExists(atPath: url.path) { merge(loadProperties(url: url)) } } } private func merge(_ other: [String: String]) { for (k, v) in other { props[k] = v } } private func loadProperties(url: URL) -> [String: String] { guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else { return [:] } var result: [String: String] = [:] for line in text.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } if let eq = trimmed.firstIndex(of: "=") { let key = String(trimmed[.. String { props[key] ?? def } func int(_ key: String, default def: Int) -> Int { Int(props[key] ?? "") ?? def } // 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) } } } // Services list (name + hostname + port). 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) } // Return defaults merged with items to ensure all required services are present let defaults = [ ServiceEndpoint(name: "K3D", host: "localhost", port: 6443), ServiceEndpoint(name: "Prometheus", host: "localhost", port: 9090), ServiceEndpoint(name: "Grafana", host: "localhost", port: 3000), ServiceEndpoint(name: "OpenBAO", host: "localhost", port: 8200), ServiceEndpoint(name: "Ollama", host: "localhost", port: 11434), ServiceEndpoint(name: "PostgreSQL", host: "localhost", port: 5432) ] if items.isEmpty { return defaults } // Merge defaults into items: for each default, if it's not in items, add it. var result = items for d in defaults { if !result.contains(where: { $0.name.uppercased() == d.name.uppercased() }) { result.append(d) } } return result } 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) } } } // 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 "K3D" entry, else first return services.first(where: { $0.name == "K3D" }) ?? 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. // Dev port-forward supervision var pfEnabled: Bool { let v = (props["pf.enabled"] ?? "").lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } // Background/foreground mode for port-forward launcher // Defaults to background to honor daemon-style commands. var pfModeBackground: Bool { let v = (props["pf.mode"] ?? "background").lowercased() // Allow synonyms if v == "bg" || v == "background" || v == "daemon" { return true } if v == "fg" || v == "foreground" { return false } return true } // Keepalive wrapper for port-forward commands. If true, a shell loop will // keep restarting the child command on exit and keep the wrapper process alive. // Default: true (so UI remains stable and processes behave like daemons). var pfKeepAlive: Bool { let v = (props["pf.keepalive"] ?? "true").lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } // Enable verbose debug logging for PortForwardManager and related startup. // Default: false. Set pf.debug=true to print detailed diagnostics. var pfDebug: Bool { let v = (props["pf.debug"] ?? "false").lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } // Discover and adopt already-running matching port-forward processes at startup/reset // Default: true var pfDiscovery: Bool { let v = (props["pf.discovery"] ?? "true").lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } // Adopt existing matching processes instead of launching duplicates // Default: true var pfAdoptExisting: Bool { let v = (props["pf.adoptExisting"] ?? "true").lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } // Optional kubeconfig path to export as KUBECONFIG for child processes var kubeconfigPath: String? { let v = (props["kubeconfig.path"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return v.isEmpty ? nil : v } var pfCommands: [String] { // Collect keys pf.1, pf.2, ... in ascending order let keys = props.keys .filter { $0.hasPrefix("pf.") } .compactMap { k -> (Int, String)? in let tail = k.dropFirst(3) if let idx = Int(tail) { return (idx, k) } return nil } .sorted { $0.0 < $1.0 } .map { $0.1 } return keys.compactMap { props[$0] } .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) } } }