diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 98986c6..921d508 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift index 04d5a56..0530cfc 100644 --- a/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift +++ b/Packages/XStatsKit/Sources/Cleaner/AppUninstaller.swift @@ -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() - 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 } diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift index cd9bff5..6485719 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift @@ -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] diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index c001292..66c8989 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1620,3 +1620,5 @@ GitHub 项目 مشروع GitHub 活动监视器 مراقب النشاط 打开系统活动监视器 فتح مراقب نشاط النظام 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 يفتح صفحة عمليات XStats عند تفعيل الميزة، أو مراقب نشاط النظام عند إيقافها. +不能在 XStats 内卸载或退出 XStats 自身 لا يمكن لهذه الأداة إلغاء تثبيت XStats نفسه أو إنهاؤه +应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index e8155c4..a20388d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 3ecfbd1..0d04de7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 852ebd6..34d5c59 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index eb9158d..01cbbb7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1620,3 +1620,5 @@ GitHub 项目 GitHub プロジェクト 活动监视器 アクティビティモニタ 打开系统活动监视器 システムのアクティビティモニタを開く 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 プロセス機能がオンの場合は XStats のプロセスページを、オフの場合はシステムのアクティビティモニタを開きます。 +不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません +应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 6c86028..b49d6e6 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1620,3 +1620,5 @@ GitHub 项目 GitHub 프로젝트 活动监视器 활성 상태 보기 打开系统活动监视器 시스템 활성 상태 보기 열기 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 프로세스 기능이 켜져 있으면 XStats 프로세스 페이지를, 꺼져 있으면 시스템 활성 상태 보기를 엽니다. +不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다 +应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 86c8a3e..24b6410 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1621,3 +1621,5 @@ GitHub 项目 GitHub 專案 活动监视器 活動監視器 打开系统活动监视器 開啟系統活動監視器 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 程序功能開啟時開啟 XStats 程序頁,關閉時開啟系統活動監視器。 +不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身 +应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index d987b4c..b8180aa 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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. """# } diff --git a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift index 9e0f8e9..3d6f822 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift @@ -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? - 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) 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? 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 统一移动。 } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift index 0236ec7..f5e350c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift @@ -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 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除")) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift index b709c95..787961e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/NetworkController.swift @@ -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? + @ObservationIgnored private var publicLookupGeneration = UUID() public private(set) var probes = History(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? @ObservationIgnored private var detailTask: Task? @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: 探测 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift index aee6a45..1ca828e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift @@ -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? + @ObservationIgnored private var selectionTask: Task? + @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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index e8dcc21..26e693f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -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 diff --git a/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift b/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift new file mode 100644 index 0000000..35ccb48 --- /dev/null +++ b/Packages/XStatsKit/Tests/UpdatesTests/DownloadCancellationTests.swift @@ -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() {} +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/PublicLookupLifecycleTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/PublicLookupLifecycleTests.swift new file mode 100644 index 0000000..a1f937c --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/PublicLookupLifecycleTests.swift @@ -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? + 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] = [:] + 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 } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerConfirmationTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerConfirmationTests.swift index 93839f6..5b9ded5 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerConfirmationTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerConfirmationTests.swift @@ -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() diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift index a67593f..a821d8d 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerRunningApplicationTests.swift @@ -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 } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift new file mode 100644 index 0000000..5d50a6d --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift @@ -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)? +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift new file mode 100644 index 0000000..e6ca28b --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift @@ -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 } + } +}