prole/prole-tools-app/Sources/Config.swift

272 lines
11 KiB
Swift

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[..<eq]).trimmingCharacters(in: .whitespaces)
let value = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
if !key.isEmpty { result[key] = value }
}
}
return result
}
func string(_ key: String, default def: String) -> 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)
}
if !items.isEmpty { return items }
return [
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: "PostgreSQL", host: "localhost", port: 5432)
]
}
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<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) }
}
}