fix: 收尾 PR 审查遗留的更新、卸载与签名问题
- 跳过已撤回的版本时不再显示"有更新";安装前发现更新源换了版本时给出明确提示(补齐 7 种语言) - 补充双区域回退与失败重排的调度测试 - 回收结果按标准化路径比对,避免路径写法不同误报本体未移动 - 抽出 scan 统一后台遍历与取消,loadApps 嵌套降到 3 层 - 自我保护测试只校验错误语义,不再比较两次翻译 - sign_sparkle.sh 在证书不在钥匙串有效列表时提前报错,避免子组件漏签 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7e0a22e132
commit
f1dd8cfacf
14 files changed
+102
-20
No files matched your search
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
|
||||
版本 {}{} الإصدار {}{}
|
||||
版本是 {},清单写的是 {} الإصدار {} لكن البيان يذكر {}
|
||||
版本清单格式不正确 بيان إصدار غير صالح
|
||||
更新源已发布其他版本,请重新检查更新后再安装 تم نشر إصدار مختلف. تحقق من التحديثات مرة أخرى قبل التثبيت.
|
||||
物理地址 عنوان الجهاز
|
||||
状态 الحالة
|
||||
状态圆点 نقطة الحالة
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
|
||||
版本 {}{} バージョン {}{}
|
||||
版本是 {},清单写的是 {} バージョンは{}ですが、一覧には{}と記載されています
|
||||
版本清单格式不正确 バージョン一覧の形式が正しくありません
|
||||
更新源已发布其他版本,请重新检查更新后再安装 別のバージョンが公開されました。インストールする前にもう一度アップデートを確認してください。
|
||||
物理地址 ハードウェアアドレス
|
||||
状态 状態
|
||||
状态圆点 状態ドット
|
||||
|
||||
|
@@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
|
||||
版本 {}{} 버전 {}{}
|
||||
版本是 {},清单写的是 {} 버전은 {}이지만 목록에는 {}(으)로 표시되어 있습니다
|
||||
版本清单格式不正确 버전 목록 형식이 올바르지 않습니다
|
||||
更新源已发布其他版本,请重新检查更新后再安装 다른 버전이 게시되었습니다. 설치하기 전에 업데이트를 다시 확인하세요.
|
||||
物理地址 하드웨어 주소
|
||||
状态 상태
|
||||
状态圆点 상태 점
|
||||
|
||||
|
@@ -913,6 +913,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
|
||||
版本 {}{} 版本 {}{}
|
||||
版本是 {},清单写的是 {} 版本是 {},清單寫的是 {}
|
||||
版本清单格式不正确 版本清單格式不正確
|
||||
更新源已发布其他版本,请重新检查更新后再安装 更新來源已發布其他版本,請重新檢查更新後再安裝
|
||||
物理地址 物理地址
|
||||
状态 狀態
|
||||
状态圆点 狀態圓點
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
@@ -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 原始制品完整。
|
||||
|
||||
Reference in new issue
Block a user