import Foundation import Logger import ServiceManagement public struct LaunchAgentManager { let lastLaunchAgentVersionKey = "LastLaunchAgentVersion" let serviceIdentifier: String let executablePath: String let bundleIdentifier: String var launchAgentDirURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents") } var launchAgentPath: String { launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path } public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) { self.serviceIdentifier = serviceIdentifier self.executablePath = executablePath self.bundleIdentifier = bundleIdentifier } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { if #available(macOS 13, *) { await removeObsoleteLaunchAgent() try await setupLaunchAgent() } else { guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return } try await setupLaunchAgent() await removeObsoleteLaunchAgent() } } @available(macOS 13.0, *) public func isBackgroundPermissionGranted() async -> Bool { // On macOS 13+, check SMAppService status let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") let status = bridgeLaunchAgent.status return status != .requiresApproval } public func setupLaunchAgent() async throws { if #available(macOS 13, *) { Logger.client.info("Registering bridge launch agent") let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") try bridgeLaunchAgent.register() } else { Logger.client.info("Creating and loading bridge launch agent") let content = """ Label \(serviceIdentifier) Program \(executablePath) MachServices \(serviceIdentifier) AssociatedBundleIdentifiers \(bundleIdentifier) \(serviceIdentifier) """ if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) { try FileManager.default.createDirectory( at: launchAgentDirURL, withIntermediateDirectories: false ) } FileManager.default.createFile( atPath: launchAgentPath, contents: content.data(using: .utf8) ) try await launchctl("load", launchAgentPath) } let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey) } public func removeLaunchAgent() async throws { if #available(macOS 13, *) { Logger.client.info("Unregistering bridge launch agent") let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist") try await bridgeLaunchAgent.unregister() } else { Logger.client.info("Unloading and removing bridge launch agent") try await launchctl("unload", launchAgentPath) try FileManager.default.removeItem(atPath: launchAgentPath) } } public func reloadLaunchAgent() async throws { if #unavailable(macOS 13) { Logger.client.info("Reloading bridge launch agent") try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier) } } public func removeObsoleteLaunchAgent() async { if #available(macOS 13, *) { let path = launchAgentPath if FileManager.default.fileExists(atPath: path) { Logger.client.info("Unloading and removing old bridge launch agent") try? await launchctl("unload", path) try? FileManager.default.removeItem(atPath: path) } } else { let path = launchAgentPath.replacingOccurrences( of: "ExtensionService", with: "XPCService" ) if FileManager.default.fileExists(atPath: path) { Logger.client.info("Removing old bridge launch agent plist") try? FileManager.default.removeItem(atPath: path) } } } } private func process(_ launchPath: String, _ args: [String]) async throws { let task = Process() task.launchPath = launchPath task.arguments = args task.environment = [ "PATH": "/usr/bin", ] let outpipe = Pipe() task.standardOutput = outpipe return try await withUnsafeThrowingContinuation { continuation in do { task.terminationHandler = { process in do { if process.terminationStatus == 0 { continuation.resume(returning: ()) } else { if let data = try? outpipe.fileHandleForReading.readToEnd(), let content = String(data: data, encoding: .utf8) { continuation.resume(throwing: E(errorDescription: content)) } else { continuation.resume( throwing: E( errorDescription: "Unknown error." ) ) } } } } try task.run() } catch { continuation.resume(throwing: error) } } } private func helper(_ args: String...) async throws { // TODO: A more robust way to locate the executable. guard let url = Bundle.main.executableURL? .deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("Applications") .appendingPathComponent("Helper") else { throw E(errorDescription: "Unable to locate Helper.") } return try await process(url.path, args) } private func launchctl(_ args: String...) async throws { return try await process("/bin/launchctl", args) } struct E: Error, LocalizedError { var errorDescription: String? }