From a7f13542ae54e9366f82f63d464a325bed346de6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:53:48 +0100 Subject: [PATCH 1/2] 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> --- MacMonitor.xcodeproj/project.pbxproj | 122 +++++++++ Sources/App/MacMonitorApp.swift | 22 +- Sources/App/SystemTelemetryStore.swift | 29 ++- Sources/Intelligence/AlertConfiguration.swift | 67 +++++ Sources/Intelligence/AlertEngine.swift | 227 +++++++++++++++++ Sources/Intelligence/AlertModels.swift | 125 ++++++++++ .../AlertNotificationDispatcher.swift | 132 ++++++++++ Sources/UI/Alerts/AlertsView.swift | 163 ++++++++++++ Sources/UI/ContentView.swift | 36 +++ Sources/UI/MenuBar/MenuBarStatusView.swift | 10 +- Sources/UI/Settings/AlertsSettingsView.swift | 165 ++++++++++++ Tests/MMAlertsTests.swift | 236 ++++++++++++++++++ 12 files changed, 1329 insertions(+), 5 deletions(-) create mode 100644 Sources/Intelligence/AlertConfiguration.swift create mode 100644 Sources/Intelligence/AlertEngine.swift create mode 100644 Sources/Intelligence/AlertModels.swift create mode 100644 Sources/Intelligence/AlertNotificationDispatcher.swift create mode 100644 Sources/UI/Alerts/AlertsView.swift create mode 100644 Sources/UI/Settings/AlertsSettingsView.swift create mode 100644 Tests/MMAlertsTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 4d03701..33ae665 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -7,25 +7,35 @@ objects = { /* Begin PBXBuildFile section */ + 0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */; }; 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */; }; + 11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */; }; 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; }; + 17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */; }; 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; }; 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; + 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; }; 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; 4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */; }; + 475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */; }; 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */; }; + 4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */; }; + 4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */; }; 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; }; + 5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */; }; 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; }; 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */; }; 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */; }; + 75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */; }; 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52471E871E5CC1E444318070 /* MMProcessTests.swift */; }; + 7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */; }; 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; @@ -35,22 +45,28 @@ 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */; }; 951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */; }; 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; }; + 9B882E4189597719436F18FB /* AlertsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */; }; A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */; }; A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8530556B240CCDEA63009DB /* AlertEngine.swift */; }; ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */; }; AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A0F723121CDF9597316F58 /* MMGPUTests.swift */; }; B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; + D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */; }; + D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */; }; D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */; }; + DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */; }; E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; }; E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; + EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */; }; F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; }; FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */; }; /* End PBXBuildFile section */ @@ -72,6 +88,7 @@ 09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = ""; }; 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; 0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkSocketsProvider.h; sourceTree = ""; }; + 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickGlancePopoverView.swift; sourceTree = ""; }; 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAudioTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; @@ -88,21 +105,27 @@ 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkSocketsTests.swift; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; 3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkBandwidthProvider.h; sourceTree = ""; }; + 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryTrendChartView.swift; sourceTree = ""; }; 3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = ""; }; 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDiskIOProvider.m; sourceTree = ""; }; 41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessTelemetryProvider.h; sourceTree = ""; }; 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = ""; }; + 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollingRingBuffer.swift; sourceTree = ""; }; 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; + 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryHistoryStore.swift; sourceTree = ""; }; 52471E871E5CC1E444318070 /* MMProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessTests.swift; sourceTree = ""; }; 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessDetailInspector.m; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; + 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMHistoryTests.swift; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; 5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAudioTelemetryProvider.h; sourceTree = ""; }; 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; + 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsView.swift; sourceTree = ""; }; + 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertNotificationDispatcher.swift; sourceTree = ""; }; 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessDetailsTests.swift; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessTelemetryProvider.m; sourceTree = ""; }; @@ -110,33 +133,43 @@ 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMLoadAverageTests.swift; sourceTree = ""; }; 80A0F723121CDF9597316F58 /* MMGPUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMGPUTests.swift; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; + 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMenuBarTests.swift; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; + 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertConfiguration.swift; sourceTree = ""; }; + 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsSettingsView.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMBatteryTelemetryProvider.m; sourceTree = ""; }; 9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMGPUTelemetryProvider.h; sourceTree = ""; }; + 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusView.swift; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDiskIOProvider.h; sourceTree = ""; }; B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMBatteryTests.swift; sourceTree = ""; }; B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = ""; }; B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMLoadAverageProvider.m; sourceTree = ""; }; + B657679DB7D1F39AB4911B7B /* AlertModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModels.swift; sourceTree = ""; }; C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkBandwidthProvider.m; sourceTree = ""; }; C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = ""; }; + C8530556B240CCDEA63009DB /* AlertEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEngine.swift; sourceTree = ""; }; C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = ""; }; CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPeripheralsProvider.h; sourceTree = ""; }; D1486E1512594482380CB492 /* MMPeripheralsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPeripheralsTests.swift; sourceTree = ""; }; + D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAlertsTests.swift; sourceTree = ""; }; D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = ""; }; + D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDisplayManager.h; sourceTree = ""; }; D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMGPUTelemetryProvider.m; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkSocketsProvider.m; sourceTree = ""; }; DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMKernelTelemetryTests.swift; sourceTree = ""; }; + DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDisplayTests.swift; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = ""; }; E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = ""; }; F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = ""; }; F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -169,20 +202,43 @@ 4D8D8B40632C525CBC1D0E43 /* App */, 54787E6270EA7B9A1BE11359 /* Core */, 9844EC526E3527F6A582179F /* Hardware */, + 4BD6F71AA668F3A4B161D0FA /* History */, + DB11C1CC50774018D85A1D79 /* Intelligence */, 8BF79E3BCB76E7A1669DC930 /* Telemetry */, 3E4A0B53C583BD0B7A68EACE /* UI */, ); path = Sources; sourceTree = ""; }; + 3A1C97833AFDC29469DCA9ED /* Charts */ = { + isa = PBXGroup; + children = ( + 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */, + ); + path = Charts; + sourceTree = ""; + }; 3E4A0B53C583BD0B7A68EACE /* UI */ = { isa = PBXGroup; children = ( E142AE61F9B87757997870DD /* ContentView.swift */, + B42842AC7D80A8B77A4A611D /* Alerts */, + 3A1C97833AFDC29469DCA9ED /* Charts */, + BC3C9017978097F8240CEC63 /* MenuBar */, + E3A5B8BAED7E91EE8C6B3B2B /* Settings */, ); path = UI; sourceTree = ""; }; + 4BD6F71AA668F3A4B161D0FA /* History */ = { + isa = PBXGroup; + children = ( + 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */, + 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */, + ); + path = History; + sourceTree = ""; + }; 4D8D8B40632C525CBC1D0E43 /* App */ = { isa = PBXGroup; children = ( @@ -243,11 +299,21 @@ path = Power; sourceTree = ""; }; + 8B972045B60442056C6456DF /* Display */ = { + isa = PBXGroup; + children = ( + D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */, + F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */, + ); + path = Display; + sourceTree = ""; + }; 8BF79E3BCB76E7A1669DC930 /* Telemetry */ = { isa = PBXGroup; children = ( 92E1EF545807CF318620FEAA /* Audio */, 78383CED205BCA772701AE48 /* CPU */, + 8B972045B60442056C6456DF /* Display */, AD2995435E44FA3E1790A4F3 /* Fan */, F99AF003017BA9F19F7AEB1E /* GPU */, CA2F30473D63EC64CBD65437 /* Kernel */, @@ -309,6 +375,14 @@ path = Fan; sourceTree = ""; }; + B42842AC7D80A8B77A4A611D /* Alerts */ = { + isa = PBXGroup; + children = ( + 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */, + ); + path = Alerts; + sourceTree = ""; + }; B6EB3F6545276C40212C2992 /* Storage */ = { isa = PBXGroup; children = ( @@ -320,6 +394,15 @@ path = Storage; sourceTree = ""; }; + BC3C9017978097F8240CEC63 /* MenuBar */ = { + isa = PBXGroup; + children = ( + 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */, + 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */, + ); + path = MenuBar; + sourceTree = ""; + }; C80C3EEE8F912F4B214102E3 /* Products */ = { isa = PBXGroup; children = ( @@ -333,17 +416,21 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */, 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */, B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */, B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */, + DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */, 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */, 80A0F723121CDF9597316F58 /* MMGPUTests.swift */, + 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */, DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */, 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, + 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */, 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */, 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */, D1486E1512594482380CB492 /* MMPeripheralsTests.swift */, @@ -365,6 +452,17 @@ path = Kernel; sourceTree = ""; }; + DB11C1CC50774018D85A1D79 /* Intelligence */ = { + isa = PBXGroup; + children = ( + 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */, + C8530556B240CCDEA63009DB /* AlertEngine.swift */, + B657679DB7D1F39AB4911B7B /* AlertModels.swift */, + 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */, + ); + path = Intelligence; + sourceTree = ""; + }; DBDC37231C01F0A2D4A7FC73 /* Thermal */ = { isa = PBXGroup; children = ( @@ -388,6 +486,14 @@ path = AppleSMC; sourceTree = ""; }; + E3A5B8BAED7E91EE8C6B3B2B /* Settings */ = { + isa = PBXGroup; + children = ( + 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; E56B76815ED75B28A13D6814 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -490,17 +596,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */, 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */, BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */, 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */, 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */, + 75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */, E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */, AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */, + D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */, 901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */, ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, + 0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */, 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */, 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */, 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */, @@ -517,6 +627,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */, + AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */, + 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */, + 17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */, + 5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */, + 9B882E4189597719436F18FB /* AlertsView.swift in Sources */, A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */, @@ -525,6 +641,7 @@ 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */, FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */, + DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */, 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */, 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */, A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */, @@ -540,7 +657,12 @@ F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, + 7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */, + 4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */, + 11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */, A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */, + EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */, + 475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift index 93fb332..8b4c7ba 100644 --- a/Sources/App/MacMonitorApp.swift +++ b/Sources/App/MacMonitorApp.swift @@ -1,9 +1,20 @@ import SwiftUI +/// Bridges app-lifecycle events into SwiftUI: requests Notification Center +/// authorization once at launch so threshold alerts can post banners. +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + if AlertEngine.shared.configuration.alertsEnabled { + AlertEngine.shared.requestNotificationAuthorization() + } + } +} + @main struct MacMonitorApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var store = SystemTelemetryStore.shared - + var body: some Scene { WindowGroup { ContentView() @@ -11,15 +22,20 @@ struct MacMonitorApp: App { .windowStyle(.titleBar) .windowToolbarStyle(.unified) .defaultSize(width: 960, height: 640) - + MenuBarExtra { QuickGlancePopoverView(store: store) } label: { MenuBarStatusView( cpuLoad: store.cpuLoad.totalLoad, - memoryPercent: store.memory.utilizationPercentage + memoryPercent: store.memory.utilizationPercentage, + activeAlertCount: store.alertEngine.activeAlerts.count ) } .menuBarExtraStyle(.window) + + Settings { + AlertsSettingsView() + } } } diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index 3a57c8e..f56bc28 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -257,6 +257,9 @@ public final class SystemTelemetryStore { /// Historical Telemetry Store public let historyStore = TelemetryHistoryStore.shared + /// Threshold alert engine (evaluated after every decode pass) + public let alertEngine = AlertEngine.shared + /// Display Manager helper public let displayManager = MMDisplayManager.shared() @@ -345,7 +348,8 @@ public final class SystemTelemetryStore { m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0 m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0 - m.pressureLevel = memDict["pressureLevel"] as? String ?? "Normal" + m.pressureLevel = memDict["memoryPressureStatus"] as? String + ?? memDict["pressureLevel"] as? String ?? "Normal" self.memory = m } @@ -682,6 +686,29 @@ public final class SystemTelemetryStore { timestamp: self.lastUpdateTimestamp ) self.historyStore.record(snapshot: snapshotRecord) + + // 20. Evaluate Threshold Alert Rules + var context = AlertEvaluationContext(timestamp: self.lastUpdateTimestamp) + context.peakCoreTemperature = self.cpuThermal.peakCoreTemperature + context.packageTemperature = self.cpuThermal.packageTemperature + context.fanRPMs = Dictionary(uniqueKeysWithValues: self.fans.map { ($0.index, $0.currentRPM) }) + context.memoryFreeBytes = self.memory.freeBytes + context.memoryPressureLevel = self.memory.pressureLevel + context.volumes = self.storageVolumes.map { + StorageVolumeContext( + mountPoint: $0.mountPoint, + volumeName: $0.volumeName, + freeBytes: $0.freeBytes, + freePercent: $0.totalBytes > 0 + ? Double($0.freeBytes) / Double($0.totalBytes) * 100.0 + : 0 + ) + } + context.batteryLevel = self.power.batteryLevel + context.hasBattery = self.batteryHealth.hasBattery + context.onExternalPower = self.batteryHealth.externalConnected + context.isCharging = self.batteryHealth.isCharging || self.power.isCharging + self.alertEngine.evaluate(context) } public func terminateProcess(pid: pid_t, force: Bool) -> Bool { diff --git a/Sources/Intelligence/AlertConfiguration.swift b/Sources/Intelligence/AlertConfiguration.swift new file mode 100644 index 0000000..ee2da63 --- /dev/null +++ b/Sources/Intelligence/AlertConfiguration.swift @@ -0,0 +1,67 @@ +import Foundation + +// MARK: - Alert Configuration + +/// User-tunable threshold and cooldown settings for `AlertEngine`. +/// Persisted as a single JSON blob in `UserDefaults` so the engine can read +/// configuration outside of a SwiftUI context. +public struct AlertConfiguration: Codable, Equatable, Sendable { + /// Global switch — when false, no rules are evaluated. + public var alertsEnabled: Bool = true + + /// Minimum minutes between two notifications for the same alert identifier + /// while a condition remains breached. + public var cooldownMinutes: Double = 15 + + // MARK: CPU Temperature + public var cpuTempEnabled: Bool = true + /// Degrees Celsius; breach when the highest core/package reading reaches this. + public var cpuTempThresholdC: Double = 95 + + // MARK: Fan Stall + public var fanStallEnabled: Bool = true + /// Degrees Celsius; a fan reading 0 RPM only alerts while the CPU is at or + /// above this temperature (distinguishes genuine stalls from idle spin-down). + public var fanStallTempThresholdC: Double = 75 + + // MARK: Memory Pressure + public var memoryEnabled: Bool = true + /// Free physical RAM threshold in megabytes. + public var memoryFreeMBThreshold: Double = 500 + /// Also alert whenever the kernel pressure tier reports "Critical". + public var memoryAlertOnCriticalPressure: Bool = true + + // MARK: Storage + public var storageEnabled: Bool = true + /// Free-space threshold per volume in gigabytes. + public var storageFreeGBThreshold: Double = 10 + /// Free-space threshold per volume as a percentage of capacity. + public var storageFreePercentThreshold: Double = 10 + + // MARK: Battery + public var batteryEnabled: Bool = true + /// Charge percentage; only evaluated while discharging on battery power. + public var batteryPercentThreshold: Double = 10 + + public init() {} +} + +// MARK: - UserDefaults Persistence + +public extension AlertConfiguration { + static let storageKey = "com.i3omb.macmonitor.alertConfiguration" + + static func load(from defaults: UserDefaults = .standard) -> AlertConfiguration { + guard let data = defaults.data(forKey: storageKey), + let config = try? JSONDecoder().decode(AlertConfiguration.self, from: data) else { + return AlertConfiguration() + } + return config + } + + func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { + defaults.set(data, forKey: AlertConfiguration.storageKey) + } + } +} diff --git a/Sources/Intelligence/AlertEngine.swift b/Sources/Intelligence/AlertEngine.swift new file mode 100644 index 0000000..3600c33 --- /dev/null +++ b/Sources/Intelligence/AlertEngine.swift @@ -0,0 +1,227 @@ +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() + + 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 { + for volume in ctx.volumes { + 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 + } +} diff --git a/Sources/Intelligence/AlertModels.swift b/Sources/Intelligence/AlertModels.swift new file mode 100644 index 0000000..c1135fc --- /dev/null +++ b/Sources/Intelligence/AlertModels.swift @@ -0,0 +1,125 @@ +import Foundation + +// MARK: - Alert Models + +/// Categories of threshold conditions evaluated by `AlertEngine`. +public enum AlertKind: String, Codable, CaseIterable, Sendable { + case cpuTemperature + case fanStall + case memoryPressure + case lowStorage + case lowBattery +} + +/// Visual and audible emphasis level for a raised alert. +public enum AlertSeverity: String, Codable, Sendable { + case warning + case critical +} + +/// A single breached threshold condition. +public struct SystemAlert: Identifiable, Equatable, Sendable { + /// Stable identifier of the form `""` or `":"` + /// (e.g. `"lowStorage:/"`, `"fanStall:0"`) used for cooldown bookkeeping. + public let id: String + public let kind: AlertKind + public let severity: AlertSeverity + public let title: String + public let message: String + public let timestamp: Date + + public init(id: String, kind: AlertKind, severity: AlertSeverity, title: String, message: String, timestamp: Date) { + self.id = id + self.kind = kind + self.severity = severity + self.title = title + self.message = message + self.timestamp = timestamp + } +} + +/// What happened to an alert condition during an evaluation pass. +public enum AlertEventOutcome: String, Codable, Sendable { + case triggered + case resolved + case suppressedCooldown +} + +/// An entry in the in-memory alert history log. +public struct AlertEvent: Identifiable, Equatable, Sendable { + public let id: UUID + public let timestamp: Date + public let alertID: String + public let kind: AlertKind + public let outcome: AlertEventOutcome + public let message: String + + public init(timestamp: Date, alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String) { + self.id = UUID() + self.timestamp = timestamp + self.alertID = alertID + self.kind = kind + self.outcome = outcome + self.message = message + } +} + +/// Per-volume storage context consumed by the low-storage rule. +public struct StorageVolumeContext: Equatable, Sendable { + public let mountPoint: String + public let volumeName: String + public let freeBytes: UInt64 + public let freePercent: Double + + public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) { + self.mountPoint = mountPoint + self.volumeName = volumeName + self.freeBytes = freeBytes + self.freePercent = freePercent + } +} + +/// Flat, immutable snapshot of the metrics the alert rules inspect. +/// Assembled by `SystemTelemetryStore` after each decode pass so the engine +/// stays decoupled from provider dictionary schemas and testable in isolation. +public struct AlertEvaluationContext: Sendable { + public var peakCoreTemperature: Double = 0 + public var packageTemperature: Double = 0 + /// Fan index -> current RPM. Empty when no fan telemetry is available. + public var fanRPMs: [Int: Double] = [:] + public var memoryFreeBytes: UInt64 = 0 + public var memoryPressureLevel: String = "Normal" + public var volumes: [StorageVolumeContext] = [] + /// Charge level 0-100 reported by the power provider. + public var batteryLevel: Double = 100 + public var hasBattery: Bool = false + public var onExternalPower: Bool = true + public var isCharging: Bool = false + public var timestamp: Date = Date() + + public init( + peakCoreTemperature: Double = 0, + packageTemperature: Double = 0, + fanRPMs: [Int: Double] = [:], + memoryFreeBytes: UInt64 = 0, + memoryPressureLevel: String = "Normal", + volumes: [StorageVolumeContext] = [], + batteryLevel: Double = 100, + hasBattery: Bool = false, + onExternalPower: Bool = true, + isCharging: Bool = false, + timestamp: Date = Date() + ) { + self.peakCoreTemperature = peakCoreTemperature + self.packageTemperature = packageTemperature + self.fanRPMs = fanRPMs + self.memoryFreeBytes = memoryFreeBytes + self.memoryPressureLevel = memoryPressureLevel + self.volumes = volumes + self.batteryLevel = batteryLevel + self.hasBattery = hasBattery + self.onExternalPower = onExternalPower + self.isCharging = isCharging + self.timestamp = timestamp + } +} diff --git a/Sources/Intelligence/AlertNotificationDispatcher.swift b/Sources/Intelligence/AlertNotificationDispatcher.swift new file mode 100644 index 0000000..8ebb06c --- /dev/null +++ b/Sources/Intelligence/AlertNotificationDispatcher.swift @@ -0,0 +1,132 @@ +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() + } +} diff --git a/Sources/UI/Alerts/AlertsView.swift b/Sources/UI/Alerts/AlertsView.swift new file mode 100644 index 0000000..c8f0be2 --- /dev/null +++ b/Sources/UI/Alerts/AlertsView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +// MARK: - Alerts & Notifications View + +/// Sidebar tab showing currently breached thresholds and the in-app +/// alert history log maintained by `AlertEngine`. +public struct AlertsView: View { + @State private var engine = AlertEngine.shared + + public init() {} + + public var body: some View { + VStack(alignment: .leading, spacing: 20) { + activeAlertsCard + historyCard + } + } + + // MARK: - Active Alerts + + private var activeAlertsCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Active Alerts", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + Spacer() + if engine.activeAlerts.isEmpty { + Text("ALL CLEAR") + .font(.caption.bold()) + .foregroundStyle(.green) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.15), in: Capsule()) + } else { + Text("\(engine.activeAlerts.count) BREACHED") + .font(.caption.bold()) + .foregroundStyle(.red) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15), in: Capsule()) + } + } + + if engine.activeAlerts.isEmpty { + Text("All monitored thresholds are within normal limits.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(engine.activeAlerts) { alert in + HStack(alignment: .top, spacing: 10) { + Image(systemName: iconName(for: alert.kind)) + .foregroundStyle(color(for: alert.severity)) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(alert.title) + .font(.caption.bold()) + Text(alert.message) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(alert.severity.rawValue.uppercased()) + .font(.caption2.bold()) + .foregroundStyle(color(for: alert.severity)) + Text(alert.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(8) + .background(color(for: alert.severity).opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - History Log + + private var historyCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Alert History", systemImage: "clock.arrow.circlepath") + .font(.headline) + Spacer() + Button("Clear History") { + engine.clearHistory() + } + .buttonStyle(.bordered) + .disabled(engine.history.isEmpty) + } + + if engine.history.isEmpty { + Text("No alert events recorded yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(engine.history) { event in + HStack(alignment: .top, spacing: 10) { + Image(systemName: outcomeIcon(event.outcome)) + .foregroundStyle(outcomeColor(event.outcome)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(event.message) + .font(.caption) + Text(event.alertID) + .font(.caption2) + .foregroundStyle(.secondary) + .monospaced() + } + Spacer() + Text(event.timestamp.formatted(date: .abbreviated, time: .standard)) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + Divider() + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Presentation Helpers + + private func iconName(for kind: AlertKind) -> String { + switch kind { + case .cpuTemperature: return "thermometer.high" + case .fanStall: return "fanblades" + case .memoryPressure: return "memorychip" + case .lowStorage: return "internaldrive" + case .lowBattery: return "battery.25" + } + } + + private func color(for severity: AlertSeverity) -> Color { + switch severity { + case .critical: return .red + case .warning: return .orange + } + } + + private func outcomeIcon(_ outcome: AlertEventOutcome) -> String { + switch outcome { + case .triggered: return "bell.fill" + case .resolved: return "checkmark.circle.fill" + case .suppressedCooldown: return "bell.slash" + } + } + + private func outcomeColor(_ outcome: AlertEventOutcome) -> Color { + switch outcome { + case .triggered: return .red + case .resolved: return .green + case .suppressedCooldown: return .secondary + } + } +} diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index c859821..24420af 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -7,6 +7,7 @@ public struct ContentView: View { @State private var socketSearchText: String = "" @State private var selectedPID: Int32? = nil @State private var showingProcessDetail: Bool = false + @State private var navBus = AppNavigationBus.shared public init() {} @@ -25,6 +26,15 @@ public struct ContentView: View { ProcessDetailSheet(inspector: store.processInspector, pid: pid) } } + .onChange(of: navBus.requestCounter) { _, _ in + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } + if let tab = navBus.requestedTab { + selectedTab = tab + } + } } // MARK: - Sidebar @@ -75,6 +85,12 @@ public struct ContentView: View { Label("Historical Trends", systemImage: "chart.line.uptrend.xyaxis") .tag("Trends") } + + Section("Intelligence") { + Label("Alerts & Notifications", systemImage: "bell.badge") + .tag("Alerts") + .badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)")) + } } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300) @@ -129,6 +145,8 @@ public struct ContentView: View { audioDevicesCard case "Trends": historicalTrendsView + case "Alerts": + AlertsView() default: dashboardView } @@ -184,6 +202,24 @@ public struct ContentView: View { Spacer() + if !store.alertEngine.activeAlerts.isEmpty { + Button { + selectedTab = "Alerts" + } label: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("\(store.alertEngine.activeAlerts.count)") + .font(.caption.bold().monospacedDigit()) + } + .foregroundStyle(.red) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.red.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + .help("\(store.alertEngine.activeAlerts.count) active alert(s) — click to review") + } + HStack(spacing: 8) { Circle() .fill(store.isRunning ? Color.green : Color.red) diff --git a/Sources/UI/MenuBar/MenuBarStatusView.swift b/Sources/UI/MenuBar/MenuBarStatusView.swift index f31974d..e000040 100644 --- a/Sources/UI/MenuBar/MenuBarStatusView.swift +++ b/Sources/UI/MenuBar/MenuBarStatusView.swift @@ -3,14 +3,22 @@ import SwiftUI public struct MenuBarStatusView: View { public let cpuLoad: Double public let memoryPercent: Double + public let activeAlertCount: Int - public init(cpuLoad: Double, memoryPercent: Double) { + public init(cpuLoad: Double, memoryPercent: Double, activeAlertCount: Int = 0) { self.cpuLoad = cpuLoad self.memoryPercent = memoryPercent + self.activeAlertCount = activeAlertCount } public var body: some View { HStack(spacing: 5) { + if activeAlertCount > 0 { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundStyle(.orange) + } + Image(systemName: "cpu") .font(.system(size: 11)) Text(String(format: "%.0f%%", cpuLoad)) diff --git a/Sources/UI/Settings/AlertsSettingsView.swift b/Sources/UI/Settings/AlertsSettingsView.swift new file mode 100644 index 0000000..bf693f5 --- /dev/null +++ b/Sources/UI/Settings/AlertsSettingsView.swift @@ -0,0 +1,165 @@ +import SwiftUI +import UserNotifications + +// MARK: - Alerts Settings Pane + +/// Preferences UI for threshold alert rules. Bound directly to +/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`. +public struct AlertsSettingsView: View { + @Bindable private var engine = AlertEngine.shared + @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined + + public init() {} + + public var body: some View { + Form { + Section("Notifications") { + Toggle("Enable Threshold Alerts", isOn: $engine.configuration.alertsEnabled) + + HStack { + Text("Notification Permission") + Spacer() + Text(statusText) + .foregroundStyle(statusColor) + if authorizationStatus == .notDetermined { + Button("Request Permission") { + engine.requestNotificationAuthorization() + refreshAuthorizationStatus() + } + } else if authorizationStatus == .denied { + Button("Open System Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } + } + } + + Button("Send Test Notification") { + engine.dispatcher.dispatch(SystemAlert( + id: "testNotification", + kind: .cpuTemperature, + severity: .warning, + title: String(localized: "MacMonitor Test Alert"), + message: String(localized: "Notifications are configured correctly."), + timestamp: Date() + )) + } + .disabled(!engine.configuration.alertsEnabled) + + Picker("Alert Cooldown", selection: $engine.configuration.cooldownMinutes) { + Text("1 minute").tag(1.0) + Text("5 minutes").tag(5.0) + Text("15 minutes").tag(15.0) + Text("30 minutes").tag(30.0) + Text("1 hour").tag(60.0) + } + } + + Section("CPU Temperature") { + Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled) + LabeledContent("Threshold") { + Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.cpuTempThresholdC))°C") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + } + + Section("Cooling Fans") { + Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled) + LabeledContent("Alert when CPU above") { + Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.fanStallTempThresholdC))°C") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + Text("Triggers when a fan reports 0 RPM while the CPU is hot.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Memory") { + Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled) + LabeledContent("Free RAM below") { + Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) { + EmptyView() + } + Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB") + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + } + Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure) + } + + Section("Storage") { + Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled) + LabeledContent("Free space below") { + Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + LabeledContent("Free percent below") { + Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.storageFreePercentThreshold))%") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + } + + Section("Battery") { + Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled) + LabeledContent("Charge below") { + Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.batteryPercentThreshold))%") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + Text("Only fires while discharging on battery power.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .frame(minWidth: 480, minHeight: 520) + .task { await refreshAuthorizationStatus() } + } + + private var statusText: String { + switch authorizationStatus { + case .authorized, .provisional, .ephemeral: return "Authorized" + case .denied: return "Denied" + case .notDetermined: return "Not Requested" + @unknown default: return "Unknown" + } + } + + private var statusColor: Color { + switch authorizationStatus { + case .authorized, .provisional, .ephemeral: return .green + case .denied: return .red + default: return .secondary + } + } + + private func refreshAuthorizationStatus() { + Task { + authorizationStatus = await engine.notificationAuthorizationStatus() + } + } + + private func refreshAuthorizationStatus() async { + authorizationStatus = await engine.notificationAuthorizationStatus() + } +} diff --git a/Tests/MMAlertsTests.swift b/Tests/MMAlertsTests.swift new file mode 100644 index 0000000..c30063a --- /dev/null +++ b/Tests/MMAlertsTests.swift @@ -0,0 +1,236 @@ +import XCTest +import UserNotifications +@testable import MacMonitor + +// MARK: - Mock Dispatcher + +final class MockNotificationDispatcher: AlertNotificationDispatching { + private(set) var dispatchedAlerts: [SystemAlert] = [] + private(set) var authorizationRequests = 0 + var stubbedStatus: UNAuthorizationStatus = .authorized + + func requestAuthorization() { + authorizationRequests += 1 + } + + func dispatch(_ alert: SystemAlert) { + dispatchedAlerts.append(alert) + } + + func authorizationStatus() async -> UNAuthorizationStatus { + stubbedStatus + } +} + +// MARK: - Tests + +@MainActor +final class MMAlertsTests: XCTestCase { + private var mock: MockNotificationDispatcher! + private var engine: AlertEngine! + private var defaults: UserDefaults! + + override func setUp() async throws { + mock = MockNotificationDispatcher() + defaults = UserDefaults(suiteName: "MMAlertsTests-\(UUID().uuidString)")! + engine = AlertEngine(dispatcher: mock, defaults: defaults) + } + + // MARK: Helpers + + private func context( + peakTemp: Double = 50, + packageTemp: Double = 50, + fans: [Int: Double] = [0: 2500], + freeMemMB: Double = 8192, + pressure: String = "Normal", + volumes: [StorageVolumeContext] = [], + battery: Double = 80, + hasBattery: Bool = false, + onAC: Bool = true, + charging: Bool = false, + at date: Date = Date() + ) -> AlertEvaluationContext { + AlertEvaluationContext( + peakCoreTemperature: peakTemp, + packageTemperature: packageTemp, + fanRPMs: fans, + memoryFreeBytes: UInt64(freeMemMB * 1024 * 1024), + memoryPressureLevel: pressure, + volumes: volumes, + batteryLevel: battery, + hasBattery: hasBattery, + onExternalPower: onAC, + isCharging: charging, + timestamp: date + ) + } + + // MARK: CPU Temperature + + func testCPUTemperatureRuleTriggersAboveThreshold() { + engine.evaluate(context(peakTemp: 96)) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.kind, .cpuTemperature) + XCTAssertEqual(engine.activeAlerts.first?.severity, .critical) + XCTAssertEqual(mock.dispatchedAlerts.count, 1) + } + + func testCPUTemperatureRuleUsesMaxOfPeakAndPackage() { + engine.evaluate(context(peakTemp: 40, packageTemp: 96)) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + func testCPUTemperatureRuleDoesNotFireBelowThreshold() { + engine.evaluate(context(peakTemp: 94.9, packageTemp: 50)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(mock.dispatchedAlerts.isEmpty) + } + + // MARK: Fan Stall + + func testFanStallTriggersWhenZeroRPMWhileHot() { + engine.evaluate(context(packageTemp: 80, fans: [0: 0, 1: 2200])) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.id, "fanStall:0") + } + + func testFanStallDoesNotTriggerWhileCPUIsCool() { + engine.evaluate(context(packageTemp: 40, fans: [0: 0])) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testFanStallSkippedWhenNoFanTelemetry() { + engine.evaluate(context(packageTemp: 90, fans: [:])) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Memory + + func testMemoryRuleTriggersOnLowFreeBytes() { + engine.evaluate(context(freeMemMB: 400)) + XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure) + XCTAssertEqual(engine.activeAlerts.first?.severity, .warning) + } + + func testMemoryRuleTriggersOnCriticalPressure() { + engine.evaluate(context(freeMemMB: 8192, pressure: "Critical")) + XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure) + XCTAssertEqual(engine.activeAlerts.first?.severity, .critical) + } + + func testMemoryRuleDoesNotFireWhenHealthy() { + engine.evaluate(context(freeMemMB: 8192, pressure: "Normal")) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Storage + + func testStorageRuleTriggersPerVolume() { + let volumes = [ + StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5), + StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50) + ] + engine.evaluate(context(volumes: volumes)) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.id, "lowStorage:/") + } + + func testStorageRuleTriggersOnLowPercent() { + let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)] + engine.evaluate(context(volumes: volumes)) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + // MARK: Battery + + func testBatteryRuleTriggersOnlyWhileDischarging() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: false)) + XCTAssertEqual(engine.activeAlerts.first?.kind, .lowBattery) + } + + func testBatteryRuleSkippedOnExternalPower() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: true, charging: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testBatteryRuleSkippedWhileCharging() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: true)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testBatteryRuleSkippedWithoutBattery() { + engine.evaluate(context(battery: 5, hasBattery: false, onAC: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Cooldown & Resolution + + func testCooldownSuppressesRepeatNotifications() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(60))) + XCTAssertEqual(mock.dispatchedAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + func testCooldownExpiresAndRefires() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(16 * 60))) + XCTAssertEqual(mock.dispatchedAlerts.count, 2) + } + + func testResolutionClearsAlertAndAllowsImmediateRealert() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 60, at: t0.addingTimeInterval(60))) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(engine.history.contains { $0.outcome == .resolved }) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(120))) + XCTAssertEqual(mock.dispatchedAlerts.count, 2) + } + + func testHistoryRecordsTriggeredEvents() { + engine.evaluate(context(peakTemp: 100)) + XCTAssertEqual(engine.history.count, 1) + XCTAssertEqual(engine.history.first?.outcome, .triggered) + XCTAssertEqual(engine.history.first?.alertID, AlertKind.cpuTemperature.rawValue) + } + + // MARK: Configuration & Enablement + + func testGlobalDisableSuppressesAllRules() { + engine.configuration.alertsEnabled = false + engine.evaluate(context(peakTemp: 120, fans: [0: 0], freeMemMB: 10, battery: 1, hasBattery: true, onAC: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(mock.dispatchedAlerts.isEmpty) + } + + func testPerRuleDisableSuppressesThatRule() { + engine.configuration.cpuTempEnabled = false + engine.evaluate(context(peakTemp: 120)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testConfigurationPersistsToUserDefaults() { + engine.configuration.cpuTempThresholdC = 80 + let reloaded = AlertConfiguration.load(from: defaults) + XCTAssertEqual(reloaded.cpuTempThresholdC, 80) + } + + func testConfigurationDefaults() { + let config = AlertConfiguration() + XCTAssertTrue(config.alertsEnabled) + XCTAssertEqual(config.cooldownMinutes, 15) + XCTAssertEqual(config.cpuTempThresholdC, 95) + XCTAssertEqual(config.memoryFreeMBThreshold, 500) + XCTAssertEqual(config.storageFreeGBThreshold, 10) + XCTAssertEqual(config.batteryPercentThreshold, 10) + } + + func testAuthorizationRequestForwarded() { + engine.requestNotificationAuthorization() + XCTAssertEqual(mock.authorizationRequests, 1) + } +} -- 2.39.5 From 073d4036c7c2f2b38ac51f51f84d3e781d1daac1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:58:09 +0100 Subject: [PATCH 2/2] ci: add release packaging job producing MacMonitor-intel-x86_64.zip on tags Add a release-package job (gated on v* tags, after build-and-test) that builds the Release configuration for x86_64, ad-hoc signs the app bundle so UNUserNotificationCenter works in the distributed build, archives MacMonitor.app via ditto, uploads it as a workflow artifact, and creates a Gitea release with the zip attached via the API token. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/build.yml | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 86b137c..e4ca251 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -74,3 +74,80 @@ jobs: -destination 'platform=macOS,arch=x86_64' \ CODE_SIGNING_ALLOWED=NO fi + + release-package: + name: Package Release Artifact + needs: build-and-test + runs-on: macos-14 + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Generate Xcode Project + run: | + if command -v xcodegen &> /dev/null; then + xcodegen generate + else + echo "xcodegen not preinstalled, using tracked MacMonitor.xcodeproj" + fi + + - name: Build Release (Intel x86_64) + run: | + set -o pipefail + xcodebuild clean build \ + -scheme MacMonitor \ + -configuration Release \ + -destination 'generic/platform=macOS,arch=x86_64' \ + -derivedDataPath "$PWD/DerivedData" \ + CODE_SIGNING_ALLOWED=NO + + - name: Ad-hoc Sign Application Bundle + run: | + APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app" + # Ad-hoc signing is required for UNUserNotificationCenter authorization + # and banner delivery in distributed unsigned builds. + codesign --force --deep --sign - "$APP" + codesign --verify --verbose "$APP" + + - name: Create Distribution Archive + run: | + APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app" + cd "$(dirname "$APP")" + ditto -c -k --sequesterRsrc --keepParent "MacMonitor.app" "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" + ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" + + - name: Upload Workflow Artifact + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: MacMonitor-intel-x86_64 + path: MacMonitor-intel-x86_64.zip + + - name: Create Gitea Release & Attach Artifact + run: | + set -e + TAG="${{ github.ref_name }}" + API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}" + + # Create the release (ignore conflict if it already exists) + curl -sS -X POST "$API/releases" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"MacMonitor $TAG\",\"draft\":false,\"prerelease\":false}" \ + -o /tmp/release.json -w "create_release_http=%{http_code}\n" + + # Resolve release ID (created above or pre-existing) + RELEASE_ID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2) + if [ -z "$RELEASE_ID" ]; then + RELEASE_ID=$(curl -sS "$API/releases/tags/$TAG" -H "$AUTH" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + fi + if [ -z "$RELEASE_ID" ]; then + echo "ERROR: could not resolve release ID for $TAG"; exit 1 + fi + + curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.zip" \ + -H "$AUTH" -H "Content-Type: application/zip" \ + --data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" \ + -o /tmp/asset.json -w "attach_asset_http=%{http_code}\n" -- 2.39.5