卸载应用:残留文件查找、移到废纸篓、从程序坞移除
This commit is contained in:
1 parent
9fb8ead908
commit
ff790f5627
9 files changed
+645
-3
No files matched your search
@@ -15,6 +15,7 @@
|
||||
- 系统通知(设置 · 通知):CPU 过热(持续 1 分钟高于设定温度)、内存压力严重(持续 30 秒)、磁盘空间不足(低于 10% 或 10 GB)、网络断开超过 20 秒(恢复后再提示)、电池最大容量低于 80%,逐项开关,默认关闭;同一状况持续期间只提醒一次并有冷却时间,点通知打开对应页面;可发送测试通知,通知被关闭时提示去系统设置打开。
|
||||
- 功耗与频率:“温度与风扇”页和温度详情新增功耗,显示整机功耗走势、电源输入、电池充放电功率和 GPU 功耗(读 SMC 与系统能耗统计,不需要辅助工具);CPU 详情的核心分工显示各类核心工作时的平均频率。读取能耗统计有一定开销,只在这些界面打开时每 2 秒读一次。
|
||||
- 磁盘页(主窗口侧边栏“磁盘”):启动磁盘容量、所有磁盘合计的实时读写速度与 60 秒走势、SSD 健康(通过系统自带的 NVMe SMART 接口读取剩余寿命、累计读写量、备用空间、温度、通电时间与次数、异常断电和介质错误,出现警告时提示备份)、读写磁盘最多的应用;磁盘空间不足的通知改为打开磁盘页。
|
||||
- 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。
|
||||
|
||||
#### 调整
|
||||
|
||||
|
||||
@@ -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<String> = []) -> [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<String>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user