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>
133 lines
4.8 KiB
Swift
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()
|
|
}
|
|
}
|