fix(lifecycle): 修复更新取消与卸载、查询生命周期

This commit is contained in:
ysicing authored and 缘生 committed 2026-09-30 15:20:08 +08:00
1 parent 3196ebef83
commit 7e2024eedf
22 files changed
+784 -129

No files matched your search

+22
View File
@@ -101,6 +101,20 @@ right before deletion, because apps start in between.
Regenerable caches are deleted outright; user files go to the Trash. Each action is appended
to `~/Library/Logs/XStats/cleanup.log` as a JSON line.
Application uninstallation uses a separate controller and scans only app-specific paths. The current
bundle identifier is rejected in validation, drag-in selection and quit requests; the current process
is never terminated by the uninstaller. App-list and selected-app scans each own one cancellable task
and generation token. Background traversal is structured under those tasks and checks cancellation
between files. Leaving the page, closing or minimizing the main window cancels scanning; completed
results are kept, while incomplete work resumes on reopening. Re-selecting an app cannot let an older
scan restore deselected files.
Trash operations are confirmed separately. The completion dictionary, rather than the number of
moved files, determines whether the app itself was removed. Only a moved app loses its list and Dock
entries. When the app cannot be moved, successfully moved residue is removed from the selection and
failed items remain available for retry. Reported sizes include only files actually moved. This flow
currently does not unregister third-party background services or stop independently running helpers.
## Disk tools
The disk page is also where users act on the disk. `DiskToolsController` (`XStatsUI/State`) fronts four
@@ -307,6 +321,10 @@ the quota reader has no access to session-log content.
timer that syncs MaxMind GeoLite2 onto `getopenstats.com/geoip/` (account and key in
`/etc/openstats/maxmind.env` on the server); the app no longer downloads those files, they are kept
for other uses.
Each public-IP query owns a cancellable task and generation token. Disabling lookup clears the
cache and invalidates the query; sleep or lock pauses it. Every asynchronous stage and final cache
write checks both generation and current settings, so late callbacks cannot restore cleared data
or replace a newer lookup. An old completion cannot clear the newer query's loading state.
- **Per-process traffic** — cumulative bytes from `/usr/bin/nettop`, diffed between samples.
- **Menu bar IP location** — optional flag or localized region name after network speeds, disabled
by default. It reads the same `NetworkController.publicAddresses.countryCode` as network details,
@@ -356,6 +374,10 @@ temporary folder, the new one moved into place (restored on failure; an administ
used when the folder is not writable), and a detached shell waits for the process to exit before
reopening the app with `open -n`. LaunchServices can retain the old instance after its PID disappears;
requesting a new instance prevents the relaunch from being routed to that terminated process.
Downloads use URLSession's asynchronous download API with a task-level progress delegate. The system
coordinates task creation and cancellation, avoiding a task being created on an already invalidated
session. Cancellation is checked before verification and before replacement starts; replacement itself
must finish or roll back once begun.
After an update the old helper may still be running; the app unregisters an outdated
helper, re-registers the bundled version, and verifies the protocol before privileged calls resume.
@@ -53,6 +53,7 @@ public struct AppLeftover: Sendable, Identifiable, Hashable {
public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertible {
case systemApp
case currentApp
case running
case notAnApp
case unsafePath(String)
@@ -60,6 +61,7 @@ public enum AppUninstallError: Error, Sendable, Equatable, CustomStringConvertib
public var description: String {
switch self {
case .systemApp: tr("系统自带的应用不能卸载")
case .currentApp: tr("不能在 XStats 内卸载或退出 XStats 自身")
case .running: tr("应用正在运行,请先退出")
case .notAnApp: tr("不是应用程序")
case .unsafePath(let path): tr("不在可卸载的位置:\(path)")
@@ -104,57 +106,73 @@ public enum AppUninstaller {
teamIdentifier: nil)
}
/// 系统自带与 Apple 的应用不允许卸载
public static func validate(_ app: InstalledApp, home: String = NSHomeDirectory()) throws {
/// 系统自带、Apple 的应用与当前应用自身不允许卸载;同 Bundle ID 的副本也视作自身。
public static func validate(_ app: InstalledApp, home: String = NSHomeDirectory(),
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier) 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 }
if currentBundleIdentifier?.caseInsensitiveCompare(app.bundleIdentifier) == .orderedSame { throw AppUninstallError.currentApp }
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] {
(try? leftovers(for: app, home: home, checkCancellation: {})) ?? []
}
/// 可取消的残留扫描;扫描不完整时抛错,不发布部分候选。
public static func scanLeftovers(for app: InstalledApp, home: String = NSHomeDirectory()) throws -> [AppLeftover] {
try leftovers(for: app, home: home, checkCancellation: { try Task.checkCancellation() })
}
private static func leftovers(for app: InstalledApp, home: String,
checkCancellation: () throws -> Void) throws -> [AppLeftover] {
try checkCancellation()
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) {
func add(_ url: URL, _ kind: AppLeftover.Kind) throws {
try checkCancellation()
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)))
results.append(AppLeftover(url: url, kind: kind,
size: try CleanEngine.scanAllocatedSize(of: url, checkCancellation: checkCancellation)))
}
/// 目录下名字等于包名、以“包名.”开头或满足 extra 的条目
func scan(_ relative: String, _ kind: AppLeftover.Kind, extra: (String) -> Bool = { _ in false }) {
func scan(_ relative: String, _ kind: AppLeftover.Kind, extra: (String) -> Bool = { _ in false }) throws {
try checkCancellation()
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)
try 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
try add(app.url, .application)
try scan("Application Support", .support) { $0 == app.name }
try scan("Caches", .caches)
try scan("HTTPStorages", .caches)
try scan("Preferences", .preferences)
try scan("Preferences/ByHost", .preferences)
try scan("Containers", .containers)
try scan("Application Scripts", .containers)
try scan("Group Containers", .containers) { name in
guard supportsDerivedNames(id) else { return false }
// 形如 “TEAMID.com.example.app” 或 “group.com.example.app”
return 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)
try scan("Saved Application State", .savedState)
try scan("Logs", .logs) { $0 == app.name }
try scan("Logs/DiagnosticReports", .logs) { $0.hasPrefix(app.url.deletingPathExtension().lastPathComponent + "-") }
try scan("WebKit", .webData)
try scan("Cookies", .webData)
try scan("LaunchAgents", .launchAgents)
return results
}
@@ -91,7 +91,8 @@ public enum CleanEngine {
(try? scanAllocatedSize(of: url, checkCancellation: {})) ?? 0
}
static func scanAllocatedSize(of url: URL,
/// 完整体积计量,遍历期间响应取消;取消时抛错,不返回部分大小。
public static func scanAllocatedSize(of url: URL,
checkCancellation: () throws -> Void = { try Task.checkCancellation() }) throws -> UInt64 {
try checkCancellation()
let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
@@ -1620,3 +1620,5 @@ GitHub 项目 مشروع GitHub
活动监视器 مراقب النشاط
打开系统活动监视器 فتح مراقب نشاط النظام
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 يفتح صفحة عمليات XStats عند تفعيل الميزة، أو مراقب نشاط النظام عند إيقافها.
不能在 XStats 内卸载或退出 XStats 自身 لا يمكن لهذه الأداة إلغاء تثبيت XStats نفسه أو إنهاؤه
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة.
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1620,3 +1620,5 @@ GitHub 项目 GitHub-Projekt
活动监视器 Aktivitätsanzeige
打开系统活动监视器 macOS-Aktivitätsanzeige öffnen
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Öffnet bei aktivierter Prozessfunktion die XStats-Prozessseite, andernfalls die macOS-Aktivitätsanzeige.
不能在 XStats 内卸载或退出 XStats 自身 XStats kann sich mit diesem Werkzeug nicht selbst deinstallieren oder beenden
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 Die App konnte nicht verschoben werden. Zugehörige Objekte im Papierkorb: {} (ca. {}). Beheben Sie den Fehler und versuchen Sie es erneut.
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1620,3 +1620,5 @@ GitHub 项目 Proyecto en GitHub
活动监视器 Monitor de Actividad
打开系统活动监视器 Abrir el Monitor de Actividad de macOS
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Abre Procesos de XStats si la función está activada; si está desactivada, abre el Monitor de Actividad de macOS.
不能在 XStats 内卸载或退出 XStats 自身 XStats no puede desinstalarse ni cerrarse desde esta herramienta
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 No se pudo mover la app. Se movieron {} elementos relacionados a la Papelera (aprox. {}). Resuelve el error e inténtalo de nuevo.
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1620,3 +1620,5 @@ GitHub 项目 Projet GitHub
活动监视器 Moniteur d’activité
打开系统活动监视器 Ouvrir le Moniteur d’activité de macOS
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Ouvre les processus XStats si la fonction est activée, sinon le Moniteur d’activité de macOS.
不能在 XStats 内卸载或退出 XStats 自身 XStats ne peut pas se désinstaller ou se quitter depuis cet outil
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 L’app n’a pas pu être déplacée. Éléments associés placés dans la Corbeille : {} (environ {}). Corrigez l’erreur et réessayez.
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1620,3 +1620,5 @@ GitHub 项目 GitHub プロジェクト
活动监视器 アクティビティモニタ
打开系统活动监视器 システムのアクティビティモニタを開く
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 プロセス機能がオンの場合は XStats のプロセスページを、オフの場合はシステムのアクティビティモニタを開きます。
不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1620,3 +1620,5 @@ GitHub 项目 GitHub 프로젝트
活动监视器 활성 상태 보기
打开系统活动监视器 시스템 활성 상태 보기 열기
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 프로세스 기능이 켜져 있으면 XStats 프로세스 페이지를, 꺼져 있으면 시스템 활성 상태 보기를 엽니다.
不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요.
1 # Copyright (C) 2026 ysicing
1620 活动监视器
1621 打开系统活动监视器
1622 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1623 不能在 XStats 内卸载或退出 XStats 自身
1624 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1621,3 +1621,5 @@ GitHub 项目 GitHub 專案
活动监视器 活動監視器
打开系统活动监视器 開啟系統活動監視器
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 程序功能開啟時開啟 XStats 程序頁,關閉時開啟系統活動監視器。
不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。
1 # Copyright (c) 2026 GiantAccel, LLC
1621 活动监视器
1622 打开系统活动监视器
1623 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。
1624 不能在 XStats 内卸载或退出 XStats 自身
1625 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。
@@ -1429,5 +1429,7 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
活动监视器 Activity Monitor
打开系统活动监视器 Open macOS Activity Monitor
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Opens XStats Processes when enabled, or macOS Activity Monitor when the feature is off.
不能在 XStats 内卸载或退出 XStats 自身 XStats cannot uninstall or quit itself from this tool
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 The app could not be moved. Related items moved to Trash: {} (about {}). Resolve the error and try again.
"""#
}
@@ -207,32 +207,36 @@ public enum UpdateInstaller {
}
}
/// 带进度的下载:系统把文件下载到临时位置,完成回调里立即移到目标位置
private final class Downloader: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
/// 带进度的下载:交由系统异步 API 协调任务创建和取消,完成后移到目标位置。
final class Downloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let destination: URL
private let progress: @Sendable (Double) -> Void
private let lock = NSLock()
private var continuation: CheckedContinuation<Void, Error>?
private var moveError: Error?
init(destination: URL, progress: @escaping @Sendable (Double) -> Void) {
self.destination = destination
self.progress = progress
}
func run(_ url: URL) async throws {
let configuration = URLSessionConfiguration.ephemeral
func run(_ url: URL, configuration: URLSessionConfiguration = .ephemeral) async throws {
try Task.checkCancellation()
configuration.timeoutIntervalForRequest = 60
configuration.timeoutIntervalForResource = 15 * 60
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
let session = URLSession(configuration: configuration)
defer { session.finishTasksAndInvalidate() }
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
lock.withLock { self.continuation = continuation }
session.downloadTask(with: url).resume()
do {
// 原来的取消回调可能先 invalidate Session,再创建任务,触发无法捕获的原生异常。
// 系统 API 自行协调这个时序,任务级 delegate 仍负责进度反馈。
let (location, response) = try await session.download(from: url, delegate: self)
try Task.checkCancellation()
if let response = response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) {
throw UpdateError.download(tr("服务器返回 \(response.statusCode)"))
}
} onCancel: {
session.invalidateAndCancel()
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)
try Task.checkCancellation()
} catch {
if Task.isCancelled { throw CancellationError() }
throw error as? UpdateError ?? UpdateError.download(error.localizedDescription)
}
}
@@ -243,28 +247,6 @@ private final class Downloader: NSObject, URLSessionDownloadDelegate, @unchecked
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
if let response = downloadTask.response as? HTTPURLResponse, !(200..<300).contains(response.statusCode) {
moveError = UpdateError.download(tr("服务器返回 \(response.statusCode)"))
return
}
do {
try? FileManager.default.removeItem(at: destination)
try FileManager.default.moveItem(at: location, to: destination)
} catch {
moveError = error
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let continuation = lock.withLock { () -> CheckedContinuation<Void, Error>? in
defer { self.continuation = nil }
return self.continuation
}
if let error = error ?? moveError {
let message = (error as? UpdateError)?.errorDescription ?? error.localizedDescription
continuation?.resume(throwing: error as? UpdateError ?? UpdateError.download(message))
} else {
continuation?.resume()
}
// 协议要求实现此方法;临时文件由系统 async 返回值交给 run 统一移动。
}
}
@@ -29,7 +29,14 @@ struct UninstallerPage: View {
}
}
.padding(.leading, DS.Space.s3)
.onAppear { if uninstaller.apps.isEmpty { uninstaller.loadApps() } }
.onAppear { if model.isMainWindowVisible { uninstaller.resumeScanning() } }
.onDisappear { uninstaller.cancelScanning() }
.onChange(of: model.isMainWindowVisible) { _, visible in
if visible { uninstaller.resumeScanning() } else { uninstaller.cancelScanning() }
}
.onChange(of: uninstaller.isRemoving) { _, removing in
if !removing, model.isMainWindowVisible { uninstaller.resumeScanning() }
}
.onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
_ = provider.loadObject(ofClass: URL.self) { url, _ in
@@ -250,7 +257,7 @@ private struct AppDetailCard: View {
Spacer()
Button(uninstaller.isRemoving ? tr("正在移除…") : tr("卸载")) { confirm() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(running || uninstaller.isScanning || uninstaller.isRemoving)
.disabled(running || !uninstaller.canUninstall)
}
// 程序坞里的图标始终一并移除,不单独做开关:留着一个空图标没有意义
Text(tr("只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除"))
@@ -35,15 +35,18 @@ public final class NetworkController {
}
/// 仅用于界面转圈:有缓存可显示时不转圈,后台悄悄核对
public private(set) var isLookingUpPublic = false
/// 仅用于重入保护,与转圈状态相反——有缓存时也必须挡住并发查询,
/// 否则两个 Task 会各发三次请求、并发写缓存,失败结果可能覆盖好结果
@ObservationIgnored private var isFetchingPublic = false
/// 有缓存时不转圈,但仍用任务句柄挡住同一代的并发查询。
@ObservationIgnored private var publicLookupTask: Task<Void, Never>?
@ObservationIgnored private var publicLookupGeneration = UUID()
public private(set) var probes = History<ProbeSample>(capacity: probeCapacity)
public private(set) var processes: [NetworkProcessUsage] = []
/// 各进程流量的平滑排行,列表按它排序而不是按瞬时速率
@ObservationIgnored private var processRanking = ActivityRanking()
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let cacheDefaults: UserDefaults
@ObservationIgnored private let fetchPublicAddresses: @Sendable () async -> PublicAddresses
@ObservationIgnored private let fetchGeo: @Sendable (String) async -> PublicAddressLookup.GeoInfo?
@ObservationIgnored private var probeTask: Task<Void, Never>?
@ObservationIgnored private var detailTask: Task<Void, Never>?
@ObservationIgnored private var processSampler = NetworkProcessSampler()
@@ -84,9 +87,14 @@ public final class NetworkController {
return sysctlbyname("kern.boottime", &boot, &size, nil, 0) == 0 ? TimeInterval(boot.tv_sec) : nil
}()
init(settings: AppSettings) {
init(settings: AppSettings, cacheDefaults: UserDefaults = .standard,
fetchPublicAddresses: @escaping @Sendable () async -> PublicAddresses = { await PublicAddressLookup.fetch(includeGeo: false) },
fetchGeo: @escaping @Sendable (String) async -> PublicAddressLookup.GeoInfo? = { await PublicAddressLookup.geo(for: $0) }) {
self.settings = settings
if let data = UserDefaults.standard.data(forKey: Self.cacheKey),
self.cacheDefaults = cacheDefaults
self.fetchPublicAddresses = fetchPublicAddresses
self.fetchGeo = fetchGeo
if let data = cacheDefaults.data(forKey: Self.cacheKey),
let cache = try? JSONDecoder().decode(PublicCache.self, from: data), settings.publicIPLookup {
publicResults = Dictionary(uniqueKeysWithValues: cache.results.compactMap { key, value in IPFamily(rawValue: key).map { ($0, value) } })
}
@@ -161,6 +169,7 @@ public final class NetworkController {
func setPaused(_ paused: Bool) {
isPaused = paused
if paused { cancelPublicLookup() }
applyProbeState()
applyDetailState()
}
@@ -219,20 +228,28 @@ public final class NetworkController {
/// 因为它只查请求方自己):地址没变、结果不满 7 天的那一族沿用缓存,否则重新查并写回缓存。
/// `force` 为真时(用户点了刷新)两族都重查
func lookUpPublicAddresses(force: Bool = false) {
guard settings.publicIPLookup, !isFetchingPublic else { return }
isFetchingPublic = true
guard settings.publicIPLookup, !isPaused, publicLookupTask == nil else { return }
let generation = UUID()
publicLookupGeneration = generation
// 有缓存可显示时不转圈,后台悄悄核对
isLookingUpPublic = publicResults.isEmpty || force
let localIPv4 = details?.physical?.ipv4 ?? []
Task {
// 无论正常结束、抛出还是被取消都要放开重入锁,否则查询会永久卡死
defer { isFetchingPublic = false }
let base = await PublicAddressLookup.fetch(includeGeo: false)
let cached = Self.loadCache()
publicLookupTask = Task { [weak self] in
guard let self else { return }
defer {
// 旧查询结束不能清掉新查询的句柄或加载状态。
if self.publicLookupGeneration == generation {
self.publicLookupTask = nil
self.isLookingUpPublic = false
}
}
let base = await self.fetchPublicAddresses()
guard self.isCurrentPublicLookup(generation) else { return }
let cached = self.loadCache()
let now = Date()
@MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? {
guard let ip else { return nil }
guard self.isCurrentPublicLookup(generation), let ip else { return nil }
let previous = cached?.results[family.rawValue]
let sameAddress = previous.map { ($0.queriedAddress ?? (family == .v4 ? $0.ipv4 : $0.ipv6)) == ip } ?? false
// 上次什么都没查到的不算缓存,这次重查
@@ -242,7 +259,9 @@ public final class NetworkController {
}
var result = base
result.queriedAddress = ip
if let info = await PublicAddressLookup.geo(for: ip) {
let info = await self.fetchGeo(ip)
guard self.isCurrentPublicLookup(generation) else { return nil }
if let info {
result.apply(info)
// 分流代理下 cleanip.io 看到的出口可能与 Cloudflare 不同:纯净度是那个地址的,界面也显示那个地址
if let seen = info.ip, seen != ip {
@@ -259,6 +278,7 @@ public final class NetworkController {
var results: [IPFamily: PublicAddresses] = [:]
if let v4 = await v4 { results[.v4] = v4 }
if let v6 = await v6 { results[.v6] = v6 }
guard self.isCurrentPublicLookup(generation) else { return }
// 两份结果都带着两族地址,各族以自己那份结果为准;缓存里留下的另一族旧地址不算
let ipv4 = results[.v4]?.ipv4 ?? base.ipv4
let ipv6 = results[.v6]?.ipv6 ?? base.ipv6
@@ -279,7 +299,7 @@ public final class NetworkController {
let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city
if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now }
}
Self.saveCache(PublicCache(results: Dictionary(uniqueKeysWithValues: results.map { ($0.key.rawValue, $0.value) }),
self.saveCache(PublicCache(results: Dictionary(uniqueKeysWithValues: results.map { ($0.key.rawValue, $0.value) }),
geoDates: dates))
publicResults = results
if publicResults[publicFamily] == nil, let only = publicResults.keys.first { publicFamily = only }
@@ -288,12 +308,23 @@ public final class NetworkController {
}
}
private static func loadCache() -> PublicCache? {
UserDefaults.standard.data(forKey: cacheKey).flatMap { try? JSONDecoder().decode(PublicCache.self, from: $0) }
private func isCurrentPublicLookup(_ generation: UUID) -> Bool {
!Task.isCancelled && publicLookupGeneration == generation && settings.publicIPLookup && !isPaused
}
private static func saveCache(_ cache: PublicCache) {
UserDefaults.standard.set(try? JSONEncoder().encode(cache), forKey: cacheKey)
private func cancelPublicLookup() {
publicLookupGeneration = UUID()
publicLookupTask?.cancel()
publicLookupTask = nil
isLookingUpPublic = false
}
private func loadCache() -> PublicCache? {
cacheDefaults.data(forKey: Self.cacheKey).flatMap { try? JSONDecoder().decode(PublicCache.self, from: $0) }
}
private func saveCache(_ cache: PublicCache) {
cacheDefaults.set(try? JSONEncoder().encode(cache), forKey: Self.cacheKey)
}
/// 10 分钟内且本地地址未变化时沿用上次结果
@@ -305,9 +336,10 @@ public final class NetworkController {
}
func clearPublicAddresses() {
cancelPublicLookup()
publicResults = [:]
lastPublicLookup = nil
UserDefaults.standard.removeObject(forKey: Self.cacheKey)
cacheDefaults.removeObject(forKey: Self.cacheKey)
}
// MARK: 探测
@@ -22,8 +22,33 @@ public final class UninstallerController {
/// 有应用启动或退出时变化,让“正在运行”的提示跟着刷新
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 init() {
public convenience init() {
self.init(currentBundleIdentifier: Bundle.main.bundleIdentifier)
}
init(currentBundleIdentifier: String?,
listApplications: @escaping @Sendable () throws -> [InstalledApp] = { AppUninstaller.installedApps() },
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
@@ -35,23 +60,61 @@ public final class UninstallerController {
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 !isLoading else { return }
guard !isRemoving, applicationTask == nil else { return }
let generation = UUID()
applicationGeneration = generation
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
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 withThrowingTaskGroup(of: [InstalledApp].self) { group in
group.addTask(priority: .utility) { try Task.checkCancellation(); return try list() }
return try await group.next() ?? []
}
guard let self, !Task.isCancelled, self.applicationGeneration == generation else { return }
self.apps = apps.filter { (try? AppUninstaller.validate($0, currentBundleIdentifier: self.currentBundleIdentifier)) != nil }
// 列表先展示;完整计量结束前仍保持重入保护,关闭页面会取消当前子任务。
for app in self.apps where self.sizes[app.id] == nil {
try Task.checkCancellation()
let size = try await withThrowingTaskGroup(of: UInt64.self) { group in
group.addTask(priority: .utility) { try Task.checkCancellation(); return try measure(app.url) }
return try await group.next() ?? 0
}
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)
}
}
}
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 = []
@@ -59,15 +122,49 @@ public final class UninstallerController {
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
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 withThrowingTaskGroup(of: [AppLeftover].self) { group in
group.addTask(priority: .utility) { try Task.checkCancellation(); return try find(app) }
return try await group.next() ?? []
}
// 应用相同也可能是另一次扫描,不能用 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 {
@@ -75,7 +172,7 @@ public final class UninstallerController {
return
}
do {
try AppUninstaller.validate(app)
try AppUninstaller.validate(app, currentBundleIdentifier: currentBundleIdentifier)
select(app)
} catch {
outcome = ("\(error)", true)
@@ -94,14 +191,23 @@ public final class UninstallerController {
}
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, !isRemoving else { return }
guard let selected, canUninstall else { return }
pendingUninstall = selected
}
@@ -116,42 +222,59 @@ public final class UninstallerController {
}
private func uninstall() {
guard let app = selected, !isRemoving else { return }
guard let app = selected, canUninstall else { return }
do {
try AppUninstaller.validate(app)
try AppUninstaller.validate(app, currentBundleIdentifier: currentBundleIdentifier)
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
let items = leftovers.filter { chosen.contains($0.id) }
let urls = items.map(\.url)
// 回收开始后不能让仍在运行的列表扫描重新发布已经移除的应用。
cancelScanning()
isRemoving = true
outcome = nil
// NSWorkspace 负责需要管理员权限的情况(例如 root 拥有的应用),并能在废纸篓中“放回原处”
NSWorkspace.shared.recycle(urls) { [weak self] moved, error in
let movedCount = moved.count
let message = error?.localizedDescription
recycleFiles(urls) { [weak self] moved, message in
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 }
self.finishUninstall(app, items: items, moved: moved, errorMessage: message)
}
}
}
/// 回收可部分成功;只有本体确实移动后,才能移除列表和程序坞入口。
func finishUninstall(_ app: InstalledApp, items: [AppLeftover], moved: [URL: URL], errorMessage: String?) {
isRemoving = false
let movedItems = items.filter { moved[$0.url] != nil }
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)") } ?? ""
if moved[app.url] != nil {
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 { moved[$0.url] != nil }
chosen.subtract(movedItems.map(\.id))
}
}
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),实际移到废纸篓 \(movedItems.count) 项,本体已移动:\(moved[app.url] != nil)")
}
/// 修改程序坞偏好里的 persistent-apps 并重启程序坞
private static func removeDockTile(for url: URL) -> Bool {
let domain = "com.apple.dock" as CFString
@@ -202,6 +202,7 @@ public final class UpdateController {
self.phase = .downloading(fraction)
}
}
try Task.checkCancellation()
phase = .verifying
let candidate = try await Task.detached {
guard try UpdateInstaller.sha256(of: archive) == release.sha256.lowercased() else {
@@ -212,6 +213,8 @@ public final class UpdateController {
return candidate
}.value
// 替换开始前兑现取消请求;一旦开始替换,必须完整执行或回滚。
try Task.checkCancellation()
phase = .installing
if UpdateInstaller.canReplaceInPlace(app) {
try await Task.detached { try UpdateInstaller.replace(app, with: candidate, backupDirectory: work) }.value
@@ -0,0 +1,72 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import Updates
struct DownloadCancellationTests {
@Test func successfulDownloadMovesTemporaryFileAndRejectsHTTPFailure() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadResponse-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [DownloadResponseProtocol.self]
let destination = root.appendingPathComponent("update.zip")
try await Downloader(destination: destination, progress: { _ in })
.run(URL(string: "https://download.test/success")!, configuration: configuration)
#expect(try Data(contentsOf: destination) == Data("verified payload".utf8))
try FileManager.default.removeItem(at: destination)
await #expect(throws: UpdateError.self) {
try await Downloader(destination: destination, progress: { _ in })
.run(URL(string: "https://download.test/failure")!, configuration: configuration)
}
#expect(!FileManager.default.fileExists(atPath: destination.path))
}
@Test func cancelledBeforeStartingDoesNotCreateASessionTask() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadCancellation-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let task = Task {
withUnsafeCurrentTask { $0?.cancel() }
return try await UpdateInstaller.download(URL(string: "http://127.0.0.1:9/update.zip")!, into: root) { _ in }
}
await #expect(throws: CancellationError.self) { try await task.value }
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("update.zip").path))
}
@Test func cancellationRacingWithStartupAlwaysCompletes() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("DownloadStartup-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
for _ in 0..<30 {
let task = Task {
try await UpdateInstaller.download(URL(string: "http://127.0.0.1:9/update.zip")!, into: root) { _ in }
}
await Task.yield()
task.cancel()
do {
_ = try await task.value
Issue.record("已取消的下载不应成功")
} catch is CancellationError {
} catch let error as UpdateError {
// 本机关闭端口可在取消送达前先返回连接错误。
if case .download = error {} else { Issue.record("非预期错误:\(error)") }
}
}
}
}
private final class DownloadResponseProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let response = HTTPURLResponse(url: request.url!, statusCode: request.url!.path == "/success" ? 200 : 503,
httpVersion: "HTTP/1.1", headerFields: ["Content-Length": "16"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("verified payload".utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@@ -0,0 +1,139 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Metrics
import Testing
@testable import XStatsUI
@MainActor
struct PublicLookupLifecycleTests {
@Test func disablingLookupPreventsFollowUpRequestsAndCacheWrite() async throws {
let domain = "PublicLookupLifecycle.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let settings = AppSettings(defaults: defaults)
settings.publicIPLookup = true
let gate = PublicLookupGate()
let controller = NetworkController(settings: settings, cacheDefaults: defaults,
fetchPublicAddresses: { await gate.fetch() },
fetchGeo: { _ in await gate.countGeo(); return nil })
controller.lookUpPublicAddresses()
try await waitForRequests(gate, count: 1)
settings.publicIPLookup = false
controller.clearPublicAddresses()
await gate.finish(0, country: "US")
try await Task.sleep(for: .milliseconds(30))
#expect(await gate.geoCalls == 0)
#expect(controller.publicResults.isEmpty && !controller.isLookingUpPublic)
#expect(defaults.data(forKey: "publicAddressCache4") == nil)
}
@Test func obsoleteCompletionCannotOverwriteNewQueryOrItsLoadingState() async throws {
let domain = "PublicLookupLifecycle.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let settings = AppSettings(defaults: defaults)
settings.publicIPLookup = true
let gate = PublicLookupGate()
let controller = NetworkController(settings: settings, cacheDefaults: defaults,
fetchPublicAddresses: { await gate.fetch() }, fetchGeo: { _ in nil })
controller.lookUpPublicAddresses()
try await waitForRequests(gate, count: 1)
controller.clearPublicAddresses()
controller.lookUpPublicAddresses(force: true)
try await waitForRequests(gate, count: 2)
// 旧请求先回来,也不能清掉正在执行的新请求的加载状态。
await gate.finish(0, country: "US")
try await Task.sleep(for: .milliseconds(30))
#expect(controller.isLookingUpPublic && controller.publicResults.isEmpty)
await gate.finish(1, country: "JP")
let deadline = ContinuousClock.now + .seconds(30)
while controller.isLookingUpPublic, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(controller.publicAddresses?.countryCode == "JP")
#expect(controller.publicAddresses?.ipv4 == "198.51.100.2")
let data = try #require(defaults.data(forKey: "publicAddressCache4"))
let cache = String(decoding: data, as: UTF8.self)
#expect(cache.contains("JP") && !cache.contains("US"))
}
@Test func pausingCancelsPendingLookupAndStopsNewQueries() async throws {
let domain = "PublicLookupLifecycle.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let settings = AppSettings(defaults: defaults)
settings.publicIPLookup = true
let gate = PublicLookupGate()
let controller = NetworkController(settings: settings, cacheDefaults: defaults,
fetchPublicAddresses: { await gate.fetch() }, fetchGeo: { _ in nil })
controller.lookUpPublicAddresses()
try await waitForRequests(gate, count: 1)
controller.setPaused(true)
controller.lookUpPublicAddresses(force: true)
await gate.finish(0, country: "US")
try await Task.sleep(for: .milliseconds(30))
#expect(await gate.requests == 1)
#expect(controller.publicResults.isEmpty && !controller.isLookingUpPublic)
#expect(defaults.data(forKey: "publicAddressCache4") == nil)
}
@Test func disablingDuringGeoLookupRejectsLateGeoCompletion() async throws {
let domain = "PublicLookupLifecycle.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let settings = AppSettings(defaults: defaults)
settings.publicIPLookup = true
let gate = GeoLookupGate()
let controller = NetworkController(settings: settings, cacheDefaults: defaults,
fetchPublicAddresses: { PublicAddresses(ipv4: "198.51.100.9", countryCode: "US") },
fetchGeo: { _ in await gate.lookup() })
defer { controller.clearPublicAddresses(); Task { await gate.finish() } }
controller.lookUpPublicAddresses(force: true)
let deadline = ContinuousClock.now + .seconds(30)
while !(await gate.started), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(await gate.started)
settings.publicIPLookup = false
controller.clearPublicAddresses()
await gate.finish()
try await Task.sleep(for: .milliseconds(30))
#expect(controller.publicResults.isEmpty && !controller.isLookingUpPublic)
#expect(defaults.data(forKey: "publicAddressCache4") == nil)
}
private func waitForRequests(_ gate: PublicLookupGate, count: Int) async throws {
let deadline = ContinuousClock.now + .seconds(30)
while await gate.requests < count, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(await gate.requests == count)
}
}
private actor GeoLookupGate {
private var continuation: CheckedContinuation<Void, Never>?
private(set) var started = false
func lookup() async -> PublicAddressLookup.GeoInfo? {
started = true
await withCheckedContinuation { continuation = $0 }
return nil
}
func finish() {
continuation?.resume()
continuation = nil
}
}
/// 故意不响应取消的服务替身,验证即使迟到回调到达,也不能发布过期状态。
private actor PublicLookupGate {
private var continuations: [Int: CheckedContinuation<PublicAddresses, Never>] = [:]
private(set) var requests = 0
private(set) var geoCalls = 0
func fetch() async -> PublicAddresses {
let index = requests
requests += 1
return await withCheckedContinuation { continuations[index] = $0 }
}
func finish(_ index: Int, country: String) {
continuations.removeValue(forKey: index)?.resume(returning:
PublicAddresses(ipv4: "198.51.100.\(index + 1)", countryCode: country))
}
func countGeo() { geoCalls += 1 }
}
@@ -1,7 +1,7 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Cleaner
@testable import Cleaner
import Foundation
import Testing
@testable import XStatsUI
@@ -13,10 +13,15 @@ struct UninstallerConfirmationTests {
bundleIdentifier: "test.\(name)", version: nil, teamIdentifier: nil)
}
@Test func cancelKeepsSelectedAppWithoutRemovingIt() {
let controller = UninstallerController()
@Test func cancelKeepsSelectedAppWithoutRemovingIt() async throws {
let controller = UninstallerController(currentBundleIdentifier: nil, findLeftovers: {
[AppLeftover(url: $0.url, kind: .application, size: 100)]
})
let target = app("ConfirmationTestA")
controller.select(target)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(controller.canUninstall)
controller.requestUninstall()
#expect(controller.pendingUninstall == target)
@@ -28,11 +33,16 @@ struct UninstallerConfirmationTests {
#expect(!controller.isRemoving)
}
@Test func switchingAppInvalidatesOldConfirmation() {
let controller = UninstallerController()
@Test func switchingAppInvalidatesOldConfirmation() async throws {
let controller = UninstallerController(currentBundleIdentifier: nil, findLeftovers: {
[AppLeftover(url: $0.url, kind: .application, size: 100)]
})
let first = app("ConfirmationTestA")
let second = app("ConfirmationTestB")
controller.select(first)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(controller.canUninstall)
controller.requestUninstall()
controller.select(second)
controller.confirmUninstall()
@@ -27,8 +27,9 @@ struct UninstallerRunningApplicationTests {
version: nil, teamIdentifier: nil)
controller.select(target)
// openApplication 返回与运行列表更新不是同一个时刻。
let launchDeadline = ContinuousClock.now + .seconds(5)
while ContinuousClock.now < launchDeadline, !controller.isRunning(target) {
// 全套 UI 测试可长时间占用主线程,不能把调度等待误判成启动失败。
let launchDeadline = ContinuousClock.now + .seconds(30)
while ContinuousClock.now < launchDeadline, !running.isFinishedLaunching || !controller.isRunning(target) {
try await Task.sleep(for: .milliseconds(20))
}
try #require(controller.isRunning(target))
@@ -49,7 +50,7 @@ struct UninstallerRunningApplicationTests {
}
controller.quit(target)
let deadline = ContinuousClock.now + .seconds(5)
let deadline = ContinuousClock.now + .seconds(30)
while ContinuousClock.now < deadline, !running.isTerminated || !change.received {
try await Task.sleep(for: .milliseconds(20))
}
@@ -87,6 +88,16 @@ struct UninstallerRunningApplicationTests {
try compiler.run()
compiler.waitUntilExit()
try #require(compiler.terminationStatus == 0)
// 绑定完整 App 的 Bundle ID,避免仅签名 Mach-O 时 LaunchServices 使用临时可执行文件身份。
let signer = Process()
signer.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
signer.arguments = ["--force", "--sign", "-", app.path]
signer.standardInput = FileHandle.nullDevice
signer.standardOutput = FileHandle.nullDevice
signer.standardError = FileHandle.standardError
try signer.run()
signer.waitUntilExit()
try #require(signer.terminationStatus == 0)
return app
}
}
@@ -0,0 +1,98 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Metrics
import Testing
@testable import Cleaner
@testable import XStatsUI
@MainActor
struct UninstallerSafetyTests {
private func target(_ name: String = UUID().uuidString, identifier: String = "test.uninstall.target") -> InstalledApp {
InstalledApp(url: URL(fileURLWithPath: "/Applications/Audit-\(name).app"), name: name,
bundleIdentifier: identifier, version: nil, teamIdentifier: nil)
}
@Test(arguments: [false, true])
func currentApplicationAndItsCopiesAreRejectedByEveryEntry(uppercase: Bool) {
let own = "test.uninstall.current"
let app = target(identifier: uppercase ? own.uppercased() : own)
#expect(throws: AppUninstallError.currentApp) {
try AppUninstaller.validate(app, currentBundleIdentifier: own)
}
let controller = UninstallerController(currentBundleIdentifier: own)
controller.select(app)
#expect(controller.selected == nil && !controller.isScanning)
controller.quit(app)
#expect(controller.outcome?.isError == true)
#expect(controller.outcome?.text == AppUninstallError.currentApp.description)
#expect(controller.pendingUninstall == nil)
}
@Test func movingOnlyResidueKeepsTheApplicationAndReportsActualBytes() async throws {
let app = target()
let body = AppLeftover(url: app.url, kind: .application, size: 1_000_000)
let cache = AppLeftover(url: URL(fileURLWithPath: "/tmp/audit-cache"), kind: .caches, size: 1_000)
let controller = UninstallerController(currentBundleIdentifier: "test.uninstall.current", findLeftovers: { _ in [body, cache] })
controller.select(app)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(controller.canUninstall)
controller.finishUninstall(app, items: [body, cache],
moved: [cache.url: URL(fileURLWithPath: "/tmp/trash-cache")], errorMessage: "permission denied")
#expect(controller.selected == app)
#expect(controller.leftovers == [body])
#expect(controller.chosen == [body.id] && controller.canUninstall)
#expect(controller.outcome?.isError == true)
#expect(controller.outcome?.text.contains("应用本体未能移动") == true)
#expect(controller.outcome?.text.contains(Format.bytes(cache.size, base: .decimal)) == true)
#expect(controller.outcome?.text.contains(Format.bytes(body.size + cache.size, base: .decimal)) == false)
#expect(controller.outcome?.text.contains("permission denied") == true)
#expect(!controller.isRemoving)
}
@Test func failedRecycleKeepsSelectionAndSuccessfulBodyClearsIt() async throws {
let app = target()
let body = AppLeftover(url: app.url, kind: .application, size: 1000)
let controller = UninstallerController(currentBundleIdentifier: "test.uninstall.current", findLeftovers: { _ in [body] })
controller.select(app)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(controller.canUninstall)
controller.finishUninstall(app, items: [body], moved: [:], errorMessage: "denied")
#expect(controller.selected == app && controller.outcome?.isError == true)
controller.finishUninstall(app, items: [body], moved: [app.url: URL(fileURLWithPath: "/tmp/trash-app")], errorMessage: nil)
#expect(controller.selected == nil && controller.outcome?.isError == false)
}
@Test func removalFreezesSelectionAndBlocksNewScans() async throws {
let app = target()
let body = AppLeftover(url: app.url, kind: .application, size: 1000)
let transaction = RecycleTransaction()
let controller = UninstallerController(currentBundleIdentifier: nil,
listApplications: { Issue.record("回收期间不能启动列表扫描"); return [] },
findLeftovers: { _ in [body] },
recycleFiles: { urls, completion in transaction.urls = urls; transaction.completion = completion })
controller.select(app)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(controller.canUninstall)
controller.requestUninstall()
controller.confirmUninstall()
try #require(controller.isRemoving)
#expect(transaction.urls == [app.url])
controller.select(target())
controller.loadApps()
#expect(controller.selected == app && !controller.isLoading && !controller.isScanning)
transaction.completion?([app.url: URL(fileURLWithPath: "/tmp/trash-app")], nil)
while controller.isRemoving, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(!controller.isRemoving && controller.selected == nil)
}
}
@MainActor
private final class RecycleTransaction {
var urls: [URL] = []
var completion: (@Sendable ([URL: URL], String?) -> Void)?
}
@@ -0,0 +1,121 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import Cleaner
@testable import XStatsUI
@MainActor
struct UninstallerScanLifecycleTests {
private func app() -> InstalledApp {
InstalledApp(url: URL(fileURLWithPath: "/Applications/Scan-\(UUID()).app"), name: "ScanFixture",
bundleIdentifier: "test.scan.fixture", version: nil, teamIdentifier: nil)
}
@Test func obsoleteScanOfSameApplicationCannotOverwriteNewChoices() async throws {
let target = app()
let gate = ScanGate()
let body = AppLeftover(url: target.url, kind: .application, size: 100)
let cache = AppLeftover(url: URL(fileURLWithPath: "/tmp/scan-cache"), kind: .caches, size: 100)
let controller = UninstallerController(currentBundleIdentifier: nil, findLeftovers: { _ in
if gate.increment() == 1 {
gate.markStarted()
_ = gate.release.wait(timeout: .now() + 30)
gate.markFinished()
}
return [body, cache]
})
defer { gate.release.signal(); controller.cancelScanning() }
controller.select(target)
let started = try await gate.waitUntilStarted()
try #require(started)
controller.select(target)
let deadline = ContinuousClock.now + .seconds(30)
while controller.isScanning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(!controller.isScanning)
controller.toggle(cache)
#expect(!controller.chosen.contains(cache.id))
gate.release.signal()
while !gate.finished, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
try #require(gate.finished)
try await Task.sleep(for: .milliseconds(50))
#expect(controller.selected == target)
#expect(controller.chosen == [body.id])
}
@Test func sizeCalculationIsSingleAndStopsWhenPageLeaves() async throws {
let target = app()
let listCalls = ScanGate()
let measureCalls = ScanGate()
let controller = UninstallerController(currentBundleIdentifier: nil,
listApplications: { _ = listCalls.increment(); return [target] },
measureSize: { _ in
if measureCalls.increment() == 1 {
measureCalls.markStarted()
while !Task.isCancelled { Thread.sleep(forTimeInterval: 0.005) }
measureCalls.markFinished()
throw CancellationError()
}
return 321
})
defer { controller.cancelScanning() }
controller.loadApps()
let started = try await measureCalls.waitUntilStarted()
try #require(started)
#expect(controller.isLoading)
controller.loadApps()
#expect(listCalls.calls == 1)
controller.cancelScanning()
#expect(!controller.isLoading && !controller.isScanning)
let deadline = ContinuousClock.now + .seconds(30)
while !measureCalls.finished, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(measureCalls.finished)
#expect(controller.sizes[target.id] == nil)
controller.resumeScanning()
while controller.isLoading, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(!controller.isLoading)
#expect(controller.sizes[target.id] == 321)
#expect(listCalls.calls == 2 && measureCalls.calls == 2)
}
@Test func clearingSelectionCancelsWorkAndResetsScanningState() async throws {
let gate = ScanGate()
let controller = UninstallerController(currentBundleIdentifier: nil, findLeftovers: { _ in
gate.markStarted()
while !Task.isCancelled { Thread.sleep(forTimeInterval: 0.005) }
gate.markFinished()
throw CancellationError()
})
defer { controller.cancelScanning() }
controller.select(app())
let started = try await gate.waitUntilStarted()
try #require(started)
controller.select(nil)
#expect(controller.selected == nil && !controller.isScanning)
let deadline = ContinuousClock.now + .seconds(30)
while !gate.finished, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(gate.finished && controller.leftovers.isEmpty)
}
}
/// 同步扫描边界的测试闸门;可变计数与完成状态均由锁保护。
private final class ScanGate: @unchecked Sendable {
let release = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var count = 0
private var done = false
private var started = false
var calls: Int { lock.withLock { count } }
var finished: Bool { lock.withLock { done } }
func increment() -> Int { lock.withLock { count += 1; return count } }
func markFinished() { lock.withLock { done = true } }
func markStarted() { lock.withLock { started = true } }
func waitUntilStarted() async throws -> Bool {
let deadline = ContinuousClock.now + .seconds(30)
while !lock.withLock({ started }), ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
}
return lock.withLock { started }
}
}