fix: 收尾 PR 审查遗留的更新、卸载与签名问题

- 跳过已撤回的版本时不再显示"有更新";安装前发现更新源换了版本时给出明确提示(补齐 7 种语言)
- 补充双区域回退与失败重排的调度测试
- 回收结果按标准化路径比对,避免路径写法不同误报本体未移动
- 抽出 scan 统一后台遍历与取消,loadApps 嵌套降到 3 层
- 自我保护测试只校验错误语义,不再比较两次翻译
- sign_sparkle.sh 在证书不在钥匙串有效列表时提前报错,避免子组件漏签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysicingandClaude Opus 5.5 committed 2026-09-30 16:59:02 +08:00
1 parent 7e0a22e132
commit f1dd8cfacf
14 files changed
+102 -20

No files matched your search

@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
版本 {}{} الإصدار {}{}
版本是 {},清单写的是 {} الإصدار {} لكن البيان يذكر {}
版本清单格式不正确 بيان إصدار غير صالح
更新源已发布其他版本,请重新检查更新后再安装 تم نشر إصدار مختلف. تحقق من التحديثات مرة أخرى قبل التثبيت.
物理地址 عنوان الجهاز
状态 الحالة
状态圆点 نقطة الحالة
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py
版本 {}{} Version {}{}
版本是 {},清单写的是 {} Version ist {}, im Manifest steht jedoch {}
版本清单格式不正确 Ungültiges Versionsmanifest
更新源已发布其他版本,请重新检查更新后再安装 Es wurde eine andere Version veröffentlicht. Suche vor der Installation erneut nach Updates.
物理地址 Hardwareadresse
状态 Status
状态圆点 Statuspunkt
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes
版本 {}{} Versión {}{}
版本是 {},清单写的是 {} La versión es {}, pero el manifiesto indica {}
版本清单格式不正确 Manifiesto de versión no válido
更新源已发布其他版本,请重新检查更新后再安装 Se ha publicado otra versión. Vuelve a buscar actualizaciones antes de instalar.
物理地址 Dirección de hardware
状态 Estado
状态圆点 Punto de estado
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P
版本 {}{} Version {}{}
版本是 {},清单写的是 {} La version est {}, mais le manifeste indique {}
版本清单格式不正确 Manifeste de version incorrect
更新源已发布其他版本,请重新检查更新后再安装 Une autre version a été publiée. Recherchez à nouveau les mises à jour avant l’installation.
物理地址 Adresse matérielle
状态 État
状态圆点 Point d’état
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
版本 {}{} バージョン {}{}
版本是 {},清单写的是 {} バージョンは{}ですが、一覧には{}と記載されています
版本清单格式不正确 バージョン一覧の形式が正しくありません
更新源已发布其他版本,请重新检查更新后再安装 別のバージョンが公開されました。インストールする前にもう一度アップデートを確認してください。
物理地址 ハードウェアアドレス
状态 状態
状态圆点 状態ドット
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
版本 {}{} 버전 {}{}
版本是 {},清单写的是 {} 버전은 {}이지만 목록에는 {}(으)로 표시되어 있습니다
版本清单格式不正确 버전 목록 형식이 올바르지 않습니다
更新源已发布其他版本,请重新检查更新后再安装 다른 버전이 게시되었습니다. 설치하기 전에 업데이트를 다시 확인하세요.
物理地址 하드웨어 주소
状态 상태
状态圆点 상태 점
1 # Copyright (C) 2026 ysicing
912 版本 {}{}
913 版本是 {},清单写的是 {}
914 版本清单格式不正确
915 更新源已发布其他版本,请重新检查更新后再安装
916 物理地址
917 状态
918 状态圆点
@@ -913,6 +913,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
版本 {}{} 版本 {}{}
版本是 {},清单写的是 {} 版本是 {},清單寫的是 {}
版本清单格式不正确 版本清單格式不正確
更新源已发布其他版本,请重新检查更新后再安装 更新來源已發布其他版本,請重新檢查更新後再安裝
物理地址 物理地址
状态 狀態
状态圆点 狀態圓點
1 # Copyright (c) 2026 GiantAccel, LLC
913 版本 {}{}
914 版本是 {},清单写的是 {}
915 版本清单格式不正确
916 更新源已发布其他版本,请重新检查更新后再安装
917 物理地址
918 状态
919 状态圆点
@@ -1043,6 +1043,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and
版本 {}{} Version {}{}
版本是 {},清单写的是 {} Version is {}, but the manifest says {}
版本清单格式不正确 Invalid version manifest
更新源已发布其他版本,请重新检查更新后再安装 A different version has been published. Check for updates again before installing.
物理地址 Hardware address
状态 Status
状态圆点 Status Dot
@@ -10,12 +10,15 @@ public enum UpdateError: Error, LocalizedError, Equatable {
case download(String)
case invalidBundle(String)
case install(String)
/// 确认后到开始安装之间,更新源已换成另一个版本。
case releaseChanged
public var errorDescription: String? {
switch self {
case .download(let reason): tr("下载失败:\(reason)")
case .invalidBundle(let reason): tr("安装包内容不正确:\(reason)")
case .install(let reason): tr("替换应用失败:\(reason)")
case .releaseChanged: tr("更新源已发布其他版本,请重新检查更新后再安装")
}
}
}
@@ -147,7 +147,7 @@ final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate {
guard candidate.build == expected.build, candidate.version == expected.version,
candidate.url == expected.url, candidate.size == expected.size,
candidate.minimumSystem == expected.minimumSystem else {
throw UpdateError.invalidBundle(tr("版本清单格式不正确"))
throw UpdateError.releaseChanged
}
}
}
@@ -191,7 +191,8 @@ final class SparkleInstaller: NSObject, SPUUserDriver, SPUUpdaterDelegate {
}
if case .skip? = action {
// 跳过点击已在本机排队;网络失败不能撤销选择或转为安装错误。
onPhase(.available)
// 版本已从更新源撤回时 release 已被清空,保留“未找到更新”给出的状态。
if hasKnownRelease() { onPhase(.available) }
} else if let error, !cancelled {
let failure = error as NSError
if failure.domain != SUSparkleErrorDomain || failure.code != SUError.noUpdateError.rawValue {
@@ -80,20 +80,14 @@ public final class UninstallerController {
}
}
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() ?? []
}
let apps = try await Self.scan(list)
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
group.addTask(priority: .utility) { try Task.checkCancellation(); return try measure(app.url) }
return try await group.next() ?? 0
}
let size = try await Self.scan { try measure(app.url) }
guard !Task.isCancelled, self.applicationGeneration == generation else { return }
self.sizes[app.id] = size
}
@@ -104,6 +98,15 @@ public final class UninstallerController {
}
}
/// 同步文件遍历放到 utility 子任务执行;子任务继承父任务取消,遍历内部据此中止。
private static func scan<T: Sendable>(_ work: @escaping @Sendable () throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask(priority: .utility) { try Task.checkCancellation(); return try work() }
guard let result = try await group.next() else { throw CancellationError() }
return result
}
}
/// 用户显式刷新:中断进行中的列举或计量后重新列举,已算出的大小保留。
func reloadApps() {
guard !isRemoving else { return }
@@ -140,10 +143,7 @@ public final class UninstallerController {
}
}
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() ?? []
}
let found = try await Self.scan { try find(app) }
// 应用相同也可能是另一次扫描,不能用 selected == app 代替任务代次。
guard let self, !Task.isCancelled, self.selectionGeneration == generation, self.selected == app else { return }
self.leftovers = found
@@ -257,7 +257,10 @@ public final class UninstallerController {
/// 回收可部分成功;只有本体确实移动后,才能移除列表和程序坞入口。
func finishUninstall(_ app: InstalledApp, items: [AppLeftover], moved: [URL: URL], errorMessage: String?) {
isRemoving = false
let movedItems = items.filter { moved[$0.url] != nil }
// 回收结果的键可能是规范化后的 URL(如目录末尾斜杠不同),按标准化路径比对。
let movedPaths = Set(moved.keys.map(\.standardizedFileURL.path))
let wasMoved = { (url: URL) in movedPaths.contains(url.standardizedFileURL.path) }
let movedItems = items.filter { wasMoved($0.url) }
guard !movedItems.isEmpty else {
outcome = (tr("没有移动任何文件\(errorMessage.map { tr(":\($0)") } ?? "")"), true)
return
@@ -266,7 +269,8 @@ public final class UninstallerController {
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 appMoved = wasMoved(app.url)
if appMoved {
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,
@@ -277,11 +281,11 @@ public final class UninstallerController {
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 }
leftovers.removeAll { wasMoved($0.url) }
chosen.subtract(movedItems.map(\.id))
}
}
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),实际移到废纸篓 \(movedItems.count) 项,本体已移动:\(moved[app.url] != nil)")
Log.app.notice("卸载 \(app.bundleIdentifier, privacy: .public),实际移到废纸篓 \(movedItems.count) 项,本体已移动:\(appMoved)")
}
/// 修改程序坞偏好里的 persistent-apps 并重启程序坞
@@ -63,7 +63,7 @@ struct SparkleInstallerTests {
if build == release.build {
try installer.updater(updater, shouldProceedWithUpdate: item, updateCheck: .updates)
} else {
#expect(throws: UpdateError.self) {
#expect(throws: UpdateError.releaseChanged) {
try installer.updater(updater, shouldProceedWithUpdate: item, updateCheck: .updates)
}
}
@@ -181,6 +181,48 @@ struct SparkleInstallerTests {
#expect(phases.last == (known ? .available : .idle))
}
@Test func failedPrimaryRegionRetriesFallbackOnceThenReportsFailure() {
let primary = URL(string: "https://primary.example.test/appcast.xml")!
let fallback = URL(string: "https://fallback.example.test/appcast.xml")!
var phases: [UpdateController.Phase] = []
var finished: [Bool] = []
let driver = SparkleInstaller(endpoints: [primary, fallback], onPhase: { phases.append($0) }, onRelaunch: {})
driver.onCycleFinished = { finished.append($0) }
let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver)
let offline = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)
driver.check(userInitiated: false)
#expect(driver.feedURLString(for: updater) == primary.absoluteString)
// 主区域失败:切到备用区域重试,本轮尚未结束,不能触发失败重排。
driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline)
#expect(finished.isEmpty)
#expect(driver.feedURLString(for: updater) == fallback.absoluteString)
try? driver.updater(updater, mayPerform: .updatesInBackground)
#expect(driver.feedURLString(for: updater) == fallback.absoluteString, "区域重试不能复位到主区域")
// 备用区域也失败:只结束一次并报告未取得 feed,由控制器按 1 小时重试间隔重排。
driver.updater(updater, didFinishUpdateCycleFor: .updatesInBackground, error: offline)
#expect(finished == [false])
#expect(phases.last == .idle)
#expect(driver.feedURLString(for: updater) == fallback.absoluteString, "结束后保留最后地址,避免 Sparkle 视为换源")
// 下一轮自动检查重新从主区域开始。
try? driver.updater(updater, mayPerform: .updatesInBackground)
#expect(driver.feedURLString(for: updater) == primary.absoluteString)
}
@Test func skippingWithdrawnReleaseDoesNotClaimAnUpdateIsAvailable() {
var phases: [UpdateController.Phase] = []
var known = true
let driver = SparkleInstaller(endpoints: [URL(string: "https://example.test/appcast.xml")!],
onPhase: { phases.append($0) }, onRelaunch: {})
driver.hasKnownRelease = { known }
driver.onNoUpdate = { known = false }
let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: driver, delegate: driver)
driver.check(userInitiated: false, action: .skip(release()))
let noUpdate = NSError(domain: SUSparkleErrorDomain, code: Int(SUError.noUpdateError.rawValue))
driver.updaterDidNotFindUpdate(updater, error: noUpdate)
driver.updater(updater, didFinishUpdateCycleFor: .updates, error: noUpdate)
#expect(phases == [.upToDate], "版本已撤回:不能在清空版本信息后仍显示有更新")
}
@Test func calendarMonthLaunchOnlyAndFailureRetryIntervals() throws {
let calendar = Calendar.current
let jan = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 31, hour: 12)))
@@ -25,8 +25,8 @@ struct UninstallerSafetyTests {
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)
}
@@ -65,6 +65,26 @@ struct UninstallerSafetyTests {
#expect(controller.selected == nil && controller.outcome?.isError == false)
}
@Test func recycleResultKeysAreMatchedByStandardizedPath() async throws {
let app = target()
let body = AppLeftover(url: app.url, kind: .application, size: 1000)
let cache = AppLeftover(url: URL(fileURLWithPath: "/tmp/normalized-cache"), kind: .caches, size: 10)
let controller = UninstallerController(currentBundleIdentifier: nil, 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)
// 系统可能返回带目录斜杠或未折叠 ".." 的 URL,仍应认定为同一项已移动。
let bodyKey = URL(fileURLWithPath: app.url.path + "/", isDirectory: true)
let cacheKey = URL(fileURLWithPath: "/tmp/sub/../normalized-cache")
try #require(bodyKey != app.url && cacheKey != cache.url)
controller.finishUninstall(app, items: [body, cache],
moved: [bodyKey: URL(fileURLWithPath: "/tmp/trash-app"), cacheKey: URL(fileURLWithPath: "/tmp/trash-cache")],
errorMessage: nil)
#expect(controller.selected == nil && controller.outcome?.isError == false)
#expect(!controller.apps.contains(app))
}
@Test func removalFreezesSelectionAndBlocksNewScans() async throws {
let app = target()
let body = AppLeftover(url: app.url, kind: .application, size: 1000)
+4
View File
@@ -13,6 +13,10 @@ if [ -z "$IDENTITY" ] || [ "$IDENTITY" = "-" ]; then
IDENTITY="-"
STAMP=(--timestamp=none)
OPTIONS=(--options 0)
elif ! security find-identity -v -p codesigning 2>/dev/null | grep -qF "$IDENTITY"; then
# Xcode 只在取得团队 ID 时签名子组件;证书不可用时不能再按正式分支只重签外层。
echo "error: 签名证书 $IDENTITY 不在钥匙串有效证书中(过期、吊销或缺少中间证书);本地构建可设置 SIGN_ID=- 使用临时签名" >&2
exit 1
fi
# 官方框架包含 Intel 切片;只修改嵌入副本,保持 Swift Package 原始制品完整。