mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 00:23:25 -06:00
0dbdf994b3
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
148 lines
5.2 KiB
Swift
148 lines
5.2 KiB
Swift
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 """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>ai.openclaw.mac</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>\(bundlePath)/Contents/MacOS/OpenClaw</string>
|
|
</array>
|
|
<key>WorkingDirectory</key>
|
|
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>PATH</key>
|
|
<string>\(path)</string>\(profileEnvironment)
|
|
</dict>
|
|
<key>StandardOutPath</key>
|
|
<string>\(LogLocator.launchdLogPath)</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>\(LogLocator.launchdLogPath)</string>
|
|
</dict>
|
|
</plist>
|
|
"""
|
|
}
|
|
|
|
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>\(key)</key>
|
|
<string>\(self.escapePlistText(value))</string>
|
|
"""
|
|
}.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
|