import Foundation // Lightweight helper to manage the user LaunchAgent that runs kubectl port-forwards. // This replaces the in-app PortForwardManager supervision. final class LaunchAgentManager { // Filenames/paths private let label = "org.prole.prole-db.kpf-dev" private var plistURL: URL { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent("Library/LaunchAgents/\(label).plist") } private var helperScriptURL: URL { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent("Library/Application Support/Prole/bin/prole-kpf.sh") } // Key that stores the list of commands inside the plist (array of strings) private let commandsKey = "ProleCommands" // Read commands from the LaunchAgent plist. Returns empty if missing. func readCommands() -> [String] { guard let data = try? Data(contentsOf: plistURL) else { return [] } var format = PropertyListSerialization.PropertyListFormat.xml guard let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format), let dict = obj as? [String: Any], let cmds = dict[commandsKey] as? [String] else { return [] } return cmds } // Write commands back to the plist, preserving other keys if present. func writeCommands(_ commands: [String]) { var dict: [String: Any] = [:] if let data = try? Data(contentsOf: plistURL), let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), let existing = obj as? [String: Any] { dict = existing } dict[commandsKey] = commands if let out = try? PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) { // Ensure parent directory exists try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true) try? out.write(to: plistURL) } } // Reload the LaunchAgent: try kickstart; if not present, bootstrap then kickstart. func resetAgent() { let uid = getuid() let domainTarget = "gui/\(uid)/\(label)" // Try kickstart first (reload) let ks = run("/bin/launchctl", ["kickstart", "-k", domainTarget]) if ks.exitCode == 0 { return } // If kickstart failed, try bootout then bootstrap, then kickstart again _ = run("/bin/launchctl", ["bootout", "gui/\(uid)", domainTarget]) // bootstrap requires path to plist _ = run("/bin/launchctl", ["bootstrap", "gui/\(uid)", plistURL.path]) _ = run("/bin/launchctl", ["kickstart", "-k", domainTarget]) } // Ensure the helper shell script exists by copying the external bundled script; no inline content. func ensureHelperScript() { let fm = FileManager.default let dir = helperScriptURL.deletingLastPathComponent() try? fm.createDirectory(at: dir, withIntermediateDirectories: true) // Locate external script in app bundle Resources let bundle = Bundle.main let scriptInBundleURL = bundle.url(forResource: "init-port-fowards", withExtension: "sh") guard let srcURL = scriptInBundleURL else { return } var needsCopy = true if fm.fileExists(atPath: helperScriptURL.path) { if let srcAttr = try? fm.attributesOfItem(atPath: srcURL.path), let dstAttr = try? fm.attributesOfItem(atPath: helperScriptURL.path), let srcMod = srcAttr[.modificationDate] as? Date, let dstMod = dstAttr[.modificationDate] as? Date, dstMod >= srcMod { needsCopy = false } } if needsCopy { _ = try? fm.removeItem(at: helperScriptURL) do { try fm.copyItem(at: srcURL, to: helperScriptURL) } catch { if let data = try? Data(contentsOf: srcURL) { try? data.write(to: helperScriptURL) } } try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: helperScriptURL.path) } } static let defaultHelperScript = "" // MARK: - Helpers @discardableResult private func run(_ path: String, _ args: [String]) -> (exitCode: Int32, out: String, err: String) { let p = Process() p.executableURL = URL(fileURLWithPath: path) p.arguments = args let outPipe = Pipe(); let errPipe = Pipe() p.standardOutput = outPipe p.standardError = errPipe do { try p.run() } catch { return (exitCode: -1, out: "", err: String(describing: error)) } p.waitUntilExit() let outData = outPipe.fileHandleForReading.readDataToEndOfFile() let errData = errPipe.fileHandleForReading.readDataToEndOfFile() let outStr = String(data: outData, encoding: .utf8) ?? "" let errStr = String(data: errData, encoding: .utf8) ?? "" return (p.terminationStatus, outStr, errStr) } }