卸载应用:残留文件查找、移到废纸篓、从程序坞移除

This commit is contained in:
gentpan committed 2026-09-13 08:38:31 +05:00
1 parent 9fb8ead908
commit ff790f5627
9 files changed
+645 -3

No files matched your search

+1
View File
@@ -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)
}
}