Compare commits
9
Commits
9f2d0c58d8
...
30976e9be4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30976e9be4 | ||
|
|
547aedd3b6 | ||
|
|
5c26579bd8 | ||
|
|
63d6bd6209 | ||
|
|
c6002955fd | ||
|
|
6f76d6cc90 | ||
|
|
a6c7a3bee0 | ||
|
|
17b9147179 | ||
|
|
c461a9886e |
@@ -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:
|
||||
|
||||
@@ -25,3 +25,7 @@ xcuserdata/
|
||||
*.dmg
|
||||
*.zip
|
||||
*.app
|
||||
FIRST-PASS-REFERENCE.md
|
||||
SECOND-PASS-REFERENCE.md
|
||||
Docs/AppleSilicon-plan.md
|
||||
Docs/Monterey-plan.md
|
||||
|
||||
+3
-3
@@ -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`)
|
||||
|
||||
@@ -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";
|
||||
|
||||
+108
-13
@@ -1,19 +1,67 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center
|
||||
/// authorization once at launch so threshold alerts can post banners.
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var statusItem: NSStatusItem?
|
||||
var popover: NSPopover?
|
||||
var cancellable: AnyCancellable?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if AlertEngine.shared.configuration.alertsEnabled {
|
||||
AlertEngine.shared.requestNotificationAuthorization()
|
||||
}
|
||||
setupMenuBar()
|
||||
}
|
||||
|
||||
private func setupMenuBar() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
let popover = NSPopover()
|
||||
popover.contentSize = NSSize(width: 320, height: 400)
|
||||
popover.behavior = .transient
|
||||
popover.contentViewController = NSHostingController(rootView: QuickGlancePopoverView(store: SystemTelemetryStore.shared))
|
||||
self.popover = popover
|
||||
|
||||
if let button = statusItem?.button {
|
||||
button.action = #selector(togglePopover(_:))
|
||||
button.target = self
|
||||
}
|
||||
|
||||
cancellable = SystemTelemetryStore.shared.$latestSnapshot.sink { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.updateMenuBarLabel()
|
||||
}
|
||||
}
|
||||
updateMenuBarLabel()
|
||||
}
|
||||
|
||||
@objc func togglePopover(_ sender: AnyObject?) {
|
||||
guard let button = statusItem?.button, let popover = popover else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMenuBarLabel() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
let cpu = SystemTelemetryStore.shared.cpuLoad.totalLoad
|
||||
let mem = SystemTelemetryStore.shared.memory.utilizationPercentage
|
||||
let alerts = AlertEngine.shared.activeAlerts.count
|
||||
|
||||
let alertStr = alerts > 0 ? "⚠️ " : ""
|
||||
button.title = String(format: "%@CPU: %.0f%% • RAM: %.0f%%", alertStr, cpu, mem)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct MacMonitorApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var store = SystemTelemetryStore.shared
|
||||
@StateObject private var store = SystemTelemetryStore.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -21,21 +69,68 @@ struct MacMonitorApp: App {
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
.defaultSize(width: 960, height: 640)
|
||||
|
||||
MenuBarExtra {
|
||||
QuickGlancePopoverView(store: store)
|
||||
} label: {
|
||||
MenuBarStatusView(
|
||||
cpuLoad: store.cpuLoad.totalLoad,
|
||||
memoryPercent: store.memory.utilizationPercentage,
|
||||
activeAlertCount: store.alertEngine.activeAlerts.count
|
||||
)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
Settings {
|
||||
AlertsSettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI Shims (macOS 12 Support)
|
||||
|
||||
public struct MMLabeledContent<Label: View, Content: View>: View {
|
||||
public let label: Label
|
||||
public let content: Content
|
||||
|
||||
public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
|
||||
self.label = label()
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: () -> Content) where Label == Text {
|
||||
self.label = Text(titleKey)
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) where Label == Text {
|
||||
self.label = Text(title)
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
label
|
||||
Spacer()
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder
|
||||
func mmMonospaced() -> some View {
|
||||
if #available(macOS 13.0, *) {
|
||||
self.monospaced()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func mmMonospacedDigit() -> some View {
|
||||
if #available(macOS 13.0, *) {
|
||||
self.monospacedDigit()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func mmFormStyleGrouped() -> some View {
|
||||
if #available(macOS 13.0, *) {
|
||||
self.formStyle(.grouped)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct TelemetrySnapshotRecord {
|
||||
public let cpuLoad: Double
|
||||
@@ -35,8 +36,7 @@ public struct TelemetrySnapshotRecord {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
public final class TelemetryHistoryStore {
|
||||
public final class TelemetryHistoryStore: ObservableObject {
|
||||
public static let shared = TelemetryHistoryStore()
|
||||
|
||||
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||
@@ -48,7 +48,7 @@ public final class TelemetryHistoryStore {
|
||||
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||
public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||
|
||||
public private(set) var sampleCounter: UInt64 = 0
|
||||
@Published public private(set) var sampleCounter: UInt64 = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import Combine
|
||||
|
||||
// MARK: - Navigation Bus
|
||||
|
||||
/// Lightweight bus letting notification action handlers steer the main window:
|
||||
/// tapping a notification (or its "View Processes" action) activates the app and
|
||||
/// selects the requested sidebar tab inside `ContentView`.
|
||||
@Observable
|
||||
public final class AppNavigationBus {
|
||||
public final class AppNavigationBus: ObservableObject {
|
||||
public static let shared = AppNavigationBus()
|
||||
|
||||
/// Sidebar tag the main window should switch to (e.g. `"Processes"`).
|
||||
/// `nil` means "just bring the window forward".
|
||||
public private(set) var requestedTab: String?
|
||||
@Published public private(set) var requestedTab: String?
|
||||
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
|
||||
public private(set) var requestCounter: Int = 0
|
||||
@Published public private(set) var requestCounter: Int = 0
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -118,11 +118,12 @@ public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDis
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let targetTab: String? = switch response.actionIdentifier {
|
||||
let targetTab: String?
|
||||
switch response.actionIdentifier {
|
||||
case AlertNotificationAction.viewProcessesIdentifier:
|
||||
"Processes"
|
||||
targetTab = "Processes"
|
||||
default:
|
||||
nil
|
||||
targetTab = nil
|
||||
}
|
||||
Task { @MainActor in
|
||||
AppNavigationBus.shared.request(tab: targetTab)
|
||||
|
||||
@@ -4,8 +4,9 @@ import SwiftUI
|
||||
|
||||
/// Sidebar tab showing currently breached thresholds and the in-app
|
||||
/// alert history log maintained by `AlertEngine`.
|
||||
@MainActor
|
||||
public struct AlertsView: View {
|
||||
@State private var engine = AlertEngine.shared
|
||||
@ObservedObject private var engine = AlertEngine.shared
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -109,7 +110,7 @@ public struct AlertsView: View {
|
||||
Text(event.alertID)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospaced()
|
||||
.mmMonospaced()
|
||||
}
|
||||
Spacer()
|
||||
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
#if canImport(Charts)
|
||||
import Charts
|
||||
#endif
|
||||
|
||||
public struct TelemetryTrendChartView: View {
|
||||
public let title: String
|
||||
@@ -43,8 +45,7 @@ public struct TelemetryTrendChartView: View {
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "Cur: %.1f %@", currentValue, unit))
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
Text(String(format: "Avg: %.1f %@", averageValue, unit))
|
||||
.font(.caption)
|
||||
@@ -55,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ContentView: View {
|
||||
@State private var store = SystemTelemetryStore.shared
|
||||
@State private var selectedTab: String = "Dashboard"
|
||||
@StateObject private var store = SystemTelemetryStore.shared
|
||||
@State private var selectedTab: String? = "Dashboard"
|
||||
@State private var processSearchText: String = ""
|
||||
@State private var socketSearchText: String = ""
|
||||
@State private var selectedPID: Int32? = nil
|
||||
@State private var showingProcessDetail: Bool = false
|
||||
@State private var navBus = AppNavigationBus.shared
|
||||
@StateObject private var navBus = AppNavigationBus.shared
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
NavigationSplitView {
|
||||
NavigationView {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.frame(minWidth: 1000, minHeight: 700)
|
||||
@@ -26,7 +26,7 @@ public struct ContentView: View {
|
||||
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
|
||||
}
|
||||
}
|
||||
.onChange(of: navBus.requestCounter) { _, _ in
|
||||
.onChange(of: navBus.requestCounter) { _ in
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
@@ -109,7 +109,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
|
||||
@@ -120,7 +120,7 @@ public struct ContentView: View {
|
||||
headerCard
|
||||
engineControlCard
|
||||
|
||||
switch selectedTab {
|
||||
switch selectedTab ?? "Dashboard" {
|
||||
case "Dashboard":
|
||||
dashboardView
|
||||
case "Architecture":
|
||||
@@ -169,7 +169,7 @@ public struct ContentView: View {
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("MacMonitor — \(selectedTab)")
|
||||
.navigationTitle("MacMonitor — \(selectedTab ?? "Dashboard")")
|
||||
}
|
||||
|
||||
// MARK: - Dashboard Composite View
|
||||
@@ -358,20 +358,20 @@ public struct ContentView: View {
|
||||
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
|
||||
.font(.headline)
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Context Switches:").foregroundStyle(.secondary)
|
||||
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
|
||||
Text("Syscalls:").foregroundStyle(.secondary)
|
||||
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Page Faults:").foregroundStyle(.secondary)
|
||||
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
|
||||
Text("COW Faults:").foregroundStyle(.secondary)
|
||||
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Pageins / Pageouts:").foregroundStyle(.secondary)
|
||||
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
|
||||
Text("Zero-Fill:").foregroundStyle(.secondary)
|
||||
@@ -446,20 +446,20 @@ public struct ContentView: View {
|
||||
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
|
||||
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Used:").foregroundStyle(.secondary)
|
||||
Text(formatBytes(store.memory.usedBytes)).bold()
|
||||
Text("Free:").foregroundStyle(.secondary)
|
||||
Text(formatBytes(store.memory.freeBytes)).bold()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Wired:").foregroundStyle(.secondary)
|
||||
Text(formatBytes(store.memory.wiredBytes))
|
||||
Text("Compressed:").foregroundStyle(.secondary)
|
||||
Text(formatBytes(store.memory.compressedBytes))
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Swap Used:").foregroundStyle(.secondary)
|
||||
Text(formatBytes(store.memory.swapUsedBytes))
|
||||
Text("Swap Total:").foregroundStyle(.secondary)
|
||||
@@ -603,10 +603,10 @@ public struct ContentView: View {
|
||||
Text(s.protocolName)
|
||||
}
|
||||
TableColumn("Local Endpoint") { s in
|
||||
Text("\(s.localAddress):\(s.localPort)").monospaced()
|
||||
Text("\(s.localAddress):\(s.localPort)").mmMonospaced()
|
||||
}
|
||||
TableColumn("Remote Endpoint") { s in
|
||||
Text("\(s.remoteAddress):\(s.remotePort)").monospaced()
|
||||
Text("\(s.remoteAddress):\(s.remotePort)").mmMonospaced()
|
||||
}
|
||||
TableColumn("State") { s in
|
||||
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
|
||||
@@ -752,20 +752,20 @@ public struct ContentView: View {
|
||||
if !store.batteryHealth.hasBattery {
|
||||
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Cycle Count:").foregroundStyle(.secondary)
|
||||
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
|
||||
Text("Temperature:").foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Voltage / Power:").foregroundStyle(.secondary)
|
||||
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
|
||||
Text("Power Adapter:").foregroundStyle(.secondary)
|
||||
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Capacity:").foregroundStyle(.secondary)
|
||||
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
|
||||
Text("Manufacturer:").foregroundStyle(.secondary)
|
||||
@@ -798,10 +798,10 @@ public struct ContentView: View {
|
||||
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
TableColumn("Vendor ID") { d in
|
||||
Text(String(format: "0x%04x", d.vendorID)).monospaced()
|
||||
Text(String(format: "0x%04x", d.vendorID)).mmMonospaced()
|
||||
}
|
||||
TableColumn("Product ID") { d in
|
||||
Text(String(format: "0x%04x", d.productID)).monospaced()
|
||||
Text(String(format: "0x%04x", d.productID)).mmMonospaced()
|
||||
}
|
||||
TableColumn("Built-In") { d in
|
||||
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
|
||||
@@ -962,14 +962,14 @@ public struct ContentView: View {
|
||||
.font(.title3.bold().monospacedDigit())
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("CPU Power:").foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
|
||||
Text("GPU Power:").foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("CPU Voltage:").foregroundStyle(.secondary)
|
||||
Text(String(format: "%.2f V", store.power.cpuVoltage))
|
||||
Text("CPU Current:").foregroundStyle(.secondary)
|
||||
@@ -990,24 +990,22 @@ public struct ContentView: View {
|
||||
Text("Host Architecture & Kernel")
|
||||
.font(.headline)
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Hardware Model:").foregroundStyle(.secondary)
|
||||
Text(store.hostModel).bold()
|
||||
Text("Physical Cores:").foregroundStyle(.secondary)
|
||||
Text("\(store.physicalCpuCount)").bold()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Darwin Kernel:").foregroundStyle(.secondary)
|
||||
Text(store.kernelVersion).bold()
|
||||
Text("Logical Cores:").foregroundStyle(.secondary)
|
||||
Text("\(store.logicalCpuCount)").bold()
|
||||
}
|
||||
GridRow {
|
||||
HStack {
|
||||
Text("Target Architecture:").foregroundStyle(.secondary)
|
||||
Text("Intel x86_64").bold()
|
||||
Text("Deployment Target:").foregroundStyle(.secondary)
|
||||
Text("macOS 14.0 (Sonoma)").bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,6 +1219,7 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
// MARK: - Process Detail Inspector Sheet
|
||||
@MainActor
|
||||
struct ProcessDetailSheet: View {
|
||||
let inspector: MMProcessDetailInspector
|
||||
let pid: Int32
|
||||
@@ -1249,8 +1248,9 @@ struct ProcessDetailSheet: View {
|
||||
if selectedDetailTab == "Threads" {
|
||||
List(threads.indices, id: \.self) { idx in
|
||||
let t = threads[idx]
|
||||
let threadID = "\(t["threadID"] ?? idx)"
|
||||
HStack {
|
||||
Text("Thread \(t["threadID"] ?? idx)").bold()
|
||||
Text("Thread \(threadID)").bold()
|
||||
Spacer()
|
||||
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
|
||||
Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
|
||||
@@ -1263,11 +1263,16 @@ struct ProcessDetailSheet: View {
|
||||
} else {
|
||||
List(sockets.indices, id: \.self) { idx in
|
||||
let s = sockets[idx]
|
||||
let proto = s["protocol"] as? String ?? "TCP"
|
||||
let localAddr = (s["localAddress"] as? String) ?? ""
|
||||
let localPort = (s["localPort"] as? Int) ?? 0
|
||||
let remoteAddr = (s["remoteAddress"] as? String) ?? ""
|
||||
let remotePort = (s["remotePort"] as? Int) ?? 0
|
||||
HStack {
|
||||
Text(s["protocol"] as? String ?? "TCP").bold()
|
||||
Text(proto).bold()
|
||||
Spacer()
|
||||
Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)")
|
||||
.monospaced()
|
||||
Text("\(localAddr):\(localPort) → \(remoteAddr):\(remotePort)")
|
||||
.mmMonospaced()
|
||||
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct QuickGlancePopoverView: View {
|
||||
var store: SystemTelemetryStore
|
||||
@ObservedObject var store: SystemTelemetryStore
|
||||
|
||||
public init(store: SystemTelemetryStore) {
|
||||
self.store = store
|
||||
@@ -109,9 +110,7 @@ public struct QuickGlancePopoverView: View {
|
||||
}
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.fontDesign(.monospaced)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import UserNotifications
|
||||
|
||||
/// Preferences UI for threshold alert rules. Bound directly to
|
||||
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
|
||||
@MainActor
|
||||
public struct AlertsSettingsView: View {
|
||||
@Bindable private var engine = AlertEngine.shared
|
||||
@ObservedObject private var engine = AlertEngine.shared
|
||||
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||
|
||||
public init() {}
|
||||
@@ -58,24 +59,24 @@ public struct AlertsSettingsView: View {
|
||||
|
||||
Section("CPU Temperature") {
|
||||
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
|
||||
LabeledContent("Threshold") {
|
||||
MMLabeledContent("Threshold") {
|
||||
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Cooling Fans") {
|
||||
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
|
||||
LabeledContent("Alert when CPU above") {
|
||||
MMLabeledContent("Alert when CPU above") {
|
||||
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
|
||||
@@ -85,12 +86,12 @@ public struct AlertsSettingsView: View {
|
||||
|
||||
Section("Memory") {
|
||||
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
|
||||
LabeledContent("Free RAM below") {
|
||||
MMLabeledContent("Free RAM below") {
|
||||
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 64, alignment: .trailing)
|
||||
}
|
||||
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
|
||||
@@ -98,32 +99,32 @@ public struct AlertsSettingsView: View {
|
||||
|
||||
Section("Storage") {
|
||||
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
|
||||
LabeledContent("Free space below") {
|
||||
MMLabeledContent("Free space below") {
|
||||
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
LabeledContent("Free percent below") {
|
||||
MMLabeledContent("Free percent below") {
|
||||
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Battery") {
|
||||
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
|
||||
LabeledContent("Charge below") {
|
||||
MMLabeledContent("Charge below") {
|
||||
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
|
||||
EmptyView()
|
||||
}
|
||||
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
|
||||
.monospacedDigit()
|
||||
.mmMonospacedDigit()
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
Text("Only fires while discharging on battery power.")
|
||||
@@ -131,7 +132,7 @@ public struct AlertsSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.mmFormStyleGrouped()
|
||||
.frame(minWidth: 480, minHeight: 520)
|
||||
.task { await refreshAuthorizationStatus() }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import XCTest
|
||||
import SwiftUI
|
||||
@testable import MacMonitor
|
||||
|
||||
@MainActor
|
||||
final class MMMenuBarTests: XCTestCase {
|
||||
|
||||
func testMenuBarStatusViewInitialization() {
|
||||
|
||||
Reference in New Issue
Block a user