Read-only mounts (DMG images like /Volumes/Ghostty, update snapshots) with ~0% free produced false-positive low-storage alerts. StorageVolumeContext now carries isReadOnly and totalBytes; the rule skips read-only volumes and volumes under 1 GB where thresholds are meaningless. Regression tests cover both exclusions plus the normal trigger path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
231 lines
9.0 KiB
Swift
231 lines
9.0 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
|
|
// MARK: - Alert Engine
|
|
|
|
/// Evaluates threshold rules against each decoded telemetry snapshot.
|
|
/// Runs on `@MainActor` — it performs only in-memory comparisons over
|
|
/// `AlertEvaluationContext` and never touches IOKit/Mach/sysctl, preserving
|
|
/// the zero-UI-blocking invariant.
|
|
///
|
|
/// Lifecycle per alert identifier:
|
|
/// breach edge -> fire notification (subject to cooldown), log `.triggered`
|
|
/// persistent -> re-fire only after `cooldownMinutes` elapse
|
|
/// recovery -> remove from active set, log `.resolved`, clear cooldown so
|
|
/// the next breach alerts immediately
|
|
@Observable
|
|
@MainActor
|
|
public final class AlertEngine {
|
|
public static let shared = AlertEngine()
|
|
|
|
/// Currently breached conditions, refreshed every evaluation pass.
|
|
public private(set) var activeAlerts: [SystemAlert] = []
|
|
/// Newest-first audit log of trigger/resolve events (capped).
|
|
public private(set) var history: [AlertEvent] = []
|
|
|
|
/// Threshold settings. Mutations are persisted immediately.
|
|
public var configuration: AlertConfiguration {
|
|
didSet { configuration.save(to: defaults) }
|
|
}
|
|
|
|
public let dispatcher: any AlertNotificationDispatching
|
|
private let defaults: UserDefaults
|
|
private let historyLimit = 200
|
|
private var lastFiredAt: [String: Date] = [:]
|
|
private var breachedAlerts: [String: SystemAlert] = [:]
|
|
|
|
private convenience init() {
|
|
self.init(dispatcher: UNAlertNotificationDispatcher(), defaults: .standard)
|
|
}
|
|
|
|
init(dispatcher: any AlertNotificationDispatching, defaults: UserDefaults) {
|
|
self.dispatcher = dispatcher
|
|
self.defaults = defaults
|
|
self.configuration = AlertConfiguration.load(from: defaults)
|
|
}
|
|
|
|
/// Forwards to the dispatcher; call once at launch (and from Settings).
|
|
public func requestNotificationAuthorization() {
|
|
dispatcher.requestAuthorization()
|
|
}
|
|
|
|
public func notificationAuthorizationStatus() async -> UNAuthorizationStatus {
|
|
await dispatcher.authorizationStatus()
|
|
}
|
|
|
|
// MARK: Evaluation
|
|
|
|
public func evaluate(_ context: AlertEvaluationContext) {
|
|
guard configuration.alertsEnabled else {
|
|
// Wipe silently so stale alerts do not linger while disabled.
|
|
activeAlerts = []
|
|
breachedAlerts = [:]
|
|
lastFiredAt = [:]
|
|
return
|
|
}
|
|
|
|
let candidates = buildCandidates(for: context)
|
|
var currentIDs = Set<String>()
|
|
|
|
for alert in candidates {
|
|
currentIDs.insert(alert.id)
|
|
breachedAlerts[alert.id] = alert
|
|
if shouldFire(alertID: alert.id, at: context.timestamp) {
|
|
fire(alert, at: context.timestamp)
|
|
}
|
|
}
|
|
|
|
let resolvedIDs = breachedAlerts.keys
|
|
.filter { !currentIDs.contains($0) }
|
|
.sorted()
|
|
for id in resolvedIDs {
|
|
let resolved = breachedAlerts.removeValue(forKey: id)
|
|
lastFiredAt.removeValue(forKey: id)
|
|
let kind = resolved?.kind ?? Self.kind(fromAlertID: id)
|
|
appendEvent(
|
|
alertID: id,
|
|
kind: kind,
|
|
outcome: .resolved,
|
|
message: String(localized: "\(resolved?.title ?? id) — condition cleared"),
|
|
at: context.timestamp
|
|
)
|
|
}
|
|
|
|
activeAlerts = candidates
|
|
}
|
|
|
|
/// Clears all active/breached state and the audit log.
|
|
public func reset() {
|
|
activeAlerts = []
|
|
history = []
|
|
breachedAlerts = [:]
|
|
lastFiredAt = [:]
|
|
}
|
|
|
|
public func clearHistory() {
|
|
history = []
|
|
}
|
|
|
|
// MARK: Rule Evaluation
|
|
|
|
private func buildCandidates(for ctx: AlertEvaluationContext) -> [SystemAlert] {
|
|
let cfg = configuration
|
|
var alerts: [SystemAlert] = []
|
|
|
|
// 1. High CPU temperature
|
|
if cfg.cpuTempEnabled {
|
|
let temp = max(ctx.peakCoreTemperature, ctx.packageTemperature)
|
|
if temp >= cfg.cpuTempThresholdC {
|
|
alerts.append(SystemAlert(
|
|
id: AlertKind.cpuTemperature.rawValue,
|
|
kind: .cpuTemperature,
|
|
severity: .critical,
|
|
title: String(localized: "High CPU Temperature"),
|
|
message: String(localized: "CPU reached \(Int(temp))°C — threshold is \(Int(cfg.cpuTempThresholdC))°C"),
|
|
timestamp: ctx.timestamp
|
|
))
|
|
}
|
|
}
|
|
|
|
// 2. Cooling fan stall — only meaningful when fans are reported and the
|
|
// CPU is hot enough that a working fan should be spinning.
|
|
if cfg.fanStallEnabled,
|
|
!ctx.fanRPMs.isEmpty,
|
|
ctx.packageTemperature >= cfg.fanStallTempThresholdC {
|
|
for (index, rpm) in ctx.fanRPMs.sorted(by: { $0.key < $1.key }) where rpm <= 0 {
|
|
alerts.append(SystemAlert(
|
|
id: "\(AlertKind.fanStall.rawValue):\(index)",
|
|
kind: .fanStall,
|
|
severity: .critical,
|
|
title: String(localized: "Cooling Fan Failure"),
|
|
message: String(localized: "Fan \(index + 1) reads 0 RPM while the CPU is at \(Int(ctx.packageTemperature))°C"),
|
|
timestamp: ctx.timestamp
|
|
))
|
|
}
|
|
}
|
|
|
|
// 3. Low free memory / critical pressure
|
|
if cfg.memoryEnabled {
|
|
let freeMB = Double(ctx.memoryFreeBytes) / (1024 * 1024)
|
|
let critical = cfg.memoryAlertOnCriticalPressure
|
|
&& ctx.memoryPressureLevel.localizedCaseInsensitiveContains("critical")
|
|
if freeMB < cfg.memoryFreeMBThreshold || critical {
|
|
alerts.append(SystemAlert(
|
|
id: AlertKind.memoryPressure.rawValue,
|
|
kind: .memoryPressure,
|
|
severity: critical ? .critical : .warning,
|
|
title: String(localized: "Memory Pressure"),
|
|
message: String(localized: "Only \(Int(freeMB)) MB RAM free — pressure is \(ctx.memoryPressureLevel)"),
|
|
timestamp: ctx.timestamp
|
|
))
|
|
}
|
|
}
|
|
|
|
// 4. Low storage space (per mounted volume)
|
|
if cfg.storageEnabled {
|
|
// Skip read-only mounts (DMGs, snapshots) and trivially small
|
|
// volumes where free-space thresholds are meaningless.
|
|
let minTotalBytes: UInt64 = 1_073_741_824 // 1 GB
|
|
for volume in ctx.volumes where !volume.isReadOnly && volume.totalBytes >= minTotalBytes {
|
|
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
|
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
|
alerts.append(SystemAlert(
|
|
id: "\(AlertKind.lowStorage.rawValue):\(volume.mountPoint)",
|
|
kind: .lowStorage,
|
|
severity: .warning,
|
|
title: String(localized: "Low Disk Space"),
|
|
message: String(localized: "\"\(volume.volumeName)\" has \(String(format: "%.1f", freeGB)) GB free (\(Int(volume.freePercent))%)"),
|
|
timestamp: ctx.timestamp
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Low battery — only while actually discharging on battery power.
|
|
if cfg.batteryEnabled,
|
|
ctx.hasBattery,
|
|
!ctx.onExternalPower,
|
|
!ctx.isCharging,
|
|
ctx.batteryLevel <= cfg.batteryPercentThreshold {
|
|
alerts.append(SystemAlert(
|
|
id: AlertKind.lowBattery.rawValue,
|
|
kind: .lowBattery,
|
|
severity: .critical,
|
|
title: String(localized: "Low Battery"),
|
|
message: String(localized: "Battery is at \(Int(ctx.batteryLevel))% and not charging"),
|
|
timestamp: ctx.timestamp
|
|
))
|
|
}
|
|
|
|
return alerts
|
|
}
|
|
|
|
// MARK: Cooldown & History
|
|
|
|
private func shouldFire(alertID: String, at date: Date) -> Bool {
|
|
guard let last = lastFiredAt[alertID] else { return true }
|
|
return date.timeIntervalSince(last) >= configuration.cooldownMinutes * 60
|
|
}
|
|
|
|
private func fire(_ alert: SystemAlert, at date: Date) {
|
|
lastFiredAt[alert.id] = date
|
|
dispatcher.dispatch(alert)
|
|
appendEvent(alertID: alert.id, kind: alert.kind, outcome: .triggered, message: alert.message, at: date)
|
|
}
|
|
|
|
private func appendEvent(alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String, at date: Date) {
|
|
history.insert(
|
|
AlertEvent(timestamp: date, alertID: alertID, kind: kind, outcome: outcome, message: message),
|
|
at: 0
|
|
)
|
|
if history.count > historyLimit {
|
|
history.removeLast(history.count - historyLimit)
|
|
}
|
|
}
|
|
|
|
private static func kind(fromAlertID id: String) -> AlertKind {
|
|
let prefix = id.prefix { $0 != ":" }
|
|
return AlertKind(rawValue: String(prefix)) ?? .cpuTemperature
|
|
}
|
|
}
|