import Foundation enum LaunchAgentManager { private static let legacyLaunchdLabels = [ "com.steipete.clawdbot", "com.clawdbot.mac", ] private static var plistURL: URL { FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/bot.molt.mac.plist") } private static var legacyPlistURLs: [URL] { self.legacyLaunchdLabels.map { label in FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(label).plist") } } static func status() async -> Bool { guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) return result == 0 } static func set(enabled: Bool, bundlePath: String) async { if enabled { for legacyLabel in self.legacyLaunchdLabels { _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLabel)"]) } for legacyURL in self.legacyPlistURLs { try? FileManager().removeItem(at: legacyURL) } self.writePlist(bundlePath: bundlePath) _ = 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) } } private static func writePlist(bundlePath: String) { let plist = """ Label bot.molt.mac ProgramArguments \(bundlePath)/Contents/MacOS/Moltbot WorkingDirectory \(FileManager().homeDirectoryForCurrentUser.path) RunAtLoad KeepAlive EnvironmentVariables PATH \(CommandResolver.preferredPaths().joined(separator: ":")) StandardOutPath \(LogLocator.launchdLogPath) StandardErrorPath \(LogLocator.launchdLogPath) """ try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8) } @discardableResult private static func runLaunchctl(_ args: [String]) async -> Int32 { await Task.detached(priority: .utility) { () -> Int32 in let process = Process() process.launchPath = "/bin/launchctl" process.arguments = args let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { _ = try process.runAndReadToEnd(from: pipe) return process.terminationStatus } catch { return -1 } }.value } }