启动项管理:列出 LaunchAgents / LaunchDaemons,停用或启用当前用户的启动项
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user