fix(uninstaller,updates): 列表发布即结束扫描态并保留后台已发现的新版
- 列表发布后立即结束"正在扫描应用",计量在后台继续;新增 reloadApps 供显式刷新中断计量重新列举,已算出的大小保留 - 回收期间禁止勾选残留项,界面复选框同步禁用 - 后台检查失败时若已发现新版则保持"有更新",不再退回空闲 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user