diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 061cacd..db9412a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 版本 {}{} الإصدار {}{} 版本是 {},清单写的是 {} الإصدار {} لكن البيان يذكر {} 版本清单格式不正确 بيان إصدار غير صالح +更新源已发布其他版本,请重新检查更新后再安装 تم نشر إصدار مختلف. تحقق من التحديثات مرة أخرى قبل التثبيت. 物理地址 عنوان الجهاز 状态 الحالة 状态圆点 نقطة الحالة diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index d3114ff..4dced8c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index c4e11ef..2c7525d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index fa8ee97..748f2e3 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index c4103df..6d548f2 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 版本 {}{} バージョン {}{} 版本是 {},清单写的是 {} バージョンは{}ですが、一覧には{}と記載されています 版本清单格式不正确 バージョン一覧の形式が正しくありません +更新源已发布其他版本,请重新检查更新后再安装 別のバージョンが公開されました。インストールする前にもう一度アップデートを確認してください。 物理地址 ハードウェアアドレス 状态 状態 状态圆点 状態ドット diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index c032441..e1c7b40 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -912,6 +912,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 版本 {}{} 버전 {}{} 版本是 {},清单写的是 {} 버전은 {}이지만 목록에는 {}(으)로 표시되어 있습니다 版本清单格式不正确 버전 목록 형식이 올바르지 않습니다 +更新源已发布其他版本,请重新检查更新后再安装 다른 버전이 게시되었습니다. 설치하기 전에 업데이트를 다시 확인하세요. 物理地址 하드웨어 주소 状态 상태 状态圆点 상태 점 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 0bc701e..88412f9 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -913,6 +913,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 版本 {}{} 版本 {}{} 版本是 {},清单写的是 {} 版本是 {},清單寫的是 {} 版本清单格式不正确 版本清單格式不正確 +更新源已发布其他版本,请重新检查更新后再安装 更新來源已發布其他版本,請重新檢查更新後再安裝 物理地址 物理地址 状态 狀態 状态圆点 狀態圓點 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index b577fa8..bce45cb 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift index f1d5afc..cdfacde 100644 --- a/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift +++ b/Packages/XStatsKit/Sources/Updates/UpdateInstaller.swift @@ -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("更新源已发布其他版本,请重新检查更新后再安装") } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift index 99060a0..b996f89 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SparkleInstaller.swift @@ -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 { diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift index 8a26491..b95ddce 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UninstallerController.swift @@ -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(_ 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 并重启程序坞 diff --git a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift index a4a2147..8afe9a6 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/SparkleInstallerTests.swift @@ -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))) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift index 7fc1b41..aebb358 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/UninstallerSafetyTests.swift @@ -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) diff --git a/scripts/sign_sparkle.sh b/scripts/sign_sparkle.sh index 66d78ac..6883ab0 100644 --- a/scripts/sign_sparkle.sh +++ b/scripts/sign_sparkle.sh @@ -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 原始制品完整。