1 Commits
Author SHA1 Message Date
gronod 64392a5b9f ci: produce release distribution artifact as .dmg file 2026-09-08 14:59:10 +01:00
27 changed files with 225 additions and 613 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ concurrency:
jobs: jobs:
build-and-test: build-and-test:
name: Build & Test (Intel x86_64) name: Build & Test (Intel x86_64)
runs-on: macos-12 runs-on: macos-14
steps: steps:
- name: Check out repository - name: Check out repository
@@ -78,7 +78,7 @@ jobs:
release-package: release-package:
name: Package Release Artifact name: Package Release Artifact
needs: build-and-test needs: build-and-test
runs-on: macos-12 runs-on: macos-14
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
-4
View File
@@ -25,7 +25,3 @@ xcuserdata/
*.dmg *.dmg
*.zip *.zip
*.app *.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 ## 1. Project Specifications & Architectural Constraints
- **Platform Target:** macOS for Intel (`x86_64`). - **Platform Target:** macOS for Intel (`x86_64`).
- **Minimum OS Version:** macOS 12.0 (Monterey) or higher. - **Minimum OS Version:** macOS 14.0 (Sonoma) or higher.
- **Language Stack:** SwiftUI (Presentation & ViewModels) + Objective-C / C (Low-level Mach, IOKit, SMC, BSD Sysctl APIs). - **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. - **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]`). - **CI/CD Platform:** Gitea Actions executing on Intel macOS runners (`[macos, intel]`).
@@ -144,7 +144,7 @@ graph TD
2. **Phase 1.2 (Parallel Tracks):** 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.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. - **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 12. - **Quality Gate M1:** Successful build and execution of sample test suite on Gitea Actions runner compiling for `x86_64` macOS 14.
--- ---
@@ -189,7 +189,7 @@ graph TD
### Milestone 4: Presentation, Visuals & Menu Bar Integration ### Milestone 4: Presentation, Visuals & Menu Bar Integration
- **Integration Branch:** `milestone/m4-presentation` - **Integration Branch:** `milestone/m4-presentation`
- **Goal:** User-facing presentation layer: macOS 12 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs. - **Goal:** User-facing presentation layer: macOS 14 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs.
- **Execution Order:** - **Execution Order:**
1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`) 1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`)
2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`) 2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`)
+10 -14
View File
@@ -16,7 +16,6 @@
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; }; 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; };
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */; };
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; };
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; }; 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; };
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; };
@@ -168,7 +167,6 @@
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = "<group>"; }; E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = "<group>"; };
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = "<group>"; }; E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = "<group>"; };
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSnapshotContractTests.swift; sourceTree = "<group>"; };
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; }; F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; };
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = "<group>"; };
@@ -440,7 +438,6 @@
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */, 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */,
52471E871E5CC1E444318070 /* MMProcessTests.swift */, 52471E871E5CC1E444318070 /* MMProcessTests.swift */,
940D608A1AF80F75584E744E /* MMSMCTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */,
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */,
78325870C7CD8312AECA2236 /* MMStorageTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
); );
path = Tests; path = Tests;
@@ -621,7 +618,6 @@
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */, 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */,
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */, 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */,
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */,
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
); );
@@ -698,7 +694,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor; PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -720,7 +716,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
"@loader_path/../Frameworks", "@loader_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests; PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -765,7 +761,7 @@
CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = ""; CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -778,8 +774,8 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.2; MARKETING_VERSION = 0.0.1;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
@@ -807,7 +803,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
"@loader_path/../Frameworks", "@loader_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests; PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
@@ -852,7 +848,7 @@
CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = ""; CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
@@ -871,8 +867,8 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.2; MARKETING_VERSION = 0.0.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
@@ -902,7 +898,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor; PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
+13 -108
View File
@@ -1,67 +1,19 @@
import SwiftUI import SwiftUI
import Combine
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center /// Bridges app-lifecycle events into SwiftUI: requests Notification Center
/// authorization once at launch so threshold alerts can post banners. /// authorization once at launch so threshold alerts can post banners.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
var cancellable: AnyCancellable?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
if AlertEngine.shared.configuration.alertsEnabled { if AlertEngine.shared.configuration.alertsEnabled {
AlertEngine.shared.requestNotificationAuthorization() 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 @main
struct MacMonitorApp: App { struct MacMonitorApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var store = SystemTelemetryStore.shared @State private var store = SystemTelemetryStore.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@@ -69,68 +21,21 @@ struct MacMonitorApp: App {
} }
.windowStyle(.titleBar) .windowStyle(.titleBar)
.windowToolbarStyle(.unified) .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 { Settings {
AlertsSettingsView() 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
}
}
}
+71 -75
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Observation
// MARK: - Typed Telemetry Models // MARK: - Typed Telemetry Models
@@ -220,38 +220,39 @@ public struct AudioDeviceItem: Identifiable, Sendable {
// MARK: - SystemTelemetryStore // MARK: - SystemTelemetryStore
@Observable
@MainActor @MainActor
public final class SystemTelemetryStore: ObservableObject { public final class SystemTelemetryStore {
public static let shared = SystemTelemetryStore() public static let shared = SystemTelemetryStore()
@Published public private(set) var isRunning: Bool = false public private(set) var isRunning: Bool = false
@Published public private(set) var lastUpdateTimestamp: Date = .now public private(set) var lastUpdateTimestamp: Date = .now
@Published public private(set) var sampleInterval: TimeInterval = 1.0 public private(set) var sampleInterval: TimeInterval = 1.0
/// Raw snapshots organized by provider identifier /// Raw snapshots organized by provider identifier
@Published public private(set) var latestSnapshot: [String: [String: Any]] = [:] public private(set) var latestSnapshot: [String: [String: Any]] = [:]
/// Milestone 2 Structured Metrics /// Milestone 2 Structured Metrics
@Published public private(set) var cpuLoad = CPULoadMetrics() public private(set) var cpuLoad = CPULoadMetrics()
@Published public private(set) var memory = MemoryMetrics() public private(set) var memory = MemoryMetrics()
@Published public private(set) var storageVolumes: [StorageVolumeItem] = [] public private(set) var storageVolumes: [StorageVolumeItem] = []
@Published public private(set) var cpuThermal = CPUThermalMetrics() public private(set) var cpuThermal = CPUThermalMetrics()
@Published public private(set) var fans: [FanTelemetryItem] = [] public private(set) var fans: [FanTelemetryItem] = []
@Published public private(set) var componentTemps: [ComponentThermalItem] = [] public private(set) var componentTemps: [ComponentThermalItem] = []
@Published public private(set) var power = PowerMetrics() public private(set) var power = PowerMetrics()
/// Milestone 3 Structured Metrics /// Milestone 3 Structured Metrics
@Published public private(set) var kernelCounters = KernelCountersMetrics() public private(set) var kernelCounters = KernelCountersMetrics()
@Published public private(set) var systemLoad = SystemLoadMetrics() public private(set) var systemLoad = SystemLoadMetrics()
@Published public private(set) var diskIO: [DiskIOItem] = [] public private(set) var diskIO: [DiskIOItem] = []
@Published public private(set) var networkBandwidth: [NetworkBandwidthItem] = [] public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
@Published public private(set) var networkSockets: [SocketItem] = [] public private(set) var networkSockets: [SocketItem] = []
@Published public private(set) var processes: [ProcessItem] = [] public private(set) var processes: [ProcessItem] = []
@Published public private(set) var gpus: [GPUCardItem] = [] public private(set) var gpus: [GPUCardItem] = []
@Published public private(set) var batteryHealth = BatteryHealthMetrics() public private(set) var batteryHealth = BatteryHealthMetrics()
@Published public private(set) var peripherals: [PeripheralDeviceItem] = [] public private(set) var peripherals: [PeripheralDeviceItem] = []
@Published public private(set) var audioDevices: [AudioDeviceItem] = [] public private(set) var audioDevices: [AudioDeviceItem] = []
@Published public private(set) var displays: [MMDisplayInfo] = [] public private(set) var displays: [MMDisplayInfo] = []
/// Historical Telemetry Store /// Historical Telemetry Store
public let historyStore = TelemetryHistoryStore.shared public let historyStore = TelemetryHistoryStore.shared
@@ -318,18 +319,18 @@ public final class SystemTelemetryStore: ObservableObject {
} }
} }
func decodeSnapshot(_ snapshot: [String: [String: Any]]) { private func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
// 1. CPU Load // 1. CPU Load
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpuload"] { if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] {
var m = CPULoadMetrics() var m = CPULoadMetrics()
m.systemLoad = (cpuDict["systemPercent"] as? NSNumber)?.doubleValue ?? 0 m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0
m.userLoad = (cpuDict["userPercent"] as? NSNumber)?.doubleValue ?? 0 m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0
m.idleLoad = (cpuDict["idlePercent"] as? NSNumber)?.doubleValue ?? 100 m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100
m.totalLoad = (cpuDict["totalPercent"] as? NSNumber)?.doubleValue ?? 0 m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0
if let cores = cpuDict["cores"] as? [[String: Any]] { if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] {
m.perCoreLoad = cores.map { ($0["totalPercent"] as? NSNumber)?.doubleValue ?? 0 } m.perCoreLoad = perCore.map { $0.doubleValue }
} }
m.cpuFrequencyMHz = ((cpuDict["frequencyHz"] as? NSNumber)?.doubleValue ?? 0) / 1e6 m.cpuFrequencyMHz = (cpuDict["cpuFrequencyMHz"] as? NSNumber)?.doubleValue ?? 0
m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false
self.cpuLoad = m self.cpuLoad = m
} }
@@ -346,8 +347,7 @@ public final class SystemTelemetryStore: ObservableObject {
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0 m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
m.utilizationPercentage = m.totalBytes > 0 m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0
? Double(m.usedBytes) / Double(m.totalBytes) * 100.0 : 0
m.pressureLevel = memDict["memoryPressureStatus"] as? String m.pressureLevel = memDict["memoryPressureStatus"] as? String
?? memDict["pressureLevel"] as? String ?? "Normal" ?? memDict["pressureLevel"] as? String ?? "Normal"
self.memory = m self.memory = m
@@ -361,11 +361,11 @@ public final class SystemTelemetryStore: ObservableObject {
return StorageVolumeItem( return StorageVolumeItem(
mountPoint: mount, mountPoint: mount,
volumeName: dict["volumeName"] as? String ?? mount, volumeName: dict["volumeName"] as? String ?? mount,
fileSystem: dict["fsType"] as? String ?? "APFS", fileSystem: dict["fileSystem"] as? String ?? "APFS",
totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0, totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0,
freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0, freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0,
usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0, usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0,
usedPercentage: (dict["usedPercent"] as? NSNumber)?.doubleValue ?? 0, usedPercentage: (dict["usedPercentage"] as? NSNumber)?.doubleValue ?? 0,
isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false
) )
} }
@@ -394,7 +394,7 @@ public final class SystemTelemetryStore: ObservableObject {
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"], if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
let fanArray = fanDict["fans"] as? [[String: Any]] { let fanArray = fanDict["fans"] as? [[String: Any]] {
self.fans = fanArray.compactMap { d in self.fans = fanArray.compactMap { d in
let idx = (d["fanIndex"] as? NSNumber)?.intValue ?? 0 let idx = (d["index"] as? NSNumber)?.intValue ?? 0
return FanTelemetryItem( return FanTelemetryItem(
index: idx, index: idx,
name: d["name"] as? String ?? "Fan \(idx + 1)", name: d["name"] as? String ?? "Fan \(idx + 1)",
@@ -402,7 +402,7 @@ public final class SystemTelemetryStore: ObservableObject {
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0, minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0, maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0,
targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0, targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0,
utilization: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0 utilization: (d["utilization"] as? NSNumber)?.doubleValue ?? 0
) )
} }
} }
@@ -446,27 +446,27 @@ public final class SystemTelemetryStore: ObservableObject {
} }
// 8. Kernel Counters // 8. Kernel Counters
if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel.counters"] { if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel"] {
var m = KernelCountersMetrics() var m = KernelCountersMetrics()
m.contextSwitchesPerSec = (kDict["contextSwitchesRate"] as? NSNumber)?.doubleValue ?? 0 m.contextSwitchesPerSec = (kDict["contextSwitchesPerSec"] as? NSNumber)?.doubleValue ?? 0
m.syscallsPerSec = (kDict["syscallsRate"] as? NSNumber)?.doubleValue ?? 0 m.syscallsPerSec = (kDict["syscallsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageFaultsPerSec = (kDict["pageFaultsRate"] as? NSNumber)?.doubleValue ?? 0 m.pageFaultsPerSec = (kDict["pageFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.cowFaultsPerSec = (kDict["cowFaultsRate"] as? NSNumber)?.doubleValue ?? 0 m.cowFaultsPerSec = (kDict["cowFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.zeroFillFaultsPerSec = (kDict["zeroFillsRate"] as? NSNumber)?.doubleValue ?? 0 m.zeroFillFaultsPerSec = (kDict["zeroFillFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageinsPerSec = (kDict["pageinsRate"] as? NSNumber)?.doubleValue ?? 0 m.pageinsPerSec = (kDict["pageinsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageoutsPerSec = (kDict["pageoutsRate"] as? NSNumber)?.doubleValue ?? 0 m.pageoutsPerSec = (kDict["pageoutsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.totalContextSwitches = (kDict["cumulative_contextSwitches"] as? NSNumber)?.uint64Value ?? 0 m.totalContextSwitches = (kDict["totalContextSwitches"] as? NSNumber)?.uint64Value ?? 0
m.totalSyscalls = (kDict["cumulative_syscalls"] as? NSNumber)?.uint64Value ?? 0 m.totalSyscalls = (kDict["totalSyscalls"] as? NSNumber)?.uint64Value ?? 0
m.totalPageFaults = (kDict["cumulative_pageFaults"] as? NSNumber)?.uint64Value ?? 0 m.totalPageFaults = (kDict["totalPageFaults"] as? NSNumber)?.uint64Value ?? 0
self.kernelCounters = m self.kernelCounters = m
} }
// 9. System Load Average & Mach Factor // 9. System Load Average & Mach Factor
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.system.load"] { if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.systemload"] {
var m = SystemLoadMetrics() var m = SystemLoadMetrics()
m.load1Min = (loadDict["load1m"] as? NSNumber)?.doubleValue ?? 0 m.load1Min = (loadDict["load1Min"] as? NSNumber)?.doubleValue ?? 0
m.load5Min = (loadDict["load5m"] as? NSNumber)?.doubleValue ?? 0 m.load5Min = (loadDict["load5Min"] as? NSNumber)?.doubleValue ?? 0
m.load15Min = (loadDict["load15m"] as? NSNumber)?.doubleValue ?? 0 m.load15Min = (loadDict["load15Min"] as? NSNumber)?.doubleValue ?? 0
m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0 m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0
m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0 m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0
m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0 m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0
@@ -474,18 +474,18 @@ public final class SystemTelemetryStore: ObservableObject {
} }
// 10. Disk I/O & IOPS // 10. Disk I/O & IOPS
if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.storage.io"], if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.diskio"],
let disks = diskDict["disks"] as? [[String: Any]] { let disks = diskDict["disks"] as? [[String: Any]] {
self.diskIO = disks.compactMap { d in self.diskIO = disks.compactMap { d in
guard let bsd = d["bsdName"] as? String else { return nil } guard let bsd = d["bsdName"] as? String else { return nil }
return DiskIOItem( return DiskIOItem(
bsdName: bsd, bsdName: bsd,
readBps: (d["readBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, readBps: (d["readBps"] as? NSNumber)?.doubleValue ?? 0,
writeBps: (d["writeBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, writeBps: (d["writeBps"] as? NSNumber)?.doubleValue ?? 0,
readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0, readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0,
writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0, writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0,
totalBytesRead: (d["cumulativeReadBytes"] as? NSNumber)?.uint64Value ?? 0, totalBytesRead: (d["totalBytesRead"] as? NSNumber)?.uint64Value ?? 0,
totalBytesWritten: (d["cumulativeWriteBytes"] as? NSNumber)?.uint64Value ?? 0 totalBytesWritten: (d["totalBytesWritten"] as? NSNumber)?.uint64Value ?? 0
) )
} }
} }
@@ -494,17 +494,17 @@ public final class SystemTelemetryStore: ObservableObject {
if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"], if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"],
let ifaces = netDict["interfaces"] as? [[String: Any]] { let ifaces = netDict["interfaces"] as? [[String: Any]] {
self.networkBandwidth = ifaces.compactMap { d in self.networkBandwidth = ifaces.compactMap { d in
guard let name = d["name"] as? String else { return nil } guard let name = d["interfaceName"] as? String else { return nil }
return NetworkBandwidthItem( return NetworkBandwidthItem(
interfaceName: name, interfaceName: name,
ipAddress: d["ipv4Address"] as? String ?? "", ipAddress: d["ipAddress"] as? String ?? "",
isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false, isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false,
downloadBps: (d["downloadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, downloadBps: (d["downloadBps"] as? NSNumber)?.doubleValue ?? 0,
uploadBps: (d["uploadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, uploadBps: (d["uploadBps"] as? NSNumber)?.doubleValue ?? 0,
downloadPps: (d["downloadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0, downloadPps: (d["downloadPps"] as? NSNumber)?.doubleValue ?? 0,
uploadPps: (d["uploadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0, uploadPps: (d["uploadPps"] as? NSNumber)?.doubleValue ?? 0,
totalBytesIn: (d["cumulativeInBytes"] as? NSNumber)?.uint64Value ?? 0, totalBytesIn: (d["totalBytesIn"] as? NSNumber)?.uint64Value ?? 0,
totalBytesOut: (d["cumulativeOutBytes"] as? NSNumber)?.uint64Value ?? 0 totalBytesOut: (d["totalBytesOut"] as? NSNumber)?.uint64Value ?? 0
) )
} }
} }
@@ -539,8 +539,8 @@ public final class SystemTelemetryStore: ObservableObject {
ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0, ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0,
name: d["name"] as? String ?? "Unknown", name: d["name"] as? String ?? "Unknown",
cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0, cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0,
residentSize: (d["residentBytes"] as? NSNumber)?.uint64Value ?? 0, residentSize: (d["residentSize"] as? NSNumber)?.uint64Value ?? 0,
virtualSize: (d["virtualBytes"] as? NSNumber)?.uint64Value ?? 0, virtualSize: (d["virtualSize"] as? NSNumber)?.uint64Value ?? 0,
threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1, threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1,
uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0, uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0,
username: d["username"] as? String ?? "", username: d["username"] as? String ?? "",
@@ -701,14 +701,10 @@ public final class SystemTelemetryStore: ObservableObject {
freeBytes: $0.freeBytes, freeBytes: $0.freeBytes,
freePercent: $0.totalBytes > 0 freePercent: $0.totalBytes > 0
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0 ? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
: 0, : 0
totalBytes: $0.totalBytes,
isReadOnly: $0.isReadOnly
) )
} }
context.batteryLevel = self.batteryHealth.maxCapacity > 0 context.batteryLevel = self.power.batteryLevel
? Double(self.batteryHealth.currentCapacity) / Double(self.batteryHealth.maxCapacity) * 100.0
: self.power.batteryLevel
context.hasBattery = self.batteryHealth.hasBattery context.hasBattery = self.batteryHealth.hasBattery
context.onExternalPower = self.batteryHealth.externalConnected context.onExternalPower = self.batteryHealth.externalConnected
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
+3 -3
View File
@@ -57,13 +57,13 @@
return YES; return YES;
} }
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC"); CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient");
if (!matching) { if (!matching) {
os_unfair_lock_unlock(&_lock); os_unfair_lock_unlock(&_lock);
if (error) { if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
code:-1 code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}]; userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}];
} }
return NO; return NO;
} }
@@ -74,7 +74,7 @@
if (error) { if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
code:-2 code:-2
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMC service not found (running on non-Mac or unsupported VM)"}]; userInfo:@{NSLocalizedDescriptionKey: @"AppleSMCClient service not found (running on non-Mac or unsupported VM)"}];
} }
return NO; return NO;
} }
+4
View File
@@ -15,6 +15,8 @@
#define kSMCGetKeyFromIndex 8 #define kSMCGetKeyFromIndex 8
#define kSMCGetKeyInfo 9 #define kSMCGetKeyInfo 9
#pragma pack(push, 1)
typedef struct { typedef struct {
unsigned char major; unsigned char major;
unsigned char minor; unsigned char minor;
@@ -56,6 +58,8 @@ typedef struct {
UInt8 bytes[32]; UInt8 bytes[32];
} SMCVal_t; } SMCVal_t;
#pragma pack(pop)
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) { static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
UInt32 code = 0; UInt32 code = 0;
for (int i = 0; i < 4 && str[i] != '\0'; i++) { for (int i = 0; i < 4 && str[i] != '\0'; i++) {
+3 -3
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine
public struct TelemetrySnapshotRecord { public struct TelemetrySnapshotRecord {
public let cpuLoad: Double public let cpuLoad: Double
@@ -36,7 +35,8 @@ public struct TelemetrySnapshotRecord {
} }
} }
public final class TelemetryHistoryStore: ObservableObject { @Observable
public final class TelemetryHistoryStore {
public static let shared = TelemetryHistoryStore() public static let shared = TelemetryHistoryStore()
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60) public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
@@ -48,7 +48,7 @@ public final class TelemetryHistoryStore: ObservableObject {
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60) public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
public let powerHistory = RollingRingBuffer<Double>(capacity: 60) public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
@Published public private(set) var sampleCounter: UInt64 = 0 public private(set) var sampleCounter: UInt64 = 0
public init() {} public init() {}
+6 -9
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import Combine
// MARK: - Alert Engine // MARK: - Alert Engine
@@ -14,17 +13,18 @@ import Combine
/// persistent -> re-fire only after `cooldownMinutes` elapse /// persistent -> re-fire only after `cooldownMinutes` elapse
/// recovery -> remove from active set, log `.resolved`, clear cooldown so /// recovery -> remove from active set, log `.resolved`, clear cooldown so
/// the next breach alerts immediately /// the next breach alerts immediately
@Observable
@MainActor @MainActor
public final class AlertEngine: ObservableObject { public final class AlertEngine {
public static let shared = AlertEngine() public static let shared = AlertEngine()
/// Currently breached conditions, refreshed every evaluation pass. /// Currently breached conditions, refreshed every evaluation pass.
@Published public private(set) var activeAlerts: [SystemAlert] = [] public private(set) var activeAlerts: [SystemAlert] = []
/// Newest-first audit log of trigger/resolve events (capped). /// Newest-first audit log of trigger/resolve events (capped).
@Published public private(set) var history: [AlertEvent] = [] public private(set) var history: [AlertEvent] = []
/// Threshold settings. Mutations are persisted immediately. /// Threshold settings. Mutations are persisted immediately.
@Published public var configuration: AlertConfiguration { public var configuration: AlertConfiguration {
didSet { configuration.save(to: defaults) } didSet { configuration.save(to: defaults) }
} }
@@ -163,10 +163,7 @@ public final class AlertEngine: ObservableObject {
// 4. Low storage space (per mounted volume) // 4. Low storage space (per mounted volume)
if cfg.storageEnabled { if cfg.storageEnabled {
// Skip read-only mounts (DMGs, snapshots) and trivially small for volume in ctx.volumes {
// 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) let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold { if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
alerts.append(SystemAlert( alerts.append(SystemAlert(
+1 -7
View File
@@ -70,18 +70,12 @@ public struct StorageVolumeContext: Equatable, Sendable {
public let volumeName: String public let volumeName: String
public let freeBytes: UInt64 public let freeBytes: UInt64
public let freePercent: Double 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.mountPoint = mountPoint
self.volumeName = volumeName self.volumeName = volumeName
self.freeBytes = freeBytes self.freeBytes = freeBytes
self.freePercent = freePercent self.freePercent = freePercent
self.totalBytes = totalBytes
self.isReadOnly = isReadOnly
} }
} }
@@ -1,20 +1,20 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import Combine
// MARK: - Navigation Bus // MARK: - Navigation Bus
/// Lightweight bus letting notification action handlers steer the main window: /// Lightweight bus letting notification action handlers steer the main window:
/// tapping a notification (or its "View Processes" action) activates the app and /// tapping a notification (or its "View Processes" action) activates the app and
/// selects the requested sidebar tab inside `ContentView`. /// selects the requested sidebar tab inside `ContentView`.
public final class AppNavigationBus: ObservableObject { @Observable
public final class AppNavigationBus {
public static let shared = AppNavigationBus() public static let shared = AppNavigationBus()
/// Sidebar tag the main window should switch to (e.g. `"Processes"`). /// Sidebar tag the main window should switch to (e.g. `"Processes"`).
/// `nil` means "just bring the window forward". /// `nil` means "just bring the window forward".
@Published public private(set) var requestedTab: String? public private(set) var requestedTab: String?
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`. /// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
@Published public private(set) var requestCounter: Int = 0 public private(set) var requestCounter: Int = 0
private init() {} private init() {}
@@ -118,12 +118,11 @@ public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDis
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
let targetTab: String? let targetTab: String? = switch response.actionIdentifier {
switch response.actionIdentifier {
case AlertNotificationAction.viewProcessesIdentifier: case AlertNotificationAction.viewProcessesIdentifier:
targetTab = "Processes" "Processes"
default: default:
targetTab = nil nil
} }
Task { @MainActor in Task { @MainActor in
AppNavigationBus.shared.request(tab: targetTab) AppNavigationBus.shared.request(tab: targetTab)
@@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
/** /**
* Static calculator method for deterministic unit testing. * Static calculator method for deterministic unit testing.
*/ */
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList; + (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList;
@end @end
+3 -15
View File
@@ -63,7 +63,7 @@
return (_probedFanCount >= 0) ? _probedFanCount : 0; return (_probedFanCount >= 0) ? _probedFanCount : 0;
} }
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList { + (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList {
if (!fanList || fanList.count == 0) { if (!fanList || fanList.count == 0) {
return @{ return @{
@"fanCount": @(0), @"fanCount": @(0),
@@ -78,7 +78,7 @@
double peakRPM = 0.0; double peakRPM = 0.0;
for (NSUInteger i = 0; i < fanList.count; i++) { for (NSUInteger i = 0; i < fanList.count; i++) {
NSDictionary<NSString *, id> *raw = fanList[i]; NSDictionary<NSString *, NSNumber *> *raw = fanList[i];
double current = [raw[@"currentRPM"] doubleValue]; double current = [raw[@"currentRPM"] doubleValue];
double min = [raw[@"minRPM"] doubleValue]; double min = [raw[@"minRPM"] doubleValue];
double max = [raw[@"maxRPM"] doubleValue]; double max = [raw[@"maxRPM"] doubleValue];
@@ -96,7 +96,6 @@
[resultList addObject:@{ [resultList addObject:@{
@"fanIndex": @(i), @"fanIndex": @(i),
@"name": raw[@"name"] ?: [NSString stringWithFormat:@"Fan %lu", (unsigned long)(i + 1)],
@"currentRPM": @(current), @"currentRPM": @(current),
@"minRPM": @(min), @"minRPM": @(min),
@"maxRPM": @(max), @"maxRPM": @(max),
@@ -121,31 +120,20 @@
} }
NSInteger count = [self fanCount]; NSInteger count = [self fanCount];
NSMutableArray<NSDictionary<NSString *, id> *> *rawList = [NSMutableArray arrayWithCapacity:count]; NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *rawList = [NSMutableArray arrayWithCapacity:count];
for (NSInteger i = 0; i < count; i++) { for (NSInteger i = 0; i < count; i++) {
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i]; NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i]; NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i]; NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i]; NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];
NSString *idKey = [NSString stringWithFormat:@"F%ldID", (long)i];
NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0); NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0);
NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0); NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0); NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac; NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac;
NSString *fanName = [NSString stringWithFormat:@"Fan %ld", (long)(i + 1)];
NSData *idData = [_smcClient readBytesForKey:idKey error:nil];
if (idData.length > 0) {
NSString *raw = [[NSString alloc] initWithData:idData encoding:NSASCIIStringEncoding];
NSString *trimmed = [raw stringByTrimmingCharactersInSet:
[NSCharacterSet characterSetWithCharactersInString:@"\0 "]];
if (trimmed.length > 0) fanName = trimmed;
}
[rawList addObject:@{ [rawList addObject:@{
@"name": fanName,
@"currentRPM": ac, @"currentRPM": ac,
@"minRPM": mn, @"minRPM": mn,
@"maxRPM": mx, @"maxRPM": mx,
@@ -21,8 +21,7 @@ NS_ASSUME_NONNULL_BEGIN
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current + (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
timeDelta:(NSTimeInterval)timeDelta timeDelta:(NSTimeInterval)timeDelta
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses;
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags;
@end @end
@@ -44,8 +44,7 @@
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current + (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
timeDelta:(NSTimeInterval)timeDelta timeDelta:(NSTimeInterval)timeDelta
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses {
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags {
double totalDownBps = 0.0; double totalDownBps = 0.0;
double totalUpBps = 0.0; double totalUpBps = 0.0;
double totalDownPps = 0.0; double totalDownPps = 0.0;
@@ -100,11 +99,9 @@
} }
NSString *ip = ipAddresses[name] ?: @""; NSString *ip = ipAddresses[name] ?: @"";
BOOL isUp = interfaceUpFlags[name] ? [interfaceUpFlags[name] boolValue] : NO;
[interfaces addObject:@{ [interfaces addObject:@{
@"name": name, @"name": name,
@"isUp": @(isUp),
@"downloadBytesPerSec": @(downBps), @"downloadBytesPerSec": @(downBps),
@"uploadBytesPerSec": @(upBps), @"uploadBytesPerSec": @(upBps),
@"downloadPacketsPerSec": @(downPps), @"downloadPacketsPerSec": @(downPps),
@@ -174,18 +171,16 @@
if (buf) free(buf); if (buf) free(buf);
} }
// 2. Query IPv4 addresses and interface flags via getifaddrs // 2. Query IPv4 addresses via getifaddrs
NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary]; NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary];
NSMutableDictionary<NSString *, NSNumber *> *upDict = [NSMutableDictionary dictionary];
struct ifaddrs *ifap = NULL; struct ifaddrs *ifap = NULL;
if (getifaddrs(&ifap) == 0) { if (getifaddrs(&ifap) == 0) {
for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) { for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) {
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
upDict[name] = @((ifa->ifa_flags & IFF_UP) != 0);
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) { if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
char ipBuf[INET_ADDRSTRLEN] = {0}; char ipBuf[INET_ADDRSTRLEN] = {0};
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr; struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) { if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
ipDict[name] = [NSString stringWithUTF8String:ipBuf]; ipDict[name] = [NSString stringWithUTF8String:ipBuf];
} }
} }
@@ -210,8 +205,7 @@
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
previousSnapshots:prev previousSnapshots:prev
timeDelta:timeDelta timeDelta:timeDelta
ipAddresses:ipDict ipAddresses:ipDict];
interfaceUpFlags:upDict];
} }
@end @end
@@ -1,7 +1,6 @@
#import "MMProcessTelemetryProvider.h" #import "MMProcessTelemetryProvider.h"
#import <libproc.h> #import <libproc.h>
#import <sys/proc_info.h> #import <sys/proc_info.h>
#import <sys/proc.h>
#import <pwd.h> #import <pwd.h>
#import <signal.h> #import <signal.h>
#import <mach/mach_time.h> #import <mach/mach_time.h>
@@ -128,9 +127,7 @@
@"residentBytes": @(tai.ptinfo.pti_resident_size), @"residentBytes": @(tai.ptinfo.pti_resident_size),
@"virtualBytes": @(tai.ptinfo.pti_virtual_size), @"virtualBytes": @(tai.ptinfo.pti_virtual_size),
@"threadCount": @(tai.ptinfo.pti_threadnum), @"threadCount": @(tai.ptinfo.pti_threadnum),
@"runningThreads": @(tai.ptinfo.pti_numrunning), @"runningThreads": @(tai.ptinfo.pti_numrunning)
@"isStopped": @(tai.pbsd.pbi_status == SSTOP),
@"isZombie": @(tai.pbsd.pbi_status == SZOMB)
}]; }];
} }
+2 -3
View File
@@ -4,9 +4,8 @@ import SwiftUI
/// Sidebar tab showing currently breached thresholds and the in-app /// Sidebar tab showing currently breached thresholds and the in-app
/// alert history log maintained by `AlertEngine`. /// alert history log maintained by `AlertEngine`.
@MainActor
public struct AlertsView: View { public struct AlertsView: View {
@ObservedObject private var engine = AlertEngine.shared @State private var engine = AlertEngine.shared
public init() {} public init() {}
@@ -110,7 +109,7 @@ public struct AlertsView: View {
Text(event.alertID) Text(event.alertID)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.mmMonospaced() .monospaced()
} }
Spacer() Spacer()
Text(event.timestamp.formatted(date: .abbreviated, time: .standard)) Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
+25 -69
View File
@@ -1,7 +1,5 @@
import SwiftUI import SwiftUI
#if canImport(Charts)
import Charts import Charts
#endif
public struct TelemetryTrendChartView: View { public struct TelemetryTrendChartView: View {
public let title: String public let title: String
@@ -45,7 +43,8 @@ public struct TelemetryTrendChartView: View {
Spacer() Spacer()
HStack(spacing: 12) { HStack(spacing: 12) {
Text(String(format: "Cur: %.1f %@", currentValue, unit)) Text(String(format: "Cur: %.1f %@", currentValue, unit))
.font(.system(size: 11, weight: .semibold)) .font(.caption)
.fontWeight(.semibold)
.foregroundColor(color) .foregroundColor(color)
Text(String(format: "Avg: %.1f %@", averageValue, unit)) Text(String(format: "Avg: %.1f %@", averageValue, unit))
.font(.caption) .font(.caption)
@@ -56,78 +55,35 @@ public struct TelemetryTrendChartView: View {
} }
} }
#if canImport(Charts) Chart {
if #available(macOS 13.0, *) { ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in
Chart { LineMark(
ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in x: .value("Sample", index),
LineMark( y: .value("Value", sample.value)
x: .value("Sample", index), )
y: .value("Value", sample.value) .interpolationMethod(.monotone)
.foregroundStyle(color)
AreaMark(
x: .value("Sample", index),
y: .value("Value", sample.value)
)
.interpolationMethod(.monotone)
.foregroundStyle(
LinearGradient(
colors: [color.opacity(0.35), color.opacity(0.05)],
startPoint: .top,
endPoint: .bottom
) )
.interpolationMethod(.monotone) )
.foregroundStyle(color)
AreaMark(
x: .value("Sample", index),
y: .value("Value", sample.value)
)
.interpolationMethod(.monotone)
.foregroundStyle(
LinearGradient(
colors: [color.opacity(0.35), color.opacity(0.05)],
startPoint: .top,
endPoint: .bottom
)
)
}
} }
.chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15)))
.chartXAxis(.hidden)
.frame(height: 120)
} else {
fallbackChart
} }
#else .chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15)))
fallbackChart .chartXAxis(.hidden)
#endif .frame(height: 120)
} }
.padding(14) .padding(14)
.background(Color(NSColor.controlBackgroundColor)) .background(Color(NSColor.controlBackgroundColor))
.cornerRadius(10) .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)
}
} }
+41 -62
View File
@@ -1,20 +1,20 @@
import SwiftUI import SwiftUI
@MainActor
public struct ContentView: View { public struct ContentView: View {
@StateObject private var store = SystemTelemetryStore.shared @State private var store = SystemTelemetryStore.shared
@State private var selectedTab: String? = "Dashboard" @State private var selectedTab: String = "Dashboard"
@State private var processSearchText: String = "" @State private var processSearchText: String = ""
@State private var socketSearchText: String = "" @State private var socketSearchText: String = ""
@State private var selectedPID: Int32? = nil @State private var selectedPID: Int32? = nil
@State private var showingProcessDetail: Bool = false @State private var showingProcessDetail: Bool = false
@StateObject private var navBus = AppNavigationBus.shared @State private var navBus = AppNavigationBus.shared
public init() {} public init() {}
public var body: some View { public var body: some View {
NavigationView { NavigationSplitView {
sidebarContent sidebarContent
} detail: {
detailContent detailContent
} }
.frame(minWidth: 1000, minHeight: 700) .frame(minWidth: 1000, minHeight: 700)
@@ -26,7 +26,7 @@ public struct ContentView: View {
ProcessDetailSheet(inspector: store.processInspector, pid: pid) ProcessDetailSheet(inspector: store.processInspector, pid: pid)
} }
} }
.onChange(of: navBus.requestCounter) { _ in .onChange(of: navBus.requestCounter) { _, _ in
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
@@ -87,29 +87,13 @@ public struct ContentView: View {
} }
Section("Intelligence") { Section("Intelligence") {
// Custom row instead of .badge() — a badge applied after .tag Label("Alerts & Notifications", systemImage: "bell.badge")
// detaches the tag from the list row and makes it unselectable. .tag("Alerts")
Label { .badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
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")
} }
} }
.listStyle(.sidebar) .listStyle(.sidebar)
.frame(minWidth: 210, idealWidth: 240, maxWidth: 300) .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300)
} }
// MARK: - Detail Content Router // MARK: - Detail Content Router
@@ -120,7 +104,7 @@ public struct ContentView: View {
headerCard headerCard
engineControlCard engineControlCard
switch selectedTab ?? "Dashboard" { switch selectedTab {
case "Dashboard": case "Dashboard":
dashboardView dashboardView
case "Architecture": case "Architecture":
@@ -169,7 +153,7 @@ public struct ContentView: View {
} }
.padding(24) .padding(24)
} }
.navigationTitle("MacMonitor — \(selectedTab ?? "Dashboard")") .navigationTitle("MacMonitor — \(selectedTab)")
} }
// MARK: - Dashboard Composite View // MARK: - Dashboard Composite View
@@ -358,20 +342,20 @@ public struct ContentView: View {
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg") Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
.font(.headline) .font(.headline)
VStack(alignment: .leading, spacing: 8) { Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) {
HStack { GridRow {
Text("Context Switches:").foregroundStyle(.secondary) Text("Context Switches:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit() Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
Text("Syscalls:").foregroundStyle(.secondary) Text("Syscalls:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit() Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
} }
HStack { GridRow {
Text("Page Faults:").foregroundStyle(.secondary) Text("Page Faults:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit() Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
Text("COW Faults:").foregroundStyle(.secondary) Text("COW Faults:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit() Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
} }
HStack { GridRow {
Text("Pageins / Pageouts:").foregroundStyle(.secondary) Text("Pageins / Pageouts:").foregroundStyle(.secondary)
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit() Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
Text("Zero-Fill:").foregroundStyle(.secondary) Text("Zero-Fill:").foregroundStyle(.secondary)
@@ -446,20 +430,20 @@ public struct ContentView: View {
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0)) ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue) .tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
VStack(alignment: .leading, spacing: 6) { Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
HStack { GridRow {
Text("Used:").foregroundStyle(.secondary) Text("Used:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.usedBytes)).bold() Text(formatBytes(store.memory.usedBytes)).bold()
Text("Free:").foregroundStyle(.secondary) Text("Free:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.freeBytes)).bold() Text(formatBytes(store.memory.freeBytes)).bold()
} }
HStack { GridRow {
Text("Wired:").foregroundStyle(.secondary) Text("Wired:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.wiredBytes)) Text(formatBytes(store.memory.wiredBytes))
Text("Compressed:").foregroundStyle(.secondary) Text("Compressed:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.compressedBytes)) Text(formatBytes(store.memory.compressedBytes))
} }
HStack { GridRow {
Text("Swap Used:").foregroundStyle(.secondary) Text("Swap Used:").foregroundStyle(.secondary)
Text(formatBytes(store.memory.swapUsedBytes)) Text(formatBytes(store.memory.swapUsedBytes))
Text("Swap Total:").foregroundStyle(.secondary) Text("Swap Total:").foregroundStyle(.secondary)
@@ -603,10 +587,10 @@ public struct ContentView: View {
Text(s.protocolName) Text(s.protocolName)
} }
TableColumn("Local Endpoint") { s in TableColumn("Local Endpoint") { s in
Text("\(s.localAddress):\(s.localPort)").mmMonospaced() Text("\(s.localAddress):\(s.localPort)").monospaced()
} }
TableColumn("Remote Endpoint") { s in TableColumn("Remote Endpoint") { s in
Text("\(s.remoteAddress):\(s.remotePort)").mmMonospaced() Text("\(s.remoteAddress):\(s.remotePort)").monospaced()
} }
TableColumn("State") { s in TableColumn("State") { s in
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary) Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
@@ -752,20 +736,20 @@ public struct ContentView: View {
if !store.batteryHealth.hasBattery { if !store.batteryHealth.hasBattery {
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary) Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
} else { } else {
VStack(alignment: .leading, spacing: 6) { Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
HStack { GridRow {
Text("Cycle Count:").foregroundStyle(.secondary) Text("Cycle Count:").foregroundStyle(.secondary)
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold() Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
Text("Temperature:").foregroundStyle(.secondary) Text("Temperature:").foregroundStyle(.secondary)
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold() Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
} }
HStack { GridRow {
Text("Voltage / Power:").foregroundStyle(.secondary) Text("Voltage / Power:").foregroundStyle(.secondary)
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))") Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
Text("Power Adapter:").foregroundStyle(.secondary) Text("Power Adapter:").foregroundStyle(.secondary)
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery") Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
} }
HStack { GridRow {
Text("Capacity:").foregroundStyle(.secondary) Text("Capacity:").foregroundStyle(.secondary)
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh") Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
Text("Manufacturer:").foregroundStyle(.secondary) Text("Manufacturer:").foregroundStyle(.secondary)
@@ -798,10 +782,10 @@ public struct ContentView: View {
.background(Color.secondary.opacity(0.15), in: Capsule()) .background(Color.secondary.opacity(0.15), in: Capsule())
} }
TableColumn("Vendor ID") { d in TableColumn("Vendor ID") { d in
Text(String(format: "0x%04x", d.vendorID)).mmMonospaced() Text(String(format: "0x%04x", d.vendorID)).monospaced()
} }
TableColumn("Product ID") { d in TableColumn("Product ID") { d in
Text(String(format: "0x%04x", d.productID)).mmMonospaced() Text(String(format: "0x%04x", d.productID)).monospaced()
} }
TableColumn("Built-In") { d in TableColumn("Built-In") { d in
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue) Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
@@ -962,14 +946,14 @@ public struct ContentView: View {
.font(.title3.bold().monospacedDigit()) .font(.title3.bold().monospacedDigit())
} }
VStack(alignment: .leading, spacing: 6) { Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
HStack { GridRow {
Text("CPU Power:").foregroundStyle(.secondary) Text("CPU Power:").foregroundStyle(.secondary)
Text(String(format: "%.1f W", store.power.cpuWatts)).bold() Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
Text("GPU Power:").foregroundStyle(.secondary) Text("GPU Power:").foregroundStyle(.secondary)
Text(String(format: "%.1f W", store.power.gpuWatts)).bold() Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
} }
HStack { GridRow {
Text("CPU Voltage:").foregroundStyle(.secondary) Text("CPU Voltage:").foregroundStyle(.secondary)
Text(String(format: "%.2f V", store.power.cpuVoltage)) Text(String(format: "%.2f V", store.power.cpuVoltage))
Text("CPU Current:").foregroundStyle(.secondary) Text("CPU Current:").foregroundStyle(.secondary)
@@ -990,22 +974,24 @@ public struct ContentView: View {
Text("Host Architecture & Kernel") Text("Host Architecture & Kernel")
.font(.headline) .font(.headline)
VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) {
HStack { GridRow {
Text("Hardware Model:").foregroundStyle(.secondary) Text("Hardware Model:").foregroundStyle(.secondary)
Text(store.hostModel).bold() Text(store.hostModel).bold()
Text("Physical Cores:").foregroundStyle(.secondary) Text("Physical Cores:").foregroundStyle(.secondary)
Text("\(store.physicalCpuCount)").bold() Text("\(store.physicalCpuCount)").bold()
} }
HStack { GridRow {
Text("Darwin Kernel:").foregroundStyle(.secondary) Text("Darwin Kernel:").foregroundStyle(.secondary)
Text(store.kernelVersion).bold() Text(store.kernelVersion).bold()
Text("Logical Cores:").foregroundStyle(.secondary) Text("Logical Cores:").foregroundStyle(.secondary)
Text("\(store.logicalCpuCount)").bold() Text("\(store.logicalCpuCount)").bold()
} }
HStack { GridRow {
Text("Target Architecture:").foregroundStyle(.secondary) Text("Target Architecture:").foregroundStyle(.secondary)
Text("Intel x86_64").bold() Text("Intel x86_64").bold()
Text("Deployment Target:").foregroundStyle(.secondary)
Text("macOS 14.0 (Sonoma)").bold()
} }
} }
} }
@@ -1219,7 +1205,6 @@ public struct ContentView: View {
} }
// MARK: - Process Detail Inspector Sheet // MARK: - Process Detail Inspector Sheet
@MainActor
struct ProcessDetailSheet: View { struct ProcessDetailSheet: View {
let inspector: MMProcessDetailInspector let inspector: MMProcessDetailInspector
let pid: Int32 let pid: Int32
@@ -1248,9 +1233,8 @@ struct ProcessDetailSheet: View {
if selectedDetailTab == "Threads" { if selectedDetailTab == "Threads" {
List(threads.indices, id: \.self) { idx in List(threads.indices, id: \.self) { idx in
let t = threads[idx] let t = threads[idx]
let threadID = "\(t["threadID"] ?? idx)"
HStack { HStack {
Text("Thread \(threadID)").bold() Text("Thread \(t["threadID"] ?? idx)").bold()
Spacer() Spacer()
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit() Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
Text(t["state"] as? String ?? "").foregroundStyle(.secondary) Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
@@ -1263,16 +1247,11 @@ struct ProcessDetailSheet: View {
} else { } else {
List(sockets.indices, id: \.self) { idx in List(sockets.indices, id: \.self) { idx in
let s = sockets[idx] 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 { HStack {
Text(proto).bold() Text(s["protocol"] as? String ?? "TCP").bold()
Spacer() Spacer()
Text("\(localAddr):\(localPort) → \(remoteAddr):\(remotePort)") Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)")
.mmMonospaced() .monospaced()
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary) Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
} }
} }
@@ -1,8 +1,7 @@
import SwiftUI import SwiftUI
@MainActor
public struct QuickGlancePopoverView: View { public struct QuickGlancePopoverView: View {
@ObservedObject var store: SystemTelemetryStore var store: SystemTelemetryStore
public init(store: SystemTelemetryStore) { public init(store: SystemTelemetryStore) {
self.store = store self.store = store
@@ -110,7 +109,9 @@ public struct QuickGlancePopoverView: View {
} }
Spacer() Spacer()
Text(value) Text(value)
.font(.system(size: 13, weight: .semibold, design: .monospaced)) .font(.subheadline)
.fontWeight(.semibold)
.fontDesign(.monospaced)
} }
} }
+14 -15
View File
@@ -5,9 +5,8 @@ import UserNotifications
/// Preferences UI for threshold alert rules. Bound directly to /// Preferences UI for threshold alert rules. Bound directly to
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`. /// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
@MainActor
public struct AlertsSettingsView: View { public struct AlertsSettingsView: View {
@ObservedObject private var engine = AlertEngine.shared @Bindable private var engine = AlertEngine.shared
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
public init() {} public init() {}
@@ -59,24 +58,24 @@ public struct AlertsSettingsView: View {
Section("CPU Temperature") { Section("CPU Temperature") {
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled) Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
MMLabeledContent("Threshold") { LabeledContent("Threshold") {
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) { Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.cpuTempThresholdC))°C") Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
} }
} }
Section("Cooling Fans") { Section("Cooling Fans") {
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled) Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
MMLabeledContent("Alert when CPU above") { LabeledContent("Alert when CPU above") {
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) { Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C") Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
} }
Text("Triggers when a fan reports 0 RPM while the CPU is hot.") Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
@@ -86,12 +85,12 @@ public struct AlertsSettingsView: View {
Section("Memory") { Section("Memory") {
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled) Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
MMLabeledContent("Free RAM below") { LabeledContent("Free RAM below") {
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) { Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB") Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 64, alignment: .trailing) .frame(width: 64, alignment: .trailing)
} }
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure) Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
@@ -99,32 +98,32 @@ public struct AlertsSettingsView: View {
Section("Storage") { Section("Storage") {
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled) Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
MMLabeledContent("Free space below") { LabeledContent("Free space below") {
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) { Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB") Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
} }
MMLabeledContent("Free percent below") { LabeledContent("Free percent below") {
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) { Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.storageFreePercentThreshold))%") Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
} }
} }
Section("Battery") { Section("Battery") {
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled) Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
MMLabeledContent("Charge below") { LabeledContent("Charge below") {
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) { Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
EmptyView() EmptyView()
} }
Text("\(Int(engine.configuration.batteryPercentThreshold))%") Text("\(Int(engine.configuration.batteryPercentThreshold))%")
.mmMonospacedDigit() .monospacedDigit()
.frame(width: 48, alignment: .trailing) .frame(width: 48, alignment: .trailing)
} }
Text("Only fires while discharging on battery power.") Text("Only fires while discharging on battery power.")
@@ -132,7 +131,7 @@ public struct AlertsSettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.mmFormStyleGrouped() .formStyle(.grouped)
.frame(minWidth: 480, minHeight: 520) .frame(minWidth: 480, minHeight: 520)
.task { await refreshAuthorizationStatus() } .task { await refreshAuthorizationStatus() }
} }
+3 -26
View File
@@ -128,10 +128,8 @@ final class MMAlertsTests: XCTestCase {
func testStorageRuleTriggersPerVolume() { func testStorageRuleTriggersPerVolume() {
let volumes = [ let volumes = [
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5),
freePercent: 5, totalBytes: 500 * 1_073_741_824), StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
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)) engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1) XCTAssertEqual(engine.activeAlerts.count, 1)
@@ -139,32 +137,11 @@ final class MMAlertsTests: XCTestCase {
} }
func testStorageRuleTriggersOnLowPercent() { func testStorageRuleTriggersOnLowPercent() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)]
freeBytes: 200 * 1_073_741_824, freePercent: 4,
totalBytes: 2_000_000_000_000)]
engine.evaluate(context(volumes: volumes)) engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1) 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 // MARK: Battery
func testBatteryRuleTriggersOnlyWhileDischarging() { func testBatteryRuleTriggersOnlyWhileDischarging() {
-1
View File
@@ -2,7 +2,6 @@ import XCTest
import SwiftUI import SwiftUI
@testable import MacMonitor @testable import MacMonitor
@MainActor
final class MMMenuBarTests: XCTestCase { final class MMMenuBarTests: XCTestCase {
func testMenuBarStatusViewInitialization() { func testMenuBarStatusViewInitialization() {
+1 -2
View File
@@ -49,8 +49,7 @@ final class MMNetworkBandwidthTests: XCTestCase {
withCurrentSnapshots: curr, withCurrentSnapshots: curr,
previousSnapshots: prev, previousSnapshots: prev,
timeDelta: timeDelta, timeDelta: timeDelta,
ipAddresses: ipDict, ipAddresses: ipDict
interfaceUpFlags: ["en0": true, "lo0": true]
) )
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0 let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
-162
View File
@@ -1,162 +0,0 @@
import XCTest
@testable import MacMonitor
/// Snapshot contract conformance tests (Issues #30 / #31).
/// Feeds synthetic dictionaries keyed exactly as the Objective-C providers emit them
/// through `SystemTelemetryStore.decodeSnapshot` and asserts the decoded Swift models
/// populate — guarding against provider-identifier and field-name drift.
@MainActor
final class MMSnapshotContractTests: XCTestCase {
private var store: SystemTelemetryStore { SystemTelemetryStore.shared }
// MARK: - Provider identifier coverage (#30)
func testEmittedProviderIdentifiersAreDecoded() {
// The four identifiers that previously never reached the decoder.
let snapshot: [String: [String: Any]] = [
"com.i3omb.macmonitor.telemetry.cpuload": [
"userPercent": 25.0, "systemPercent": 15.0,
"idlePercent": 60.0, "totalPercent": 40.0,
"cores": [["totalPercent": 10.0], ["totalPercent": 90.0]],
"frequencyHz": 2.4e9, "isThrottled": false
],
"com.i3omb.macmonitor.telemetry.kernel.counters": [
"contextSwitchesRate": 1234.5, "syscallsRate": 9876.5,
"pageFaultsRate": 42.0, "cowFaultsRate": 3.0,
"zeroFillsRate": 7.0, "pageinsRate": 1.0, "pageoutsRate": 0.5,
"cumulative_contextSwitches": 999_999 as UInt64,
"cumulative_syscalls": 888_888 as UInt64,
"cumulative_pageFaults": 777_777 as UInt64
],
"com.i3omb.macmonitor.telemetry.system.load": [
"load1m": 1.5, "load5m": 2.0, "load15m": 2.5,
"taskCount": 400, "threadCount": 2000, "machFactor": 0.75
],
"com.i3omb.macmonitor.telemetry.storage.io": [
"disks": [[
"bsdName": "disk0",
"readBytesPerSec": 1_048_576.0, "writeBytesPerSec": 524_288.0,
"readIOPS": 120.0, "writeIOPS": 60.0,
"cumulativeReadBytes": 9_999_999 as UInt64,
"cumulativeWriteBytes": 8_888_888 as UInt64
]]
]
]
store.decodeSnapshot(snapshot)
XCTAssertEqual(store.cpuLoad.userLoad, 25.0, accuracy: 0.001)
XCTAssertEqual(store.cpuLoad.systemLoad, 15.0, accuracy: 0.001)
XCTAssertEqual(store.cpuLoad.totalLoad, 40.0, accuracy: 0.001)
XCTAssertEqual(store.cpuLoad.perCoreLoad, [10.0, 90.0])
XCTAssertEqual(store.cpuLoad.cpuFrequencyMHz, 2400.0, accuracy: 0.001)
XCTAssertEqual(store.kernelCounters.contextSwitchesPerSec, 1234.5, accuracy: 0.001)
XCTAssertEqual(store.kernelCounters.syscallsPerSec, 9876.5, accuracy: 0.001)
XCTAssertEqual(store.kernelCounters.totalContextSwitches, 999_999)
XCTAssertEqual(store.kernelCounters.totalSyscalls, 888_888)
XCTAssertEqual(store.kernelCounters.totalPageFaults, 777_777)
XCTAssertEqual(store.systemLoad.load1Min, 1.5, accuracy: 0.001)
XCTAssertEqual(store.systemLoad.load5Min, 2.0, accuracy: 0.001)
XCTAssertEqual(store.systemLoad.load15Min, 2.5, accuracy: 0.001)
XCTAssertEqual(store.systemLoad.taskCount, 400)
XCTAssertEqual(store.systemLoad.threadCount, 2000)
XCTAssertEqual(store.diskIO.count, 1)
XCTAssertEqual(store.diskIO[0].bsdName, "disk0")
XCTAssertEqual(store.diskIO[0].readBps, 1_048_576.0, accuracy: 0.001)
XCTAssertEqual(store.diskIO[0].totalBytesRead, 9_999_999)
}
// MARK: - Field-name drift (#31)
func testMemoryUtilizationComputedFromUsedOverTotal() {
store.decodeSnapshot([
"com.i3omb.macmonitor.telemetry.memory": [
"totalBytes": 16_000_000_000 as UInt64,
"usedBytes": 8_000_000_000 as UInt64,
"freeBytes": 8_000_000_000 as UInt64,
"memoryPressureStatus": "Normal"
]
])
XCTAssertEqual(store.memory.utilizationPercentage, 50.0, accuracy: 0.001)
}
func testStorageVolumeEmitsUsedPercentAndFSType() {
store.decodeSnapshot([
"com.i3omb.macmonitor.telemetry.storage": [
"volumes": [[
"volumeName": "Macintosh HD", "mountPoint": "/",
"devicePath": "/dev/disk1s1", "fsType": "apfs",
"totalBytes": 500_000_000_000 as UInt64,
"usedBytes": 250_000_000_000 as UInt64,
"freeBytes": 250_000_000_000 as UInt64,
"usedPercent": 50.0, "isReadOnly": false
]]
]
])
XCTAssertEqual(store.storageVolumes.count, 1)
XCTAssertEqual(store.storageVolumes[0].usedPercentage, 50.0, accuracy: 0.001)
XCTAssertEqual(store.storageVolumes[0].fileSystem, "apfs")
}
func testNetworkInterfaceEmittedKeys() {
store.decodeSnapshot([
"com.i3omb.macmonitor.telemetry.network.bandwidth": [
"interfaces": [[
"name": "en0", "isUp": true, "ipv4Address": "192.168.1.10",
"downloadBytesPerSec": 5_000.0, "uploadBytesPerSec": 1_000.0,
"downloadPacketsPerSec": 40.0, "uploadPacketsPerSec": 20.0,
"cumulativeInBytes": 10_000_000 as UInt64,
"cumulativeOutBytes": 5_000_000 as UInt64
]]
]
])
XCTAssertEqual(store.networkBandwidth.count, 1)
let iface = store.networkBandwidth[0]
XCTAssertEqual(iface.interfaceName, "en0")
XCTAssertTrue(iface.isUp)
XCTAssertEqual(iface.ipAddress, "192.168.1.10")
XCTAssertEqual(iface.downloadBps, 5_000.0, accuracy: 0.001)
XCTAssertEqual(iface.totalBytesIn, 10_000_000)
}
func testProcessResidentAndVirtualBytesDecode() {
store.decodeSnapshot([
"com.i3omb.macmonitor.telemetry.process": [
"processes": [[
"pid": 42, "ppid": 1, "name": "testproc",
"cpuPercent": 3.5,
"residentBytes": 100_000_000 as UInt64,
"virtualBytes": 400_000_000 as UInt64,
"threadCount": 5, "uid": 501, "username": "gordon",
"isStopped": false, "isZombie": false
]]
]
])
XCTAssertEqual(store.processes.count, 1)
XCTAssertEqual(store.processes[0].residentSize, 100_000_000)
XCTAssertEqual(store.processes[0].virtualSize, 400_000_000)
XCTAssertFalse(store.processes[0].isStopped)
XCTAssertFalse(store.processes[0].isZombie)
}
func testFanEmittedKeysDecode() {
store.decodeSnapshot([
"com.i3omb.macmonitor.telemetry.fan": [
"fans": [[
"fanIndex": 0, "name": "Exhaust",
"currentRPM": 2000.0, "minRPM": 1200.0,
"maxRPM": 6000.0, "targetRPM": 2000.0,
"utilizationPercent": 16.67
]]
]
])
XCTAssertEqual(store.fans.count, 1)
XCTAssertEqual(store.fans[0].index, 0)
XCTAssertEqual(store.fans[0].name, "Exhaust")
XCTAssertEqual(store.fans[0].utilization, 16.67, accuracy: 0.01)
}
}
+2 -2
View File
@@ -16,8 +16,8 @@ settings:
CLANG_ENABLE_OBJC_ARC: YES CLANG_ENABLE_OBJC_ARC: YES
CLANG_ENABLE_MODULES: YES CLANG_ENABLE_MODULES: YES
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
MARKETING_VERSION: "0.0.2" MARKETING_VERSION: "0.0.1"
CURRENT_PROJECT_VERSION: "2" CURRENT_PROJECT_VERSION: "1"
CODE_SIGNING_ALLOWED: NO CODE_SIGNING_ALLOWED: NO
CODE_SIGN_IDENTITY: "" CODE_SIGN_IDENTITY: ""
GCC_C_LANGUAGE_STANDARD: "gnu17" GCC_C_LANGUAGE_STANDARD: "gnu17"