启动项管理:列出 LaunchAgents / LaunchDaemons,停用或启用当前用户的启动项

This commit is contained in:
gentpan committed 2026-09-13 08:42:07 +05:00
1 parent ff790f5627
commit 1a79a5d98d
8 files changed
+386 -2

No files matched your search

@@ -0,0 +1,168 @@
import Foundation
/// 一个 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: "当前用户"
case .allUsers: "所有用户"
case .system: "系统服务"
}
}
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[..<range.lowerBound]) + ".app"
}
}
/// launchd 中的运行状态
public struct LaunchStatus: Sendable, Equatable {
public var loaded: [String: Int?] = [:]
public var disabled: [String: Bool] = [:]
public init(loaded: [String: Int?] = [:], disabled: [String: Bool] = [:]) {
self.loaded = loaded
self.disabled = disabled
}
public func isDisabled(_ item: LaunchItem) -> 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 "所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理" }
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 : "启用失败:\(result.output)"
} else {
_ = run(["disable", target])
let result = runStatus(["bootout", target])
// 没有加载时返回 3 / 113,不算失败
return [0, 3, 36, 113].contains(result.status) ? nil : "停用失败:\(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))
}
}
@@ -39,6 +39,7 @@ public struct MainWindowView: View {
case .keepAwake: KeepAwakePage()
case .cleaner: CleanerPage()
case .uninstaller: UninstallerPage()
case .startupItems: StartupItemsPage()
case .settingsGeneral: SettingsTabPage { GeneralSettings() }
case .settingsMenuBar: SettingsTabPage { MenuBarSettings() }
case .settingsNotifications: SettingsTabPage { NotificationSettings() }
@@ -0,0 +1,166 @@
import AppKit
import Cleaner
import ServiceManagement
import SwiftUI
/// 启动项:LaunchAgents / LaunchDaemons 里的后台项目,当前用户的可以直接停用
@MainActor
@Observable
final class StartupItemsController {
private(set) var items: [LaunchItem] = []
private(set) var status = LaunchStatus()
private(set) var isLoading = false
private(set) var working: String?
private(set) var message: (text: String, isError: Bool)?
func refresh() {
guard !isLoading else { return }
isLoading = true
Task {
let (items, status) = await Task.detached { (LaunchItems.scan(), LaunchItems.status()) }.value
self.items = items
self.status = status
isLoading = false
}
}
func setEnabled(_ enabled: Bool, _ item: LaunchItem) {
working = item.id
message = nil
Task {
let error = await Task.detached { LaunchItems.setEnabled(enabled, item: item) }.value
working = nil
if let error {
message = (error, true)
} else {
Log.app.notice("\(enabled ? "启用" : "停用", privacy: .public)启动项 \(item.label, privacy: .public)")
message = ("已\(enabled ? "启用" : "停用")“\(item.label)”", false)
}
isLoading = false
refresh()
}
}
}
struct StartupItemsPage: View {
@Environment(AppModel.self) private var model
var body: some View {
let controller = model.startupItems
PageScroll {
InfoBanner(icon: "info.circle",
text: "这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。") {
Button("登录项设置") { SMAppService.openSystemSettingsLoginItems() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
if let message = controller.message {
InfoBanner(icon: message.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill",
text: message.text, tone: message.isError ? .error : .success)
}
ForEach(LaunchItem.Scope.allCases, id: \.self) { scope in
let items = controller.items.filter { $0.scope == scope }
Card {
CardHeader(icon: scope == .system ? "lock.shield" : "person.crop.circle", title: scope.title) {
HStack(spacing: DS.Space.s2) {
Text(verbatim: scopeDetail(scope, count: items.count))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
if scope == .user {
IconButton(systemName: "arrow.clockwise", help: "刷新") { controller.refresh() }
}
}
}
if items.isEmpty {
Text(controller.isLoading && controller.items.isEmpty ? "正在读取…" : "没有启动项").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
if index > 0 { HairlineDivider() }
StartupItemRow(item: item, status: controller.status, isWorking: controller.working == item.id) { enabled in
controller.setEnabled(enabled, item)
}
}
}
}
}
.onAppear { controller.refresh() }
}
private func scopeDetail(_ scope: LaunchItem.Scope, count: Int) -> String {
switch scope {
case .user: "\(count) 项 · 可直接停用"
case .allUsers, .system: "\(count) 项 · 需要管理员权限,只读"
}
}
}
private struct StartupItemRow: View {
let item: LaunchItem
let status: LaunchStatus
let isWorking: Bool
let setEnabled: (Bool) -> Void
var body: some View {
let disabled = status.isDisabled(item)
HStack(spacing: DS.Space.s3) {
AppIconCache.shared.image(bundlePath: item.appBundlePath)
.resizable()
.frame(width: DS.Size.iconStandalone + DS.Space.s1, height: DS.Size.iconStandalone + DS.Space.s1)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
HStack(spacing: DS.Space.s2) {
Text(verbatim: title)
.dsFont(.sm, weight: .medium)
.foregroundStyle(disabled ? DS.Palette.textTertiary : DS.Palette.textPrimary)
.lineLimit(1)
badge(disabled: disabled)
}
Text(verbatim: [item.label, flags].filter { !$0.isEmpty }.joined(separator: " · "))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if let program = item.program {
Text(verbatim: program.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.truncationMode(.middle)
.help(program)
}
}
Spacer(minLength: DS.Space.s2)
MiniIconButton(systemName: "magnifyingglass", help: "在访达中显示") {
NSWorkspace.shared.activateFileViewerSelecting([item.plist])
}
if item.scope == .user {
if isWorking {
ProgressView().controlSize(.small)
} else {
DSToggle(isOn: Binding(get: { !disabled }, set: { setEnabled($0) }), label: "启用 \(title)")
}
}
}
}
private var title: String {
if let bundle = item.appBundlePath {
return AppNameCache.shared.name(forBundle: bundle)
}
return item.program.map { ($0 as NSString).lastPathComponent } ?? item.label
}
private var flags: String {
[item.runAtLoad ? "登录时运行" : nil, item.keepAlive ? "保持运行" : nil].compactMap { $0 }.joined(separator: "、")
}
@ViewBuilder
private func badge(disabled: Bool) -> some View {
if disabled {
StatusBadge(text: "已停用")
} else if let pid = status.pid(item) {
StatusBadge(text: "运行中 · PID \(pid)", tone: .success)
} else if status.isLoaded(item) {
StatusBadge(text: "已加载", tone: .primary)
}
}
}
@@ -41,6 +41,9 @@ enum SnapshotRenderer {
// 清理页展示真实扫描结果(只读,不删除任何文件)
model.cleaner.scan()
while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) }
model.startupItems.refresh()
model.uninstaller.loadApps()
try? await Task.sleep(for: .seconds(2))
model.updates.showPreview(UpdateRelease(
version: "0.3.0", build: "3", date: "2026-09-20", minimumSystem: "14.0",
url: URL(string: "https://getopenstats.com/download/OpenStats-0.3.0.zip")!, sha256: String(repeating: "0", count: 64),
@@ -22,6 +22,7 @@ public final class AppModel {
let diagnostics = DiagnosticsExporter()
public let alerts: AlertController
public let uninstaller = UninstallerController()
let startupItems = StartupItemsController()
@ObservationIgnored public let hub = MetricsHub()
public var isMainWindowVisible = false
@@ -220,13 +220,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
/// 主窗口侧边栏的页面
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case overview, system, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller
case overview, system, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller, startupItems
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout
public var id: String { rawValue }
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .disk, .network, .thermal]
static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner, .uninstaller]
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller]
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout]
var isSettings: Bool { Self.settings.contains(self) }
@@ -248,6 +248,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .keepAwake: "防休眠"
case .cleaner: "清理"
case .uninstaller: "卸载应用"
case .startupItems: "启动项"
case .settingsGeneral: "通用"
case .settingsMenuBar: "菜单栏"
case .settingsNotifications: "通知"
@@ -270,6 +271,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .keepAwake: "cup.and.saucer"
case .cleaner: "sparkles"
case .uninstaller: "trash"
case .startupItems: "power"
case .settingsGeneral: "gearshape"
case .settingsMenuBar: "menubar.rectangle"
case .settingsNotifications: "bell.badge"
@@ -0,0 +1,42 @@
import Foundation
import Testing
@testable import Cleaner
@Suite struct LaunchItemsTests {
@Test func parsesPlist() throws {
let url = URL(fileURLWithPath: "/tmp/com.example.agent.plist")
let item = try #require(LaunchItems.item(from: [
"Label": "com.example.agent",
"ProgramArguments": ["/Applications/Example.app/Contents/MacOS/agent", "--wake"],
"RunAtLoad": true,
"KeepAlive": ["SuccessfulExit": false],
], plist: url, scope: .user))
#expect(item.program == "/Applications/Example.app/Contents/MacOS/agent")
#expect(item.appBundlePath == "/Applications/Example.app")
#expect(item.runAtLoad && item.keepAlive && !item.disabledInPlist)
#expect(LaunchItems.item(from: ["Program": "/bin/true"], plist: url, scope: .user) == nil)
}
@Test func parsesLaunchctlOutput() {
let list = "PID\tStatus\tLabel\n-\t0\tcom.example.idle\n2879\t0\tcom.example.running\n"
let loaded = LaunchItems.parseList(list)
#expect(loaded["com.example.running"] == .some(2879))
#expect(loaded["com.example.idle"] == .some(nil))
#expect(loaded["com.example.missing"] == nil)
let disabled = LaunchItems.parseDisabled("""
\tdisabled services = {
\t\t"com.example.on" => enabled
\t\t"com.example.off" => disabled
\t}
""")
#expect(disabled["com.example.on"] == false)
#expect(disabled["com.example.off"] == true)
}
@Test func refusesNonUserItems() {
let item = LaunchItem(label: "com.example.daemon", plist: URL(fileURLWithPath: "/Library/LaunchDaemons/x.plist"), scope: .system,
program: nil, runAtLoad: true, keepAlive: false, disabledInPlist: false)
#expect(LaunchItems.setEnabled(false, item: item) != nil)
}
}