mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 17:44:33 +00:00
272 lines
11 KiB
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) }
|
|
}
|
|
}
|