diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1848b..1ef0061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。 - 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。 - 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。 +- 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Cleaner/LaunchItems.swift b/Packages/OpenStatsKit/Sources/Cleaner/LaunchItems.swift new file mode 100644 index 0000000..cc77fa5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/LaunchItems.swift @@ -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[.. 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)) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 6315fae..4d83b8a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -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() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/StartupItemsPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/StartupItemsPage.swift new file mode 100644 index 0000000..1b0363a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/StartupItemsPage.swift @@ -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) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 31d01ff..e566258 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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), diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 489b765..81d18c4 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 3972dc9..809a34f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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" diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/LaunchItemsTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/LaunchItemsTests.swift new file mode 100644 index 0000000..bcbda45 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/LaunchItemsTests.swift @@ -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) + } +}