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]
|
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 }
|
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
|
return result.status == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
if enabled {
|
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)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||||
@@ -60,23 +60,31 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
let desiredToken = self.preferredGatewayToken()
|
||||||
self.writePlist(bundlePath: bundlePath, port: port)
|
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
|
// 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.
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
if let snapshot = await self.gatewayJobSnapshot(),
|
let loaded = await self.isLoaded()
|
||||||
snapshot.matches(port: port, bind: desiredBind)
|
if loaded,
|
||||||
|
let existing = self.readPlistConfig(),
|
||||||
|
existing.matches(desiredConfig)
|
||||||
{
|
{
|
||||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||||
await self.ensureEnabled()
|
await self.ensureEnabled()
|
||||||
_ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
|
self.writePlist(bundlePath: bundlePath, port: port)
|
||||||
|
|
||||||
await self.ensureEnabled()
|
await self.ensureEnabled()
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
if loaded {
|
||||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
|
}
|
||||||
|
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
if bootstrap.status != 0 {
|
if bootstrap.status != 0 {
|
||||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||||
@@ -89,13 +97,14 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd disable requested")
|
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)
|
try? FileManager.default.removeItem(at: self.plistURL)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func kickstart() async {
|
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) {
|
private static func writePlist(bundlePath: String, port: Int) {
|
||||||
@@ -221,40 +230,39 @@ enum GatewayLaunchAgentManager {
|
|||||||
.replacingOccurrences(of: "'", with: "'")
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LaunchctlResult {
|
private struct DesiredConfig: Equatable {
|
||||||
let status: Int32
|
let port: Int
|
||||||
let output: String
|
let bind: String
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LaunchdJobSnapshot: Equatable {
|
private struct InstalledConfig: Equatable {
|
||||||
let pid: Int?
|
|
||||||
let port: Int?
|
let port: Int?
|
||||||
let bind: String?
|
let bind: String?
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
|
||||||
func matches(port: Int, bind: String) -> Bool {
|
func matches(_ desired: DesiredConfig) -> Bool {
|
||||||
guard self.port == port else { return false }
|
guard self.port == desired.port else { return false }
|
||||||
if let bindValue = self.bind {
|
guard (self.bind ?? "loopback") == desired.bind else { return false }
|
||||||
return bindValue == bind
|
guard self.token == desired.token else { return false }
|
||||||
}
|
guard self.password == desired.password else { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot {
|
private static func readPlistConfig() -> InstalledConfig? {
|
||||||
let pid = self.extractIntValue(output: output, key: "pid")
|
guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
|
||||||
let port = self.extractFlagIntValue(output: output, flag: "--port")
|
return InstalledConfig(
|
||||||
let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased()
|
port: snapshot.port,
|
||||||
return LaunchdJobSnapshot(pid: pid, port: port, bind: bind)
|
bind: snapshot.bind,
|
||||||
}
|
token: snapshot.token,
|
||||||
|
password: snapshot.password)
|
||||||
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 ensureEnabled() async {
|
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 }
|
guard result.status != 0 else { return }
|
||||||
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if msg.isEmpty {
|
if msg.isEmpty {
|
||||||
@@ -264,65 +272,15 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractIntValue(output: String, key: String) -> Int? {
|
private static func ensureDisabled() async {
|
||||||
// launchctl print commonly emits `pid = 123`
|
let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||||
guard let range = output.range(of: "\(key) =") else { return nil }
|
guard result.status != 0 else { return }
|
||||||
var idx = range.upperBound
|
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) }
|
if msg.isEmpty {
|
||||||
var end = idx
|
self.logger.warning("launchd disable failed")
|
||||||
while end < output.endIndex, output[end].isNumber { end = output.index(after: end) }
|
} else {
|
||||||
guard end > idx else { return nil }
|
self.logger.warning("launchd disable failed: \(msg)")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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 {
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||||
let enabled = await GatewayLaunchAgentManager.status()
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
guard !enabled else { return }
|
guard !enabled else { return }
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
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
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite struct GatewayLaunchAgentManagerTests {
|
@Suite struct GatewayLaunchAgentManagerTests {
|
||||||
@Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() {
|
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
||||||
let output = """
|
let url = FileManager.default.temporaryDirectory
|
||||||
service = com.clawdbot.gateway
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
program arguments = (
|
let plist: [String: Any] = [
|
||||||
"/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot",
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
||||||
"gateway-daemon",
|
"EnvironmentVariables": [
|
||||||
"--port",
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
"18789",
|
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||||
"--bind",
|
],
|
||||||
"loopback"
|
]
|
||||||
)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
pid = 123
|
try data.write(to: url, options: [.atomic])
|
||||||
"""
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
|
||||||
#expect(snapshot.pid == 123)
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
#expect(snapshot.bind == "loopback")
|
#expect(snapshot.bind == "loopback")
|
||||||
#expect(snapshot.matches(port: 18789, bind: "loopback"))
|
#expect(snapshot.token == "secret")
|
||||||
#expect(snapshot.matches(port: 18789, bind: "tailnet") == false)
|
#expect(snapshot.password == "pw")
|
||||||
#expect(snapshot.matches(port: 19999, bind: "loopback") == false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() {
|
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
||||||
let output = """
|
let url = FileManager.default.temporaryDirectory
|
||||||
argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet }
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
pid = 0
|
let plist: [String: Any] = [
|
||||||
"""
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
||||||
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
]
|
||||||
#expect(snapshot.pid == 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
#expect(snapshot.port == 19999)
|
try data.write(to: url, options: [.atomic])
|
||||||
#expect(snapshot.bind == "tailnet")
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
}
|
|
||||||
|
|
||||||
@Test func parseLaunchctlPrintSnapshotAllowsMissingBind() {
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
let output = """
|
|
||||||
program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
|
|
||||||
pid = 456
|
|
||||||
"""
|
|
||||||
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
|
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
#expect(snapshot.bind == nil)
|
#expect(snapshot.bind == nil)
|
||||||
#expect(snapshot.matches(port: 18789, bind: "loopback"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user