import Foundation import Localization /// 一个 launchd 启动项(LaunchAgents / LaunchDaemons 里的 plist) public struct LaunchItem: Sendable, Identifiable, Hashable { public enum Scope: String, Sendable, CaseIterable { /// ~/Library/LaunchAgents:只对当前用户生效,可以直接启用或停用 case user /// /Library/LaunchAgents:所有用户登录时运行 case allUsers /// /Library/LaunchDaemons:以 root 在后台运行 case system public var title: String { switch self { case .user: tr("当前用户") case .allUsers: tr("所有用户") case .system: tr("系统服务") } } public var directory: String { switch self { case .user: NSHomeDirectory() + "/Library/LaunchAgents" case .allUsers: "/Library/LaunchAgents" case .system: "/Library/LaunchDaemons" } } } public var id: String { plist.path } public let label: String public let plist: URL public let scope: Scope /// 可执行文件路径(Program 或 ProgramArguments 的第一项) public let program: String? public let runAtLoad: Bool public let keepAlive: Bool /// 在 plist 里被标记为 Disabled public let disabledInPlist: Bool /// 可执行文件所在的 .app(用于显示图标和名称) public var appBundlePath: String? { guard let program, let range = program.range(of: ".app/") else { return nil } return String(program[.. Bool { disabled[item.label] ?? item.disabledInPlist } public func pid(_ item: LaunchItem) -> Int? { loaded[item.label] ?? nil } public func isLoaded(_ item: LaunchItem) -> Bool { loaded[item.label] != nil } } public enum LaunchItems { public static func scan(scopes: [LaunchItem.Scope] = LaunchItem.Scope.allCases) -> [LaunchItem] { scopes.flatMap { scope -> [LaunchItem] in let directory = URL(fileURLWithPath: scope.directory) let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] return files.filter { $0.pathExtension == "plist" }.compactMap { url in guard let data = try? Data(contentsOf: url), let dictionary = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } return item(from: dictionary, plist: url, scope: scope) } } .sorted { $0.label.localizedStandardCompare($1.label) == .orderedAscending } } static func item(from plist: [String: Any], plist url: URL, scope: LaunchItem.Scope) -> LaunchItem? { guard let label = plist["Label"] as? String, !label.isEmpty else { return nil } let program = (plist["Program"] as? String) ?? (plist["ProgramArguments"] as? [String])?.first let keepAlive: Bool = { if let flag = plist["KeepAlive"] as? Bool { return flag } return plist["KeepAlive"] is [String: Any] }() return LaunchItem(label: label, plist: url, scope: scope, program: program, runAtLoad: plist["RunAtLoad"] as? Bool ?? false, keepAlive: keepAlive, disabledInPlist: plist["Disabled"] as? Bool ?? false) } /// 当前用户域的加载与停用状态;系统域的停用列表需要 root,读不到时按 plist 判断 public static func status() -> LaunchStatus { let uid = getuid() var status = LaunchStatus() status.loaded = parseList(run(["list"])) status.disabled = parseDisabled(run(["print-disabled", "gui/\(uid)"])) for (label, disabled) in parseDisabled(run(["print-disabled", "system"])) where status.disabled[label] == nil { status.disabled[label] = disabled } return status } /// `launchctl list`:PID、状态、标签,PID 为“-”表示已加载但没有在运行 static func parseList(_ output: String) -> [String: Int?] { var result: [String: Int?] = [:] for line in output.split(separator: "\n").dropFirst() { let columns = line.split(separator: "\t", omittingEmptySubsequences: false) guard columns.count >= 3 else { continue } result[String(columns[2])] = Int(columns[0]) } return result } /// `launchctl print-disabled`:`"标签" => enabled / disabled` static func parseDisabled(_ output: String) -> [String: Bool] { var result: [String: Bool] = [:] for line in output.split(separator: "\n") { let parts = line.components(separatedBy: "=>") guard parts.count == 2 else { continue } let label = parts[0].trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) let state = parts[1].trimmingCharacters(in: .whitespaces) guard !label.isEmpty else { continue } result[label] = state.hasPrefix("disabled") || state == "true" } return result } /// 停用:写入 launchd 的停用记录并卸载,不删除 plist,随时可以重新启用。只处理当前用户的启动项 public static func setEnabled(_ enabled: Bool, item: LaunchItem) -> String? { guard item.scope == .user else { return tr("所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理") } let domain = "gui/\(getuid())" let target = "\(domain)/\(item.label)" if enabled { _ = run(["enable", target]) let result = runStatus(["bootstrap", domain, item.plist.path]) // 已经加载时返回 5 / 37,不算失败 return [0, 5, 37].contains(result.status) ? nil : tr("启用失败:\(result.output)") } else { _ = run(["disable", target]) let result = runStatus(["bootout", target]) // 没有加载时返回 3 / 113,不算失败 return [0, 3, 36, 113].contains(result.status) ? nil : tr("停用失败:\(result.output)") } } private static func run(_ arguments: [String]) -> String { runStatus(arguments).output } private static func runStatus(_ arguments: [String]) -> (status: Int32, output: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/launchctl") process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { try process.run() } catch { return (-1, error.localizedDescription) } let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() return (process.terminationStatus, String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) } }