import Foundation
import OSLog
enum LaunchAgentManager {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent")
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist")
}
static func status(profile: AppProfile = .current) async -> Bool {
if profile.isActive {
self.logger.info("login-agent status skipped (unavailable under app profile)")
return false
}
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
return await self.isLoaded()
}
private static func isLoaded() async -> Bool {
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0
}
@discardableResult
static func set(
enabled: Bool,
bundlePath: String,
profile: AppProfile = .current,
loaded: Bool? = nil,
writePlist: ((String) -> Void)? = nil) async -> Bool
{
if profile.isActive {
self.logger.info("login-agent change skipped (unavailable under app profile)")
return false
}
if enabled {
let persist = writePlist ?? { self.writePlist(bundlePath: $0) }
persist(bundlePath)
let alreadyLoaded = if let loaded {
loaded
} else {
await self.isLoaded()
}
// Startup hydrates the toggle from launchd. Reinstalling the active job here
// would boot out the app that is still responsible for bootstrapping it again.
guard !alreadyLoaded else { return false }
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
} else {
// Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager().removeItem(at: self.plistURL)
}
return true
}
private static func writePlist(bundlePath: String) {
let plist = self.plistContents(bundlePath: bundlePath)
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
static func plistContents(
bundlePath: String,
preferredPaths: [String] = CommandResolver.preferredPaths()) -> String
{
let path = self.escapePlistText(preferredPaths.joined(separator: ":"))
let profileEnvironment = self.profileEnvironmentPlistEntries()
return """
Label
ai.openclaw.mac
ProgramArguments
\(bundlePath)/Contents/MacOS/OpenClaw
WorkingDirectory
\(FileManager().homeDirectoryForCurrentUser.path)
RunAtLoad
EnvironmentVariables
PATH
\(path)\(profileEnvironment)
StandardOutPath
\(LogLocator.launchdLogPath)
StandardErrorPath
\(LogLocator.launchdLogPath)
"""
}
private static func profileEnvironmentPlistEntries() -> String {
["OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR"].compactMap { key in
guard let value = OpenClawEnv.path(key) else { return nil }
return """
\(key)
\(self.escapePlistText(value))
"""
}.joined()
}
private static func escapePlistText(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
.replacingOccurrences(of: "'", with: "'")
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> Int32 {
#if DEBUG
self.testingLaunchctlCalls.append(args)
#endif
do {
return try await BoundedProcess.run(
path: "/bin/launchctl",
arguments: args,
timeout: 5).terminationStatus
} catch {
return -1
}
}
}
#if DEBUG
extension LaunchAgentManager {
private nonisolated(unsafe) static var testingLaunchctlCalls: [[String]] = []
static func _testResetLaunchctlCalls() {
self.testingLaunchctlCalls = []
}
static func _testLaunchctlCallSnapshot() -> [[String]] {
self.testingLaunchctlCalls
}
}
#endif