From beabf016b033b992bbdfe7d4418dc7fa7671cd36 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 11:36:34 +0100 Subject: [PATCH 1/9] chore: ignore reference and plan markdown files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c83c235..c10ce3b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ xcuserdata/ *.dmg *.zip *.app +FIRST-PASS-REFERENCE.md +SECOND-PASS-REFERENCE.md +Docs/AppleSilicon-plan.md +Docs/Monterey-plan.md -- 2.39.5 From 01e871dc2cc8f3a483522b3c54cbb05cc0059432 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 11:48:27 +0100 Subject: [PATCH 2/9] feat(#41): Revert target deployment configuration to macOS 12 --- .gitea/workflows/build.yml | 4 ++-- BUILD-PLAN.md | 6 +++--- MacMonitor.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c62c11d..c351ee2 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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: diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md index d80bc39..f6b62a9 100644 --- a/BUILD-PLAN.md +++ b/BUILD-PLAN.md @@ -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`) diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index af181f3..53bd72e 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -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"; @@ -778,7 +778,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -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"; @@ -871,7 +871,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -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"; -- 2.39.5 From 7ba4284be1696f6af72923512ce00c58b71c77df Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 11:53:08 +0100 Subject: [PATCH 3/9] feat(#42): Refactor State Management from Observation to Combine --- Sources/App/SystemTelemetryStore.swift | 49 +++++++++---------- Sources/History/TelemetryHistoryStore.swift | 6 +-- Sources/Intelligence/AlertEngine.swift | 10 ++-- .../AlertNotificationDispatcher.swift | 8 +-- Sources/UI/Settings/AlertsSettingsView.swift | 2 +- 5 files changed, 37 insertions(+), 38 deletions(-) diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index af963af..2de4926 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -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 diff --git a/Sources/History/TelemetryHistoryStore.swift b/Sources/History/TelemetryHistoryStore.swift index edd5689..300dabb 100644 --- a/Sources/History/TelemetryHistoryStore.swift +++ b/Sources/History/TelemetryHistoryStore.swift @@ -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(capacity: 60) @@ -48,7 +48,7 @@ public final class TelemetryHistoryStore { public let cpuTempHistory = RollingRingBuffer(capacity: 60) public let powerHistory = RollingRingBuffer(capacity: 60) - public private(set) var sampleCounter: UInt64 = 0 + @Published public private(set) var sampleCounter: UInt64 = 0 public init() {} diff --git a/Sources/Intelligence/AlertEngine.swift b/Sources/Intelligence/AlertEngine.swift index a1670e7..22ba45d 100644 --- a/Sources/Intelligence/AlertEngine.swift +++ b/Sources/Intelligence/AlertEngine.swift @@ -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) } } diff --git a/Sources/Intelligence/AlertNotificationDispatcher.swift b/Sources/Intelligence/AlertNotificationDispatcher.swift index 8ebb06c..8ba5992 100644 --- a/Sources/Intelligence/AlertNotificationDispatcher.swift +++ b/Sources/Intelligence/AlertNotificationDispatcher.swift @@ -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() {} diff --git a/Sources/UI/Settings/AlertsSettingsView.swift b/Sources/UI/Settings/AlertsSettingsView.swift index bf693f5..eb7d6c8 100644 --- a/Sources/UI/Settings/AlertsSettingsView.swift +++ b/Sources/UI/Settings/AlertsSettingsView.swift @@ -6,7 +6,7 @@ import UserNotifications /// Preferences UI for threshold alert rules. Bound directly to /// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`. public struct AlertsSettingsView: View { - @Bindable private var engine = AlertEngine.shared + @ObservedObject private var engine = AlertEngine.shared @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined public init() {} -- 2.39.5 From 5676279a4ce595de489efc1ac5efdf35fb1b36a1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 11:55:10 +0100 Subject: [PATCH 4/9] feat(#43): Downgrade App Lifecycle and Navigation Framework --- Sources/App/MacMonitorApp.swift | 61 ++++++++++++++++++++++++++------- Sources/UI/ContentView.swift | 11 +++--- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift index 8b4c7ba..528b198 100644 --- a/Sources/App/MacMonitorApp.swift +++ b/Sources/App/MacMonitorApp.swift @@ -1,19 +1,66 @@ import SwiftUI +import Combine /// Bridges app-lifecycle events into SwiftUI: requests Notification Center /// authorization once at launch so threshold alerts can post banners. 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,18 +68,6 @@ 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() diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 5e76b6f..6629af2 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -1,20 +1,19 @@ import SwiftUI public struct ContentView: View { - @State private var store = SystemTelemetryStore.shared + @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 +25,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) @@ -109,7 +108,7 @@ public struct ContentView: View { } } .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300) + .frame(minWidth: 210, idealWidth: 240, maxWidth: 300) } // MARK: - Detail Content Router -- 2.39.5 From 9f5481a1a9d440c9775090f242a129acb6a471fc Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 12:02:09 +0100 Subject: [PATCH 5/9] Fix UI shims for macOS 12 compatibility --- Sources/App/MacMonitorApp.swift | 60 ++++++++++++ Sources/UI/Alerts/AlertsView.swift | 2 +- .../UI/Charts/TelemetryTrendChartView.swift | 94 ++++++++++++++----- Sources/UI/ContentView.swift | 52 +++++----- .../UI/MenuBar/QuickGlancePopoverView.swift | 4 +- Sources/UI/Settings/AlertsSettingsView.swift | 26 ++--- 6 files changed, 170 insertions(+), 68 deletions(-) diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift index 528b198..696dc95 100644 --- a/Sources/App/MacMonitorApp.swift +++ b/Sources/App/MacMonitorApp.swift @@ -3,6 +3,7 @@ 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? @@ -74,3 +75,62 @@ struct MacMonitorApp: App { } } } + +// MARK: - UI Shims (macOS 12 Support) + +public struct MMLabeledContent: 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(_ 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 + } + } +} diff --git a/Sources/UI/Alerts/AlertsView.swift b/Sources/UI/Alerts/AlertsView.swift index c8f0be2..7dd65fe 100644 --- a/Sources/UI/Alerts/AlertsView.swift +++ b/Sources/UI/Alerts/AlertsView.swift @@ -109,7 +109,7 @@ public struct AlertsView: View { Text(event.alertID) .font(.caption2) .foregroundStyle(.secondary) - .monospaced() + .mmMonospaced() } Spacer() Text(event.timestamp.formatted(date: .abbreviated, time: .standard)) diff --git a/Sources/UI/Charts/TelemetryTrendChartView.swift b/Sources/UI/Charts/TelemetryTrendChartView.swift index 9856572..21d9d2d 100644 --- a/Sources/UI/Charts/TelemetryTrendChartView.swift +++ b/Sources/UI/Charts/TelemetryTrendChartView.swift @@ -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,35 +56,78 @@ public struct TelemetryTrendChartView: View { } } - Chart { - ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in - LineMark( - 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 + #if canImport(Charts) + if #available(macOS 13.0, *) { + Chart { + ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in + LineMark( + 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 + ) + ) + } } + .chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15))) + .chartXAxis(.hidden) + .frame(height: 120) + } else { + fallbackChart } - .chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15))) - .chartXAxis(.hidden) - .frame(height: 120) + #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) + } } diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 6629af2..0b4f8a5 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -2,7 +2,7 @@ import SwiftUI public struct ContentView: View { @StateObject 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 socketSearchText: String = "" @State private var selectedPID: Int32? = nil @@ -119,7 +119,7 @@ public struct ContentView: View { headerCard engineControlCard - switch selectedTab { + switch selectedTab ?? "Dashboard" { case "Dashboard": dashboardView case "Architecture": @@ -357,20 +357,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) @@ -445,20 +445,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) @@ -602,10 +602,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) @@ -751,20 +751,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) @@ -797,10 +797,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) @@ -961,14 +961,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) @@ -989,20 +989,20 @@ 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) @@ -1266,7 +1266,7 @@ struct ProcessDetailSheet: View { Text(s["protocol"] as? String ?? "TCP").bold() Spacer() Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)") - .monospaced() + .mmMonospaced() Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary) } } diff --git a/Sources/UI/MenuBar/QuickGlancePopoverView.swift b/Sources/UI/MenuBar/QuickGlancePopoverView.swift index 5a8d09b..07e0ab5 100644 --- a/Sources/UI/MenuBar/QuickGlancePopoverView.swift +++ b/Sources/UI/MenuBar/QuickGlancePopoverView.swift @@ -109,9 +109,7 @@ public struct QuickGlancePopoverView: View { } Spacer() Text(value) - .font(.subheadline) - .fontWeight(.semibold) - .fontDesign(.monospaced) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) } } diff --git a/Sources/UI/Settings/AlertsSettingsView.swift b/Sources/UI/Settings/AlertsSettingsView.swift index eb7d6c8..29584a0 100644 --- a/Sources/UI/Settings/AlertsSettingsView.swift +++ b/Sources/UI/Settings/AlertsSettingsView.swift @@ -58,24 +58,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 +85,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 +98,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 +131,7 @@ public struct AlertsSettingsView: View { .foregroundStyle(.secondary) } } - .formStyle(.grouped) + .mmFormStyleGrouped() .frame(minWidth: 480, minHeight: 520) .task { await refreshAuthorizationStatus() } } -- 2.39.5 From 33bee371f176fb8bcbd1cd1134619e73e6426577 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 12:45:11 +0100 Subject: [PATCH 6/9] fix: Replace Swift 5.9 switch expression for Xcode 14 compatibility --- Sources/Intelligence/AlertNotificationDispatcher.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Intelligence/AlertNotificationDispatcher.swift b/Sources/Intelligence/AlertNotificationDispatcher.swift index 8ba5992..6b067c9 100644 --- a/Sources/Intelligence/AlertNotificationDispatcher.swift +++ b/Sources/Intelligence/AlertNotificationDispatcher.swift @@ -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) -- 2.39.5 From 7473e95635113b6e0c38a3ee9cca314e383e5f53 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 12:53:17 +0100 Subject: [PATCH 7/9] fix: Isolate views to @MainActor and use @ObservedObject for Swift 5.7 compatibility --- Sources/UI/Alerts/AlertsView.swift | 3 ++- Sources/UI/ContentView.swift | 2 ++ Sources/UI/MenuBar/QuickGlancePopoverView.swift | 3 ++- Sources/UI/Settings/AlertsSettingsView.swift | 1 + Tests/MMMenuBarTests.swift | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/UI/Alerts/AlertsView.swift b/Sources/UI/Alerts/AlertsView.swift index 7dd65fe..c27e522 100644 --- a/Sources/UI/Alerts/AlertsView.swift +++ b/Sources/UI/Alerts/AlertsView.swift @@ -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() {} diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 0b4f8a5..e52bba5 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI +@MainActor public struct ContentView: View { @StateObject private var store = SystemTelemetryStore.shared @State private var selectedTab: String? = "Dashboard" @@ -1220,6 +1221,7 @@ public struct ContentView: View { } // MARK: - Process Detail Inspector Sheet +@MainActor struct ProcessDetailSheet: View { let inspector: MMProcessDetailInspector let pid: Int32 diff --git a/Sources/UI/MenuBar/QuickGlancePopoverView.swift b/Sources/UI/MenuBar/QuickGlancePopoverView.swift index 07e0ab5..44fbd12 100644 --- a/Sources/UI/MenuBar/QuickGlancePopoverView.swift +++ b/Sources/UI/MenuBar/QuickGlancePopoverView.swift @@ -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 diff --git a/Sources/UI/Settings/AlertsSettingsView.swift b/Sources/UI/Settings/AlertsSettingsView.swift index 29584a0..41a77d5 100644 --- a/Sources/UI/Settings/AlertsSettingsView.swift +++ b/Sources/UI/Settings/AlertsSettingsView.swift @@ -5,6 +5,7 @@ import UserNotifications /// Preferences UI for threshold alert rules. Bound directly to /// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`. +@MainActor public struct AlertsSettingsView: View { @ObservedObject private var engine = AlertEngine.shared @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined diff --git a/Tests/MMMenuBarTests.swift b/Tests/MMMenuBarTests.swift index 2ff00e3..3fdd308 100644 --- a/Tests/MMMenuBarTests.swift +++ b/Tests/MMMenuBarTests.swift @@ -2,6 +2,7 @@ import XCTest import SwiftUI @testable import MacMonitor +@MainActor final class MMMenuBarTests: XCTestCase { func testMenuBarStatusViewInitialization() { -- 2.39.5 From 8dafffdd6e604e606e9d7a065389a972b77fbd30 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 12:56:45 +0100 Subject: [PATCH 8/9] fix: Type string interpolations in ContentView for Swift 5.7 compatibility --- Sources/UI/ContentView.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index e52bba5..082392f 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -169,7 +169,7 @@ public struct ContentView: View { } .padding(24) } - .navigationTitle("MacMonitor — \(selectedTab)") + .navigationTitle("MacMonitor — \(selectedTab ?? "Dashboard")") } // MARK: - Dashboard Composite View @@ -1250,8 +1250,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) @@ -1264,10 +1265,15 @@ 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)") + Text("\(localAddr):\(localPort) → \(remoteAddr):\(remotePort)") .mmMonospaced() Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary) } -- 2.39.5 From e68a053740d93b3cc658d6e1bc21bab7cfa968da Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 1 Oct 2026 13:23:03 +0100 Subject: [PATCH 9/9] fix(ui): Remove Deployment Target display from Host Architecture view --- Sources/UI/ContentView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 082392f..fbc5fcd 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -1006,8 +1006,6 @@ public struct ContentView: View { HStack { Text("Target Architecture:").foregroundStyle(.secondary) Text("Intel x86_64").bold() - Text("Deployment Target:").foregroundStyle(.secondary) - Text("macOS 14.0 (Sonoma)").bold() } } } -- 2.39.5