Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift
T

307 lines
12 KiB
Swift

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: .disk
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) }
}
}