系统通知:过热、内存压力、磁盘空间、网络断开、电池健康
This commit is contained in:
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)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user