fix(uninstaller,updates): 列表发布即结束扫描态并保留后台已发现的新版

- 列表发布后立即结束"正在扫描应用",计量在后台继续;新增 reloadApps 供显式刷新中断计量重新列举,已算出的大小保留
- 回收期间禁止勾选残留项,界面复选框同步禁用
- 后台检查失败时若已发现新版则保持"有更新",不再退回空闲

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysicingandClaude Opus 5.5 authored and 缘生 committed 2026-09-30 15:20:08 +08:00
1 parent 036a4a6598
commit 3dd5aa3e6c
7 files changed
+72 -10

No files matched your search

@@ -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)
@@ -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 变化
@@ -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) }
}
@@ -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
@@ -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)))
@@ -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)
@@ -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