系统通知:过热、内存压力、磁盘空间、网络断开、电池健康

This commit is contained in:
gentpan committed 2026-09-13 08:19:36 +05:00
1 parent a67d12120b
commit 1e8c2f0773
8 files changed
+426 -2

No files matched your search

@@ -53,6 +53,11 @@ public final class AppController: NSObject, NSApplicationDelegate {
updateNetworkVisibility()
Task { await model.geo.updateIfNeeded() }
startUpdateChecks()
model.alerts.openTab = { [weak self] tab in
self?.menuBar.dismissPopovers()
self?.mainWindow.show(tab: tab)
}
model.alerts.start()
// 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口
let arguments = CommandLine.arguments
@@ -150,9 +155,11 @@ public final class AppController: NSObject, NSApplicationDelegate {
_ = model.isNetworkDetailVisible
_ = model.settings.probeEnabled
_ = model.settings.probeInBackground
_ = model.settings.enabledAlerts
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.model.alerts.applySettings()
await self.model.hub.update(self.model.demand)
self.applyAppearance()
self.menuBar.update()
@@ -39,6 +39,7 @@ public struct MainWindowView: View {
case .cleaner: CleanerPage()
case .settingsGeneral: SettingsTabPage { GeneralSettings() }
case .settingsMenuBar: SettingsTabPage { MenuBarSettings() }
case .settingsNotifications: SettingsTabPage { NotificationSettings() }
case .settingsHelper: SettingsTabPage { HelperSettings() }
case .settingsAbout: SettingsTabPage { AboutSettings() }
}
@@ -89,6 +89,59 @@ struct GeneralSettings: View {
}
}
// MARK: - 通知
struct NotificationSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
let alerts = model.alerts
if alerts.authorization == .denied {
InfoBanner(icon: "bell.slash", text: "OpenStats 的通知被关闭了,打开的提醒不会显示。", tone: .warning) {
Button("打开通知设置") { alerts.openNotificationSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
SettingsGroup(caption: "发生这些状况时发送系统通知,点通知打开对应页面") {
ForEach(Array(AlertKind.allCases.enumerated()), id: \.element) { index, kind in
GroupRow(showsDivider: index > 0) {
SettingRow(title: kind.title, subtitle: kind.detail, icon: kind.symbol) {
DSToggle(isOn: Binding(get: { settings.enabledAlerts.contains(kind) },
set: { on in
if on { settings.enabledAlerts.insert(kind) } else { settings.enabledAlerts.remove(kind) }
}),
label: kind.title)
}
}
if kind == .cpuTemperature, settings.enabledAlerts.contains(.cpuTemperature) {
GroupRow {
SettingRow(title: "过热温度") {
SegmentedControl(selection: $settings.alertCPUTemperature,
options: AppSettings.alertTemperatureOptions.map {
($0, Format.temperature(Double($0), fahrenheit: settings.useFahrenheit))
})
.frame(width: DS.Size.sidebarWidth + DS.Space.s12)
}
}
}
}
}
SettingsGroup {
GroupRow(showsDivider: false) {
SettingRow(title: "发送测试通知", subtitle: alerts.authorization == .allowed ? "确认通知能正常显示" : "首次发送时系统会询问是否允许通知") {
Button("发送") { alerts.sendTest() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
}
.onAppear { alerts.refreshAuthorization() }
}
}
// MARK: - 诊断
struct DiagnosticsSettings: View {
@@ -0,0 +1,306 @@
import AppKit
import Foundation
import Metrics
import Network
import Observation
import UserNotifications
/// 可以发系统通知的状况
public enum AlertKind: String, CaseIterable, Identifiable, Sendable {
case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth
public var id: String { rawValue }
var title: String {
switch self {
case .cpuTemperature: "CPU 过热"
case .memoryPressure: "内存压力严重"
case .diskSpace: "磁盘空间不足"
case .networkDown: "网络断开"
case .batteryHealth: "电池健康度下降"
}
}
var detail: String {
switch self {
case .cpuTemperature: "CPU 温度持续 1 分钟高于设定值"
case .memoryPressure: "内存压力持续 30 秒处于严重,系统开始大量换页"
case .diskSpace: "启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次"
case .networkDown: "网络连接中断超过 20 秒,恢复后再提示一次"
case .batteryHealth: "电池最大容量低于 80%,每 30 天最多提醒一次"
}
}
var symbol: String {
switch self {
case .cpuTemperature: "thermometer.high"
case .memoryPressure: "memorychip"
case .diskSpace: "internaldrive"
case .networkDown: "wifi.slash"
case .batteryHealth: "battery.25percent"
}
}
/// 点通知打开的页面
var tab: PanelTab {
switch self {
case .cpuTemperature: .thermal
case .memoryPressure: .memory
case .diskSpace: .cleaner
case .networkDown: .network
case .batteryHealth: .system
}
}
/// 状况持续多久才提醒
var sustain: TimeInterval {
switch self {
case .cpuTemperature: 60
case .memoryPressure: 30
case .networkDown: 20
case .diskSpace, .batteryHealth: 0
}
}
/// 同一状况两次提醒的最短间隔
var cooldown: TimeInterval {
switch self {
case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60
case .diskSpace: 6 * 60 * 60
case .batteryHealth: 30 * 24 * 60 * 60
}
}
}
/// 一种状况的触发判断:持续满 sustain 秒才触发,同一段持续期内只触发一次,两次之间至少间隔 cooldown
struct AlertTracker: Equatable {
private(set) var activeSince: Date?
private(set) var firedThisEpisode = false
private(set) var lastFired: Date?
/// 返回 true 表示现在应该发通知
mutating func update(isActive: Bool, now: Date, sustain: TimeInterval, cooldown: TimeInterval) -> Bool {
guard isActive else {
activeSince = nil
firedThisEpisode = false
return false
}
let since = activeSince ?? now
activeSince = since
guard !firedThisEpisode, now.timeIntervalSince(since) >= sustain else { return false }
if let lastFired, now.timeIntervalSince(lastFired) < cooldown { return false }
firedThisEpisode = true
lastFired = now
return true
}
}
/// 判断各项状况并发出系统通知;点通知打开主窗口的对应页面
@MainActor
@Observable
public final class AlertController: NSObject {
public enum Authorization: Equatable {
case unknown, allowed, denied
}
public private(set) var authorization: Authorization = .unknown
@ObservationIgnored var openTab: (PanelTab) -> Void = { _ in }
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private var trackers: [AlertKind: AlertTracker] = [:]
@ObservationIgnored private var pathMonitor: NWPathMonitor?
@ObservationIgnored private var networkNotified = false
@ObservationIgnored private var networkDownTask: Task<Void, Never>?
@ObservationIgnored private var periodicTask: Task<Void, Never>?
/// 单元测试与截图进程没有应用包标识,不能使用通知中心
private var center: UNUserNotificationCenter? {
Bundle.main.bundleIdentifier == nil ? nil : UNUserNotificationCenter.current()
}
init(settings: AppSettings, defaults: UserDefaults = .standard) {
self.settings = settings
self.defaults = defaults
super.init()
}
func start() {
center?.delegate = self
refreshAuthorization()
applySettings()
}
/// 开关变化后启动或停止网络监听与定期检查;首次打开任一提醒时请求通知权限
func applySettings() {
let enabled = settings.enabledAlerts
if !enabled.isEmpty, authorization == .unknown { requestAuthorization() }
if enabled.contains(.networkDown) {
if pathMonitor == nil { startNetworkMonitor() }
} else {
pathMonitor?.cancel()
pathMonitor = nil
networkDownTask?.cancel()
networkNotified = false
}
if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) {
if periodicTask == nil {
periodicTask = Task { [weak self] in
while !Task.isCancelled {
self?.checkPeriodic()
try? await Task.sleep(for: .seconds(10 * 60), tolerance: .seconds(60))
}
}
}
} else {
periodicTask?.cancel()
periodicTask = nil
}
}
// MARK: 判断
/// 每次采样后调用
func evaluate(_ store: MetricsStore, now: Date = Date()) {
let enabled = settings.enabledAlerts
guard !enabled.isEmpty else { return }
if enabled.contains(.cpuTemperature),
let cpu = store.sensors?.temperatures.first(where: { $0.group == .cpu })?.maximum {
let limit = Double(settings.alertCPUTemperature)
fireIfNeeded(.cpuTemperature, isActive: cpu >= limit, now: now,
body: "CPU 温度 \(Format.temperature(cpu, fahrenheit: settings.useFahrenheit)),已持续超过 1 分钟。可以检查高占用进程,或在“温度与风扇”里提高风扇转速。")
}
if enabled.contains(.memoryPressure), let memory = store.memory {
fireIfNeeded(.memoryPressure, isActive: memory.pressure == .critical, now: now,
body: "可用内存只剩 \(Format.bytes(memory.available)),系统正在压缩和交换内存。关闭不用的应用可以缓解。")
}
}
private func checkPeriodic(now: Date = Date()) {
let enabled = settings.enabledAlerts
if enabled.contains(.diskSpace), let disk = DiskSampler.sample() {
let low = disk.total > 0 && (Double(disk.available) / Double(disk.total) < 0.1 || disk.available < 10_000_000_000)
fireIfNeeded(.diskSpace, isActive: low, now: now,
body: "“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 OpenStats 的清理功能释放缓存。")
}
if enabled.contains(.batteryHealth), let health = BatterySampler.sample()?.health {
// 冷却时间跨越重启,记在偏好设置里
let last = defaults.object(forKey: Keys.batteryNotified) as? Date
if health < 0.8, last.map({ now.timeIntervalSince($0) >= AlertKind.batteryHealth.cooldown }) ?? true {
defaults.set(now, forKey: Keys.batteryNotified)
send(.batteryHealth, body: "电池最大容量为 \(Format.percent(health)),续航会明显缩短。可以在“系统设置 › 电池”查看是否建议维修。")
}
}
}
private func fireIfNeeded(_ kind: AlertKind, isActive: Bool, now: Date, body: @autoclosure () -> String) {
var tracker = trackers[kind] ?? AlertTracker()
let fire = tracker.update(isActive: isActive, now: now, sustain: kind.sustain, cooldown: kind.cooldown)
trackers[kind] = tracker
if fire { send(kind, body: body()) }
}
private func startNetworkMonitor() {
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
Task { @MainActor in self?.networkChanged(satisfied: satisfied) }
}
monitor.start(queue: DispatchQueue(label: "com.openstats.alerts.network"))
pathMonitor = monitor
}
private func networkChanged(satisfied: Bool) {
networkDownTask?.cancel()
if satisfied {
if networkNotified {
networkNotified = false
send(.networkDown, title: "网络已恢复", body: "网络连接已经恢复。")
}
return
}
guard !networkNotified else { return }
networkDownTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(AlertKind.networkDown.sustain))
guard !Task.isCancelled, let self else { return }
self.networkNotified = true
Log.network.notice("网络断开超过 20 秒")
self.send(.networkDown, body: "网络连接已中断超过 20 秒。可以打开网络详情查看接口与路由器状态。")
}
}
// MARK: 通知
func refreshAuthorization() {
guard let center else { return }
center.getNotificationSettings { [weak self] settings in
let status = settings.authorizationStatus
Task { @MainActor in
switch status {
case .authorized, .provisional: self?.authorization = .allowed
case .denied: self?.authorization = .denied
default: self?.authorization = .unknown
}
}
}
}
func requestAuthorization(then action: (@MainActor () -> Void)? = nil) {
center?.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
Task { @MainActor in
self?.authorization = granted ? .allowed : .denied
if granted { action?() }
}
}
}
func openNotificationSettings() {
let id = Bundle.main.bundleIdentifier ?? "com.openstats.app"
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=\(id)") {
NSWorkspace.shared.open(url)
}
}
/// 还没询问过权限时先请求,允许后再发
func sendTest() {
guard authorization != .unknown else {
requestAuthorization { [weak self] in self?.sendTest() }
return
}
send(.cpuTemperature, title: "OpenStats 通知测试", body: "通知可以正常显示。发生你打开的状况时,会像这样提醒你。")
}
private func send(_ kind: AlertKind, title: String? = nil, body: String) {
guard let center else { return }
Log.app.notice("发送通知:\(kind.rawValue, privacy: .public)")
let content = UNMutableNotificationContent()
content.title = title ?? kind.title
content.body = body
content.sound = .default
content.userInfo = ["tab": kind.tab.rawValue]
// 同一种状况用固定标识,新的提醒替换旧的,不在通知中心堆积
center.add(UNNotificationRequest(identifier: "openstats.\(kind.rawValue)", content: content, trigger: nil))
}
private enum Keys {
static let batteryNotified = "alertBatteryHealthNotified"
}
}
extension AlertController: UNUserNotificationCenterDelegate {
/// 应用在前台时也显示横幅
nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound])
}
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:))
completionHandler()
guard let tab else { return }
Task { @MainActor [weak self] in self?.openTab(tab) }
}
}
@@ -20,6 +20,7 @@ public final class AppModel {
public let explainer = ProcessExplainer()
public let updates: UpdateController
let diagnostics = DiagnosticsExporter()
public let alerts: AlertController
@ObservationIgnored public let hub = MetricsHub()
public var isMainWindowVisible = false
@@ -45,6 +46,7 @@ public final class AppModel {
geo = GeoDatabaseController(settings: settings)
network = NetworkController(settings: settings, geo: geo)
updates = UpdateController(settings: settings)
alerts = AlertController(settings: settings)
}
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
@@ -73,6 +75,7 @@ public final class AppModel {
if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) }
if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) }
if showing(.gpu, .gpu) { groups.insert(.gpu) }
if settings.enabledAlerts.contains(.cpuTemperature) { groups.insert(.cpu) }
demand.temperatures = groups
demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan)
|| fans.mode != .automatic || thermalPopover
@@ -136,5 +139,6 @@ public final class AppModel {
store.apply(snapshot)
fans.evaluateSafety()
keepAwake.evaluateBattery(store.battery)
alerts.evaluate(store)
}
}
@@ -220,13 +220,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
/// 主窗口侧边栏的页面
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case overview, system, cpu, gpu, memory, network, thermal, processes, keepAwake, cleaner
case settingsGeneral, settingsMenuBar, settingsHelper, settingsAbout
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsHelper, settingsAbout
public var id: String { rawValue }
static let monitors: [PanelTab] = [.overview, .system, .cpu, .gpu, .memory, .network, .thermal]
static let tools: [PanelTab] = [.processes, .keepAwake, .cleaner]
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsHelper, .settingsAbout]
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsHelper, .settingsAbout]
var isSettings: Bool { Self.settings.contains(self) }
@@ -247,6 +247,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .cleaner: "清理"
case .settingsGeneral: "通用"
case .settingsMenuBar: "菜单栏"
case .settingsNotifications: "通知"
case .settingsHelper: "辅助工具"
case .settingsAbout: "关于"
}
@@ -266,6 +267,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .cleaner: "sparkles"
case .settingsGeneral: "gearshape"
case .settingsMenuBar: "menubar.rectangle"
case .settingsNotifications: "bell.badge"
case .settingsHelper: "lock.shield"
case .settingsAbout: "info.circle"
}
@@ -371,6 +373,14 @@ public final class AppSettings {
public var geoAutoUpdate: Bool {
didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) }
}
/// 打开了系统通知的状况
public var enabledAlerts: Set<AlertKind> {
didSet { defaults.set(enabledAlerts.map(\.rawValue).sorted(), forKey: Keys.enabledAlerts) }
}
/// CPU 过热提醒的温度(摄氏度)
public var alertCPUTemperature: Int {
didSet { defaults.set(alertCPUTemperature, forKey: Keys.alertCPUTemperature) }
}
/// 启动时与每天检查一次新版本
public var autoCheckUpdates: Bool {
didSet { defaults.set(autoCheckUpdates, forKey: Keys.autoCheckUpdates) }
@@ -384,6 +394,7 @@ public final class AppSettings {
public static let probeOptions = [1, 2, 5]
public static let batteryFloorOptions = [10, 20, 30, 40]
public static let fanSafetyOptions = [85, 90, 95, 100]
public static let alertTemperatureOptions = [85, 90, 95, 100]
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -418,6 +429,9 @@ public final class AppSettings {
geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity)
geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true
autoCheckUpdates = defaults.object(forKey: Keys.autoCheckUpdates) 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
}
/// 按固定顺序返回已启用的菜单栏项目
@@ -469,5 +483,7 @@ public final class AppSettings {
static let geoIncludeCity = "geoIncludeCity"
static let geoAutoUpdate = "geoAutoUpdate"
static let autoCheckUpdates = "autoCheckUpdates"
static let enabledAlerts = "enabledAlerts"
static let alertCPUTemperature = "alertCPUTemperature"
}
}
@@ -189,3 +189,39 @@ private func isolatedDefaults() -> UserDefaults {
#expect(names.contains("openstats.log"))
}
}
@Suite struct AlertTrackerTests {
@Test func firesOnceAfterSustainAndRespectsCooldown() {
var tracker = AlertTracker()
func step(_ active: Bool, _ seconds: TimeInterval) -> Bool {
tracker.update(isActive: active, now: Date(timeIntervalSince1970: seconds), sustain: 60, cooldown: 1800)
}
#expect(!step(true, 0))
#expect(!step(true, 59))
#expect(step(true, 60))
// 同一段持续期内不重复
#expect(!step(true, 600))
// 恢复后再次出现,但还在冷却期
#expect(!step(false, 700))
#expect(!step(true, 800))
#expect(!step(true, 900))
// 冷却结束后同一段持续期仍可触发
#expect(step(true, 1900))
}
@Test func briefSpikesDoNotFire() {
var tracker = AlertTracker()
var fired = false
for second in stride(from: 0.0, to: 600, by: 30) {
// 每 30 秒一升一降,从未持续满 60 秒
fired = fired || tracker.update(isActive: Int(second) % 60 == 0, now: Date(timeIntervalSince1970: second),
sustain: 60, cooldown: 1800)
}
#expect(!fired)
}
@Test func notificationTabsExist() {
#expect(PanelTab.settings.contains(.settingsNotifications))
#expect(Set(AlertKind.allCases.map(\.tab)).isSubset(of: Set(PanelTab.allCases)))
}
}