diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift index f5e350c..231eb1f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/UninstallerPage.swift @@ -120,7 +120,7 @@ private struct AppListColumn: View { TextField(tr("搜索应用"), text: $search) .textFieldStyle(.plain) .dsFont(.sm) - IconButton(systemName: "arrow.clockwise", help: tr("重新扫描")) { uninstaller.loadApps() } + IconButton(systemName: "arrow.clockwise", help: tr("重新扫描")) { uninstaller.reloadApps() } } .padding(.leading, DS.Space.s2) .frame(height: DS.Size.controlHeight) @@ -231,7 +231,7 @@ private struct AppDetailCard: View { ForEach(items) { item in HStack(spacing: DS.Space.s2) { DSCheckbox(isOn: uninstaller.chosen.contains(item.id)) { uninstaller.toggle(item) } - .disabled(item.kind == .application) + .disabled(item.kind == .application || uninstaller.isRemoving) Text(verbatim: item.url.path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) .dsFont(.xs) .foregroundStyle(DS.Palette.textPrimary) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift index 4142fa9..99060a0 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift @@ -19,6 +19,7 @@ final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate { var onNoUpdate: () -> Void = {} var onCycleFinished: (Bool) -> Void = { _ in } var legacySkippedVersion: () -> String? = { nil } + var hasKnownRelease: () -> Bool = { false } var onLegacySkipMigrated: () -> Void = {} // 控制器强持有 updater,Sparkle 强持有 userDriver;此处只能弱引用,避免循环持有。 @@ -194,7 +195,9 @@ final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate { } else if let error, !cancelled { let failure = error as NSError if failure.domain != SUSparkleErrorDomain || failure.code != SUError.noUpdateError.rawValue { - onPhase(userInitiated || action != nil ? .failed(error.localizedDescription) : .idle) + // 后台检查失败不撤销此前已发现的新版提示。 + if userInitiated || action != nil { onPhase(.failed(error.localizedDescription)) } + else { onPhase(hasKnownRelease() ? .available : .idle) } } } // 结束后必须保留本轮最后检查的地址。Sparkle 的配置 reset 会把 URL 变化 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift index f9a9fea..8a26491 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift @@ -86,7 +86,8 @@ public final class UninstallerController { } guard let self, !Task.isCancelled, self.applicationGeneration == generation else { return } self.apps = apps.filter { (try? AppUninstaller.validate($0, currentBundleIdentifier: self.currentBundleIdentifier)) != nil } - // 列表先展示;完整计量结束前仍保持重入保护,关闭页面会取消当前子任务。 + self.isLoading = false + // 列表先展示;完整计量结束前仍保持重入保护,显式刷新或关闭页面会取消当前子任务。 for app in self.apps where self.sizes[app.id] == nil { try Task.checkCancellation() let size = try await withThrowingTaskGroup(of: UInt64.self) { group in @@ -103,6 +104,14 @@ public final class UninstallerController { } } + /// 用户显式刷新:中断进行中的列举或计量后重新列举,已算出的大小保留。 + func reloadApps() { + guard !isRemoving else { return } + applicationTask?.cancel() + applicationTask = nil + loadApps() + } + func select(_ app: InstalledApp?) { guard !isRemoving else { return } if let app, currentBundleIdentifier?.caseInsensitiveCompare(app.bundleIdentifier) == .orderedSame { @@ -180,8 +189,8 @@ public final class UninstallerController { } func toggle(_ leftover: AppLeftover) { - // 应用本体必须一起移除,否则没有意义 - guard leftover.kind != .application else { return } + // 应用本体必须一起移除,否则没有意义;回收期间固定目标 + guard !isRemoving, leftover.kind != .application else { return } if chosen.contains(leftover.id) { chosen.remove(leftover.id) } else { chosen.insert(leftover.id) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index 373a352..b778aac 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -110,6 +110,7 @@ public final class UpdateController { } } driver.legacySkippedVersion = { [weak self] in self?.skippedVersion } + driver.hasKnownRelease = { [weak self] in self?.release != nil } driver.onLegacySkipMigrated = { [weak self] in self?.skippedVersion = nil } driver.onCycleFinished = { [weak self] success in self?.applySchedule(retry: !success) } sparkleInstaller = driver diff --git a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift index f920a0a..a4a2147 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift @@ -168,6 +168,19 @@ struct SparkleInstallerTests { #expect(phases == [.idle]) } + @Test(arguments: [true, false]) + func backgroundFailureKeepsPreviouslyFoundRelease(known: Bool) { + var phases: [UpdateController.Phase] = [] + let driver = SparkleInstaller(endpoints: [URL(string: "https://example.test/appcast.xml")!], + onPhase: { phases.append($0) }, onRelaunch: {}) + driver.hasKnownRelease = { known } + let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver) + driver.check(userInitiated: false) + let offline = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) + driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline) + #expect(phases.last == (known ? .available : .idle)) + } + @Test func calendarMonthLaunchOnlyAndFailureRetryIntervals() throws { let calendar = Calendar.current let jan = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 31, hour: 12))) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift index 3fc9940..7fc1b41 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift @@ -68,10 +68,11 @@ struct UninstallerSafetyTests { @Test func removalFreezesSelectionAndBlocksNewScans() async throws { let app = target() let body = AppLeftover(url: app.url, kind: .application, size: 1000) + let cache = AppLeftover(url: URL(fileURLWithPath: "/tmp/frozen-cache"), kind: .caches, size: 10) let transaction = RecycleTransaction() let controller = UninstallerController(currentBundleIdentifier: nil, listApplications: { Issue.record("回收期间不能启动列表扫描"); return [] }, - findLeftovers: { _ in [body] }, + findLeftovers: { _ in [body, cache] }, recycleFiles: { urls, completion in transaction.urls = urls; transaction.completion = completion }) controller.select(app) let deadline = ContinuousClock.now + .seconds(30) @@ -80,10 +81,13 @@ struct UninstallerSafetyTests { controller.requestUninstall() controller.confirmUninstall() try #require(controller.isRemoving) - #expect(transaction.urls == [app.url]) + #expect(Set(transaction.urls) == [app.url, cache.url]) controller.select(target()) controller.loadApps() + controller.reloadApps() + controller.toggle(cache) #expect(controller.selected == app && !controller.isLoading && !controller.isScanning) + #expect(controller.chosen == [body.id, cache.id], "回收期间勾选必须固定") 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) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift index e6ca28b..d193c3f 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerScanLifecycleTests.swift @@ -63,7 +63,8 @@ struct UninstallerScanLifecycleTests { controller.loadApps() let started = try await measureCalls.waitUntilStarted() try #require(started) - #expect(controller.isLoading) + // 列表发布后即结束"正在扫描",大小计量在后台继续。 + #expect(!controller.isLoading && controller.apps == [target]) controller.loadApps() #expect(listCalls.calls == 1) controller.cancelScanning() @@ -73,12 +74,43 @@ struct UninstallerScanLifecycleTests { #expect(measureCalls.finished) #expect(controller.sizes[target.id] == nil) controller.resumeScanning() - while controller.isLoading, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } + while controller.sizes[target.id] == nil, 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 explicitReloadRelistsDuringMeasurementAndKeepsKnownSizes() async throws { + let measured = app() + let slow = app() + let listCalls = ScanGate() + let slowCalls = ScanGate() + let measuredCalls = ScanGate() + let controller = UninstallerController(currentBundleIdentifier: nil, + listApplications: { _ = listCalls.increment(); return [measured, slow] }, + measureSize: { url in + if url == measured.url { _ = measuredCalls.increment(); return 100 } + if slowCalls.increment() == 1 { + slowCalls.markStarted() + while !Task.isCancelled { Thread.sleep(forTimeInterval: 0.005) } + slowCalls.markFinished() + throw CancellationError() + } + return 200 + }) + defer { controller.cancelScanning() } + controller.loadApps() + let started = try await slowCalls.waitUntilStarted() + try #require(started) + controller.reloadApps() + let deadline = ContinuousClock.now + .seconds(30) + while controller.sizes[slow.id] == nil, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } + #expect(listCalls.calls == 2, "计量未完成时显式刷新也必须重新列举") + #expect(slowCalls.finished, "被中断的计量必须响应取消") + #expect(controller.sizes[measured.id] == 100 && controller.sizes[slow.id] == 200) + #expect(measuredCalls.calls == 1, "已算出的大小不应重复计量") + } + @Test func clearingSelectionCancelsWorkAndResetsScanningState() async throws { let gate = ScanGate() let controller = UninstallerController(currentBundleIdentifier: nil, findLeftovers: { _ in