Files
ysicingandClaude Opus 5.5 f1dd8cfacf fix: 收尾 PR 审查遗留的更新、卸载与签名问题
- 跳过已撤回的版本时不再显示"有更新";安装前发现更新源换了版本时给出明确提示(补齐 7 种语言)
- 补充双区域回退与失败重排的调度测试
- 回收结果按标准化路径比对,避免路径写法不同误报本体未移动
- 抽出 scan 统一后台遍历与取消,loadApps 嵌套降到 3 层
- 自我保护测试只校验错误语义,不再比较两次翻译
- sign_sparkle.sh 在证书不在钥匙串有效列表时提前报错,避免子组件漏签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:59:02 +08:00

306 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Cleaner
import Foundation
import Localization
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 pendingUninstall: 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> = []
/// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新
private var runningRevision = 0
@ObservationIgnored private var runningApplicationsObservation: NSKeyValueObservation?
@ObservationIgnored private let currentBundleIdentifier: String?
@ObservationIgnored private let listApplications: @Sendable () throws -> [InstalledApp]
@ObservationIgnored private let findLeftovers: @Sendable (InstalledApp) throws -> [AppLeftover]
@ObservationIgnored private let measureSize: @Sendable (URL) throws -> UInt64
@ObservationIgnored private let recycleFiles: ([URL], @escaping @Sendable ([URL: URL], String?) -> Void) -> Void
@ObservationIgnored private var applicationTask: Task<Void, Never>?
@ObservationIgnored private var selectionTask: Task<Void, Never>?
@ObservationIgnored private var applicationGeneration = UUID()
@ObservationIgnored private var selectionGeneration = UUID()
@ObservationIgnored private var hasScannedSelection = false
public convenience init() {
self.init(currentBundleIdentifier: Bundle.main.bundleIdentifier)
}
init(currentBundleIdentifier: String?,
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { try AppUninstaller.scanInstalledApps() },
findLeftovers: @escaping @Sendable (InstalledApp) throws -> [AppLeftover] = { try AppUninstaller.scanLeftovers(for: $0) },
measureSize: @escaping @Sendable (URL) throws -> UInt64 = { try CleanEngine.scanAllocatedSize(of: $0) },
recycleFiles: @escaping ([URL], @escaping @Sendable ([URL: URL], String?) -> Void) -> Void = { urls, completion in
NSWorkspace.shared.recycle(urls) { moved, error in completion(moved, error?.localizedDescription) }
}) {
self.currentBundleIdentifier = currentBundleIdentifier
self.listApplications = listApplications
self.findLeftovers = findLeftovers
self.measureSize = measureSize
self.recycleFiles = recycleFiles
// LSUIElement 应用(如 MacTools)不会发送普通应用的启动/退出通知。
// 观察运行列表才能覆盖这些应用;异步回到主线程,等 AppKit 更新列表后再通知视图。
runningApplicationsObservation = NSWorkspace.shared.observe(\.runningApplications) { [weak self] _, _ in
Task { @MainActor [weak self] in self?.runningRevision += 1 }
}
}
var chosenSize: UInt64 {
leftovers.filter { chosen.contains($0.id) }.reduce(0) { $0 + $1.size }
}
var canUninstall: Bool {
selected != nil && hasScannedSelection && !isScanning && !isRemoving
&& leftovers.contains { $0.kind == .application && chosen.contains($0.id) }
}
func loadApps() {
guard !isRemoving, applicationTask == nil else { return }
let generation = UUID()
applicationGeneration = generation
isLoading = true
let list = listApplications
let measure = measureSize
applicationTask = Task { [weak self] in
defer {
if let self, self.applicationGeneration == generation {
self.isLoading = false
self.applicationTask = nil
}
}
do {
let apps = try await Self.scan(list)
guard let self, !Task.isCancelled, self.applicationGeneration == generation else { return }
self.apps = apps.filter { (try? AppUninstaller.validate($0, currentBundleIdentifier: self.currentBundleIdentifier)) != nil }
self.isLoading = false
// 列表先展示;完整计量结束前仍保持重入保护,显式刷新或关闭页面会取消当前子任务。
for app in self.apps where self.sizes[app.id] == nil {
try Task.checkCancellation()
let size = try await Self.scan { try measure(app.url) }
guard !Task.isCancelled, self.applicationGeneration == generation else { return }
self.sizes[app.id] = size
}
} catch {
guard let self, self.applicationGeneration == generation, !Task.isCancelled else { return }
self.outcome = (error.localizedDescription, true)
}
}
}
/// 同步文件遍历放到 utility 子任务执行;子任务继承父任务取消,遍历内部据此中止。
private static func scan<T: Sendable>(_ work: @escaping @Sendable () throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask(priority: .utility) { try Task.checkCancellation(); return try work() }
guard let result = try await group.next() else { throw CancellationError() }
return result
}
}
/// 用户显式刷新:中断进行中的列举或计量后重新列举,已算出的大小保留。
func reloadApps() {
guard !isRemoving else { return }
applicationTask?.cancel()
applicationTask = nil
loadApps()
}
func select(_ app: InstalledApp?) {
guard !isRemoving else { return }
if let app, currentBundleIdentifier?.caseInsensitiveCompare(app.bundleIdentifier) == .orderedSame {
outcome = (AppUninstallError.currentApp.description, true)
return
}
selectionTask?.cancel()
let generation = UUID()
selectionGeneration = generation
selectionTask = nil
isScanning = false
hasScannedSelection = false
pendingUninstall = nil
selected = app
leftovers = []
chosen = []
outcome = nil
guard let app else { return }
isScanning = true
let find = findLeftovers
selectionTask = Task { [weak self] in
defer {
if let self, self.selectionGeneration == generation {
self.isScanning = false
self.selectionTask = nil
}
}
do {
let found = try await Self.scan { try find(app) }
// 应用相同也可能是另一次扫描,不能用 selected == app 代替任务代次。
guard let self, !Task.isCancelled, self.selectionGeneration == generation, self.selected == app else { return }
self.leftovers = found
self.chosen = Set(found.map(\.id))
self.hasScannedSelection = true
} catch {
guard let self, self.selectionGeneration == generation, !Task.isCancelled else { return }
self.outcome = (error.localizedDescription, true)
}
}
}
/// 离开页面、关闭或最小化窗口时中断遍历;已完成结果保留,未完成结果稍后重扫。
func cancelScanning() {
applicationGeneration = UUID()
selectionGeneration = UUID()
applicationTask?.cancel()
selectionTask?.cancel()
applicationTask = nil
selectionTask = nil
isLoading = false
isScanning = false
pendingUninstall = nil
}
func resumeScanning() {
if apps.isEmpty || apps.contains(where: { sizes[$0.id] == nil }) { loadApps() }
if let selected, !hasScannedSelection, !isScanning { select(selected) }
}
/// 拖进来的 .app
func select(url: URL) {
guard let app = AppUninstaller.app(at: url) else {
outcome = (tr("不是应用程序"), true)
return
}
do {
try AppUninstaller.validate(app, currentBundleIdentifier: currentBundleIdentifier)
select(app)
} catch {
outcome = ("\(error)", true)
}
}
func toggle(_ leftover: AppLeftover) {
// 应用本体必须一起移除,否则没有意义;回收期间固定目标
guard !isRemoving, 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) {
guard currentBundleIdentifier?.caseInsensitiveCompare(app.bundleIdentifier) != .orderedSame else {
outcome = (AppUninstallError.currentApp.description, true)
return
}
for running in NSRunningApplication.runningApplications(withBundleIdentifier: app.bundleIdentifier) {
// 防御直接调用或异常 Bundle 元数据,绝不向当前进程发送退出请求。
guard running.processIdentifier != ProcessInfo.processInfo.processIdentifier else {
outcome = (AppUninstallError.currentApp.description, true)
continue
}
let accepted = running.terminate()
Log.app.notice("卸载前请求退出应用 \(app.name, privacy: .public),包名 \(app.bundleIdentifier, privacy: .public),PID \(running.processIdentifier),请求已发送:\(accepted)")
}
}
func requestUninstall() {
guard let selected, canUninstall else { return }
pendingUninstall = selected
}
func cancelUninstall() {
pendingUninstall = nil
}
func confirmUninstall() {
guard let pendingUninstall, pendingUninstall == selected else { return }
self.pendingUninstall = nil
uninstall()
}
private func uninstall() {
guard let app = selected, canUninstall else { return }
do {
try AppUninstaller.validate(app, currentBundleIdentifier: currentBundleIdentifier)
guard !isRunning(app) else { throw AppUninstallError.running }
} catch {
outcome = ("\(error)", true)
return
}
let items = leftovers.filter { chosen.contains($0.id) }
let urls = items.map(\.url)
// 回收开始后不能让仍在运行的列表扫描重新发布已经移除的应用。
cancelScanning()
isRemoving = true
outcome = nil
// NSWorkspace 负责需要管理员权限的情况(例如 root 拥有的应用),并能在废纸篓中“放回原处”
recycleFiles(urls) { [weak self] moved, message in
Task { @MainActor in
guard let self else { return }
self.finishUninstall(app, items: items, moved: moved, errorMessage: message)
}
}
}
/// 回收可部分成功;只有本体确实移动后,才能移除列表和程序坞入口。
func finishUninstall(_ app: InstalledApp, items: [AppLeftover], moved: [URL: URL], errorMessage: String?) {
isRemoving = false
// 回收结果的键可能是规范化后的 URL(如目录末尾斜杠不同),按标准化路径比对。
let movedPaths = Set(moved.keys.map(\.standardizedFileURL.path))
let wasMoved = { (url: URL) in movedPaths.contains(url.standardizedFileURL.path) }
let movedItems = items.filter { wasMoved($0.url) }
guard !movedItems.isEmpty else {
outcome = (tr("没有移动任何文件\(errorMessage.map { tr(":\($0)") } ?? "")"), true)
return
}
let bytes = movedItems.reduce(0) { $0 + $1.size }
let remaining = items.count - movedItems.count
let partial = remaining > 0 ? tr(",\(remaining.formatted(.number.locale(L10n.locale))) 项未能移动") : ""
let detail = errorMessage.map { tr(":\($0)") } ?? ""
let appMoved = wasMoved(app.url)
if appMoved {
let dock = Self.removeDockTile(for: app.url)
let residualCount = movedItems.filter { $0.kind != .application }.count.formatted(.number.locale(L10n.locale))
outcome = (tr("已将 \(app.name) 与 \(residualCount) 项残留移到废纸篓,约 \(Format.bytes(bytes, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(partial)。需要时可以在废纸篓里放回。") + detail,
remaining > 0 || errorMessage != nil)
if selected == app { selected = nil; leftovers = []; chosen = [] }
apps.removeAll { $0 == app }
} else {
let count = movedItems.count.formatted(.number.locale(L10n.locale))
outcome = (tr("应用本体未能移动;已将 \(count) 项残留移到废纸篓,约 \(Format.bytes(bytes, base: .decimal))。请解决错误后重试。") + detail, true)
if selected == app {
leftovers.removeAll { wasMoved($0.url) }
chosen.subtract(movedItems.map(\.id))
}
}
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),实际移到废纸篓 \(movedItems.count) 项,本体已移动:\(appMoved)")
}
/// 修改程序坞偏好里的 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
}
}