refactor(macos): move launchctl + plist snapshot

This commit is contained in:
Peter Steinberger
2026-01-07 20:13:21 +00:00
parent 54960d1380
commit d45fcc44da
4 changed files with 161 additions and 129 deletions

View File

@@ -43,15 +43,15 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
}
static func status() async -> Bool {
static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
@@ -60,23 +60,31 @@ enum GatewayLaunchAgentManager {
}
let desiredBind = self.preferredGatewayBind() ?? "loopback"
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(bundlePath: bundlePath, port: port)
let desiredToken = self.preferredGatewayToken()
let desiredPassword = self.preferredGatewayPassword()
let desiredConfig = DesiredConfig(port: port, bind: desiredBind, token: desiredToken, password: desiredPassword)
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
if let snapshot = await self.gatewayJobSnapshot(),
snapshot.matches(port: port, bind: desiredBind)
let loaded = await self.isLoaded()
if loaded,
let existing = self.readPlistConfig(),
existing.matches(desiredConfig)
{
self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled()
_ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil
}
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(bundlePath: bundlePath, port: port)
await self.ensureEnabled()
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if loaded {
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
@@ -89,13 +97,14 @@ enum GatewayLaunchAgentManager {
}
self.logger.info("launchd disable requested")
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(bundlePath: String, port: Int) {
@@ -221,40 +230,39 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "'")
}
private struct LaunchctlResult {
let status: Int32
let output: String
private struct DesiredConfig: Equatable {
let port: Int
let bind: String
let token: String?
let password: String?
}
struct LaunchdJobSnapshot: Equatable {
let pid: Int?
private struct InstalledConfig: Equatable {
let port: Int?
let bind: String?
let token: String?
let password: String?
func matches(port: Int, bind: String) -> Bool {
guard self.port == port else { return false }
if let bindValue = self.bind {
return bindValue == bind
}
func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == desired.port else { return false }
guard (self.bind ?? "loopback") == desired.bind else { return false }
guard self.token == desired.token else { return false }
guard self.password == desired.password else { return false }
return true
}
}
static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot {
let pid = self.extractIntValue(output: output, key: "pid")
let port = self.extractFlagIntValue(output: output, flag: "--port")
let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased()
return LaunchdJobSnapshot(pid: pid, port: port, bind: bind)
}
private static func gatewayJobSnapshot() async -> LaunchdJobSnapshot? {
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status == 0 else { return nil }
return self.parseLaunchctlPrintSnapshot(result.output)
private static func readPlistConfig() -> InstalledConfig? {
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
return InstalledConfig(
port: snapshot.port,
bind: snapshot.bind,
token: snapshot.token,
password: snapshot.password)
}
private static func ensureEnabled() async {
let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
@@ -264,65 +272,15 @@ enum GatewayLaunchAgentManager {
}
}
private static func extractIntValue(output: String, key: String) -> Int? {
// launchctl print commonly emits `pid = 123`
guard let range = output.range(of: "\(key) =") else { return nil }
var idx = range.upperBound
while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) }
var end = idx
while end < output.endIndex, output[end].isNumber { end = output.index(after: end) }
guard end > idx else { return nil }
return Int(output[idx..<end])
}
private static func extractFlagIntValue(output: String, flag: String) -> Int? {
guard let raw = self.extractFlagStringValue(output: output, flag: flag) else { return nil }
return Int(raw)
}
private static func extractFlagStringValue(output: String, flag: String) -> String? {
guard let range = output.range(of: flag) else { return nil }
var idx = range.upperBound
while idx < output.endIndex {
let ch = output[idx]
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "=" || ch == "\"" || ch == "'" {
idx = output.index(after: idx)
continue
}
break
private static func ensureDisabled() async {
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty {
self.logger.warning("launchd disable failed")
} else {
self.logger.warning("launchd disable failed: \(msg)")
}
guard idx < output.endIndex else { return nil }
var end = idx
while end < output.endIndex {
let ch = output[end]
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "\"" || ch == "'" || ch == "\n" || ch == "\r" {
break
}
end = output.index(after: end)
}
let token = output[idx..<end].trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : String(token)
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
} catch {
return LaunchctlResult(status: -1, output: error.localizedDescription)
}
}.value
}
}

View File

@@ -70,7 +70,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.status()
let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()

View File

@@ -0,0 +1,82 @@
import Foundation
enum Launchctl {
struct Result: Sendable {
let status: Int32
let output: String
}
@discardableResult
static func run(_ args: [String]) async -> Result {
await Task.detached(priority: .utility) { () -> Result in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output)
} catch {
return Result(status: -1, output: error.localizedDescription)
}
}.value
}
}
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String]
let environment: [String: String]
let port: Int?
let bind: String?
let token: String?
let password: String?
}
enum LaunchAgentPlist {
static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
guard let data = try? Data(contentsOf: url) else { return nil }
let rootAny: Any
do {
rootAny = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil)
} catch {
return nil
}
guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return LaunchAgentPlistSnapshot(
programArguments: programArguments,
environment: env,
port: port,
bind: bind,
token: token,
password: password)
}
private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
return Int(raw)
}
private static func extractFlagString(_ args: [String], flag: String) -> String? {
guard let idx = args.firstIndex(of: flag) else { return nil }
let valueIdx = args.index(after: idx)
guard valueIdx < args.endIndex else { return nil }
let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
}
}

View File

@@ -1,49 +1,41 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite struct GatewayLaunchAgentManagerTests {
@Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() {
let output = """
service = com.clawdbot.gateway
program arguments = (
"/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot",
"gateway-daemon",
"--port",
"18789",
"--bind",
"loopback"
)
pid = 123
"""
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
#expect(snapshot.pid == 123)
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"EnvironmentVariables": [
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
#expect(snapshot.bind == "loopback")
#expect(snapshot.matches(port: 18789, bind: "loopback"))
#expect(snapshot.matches(port: 18789, bind: "tailnet") == false)
#expect(snapshot.matches(port: 19999, bind: "loopback") == false)
#expect(snapshot.token == "secret")
#expect(snapshot.password == "pw")
}
@Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() {
let output = """
argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet }
pid = 0
"""
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
#expect(snapshot.pid == 0)
#expect(snapshot.port == 19999)
#expect(snapshot.bind == "tailnet")
}
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) }
@Test func parseLaunchctlPrintSnapshotAllowsMissingBind() {
let output = """
program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
pid = 456
"""
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
#expect(snapshot.bind == nil)
#expect(snapshot.matches(port: 18789, bind: "loopback"))
}
}