prole/prole-app/Sources/LaunchAgentManager.swift
chrisfu d1e4cb5db3 Remove inline shell from app; bundle external init script; fix build and packaging
•
Strip all embedded shell from LaunchAgentManager.swift; use external etc/init-port-fowards.sh copied from app Resources at runtime; set executable perms
•
Set defaultHelperScript to empty to eliminate inline script
•
Build script: bundle init-port-fowards.sh into Prole.app/Contents/Resources and chmod +x; capture spm_build_binary path via tail -n1
•
Fix malformed multiline XML in PortMappingsXML.save()
•
Preferences: handle new .agentPF tab in didSelect and windowDidResize
Result: Prole.app builds cleanly on arm64; no inline shell remains; embedded dylibs packaged and codesigned
2025-12-15 00:39:52 -08:00

118 lines
5.1 KiB
Swift

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