feat(update): 新增版本更新系统通知

This commit is contained in:
ysicing committed 2026-09-29 19:38:20 +08:00
1 parent 3e4448e3e5
commit c48c50fca3
16 files changed
+218 -5

No files matched your search

+2
View File
@@ -190,6 +190,8 @@ git push
- `Packages/XStatsKit/Sources/XStatsUI/Rest`:本机休息计时、幕布、Mini HUD 与合成声音。
- `Packages/XStatsKit/Tests`:Swift 单元测试。
更新提醒:通知设置中的“新版本更新”默认开启,沿用自动更新检查调度;后台发现未跳过的新版本时发送系统通知,不主动打开升级窗口。“运行时静默”与“从不”继续保持静默,手动检查仍显示更新窗口。仅在通知成功提交后记录最近提醒版本,跨重启去重;权限拒绝或发送失败不消耗提醒机会。点击通知打开更新窗口,重启后尚无清单时重新检查。通知偏好参与设置备份,提醒记录仅留在本机。
菜单栏绘图:每个状态项仅保存上一轮绘图读数,相关数值未变化时复用图像;历史风格继续比较历史数据,设置、语言、外观与布局变化必须使缓存失效。悬停提示独立更新。设置页实时预览在主窗口关闭或最小化时停止观察采样数据,快照渲染不受影响。验证需包含后台 CPU 对照,以及风格、充电状态和历史曲线的刷新边界。
硬件能力:风扇相关入口仅在成功读取到大于 0 的风扇数量后显示;尚未采样、数量未知或明确为 0 时隐藏。未知不等同于无风扇,仍沿既有需求采样重试;已确认的数量保留至本次运行结束,短暂读取失败与转速为 0 不导致入口消失。能力过滤不改写用户保存的展示偏好。
@@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
续 متابعة
GitHub 项目 مشروع GitHub
赞助 XStats ادعم XStats
新版本更新 تحديثات الإصدارات الجديدة
后台发现新版本时通知,每个版本仅提醒一次 الإشعار عند العثور على تحديث في الخلفية، مرة واحدة لكل إصدار
点击查看更新内容并安装。 انقر للاطلاع على المستجدات وتثبيت التحديث.
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -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.
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -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.
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -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.
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
续 続き
GitHub 项目 GitHub プロジェクト
赞助 XStats XStats を支援
新版本更新 新しいバージョン
后台发现新版本时通知,每个版本仅提醒一次 バックグラウンドで更新を検出したとき、バージョンごとに一度だけ通知
点击查看更新内容并安装。 クリックして更新内容を確認し、インストールします。
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -1608,3 +1608,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
续 계속
GitHub 项目 GitHub 프로젝트
赞助 XStats XStats 후원
新版本更新 새 버전 업데이트
后台发现新版本时通知,每个版本仅提醒一次 백그라운드에서 업데이트를 찾으면 버전마다 한 번만 알림
点击查看更新内容并安装。 클릭하여 업데이트 내용을 확인하고 설치하세요.
1 # Copyright (C) 2026 ysicing
1608 续
1609 GitHub 项目
1610 赞助 XStats
1611 新版本更新
1612 后台发现新版本时通知,每个版本仅提醒一次
1613 点击查看更新内容并安装。
@@ -1609,3 +1609,6 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
续 續
GitHub 项目 GitHub 專案
赞助 XStats 贊助 XStats
新版本更新 新版本更新
后台发现新版本时通知,每个版本仅提醒一次 背景發現新版本時通知,每個版本僅提醒一次
点击查看更新内容并安装。 點擊查看更新內容並安裝。
1 # Copyright (c) 2026 GiantAccel, LLC
1609 续
1610 GitHub 项目
1611 赞助 XStats
1612 新版本更新
1613 后台发现新版本时通知,每个版本仅提醒一次
1614 点击查看更新内容并安装。
@@ -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)
@@ -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()
}
@@ -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
@@ -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) }
}
@@ -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<AlertKind> {
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"
@@ -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)
@@ -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<Void, Never>?
@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<UpdateRelease, UpdateError> {
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"
}
}
@@ -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)
}
}