fix(lifecycle): 修复更新取消与卸载、查询生命周期
This commit is contained in:
1 parent
3196ebef83
commit
7e2024eedf
22 files changed
+784
-129
No files matched your search
@@ -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 نفسه أو إنهاؤه
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -1620,3 +1620,5 @@ GitHub 项目 GitHub プロジェクト
|
||||
活动监视器 アクティビティモニタ
|
||||
打开系统活动监视器 システムのアクティビティモニタを開く
|
||||
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 プロセス機能がオンの場合は XStats のプロセスページを、オフの場合はシステムのアクティビティモニタを開きます。
|
||||
不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。
|
||||
|
@@ -1620,3 +1620,5 @@ GitHub 项目 GitHub 프로젝트
|
||||
活动监视器 활성 상태 보기
|
||||
打开系统活动监视器 시스템 활성 상태 보기 열기
|
||||
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 프로세스 기능이 켜져 있으면 XStats 프로세스 페이지를, 꺼져 있으면 시스템 활성 상태 보기를 엽니다.
|
||||
不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요.
|
||||
|
@@ -1621,3 +1621,5 @@ GitHub 项目 GitHub 專案
|
||||
活动监视器 活動監視器
|
||||
打开系统活动监视器 開啟系統活動監視器
|
||||
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 程序功能開啟時開啟 XStats 程序頁,關閉時開啟系統活動監視器。
|
||||
不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。
|
||||
|
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user