Files
MacMonitor/Sources/Intelligence/AlertNotificationDispatcher.swift
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a7f13542ae feat(alerts): implement threshold alert engine and Notification Center integration (fixes #24)
Introduce Sources/Intelligence with an @Observable AlertEngine that evaluates
five configurable rules (CPU temperature, fan stall, low memory, low storage,
low battery) against a Sendable AlertEvaluationContext built after each
telemetry decode pass. Breached conditions dispatch through a mockable
AlertNotificationDispatching protocol to UNUserNotificationCenter with
per-alert cooldowns, edge-triggered resolve detection, and a capped
in-memory history log.

Add a macOS Settings scene (AlertsSettingsView) for thresholds, cooldown,
permission status, and test notifications; a new Alerts and Notifications
sidebar tab with active-alert cards and the history log; header and menu bar
warning indicators; and an AppNavigationBus so notification actions
(Open MacMonitor, View Processes) steer the main window.

Also fix the memory snapshot decode to read memoryPressureStatus so
pressureLevel is populated correctly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 13:53:48 +01:00

133 lines
4.8 KiB
Swift

import Foundation
import UserNotifications
// MARK: - Navigation Bus
/// Lightweight bus letting notification action handlers steer the main window:
/// tapping a notification (or its "View Processes" action) activates the app and
/// selects the requested sidebar tab inside `ContentView`.
@Observable
public final class AppNavigationBus {
public static let shared = AppNavigationBus()
/// Sidebar tag the main window should switch to (e.g. `"Processes"`).
/// `nil` means "just bring the window forward".
public private(set) var requestedTab: String?
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
public private(set) var requestCounter: Int = 0
private init() {}
public func request(tab: String?) {
requestedTab = tab
requestCounter += 1
}
}
// MARK: - Dispatch Protocol
/// Abstraction over the delivery channel used by `AlertEngine` so unit tests can
/// inject a mock and never touch the real `UNUserNotificationCenter`.
public protocol AlertNotificationDispatching: AnyObject {
/// Requests `.alert` + `.sound` permission (idempotent; no-op if determined).
func requestAuthorization()
/// Posts a banner notification for a breached threshold.
func dispatch(_ alert: SystemAlert)
/// Current authorization status, used to render the Settings pane.
func authorizationStatus() async -> UNAuthorizationStatus
}
// MARK: - Notification Action Identifiers
public enum AlertNotificationAction {
public static let categoryIdentifier = "MM_ALERT"
public static let openAppIdentifier = "MM_ALERT_OPEN_APP"
public static let viewProcessesIdentifier = "MM_ALERT_VIEW_PROCESSES"
}
// MARK: - UserNotifications Implementation
/// Delivers threshold alerts through macOS Notification Center and routes
/// notification actions back into the UI via `AppNavigationBus`.
public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDispatching, UNUserNotificationCenterDelegate, @unchecked Sendable {
private let center: UNUserNotificationCenter
public override init() {
self.center = UNUserNotificationCenter.current()
super.init()
center.delegate = self
registerCategories()
}
private func registerCategories() {
let open = UNNotificationAction(
identifier: AlertNotificationAction.openAppIdentifier,
title: String(localized: "Open MacMonitor"),
options: [.foreground]
)
let processes = UNNotificationAction(
identifier: AlertNotificationAction.viewProcessesIdentifier,
title: String(localized: "View Processes"),
options: [.foreground]
)
let category = UNNotificationCategory(
identifier: AlertNotificationAction.categoryIdentifier,
actions: [open, processes],
intentIdentifiers: []
)
center.setNotificationCategories([category])
}
public func requestAuthorization() {
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
public func authorizationStatus() async -> UNAuthorizationStatus {
await center.notificationSettings().authorizationStatus
}
public func dispatch(_ alert: SystemAlert) {
let content = UNMutableNotificationContent()
content.title = alert.title
content.body = alert.message
content.sound = .default
content.categoryIdentifier = AlertNotificationAction.categoryIdentifier
content.userInfo = ["kind": alert.kind.rawValue, "severity": alert.severity.rawValue]
if alert.severity == .critical {
content.interruptionLevel = .timeSensitive
}
let request = UNNotificationRequest(identifier: alert.id, content: content, trigger: nil)
center.add(request) { _ in }
}
// MARK: UNUserNotificationCenterDelegate
/// Show banners even while MacMonitor is frontmost — alerts are warnings the
/// user explicitly asked to see regardless of focus.
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .list])
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let targetTab: String? = switch response.actionIdentifier {
case AlertNotificationAction.viewProcessesIdentifier:
"Processes"
default:
nil
}
Task { @MainActor in
AppNavigationBus.shared.request(tab: targetTab)
}
completionHandler()
}
}