23 Commits
Author SHA1 Message Date
gronod 070cf67796 Merge pull request 'fix(ui): Remove Deployment Target attribute from Host Architecture view' (#58) from fix/remove-deployment-target-ui into develop 2026-10-01 13:23:40 +01:00
gronod e68a053740 fix(ui): Remove Deployment Target display from Host Architecture view 2026-10-01 13:23:03 +01:00
gronod 05669f1b41 Merge pull request 'fix: Type string interpolations in ContentView for Swift 5.7 compatibility' (#57) from hotfix/macos12-string-interpolation into develop
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Successful in 1m13s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Successful in 1m11s
2026-10-01 12:57:05 +01:00
gronod 8dafffdd6e fix: Type string interpolations in ContentView for Swift 5.7 compatibility 2026-10-01 12:56:45 +01:00
gronod 68e6df2938 Merge pull request 'fix: Isolate views to @MainActor and use @ObservedObject for Swift 5.7 compatibility' (#56) from hotfix/macos12-mainactor-views into develop
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Failing after 20s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Skipped
2026-10-01 12:53:48 +01:00
gronod 7473e95635 fix: Isolate views to @MainActor and use @ObservedObject for Swift 5.7 compatibility 2026-10-01 12:53:17 +01:00
gronod 57e2ddd225 Merge pull request 'fix: Replace Swift 5.9 switch expression for Xcode 14 compatibility' (#55) from hotfix/macos12-switch-expr into develop
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Failing after 13s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Skipped
2026-10-01 12:45:41 +01:00
gronod 33bee371f1 fix: Replace Swift 5.9 switch expression for Xcode 14 compatibility 2026-10-01 12:45:11 +01:00
gronod bb91042da7 Merge pull request 'feat: macOS 12 (Monterey) Support Milestone' (#54) from milestone/macos-12-support into develop
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Failing after 1m30s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Skipped
2026-10-01 12:03:19 +01:00
gronod f8f2cc0b90 Merge pull request 'feat: UI Shims for macOS 12 (Resolves #44)' (#53) from feat/44-ui-shims into milestone/macos-12-support 2026-10-01 12:03:04 +01:00
gronod 9f5481a1a9 Fix UI shims for macOS 12 compatibility 2026-10-01 12:02:09 +01:00
gronod 5676279a4c feat(#43): Downgrade App Lifecycle and Navigation Framework 2026-10-01 11:55:10 +01:00
gronod 7ba4284be1 feat(#42): Refactor State Management from Observation to Combine 2026-10-01 11:53:08 +01:00
gronod 01e871dc2c feat(#41): Revert target deployment configuration to macOS 12 2026-10-01 11:48:27 +01:00
gronod beabf016b0 chore: ignore reference and plan markdown files 2026-10-01 11:36:34 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 60f4039013 chore(release): bump version to 0.0.2 (build 2)
M6 bugfix release: restores all telemetry sections (snapshot contract,
SMC service+struct fixes), alert false-positive filtering, and sidebar
selection.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:19:34 +01:00
gronod 125c489881 Merge pull request 'M6: Bugfix & Stability — milestone integration' (#39) from milestone/m6-bugfix-stability into develop 2026-09-08 15:18:52 +01:00
gronod d75873fa30 Merge pull request 'fix(ui): restore Alerts sidebar row selection (Issue #34)' (#38) from fix/34-alerts-sidebar-badge into milestone/m6-bugfix-stability 2026-09-08 15:18:25 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3095bd106a fix(ui): restore Alerts sidebar row selection by replacing .badge (fixes #34)
Applying .badge after .tag on a List(selection:) row wrapped the tagged
label in a view that no longer carried the tag, making the "Alerts &
Notifications" row unselectable on macOS 14. Replaced with an explicit
Label + HStack row showing the active-alert count as a styled capsule —
tag stays on the row, count still renders when alerts are active.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:18:07 +01:00
gronod d0c286e7c8 Merge pull request 'fix(alerts): skip read-only/small volumes in low-storage rule (Issue #33)' (#37) from fix/33-readonly-storage-alerts into milestone/m6-bugfix-stability 2026-09-08 15:17:04 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 807fcae6b2 fix(alerts): skip read-only and trivially small volumes in low-storage rule (fixes #33)
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>
2026-09-08 15:16:45 +01:00
gronod 0e5af9d70d Merge pull request 'fix(smc): correct IOKit service name + SMCKeyData_t layout (Issue #32)' (#36) from fix/32-smc-service-name into milestone/m6-bugfix-stability 2026-09-08 15:15:08 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> acf5d03277 fix(smc): correct IOKit service name and SMCKeyData_t layout (fixes #32)
Two latent bugs kept all SMC telemetry dead:
- IOServiceMatching("AppleSMCClient") never matched — the kernel service
  is AppleSMC, so openWithError always failed and every SMC-gated provider
  (thermal, fan, component, power) reported unavailable.
- #pragma pack(1) shrank SMCKeyData_t to 74 bytes; the SMC user client
  requires the canonical 80-byte layout and rejected every
  IOConnectCallStructMethod with kIOReturnBadArgument. Verified live on
  Intel hardware: unpacked calls succeed and return key data.

Also sources the alert engine's batteryLevel from the SMC-independent
batteryHealth path (currentCapacity/maxCapacity), falling back to the
power provider.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:14:41 +01:00
20 changed files with 368 additions and 170 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ concurrency:
jobs:
build-and-test:
name: Build & Test (Intel x86_64)
runs-on: macos-14
runs-on: macos-12
steps:
- name: Check out repository
@@ -78,7 +78,7 @@ jobs:
release-package:
name: Package Release Artifact
needs: build-and-test
runs-on: macos-14
runs-on: macos-12
if: startsWith(github.ref, 'refs/tags/v')
steps:
+4
View File
@@ -25,3 +25,7 @@ xcuserdata/
*.dmg
*.zip
*.app
FIRST-PASS-REFERENCE.md
SECOND-PASS-REFERENCE.md
Docs/AppleSilicon-plan.md
Docs/Monterey-plan.md
+3 -3
View File
@@ -3,7 +3,7 @@
## 1. Project Specifications & Architectural Constraints
- **Platform Target:** macOS for Intel (`x86_64`).
- **Minimum OS Version:** macOS 14.0 (Sonoma) or higher.
- **Minimum OS Version:** macOS 12.0 (Monterey) or higher.
- **Language Stack:** SwiftUI (Presentation & ViewModels) + Objective-C / C (Low-level Mach, IOKit, SMC, BSD Sysctl APIs).
- **Interoperability:** Modular Objective-C bridging layer (`MacMonitor-Bridging-Header.h`) exposing thread-safe C primitives to Swift.
- **CI/CD Platform:** Gitea Actions executing on Intel macOS runners (`[macos, intel]`).
@@ -144,7 +144,7 @@ graph TD
2. **Phase 1.2 (Parallel Tracks):**
- **Track 1.2A:** `#2` AppleSMC Client (`feat/2-smc-client`). Low-level SMC key reader, data parsers (`sp78`, `fpe2`, `flt`).
- **Track 1.2B:** `#25` Gitea CI/CD Pipeline (`feat/25-gitea-ci`). Create `.gitea/workflows/build.yml` targeting Intel runners.
- **Quality Gate M1:** Successful build and execution of sample test suite on Gitea Actions runner compiling for `x86_64` macOS 14.
- **Quality Gate M1:** Successful build and execution of sample test suite on Gitea Actions runner compiling for `x86_64` macOS 12.
---
@@ -189,7 +189,7 @@ graph TD
### Milestone 4: Presentation, Visuals & Menu Bar Integration
- **Integration Branch:** `milestone/m4-presentation`
- **Goal:** User-facing presentation layer: macOS 14 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs.
- **Goal:** User-facing presentation layer: macOS 12 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs.
- **Execution Order:**
1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`)
2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`)
+10 -10
View File
@@ -698,7 +698,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -720,7 +720,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -765,7 +765,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -778,8 +778,8 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 0.0.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
@@ -807,7 +807,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -852,7 +852,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -871,8 +871,8 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 0.0.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
@@ -902,7 +902,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
+108 -13
View File
@@ -1,19 +1,67 @@
import SwiftUI
import Combine
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center
/// authorization once at launch so threshold alerts can post banners.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
var cancellable: AnyCancellable?
func applicationDidFinishLaunching(_ notification: Notification) {
if AlertEngine.shared.configuration.alertsEnabled {
AlertEngine.shared.requestNotificationAuthorization()
}
setupMenuBar()
}
private func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let popover = NSPopover()
popover.contentSize = NSSize(width: 320, height: 400)
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: QuickGlancePopoverView(store: SystemTelemetryStore.shared))
self.popover = popover
if let button = statusItem?.button {
button.action = #selector(togglePopover(_:))
button.target = self
}
cancellable = SystemTelemetryStore.shared.$latestSnapshot.sink { [weak self] _ in
DispatchQueue.main.async {
self?.updateMenuBarLabel()
}
}
updateMenuBarLabel()
}
@objc func togglePopover(_ sender: AnyObject?) {
guard let button = statusItem?.button, let popover = popover else { return }
if popover.isShown {
popover.performClose(sender)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
private func updateMenuBarLabel() {
guard let button = statusItem?.button else { return }
let cpu = SystemTelemetryStore.shared.cpuLoad.totalLoad
let mem = SystemTelemetryStore.shared.memory.utilizationPercentage
let alerts = AlertEngine.shared.activeAlerts.count
let alertStr = alerts > 0 ? "⚠️ " : ""
button.title = String(format: "%@CPU: %.0f%% • RAM: %.0f%%", alertStr, cpu, mem)
}
}
@main
struct MacMonitorApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var store = SystemTelemetryStore.shared
@StateObject private var store = SystemTelemetryStore.shared
var body: some Scene {
WindowGroup {
@@ -21,21 +69,68 @@ 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,
activeAlertCount: store.alertEngine.activeAlerts.count
)
}
.menuBarExtraStyle(.window)
Settings {
AlertsSettingsView()
}
}
}
// MARK: - UI Shims (macOS 12 Support)
public struct MMLabeledContent<Label: View, Content: View>: View {
public let label: Label
public let content: Content
public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
self.label = label()
self.content = content()
}
public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: () -> Content) where Label == Text {
self.label = Text(titleKey)
self.content = content()
}
public init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) where Label == Text {
self.label = Text(title)
self.content = content()
}
public var body: some View {
HStack {
label
Spacer()
content
}
}
}
public extension View {
@ViewBuilder
func mmMonospaced() -> some View {
if #available(macOS 13.0, *) {
self.monospaced()
} else {
self
}
}
@ViewBuilder
func mmMonospacedDigit() -> some View {
if #available(macOS 13.0, *) {
self.monospacedDigit()
} else {
self
}
}
@ViewBuilder
func mmFormStyleGrouped() -> some View {
if #available(macOS 13.0, *) {
self.formStyle(.grouped)
} else {
self
}
}
}
+30 -27
View File
@@ -1,6 +1,6 @@
import Foundation
import SwiftUI
import Observation
import Combine
// MARK: - Typed Telemetry Models
@@ -220,39 +220,38 @@ public struct AudioDeviceItem: Identifiable, Sendable {
// MARK: - SystemTelemetryStore
@Observable
@MainActor
public final class SystemTelemetryStore {
public final class SystemTelemetryStore: ObservableObject {
public static let shared = SystemTelemetryStore()
public private(set) var isRunning: Bool = false
public private(set) var lastUpdateTimestamp: Date = .now
public private(set) var sampleInterval: TimeInterval = 1.0
@Published public private(set) var isRunning: Bool = false
@Published public private(set) var lastUpdateTimestamp: Date = .now
@Published public private(set) var sampleInterval: TimeInterval = 1.0
/// Raw snapshots organized by provider identifier
public private(set) var latestSnapshot: [String: [String: Any]] = [:]
@Published public private(set) var latestSnapshot: [String: [String: Any]] = [:]
/// Milestone 2 Structured Metrics
public private(set) var cpuLoad = CPULoadMetrics()
public private(set) var memory = MemoryMetrics()
public private(set) var storageVolumes: [StorageVolumeItem] = []
public private(set) var cpuThermal = CPUThermalMetrics()
public private(set) var fans: [FanTelemetryItem] = []
public private(set) var componentTemps: [ComponentThermalItem] = []
public private(set) var power = PowerMetrics()
@Published public private(set) var cpuLoad = CPULoadMetrics()
@Published public private(set) var memory = MemoryMetrics()
@Published public private(set) var storageVolumes: [StorageVolumeItem] = []
@Published public private(set) var cpuThermal = CPUThermalMetrics()
@Published public private(set) var fans: [FanTelemetryItem] = []
@Published public private(set) var componentTemps: [ComponentThermalItem] = []
@Published public private(set) var power = PowerMetrics()
/// Milestone 3 Structured Metrics
public private(set) var kernelCounters = KernelCountersMetrics()
public private(set) var systemLoad = SystemLoadMetrics()
public private(set) var diskIO: [DiskIOItem] = []
public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
public private(set) var networkSockets: [SocketItem] = []
public private(set) var processes: [ProcessItem] = []
public private(set) var gpus: [GPUCardItem] = []
public private(set) var batteryHealth = BatteryHealthMetrics()
public private(set) var peripherals: [PeripheralDeviceItem] = []
public private(set) var audioDevices: [AudioDeviceItem] = []
public private(set) var displays: [MMDisplayInfo] = []
@Published public private(set) var kernelCounters = KernelCountersMetrics()
@Published public private(set) var systemLoad = SystemLoadMetrics()
@Published public private(set) var diskIO: [DiskIOItem] = []
@Published public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
@Published public private(set) var networkSockets: [SocketItem] = []
@Published public private(set) var processes: [ProcessItem] = []
@Published public private(set) var gpus: [GPUCardItem] = []
@Published public private(set) var batteryHealth = BatteryHealthMetrics()
@Published public private(set) var peripherals: [PeripheralDeviceItem] = []
@Published public private(set) var audioDevices: [AudioDeviceItem] = []
@Published public private(set) var displays: [MMDisplayInfo] = []
/// Historical Telemetry Store
public let historyStore = TelemetryHistoryStore.shared
@@ -702,10 +701,14 @@ public final class SystemTelemetryStore {
freeBytes: $0.freeBytes,
freePercent: $0.totalBytes > 0
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
: 0
: 0,
totalBytes: $0.totalBytes,
isReadOnly: $0.isReadOnly
)
}
context.batteryLevel = self.power.batteryLevel
context.batteryLevel = self.batteryHealth.maxCapacity > 0
? Double(self.batteryHealth.currentCapacity) / Double(self.batteryHealth.maxCapacity) * 100.0
: self.power.batteryLevel
context.hasBattery = self.batteryHealth.hasBattery
context.onExternalPower = self.batteryHealth.externalConnected
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
+3 -3
View File
@@ -57,13 +57,13 @@
return YES;
}
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient");
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC");
if (!matching) {
os_unfair_lock_unlock(&_lock);
if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}];
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}];
}
return NO;
}
@@ -74,7 +74,7 @@
if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
code:-2
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMCClient service not found (running on non-Mac or unsupported VM)"}];
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMC service not found (running on non-Mac or unsupported VM)"}];
}
return NO;
}
-4
View File
@@ -15,8 +15,6 @@
#define kSMCGetKeyFromIndex 8
#define kSMCGetKeyInfo 9
#pragma pack(push, 1)
typedef struct {
unsigned char major;
unsigned char minor;
@@ -58,8 +56,6 @@ typedef struct {
UInt8 bytes[32];
} SMCVal_t;
#pragma pack(pop)
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
UInt32 code = 0;
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
+3 -3
View File
@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import Combine
public struct TelemetrySnapshotRecord {
public let cpuLoad: Double
@@ -35,8 +36,7 @@ public struct TelemetrySnapshotRecord {
}
}
@Observable
public final class TelemetryHistoryStore {
public final class TelemetryHistoryStore: ObservableObject {
public static let shared = TelemetryHistoryStore()
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
@@ -48,7 +48,7 @@ public final class TelemetryHistoryStore {
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
public private(set) var sampleCounter: UInt64 = 0
@Published public private(set) var sampleCounter: UInt64 = 0
public init() {}
+9 -6
View File
@@ -1,5 +1,6 @@
import Foundation
import UserNotifications
import Combine
// MARK: - Alert Engine
@@ -13,18 +14,17 @@ import UserNotifications
/// 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 final class AlertEngine: ObservableObject {
public static let shared = AlertEngine()
/// Currently breached conditions, refreshed every evaluation pass.
public private(set) var activeAlerts: [SystemAlert] = []
@Published public private(set) var activeAlerts: [SystemAlert] = []
/// Newest-first audit log of trigger/resolve events (capped).
public private(set) var history: [AlertEvent] = []
@Published public private(set) var history: [AlertEvent] = []
/// Threshold settings. Mutations are persisted immediately.
public var configuration: AlertConfiguration {
@Published public var configuration: AlertConfiguration {
didSet { configuration.save(to: defaults) }
}
@@ -163,7 +163,10 @@ public final class AlertEngine {
// 4. Low storage space (per mounted volume)
if cfg.storageEnabled {
for volume in ctx.volumes {
// 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(
+7 -1
View File
@@ -70,12 +70,18 @@ public struct StorageVolumeContext: Equatable, Sendable {
public let volumeName: String
public let freeBytes: UInt64
public let freePercent: Double
public let totalBytes: UInt64
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
public let isReadOnly: Bool
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) {
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
self.mountPoint = mountPoint
self.volumeName = volumeName
self.freeBytes = freeBytes
self.freePercent = freePercent
self.totalBytes = totalBytes
self.isReadOnly = isReadOnly
}
}
@@ -1,20 +1,20 @@
import Foundation
import UserNotifications
import Combine
// 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 final class AppNavigationBus: ObservableObject {
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?
@Published 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
@Published public private(set) var requestCounter: Int = 0
private init() {}
@@ -118,11 +118,12 @@ public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDis
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let targetTab: String? = switch response.actionIdentifier {
let targetTab: String?
switch response.actionIdentifier {
case AlertNotificationAction.viewProcessesIdentifier:
"Processes"
targetTab = "Processes"
default:
nil
targetTab = nil
}
Task { @MainActor in
AppNavigationBus.shared.request(tab: targetTab)
+3 -2
View File
@@ -4,8 +4,9 @@ import SwiftUI
/// Sidebar tab showing currently breached thresholds and the in-app
/// alert history log maintained by `AlertEngine`.
@MainActor
public struct AlertsView: View {
@State private var engine = AlertEngine.shared
@ObservedObject private var engine = AlertEngine.shared
public init() {}
@@ -109,7 +110,7 @@ public struct AlertsView: View {
Text(event.alertID)
.font(.caption2)
.foregroundStyle(.secondary)
.monospaced()
.mmMonospaced()
}
Spacer()
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
@@ -1,5 +1,7 @@
import SwiftUI
#if canImport(Charts)
import Charts
#endif
public struct TelemetryTrendChartView: View {
public let title: String
@@ -43,8 +45,7 @@ public struct TelemetryTrendChartView: View {
Spacer()
HStack(spacing: 12) {
Text(String(format: "Cur: %.1f %@", currentValue, unit))
.font(.caption)
.fontWeight(.semibold)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(color)
Text(String(format: "Avg: %.1f %@", averageValue, unit))
.font(.caption)
@@ -55,6 +56,8 @@ public struct TelemetryTrendChartView: View {
}
}
#if canImport(Charts)
if #available(macOS 13.0, *) {
Chart {
ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in
LineMark(
@@ -81,9 +84,50 @@ public struct TelemetryTrendChartView: View {
.chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15)))
.chartXAxis(.hidden)
.frame(height: 120)
} else {
fallbackChart
}
#else
fallbackChart
#endif
}
.padding(14)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(10)
}
@ViewBuilder
private var fallbackChart: some View {
Canvas { context, size in
guard samples.count > 1 else { return }
let effectiveMaxY = maxY ?? max(1.0, maxValue * 1.15)
let dx = size.width / CGFloat(samples.count - 1)
var path = Path()
var areaPath = Path()
for (index, sample) in samples.enumerated() {
let x = CGFloat(index) * dx
let y = size.height - (CGFloat(sample.value) / CGFloat(effectiveMaxY)) * size.height
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
areaPath.move(to: CGPoint(x: x, y: size.height))
areaPath.addLine(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
areaPath.addLine(to: CGPoint(x: x, y: y))
}
}
if let lastIndex = samples.indices.last {
let lastX = CGFloat(lastIndex) * dx
areaPath.addLine(to: CGPoint(x: lastX, y: size.height))
areaPath.closeSubpath()
}
context.fill(areaPath, with: .linearGradient(Gradient(colors: [color.opacity(0.35), color.opacity(0.05)]), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height)))
context.stroke(path, with: .color(color), lineWidth: 2)
}
.frame(height: 120)
}
}
+61 -40
View File
@@ -1,20 +1,20 @@
import SwiftUI
@MainActor
public struct ContentView: View {
@State private var store = SystemTelemetryStore.shared
@State private var selectedTab: String = "Dashboard"
@StateObject private var store = SystemTelemetryStore.shared
@State private var selectedTab: String? = "Dashboard"
@State private var processSearchText: String = ""
@State private var socketSearchText: String = ""
@State private var selectedPID: Int32? = nil
@State private var showingProcessDetail: Bool = false
@State private var navBus = AppNavigationBus.shared
@StateObject private var navBus = AppNavigationBus.shared
public init() {}
public var body: some View {
NavigationSplitView {
NavigationView {
sidebarContent
} detail: {
detailContent
}
.frame(minWidth: 1000, minHeight: 700)
@@ -26,7 +26,7 @@ public struct ContentView: View {
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
}
}
.onChange(of: navBus.requestCounter) { _, _ in
.onChange(of: navBus.requestCounter) { _ in
NSApp.activate(ignoringOtherApps: true)
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
window.makeKeyAndOrderFront(nil)
@@ -87,13 +87,29 @@ public struct ContentView: View {
}
Section("Intelligence") {
Label("Alerts & Notifications", systemImage: "bell.badge")
// Custom row instead of .badge() — a badge applied after .tag
// detaches the tag from the list row and makes it unselectable.
Label {
HStack {
Text("Alerts & Notifications")
Spacer()
if !store.alertEngine.activeAlerts.isEmpty {
Text("\(store.alertEngine.activeAlerts.count)")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.85), in: Capsule())
.foregroundStyle(.white)
}
}
} icon: {
Image(systemName: "bell.badge")
}
.tag("Alerts")
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
}
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300)
.frame(minWidth: 210, idealWidth: 240, maxWidth: 300)
}
// MARK: - Detail Content Router
@@ -104,7 +120,7 @@ public struct ContentView: View {
headerCard
engineControlCard
switch selectedTab {
switch selectedTab ?? "Dashboard" {
case "Dashboard":
dashboardView
case "Architecture":
@@ -153,7 +169,7 @@ public struct ContentView: View {
}
.padding(24)
}
.navigationTitle("MacMonitor — \(selectedTab)")
.navigationTitle("MacMonitor — \(selectedTab ?? "Dashboard")")
}
// MARK: - Dashboard Composite View
@@ -342,20 +358,20 @@ public struct ContentView: View {
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
.font(.headline)
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) {
GridRow {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Context Switches:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
Text("Syscalls:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
}
GridRow {
HStack {
Text("Page Faults:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
Text("COW Faults:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
}
GridRow {
HStack {
Text("Pageins / Pageouts:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
Text("Zero-Fill:").foregroundStyle(.secondary)
@@ -430,20 +446,20 @@ public struct ContentView: View {
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
GridRow {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Used:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.usedBytes)).bold()
Text("Free:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.freeBytes)).bold()
}
GridRow {
HStack {
Text("Wired:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.wiredBytes))
Text("Compressed:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.compressedBytes))
}
GridRow {
HStack {
Text("Swap Used:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.swapUsedBytes))
Text("Swap Total:").foregroundStyle(.secondary)
@@ -587,10 +603,10 @@ public struct ContentView: View {
Text(s.protocolName)
}
TableColumn("Local Endpoint") { s in
Text("\(s.localAddress):\(s.localPort)").monospaced()
Text("\(s.localAddress):\(s.localPort)").mmMonospaced()
}
TableColumn("Remote Endpoint") { s in
Text("\(s.remoteAddress):\(s.remotePort)").monospaced()
Text("\(s.remoteAddress):\(s.remotePort)").mmMonospaced()
}
TableColumn("State") { s in
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
@@ -736,20 +752,20 @@ public struct ContentView: View {
if !store.batteryHealth.hasBattery {
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
} else {
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
GridRow {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Cycle Count:").foregroundStyle(.secondary)
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
Text("Temperature:").foregroundStyle(.secondary)
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
}
GridRow {
HStack {
Text("Voltage / Power:").foregroundStyle(.secondary)
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
Text("Power Adapter:").foregroundStyle(.secondary)
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
}
GridRow {
HStack {
Text("Capacity:").foregroundStyle(.secondary)
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
Text("Manufacturer:").foregroundStyle(.secondary)
@@ -782,10 +798,10 @@ public struct ContentView: View {
.background(Color.secondary.opacity(0.15), in: Capsule())
}
TableColumn("Vendor ID") { d in
Text(String(format: "0x%04x", d.vendorID)).monospaced()
Text(String(format: "0x%04x", d.vendorID)).mmMonospaced()
}
TableColumn("Product ID") { d in
Text(String(format: "0x%04x", d.productID)).monospaced()
Text(String(format: "0x%04x", d.productID)).mmMonospaced()
}
TableColumn("Built-In") { d in
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
@@ -946,14 +962,14 @@ public struct ContentView: View {
.font(.title3.bold().monospacedDigit())
}
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
GridRow {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("CPU Power:").foregroundStyle(.secondary)
Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
Text("GPU Power:").foregroundStyle(.secondary)
Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
}
GridRow {
HStack {
Text("CPU Voltage:").foregroundStyle(.secondary)
Text(String(format: "%.2f V", store.power.cpuVoltage))
Text("CPU Current:").foregroundStyle(.secondary)
@@ -974,24 +990,22 @@ public struct ContentView: View {
Text("Host Architecture & Kernel")
.font(.headline)
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) {
GridRow {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Hardware Model:").foregroundStyle(.secondary)
Text(store.hostModel).bold()
Text("Physical Cores:").foregroundStyle(.secondary)
Text("\(store.physicalCpuCount)").bold()
}
GridRow {
HStack {
Text("Darwin Kernel:").foregroundStyle(.secondary)
Text(store.kernelVersion).bold()
Text("Logical Cores:").foregroundStyle(.secondary)
Text("\(store.logicalCpuCount)").bold()
}
GridRow {
HStack {
Text("Target Architecture:").foregroundStyle(.secondary)
Text("Intel x86_64").bold()
Text("Deployment Target:").foregroundStyle(.secondary)
Text("macOS 14.0 (Sonoma)").bold()
}
}
}
@@ -1205,6 +1219,7 @@ public struct ContentView: View {
}
// MARK: - Process Detail Inspector Sheet
@MainActor
struct ProcessDetailSheet: View {
let inspector: MMProcessDetailInspector
let pid: Int32
@@ -1233,8 +1248,9 @@ struct ProcessDetailSheet: View {
if selectedDetailTab == "Threads" {
List(threads.indices, id: \.self) { idx in
let t = threads[idx]
let threadID = "\(t["threadID"] ?? idx)"
HStack {
Text("Thread \(t["threadID"] ?? idx)").bold()
Text("Thread \(threadID)").bold()
Spacer()
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
@@ -1247,11 +1263,16 @@ struct ProcessDetailSheet: View {
} else {
List(sockets.indices, id: \.self) { idx in
let s = sockets[idx]
let proto = s["protocol"] as? String ?? "TCP"
let localAddr = (s["localAddress"] as? String) ?? ""
let localPort = (s["localPort"] as? Int) ?? 0
let remoteAddr = (s["remoteAddress"] as? String) ?? ""
let remotePort = (s["remotePort"] as? Int) ?? 0
HStack {
Text(s["protocol"] as? String ?? "TCP").bold()
Text(proto).bold()
Spacer()
Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)")
.monospaced()
Text("\(localAddr):\(localPort) → \(remoteAddr):\(remotePort)")
.mmMonospaced()
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
}
}
@@ -1,7 +1,8 @@
import SwiftUI
@MainActor
public struct QuickGlancePopoverView: View {
var store: SystemTelemetryStore
@ObservedObject var store: SystemTelemetryStore
public init(store: SystemTelemetryStore) {
self.store = store
@@ -109,9 +110,7 @@ public struct QuickGlancePopoverView: View {
}
Spacer()
Text(value)
.font(.subheadline)
.fontWeight(.semibold)
.fontDesign(.monospaced)
.font(.system(size: 13, weight: .semibold, design: .monospaced))
}
}
+15 -14
View File
@@ -5,8 +5,9 @@ import UserNotifications
/// Preferences UI for threshold alert rules. Bound directly to
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
@MainActor
public struct AlertsSettingsView: View {
@Bindable private var engine = AlertEngine.shared
@ObservedObject private var engine = AlertEngine.shared
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
public init() {}
@@ -58,24 +59,24 @@ public struct AlertsSettingsView: View {
Section("CPU Temperature") {
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
LabeledContent("Threshold") {
MMLabeledContent("Threshold") {
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 48, alignment: .trailing)
}
}
Section("Cooling Fans") {
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
LabeledContent("Alert when CPU above") {
MMLabeledContent("Alert when CPU above") {
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 48, alignment: .trailing)
}
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
@@ -85,12 +86,12 @@ public struct AlertsSettingsView: View {
Section("Memory") {
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
LabeledContent("Free RAM below") {
MMLabeledContent("Free RAM below") {
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
EmptyView()
}
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 64, alignment: .trailing)
}
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
@@ -98,32 +99,32 @@ public struct AlertsSettingsView: View {
Section("Storage") {
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
LabeledContent("Free space below") {
MMLabeledContent("Free space below") {
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 48, alignment: .trailing)
}
LabeledContent("Free percent below") {
MMLabeledContent("Free percent below") {
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 48, alignment: .trailing)
}
}
Section("Battery") {
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
LabeledContent("Charge below") {
MMLabeledContent("Charge below") {
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
EmptyView()
}
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
.monospacedDigit()
.mmMonospacedDigit()
.frame(width: 48, alignment: .trailing)
}
Text("Only fires while discharging on battery power.")
@@ -131,7 +132,7 @@ public struct AlertsSettingsView: View {
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.mmFormStyleGrouped()
.frame(minWidth: 480, minHeight: 520)
.task { await refreshAuthorizationStatus() }
}
+26 -3
View File
@@ -128,8 +128,10 @@ final class MMAlertsTests: XCTestCase {
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)
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
freePercent: 5, totalBytes: 500 * 1_073_741_824),
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
freePercent: 50, totalBytes: 1_000_000_000_000)
]
engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1)
@@ -137,11 +139,32 @@ final class MMAlertsTests: XCTestCase {
}
func testStorageRuleTriggersOnLowPercent() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)]
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big",
freeBytes: 200 * 1_073_741_824, freePercent: 4,
totalBytes: 2_000_000_000_000)]
engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1)
}
func testStorageRuleSkipsReadOnlyVolumes() {
// A read-only DMG mount (e.g. an app running from a mounted image)
// with ~0% free must not alert — issue #33 false positive.
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
freeBytes: 1_048_576, freePercent: 0.1,
totalBytes: 500_000_000_000, isReadOnly: true)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
XCTAssertTrue(engine.history.isEmpty)
}
func testStorageRuleSkipsTinyVolumes() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
freeBytes: 1_048_576, freePercent: 0.5,
totalBytes: 200_000_000, isReadOnly: false)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
}
// MARK: Battery
func testBatteryRuleTriggersOnlyWhileDischarging() {
+1
View File
@@ -2,6 +2,7 @@ import XCTest
import SwiftUI
@testable import MacMonitor
@MainActor
final class MMMenuBarTests: XCTestCase {
func testMenuBarStatusViewInitialization() {
+2 -2
View File
@@ -16,8 +16,8 @@ settings:
CLANG_ENABLE_OBJC_ARC: YES
CLANG_ENABLE_MODULES: YES
GENERATE_INFOPLIST_FILE: YES
MARKETING_VERSION: "0.0.1"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "0.0.2"
CURRENT_PROJECT_VERSION: "2"
CODE_SIGNING_ALLOWED: NO
CODE_SIGN_IDENTITY: ""
GCC_C_LANGUAGE_STANDARD: "gnu17"