refactor(macos): move launchctl + plist snapshot
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
82
apps/macos/Sources/Clawdbot/Launchctl.swift
Normal file
82
apps/macos/Sources/Clawdbot/Launchctl.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user