From ff790f5627b34e0732e3e3f835edce55e5fd9fe5 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:38:31 +0500 Subject: [PATCH] =?UTF-8?q?=E5=8D=B8=E8=BD=BD=E5=BA=94=E7=94=A8=EF=BC=9A?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=96=87=E4=BB=B6=E6=9F=A5=E6=89=BE=E3=80=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=BA=9F=E7=BA=B8=E7=AF=93=E3=80=81=E4=BB=8E?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=9D=9E=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Sources/Cleaner/AppUninstaller.swift | 176 +++++++++++++ .../Sources/Cleaner/CleanEngine.swift | 2 +- .../OpenStatsUI/Panel/MainWindowView.swift | 1 + .../OpenStatsUI/Panel/UninstallerPage.swift | 237 ++++++++++++++++++ .../Sources/OpenStatsUI/State/AppModel.swift | 1 + .../OpenStatsUI/State/AppSettings.swift | 6 +- .../State/UninstallerController.swift | 150 +++++++++++ .../CleanerTests/AppUninstallerTests.swift | 74 ++++++ 9 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift create mode 100644 Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 609ac00..dc1848b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。 - 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。 - 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。 +- 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift new file mode 100644 index 0000000..cf34b6c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -0,0 +1,176 @@ +import Foundation + +/// 一个可以卸载的应用 +public struct InstalledApp: Sendable, Identifiable, Hashable { + public var id: String { url.path } + public let url: URL + public let name: String + public let bundleIdentifier: String + public let version: String? + /// 签名团队,用于查找 Group Containers + public let teamIdentifier: String? + + public init(url: URL, name: String, bundleIdentifier: String, version: String?, teamIdentifier: String?) { + self.url = url + self.name = name + self.bundleIdentifier = bundleIdentifier + self.version = version + self.teamIdentifier = teamIdentifier + } +} + +/// 应用留下的文件 +public struct AppLeftover: Sendable, Identifiable, Hashable { + public enum Kind: String, Sendable, CaseIterable { + case application, support, caches, preferences, containers, savedState, logs, webData, launchAgents + + public var title: String { + switch self { + case .application: "应用本体" + case .support: "应用数据" + case .caches: "缓存" + case .preferences: "偏好设置" + case .containers: "沙盒容器" + case .savedState: "窗口状态" + case .logs: "日志与崩溃报告" + case .webData: "网页数据" + case .launchAgents: "登录启动项" + } + } + } + + public var id: String { url.path } + public let url: URL + public let kind: Kind + public let size: UInt64 +} + +public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertible { + case systemApp + case running + case notAnApp + case unsafePath(String) + + public var description: String { + switch self { + case .systemApp: "系统自带的应用不能卸载" + case .running: "应用正在运行,请先退出" + case .notAnApp: "不是应用程序" + case .unsafePath(let path): "不在可卸载的位置:\(path)" + } + } +} + +/// 查找应用的残留文件。只按包名(bundle identifier)精确匹配或以“包名.”开头, +/// 应用名只用于 Application Support 与 Logs 下与应用同名的目录,避免误伤名字相近的其他应用 +public enum AppUninstaller { + public static func applicationDirectories(home: String = NSHomeDirectory()) -> [URL] { + [URL(fileURLWithPath: "/Applications"), URL(fileURLWithPath: home).appendingPathComponent("Applications")] + } + + /// /Applications 与 ~/Applications 下的应用(含一层子文件夹),按名称排序 + public static func installedApps(home: String = NSHomeDirectory(), excluding excludedIdentifiers: Set = []) -> [InstalledApp] { + var apps: [InstalledApp] = [] + let manager = FileManager.default + for directory in applicationDirectories(home: home) { + let entries = (try? manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] + for entry in entries { + if entry.pathExtension == "app" { + if let app = app(at: entry) { apps.append(app) } + } else if (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true { + let nested = (try? manager.contentsOfDirectory(at: entry, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] + apps += nested.filter { $0.pathExtension == "app" }.compactMap(app(at:)) + } + } + } + return apps + .filter { !excludedIdentifiers.contains($0.bundleIdentifier) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + public static func app(at url: URL) -> InstalledApp? { + guard url.pathExtension == "app", let bundle = Bundle(url: url), let identifier = bundle.bundleIdentifier else { return nil } + let name = (bundle.localizedInfoDictionary?["CFBundleDisplayName"] as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? url.deletingPathExtension().lastPathComponent + return InstalledApp(url: url, name: name, bundleIdentifier: identifier, + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + teamIdentifier: nil) + } + + /// 系统自带与 Apple 的应用不允许卸载 + public static func validate(_ app: InstalledApp, home: String = NSHomeDirectory()) throws { + let path = app.url.resolvingSymlinksInPath().path + guard app.url.pathExtension == "app" else { throw AppUninstallError.notAnApp } + if path.hasPrefix("/System/") || app.bundleIdentifier.hasPrefix("com.apple.") { throw AppUninstallError.systemApp } + let allowed = applicationDirectories(home: home).map { $0.path + "/" } + guard allowed.contains(where: { path.hasPrefix($0) }) else { throw AppUninstallError.unsafePath(path) } + } + + public static func leftovers(for app: InstalledApp, home: String = NSHomeDirectory()) -> [AppLeftover] { + let library = URL(fileURLWithPath: home).appendingPathComponent("Library") + let id = app.bundleIdentifier + let manager = FileManager.default + var results: [AppLeftover] = [] + var seen = Set() + + func add(_ url: URL, _ kind: AppLeftover.Kind) { + let path = url.standardizedFileURL.path + guard !seen.contains(path), manager.fileExists(atPath: path) else { return } + seen.insert(path) + results.append(AppLeftover(url: url, kind: kind, size: CleanEngine.allocatedSize(of: url))) + } + + /// 目录下名字等于包名、以“包名.”开头或满足 extra 的条目 + func scan(_ relative: String, _ kind: AppLeftover.Kind, extra: (String) -> Bool = { _ in false }) { + let directory = library.appendingPathComponent(relative) + let names = (try? manager.contentsOfDirectory(atPath: directory.path)) ?? [] + for name in names where matchesIdentifier(name, id) || extra(name) { + add(directory.appendingPathComponent(name), kind) + } + } + + add(app.url, .application) + scan("Application Support", .support) { $0 == app.name } + scan("Caches", .caches) + scan("HTTPStorages", .caches) + scan("Preferences", .preferences) + scan("Preferences/ByHost", .preferences) + scan("Containers", .containers) + scan("Application Scripts", .containers) + scan("Group Containers", .containers) { name in + // 形如 “TEAMID.com.example.app” 或 “group.com.example.app” + name.hasSuffix("." + id) || name == "group." + id || name.hasPrefix("group." + id + ".") + } + scan("Saved Application State", .savedState) + scan("Logs", .logs) { $0 == app.name } + scan("Logs/DiagnosticReports", .logs) { $0.hasPrefix(app.url.deletingPathExtension().lastPathComponent + "-") } + scan("WebKit", .webData) + scan("Cookies", .webData) + scan("LaunchAgents", .launchAgents) + return results + } + + /// 名字等于包名,或是包名后接“.”的派生名(com.example.app.plist、com.example.app.savedState、com.example.app.helper) + static func matchesIdentifier(_ name: String, _ identifier: String) -> Bool { + guard !identifier.isEmpty else { return false } + let lowered = name.lowercased() + let id = identifier.lowercased() + return lowered == id || lowered.hasPrefix(id + ".") + } + + // MARK: 程序坞 + + /// 从程序坞的 persistent-apps 中去掉指向该应用的图标,返回是否有改动(调用方随后重启程序坞) + public static func removingDockTile(for appURL: URL, from tiles: [[String: Any]]) -> [[String: Any]]? { + let target = appURL.standardizedFileURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let filtered = tiles.filter { tile in + guard let data = tile["tile-data"] as? [String: Any], + let file = data["file-data"] as? [String: Any], + let string = file["_CFURLString"] as? String else { return true } + let path = (URL(string: string)?.path ?? string).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return path != target + } + return filtered.count == tiles.count ? nil : filtered + } +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift index 527f07d..ff04029 100644 --- a/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift @@ -67,7 +67,7 @@ public enum CleanEngine { } /// 实际占用的磁盘空间;不跟随符号链接 - static func allocatedSize(of url: URL) -> UInt64 { + public static func allocatedSize(of url: URL) -> UInt64 { let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey] guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isSymbolicLink != true else { return 0 } if values.isRegularFile == true { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index d086806..6315fae 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -38,6 +38,7 @@ public struct MainWindowView: View { case .processes: ProcessesPage() case .keepAwake: KeepAwakePage() case .cleaner: CleanerPage() + case .uninstaller: UninstallerPage() case .settingsGeneral: SettingsTabPage { GeneralSettings() } case .settingsMenuBar: SettingsTabPage { MenuBarSettings() } case .settingsNotifications: SettingsTabPage { NotificationSettings() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift new file mode 100644 index 0000000..f0557b1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/UninstallerPage.swift @@ -0,0 +1,237 @@ +import Cleaner +import Metrics +import SwiftUI +import UniformTypeIdentifiers + +/// 卸载应用:左侧应用列表,右侧残留文件与卸载按钮;也可以把应用拖到页面上 +struct UninstallerPage: View { + @Environment(AppModel.self) private var model + @State private var search = "" + @State private var confirming = false + @State private var isDropTargeted = false + + var body: some View { + let uninstaller = model.uninstaller + + HStack(alignment: .top, spacing: DS.Space.s3) { + AppListColumn(search: $search) + .frame(width: DS.Size.sidebarWidth + DS.Space.s16) + PageScroll { + if let app = uninstaller.selected { + AppDetailCard(app: app, confirm: { confirming = true }) + } else { + DropHint(isTargeted: isDropTargeted) + } + if let outcome = uninstaller.outcome { + InfoBanner(icon: outcome.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill", + text: outcome.text, tone: outcome.isError ? .error : .success) + } + } + } + .padding(.leading, DS.Space.s3) + .onAppear { if uninstaller.apps.isEmpty { uninstaller.loadApps() } } + .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in + guard let provider = providers.first else { return false } + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url else { return } + Task { @MainActor in uninstaller.select(url: url) } + } + return true + } + .confirmationDialog(uninstaller.selected.map { "卸载“\($0.name)”?" } ?? "", isPresented: $confirming, titleVisibility: .visible) { + Button("移到废纸篓", role: .destructive) { uninstaller.uninstall() } + Button("取消", role: .cancel) {} + } message: { + Text("应用与勾选的 \(max(0, uninstaller.chosen.count - 1)) 项残留会移到废纸篓,约 \(Format.bytes(uninstaller.chosenSize, base: .decimal))。清空废纸篓前都可以放回。") + } + } +} + +private struct AppListColumn: View { + @Environment(AppModel.self) private var model + @Binding var search: String + + var body: some View { + let uninstaller = model.uninstaller + let query = search.trimmingCharacters(in: .whitespaces) + let apps = query.isEmpty ? uninstaller.apps + : uninstaller.apps.filter { $0.name.localizedCaseInsensitiveContains(query) || $0.bundleIdentifier.localizedCaseInsensitiveContains(query) } + + VStack(alignment: .leading, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s2) { + Image(systemName: "magnifyingglass") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.textTertiary) + TextField("搜索应用", text: $search) + .textFieldStyle(.plain) + .dsFont(.sm) + IconButton(systemName: "arrow.clockwise", help: "重新扫描") { uninstaller.loadApps() } + } + .padding(.leading, DS.Space.s2) + .frame(height: DS.Size.controlHeight) + .background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .overlay(RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.stroke)) + + Text(uninstaller.isLoading ? "正在扫描应用…" : "\(apps.count) 个应用,系统自带的不列出") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + + ScrollView { + LazyVStack(spacing: DS.Space.s1 / 2) { + ForEach(apps) { app in + AppListRow(app: app, size: uninstaller.sizes[app.id], isSelected: uninstaller.selected == app) { + uninstaller.select(app) + } + } + } + } + .overlayScrollers() + } + .padding(.vertical, DS.Space.s3) + .frame(maxHeight: .infinity, alignment: .top) + } +} + +private struct AppListRow: View { + let app: InstalledApp + let size: UInt64? + let isSelected: Bool + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: DS.Space.s2) { + AppIconCache.shared.image(bundlePath: app.url.path) + .resizable() + .frame(width: DS.Size.iconStandalone + DS.Space.s1, height: DS.Size.iconStandalone + DS.Space.s1) + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: app.name) + .dsFont(.sm, weight: isSelected ? .semibold : .regular) + .foregroundStyle(isSelected ? DS.Palette.primary : DS.Palette.textPrimary) + .lineLimit(1) + Text(verbatim: size.map { Format.bytes($0, base: .decimal) } ?? "计算中") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, DS.Space.s2) + .padding(.vertical, DS.Space.s1) + .background(isSelected ? DS.Palette.primarySoft : hovering ? DS.Palette.surface : .clear, + in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + } +} + +private struct AppDetailCard: View { + @Environment(AppModel.self) private var model + let app: InstalledApp + let confirm: () -> Void + + var body: some View { + @Bindable var uninstaller = model.uninstaller + let running = uninstaller.isRunning(app) + + Card { + HStack(spacing: DS.Space.s3) { + AppIconCache.shared.image(bundlePath: app.url.path) + .resizable() + .frame(width: DS.Space.s12, height: DS.Space.s12) + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(verbatim: app.name).dsFont(.lg, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + Text(verbatim: [app.version.map { "版本 \($0)" }, app.bundleIdentifier].compactMap { $0 }.joined(separator: " · ")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + + if running { + InfoBanner(icon: "exclamationmark.triangle", text: "\(app.name) 正在运行,卸载前需要先退出。", tone: .warning) { + Button("退出应用") { uninstaller.quit(app) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + } + + HairlineDivider() + HStack { + Text("将移到废纸篓").dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + Spacer() + Text(verbatim: uninstaller.isScanning ? "正在查找残留…" : "已选 \(uninstaller.chosen.count) 项 · \(Format.bytes(uninstaller.chosenSize, base: .decimal))") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + + ForEach(AppLeftover.Kind.allCases, id: \.self) { kind in + let items = uninstaller.leftovers.filter { $0.kind == kind } + if !items.isEmpty { + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text(kind.title).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary) + ForEach(items) { item in + HStack(spacing: DS.Space.s2) { + DSCheckbox(isOn: uninstaller.chosen.contains(item.id)) { uninstaller.toggle(item) } + .disabled(item.kind == .application) + Text(verbatim: item.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .help(item.url.path) + Spacer(minLength: DS.Space.s2) + Text(verbatim: Format.bytes(item.size, base: .decimal)) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospacedDigit() + MiniIconButton(systemName: "magnifyingglass", help: "在访达中显示") { + NSWorkspace.shared.activateFileViewerSelecting([item.url]) + } + } + } + } + } + } + + HairlineDivider() + HStack(spacing: DS.Space.s3) { + DSToggle(isOn: $uninstaller.removeFromDock, label: "从程序坞移除图标") + Text("同时从程序坞移除图标").dsFont(.sm).foregroundStyle(DS.Palette.textPrimary) + Spacer() + Button(uninstaller.isRemoving ? "正在移除…" : "卸载") { confirm() } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(running || uninstaller.isScanning || uninstaller.isRemoving) + } + Text("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct DropHint: View { + let isTargeted: Bool + + var body: some View { + VStack(spacing: DS.Space.s3) { + Image(systemName: "arrow.down.app") + .font(.system(size: DS.TextSize.xxl.rawValue)) + .foregroundStyle(isTargeted ? DS.Palette.primary : DS.Palette.textTertiary) + Text("从左侧选择应用,或把应用拖到这里") + .dsFont(.sm, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + Text("会一并找出它留在资源库里的缓存、偏好设置、容器与登录启动项,全部移到废纸篓,可以放回") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(DS.Space.s12) + .background(isTargeted ? DS.Palette.primarySoft : DS.Palette.surface, in: RoundedRectangle(cornerRadius: DS.Radius.lg)) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index a520056..489b765 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -21,6 +21,7 @@ public final class AppModel { public let updates: UpdateController let diagnostics = DiagnosticsExporter() public let alerts: AlertController + public let uninstaller = UninstallerController() @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 f1e0f60..3972dc9 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 + case overview, system, cpu, gpu, memory, disk, network, thermal, processes, keepAwake, cleaner, uninstaller 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] + static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner, .uninstaller] static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout] var isSettings: Bool { Self.settings.contains(self) } @@ -247,6 +247,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .processes: "进程" case .keepAwake: "防休眠" case .cleaner: "清理" + case .uninstaller: "卸载应用" case .settingsGeneral: "通用" case .settingsMenuBar: "菜单栏" case .settingsNotifications: "通知" @@ -268,6 +269,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .processes: "list.bullet.rectangle" case .keepAwake: "cup.and.saucer" case .cleaner: "sparkles" + case .uninstaller: "trash" case .settingsGeneral: "gearshape" case .settingsMenuBar: "menubar.rectangle" case .settingsNotifications: "bell.badge" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift new file mode 100644 index 0000000..9922ab9 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift @@ -0,0 +1,150 @@ +import AppKit +import Cleaner +import Foundation +import Metrics +import Observation + +/// 卸载应用:列出已安装的应用,查找残留文件,连同残留一起移到废纸篓,并从程序坞移除图标 +@MainActor +@Observable +public final class UninstallerController { + public private(set) var apps: [InstalledApp] = [] + public private(set) var sizes: [String: UInt64] = [:] + public private(set) var isLoading = false + public private(set) var selected: InstalledApp? + public private(set) var leftovers: [AppLeftover] = [] + public private(set) var isScanning = false + public private(set) var isRemoving = false + public private(set) var outcome: (text: String, isError: Bool)? + var chosen: Set = [] + var removeFromDock = true + /// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新 + private var runningRevision = 0 + @ObservationIgnored private var observers: [NSObjectProtocol] = [] + + public init() { + let center = NSWorkspace.shared.notificationCenter + for name in [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.runningRevision += 1 } + }) + } + } + + var chosenSize: UInt64 { + leftovers.filter { chosen.contains($0.id) }.reduce(0) { $0 + $1.size } + } + + func loadApps() { + guard !isLoading else { return } + isLoading = true + let own = Bundle.main.bundleIdentifier.map { Set([$0]) } ?? [] + Task { + let apps = await Task.detached { AppUninstaller.installedApps(excluding: own) }.value + self.apps = apps.filter { (try? AppUninstaller.validate($0)) != nil } + isLoading = false + // 体积在后台逐个计算,列表先显示出来 + for app in self.apps where sizes[app.id] == nil { + let url = app.url + sizes[app.id] = await Task.detached { CleanEngine.allocatedSize(of: url) }.value + } + } + } + + func select(_ app: InstalledApp?) { + selected = app + leftovers = [] + chosen = [] + outcome = nil + guard let app else { return } + isScanning = true + Task { + let found = await Task.detached { AppUninstaller.leftovers(for: app) }.value + guard selected == app else { return } + leftovers = found + chosen = Set(found.map(\.id)) + isScanning = false + } + } + + /// 拖进来的 .app + func select(url: URL) { + guard let app = AppUninstaller.app(at: url) else { + outcome = ("不是应用程序", true) + return + } + do { + try AppUninstaller.validate(app) + select(app) + } catch { + outcome = ("\(error)", true) + } + } + + func toggle(_ leftover: AppLeftover) { + // 应用本体必须一起移除,否则没有意义 + guard leftover.kind != .application else { return } + if chosen.contains(leftover.id) { chosen.remove(leftover.id) } else { chosen.insert(leftover.id) } + } + + func isRunning(_ app: InstalledApp) -> Bool { + _ = runningRevision + return !NSRunningApplication.runningApplications(withBundleIdentifier: app.bundleIdentifier).isEmpty + } + + func quit(_ app: InstalledApp) { + NSRunningApplication.runningApplications(withBundleIdentifier: app.bundleIdentifier).forEach { $0.terminate() } + } + + func uninstall() { + guard let app = selected, !isRemoving else { return } + do { + try AppUninstaller.validate(app) + guard !isRunning(app) else { throw AppUninstallError.running } + } catch { + outcome = ("\(error)", true) + return + } + let urls = leftovers.filter { chosen.contains($0.id) }.map(\.url) + let freed = chosenSize + isRemoving = true + outcome = nil + // NSWorkspace 负责需要管理员权限的情况(例如 root 拥有的应用),并能在废纸篓中“放回原处” + NSWorkspace.shared.recycle(urls) { [weak self] moved, error in + let movedCount = moved.count + let message = error?.localizedDescription + Task { @MainActor in + guard let self else { return } + self.isRemoving = false + if movedCount == 0 { + self.outcome = ("没有移动任何文件\(message.map { ":\($0)" } ?? "")", true) + return + } + var dock = false + if self.removeFromDock { dock = Self.removeDockTile(for: app.url) } + Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项") + let partial = movedCount < urls.count ? ",\(urls.count - movedCount) 项未能移动" : "" + self.outcome = ("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? ",已从程序坞移除" : "")\(partial)。需要时可以在废纸篓里放回。", + message != nil) + self.selected = nil + self.leftovers = [] + self.apps.removeAll { $0 == app } + } + } + } + + /// 修改程序坞偏好里的 persistent-apps 并重启程序坞 + private static func removeDockTile(for url: URL) -> Bool { + let domain = "com.apple.dock" as CFString + let key = "persistent-apps" as CFString + guard let tiles = CFPreferencesCopyAppValue(key, domain) as? [[String: Any]], + let updated = AppUninstaller.removingDockTile(for: url, from: tiles) else { return false } + CFPreferencesSetAppValue(key, updated as CFArray, domain) + CFPreferencesAppSynchronize(domain) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["Dock"] + try? process.run() + return true + } +} diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift new file mode 100644 index 0000000..a673dad --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/AppUninstallerTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import Cleaner + +@Suite struct AppUninstallerTests { + private func makeHome() throws -> URL { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("uninstall-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + return home + } + + private func touch(_ url: URL, directory: Bool = false) throws { + if directory { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } else { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("x".utf8).write(to: url) + } + } + + @Test func findsLeftoversByIdentifierOnly() throws { + let home = try makeHome() + defer { try? FileManager.default.removeItem(at: home) } + let lib = home.appendingPathComponent("Library") + let appURL = home.appendingPathComponent("Applications/Foo.app") + try touch(appURL, directory: true) + let app = InstalledApp(url: appURL, name: "Foo", bundleIdentifier: "com.example.foo", version: "1.0", teamIdentifier: nil) + + try touch(lib.appendingPathComponent("Application Support/Foo/data.db")) + try touch(lib.appendingPathComponent("Application Support/com.example.foo/x")) + try touch(lib.appendingPathComponent("Application Support/FooBar/keep")) // 名字相近的其他应用 + try touch(lib.appendingPathComponent("Caches/com.example.foo/c")) + try touch(lib.appendingPathComponent("Caches/com.example.foobar/keep")) // 包名前缀相同但不是派生名 + try touch(lib.appendingPathComponent("Preferences/com.example.foo.plist")) + try touch(lib.appendingPathComponent("Preferences/ByHost/com.example.foo.ABCD.plist")) + try touch(lib.appendingPathComponent("Containers/com.example.foo"), directory: true) + try touch(lib.appendingPathComponent("Group Containers/TEAM123.com.example.foo"), directory: true) + try touch(lib.appendingPathComponent("Saved Application State/com.example.foo.savedState"), directory: true) + try touch(lib.appendingPathComponent("LaunchAgents/com.example.foo.helper.plist")) + + let found = Set(AppUninstaller.leftovers(for: app, home: home.path).map { $0.url.lastPathComponent }) + #expect(found.contains("Foo.app")) + #expect(found.contains("Foo")) + #expect(found.contains("com.example.foo.plist")) + #expect(found.contains("com.example.foo.ABCD.plist")) + #expect(found.contains("TEAM123.com.example.foo")) + #expect(found.contains("com.example.foo.savedState")) + #expect(found.contains("com.example.foo.helper.plist")) + #expect(!found.contains("FooBar")) + #expect(!found.contains("com.example.foobar")) + } + + @Test func refusesSystemAndOutsideApps() { + let system = InstalledApp(url: URL(fileURLWithPath: "/System/Applications/Notes.app"), name: "Notes", + bundleIdentifier: "com.apple.Notes", version: nil, teamIdentifier: nil) + #expect(throws: AppUninstallError.systemApp) { try AppUninstaller.validate(system) } + let elsewhere = InstalledApp(url: URL(fileURLWithPath: "/tmp/Foo.app"), name: "Foo", + bundleIdentifier: "com.example.foo", version: nil, teamIdentifier: nil) + #expect(throws: AppUninstallError.self) { try AppUninstaller.validate(elsewhere) } + let normal = InstalledApp(url: URL(fileURLWithPath: "/Applications/Foo.app"), name: "Foo", + bundleIdentifier: "com.example.foo", version: nil, teamIdentifier: nil) + #expect(throws: Never.self) { try AppUninstaller.validate(normal) } + } + + @Test func removesDockTile() { + let tiles: [[String: Any]] = [ + ["tile-data": ["file-data": ["_CFURLString": "file:///Applications/Foo.app/", "_CFURLStringType": 15]]], + ["tile-data": ["file-data": ["_CFURLString": "file:///Applications/Bar.app/", "_CFURLStringType": 15]]], + ] + let result = AppUninstaller.removingDockTile(for: URL(fileURLWithPath: "/Applications/Foo.app"), from: tiles) + #expect(result?.count == 1) + #expect(AppUninstaller.removingDockTile(for: URL(fileURLWithPath: "/Applications/Baz.app"), from: tiles) == nil) + } +}