Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/State/UninstallerController.swift
T
gentpan c9d722f20d 账号同步、磁盘工具、IP 归属地数据源与纯净度、弹窗与设置调整
- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调)
- 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出
- 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计
- 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复
- 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知
- 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token
- 更新日志、架构说明、README 与译文同步
2026-09-15 03:37:24 +05:00

151 lines
6.1 KiB
Swift
Raw 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 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 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 = (tr("不是应用程序"), 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 = (tr("没有移动任何文件\(message.map { tr(":\($0)") } ?? "")"), true)
return
}
// 应用已经进了废纸篓,程序坞里的图标只会变成问号,一并移除
let dock = Self.removeDockTile(for: app.url)
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),移到废纸篓 \(movedCount) 项")
let partial = movedCount < urls.count ? tr(",\(urls.count - movedCount) 项未能移动") : ""
self.outcome = (tr("已将 \(app.name) 与 \(movedCount - 1) 项残留移到废纸篓,约 \(Format.bytes(freed, base: .decimal))\(dock ? tr(",已从程序坞移除") : "")\(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
}
}