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