diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 25de914..ebc68ad 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -190,6 +190,8 @@ git push - `Packages/XStatsKit/Sources/XStatsUI/Rest`:本机休息计时、幕布、Mini HUD 与合成声音。 - `Packages/XStatsKit/Tests`:Swift 单元测试。 +更新提醒:通知设置中的“新版本更新”默认开启,沿用自动更新检查调度;后台发现未跳过的新版本时发送系统通知,不主动打开升级窗口。“运行时静默”与“从不”继续保持静默,手动检查仍显示更新窗口。仅在通知成功提交后记录最近提醒版本,跨重启去重;权限拒绝或发送失败不消耗提醒机会。点击通知打开更新窗口,重启后尚无清单时重新检查。通知偏好参与设置备份,提醒记录仅留在本机。 + 菜单栏绘图:每个状态项仅保存上一轮绘图读数,相关数值未变化时复用图像;历史风格继续比较历史数据,设置、语言、外观与布局变化必须使缓存失效。悬停提示独立更新。设置页实时预览在主窗口关闭或最小化时停止观察采样数据,快照渲染不受影响。验证需包含后台 CPU 对照,以及风格、充电状态和历史曲线的刷新边界。 硬件能力:风扇相关入口仅在成功读取到大于 0 的风扇数量后显示;尚未采样、数量未知或明确为 0 时隐藏。未知不等同于无风扇,仍沿既有需求采样重试;已确认的数量保留至本次运行结束,短暂读取失败与转速为 0 不导致入口消失。能力过滤不改写用户保存的展示偏好。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 99d1967..172ee43 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 متابعة GitHub 项目 مشروع GitHub 赞助 XStats ادعم XStats +新版本更新 تحديثات الإصدارات الجديدة +后台发现新版本时通知,每个版本仅提醒一次 الإشعار عند العثور على تحديث في الخلفية، مرة واحدة لكل إصدار +点击查看更新内容并安装。 انقر للاطلاع على المستجدات وتثبيت التحديث. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index e1f8438..d2e0413 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 Forts. GitHub 项目 GitHub-Projekt 赞助 XStats XStats unterstützen +新版本更新 Neue Versionen +后台发现新版本时通知,每个版本仅提醒一次 Bei im Hintergrund gefundenen Updates einmal pro Version benachrichtigen +点击查看更新内容并安装。 Klicken, um Neuerungen anzusehen und das Update zu installieren. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 6aed451..7239980 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 cont. GitHub 项目 Proyecto en GitHub 赞助 XStats Apoyar XStats +新版本更新 Nuevas versiones +后台发现新版本时通知,每个版本仅提醒一次 Notificar una vez por versión al detectar actualizaciones en segundo plano +点击查看更新内容并安装。 Haz clic para ver las novedades e instalar la actualización. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 497de27..3317d4d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 suite GitHub 项目 Projet GitHub 赞助 XStats Soutenir XStats +新版本更新 Nouvelles versions +后台发现新版本时通知,每个版本仅提醒一次 Notifier une fois par version lorsqu’une mise à jour est détectée en arrière-plan +点击查看更新内容并安装。 Cliquez pour voir les nouveautés et installer la mise à jour. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index afc5222..6f50d08 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 続き GitHub 项目 GitHub プロジェクト 赞助 XStats XStats を支援 +新版本更新 新しいバージョン +后台发现新版本时通知,每个版本仅提醒一次 バックグラウンドで更新を検出したとき、バージョンごとに一度だけ通知 +点击查看更新内容并安装。 クリックして更新内容を確認し、インストールします。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 818bb21..d5d4d67 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 계속 GitHub 项目 GitHub 프로젝트 赞助 XStats XStats 후원 +新版本更新 새 버전 업데이트 +后台发现新版本时通知,每个版本仅提醒一次 백그라운드에서 업데이트를 찾으면 버전마다 한 번만 알림 +点击查看更新内容并安装。 클릭하여 업데이트 내용을 확인하고 설치하세요. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 4ebfb0a..f982d8b 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1609,3 +1609,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 续 續 GitHub 项目 GitHub 專案 赞助 XStats 贊助 XStats +新版本更新 新版本更新 +后台发现新版本时通知,每个版本仅提醒一次 背景發現新版本時通知,每個版本僅提醒一次 +点击查看更新内容并安装。 點擊查看更新內容並安裝。 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 9d3a5ba..2cc141e 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -188,6 +188,9 @@ CPU 温度达到 {},已恢复系统自动控制 CPU reached {}; fans returned CPU 温度过高 CPU is too hot CPU 负载偏高 CPU load is high CPU 负载很高 CPU load is very high +新版本更新 New version updates +后台发现新版本时通知,每个版本仅提醒一次 Notify when a background check finds an update, once per version +点击查看更新内容并安装。 Click to see what’s new and install. CPU 过热 CPU Overheating CPU:{}(以单核满载为 100%) CPU: {} (100% = one full core) Cloudflare 接入节点(机场代码) Cloudflare edge location (airport code) diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 188b832..afa37ea 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -266,6 +266,18 @@ public final class AppController: NSObject, NSApplicationDelegate { self?.calendarMenuBar.dismiss() self?.updateWindow.show() } + model.updates.onUpdateAvailable = { [weak self] version in + await self?.model.alerts.notifyUpdate(version: version) ?? false + } + model.alerts.openUpdates = { [weak self] in + guard let self else { return } + if self.model.updates.release != nil { + self.model.updates.onPrompt() + } else { + // 通知可跨应用重启保留;重新检查后展示当前有效的更新信息。 + self.checkForUpdatesFromMenu() + } + } DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in self?.model.updates.checkIfNeeded() } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 9128a99..2a7b046 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -162,8 +162,16 @@ struct NotificationSettings: View { } SettingsGroup(caption: tr("发生这些状况时发送系统通知,点通知打开对应页面")) { - ForEach(Array(AlertKind.allCases.enumerated()), id: \.element) { index, kind in - GroupRow(showsDivider: index > 0) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("新版本更新"), subtitle: tr("后台发现新版本时通知,每个版本仅提醒一次"), icon: "arrow.down.circle") { + DSToggle(isOn: Binding(get: { settings.notifyUpdates }, set: { enabled in + settings.notifyUpdates = enabled + if enabled, alerts.authorization == .unknown { alerts.requestAuthorization() } + }), label: tr("新版本更新")) + } + } + ForEach(AlertKind.allCases) { kind in + GroupRow { SettingRow(title: kind.title, subtitle: kind.detail, icon: kind.symbol) { DSToggle(isOn: Binding(get: { settings.enabledAlerts.contains(kind) }, set: { on in diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AlertController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AlertController.swift index f3aac2f..499cbd4 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AlertController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AlertController.swift @@ -114,6 +114,7 @@ public final class AlertController: NSObject { public private(set) var authorization: Authorization = .unknown @ObservationIgnored var openTab: (PanelTab) -> Void = { _ in } + @ObservationIgnored var openUpdates: () -> Void = {} @ObservationIgnored private let settings: AppSettings @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private var trackers: [AlertKind: AlertTracker] = [:] @@ -271,6 +272,37 @@ public final class AlertController: NSObject { // MARK: 通知 + /// 默认开启的更新提醒在真正发现版本时才请求权限,不新增轮询或启动时弹窗。 + func notifyUpdate(version: String) async -> Bool { + guard settings.notifyUpdates, let center else { return false } + do { + let status = await center.notificationSettings().authorizationStatus + let granted: Bool + if status == .notDetermined { + granted = try await center.requestAuthorization(options: [.alert, .sound]) + } else { + granted = status == .authorized || status == .provisional + } + authorization = granted ? .allowed : .denied + // 等待系统授权期间用户可能关闭开关。 + guard granted, settings.notifyUpdates else { return false } + try await center.add(Self.updateNotification(version: version)) + return true + } catch { + Log.update.error("更新通知发送失败:\(error.localizedDescription, privacy: .public)") + return false + } + } + + static func updateNotification(version: String) -> UNNotificationRequest { + let content = UNMutableNotificationContent() + content.title = tr("发现新版本 \(version)") + content.body = tr("点击查看更新内容并安装。") + content.sound = .default + content.userInfo = ["updates": true] + return UNNotificationRequest(identifier: "xstats.updateAvailable", content: content, trigger: nil) + } + func refreshAuthorization() { guard let center else { return } center.getNotificationSettings { [weak self] settings in @@ -337,7 +369,12 @@ extension AlertController: UNUserNotificationCenterDelegate { nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let tab = (response.notification.request.content.userInfo["tab"] as? String).flatMap(PanelTab.init(rawValue:)) + let updates = response.notification.request.content.userInfo["updates"] as? Bool == true completionHandler() + if updates { + Task { @MainActor [weak self] in self?.openUpdates() } + return + } guard let tab else { return } Task { @MainActor [weak self] in self?.openTab(tab) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 75be414..651e121 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -603,6 +603,10 @@ public final class AppSettings { public var publicIPLookup: Bool { didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) } } + /// 自动检查发现新版本时发送系统通知,默认开启。 + public var notifyUpdates: Bool { + didSet { defaults.set(notifyUpdates, forKey: Keys.notifyUpdates) } + } /// 打开了系统通知的状况 public var enabledAlerts: Set { didSet { defaults.set(enabledAlerts.map(\.rawValue).sorted(), forKey: Keys.enabledAlerts) } @@ -775,6 +779,7 @@ public final class AppSettings { hotKeys = Dictionary(uniqueKeysWithValues: storedHotKeys.compactMap { key, value in HotKeyAction(rawValue: key).map { ($0, value) } }) + notifyUpdates = defaults.object(forKey: Keys.notifyUpdates) as? Bool ?? true enabledAlerts = Set(defaults.stringArray(forKey: Keys.enabledAlerts)?.compactMap(AlertKind.init(rawValue:)) ?? []) alertCPUTemperature = Self.alertTemperatureOptions.contains(defaults.integer(forKey: Keys.alertCPUTemperature)) ? defaults.integer(forKey: Keys.alertCPUTemperature) : 95 @@ -872,6 +877,7 @@ public final class AppSettings { static let hotKeys = "hotKeys" static let language = "language" static let enabledAlerts = "enabledAlerts" + static let notifyUpdates = "notifyUpdates" static let alertCPUTemperature = "alertCPUTemperature" static let alertCPULoad = "alertCPULoad" static let cpuChartSeconds = "cpuChartSeconds" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index 45e6102..72cc7d9 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -49,6 +49,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable { public var probeTarget: String? public var publicIPLookup: Bool? public var enabledAlerts: [String]? + public var notifyUpdates: Bool? public var alertCPUTemperature: Int? public var alertCPULoad: Int? public var language: String? @@ -99,6 +100,7 @@ extension AppSettings { doc.probeTarget = probeTarget.rawValue doc.publicIPLookup = publicIPLookup doc.enabledAlerts = enabledAlerts.map(\.rawValue).sorted() + doc.notifyUpdates = notifyUpdates doc.alertCPUTemperature = alertCPUTemperature doc.alertCPULoad = alertCPULoad doc.language = language.rawValue @@ -121,6 +123,7 @@ extension AppSettings { value.flatMap { options.contains($0) ? $0 : nil } } + assign(\.notifyUpdates, doc.notifyUpdates) assign(\.calendarEnabled, doc.calendarEnabled) assign(\.calendarPreferences, doc.calendarPreferences) assign(\.restEnabled, doc.restEnabled) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift index fbd49f5..e8dcc21 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/UpdateController.swift @@ -28,8 +28,10 @@ public final class UpdateController { didSet { defaults.set(skippedVersion, forKey: Keys.skippedVersion) } } - /// 自动检查发现未跳过的新版本时调用,由 AppController 打开升级提示窗口 + /// 手动检查发现新版本时调用,由 AppController 打开升级提示窗口 @ObservationIgnored var onPrompt: () -> Void = {} + /// 后台发现新版本时请求系统通知;成功提交后才记录去重状态。 + @ObservationIgnored var onUpdateAvailable: (String) async -> Bool = { _ in false } /// 安装完成后退出应用,等进程结束再打开新版 @ObservationIgnored var terminate: () -> Void = { NSApp.terminate(nil) } @@ -37,6 +39,7 @@ public final class UpdateController { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private var task: Task? @ObservationIgnored private let launchedAt: Date + @ObservationIgnored private var isNotifying = false init(settings: AppSettings, defaults: UserDefaults = .standard, launchedAt: Date = Date()) { self.settings = settings @@ -93,8 +96,7 @@ public final class UpdateController { release = latest phase = .available Log.update.notice("发现新版本 \(latest.version, privacy: .public)") - // 手动检查时总是提示;自动检查时跳过用户选择忽略的版本 - if userInitiated || (!suppressPrompt && latest.version != skippedVersion) { onPrompt() } + await announceUpdate(version: latest.version, userInitiated: userInitiated, suppressPrompt: suppressPrompt) case .failure(let error): Log.update.error("检查更新失败:\(error.localizedDescription, privacy: .public)") // 自动检查失败不打扰用户,只在关于页里显示 @@ -103,6 +105,21 @@ public final class UpdateController { } } + /// 手动检查始终展示结果;后台通知遵循静默策略、开关、跳过版本及跨重启去重。 + func announceUpdate(version: String, userInitiated: Bool, suppressPrompt: Bool) async { + if userInitiated { + onPrompt() + return + } + guard !suppressPrompt, settings.notifyUpdates, version != skippedVersion, + !isNotifying else { return } + if let last = defaults.string(forKey: Keys.notifiedVersion), !UpdateFeed.isNewer(version, than: last) { return } + // 通知权限弹窗期间允许查看更新,但不能重复提交后台通知。 + isNotifying = true + defer { isNotifying = false } + if await onUpdateAvailable(version) { defaults.set(version, forKey: Keys.notifiedVersion) } + } + private static func fetch(currentVersion: String) async -> Result { do { let installationID = try InstallationIdentity().hashedID() @@ -248,5 +265,6 @@ public final class UpdateController { private enum Keys { static let lastChecked = "updateLastChecked" static let skippedVersion = "updateSkippedVersion" + static let notifiedVersion = "updateNotifiedVersion" } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/UpdateNotificationTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/UpdateNotificationTests.swift new file mode 100644 index 0000000..f6dff0d --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/UpdateNotificationTests.swift @@ -0,0 +1,103 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +@testable import XStatsUI + +@MainActor +struct UpdateNotificationTests { + private let defaults = UserDefaults(suiteName: "UpdateNotificationTests.\(UUID().uuidString)")! + + @Test func preferenceDefaultsOnAndPreservesAnExplicitOptOut() throws { + // 已有安装的其他通知偏好不影响新开关的默认值。 + defaults.set([String](), forKey: "enabledAlerts") + let settings = AppSettings(defaults: defaults) + #expect(settings.notifyUpdates) + settings.notifyUpdates = false + #expect(!AppSettings(defaults: defaults).notifyUpdates) + let data = try JSONEncoder().encode(settings.exportDocument()) + let restored = AppSettings(defaults: UserDefaults(suiteName: UUID().uuidString)!) + restored.apply(try JSONDecoder().decode(SettingsDocument.self, from: data)) + #expect(!restored.notifyUpdates) + restored.apply(try JSONDecoder().decode(SettingsDocument.self, from: Data("{}".utf8))) + #expect(!restored.notifyUpdates) + } + + @Test func successfulNotificationIsDeduplicatedAcrossRestarts() async { + let settings = AppSettings(defaults: defaults) + let first = UpdateController(settings: settings, defaults: defaults) + var versions: [String] = [] + first.onUpdateAvailable = { versions.append($0); return true } + await first.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + await first.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + let restarted = UpdateController(settings: settings, defaults: defaults) + restarted.onUpdateAvailable = { versions.append($0); return true } + await restarted.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + await restarted.announceUpdate(version: "1.0.1", userInitiated: false, suppressPrompt: false) + await restarted.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + #expect(versions == ["1.0.0", "1.0.1"]) + } + + @Test func deniedOrFailedSubmissionCanBeRetried() async { + let controller = UpdateController(settings: AppSettings(defaults: defaults), defaults: defaults) + var attempts = 0 + controller.onUpdateAvailable = { _ in attempts += 1; return attempts > 1 } + for _ in 0..<3 { + await controller.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + } + #expect(attempts == 2) + } + + @Test func backgroundRespectsOptOutQuietModeAndSkippedVersion() async { + defaults.set("1.0.0", forKey: "updateSkippedVersion") + let settings = AppSettings(defaults: defaults) + let controller = UpdateController(settings: settings, defaults: defaults) + var versions: [String] = [] + controller.onUpdateAvailable = { versions.append($0); return true } + settings.notifyUpdates = false + await controller.announceUpdate(version: "1.0.1", userInitiated: false, suppressPrompt: false) + settings.notifyUpdates = true + await controller.announceUpdate(version: "1.0.1", userInitiated: false, suppressPrompt: true) + await controller.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + #expect(versions.isEmpty) + await controller.announceUpdate(version: "1.0.1", userInitiated: false, suppressPrompt: false) + #expect(versions == ["1.0.1"]) + } + + @Test func manualCheckAlwaysPresentsWithoutSendingNotification() async { + defaults.set("1.0.0", forKey: "updateSkippedVersion") + defaults.set("1.0.0", forKey: "updateNotifiedVersion") + let settings = AppSettings(defaults: defaults) + settings.notifyUpdates = false + let controller = UpdateController(settings: settings, defaults: defaults) + var prompts = 0 + var notifications = 0 + controller.onPrompt = { prompts += 1 } + controller.onUpdateAvailable = { _ in notifications += 1; return true } + await controller.announceUpdate(version: "1.0.0", userInitiated: true, suppressPrompt: true) + #expect(prompts == 1) + #expect(notifications == 0) + } + + @Test func pendingAuthorizationDoesNotAllowDuplicateNotifications() async { + let controller = UpdateController(settings: AppSettings(defaults: defaults), defaults: defaults) + var notifications = 0 + controller.onUpdateAvailable = { [weak controller] version in + notifications += 1 + await controller?.announceUpdate(version: version, userInitiated: false, suppressPrompt: false) + return true + } + await controller.announceUpdate(version: "1.0.0", userInitiated: false, suppressPrompt: false) + #expect(notifications == 1) + } + + @Test func notificationRoutesToUpdatesAndReplacesPreviousVersion() { + let first = AlertController.updateNotification(version: "1.0.0") + let next = AlertController.updateNotification(version: "1.0.1") + #expect(first.identifier == next.identifier) + #expect(next.content.title.contains("1.0.1")) + #expect(next.content.userInfo["updates"] as? Bool == true) + #expect(next.trigger == nil) + } +}