From d23551b6005ea6a669e6760148799b3649927988 Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Tue, 8 Sep 2026 10:43:52 +0100 Subject: [PATCH 01/39] docs: add deterministic master BUILD-PLAN.md covering milestones, parallel tracks, and branch conventions --- BUILD-PLAN.md | 249 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 BUILD-PLAN.md diff --git a/BUILD-PLAN.md b/BUILD-PLAN.md new file mode 100644 index 0000000..9043b53 --- /dev/null +++ b/BUILD-PLAN.md @@ -0,0 +1,249 @@ +# MacMonitor: Deterministic Master Build Plan + +## 1. Project Specifications & Architectural Constraints + +- **Platform Target:** macOS for Intel (`x86_64`). +- **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). +- **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]`). +- **Target Hardware Spectrum:** Intel-based MacBook (Air/Pro), iMac, Mac mini, and Mac Pro (2019/Rack). + +--- + +## 2. Deterministic Git Branching Strategy & Naming Conventions + +### 2.1 Branch Taxonomy +All development follows a strict GitFlow-inspired branching model with deterministic naming: + +| Branch Role | Branch Pattern | Source Branch | Merge Target | Description | +| :--- | :--- | :--- | :--- | :--- | +| **Production** | `main` | N/A | N/A | Production-ready releases tagged with SemVer (`v1.0.0`). | +| **Integration** | `develop` | `main` | `main` | Primary active development and staging integration branch. | +| **Milestone** | `milestone/m-` | `develop` | `develop` | Milestone stabilization branch grouping dependent features. | +| **Feature** | `feat/-` | `milestone/m-...` | `milestone/m-...` | Isolated feature development branch tied directly to Gitea issue. | +| **Bugfix** | `fix/-` | `develop` or `main` | `develop` or `main` | Defect resolution branch. | + +### 2.2 Deterministic Feature Branch Directory + +| Issue # | Feature Title | Milestone | Exact Branch Name | +| :---: | :--- | :---: | :--- | +| **#1** | Core Application Architecture & Bridging | M1 | `feat/1-core-architecture` | +| **#2** | SMC Interface Engine (AppleSMC Client) | M1 | `feat/2-smc-client` | +| **#25** | Gitea Actions CI/CD Pipeline | M1 | `feat/25-gitea-ci` | +| **#3** | CPU Core Temperatures & Thermal Zones | M2 | `feat/3-cpu-thermals` | +| **#4** | Fan Speed & Multi-Fan Telemetry | M2 | `feat/4-fan-telemetry` | +| **#5** | Host CPU Core Utilization & Frequency | M2 | `feat/5-cpu-load` | +| **#8** | RAM Breakdown, Compression & Pressure | M2 | `feat/8-ram-breakdown` | +| **#9** | Storage Volumes & APFS Containers | M2 | `feat/9-storage-volumes` | +| **#18** | Motherboard, PCH & Heatsink Temps | M2 | `feat/18-component-temps` | +| **#19** | Power Consumption, Voltage & Current | M2 | `feat/19-power-voltage` | +| **#6** | Kernel Context Switching & Syscalls | M3 | `feat/6-kernel-counters` | +| **#7** | System Load Averages & Concurrency | M3 | `feat/7-load-averages` | +| **#10** | Real-Time Disk I/O & IOPS Telemetry | M3 | `feat/10-disk-io` | +| **#11** | Live Process Explorer & Table | M3 | `feat/11-process-explorer` | +| **#12** | Per-Process Threads & File Descriptors | M3 | `feat/12-process-threads` | +| **#14** | Intel iGPU & AMD Discrete GPU Telemetry| M3 | `feat/14-gpu-telemetry` | +| **#15** | Real-Time Network Bandwidth | M3 | `feat/15-network-throughput` | +| **#16** | Active Network Sockets Inspector | M3 | `feat/16-network-sockets` | +| **#17** | MacBook Battery & Power Adapter | M3 | `feat/17-battery-telemetry` | +| **#20** | Connected Peripherals & Bus Topology | M3 | `feat/20-bus-topology` | +| **#21** | Audio Devices & Volume Monitor | M3 | `feat/21-audio-status` | +| **#13** | Screen Brightness & Display Management| M4 | `feat/13-display-brightness` | +| **#22** | Status Bar Menu Bar Extra & Popover | M4 | `feat/22-menubar-popover` | +| **#23** | Historical Rolling Trend Charts | M4 | `feat/23-trend-charts` | +| **#24** | Threshold Alerts & macOS Notifications | M5 | `feat/24-threshold-alerts` | + +--- + +## 3. Milestone Breakdown & Implementation Sequences + +```mermaid +graph TD + classDef m1 fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px; + classDef m2 fill:#dcfce7,stroke:#15803d,stroke-width:2px; + classDef m3 fill:#fef3c7,stroke:#b45309,stroke-width:2px; + classDef m4 fill:#f3e8ff,stroke:#7e22ce,stroke-width:2px; + classDef m5 fill:#fee2e2,stroke:#b91c1c,stroke-width:2px; + + %% M1 Foundation + I1["#1 Core Architecture"]:::m1 + I2["#2 AppleSMC Client"]:::m1 + I25["#25 Gitea CI/CD Pipeline"]:::m1 + + I1 --> I2 + I1 --> I25 + + %% M2 Primary Telemetry + I5["#5 CPU Core Utilization"]:::m2 + I8["#8 RAM & Memory Pressure"]:::m2 + I9["#9 Storage Volumes"]:::m2 + I3["#3 CPU Core Temps"]:::m2 + I4["#4 Fan Speeds"]:::m2 + I18["#18 Component Temps"]:::m2 + I19["#19 Power & Voltage"]:::m2 + + I1 --> I5 + I1 --> I8 + I1 --> I9 + I2 --> I3 + I2 --> I4 + I2 --> I18 + I2 --> I19 + + %% M3 Advanced Telemetry + I6["#6 Kernel Counters"]:::m3 + I7["#7 Load Averages"]:::m3 + I10["#10 Disk I/O & IOPS"]:::m3 + I11["#11 Process Explorer"]:::m3 + I12["#12 Thread/FD Details"]:::m3 + I14["#14 GPU Telemetry"]:::m3 + I15["#15 Network Bandwidth"]:::m3 + I16["#16 Socket Inspector"]:::m3 + I17["#17 Battery Telemetry"]:::m3 + I20["#20 Peripheral Bus"]:::m3 + I21["#21 Audio Devices"]:::m3 + + I1 --> I6 + I1 --> I7 + I9 --> I10 + I1 --> I11 + I11 --> I12 + I1 --> I14 + I1 --> I15 + I11 --> I16 + I15 --> I16 + I1 --> I17 + I1 --> I20 + I1 --> I21 + + %% M4 Presentation + I13["#13 Display Brightness"]:::m4 + I22["#22 MenuBar & Popover"]:::m4 + I23["#23 Swift Trend Charts"]:::m4 + + I1 --> I13 + I1 --> I22 + I1 --> I23 + + %% M5 Intelligence + I24["#24 Alerts & Notifications"]:::m5 + I3 --> I24 + I4 --> I24 + I8 --> I24 + I9 --> I24 +``` + +--- + +### Milestone 1: Architecture Foundation & Hardware Abstraction +- **Integration Branch:** `milestone/m1-foundation` +- **Goal:** Deliver the Objective-C bridging layer, central telemetry coordinator, AppleSMC IOKit driver, and Gitea Actions Intel runner CI. +- **Execution Order:** + 1. **Phase 1.1 (Sequential Anchor):** `#1` Core Architecture (`feat/1-core-architecture`). Establish project structure, bridging header, and base `MMTelemetryProvider` protocol. + 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. + +--- + +### Milestone 2: Primary System & Thermal Telemetry +- **Integration Branch:** `milestone/m2-primary-telemetry` +- **Goal:** Core hardware and OS resource telemetry (CPU, thermals, fans, memory, storage volumes, power). +- **Execution Order (Parallel Tracks):** + - **Track 2A (OS & Kernel Track - Depends on #1):** + - `#5` Host CPU Core Utilization (`feat/5-cpu-load`) + - `#8` RAM Breakdown & Memory Pressure (`feat/8-ram-breakdown`) + - `#9` Storage Volumes & APFS Containers (`feat/9-storage-volumes`) + - **Track 2B (SMC Hardware Track - Depends on #2):** + - `#3` CPU Core Temperatures (`feat/3-cpu-thermals`) + - `#4` Fan Speed Telemetry (`feat/4-fan-telemetry`) + - `#18` Component & Heatsink Temperatures (`feat/18-component-temps`) + - `#19` Power & Voltage Telemetry (`feat/19-power-voltage`) +- **Quality Gate M2:** All 7 providers reporting valid telemetry concurrently in a unified sample loop without memory leaks or race conditions. + +--- + +### Milestone 3: Advanced Kernel, Process & Peripheral Telemetry +- **Integration Branch:** `milestone/m3-advanced-telemetry` +- **Goal:** Granular process inspection, storage throughput, network bandwidth, GPU, and peripheral buses. +- **Execution Order (Parallel Tracks):** + - **Track 3A (Process & Concurrency Subsystem):** + - `#6` Kernel Context Switching & Syscalls (`feat/6-kernel-counters`) + - `#7` System Load Averages (`feat/7-load-averages`) + - `#11` Live Process Explorer (`feat/11-process-explorer`) + - `#12` Per-Process Threads & File Descriptors (`feat/12-process-threads` - Depends on #11) + - **Track 3B (I/O & Networking Subsystem):** + - `#10` Real-Time Disk I/O & IOPS (`feat/10-disk-io` - Depends on #9) + - `#15` Real-Time Network Bandwidth (`feat/15-network-throughput`) + - `#16` Active Network Sockets (`feat/16-network-sockets` - Depends on #11 and #15) + - **Track 3C (Graphics, Power & Peripherals):** + - `#14` Intel iGPU & AMD dGPU Telemetry (`feat/14-gpu-telemetry`) + - `#17` MacBook Battery Telemetry (`feat/17-battery-telemetry`) + - `#20` Connected Peripherals & Bus Topology (`feat/20-bus-topology`) + - `#21` Audio Devices & Volume Monitor (`feat/21-audio-status`) +- **Quality Gate M3:** High-volume process and socket enumeration running smoothly under stress without UI hitching or exceeding 2% background CPU load. + +--- + +### 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. +- **Execution Order:** + 1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`) + 2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`) + 3. `#23` Historical Rolling Trend Charts (`feat/23-trend-charts`) +- **Quality Gate M4:** Menu bar and popover views maintain 60 FPS animation during telemetry refreshes; smooth dark/light mode transitions. + +--- + +### Milestone 5: Intelligence, Alerts & Hardening +- **Integration Branch:** `milestone/m5-alerts-hardening` +- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging. +- **Execution Order:** + 1. `#24` Threshold Alerts & Notification Center (`feat/24-threshold-alerts` - Depends on #3, #4, #8, #9) + 2. Full end-to-end integration testing and automated release archiving (`MacMonitor-intel-x86_64.zip`) on tagged commit. +- **Quality Gate M5:** Zero crashes, all unit test suites passing on Intel macOS runner, DMG/ZIP distribution artifact ready. + +--- + +## 4. Parallel Stage Interaction Contracts + +To allow independent development across parallel tracks, modules adhere to three immutable interaction contracts: + +### 4.1 Provider Contract (`MMTelemetryProvider`) +Every data provider must conform to `MMTelemetryProvider`: +```objc +@protocol MMTelemetryProvider +@property (nonatomic, readonly) MMTelemetryDomain domain; +@property (nonatomic, readonly, copy) NSString *providerIdentifier; +@property (nonatomic, readonly, getter=isAvailable) BOOL available; + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error; +@optional +- (void)startMonitoring; +- (void)stopMonitoring; +@end +``` + +### 4.2 Data Transfer Object (DTO) Contract +Providers emit read-only snapshot dictionaries on the background telemetry queue. Key schemas are strictly typed and documented in each issue's technical implementation notes. Swift view models ingest snapshots across the `@MainActor` boundary using value-type structs (`Sendable`). + +### 4.3 Threading & Concurrency Contract +- **Sampling Thread:** Exclusively executed on a serial Grand Central Dispatch queue (`com.i3omb.macmonitor.telemetry`, QoS: `QOS_CLASS_UTILITY`). +- **UI Store:** State updates are dispatched to the `@MainActor` shared store (`SystemTelemetryStore`). +- **Mach Memory Safety:** Every pointer allocated by Mach calls (`host_processor_info`, `proc_listpids`) must be deallocated in the same sampling tick using `vm_deallocate` or `free`. + +--- + +## 5. Verification & Testing Matrix + +| Subsystem | Automated Verification | Manual / Hardware Verification | +| :--- | :--- | :--- | +| **SMC & Sensors** | Mocked SMC binary byte-buffer parsing tests | Verifying RPM and temperatures on MacBook and iMac | +| **Mach Host CPU** | Math unit tests for tick delta calculations | Verifying CPU load match against `/usr/bin/top -l 1` | +| **Memory / VM** | Verifying formula `app + wired + compressed` vs total | Comparison with macOS Activity Monitor Memory tab | +| **Process Explorer** | PID lifecycle unit test with child spawn & termination | Testing process filtering and termination confirmation | +| **Network & Disk** | Loopback synthetic traffic generator test | Verifying large file transfer throughput metrics | +| **Build & CI** | Automated Gitea Actions build on macOS Intel runner | Xcode Archive validation and `.app` gatekeeper testing | -- 2.39.5 From 39195c6552e0d15bcfe5baf5e7764847c2adc7ff Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Tue, 8 Sep 2026 11:17:31 +0100 Subject: [PATCH 02/39] docs: update README.md with comprehensive project documentation --- README.md | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1db8b9e..f7203e2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,209 @@ -# MacMonitor +# MacMonitor ๐Ÿ–ฅ๏ธ +[![Platform](https://img.shields.io/badge/Platform-macOS%2014%2B%20Sonoma-blue.svg)](https://www.apple.com/macos/sonoma/) +[![Architecture](https://img.shields.io/badge/Architecture-Intel%20x86__64-orange.svg)](https://en.wikipedia.org/wiki/X86-64) +[![Language](https://img.shields.io/badge/Language-SwiftUI%20%7C%20Objective--C-red.svg)]() +[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea%20Actions-green.svg)](https://git.i3omb.com/gronod/MacMonitor/actions) +[![License](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE) + +**MacMonitor** is a native, high-performance system monitor engineered specifically for **Intel-based Macs** running **macOS 14 (Sonoma)** and above. Built with a hybrid architecture of a lightweight **SwiftUI** presentation layer and low-level **Objective-C/C** kernel telemetry providers, it delivers deep visibility into the unique hardware characteristics of Intel MacBooks, iMacs, Mac minis, and Mac Pros. + +--- + +## Features & Telemetry Capabilities + +### โšก Processor & Kernel Performance +- **Per-Core Utilization:** Real-time multi-core activity distribution (User %, System %, Nice %, Idle %) via Mach kernel `host_processor_info`. +- **Clock Frequencies & Throttling:** Active CPU core clock speeds (GHz), Turbo Boost state, and thermal frequency throttling indicators. +- **Kernel Concurrency:** Unix load averages (1m, 5m, 15m), normalized core saturation metrics, context switches/sec, system calls/sec, interrupts/sec, and page faults/sec. + +### โ„๏ธ Hardware Thermals & Fan Management +- **CPU DTS Core Temperatures:** Granular Intel Digital Thermal Sensor readings for every logical and physical core (`TC0C`โ€“`TC15C`), CPU package die (`TC0P`/`TC0D`), and PECI interface. +- **Multi-Fan Telemetry:** Automated fan enumeration (`FNum`), live RPM tachometers (`F{i}Ac`), minimum/maximum RPM limits, target RPM, and fan stall detection. +- **Chassis Component Matrix:** Motherboard Platform Controller Hub (PCH), system heatsinks, memory DIMMs, power supply unit (PSU), Thunderbolt controllers, and ambient air sensors. + +### ๐Ÿง  Memory & Virtual Memory +- **Activity Monitor RAM Breakdown:** Exact physical RAM distribution matching Activity Monitor (App Memory, Wired Memory, Compressed Memory, Cached Files, and Free Memory). +- **Virtual Memory Swap:** Total, used, available swap space, and real-time swap paging rates. +- **Memory Pressure Level:** Native Grand Central Dispatch memory pressure event notifications (Normal, Warning, Critical). + +### ๐Ÿ’พ Storage & Disk I/O +- **Volumes & APFS Containers:** Mounted internal SSDs, APFS snapshots, external Thunderbolt/USB volumes, with snapshot-aware purgeable space calculations. +- **Real-Time Disk Throughput:** Live read/write transfer rates (MB/s) and transaction rates (IOPS) via IOKit `IOBlockStorageDriverStatistics`. + +### ๐Ÿšฆ Live Process Explorer & Inspector +- **Process Activity Table:** Live enumeration of active processes with PID, executable path, application icon, % CPU, RSS memory, compressed memory, and thread counts. +- **Interactive Controls:** Instant search, sorting, filtering by user/system tasks, and safe process termination (`SIGTERM`/`SIGKILL`). +- **Process Drill-Down:** Detailed inspection of individual process threads, Mach ports, open file descriptors, and network sockets. + +### ๐ŸŽฎ Graphics (Dual-GPU & Integrated) +- **Intel iGPU & AMD dGPU:** Live engine utilization %, VRAM allocated/free, GPU core clock MHz, and temperature for Intel Iris/UHD Graphics and discrete AMD Radeon Pro GPUs. +- **Dynamic GPU Switching:** Seamless handling and power-down detection when macOS switches between integrated and discrete graphics. + +### ๐ŸŒ Network & Socket Inspector +- **64-Bit Network Throughput:** Instantaneous upload/download speeds (KB/s, MB/s), packet rates, and cumulative session totals via 64-bit BSD routing table counters (`struct if_data64`). +- **Active Sockets:** System-wide TCP/UDP connection explorer displaying local/remote IPs, ports, and connection states (`LISTEN`, `ESTABLISHED`, `TIME_WAIT`). + +### ๐Ÿ”‹ Battery, Power & Peripherals +- **MacBook Battery Longevity:** Charge %, health %, design vs maximum capacity, cycle count, instantaneous wattage, cell voltages, and AC adapter wattage. +- **Display & Screen Brightness:** Multi-display management with brightness monitoring and sliders via `DisplayServices`, refresh rates, resolution, and HDR status. +- **Peripheral Bus Topology:** USB hierarchy, Thunderbolt 3 chain link speeds, and PCIe expansion slot status (Mac Pro 2019). +- **Audio HAL Status:** Default input/output endpoints, sample rate (kHz), master volume scalars, and mute toggles. + +### ๐Ÿ“Š Presentation & Intelligence +- **macOS Menu Bar Extra:** Customizable, always-accessible status item with compact text or mini-sparklines. +- **Quick-Glance Popover:** Smooth, floating summary card for immediate hardware inspection. +- **Swift Charts Engine:** Rolling time-series graphs (1m to 1h) powered by high-performance in-memory ring buffers. +- **Threshold Alerts Engine:** Configurable thresholds (overheating, fan stall, memory exhaustion, low disk) with macOS Notification Center delivery and anti-spam cooldown logic. + +--- + +## Technical Architecture Overview + +```mermaid +flowchart TD + subgraph Hardware ["Intel Mac Hardware & Kernel"] + SMC["AppleSMC (IOKit)"] + MACH["Mach Kernel Host API"] + PROC["libproc & BSD Sysctl"] + IOK["IOKit Storage / GPU / USB"] + CA["CoreAudio / DisplayServices"] + end + + subgraph CoreEngine ["Low-Level Telemetry Engine (Objective-C/C)"] + CLIENT["MMAppleSMCClient"] + PROV["Telemetry Providers
(MMCPUThermalProvider, MMCPUUsageProvider, etc.)"] + COORD["MMTelemetryCoordinator
(Dispatch Timer ยท QOS_CLASS_UTILITY)"] + end + + subgraph Presentation ["Presentation Layer (SwiftUI & Swift)"] + STORE["SystemTelemetryStore (@Observable ยท @MainActor)"] + ALERT["AlertEngine"] + VIEWS["SwiftUI Dashboard ยท MenuBarExtra ยท Charts"] + end + + SMC <-->|"IOConnectCallStructMethod"| CLIENT + CLIENT --> PROV + MACH --> PROV + PROC --> PROV + IOK --> PROV + CA --> PROV + + PROV -->|"Immutable Snapshots"| COORD + COORD -->|"Async Dispatch"| STORE + STORE -->|"Evaluate Rules"| ALERT + STORE -->|"Reactive Binding"| VIEWS +``` + +For complete technical specifications, threading guarantees, and provider contracts, refer to [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Project Structure + +``` +MacMonitor/ +โ”œโ”€โ”€ .gitea/ +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ””โ”€โ”€ build.yml # Gitea Actions CI/CD workflow +โ”œโ”€โ”€ Documentation/ +โ”‚ โ”œโ”€โ”€ ARCHITECTURE.md # Detailed system architecture & API design +โ”‚ โ”œโ”€โ”€ AGENTS.md # AI pair programming guidelines & rules +โ”‚ โ””โ”€โ”€ BUILD-PLAN.md # Master deterministic roadmap & branch directory +โ”œโ”€โ”€ Sources/ +โ”‚ โ”œโ”€โ”€ Bridging/ +โ”‚ โ”‚ โ”œโ”€โ”€ MacMonitor-Bridging-Header.h +โ”‚ โ”‚ โ”œโ”€โ”€ MMTelemetryProvider.h +โ”‚ โ”‚ โ””โ”€โ”€ MMTelemetryCoordinator.h/.m +โ”‚ โ”œโ”€โ”€ Providers/ +โ”‚ โ”‚ โ”œโ”€โ”€ SMC/ # AppleSMC client, thermal & fan providers +โ”‚ โ”‚ โ”œโ”€โ”€ CPU/ # Mach processor load & kernel counters +โ”‚ โ”‚ โ”œโ”€โ”€ Memory/ # VM statistics & memory pressure +โ”‚ โ”‚ โ”œโ”€โ”€ Storage/ # Volume manager & IOKit disk I/O +โ”‚ โ”‚ โ”œโ”€โ”€ Process/ # libproc explorer & thread inspector +โ”‚ โ”‚ โ”œโ”€โ”€ Graphics/ # IOAccelerator GPU telemetry +โ”‚ โ”‚ โ”œโ”€โ”€ Network/ # BSD routing sockets & PCB inspector +โ”‚ โ”‚ โ”œโ”€โ”€ Power/ # AppleSmartBattery & electrical sensors +โ”‚ โ”‚ โ”œโ”€โ”€ Display/ # DisplayServices brightness & mode manager +โ”‚ โ”‚ โ””โ”€โ”€ Peripherals/ # USB, PCIe, and CoreAudio HAL providers +โ”‚ โ”œโ”€โ”€ UI/ +โ”‚ โ”‚ โ”œโ”€โ”€ App/ # MacMonitorApp entry point & MenuBarExtra +โ”‚ โ”‚ โ”œโ”€โ”€ Views/ # Dashboard, popover, process table views +โ”‚ โ”‚ โ”œโ”€โ”€ Charts/ # Swift Charts & ring buffer engine +โ”‚ โ”‚ โ””โ”€โ”€ ViewModels/ # SystemTelemetryStore & feature models +โ”‚ โ””โ”€โ”€ Intelligence/ +โ”‚ โ””โ”€โ”€ AlertEngine.swift # Threshold evaluations & UserNotifications +โ””โ”€โ”€ Tests/ + โ””โ”€โ”€ UnitTests/ # Mocked SMC, Mach, and Provider unit tests +``` + +--- + +## Building & Development Setup + +### Prerequisites +- macOS 14.0 (Sonoma) or newer. +- Xcode 15.0 or newer with command line tools installed. +- Intel Mac (`x86_64`) for native hardware testing (or an Intel macOS CI runner). + +### Build via Command Line +```bash +# Clone the repository +git clone https://git.i3omb.com/gronod/MacMonitor.git ~/Projects/MacMonitor +cd ~/Projects/MacMonitor + +# Check out the active integration branch +git checkout develop + +# Build Release binary for Intel x86_64 +xcodebuild build \ + -scheme MacMonitor \ + -destination 'generic/platform=macOS,arch=x86_64' \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO +``` + +### Run Automated Tests +```bash +xcodebuild test \ + -scheme MacMonitor \ + -destination 'platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO +``` + +--- + +## Roadmap & Milestones + +The project is broken down into 5 sequential delivery milestones with deterministic feature branches: + +1. **[Milestone 1: Architecture Foundation & Hardware Abstraction](https://git.i3omb.com/gronod/MacMonitor/milestone/18)** (`milestone/m1-foundation`) + - Core Objective-C bridging, `MMTelemetryCoordinator`, `AppleSMC` IOKit driver, and Gitea Actions CI. +2. **[Milestone 2: Primary System & Thermal Telemetry](https://git.i3omb.com/gronod/MacMonitor/milestone/19)** (`milestone/m2-primary-telemetry`) + - CPU load, CPU core temps, fan speeds, RAM breakdown, storage volumes, and electrical power. +3. **[Milestone 3: Advanced Kernel, Process & Peripheral Telemetry](https://git.i3omb.com/gronod/MacMonitor/milestone/20)** (`milestone/m3-advanced-telemetry`) + - Disk throughput (IOPS), Process Explorer, network bandwidth & sockets, GPU, battery, and peripherals. +4. **[Milestone 4: Presentation, Visuals & Menu Bar Integration](https://git.i3omb.com/gronod/MacMonitor/milestone/21)** (`milestone/m4-presentation`) + - macOS 14 Menu Bar Extra, quick-glance popovers, display brightness control, and Swift Charts. +5. **[Milestone 5: Intelligence, Alerts & Hardening](https://git.i3omb.com/gronod/MacMonitor/milestone/22)** (`milestone/m5-alerts-hardening`) + - Alert rule evaluation, Notification Center delivery, release packaging, and distribution. + +Refer to [BUILD-PLAN.md](BUILD-PLAN.md) for the complete milestone schedule, parallel stage contracts, and exact branch names. + +--- + +## Contributing & Git Workflow + +1. All features are developed in dedicated branches branching off their respective milestone branch (`milestone/m-...`): + ```bash + git checkout milestone/m1-foundation + git checkout -b feat/1-core-architecture + ``` +2. Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) format (`feat:`, `fix:`, `docs:`, `test:`). +3. Open a Pull Request targeting the corresponding milestone branch. Ensure CI checks pass on Gitea Actions. + +--- + +## License + +This project is licensed under the **MIT License** โ€” see the [LICENSE](LICENSE) file for details. -- 2.39.5 From a17077d8dfd1a813e2010683c1ad2ef02045a299 Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Tue, 8 Sep 2026 11:17:44 +0100 Subject: [PATCH 03/39] docs: add ARCHITECTURE.md detailing system design, SMC protocols, and kernel telemetry --- ARCHITECTURE.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c6e938c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,209 @@ +# MacMonitor: System Architecture & Technical Specifications + +This document details the architectural principles, data flow models, memory safety invariants, and subsystem implementation specifics for **MacMonitor**. + +--- + +## 1. Architectural Principles & Constraints + +### 1.1 Target Platform Invariants +- **Instruction Set Architecture:** Intel `x86_64`. +- **Target OS:** macOS 14.0 (Sonoma) or newer. +- **Language Stack:** + - **Presentation & ViewModels:** Modern Swift 5.9+ with SwiftUI, `@Observable`, and Swift Charts. + - **Telemetry & Kernel Layer:** Objective-C & C interfaces directly communicating with Mach, IOKit, BSD sysctl, CoreAudio, and DisplayServices. +- **Hybrid Bridge:** Bridging Header (`MacMonitor-Bridging-Header.h`) providing strict separation of concerns and zero overhead between C structures and Swift value types. + +--- + +## 2. Unidirectional Data Flow & Concurrency Model + +```mermaid +flowchart TD + subgraph KernelSpace ["Kernel / Driver Space"] + SMC_DEV["AppleSMC Driver"] + MACH_KERN["Mach Kernel Task/Host"] + BSD_SYS["BSD Sysctl & libproc"] + IOK_DRV["IOBlockStorage & IOAccelerator"] + end + + subgraph TelemetryLayer ["Low-Level Telemetry Engine (Serial GCD Queue)"] + DISPATCH["dispatch_source_t Timer
(com.i3omb.macmonitor.telemetry ยท QOS_CLASS_UTILITY)"] + PROV1["MMAppleSMCClient"] + PROV2["MMCPUUsageProvider"] + PROV3["MMMemoryProvider"] + PROV4["MMProcessCollector"] + BATCH["Snapshot Dictionary / DTO Aggregator"] + end + + subgraph PresentationLayer ["UI & State Layer (@MainActor)"] + STORE["SystemTelemetryStore (@Observable)"] + ALERTS["AlertEngine (Rule Evaluator)"] + VIEWS["SwiftUI Dashboard / Popover / Charts (60 FPS)"] + end + + SMC_DEV <-->|"IOConnectCallStructMethod"| PROV1 + MACH_KERN -->|"host_processor_info / host_statistics64"| PROV2 + BSD_SYS -->|"sysctl / proc_listpids"| PROV3 + BSD_SYS --> PROV4 + IOK_DRV -->|"IORegistryEntryCreateCFProperties"| BATCH + + DISPATCH -->|"Trigger Tick (1s/2s)"| PROV1 & PROV2 & PROV3 & PROV4 + PROV1 & PROV2 & PROV3 & PROV4 --> BATCH + + BATCH -->|"Async Dispatch (Snapshot Copy)"| STORE + STORE -->|"Evaluate Rules"| ALERTS + STORE -->|"Declarative Bindings"| VIEWS +``` + +### 2.1 Threading & QoS Architecture +1. **Background Polling Queue:** + All hardware and kernel polling operations execute on a dedicated serial queue: + ```objc + dispatch_queue_t queue = dispatch_queue_create("com.i3omb.macmonitor.telemetry", DISPATCH_QUEUE_SERIAL); + ``` + Configured with quality-of-service `QOS_CLASS_UTILITY` to prevent thermal or CPU contention with foreground user processes. +2. **Deterministic Sampling Timer:** + Driven by a Grand Central Dispatch timer source (`dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, ...)`). Timer coalescing leeway is set to 10% of the sample period to conserve battery on MacBooks while maintaining timing accuracy. +3. **Main Actor State Ingestion:** + Snapshots are unmarshaled into immutable, `Sendable` Swift structures and dispatched asynchronously to `@MainActor`: + ```swift + Task { @MainActor in + self.store.digest(snapshot: batch) + } + ``` +4. **Mach Virtual Memory Management Invariant:** + Calls returning kernel-allocated memory arrays (such as `host_processor_info` or `processor_cpu_load_info`) must explicitly deallocate their previous memory pointers using `vm_deallocate(mach_task_self(), ...)` in the subsequent sampling pass to prevent progressive heap bloat. + +--- + +## 3. Subsystem Implementation Specifications + +### 3.1 Apple System Management Controller (AppleSMC) +- **Service Name:** `AppleSMC` +- **IOKit Connection:** Initiated using `IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC"))` and `IOServiceOpen(service, mach_task_self(), 0, &connect)`. +- **Selector:** `kSMCHandleYPCEvent` (Index 2) dispatched via `IOConnectCallStructMethod`. + +#### SMC Data Types & Decoding Algorithms +| Data Type | FourCC | Byte Length | Encoding Scheme | Decoding Formula | +| :--- | :---: | :---: | :--- | :--- | +| `sp78` | `0x73703738` | 2 | Signed 8.8 Fixed-Point | `tempC = ((int16_t)(b[0] << 8 \| b[1])) / 256.0` | +| `fpe2` | `0x66706532` | 2 | Unsigned 14.2 Fixed-Point | `rpm = ((uint16_t)(b[0] << 8 \| b[1])) / 4.0` | +| `ui8` | `0x75693820` | 1 | Unsigned 8-bit Integer | `val = (uint8_t)b[0]` | +| `ui16` | `0x75693136` | 2 | Unsigned 16-bit Big-Endian | `val = (uint16_t)(b[0] << 8 \| b[1])` | +| `ui32` | `0x75693332` | 4 | Unsigned 32-bit Big-Endian | `val = (uint32_t)(b[0] << 24 \| ... \| b[3])` | +| `flt ` | `0x666c7420` | 4 | IEEE 754 32-bit Float | `memcpy(&val, b, 4)` | + +#### Intel SMC Key Catalog +- **Fans:** `FNum` (Total count), `F0Ac`/`F1Ac` (Current RPM), `F0Mn`/`F1Mn` (Min RPM), `F0Mx`/`F1Mx` (Max RPM), `F0Tg`/`F1Tg` (Target RPM), `F0ID`/`F1ID` (Fan name). +- **CPU Thermals:** `TC0P` (Package proximity), `TC0D` (Die), `TCXC` (PECI), `TC0C`โ€“`TC15C` (Individual core DTS). +- **Chassis Thermals:** `TPCD` (PCH Die), `Th0H`โ€“`Th2H` (Heatsinks), `TM0P`โ€“`TM3P` (Memory DIMMs), `TA0P`/`TA1P` (Chassis Ambient), `Tp0P` (Power Supply), `TTLD` (Thunderbolt). +- **Electrical & RAPL:** `PSTR`/`PDTR` (System Total Watts), `PCPR`/`PCTR` (CPU Package Watts via Intel RAPL), `PG0R` (GPU Watts), `VC0C` (Core Voltage), `IC0C` (Core Current). + +--- + +### 3.2 Host CPU & Kernel Scheduling Telemetry +- **Per-Core Tick Counting:** + Executed via `host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, ...)`. + Deltas between ticks determine normalized CPU percentages: + $$\Delta \text{ticks} = \Delta \text{user} + \Delta \text{system} + \Delta \text{idle} + \Delta \text{nice}$$ + $$\text{Core \%} = \frac{\Delta \text{user} + \Delta \text{system} + \Delta \text{nice}}{\Delta \text{ticks}} \times 100\%$$ +- **Kernel Context Switches & Syscalls:** + Polled via BSD sysctl node statistics: + - `vm.stats.sys.v_swtch` + - `vm.stats.sys.v_syscall` + - `vm.stats.sys.v_intr` + - `vm_statistics64.faults` (Page faults) + Rates are computed against elapsed nanoseconds measured using `mach_absolute_time()` and `mach_timebase_info()`. +- **Unix Load Averages:** + Queried via standard BSD `getloadavg(double load[], 3)` and normalized against `[NSProcessInfo processInfo].activeProcessorCount`. + +--- + +### 3.3 Virtual Memory & Pressure Subsystem +- **RAM Calculation Strategy:** + Direct query of `host_statistics64(mach_host_self(), HOST_VM_INFO64, ...)` multiplied by `vm_kernel_page_size`. + - **App Memory:** $(\text{internal\_count} - \text{purgeable\_count}) \times \text{page\_size}$ + - **Wired Memory:** $\text{wire\_count} \times \text{page\_size}$ + - **Compressed Memory:** $\text{compressor\_page\_count} \times \text{page\_size}$ + - **Cached Files:** $(\text{external\_page\_count} + \text{purgeable\_count}) \times \text{page\_size}$ + - **Free Memory:** $\text{free\_count} \times \text{page\_size}$ +- **Swap Statistics:** `sysctlbyname("vm.swapusage", &swap, ...)` reporting `xsu_total`, `xsu_avail`, and `xsu_used`. +- **Memory Pressure Monitoring:** + Dedicated GCD dispatch source: + ```objc + dispatch_source_create(DISPATCH_SOURCE_TYPE_MEMORYPRESSURE, 0, + DISPATCH_MEMORYPRESSURE_NORMAL | + DISPATCH_MEMORYPRESSURE_WARN | + DISPATCH_MEMORYPRESSURE_CRITICAL, queue); + ``` + +--- + +### 3.4 Storage & Disk I/O Engine +- **Volume Capacity & APFS Snapshots:** + Queried via `NSFileManager.defaultManager.mountedVolumeURLsIncludingResourceValuesForKeys:`. + Utilizes `NSURLVolumeAvailableCapacityForImportantUsageKey` and `NSURLVolumeAvailableCapacityForOpportunisticUsageKey` to properly calculate usable storage space without showing local APFS snapshots as consumed capacity. +- **Real-Time Disk Throughput (IOPS & MB/s):** + Matches `IOBlockStorageDriver` in IOKit registry. Extracts `IOBlockStorageDriverStatistics`: + - `Bytes (Read)` and `Bytes (Write)` + - `Operations (Read)` and `Operations (Write)` + Deltas over elapsed time yield read/write throughput (MB/s) and IOPS. + +--- + +### 3.5 High-Performance Process Explorer +- **Enumeration:** `proc_listpids(PROC_ALL_PIDS, 0, pids, byteSize)` to retrieve active PIDs. +- **Resource Attribution:** + - `proc_pidinfo(pid, PROC_PIDTASKINFO, ...)` yields `pti_total_user`, `pti_total_system`, `pti_resident_size`, and `pti_threadnum`. + - Differential CPU calculation tracks elapsed nanoseconds vs elapsed CPU time per process. +- **Static Metadata Caching:** + Executable paths (`proc_pidpath`) and application icons (`NSWorkspace.iconForFile:`) are cached in an internal hash map keyed by PID to eliminate file system traversal overhead during 1 Hz polling loops. Dead PIDs are pruned on each iteration. +- **Drill-Down Inspector:** + - Threads: `proc_pidinfo(..., PROC_PIDLISTTHREADS)` + `PROC_PIDTHREADINFO`. + - File Descriptors: `proc_pidinfo(..., PROC_PIDLISTFDS)` + `proc_pidfdinfo(..., PROC_PIDFDVNODEPATHINFO)`. + - Sockets: `proc_pidfdinfo(..., PROC_PIDFDSOCKETINFO)`. + +--- + +### 3.6 GPU & Graphics Telemetry +- **IOKit Accelerator Service:** Matches `IOAccelerator` nodes in the IORegistry. +- **Performance Properties:** Inspects `PerformanceStatistics` dictionary: + - `Device Utilization %` + - `GPU Core Clock Hz` + - `vramUsedBytes` / `vramFreeBytes` + - `Temperature(C)` +- **Multi-GPU Architecture:** Handles both Intel integrated graphics (Intel Iris/UHD) and discrete AMD Radeon Pro GPUs on 15"/16" MacBook Pros. Correctly accommodates AMD GPU power-down states when automatic graphics switching is idle. + +--- + +### 3.7 Network Bandwidth & Protocol Control Blocks +- **64-Bit Network Traffic Counters:** + Avoids 32-bit integer rollover on high-throughput connections by querying BSD routing tables via `sysctl` with `NET_RT_IFLIST2` and decoding `struct if_data64` (`ifi_ibytes`, `ifi_obytes`, `ifi_ipackets`, `ifi_opackets`). +- **Socket Table Inspection:** + Scans active TCP/UDP Protocol Control Blocks via `net.inet.tcp.pcblist_n` and `net.inet.udp.pcblist_n`, parsing `xinpcb_n` to extract local/foreign addresses and connection states. + +--- + +### 3.8 Display & Screen Brightness +- **Dynamic Framework Linking:** + To safely access screen brightness across built-in MacBook screens and Apple external displays, private `DisplayServices.framework` symbols (`DisplayServicesGetBrightness`, `DisplayServicesSetBrightness`) are loaded dynamically via `dlopen`/`dlsym`. +- **Display Configurations:** + CoreGraphics APIs (`CGGetActiveDisplayList`, `CGDisplayCopyDisplayMode`) provide active screen resolution, refresh rate (Hz), and built-in panel identification. + +--- + +### 3.9 Time-Series Visualization (Swift Charts Engine) +- **Ring Buffer Design:** + Fixed-capacity circular ring buffers (`RingBuffer`) maintain rolling samples (60s, 5m, 15m, 1h). + Appending is $O(1)$ with zero runtime dynamic memory reallocations. +- **Swift Charts Integration:** + Renders using Apple's declarative `Chart`, `LineMark`, and gradient `AreaMark` components, backed by Metal rasterization. + +--- + +### 3.10 Intelligence & Alert Rules Engine +- **Architecture:** Synchronous post-sampling evaluation engine (`AlertEngine`). +- **Notification Throttle & Cooldown:** + Each alert identifier maintains a cooldown timestamp. Notifications for persistent abnormal conditions (e.g. CPU temperature > 95ยฐC) are capped at a maximum of 1 alert per 10โ€“15 minutes until normalized, preventing notification floods. +- **Delivery Channel:** Native `UNUserNotificationCenter` with critical priority sounds for fan stalls and hardware emergencies. -- 2.39.5 From 72a7000c19d526752cb384068d54328da1ccbca9 Mon Sep 17 00:00:00 2001 From: gronod <1+gronod@noreply@i3omb.com> Date: Tue, 8 Sep 2026 11:18:15 +0100 Subject: [PATCH 04/39] docs: add AGENTS.md AI agent operating manual and contributor guide --- AGENTS.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a69e9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# AGENTS.md: Developer & AI Agent Operating Guide + +Welcome to **MacMonitor**. This document serves as the authoritative operating manual for autonomous AI coding agents and human engineers contributing to this repository. + +--- + +## 1. Non-Negotiable Architectural Rules + +1. **Architecture & Target OS**: + - The target architecture is **Intel `x86_64`** exclusively. Do not introduce Apple Silicon ARM64-only assembly, intrinsics, or dependencies. + - The minimum deployment target is **macOS 14.0 (Sonoma)**. Do not downgrade deployment targets or use deprecated Carbon APIs. +2. **Language Boundaries**: + - **UI & Presentation**: Modern **SwiftUI** with `@Observable`, `@MainActor`, and **Swift Charts**. + - **Kernel & Telemetry**: **Objective-C / C** communicating directly with Mach kernel, IOKit, BSD sysctls, and CoreFoundation. + - **Bridging**: All Objective-C headers exposed to Swift must be declared in `Sources/Bridging/MacMonitor-Bridging-Header.h`. +3. **Gitea MCP Tool Usage**: + - Whenever performing repository operations, creating branches, reviewing issues, updating comments, or managing pull requests on `git.i3omb.com`, **always** use the available tools on the `gitea` MCP server (`call_mcp_tool` with `ServerName: "gitea"`). + - Never use raw shell scripts (`curl`, raw tokens) to interact with the Gitea API. + +--- + +## 2. Deterministic Branching Strategy & Workflow + +Every feature implementation must follow the branch taxonomy defined in [BUILD-PLAN.md](BUILD-PLAN.md): + +```mermaid +gitGraph + commit id: "Initial commit" + branch develop + checkout develop + commit id: "Docs & Build Plan" + branch milestone/m1-foundation + checkout milestone/m1-foundation + branch feat/1-core-architecture + checkout feat/1-core-architecture + commit id: "MMTelemetryCoordinator" + checkout milestone/m1-foundation + merge feat/1-core-architecture id: "PR #1 Merged" + branch feat/2-smc-client + checkout feat/2-smc-client + commit id: "AppleSMC IOKit client" + checkout milestone/m1-foundation + merge feat/2-smc-client id: "PR #2 Merged" + checkout develop + merge milestone/m1-foundation id: "M1 Complete" +``` + +### 2.1 Branch Naming Directory +When picking up an issue, you must use the exact branch name assigned to that issue: + +- Issue #1: `feat/1-core-architecture` (branch from `milestone/m1-foundation`) +- Issue #2: `feat/2-smc-client` (branch from `milestone/m1-foundation`) +- Issue #25: `feat/25-gitea-ci` (branch from `milestone/m1-foundation`) +- Issue #3: `feat/3-cpu-thermals` (branch from `milestone/m2-primary-telemetry`) +- Issue #4: `feat/4-fan-telemetry` (branch from `milestone/m2-primary-telemetry`) +- Issue #5: `feat/5-cpu-load` (branch from `milestone/m2-primary-telemetry`) +- Issue #8: `feat/8-ram-breakdown` (branch from `milestone/m2-primary-telemetry`) +- Issue #9: `feat/9-storage-volumes` (branch from `milestone/m2-primary-telemetry`) +- Issue #18: `feat/18-component-temps` (branch from `milestone/m2-primary-telemetry`) +- Issue #19: `feat/19-power-voltage` (branch from `milestone/m2-primary-telemetry`) +- Issue #6: `feat/6-kernel-counters` (branch from `milestone/m3-advanced-telemetry`) +- Issue #7: `feat/7-load-averages` (branch from `milestone/m3-advanced-telemetry`) +- Issue #10: `feat/10-disk-io` (branch from `milestone/m3-advanced-telemetry`) +- Issue #11: `feat/11-process-explorer` (branch from `milestone/m3-advanced-telemetry`) +- Issue #12: `feat/12-process-threads` (branch from `milestone/m3-advanced-telemetry`) +- Issue #14: `feat/14-gpu-telemetry` (branch from `milestone/m3-advanced-telemetry`) +- Issue #15: `feat/15-network-throughput` (branch from `milestone/m3-advanced-telemetry`) +- Issue #16: `feat/16-network-sockets` (branch from `milestone/m3-advanced-telemetry`) +- Issue #17: `feat/17-battery-telemetry` (branch from `milestone/m3-advanced-telemetry`) +- Issue #20: `feat/20-bus-topology` (branch from `milestone/m3-advanced-telemetry`) +- Issue #21: `feat/21-audio-status` (branch from `milestone/m3-advanced-telemetry`) +- Issue #13: `feat/13-display-brightness` (branch from `milestone/m4-presentation`) +- Issue #22: `feat/22-menubar-popover` (branch from `milestone/m4-presentation`) +- Issue #23: `feat/23-trend-charts` (branch from `milestone/m4-presentation`) +- Issue #24: `feat/24-threshold-alerts` (branch from `milestone/m5-alerts-hardening`) + +--- + +## 3. Telemetry Provider Implementation Invariants + +Every low-level telemetry provider created by an agent must conform to the following contract: + +### 3.1 Contract Interface +```objc +@protocol MMTelemetryProvider +@property (nonatomic, readonly) MMTelemetryDomain domain; +@property (nonatomic, readonly, copy) NSString *providerIdentifier; +@property (nonatomic, readonly, getter=isAvailable) BOOL available; + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error; +@optional +- (void)startMonitoring; +- (void)stopMonitoring; +@end +``` + +### 3.2 Critical Memory Management Rules +- **Mach Pointer Deallocation:** Any call to `host_processor_info` allocates a Mach virtual memory buffer. Always invoke `vm_deallocate(mach_task_self(), (vm_address_t)ptr, size)` on previous iterations. +- **IOKit Registry Release:** Every `io_registry_entry_t` or `io_iterator_t` returned by `IOServiceGetMatchingServices` or `IOIteratorNext` must be balanced with `IOObjectRelease()`. +- **IOKit Connection Cleanup:** Close user clients using `IOServiceClose()` in `dealloc` or sleep handlers. +- **Autorelease Pool Scoping:** Wrap sampling logic inside `@autoreleasepool { ... }` blocks to reclaim temporary CoreFoundation/Objective-C allocations immediately on each timer tick. + +--- + +## 4. Concurrency & Thread-Safety Invariants + +1. **Zero UI Blocking**: + Under no circumstances may low-level C calls (IOKit, Mach, sysctl) be invoked directly on the main thread (`@MainActor`). All sampling occurs exclusively on `com.i3omb.macmonitor.telemetry` (`QOS_CLASS_UTILITY`). +2. **Value-Type Boundaries**: + Data passed from Objective-C to Swift view models must be immutable snapshot copies (primitive scalars, strings, or `Sendable` structs). Never pass mutable Objective-C object references into SwiftUI views. +3. **Fast Mutex Synchronization**: + For shared state (such as SMC key caches or ring buffers), use `os_unfair_lock` or `NSLock`. Do not use `@synchronized(self)`. + +--- + +## 5. Testing & Verification Requirements + +### 5.1 Synthetic Hardware Mocking in CI +When writing unit tests for Gitea Actions runners (which may execute in headless virtual machines without physical SMC chips): +- Never assert that physical hardware calls return live metrics in unit tests. +- Structure providers with dependency injection to accept synthetic binary test buffers: + ```objc + // Test mock SMC byte decoding + uint8_t mockTempBytes[2] = { 0x36, 0x80 }; // 54.5ยฐC in sp78 format + float temp = [MMSMCParser decodeSP78:mockTempBytes]; + XCTAssertEqualWithAccuracy(temp, 54.5, 0.01); + ``` +- Mock Mach host statistics using recorded structs to verify delta load calculation algorithms. + +### 5.2 Build Command Verification +Before opening any Pull Request, ensure that the project compiles cleanly for Intel x86_64: +```bash +xcodebuild build test \ + -scheme MacMonitor \ + -destination 'generic/platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO +``` + +--- + +## 6. Label & Issue Conventions on Gitea + +- **Tagging**: Every issue, PR, and milestone must carry the `Project/Antigravity` label. +- **Components**: Tag issues with functional area labels: + - `Feature/Architecture` or `Bug/Architecture` + - `Feature/Backend` or `Bug/Backend` + - `Feature/UI` or `Bug/UI` + - `Feature/DevOps` or `Bug/DevOps` +- **Priorities**: Must accurately reflect system value (`Priority/Critical`, `Priority/High`, `Priority/Medium`, `Priority/Low`). +- **Dependencies**: Keep cross-references updated in the issue body under `### Dependencies`. -- 2.39.5 From 4dedf174361c05f30b6a15091fd356ae79eab1eb Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:10:19 +0100 Subject: [PATCH 05/39] feat(arch): implement core application architecture, telemetry coordinator, and SwiftUI store (Issue #1) --- .gitignore | 27 + MacMonitor.entitlements | 10 + MacMonitor.xcodeproj/project.pbxproj | 489 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + Sources/App/MacMonitorApp.swift | 15 + Sources/App/SystemTelemetryStore.swift | 83 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 9 + Sources/Core/MMTelemetryCoordinator.h | 69 +++ Sources/Core/MMTelemetryCoordinator.m | 171 ++++++ Sources/Core/MMTelemetryDomain.h | 40 ++ Sources/Core/MMTelemetryProvider.h | 34 ++ Sources/UI/ContentView.swift | 227 ++++++++ Tests/MacMonitorTests.swift | 26 + project.yml | 63 +++ 14 files changed, 1270 insertions(+) create mode 100644 .gitignore create mode 100644 MacMonitor.entitlements create mode 100644 MacMonitor.xcodeproj/project.pbxproj create mode 100644 MacMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Sources/App/MacMonitorApp.swift create mode 100644 Sources/App/SystemTelemetryStore.swift create mode 100644 Sources/Bridging/MacMonitor-Bridging-Header.h create mode 100644 Sources/Core/MMTelemetryCoordinator.h create mode 100644 Sources/Core/MMTelemetryCoordinator.m create mode 100644 Sources/Core/MMTelemetryDomain.h create mode 100644 Sources/Core/MMTelemetryProvider.h create mode 100644 Sources/UI/ContentView.swift create mode 100644 Tests/MacMonitorTests.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c83c235 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon + +# Xcode & DerivedData +build/ +DerivedData/ +*.moved-builder +*.hmap +*.xccheckout +*.xcscmblueprint + +# Xcode user state +xcuserdata/ +*.xcuserdatad +*.xcuserstate + +# Swift Package Manager +.build/ +.swiftpm/ + +# Build outputs & Archives +*.dmg +*.zip +*.app diff --git a/MacMonitor.entitlements b/MacMonitor.entitlements new file mode 100644 index 0000000..4b29477 --- /dev/null +++ b/MacMonitor.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.get-task-allow + + + diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b1c5c95 --- /dev/null +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -0,0 +1,489 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; + 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; + 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; + 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; + A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; + A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 09422FEC7F2490CC38ED2F77 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50A0A2115D830ED94B0DCD89 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8DA8F83F9F811331FC56346B; + remoteInfo = MacMonitor; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 007F48A5A599AB4E8D216D16 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; + 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; + 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; + 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; + ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; + E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; + F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 232654D97C29A6EB7C0150DA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */, + 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1E4B6EF5F92A76F77E44AEAD = { + isa = PBXGroup; + children = ( + 2D4E3C74114E2D44711BCFAD /* Sources */, + C9A24E2CE4CBD45E8F5175DD /* Tests */, + E56B76815ED75B28A13D6814 /* Frameworks */, + C80C3EEE8F912F4B214102E3 /* Products */, + ); + sourceTree = ""; + }; + 2D4E3C74114E2D44711BCFAD /* Sources */ = { + isa = PBXGroup; + children = ( + 4D8D8B40632C525CBC1D0E43 /* App */, + 54787E6270EA7B9A1BE11359 /* Core */, + 3E4A0B53C583BD0B7A68EACE /* UI */, + ); + path = Sources; + sourceTree = ""; + }; + 3E4A0B53C583BD0B7A68EACE /* UI */ = { + isa = PBXGroup; + children = ( + E142AE61F9B87757997870DD /* ContentView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 4D8D8B40632C525CBC1D0E43 /* App */ = { + isa = PBXGroup; + children = ( + ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */, + E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */, + ); + path = App; + sourceTree = ""; + }; + 54787E6270EA7B9A1BE11359 /* Core */ = { + isa = PBXGroup; + children = ( + 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */, + F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */, + 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */, + 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */, + ); + path = Core; + sourceTree = ""; + }; + C80C3EEE8F912F4B214102E3 /* Products */ = { + isa = PBXGroup; + children = ( + F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */, + 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + C9A24E2CE4CBD45E8F5175DD /* Tests */ = { + isa = PBXGroup; + children = ( + 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; + E56B76815ED75B28A13D6814 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */, + 007F48A5A599AB4E8D216D16 /* IOKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8DA8F83F9F811331FC56346B /* MacMonitor */ = { + isa = PBXNativeTarget; + buildConfigurationList = F8BFF00A996663DD56D3BBC8 /* Build configuration list for PBXNativeTarget "MacMonitor" */; + buildPhases = ( + 9F478799591119C7B9BC9B18 /* Sources */, + 232654D97C29A6EB7C0150DA /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MacMonitor; + packageProductDependencies = ( + ); + productName = MacMonitor; + productReference = F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */; + productType = "com.apple.product-type.application"; + }; + AC0AE508FCA91494F76BAB20 /* MacMonitorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7116650F4E57A5D713138F34 /* Build configuration list for PBXNativeTarget "MacMonitorTests" */; + buildPhases = ( + 28403A95C96F704B96652C2E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + E940019BD253D9933F8340A5 /* PBXTargetDependency */, + ); + name = MacMonitorTests; + packageProductDependencies = ( + ); + productName = MacMonitorTests; + productReference = 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 50A0A2115D830ED94B0DCD89 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + TargetAttributes = { + }; + }; + buildConfigurationList = 00AAEAE8F34AB900B7EE6E43 /* Build configuration list for PBXProject "MacMonitor" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 1E4B6EF5F92A76F77E44AEAD; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C80C3EEE8F912F4B214102E3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8DA8F83F9F811331FC56346B /* MacMonitor */, + AC0AE508FCA91494F76BAB20 /* MacMonitorTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 28403A95C96F704B96652C2E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9F478799591119C7B9BC9B18 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, + EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, + 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, + A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E940019BD253D9933F8340A5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8DA8F83F9F811331FC56346B /* MacMonitor */; + targetProxy = 09422FEC7F2490CC38ED2F77 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 3BE0BD5FC635F9E80F31CE13 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MacMonitor.entitlements; + COMBINE_HIDPI_IMAGES = YES; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/Sources/Core", + "$(SRCROOT)/Sources/Bridging", + "$(SRCROOT)/Sources/Hardware/**", + ); + INFOPLIST_KEY_CFBundleDisplayName = MacMonitor; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; + }; + name = Debug; + }; + 59F6FC23B6D4F0B126319453 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/Sources/Core", + "$(SRCROOT)/Sources/Bridging", + "$(SRCROOT)/Sources/Hardware/**", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MacMonitor.app/Contents/MacOS/MacMonitor"; + }; + name = Release; + }; + 5B1AD1F77021853BC57757D7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = x86_64; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + VALID_ARCHS = x86_64; + }; + name = Release; + }; + 5D8000DCD693316A897E9E66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/Sources/Core", + "$(SRCROOT)/Sources/Bridging", + "$(SRCROOT)/Sources/Hardware/**", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MacMonitor.app/Contents/MacOS/MacMonitor"; + }; + name = Debug; + }; + 67DE32FBD89D242E194436AA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = x86_64; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VALID_ARCHS = x86_64; + }; + name = Debug; + }; + 7FA38ECBD02E9FC203FFF0EA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MacMonitor.entitlements; + COMBINE_HIDPI_IMAGES = YES; + HEADER_SEARCH_PATHS = ( + "$(SRCROOT)/Sources/Core", + "$(SRCROOT)/Sources/Bridging", + "$(SRCROOT)/Sources/Hardware/**", + ); + INFOPLIST_KEY_CFBundleDisplayName = MacMonitor; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor; + SDKROOT = macosx; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 00AAEAE8F34AB900B7EE6E43 /* Build configuration list for PBXProject "MacMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 67DE32FBD89D242E194436AA /* Debug */, + 5B1AD1F77021853BC57757D7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 7116650F4E57A5D713138F34 /* Build configuration list for PBXNativeTarget "MacMonitorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5D8000DCD693316A897E9E66 /* Debug */, + 59F6FC23B6D4F0B126319453 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + F8BFF00A996663DD56D3BBC8 /* Build configuration list for PBXNativeTarget "MacMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3BE0BD5FC635F9E80F31CE13 /* Debug */, + 7FA38ECBD02E9FC203FFF0EA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 50A0A2115D830ED94B0DCD89 /* Project object */; +} diff --git a/MacMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MacMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MacMonitor.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift new file mode 100644 index 0000000..4de7a84 --- /dev/null +++ b/Sources/App/MacMonitorApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct MacMonitorApp: App { + @State private var store = SystemTelemetryStore.shared + + var body: some Scene { + WindowGroup { + ContentView() + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + .defaultSize(width: 960, height: 640) + } +} diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift new file mode 100644 index 0000000..a70ff8e --- /dev/null +++ b/Sources/App/SystemTelemetryStore.swift @@ -0,0 +1,83 @@ +import Foundation +import SwiftUI +import Observation + +@Observable +@MainActor +public final class SystemTelemetryStore { + 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 + + /// Raw snapshots organized by provider identifier + public private(set) var latestSnapshot: [String: [String: Any]] = [:] + + /// System identification + public let hostModel: String + public let osVersion: String + public let kernelVersion: String + public let physicalCpuCount: Int + public let logicalCpuCount: Int + + private let coordinator = MMTelemetryCoordinator.shared + + private init() { + self.hostModel = SystemTelemetryStore.readSysctlString("hw.model") ?? "Intel Mac" + self.osVersion = ProcessInfo.processInfo.operatingSystemVersionString + self.kernelVersion = SystemTelemetryStore.readSysctlString("kern.osrelease") ?? "Unknown Kernel" + self.physicalCpuCount = SystemTelemetryStore.readSysctlInt("hw.physicalcpu") ?? 1 + self.logicalCpuCount = SystemTelemetryStore.readSysctlInt("hw.logicalcpu") ?? 1 + + setupCoordinator() + } + + private func setupCoordinator() { + coordinator.sampleInterval = self.sampleInterval + coordinator.snapshotHandler = { [weak self] snapshot in + guard let self else { return } + self.latestSnapshot = snapshot + self.lastUpdateTimestamp = Date() + } + } + + public func start() { + coordinator.start() + isRunning = coordinator.isRunning + } + + public func stop() { + coordinator.stop() + isRunning = coordinator.isRunning + } + + public func setSampleInterval(_ interval: TimeInterval) { + self.sampleInterval = interval + coordinator.sampleInterval = interval + } + + public func triggerManualSample() { + coordinator.sampleImmediately() + } + + public var registeredProviderCount: Int { + coordinator.registeredProviders().count + } + + private static func readSysctlString(_ name: String) -> String? { + var size: size_t = 0 + sysctlbyname(name, nil, &size, nil, 0) + guard size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + sysctlbyname(name, &buffer, &size, nil, 0) + return String(cString: buffer) + } + + private static func readSysctlInt(_ name: String) -> Int? { + var value: Int32 = 0 + var size = MemoryLayout.size + let result = sysctlbyname(name, &value, &size, nil, 0) + return result == 0 ? Int(value) : nil + } +} diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h new file mode 100644 index 0000000..9cd0d8c --- /dev/null +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -0,0 +1,9 @@ +#ifndef MacMonitor_Bridging_Header_h +#define MacMonitor_Bridging_Header_h + +// Core Architecture & Telemetry Contracts +#import "MMTelemetryDomain.h" +#import "MMTelemetryProvider.h" +#import "MMTelemetryCoordinator.h" + +#endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Core/MMTelemetryCoordinator.h b/Sources/Core/MMTelemetryCoordinator.h new file mode 100644 index 0000000..e1c3160 --- /dev/null +++ b/Sources/Core/MMTelemetryCoordinator.h @@ -0,0 +1,69 @@ +#ifndef MMTelemetryCoordinator_h +#define MMTelemetryCoordinator_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^MMTelemetrySnapshotHandler)(NSDictionary *> *snapshot); + +/** + * MMTelemetryCoordinator + * Central dispatcher orchestrating telemetry sampling loops across all registered providers + * on a dedicated low-priority background queue (QOS_CLASS_UTILITY). + */ +@interface MMTelemetryCoordinator : NSObject + +@property (class, readonly, strong) MMTelemetryCoordinator *sharedCoordinator; + +/** + * Current polling interval in seconds. Defaults to 1.0 second. + */ +@property (nonatomic, assign) NSTimeInterval sampleInterval; + +/** + * Whether the sampling timer is actively running. + */ +@property (nonatomic, readonly, getter=isRunning) BOOL running; + +/** + * Snapshot delivery callback dispatched on the main thread whenever a new sampling cycle completes. + */ +@property (nonatomic, copy, nullable) MMTelemetrySnapshotHandler snapshotHandler; + +/** + * Registers a provider. Thread-safe. + */ +- (void)registerProvider:(id)provider; + +/** + * Unregisters a provider by its unique identifier. Thread-safe. + */ +- (void)unregisterProviderWithIdentifier:(NSString *)identifier; + +/** + * Returns a list of currently registered providers. + */ +- (NSArray> *)registeredProviders; + +/** + * Starts the recurring telemetry dispatch timer. + */ +- (void)start; + +/** + * Stops the recurring telemetry dispatch timer. + */ +- (void)stop; + +/** + * Triggers an immediate one-shot sample pass across all providers. + */ +- (void)sampleImmediately; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMTelemetryCoordinator_h */ diff --git a/Sources/Core/MMTelemetryCoordinator.m b/Sources/Core/MMTelemetryCoordinator.m new file mode 100644 index 0000000..c68feda --- /dev/null +++ b/Sources/Core/MMTelemetryCoordinator.m @@ -0,0 +1,171 @@ +#import "MMTelemetryCoordinator.h" +#import + +@interface MMTelemetryCoordinator () { + os_unfair_lock _lock; + dispatch_queue_t _telemetryQueue; + dispatch_source_t _timerSource; + NSMutableDictionary> *_providers; + BOOL _running; +} +@end + +@implementation MMTelemetryCoordinator + ++ (instancetype)sharedCoordinator { + static MMTelemetryCoordinator *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _sampleInterval = 1.0; + _providers = [NSMutableDictionary dictionary]; + + dispatch_queue_attr_t qosAttr = dispatch_queue_attr_make_with_qos_class( + DISPATCH_QUEUE_SERIAL, + QOS_CLASS_UTILITY, + 0 + ); + _telemetryQueue = dispatch_queue_create("com.i3omb.macmonitor.telemetry", qosAttr); + } + return self; +} + +- (void)dealloc { + [self stop]; +} + +- (void)setSampleInterval:(NSTimeInterval)sampleInterval { + os_unfair_lock_lock(&_lock); + if (sampleInterval < 0.1) { + sampleInterval = 0.1; // clamp minimum sampling interval to 100ms + } + _sampleInterval = sampleInterval; + BOOL shouldRestart = _running; + os_unfair_lock_unlock(&_lock); + + if (shouldRestart) { + [self stop]; + [self start]; + } +} + +- (BOOL)isRunning { + os_unfair_lock_lock(&_lock); + BOOL r = _running; + os_unfair_lock_unlock(&_lock); + return r; +} + +- (void)registerProvider:(id)provider { + if (!provider || !provider.providerIdentifier) return; + + os_unfair_lock_lock(&_lock); + _providers[provider.providerIdentifier] = provider; + os_unfair_lock_unlock(&_lock); + + if ([provider respondsToSelector:@selector(startMonitoring)]) { + [provider startMonitoring]; + } +} + +- (void)unregisterProviderWithIdentifier:(NSString *)identifier { + if (!identifier) return; + + id provider = nil; + os_unfair_lock_lock(&_lock); + provider = _providers[identifier]; + [_providers removeObjectForKey:identifier]; + os_unfair_lock_unlock(&_lock); + + if (provider && [provider respondsToSelector:@selector(stopMonitoring)]) { + [provider stopMonitoring]; + } +} + +- (NSArray> *)registeredProviders { + os_unfair_lock_lock(&_lock); + NSArray *list = [_providers.allValues copy]; + os_unfair_lock_unlock(&_lock); + return list; +} + +- (void)start { + os_unfair_lock_lock(&_lock); + if (_running) { + os_unfair_lock_unlock(&_lock); + return; + } + _running = YES; + + _timerSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _telemetryQueue); + uint64_t intervalNs = (uint64_t)(_sampleInterval * NSEC_PER_SEC); + dispatch_source_set_timer(_timerSource, dispatch_time(DISPATCH_TIME_NOW, 0), intervalNs, intervalNs / 10); + + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(_timerSource, ^{ + [weakSelf performSamplePass]; + }); + + dispatch_resume(_timerSource); + os_unfair_lock_unlock(&_lock); +} + +- (void)stop { + os_unfair_lock_lock(&_lock); + if (!_running) { + os_unfair_lock_unlock(&_lock); + return; + } + _running = NO; + if (_timerSource) { + dispatch_source_cancel(_timerSource); + _timerSource = nil; + } + os_unfair_lock_unlock(&_lock); +} + +- (void)sampleImmediately { + __weak typeof(self) weakSelf = self; + dispatch_async(_telemetryQueue, ^{ + [weakSelf performSamplePass]; + }); +} + +- (void)performSamplePass { + @autoreleasepool { + NSArray> *providersToSample = [self registeredProviders]; + NSMutableDictionary *> *snapshot = + [NSMutableDictionary dictionaryWithCapacity:providersToSample.count]; + + for (id provider in providersToSample) { + @autoreleasepool { + if (!provider.isAvailable) { + continue; + } + NSError *sampleError = nil; + NSDictionary *metrics = [provider sampleTelemetryWithError:&sampleError]; + if (metrics) { + snapshot[provider.providerIdentifier] = metrics; + } + } + } + + NSDictionary *> *immutableSnapshot = [snapshot copy]; + MMTelemetrySnapshotHandler handler = self.snapshotHandler; + if (handler) { + dispatch_async(dispatch_get_main_queue(), ^{ + handler(immutableSnapshot); + }); + } + } +} + +@end diff --git a/Sources/Core/MMTelemetryDomain.h b/Sources/Core/MMTelemetryDomain.h new file mode 100644 index 0000000..5c20c2f --- /dev/null +++ b/Sources/Core/MMTelemetryDomain.h @@ -0,0 +1,40 @@ +#ifndef MMTelemetryDomain_h +#define MMTelemetryDomain_h + +#import + +typedef NS_ENUM(NSInteger, MMTelemetryDomain) { + MMTelemetryDomainSystem NS_SWIFT_NAME(system) = 0, + MMTelemetryDomainCPU NS_SWIFT_NAME(cpu), + MMTelemetryDomainThermal NS_SWIFT_NAME(thermal), + MMTelemetryDomainFan NS_SWIFT_NAME(fan), + MMTelemetryDomainMemory NS_SWIFT_NAME(memory), + MMTelemetryDomainStorage NS_SWIFT_NAME(storage), + MMTelemetryDomainNetwork NS_SWIFT_NAME(network), + MMTelemetryDomainProcess NS_SWIFT_NAME(process), + MMTelemetryDomainGPU NS_SWIFT_NAME(gpu), + MMTelemetryDomainPower NS_SWIFT_NAME(power), + MMTelemetryDomainAudio NS_SWIFT_NAME(audio), + MMTelemetryDomainPeripherals NS_SWIFT_NAME(peripherals), + MMTelemetryDomainDisplay NS_SWIFT_NAME(display) +}; + +static inline NSString * _Nonnull MMTelemetryDomainToString(MMTelemetryDomain domain) { + switch (domain) { + case MMTelemetryDomainSystem: return @"System"; + case MMTelemetryDomainCPU: return @"CPU"; + case MMTelemetryDomainThermal: return @"Thermal"; + case MMTelemetryDomainFan: return @"Fan"; + case MMTelemetryDomainMemory: return @"Memory"; + case MMTelemetryDomainStorage: return @"Storage"; + case MMTelemetryDomainNetwork: return @"Network"; + case MMTelemetryDomainProcess: return @"Process"; + case MMTelemetryDomainGPU: return @"GPU"; + case MMTelemetryDomainPower: return @"Power"; + case MMTelemetryDomainAudio: return @"Audio"; + case MMTelemetryDomainPeripherals: return @"Peripherals"; + case MMTelemetryDomainDisplay: return @"Display"; + } +} + +#endif /* MMTelemetryDomain_h */ diff --git a/Sources/Core/MMTelemetryProvider.h b/Sources/Core/MMTelemetryProvider.h new file mode 100644 index 0000000..b2df680 --- /dev/null +++ b/Sources/Core/MMTelemetryProvider.h @@ -0,0 +1,34 @@ +#ifndef MMTelemetryProvider_h +#define MMTelemetryProvider_h + +#import +#import "MMTelemetryDomain.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMTelemetryProvider + * Protocol defining the standard contract for all low-level telemetry sampling engines. + */ +@protocol MMTelemetryProvider + +@property (nonatomic, readonly) MMTelemetryDomain domain; +@property (nonatomic, readonly, copy) NSString *providerIdentifier; +@property (nonatomic, readonly, getter=isAvailable) BOOL available; + +/** + * Samples instantaneous or delta telemetry metrics. + * Called exclusively on the background utility telemetry queue: com.i3omb.macmonitor.telemetry. + * Returns an immutable dictionary containing scalar values, strings, or arrays. + */ +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error; + +@optional +- (void)startMonitoring; +- (void)stopMonitoring; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMTelemetryProvider_h */ diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift new file mode 100644 index 0000000..621b3a6 --- /dev/null +++ b/Sources/UI/ContentView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +public struct ContentView: View { + @State private var store = SystemTelemetryStore.shared + + public init() {} + + public var body: some View { + NavigationSplitView { + sidebarContent + } detail: { + detailContent + } + .frame(minWidth: 800, minHeight: 520) + .onAppear { + store.start() + } + } + + private var sidebarContent: some View { + List { + Section("System Status") { + Label("Dashboard", systemImage: "gauge.with.needle") + Label("Architecture", systemImage: "cpu") + } + + Section("Telemetry Domains") { + Label("CPU & Thermals", systemImage: "flame") + Label("Fans & Cooling", systemImage: "fanblades") + Label("Memory & Swap", systemImage: "memorychip") + Label("Disks & Volumes", systemImage: "internaldrive") + Label("Processes", systemImage: "list.bullet.rectangle") + Label("Network", systemImage: "network") + Label("Graphics (GPU)", systemImage: "display") + } + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + } + + private var detailContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header Banner + headerCard + + // Telemetry Engine Control + engineControlCard + + // System Specifications + specsCard + + // Providers Status + providersCard + } + .padding(24) + } + .navigationTitle("MacMonitor Dashboard") + } + + private var headerCard: some View { + HStack(alignment: .center, spacing: 16) { + Image(systemName: "macpro.gen3.fill") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .foregroundStyle(.blue) + + VStack(alignment: .leading, spacing: 4) { + Text(store.hostModel) + .font(.title2.bold()) + Text("Intel x86_64 โ€ข \(store.osVersion)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 8) { + Circle() + .fill(store.isRunning ? Color.green : Color.red) + .frame(width: 10, height: 10) + Text(store.isRunning ? "ENGINE ACTIVE" : "ENGINE PAUSED") + .font(.caption.bold()) + .foregroundStyle(store.isRunning ? .green : .secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary, in: Capsule()) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + private var engineControlCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Telemetry Coordinator Engine") + .font(.headline) + + HStack(spacing: 16) { + Button(action: { + if store.isRunning { + store.stop() + } else { + store.start() + } + }) { + Label(store.isRunning ? "Pause Engine" : "Start Engine", + systemImage: store.isRunning ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderedProminent) + .tint(store.isRunning ? .orange : .green) + + Button(action: { + store.triggerManualSample() + }) { + Label("Sample Now", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + + Spacer() + + Picker("Polling Interval:", selection: Binding( + get: { store.sampleInterval }, + set: { store.setSampleInterval($0) } + )) { + Text("0.5s").tag(0.5) + Text("1.0s").tag(1.0) + Text("2.0s").tag(2.0) + Text("5.0s").tag(5.0) + } + .pickerStyle(.segmented) + .frame(width: 220) + } + + HStack { + Text("Last Sample Cycle: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("Queue: com.i3omb.macmonitor.telemetry (QOS_UTILITY)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + private var specsCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Host Architecture & Kernel") + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) { + GridRow { + Text("Hardware Model:").foregroundStyle(.secondary) + Text(store.hostModel).bold() + Text("Physical Cores:").foregroundStyle(.secondary) + Text("\(store.physicalCpuCount)").bold() + } + GridRow { + Text("Darwin Kernel:").foregroundStyle(.secondary) + Text(store.kernelVersion).bold() + Text("Logical Cores:").foregroundStyle(.secondary) + Text("\(store.logicalCpuCount)").bold() + } + GridRow { + Text("Target Architecture:").foregroundStyle(.secondary) + Text("Intel x86_64").bold() + Text("Deployment Target:").foregroundStyle(.secondary) + Text("macOS 14.0 (Sonoma)").bold() + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + private var providersCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Registered Telemetry Providers") + .font(.headline) + Spacer() + Text("\(store.registeredProviderCount) Active") + .font(.caption.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.15)) + .foregroundStyle(.blue) + .clipShape(Capsule()) + } + + if store.latestSnapshot.isEmpty { + HStack(spacing: 12) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text("Telemetry coordinator initialized. Awaiting provider metrics.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } else { + ForEach(Array(store.latestSnapshot.keys.sorted()), id: \.self) { key in + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(key) + .font(.system(.body, design: .monospaced)) + Spacer() + Text("\(store.latestSnapshot[key]?.count ?? 0) metrics") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } +} diff --git a/Tests/MacMonitorTests.swift b/Tests/MacMonitorTests.swift new file mode 100644 index 0000000..b96ade9 --- /dev/null +++ b/Tests/MacMonitorTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import MacMonitor + +final class MacMonitorCoreTests: XCTestCase { + func testCoordinatorLifecycle() { + let coordinator = MMTelemetryCoordinator.shared + XCTAssertNotNil(coordinator) + + coordinator.sampleInterval = 0.5 + XCTAssertEqual(coordinator.sampleInterval, 0.5) + + coordinator.start() + XCTAssertTrue(coordinator.isRunning) + + coordinator.stop() + XCTAssertFalse(coordinator.isRunning) + } + + func testTelemetryDomainConversion() { + let domainStr = MMTelemetryDomainToString(.cpu) + XCTAssertEqual(domainStr, "CPU") + + let thermalStr = MMTelemetryDomainToString(.thermal) + XCTAssertEqual(thermalStr, "Thermal") + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..891c3ff --- /dev/null +++ b/project.yml @@ -0,0 +1,63 @@ +name: MacMonitor +options: + bundleIdPrefix: com.i3omb + deploymentTarget: + macOS: "14.0" + xcodeVersion: "16.0" + createIntermediateGroups: true + +settings: + base: + ARCHS: "x86_64" + ONLY_ACTIVE_ARCH: NO + VALID_ARCHS: "x86_64" + MACOSX_DEPLOYMENT_TARGET: "14.0" + SWIFT_VERSION: "5.0" + CLANG_ENABLE_OBJC_ARC: YES + CLANG_ENABLE_MODULES: YES + GENERATE_INFOPLIST_FILE: YES + CODE_SIGNING_ALLOWED: NO + CODE_SIGN_IDENTITY: "" + GCC_C_LANGUAGE_STANDARD: "gnu17" + CLANG_CXX_LANGUAGE_STANDARD: "gnu++20" + +targets: + MacMonitor: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources + excludes: + - "Bridging/MacMonitor-Bridging-Header.h" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "com.i3omb.MacMonitor" + INFOPLIST_KEY_CFBundleDisplayName: "MacMonitor" + INFOPLIST_KEY_NSPrincipalClass: "NSApplication" + CODE_SIGN_ENTITLEMENTS: "MacMonitor.entitlements" + SWIFT_OBJC_BRIDGING_HEADER: "Sources/Bridging/MacMonitor-Bridging-Header.h" + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/Sources/Core" + - "$(SRCROOT)/Sources/Bridging" + - "$(SRCROOT)/Sources/Hardware/**" + dependencies: + - sdk: IOKit.framework + - sdk: CoreFoundation.framework + + MacMonitorTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Tests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "com.i3omb.MacMonitorTests" + SWIFT_OBJC_BRIDGING_HEADER: "Sources/Bridging/MacMonitor-Bridging-Header.h" + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/Sources/Core" + - "$(SRCROOT)/Sources/Bridging" + - "$(SRCROOT)/Sources/Hardware/**" + dependencies: + - target: MacMonitor -- 2.39.5 From dba5af397f23824877cf6f9c2bc34366e21832c4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:12:21 +0100 Subject: [PATCH 06/39] feat(hardware): implement AppleSMC IOKit client, numeric decoders, and unit test suite (Issue #2) --- MacMonitor.xcodeproj/project.pbxproj | 34 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 5 + Sources/Hardware/AppleSMC/MMAppleSMCClient.h | 58 +++++ Sources/Hardware/AppleSMC/MMAppleSMCClient.m | 245 ++++++++++++++++++ Sources/Hardware/AppleSMC/MMSMCDefines.h | 79 ++++++ Sources/Hardware/AppleSMC/MMSMCParser.h | 50 ++++ Sources/Hardware/AppleSMC/MMSMCParser.m | 67 +++++ Tests/MMSMCTests.swift | 73 ++++++ 8 files changed, 611 insertions(+) create mode 100644 Sources/Hardware/AppleSMC/MMAppleSMCClient.h create mode 100644 Sources/Hardware/AppleSMC/MMAppleSMCClient.m create mode 100644 Sources/Hardware/AppleSMC/MMSMCDefines.h create mode 100644 Sources/Hardware/AppleSMC/MMSMCParser.h create mode 100644 Sources/Hardware/AppleSMC/MMSMCParser.m create mode 100644 Tests/MMSMCTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index b1c5c95..cd6dcff 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -8,12 +8,15 @@ /* Begin PBXBuildFile section */ 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; + 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; + EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -28,12 +31,18 @@ /* Begin PBXFileReference section */ 007F48A5A599AB4E8D216D16 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; + 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; + 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; + 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; + 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; + 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; @@ -69,6 +78,7 @@ children = ( 4D8D8B40632C525CBC1D0E43 /* App */, 54787E6270EA7B9A1BE11359 /* Core */, + 9844EC526E3527F6A582179F /* Hardware */, 3E4A0B53C583BD0B7A68EACE /* UI */, ); path = Sources; @@ -102,6 +112,14 @@ path = Core; sourceTree = ""; }; + 9844EC526E3527F6A582179F /* Hardware */ = { + isa = PBXGroup; + children = ( + E012FBB26EF9E35A2FAF87E2 /* AppleSMC */, + ); + path = Hardware; + sourceTree = ""; + }; C80C3EEE8F912F4B214102E3 /* Products */ = { isa = PBXGroup; children = ( @@ -115,10 +133,23 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + 940D608A1AF80F75584E744E /* MMSMCTests.swift */, ); path = Tests; sourceTree = ""; }; + E012FBB26EF9E35A2FAF87E2 /* AppleSMC */ = { + isa = PBXGroup; + children = ( + 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */, + 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */, + 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */, + 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */, + 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */, + ); + path = AppleSMC; + sourceTree = ""; + }; E56B76815ED75B28A13D6814 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -203,6 +234,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -212,6 +244,8 @@ buildActionMask = 2147483647; files = ( A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, + CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, + 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 9cd0d8c..63da8c2 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -6,4 +6,9 @@ #import "MMTelemetryProvider.h" #import "MMTelemetryCoordinator.h" +// Hardware Abstraction - AppleSMC +#import "MMSMCDefines.h" +#import "MMSMCParser.h" +#import "MMAppleSMCClient.h" + #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Hardware/AppleSMC/MMAppleSMCClient.h b/Sources/Hardware/AppleSMC/MMAppleSMCClient.h new file mode 100644 index 0000000..0dca9c7 --- /dev/null +++ b/Sources/Hardware/AppleSMC/MMAppleSMCClient.h @@ -0,0 +1,58 @@ +#ifndef MMAppleSMCClient_h +#define MMAppleSMCClient_h + +#import +#import "MMSMCDefines.h" +#import "MMSMCParser.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMAppleSMCClient + * Thread-safe singleton providing direct communication with the AppleSMC driver + * on Intel Mac platforms via IOKit user client calls. + */ +@interface MMAppleSMCClient : NSObject + +@property (class, readonly, strong) MMAppleSMCClient *sharedClient; + +/** + * Whether the AppleSMC IOKit driver connection is open and active. + */ +@property (nonatomic, readonly, getter=isAvailable) BOOL available; + +/** + * Total number of SMC keys advertised by hardware (#KEY), or 0 if unavailable. + */ +@property (nonatomic, readonly) NSInteger totalKeyCount; + +/** + * Explicitly opens the IOKit AppleSMC connection. + */ +- (BOOL)openWithError:(NSError **)error; + +/** + * Closes the active IOKit AppleSMC connection. + */ +- (void)close; + +/** + * Reads raw bytes for a given 4-character SMC key (e.g. "TC0P", "F0Ac"). + */ +- (nullable NSData *)readBytesForKey:(NSString *)key error:(NSError **)error; + +/** + * Reads and automatically decodes a numeric value according to its SMC type descriptor. + */ +- (nullable NSNumber *)readNumericValueForKey:(NSString *)key error:(NSError **)error; + +/** + * Returns the FourCC data type string for an SMC key (e.g. "sp78", "fpe2", "ui16"). + */ +- (nullable NSString *)readDataTypeForKey:(NSString *)key error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMAppleSMCClient_h */ diff --git a/Sources/Hardware/AppleSMC/MMAppleSMCClient.m b/Sources/Hardware/AppleSMC/MMAppleSMCClient.m new file mode 100644 index 0000000..cd17faa --- /dev/null +++ b/Sources/Hardware/AppleSMC/MMAppleSMCClient.m @@ -0,0 +1,245 @@ +#import "MMAppleSMCClient.h" +#import +#import + +@interface MMAppleSMCClient () { + os_unfair_lock _lock; + io_connect_t _connection; + NSMutableDictionary *_keyInfoCache; + BOOL _opened; + NSInteger _totalKeyCount; +} +@end + +@implementation MMAppleSMCClient + ++ (instancetype)sharedClient { + static MMAppleSMCClient *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _keyInfoCache = [NSMutableDictionary dictionary]; + [self openWithError:nil]; + } + return self; +} + +- (void)dealloc { + [self close]; +} + +- (BOOL)isAvailable { + os_unfair_lock_lock(&_lock); + BOOL avail = (_connection != IO_OBJECT_NULL && _opened); + os_unfair_lock_unlock(&_lock); + return avail; +} + +- (NSInteger)totalKeyCount { + os_unfair_lock_lock(&_lock); + NSInteger count = _totalKeyCount; + os_unfair_lock_unlock(&_lock); + return count; +} + +- (BOOL)openWithError:(NSError **)error { + os_unfair_lock_lock(&_lock); + if (_opened && _connection != IO_OBJECT_NULL) { + os_unfair_lock_unlock(&_lock); + return YES; + } + + CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient"); + if (!matching) { + os_unfair_lock_unlock(&_lock); + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}]; + } + return NO; + } + + io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, matching); + if (service == IO_OBJECT_NULL) { + os_unfair_lock_unlock(&_lock); + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-2 + userInfo:@{NSLocalizedDescriptionKey: @"AppleSMCClient service not found (running on non-Mac or unsupported VM)"}]; + } + return NO; + } + + kern_return_t kr = IOServiceOpen(service, mach_task_self(), 0, &_connection); + IOObjectRelease(service); + + if (kr != KERN_SUCCESS || _connection == IO_OBJECT_NULL) { + _connection = IO_OBJECT_NULL; + _opened = NO; + os_unfair_lock_unlock(&_lock); + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:kr + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"IOServiceOpen failed with code: 0x%x", kr]}]; + } + return NO; + } + + _opened = YES; + os_unfair_lock_unlock(&_lock); + + // Probe total keys count + NSNumber *keysCount = [self readNumericValueForKey:@"#KEY" error:nil]; + if (keysCount) { + os_unfair_lock_lock(&_lock); + _totalKeyCount = [keysCount integerValue]; + os_unfair_lock_unlock(&_lock); + } + + return YES; +} + +- (void)close { + os_unfair_lock_lock(&_lock); + if (_connection != IO_OBJECT_NULL) { + IOServiceClose(_connection); + _connection = IO_OBJECT_NULL; + } + _opened = NO; + [_keyInfoCache removeAllObjects]; + os_unfair_lock_unlock(&_lock); +} + +- (BOOL)callSMCWithInput:(const SMCKeyData_t *)input output:(SMCKeyData_t *)output { + if (_connection == IO_OBJECT_NULL) return NO; + + size_t inputSize = sizeof(SMCKeyData_t); + size_t outputSize = sizeof(SMCKeyData_t); + + kern_return_t kr = IOConnectCallStructMethod( + _connection, + kSMCHandleYPCEvent, + input, + inputSize, + output, + &outputSize + ); + + return (kr == KERN_SUCCESS && output->result == 0); +} + +- (BOOL)fetchKeyInfo:(NSString *)key info:(SMCKeyInfoData *)outInfo { + if (key.length != 4) return NO; + + os_unfair_lock_lock(&_lock); + NSValue *cached = _keyInfoCache[key]; + if (cached) { + [cached getValue:outInfo size:sizeof(SMCKeyInfoData)]; + os_unfair_lock_unlock(&_lock); + return YES; + } + + SMCKeyData_t input = {0}; + SMCKeyData_t output = {0}; + + input.key = MMSMCToFourCharCode([key UTF8String]); + input.data8 = kSMCGetKeyInfo; + + BOOL success = [self callSMCWithInput:&input output:&output]; + if (success) { + *outInfo = output.keyInfo; + _keyInfoCache[key] = [NSValue value:outInfo withObjCType:@encode(SMCKeyInfoData)]; + } + + os_unfair_lock_unlock(&_lock); + return success; +} + +- (nullable NSData *)readBytesForKey:(NSString *)key error:(NSError **)error { + if (![self isAvailable]) { + if (![self openWithError:error]) return nil; + } + + SMCKeyInfoData info = {0}; + if (![self fetchKeyInfo:key info:&info]) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-3 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve key info for '%@'", key]}]; + } + return nil; + } + + if (info.dataSize > 32) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-4 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Data size %lu exceeds 32 bytes for key '%@'", (unsigned long)info.dataSize, key]}]; + } + return nil; + } + + os_unfair_lock_lock(&_lock); + SMCKeyData_t input = {0}; + SMCKeyData_t output = {0}; + + input.key = MMSMCToFourCharCode([key UTF8String]); + input.keyInfo.dataSize = info.dataSize; + input.data8 = kSMCReadKey; + + BOOL success = [self callSMCWithInput:&input output:&output]; + os_unfair_lock_unlock(&_lock); + + if (!success) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-5 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"SMC read command failed for key '%@'", key]}]; + } + return nil; + } + + return [NSData dataWithBytes:output.bytes length:info.dataSize]; +} + +- (nullable NSString *)readDataTypeForKey:(NSString *)key error:(NSError **)error { + SMCKeyInfoData info = {0}; + if (![self fetchKeyInfo:key info:&info]) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" + code:-3 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve key info for '%@'", key]}]; + } + return nil; + } + char typeStr[5] = {0}; + MMSMCFromFourCharCode(info.dataType, typeStr); + return [NSString stringWithUTF8String:typeStr]; +} + +- (nullable NSNumber *)readNumericValueForKey:(NSString *)key error:(NSError **)error { + SMCKeyInfoData info = {0}; + if (![self fetchKeyInfo:key info:&info]) { + return nil; + } + + NSData *data = [self readBytesForKey:key error:error]; + if (!data) return nil; + + char typeStr[5] = {0}; + MMSMCFromFourCharCode(info.dataType, typeStr); + NSString *dataType = [NSString stringWithUTF8String:typeStr]; + + return [MMSMCParser decodeValueWithDataType:dataType bytes:data.bytes size:data.length]; +} + +@end diff --git a/Sources/Hardware/AppleSMC/MMSMCDefines.h b/Sources/Hardware/AppleSMC/MMSMCDefines.h new file mode 100644 index 0000000..39c5123 --- /dev/null +++ b/Sources/Hardware/AppleSMC/MMSMCDefines.h @@ -0,0 +1,79 @@ +#ifndef MMSMCDefines_h +#define MMSMCDefines_h + +#import +#import + +#define KERNEL_INDEX_SMC 2 + +// SMC Call Selectors +#define kSMCUserClientOpen 0 +#define kSMCUserClientClose 1 +#define kSMCHandleYPCEvent 2 +#define kSMCReadKey 5 +#define kSMCWriteKey 6 +#define kSMCGetKeyFromIndex 8 +#define kSMCGetKeyInfo 9 + +#pragma pack(push, 1) + +typedef struct { + unsigned char major; + unsigned char minor; + unsigned char build; + unsigned char reserved[1]; + unsigned short release; +} SMCVersion; + +typedef struct { + uint16_t version; + uint16_t length; + uint32_t cpuPLimit; + uint32_t gpuPLimit; + uint32_t memPLimit; +} SMCPLimitData; + +typedef struct { + IOByteCount dataSize; + UInt32 dataType; + UInt8 dataAttributes; +} SMCKeyInfoData; + +typedef struct { + UInt32 key; + SMCVersion vers; + SMCPLimitData pLimitData; + SMCKeyInfoData keyInfo; + UInt8 result; + UInt8 status; + UInt8 data8; + UInt32 data32; + UInt8 bytes[32]; +} SMCKeyData_t; + +typedef struct { + char key[5]; + UInt32 dataSize; + char dataType[5]; + UInt8 bytes[32]; +} SMCVal_t; + +#pragma pack(pop) + +static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) { + UInt32 code = 0; + for (int i = 0; i < 4 && str[i] != '\0'; i++) { + code = (code << 8) | (UInt8)str[i]; + } + return code; +} + +static inline void MMSMCFromFourCharCode(UInt32 code, char * _Nonnull outStr) { + outStr[0] = (char)((code >> 24) & 0xFF); + outStr[1] = (char)((code >> 16) & 0xFF); + outStr[2] = (char)((code >> 8) & 0xFF); + outStr[3] = (char)(code & 0xFF); + outStr[4] = '\0'; +} + +#endif /* MMSMCDefines_h */ diff --git a/Sources/Hardware/AppleSMC/MMSMCParser.h b/Sources/Hardware/AppleSMC/MMSMCParser.h new file mode 100644 index 0000000..e6f021e --- /dev/null +++ b/Sources/Hardware/AppleSMC/MMSMCParser.h @@ -0,0 +1,50 @@ +#ifndef MMSMCParser_h +#define MMSMCParser_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MMSMCParser : NSObject + +/** + * Decodes a signed 8.8 fixed-point format (sp78), used widely for Intel Mac temperature sensors. + */ ++ (float)decodeSP78:(const uint8_t *)bytes; + +/** + * Decodes an unsigned 14.2 fixed-point format (fpe2), used for Fan RPM values. + */ ++ (float)decodeFPE2:(const uint8_t *)bytes; + +/** + * Decodes a standard IEEE 754 32-bit floating point value (flt). + */ ++ (float)decodeFLT:(const uint8_t *)bytes; + +/** + * Decodes an unsigned 8-bit integer (ui8). + */ ++ (uint8_t)decodeUI8:(const uint8_t *)bytes; + +/** + * Decodes a big-endian unsigned 16-bit integer (ui16). + */ ++ (uint16_t)decodeUI16:(const uint8_t *)bytes; + +/** + * Decodes a big-endian unsigned 32-bit integer (ui32). + */ ++ (uint32_t)decodeUI32:(const uint8_t *)bytes; + +/** + * Generic parser taking an SMC dataType FourCC string ("sp78", "fpe2", "flt", "ui8 ", "ui16", "ui32", "flag") + * and returning a parsed NSNumber. + */ ++ (nullable NSNumber *)decodeValueWithDataType:(NSString *)dataType bytes:(const uint8_t *)bytes size:(NSUInteger)size; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMSMCParser_h */ diff --git a/Sources/Hardware/AppleSMC/MMSMCParser.m b/Sources/Hardware/AppleSMC/MMSMCParser.m new file mode 100644 index 0000000..9fcd9cb --- /dev/null +++ b/Sources/Hardware/AppleSMC/MMSMCParser.m @@ -0,0 +1,67 @@ +#import "MMSMCParser.h" +#import + +@implementation MMSMCParser + ++ (float)decodeSP78:(const uint8_t *)bytes { + if (!bytes) return 0.0f; + int16_t raw = (int16_t)(((uint16_t)bytes[0] << 8) | (uint16_t)bytes[1]); + return (float)raw / 256.0f; +} + ++ (float)decodeFPE2:(const uint8_t *)bytes { + if (!bytes) return 0.0f; + uint16_t raw = ((uint16_t)bytes[0] << 8) | (uint16_t)bytes[1]; + return (float)raw / 4.0f; +} + ++ (float)decodeFLT:(const uint8_t *)bytes { + if (!bytes) return 0.0f; + float val = 0.0f; + memcpy(&val, bytes, sizeof(float)); + return val; +} + ++ (uint8_t)decodeUI8:(const uint8_t *)bytes { + if (!bytes) return 0; + return bytes[0]; +} + ++ (uint16_t)decodeUI16:(const uint8_t *)bytes { + if (!bytes) return 0; + return ((uint16_t)bytes[0] << 8) | (uint16_t)bytes[1]; +} + ++ (uint32_t)decodeUI32:(const uint8_t *)bytes { + if (!bytes) return 0; + return ((uint32_t)bytes[0] << 24) | + ((uint32_t)bytes[1] << 16) | + ((uint32_t)bytes[2] << 8) | + (uint32_t)bytes[3]; +} + ++ (nullable NSNumber *)decodeValueWithDataType:(NSString *)dataType bytes:(const uint8_t *)bytes size:(NSUInteger)size { + if (!bytes || size == 0) return nil; + + NSString *trimmedType = [dataType stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + + if ([trimmedType isEqualToString:@"sp78"]) { + if (size >= 2) return @([self decodeSP78:bytes]); + } else if ([trimmedType isEqualToString:@"fpe2"]) { + if (size >= 2) return @([self decodeFPE2:bytes]); + } else if ([trimmedType isEqualToString:@"flt"]) { + if (size >= 4) return @([self decodeFLT:bytes]); + } else if ([trimmedType isEqualToString:@"ui8"]) { + if (size >= 1) return @([self decodeUI8:bytes]); + } else if ([trimmedType isEqualToString:@"ui16"]) { + if (size >= 2) return @([self decodeUI16:bytes]); + } else if ([trimmedType isEqualToString:@"ui32"]) { + if (size >= 4) return @([self decodeUI32:bytes]); + } else if ([trimmedType isEqualToString:@"flag"]) { + if (size >= 1) return @(bytes[0] != 0); + } + + return nil; +} + +@end diff --git a/Tests/MMSMCTests.swift b/Tests/MMSMCTests.swift new file mode 100644 index 0000000..82bc1ec --- /dev/null +++ b/Tests/MMSMCTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import MacMonitor + +final class MMSMCTests: XCTestCase { + + func testSP78TemperatureDecoding() { + // 0x36, 0x80 -> 54.5ยฐC in sp78 (signed 8.8 fixed point: 0x36 = 54, 0x80 = 128 / 256 = 0.5) + let bytes: [UInt8] = [0x36, 0x80] + let temp = MMSMCParser.decodeSP78(bytes) + XCTAssertEqual(Double(temp), 54.5, accuracy: 0.001) + + // Negative temperature: -10.25ยฐC -> -10.25 * 256 = -2624 = 0xF5C0 + let negBytes: [UInt8] = [0xF5, 0xC0] + let negTemp = MMSMCParser.decodeSP78(negBytes) + XCTAssertEqual(Double(negTemp), -10.25, accuracy: 0.001) + } + + func testFPE2FanRPMDecoding() { + // 1800 RPM in fpe2 (unsigned 14.2 fixed point: 1800 * 4 = 7200 = 0x1C20) + let bytes: [UInt8] = [0x1C, 0x20] + let rpm = MMSMCParser.decodeFPE2(bytes) + XCTAssertEqual(Double(rpm), 1800.0, accuracy: 0.001) + + // 2450.5 RPM: 2450.5 * 4 = 9802 = 0x264A + let bytesFrac: [UInt8] = [0x26, 0x4A] + let rpmFrac = MMSMCParser.decodeFPE2(bytesFrac) + XCTAssertEqual(Double(rpmFrac), 2450.5, accuracy: 0.001) + } + + func testFLTDecoding() { + // 3.14159f in IEEE 754 float + var original: Float = 3.14159 + let bytes = withUnsafeBytes(of: &original) { Array($0) } + let decoded = MMSMCParser.decodeFLT(bytes) + XCTAssertEqual(Double(decoded), 3.14159, accuracy: 0.0001) + } + + func testIntegerDecoding() { + // UI8 + let byteUI8: [UInt8] = [0x7F] + XCTAssertEqual(MMSMCParser.decodeUI8(byteUI8), 127) + + // UI16 big endian (0x1234 = 4660) + let bytesUI16: [UInt8] = [0x12, 0x34] + XCTAssertEqual(MMSMCParser.decodeUI16(bytesUI16), 4660) + + // UI32 big endian (0x12345678 = 305419896) + let bytesUI32: [UInt8] = [0x12, 0x34, 0x56, 0x78] + XCTAssertEqual(MMSMCParser.decodeUI32(bytesUI32), 305419896) + } + + func testGenericDecoderWithType() { + let tempBytes: [UInt8] = [0x41, 0x00] // 65.0ยฐC in sp78 + let val = MMSMCParser.decodeValue(withDataType: "sp78", bytes: tempBytes, size: 2) + XCTAssertNotNil(val) + XCTAssertEqual(val?.doubleValue ?? 0, 65.0, accuracy: 0.001) + + let flagBytes: [UInt8] = [0x01] + let flagVal = MMSMCParser.decodeValue(withDataType: "flag", bytes: flagBytes, size: 1) + XCTAssertNotNil(flagVal) + XCTAssertEqual(flagVal?.boolValue, true) + } + + func testSMCClientSingletonAndKeyCount() { + let client = MMAppleSMCClient.shared + XCTAssertNotNil(client) + // On physical Intel Macs, client.isAvailable should be true and totalKeyCount > 0. + // In headless environments or VMs without AppleSMC, it gracefully degrades. + if client.isAvailable { + XCTAssertGreaterThan(client.totalKeyCount, 0) + } + } +} -- 2.39.5 From ad2695aa4e405235c45f60a80bbb75cbe1cf41a6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:12:41 +0100 Subject: [PATCH 07/39] ci(gitea): add Intel macOS Gitea Actions CI/CD workflow (Issue #25) --- .gitea/workflows/build.yml | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .gitea/workflows/build.yml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..0a19b60 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,84 @@ +name: MacMonitor CI/CD Pipeline + +on: + push: + branches: + - main + - develop + - 'milestone/**' + - 'feat/**' + pull_request: + branches: + - main + - develop + - 'milestone/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & Test (Intel x86_64) + runs-on: [macos, intel] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Inspect Toolchain & Environment + run: | + echo "=== Host Architecture ===" + uname -m + echo "=== macOS Version ===" + sw_vers + echo "=== Active Xcode Version ===" + xcodebuild -version + echo "=== Available macOS SDKs ===" + xcrun --show-sdk-path + + - name: Generate Xcode Project + run: | + if command -v xcodegen &> /dev/null; then + xcodegen generate + else + echo "xcodegen not preinstalled, using tracked MacMonitor.xcodeproj" + fi + + - name: Run SwiftLint Linting + run: | + if command -v swiftlint &> /dev/null; then + swiftlint lint --reporter emoji + else + echo "swiftlint not found, skipping lint step" + fi + + - name: Build MacMonitor Scheme + run: | + set -o pipefail + if command -v xcbeautify &> /dev/null; then + xcodebuild clean build \ + -scheme MacMonitor \ + -destination 'generic/platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO | xcbeautify + else + xcodebuild clean build \ + -scheme MacMonitor \ + -destination 'generic/platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO + fi + + - name: Run Unit Tests + run: | + set -o pipefail + if command -v xcbeautify &> /dev/null; then + xcodebuild test \ + -scheme MacMonitor \ + -destination 'platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO | xcbeautify + else + xcodebuild test \ + -scheme MacMonitor \ + -destination 'platform=macOS,arch=x86_64' \ + CODE_SIGNING_ALLOWED=NO + fi -- 2.39.5 From cb625964a5fb54e00b4ba4849e16df7d6a8167ea Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:18:30 +0100 Subject: [PATCH 08/39] feat(cpu): implement MMCPULoadProvider with per-core tick deltas, frequency, and throttling (Issue #5) --- MacMonitor.xcodeproj/project.pbxproj | 26 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 3 + Sources/Telemetry/CPU/MMCPULoadProvider.h | 31 +++ Sources/Telemetry/CPU/MMCPULoadProvider.m | 201 ++++++++++++++++++ Tests/MMCPULoadTests.swift | 65 ++++++ 5 files changed, 326 insertions(+) create mode 100644 Sources/Telemetry/CPU/MMCPULoadProvider.h create mode 100644 Sources/Telemetry/CPU/MMCPULoadProvider.m create mode 100644 Tests/MMCPULoadTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index cd6dcff..cfc0624 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -7,9 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; }; 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; + 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; @@ -36,14 +38,17 @@ 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; + 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; + D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -79,6 +84,7 @@ 4D8D8B40632C525CBC1D0E43 /* App */, 54787E6270EA7B9A1BE11359 /* Core */, 9844EC526E3527F6A582179F /* Hardware */, + 8BF79E3BCB76E7A1669DC930 /* Telemetry */, 3E4A0B53C583BD0B7A68EACE /* UI */, ); path = Sources; @@ -112,6 +118,23 @@ path = Core; sourceTree = ""; }; + 78383CED205BCA772701AE48 /* CPU */ = { + isa = PBXGroup; + children = ( + 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */, + D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */, + ); + path = CPU; + sourceTree = ""; + }; + 8BF79E3BCB76E7A1669DC930 /* Telemetry */ = { + isa = PBXGroup; + children = ( + 78383CED205BCA772701AE48 /* CPU */, + ); + path = Telemetry; + sourceTree = ""; + }; 9844EC526E3527F6A582179F /* Hardware */ = { isa = PBXGroup; children = ( @@ -133,6 +156,7 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, ); path = Tests; @@ -234,6 +258,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, ); @@ -245,6 +270,7 @@ files = ( A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, + 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 63da8c2..e4f4fbe 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -11,4 +11,7 @@ #import "MMSMCParser.h" #import "MMAppleSMCClient.h" +// Telemetry Providers +#import "MMCPULoadProvider.h" + #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/CPU/MMCPULoadProvider.h b/Sources/Telemetry/CPU/MMCPULoadProvider.h new file mode 100644 index 0000000..ae74aea --- /dev/null +++ b/Sources/Telemetry/CPU/MMCPULoadProvider.h @@ -0,0 +1,31 @@ +#ifndef MMCPULoadProvider_h +#define MMCPULoadProvider_h + +#import +#import +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMCPULoadProvider + * Samples real-time Mach host processor CPU load ticks, per-core utilization percentages, + * and CPU operating frequency. + */ +@interface MMCPULoadProvider : NSObject + +@property (nonatomic, readonly) natural_t coreCount; + +/** + * Static calculator method facilitating deterministic unit testing with mock tick data. + */ ++ (NSDictionary *)calculateLoadFromPreviousTicks:(const struct processor_cpu_load_info * _Nullable)prevTicks + currentTicks:(const struct processor_cpu_load_info *)currTicks + coreCount:(natural_t)coreCount; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMCPULoadProvider_h */ diff --git a/Sources/Telemetry/CPU/MMCPULoadProvider.m b/Sources/Telemetry/CPU/MMCPULoadProvider.m new file mode 100644 index 0000000..bba05cf --- /dev/null +++ b/Sources/Telemetry/CPU/MMCPULoadProvider.m @@ -0,0 +1,201 @@ +#import "MMCPULoadProvider.h" +#import +#import + +@interface MMCPULoadProvider () { + os_unfair_lock _lock; + processor_cpu_load_info_t _previousCpuInfo; + mach_msg_type_number_t _previousCpuInfoCount; + natural_t _coreCount; + uint64_t _maxFrequencyHz; +} +@end + +@implementation MMCPULoadProvider + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _previousCpuInfo = NULL; + _previousCpuInfoCount = 0; + + // Read CPU core count + natural_t cCount = 1; + size_t size = sizeof(cCount); + sysctlbyname("hw.logicalcpu", &cCount, &size, NULL, 0); + _coreCount = cCount; + + // Read maximum frequency + uint64_t maxFreq = 0; + size = sizeof(maxFreq); + if (sysctlbyname("hw.cpufrequency_max", &maxFreq, &size, NULL, 0) != 0) { + sysctlbyname("hw.cpufrequency", &maxFreq, &size, NULL, 0); + } + _maxFrequencyHz = maxFreq; + } + return self; +} + +- (void)dealloc { + if (_previousCpuInfo != NULL) { + free(_previousCpuInfo); + _previousCpuInfo = NULL; + } +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainCPU; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.cpuload"; +} + +- (BOOL)isAvailable { + return YES; +} + +- (natural_t)coreCount { + return _coreCount; +} + ++ (NSDictionary *)calculateLoadFromPreviousTicks:(const struct processor_cpu_load_info * _Nullable)prevTicks + currentTicks:(const struct processor_cpu_load_info *)currTicks + coreCount:(natural_t)coreCount { + if (!currTicks || coreCount == 0) return @{}; + + NSMutableArray *> *coresList = [NSMutableArray arrayWithCapacity:coreCount]; + double totalUserPercent = 0.0; + double totalSystemPercent = 0.0; + double totalIdlePercent = 0.0; + double totalActivePercent = 0.0; + + for (natural_t i = 0; i < coreCount; i++) { + uint64_t user = currTicks[i].cpu_ticks[CPU_STATE_USER]; + uint64_t system = currTicks[i].cpu_ticks[CPU_STATE_SYSTEM]; + uint64_t idle = currTicks[i].cpu_ticks[CPU_STATE_IDLE]; + uint64_t nice = currTicks[i].cpu_ticks[CPU_STATE_NICE]; + + uint64_t deltaUser = 0; + uint64_t deltaSystem = 0; + uint64_t deltaIdle = 0; + uint64_t deltaNice = 0; + + if (prevTicks != NULL) { + deltaUser = (user >= prevTicks[i].cpu_ticks[CPU_STATE_USER]) ? (user - prevTicks[i].cpu_ticks[CPU_STATE_USER]) : 0; + deltaSystem = (system >= prevTicks[i].cpu_ticks[CPU_STATE_SYSTEM]) ? (system - prevTicks[i].cpu_ticks[CPU_STATE_SYSTEM]) : 0; + deltaIdle = (idle >= prevTicks[i].cpu_ticks[CPU_STATE_IDLE]) ? (idle - prevTicks[i].cpu_ticks[CPU_STATE_IDLE]) : 0; + deltaNice = (nice >= prevTicks[i].cpu_ticks[CPU_STATE_NICE]) ? (nice - prevTicks[i].cpu_ticks[CPU_STATE_NICE]) : 0; + } + + uint64_t deltaTotal = deltaUser + deltaSystem + deltaIdle + deltaNice; + double uPct = 0.0, sPct = 0.0, iPct = 100.0, aPct = 0.0; + + if (deltaTotal > 0) { + uPct = ((double)deltaUser / (double)deltaTotal) * 100.0; + sPct = ((double)deltaSystem / (double)deltaTotal) * 100.0; + iPct = ((double)deltaIdle / (double)deltaTotal) * 100.0; + aPct = 100.0 - iPct; + if (aPct < 0.0) aPct = 0.0; + if (aPct > 100.0) aPct = 100.0; + } + + totalUserPercent += uPct; + totalSystemPercent += sPct; + totalIdlePercent += iPct; + totalActivePercent += aPct; + + [coresList addObject:@{ + @"coreIndex": @(i), + @"userPercent": @(uPct), + @"systemPercent": @(sPct), + @"idlePercent": @(iPct), + @"totalPercent": @(aPct) + }]; + } + + double avgUser = coreCount > 0 ? (totalUserPercent / coreCount) : 0.0; + double avgSystem = coreCount > 0 ? (totalSystemPercent / coreCount) : 0.0; + double avgIdle = coreCount > 0 ? (totalIdlePercent / coreCount) : 100.0; + double avgTotal = coreCount > 0 ? (totalActivePercent / coreCount) : 0.0; + + return @{ + @"cores": coresList, + @"coreCount": @(coreCount), + @"userPercent": @(avgUser), + @"systemPercent": @(avgSystem), + @"idlePercent": @(avgIdle), + @"totalPercent": @(avgTotal) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + natural_t numCPUs = 0; + processor_info_array_t cpuInfo = NULL; + mach_msg_type_number_t numCpuInfo = 0; + + kern_return_t kr = host_processor_info( + mach_host_self(), + PROCESSOR_CPU_LOAD_INFO, + &numCPUs, + &cpuInfo, + &numCpuInfo + ); + + if (kr != KERN_SUCCESS || cpuInfo == NULL) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.CPU" + code:kr + userInfo:@{NSLocalizedDescriptionKey: @"host_processor_info failed to read CPU ticks"}]; + } + return nil; + } + + os_unfair_lock_lock(&_lock); + + processor_cpu_load_info_t currentCpuInfo = (processor_cpu_load_info_t)cpuInfo; + NSDictionary *metrics = [MMCPULoadProvider calculateLoadFromPreviousTicks:_previousCpuInfo + currentTicks:currentCpuInfo + coreCount:numCPUs]; + + // Free previous malloc buffer + if (_previousCpuInfo != NULL) { + free(_previousCpuInfo); + _previousCpuInfo = NULL; + } + + // Allocate new buffer to preserve state for the next delta + vm_size_t bufferSize = numCpuInfo * sizeof(integer_t); + _previousCpuInfo = (processor_cpu_load_info_t)malloc(bufferSize); + if (_previousCpuInfo != NULL) { + memcpy(_previousCpuInfo, cpuInfo, bufferSize); + _previousCpuInfoCount = numCpuInfo; + } + + // Deallocate the buffer returned by the kernel + vm_deallocate(mach_task_self(), (vm_address_t)cpuInfo, numCpuInfo * sizeof(integer_t)); + + os_unfair_lock_unlock(&_lock); + + // Read current frequency + uint64_t currentFreq = 0; + size_t size = sizeof(currentFreq); + if (sysctlbyname("hw.cpufrequency", ¤tFreq, &size, NULL, 0) != 0) { + currentFreq = _maxFrequencyHz; + } + + // Thermal State + NSProcessInfoThermalState thermalState = [NSProcessInfo processInfo].thermalState; + BOOL isThrottled = (thermalState >= NSProcessInfoThermalStateSerious); + + NSMutableDictionary *result = [metrics mutableCopy]; + result[@"frequencyHz"] = @(currentFreq); + result[@"maxFrequencyHz"] = @(_maxFrequencyHz); + result[@"thermalState"] = @(thermalState); + result[@"isThrottled"] = @(isThrottled); + + return [result copy]; +} + +@end diff --git a/Tests/MMCPULoadTests.swift b/Tests/MMCPULoadTests.swift new file mode 100644 index 0000000..968ef87 --- /dev/null +++ b/Tests/MMCPULoadTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import MacMonitor + +final class MMCPULoadTests: XCTestCase { + + func testTickDeltaCalculation() { + var prevTicks = [processor_cpu_load_info_data_t]() + var currTicks = [processor_cpu_load_info_data_t]() + + // Setup mock Core 0: 20% user, 10% system, 70% idle + var core0Prev = processor_cpu_load_info_data_t() + core0Prev.cpu_ticks.0 = 100 // USER + core0Prev.cpu_ticks.1 = 50 // SYSTEM + core0Prev.cpu_ticks.2 = 850 // IDLE + core0Prev.cpu_ticks.3 = 0 // NICE + prevTicks.append(core0Prev) + + var core0Curr = processor_cpu_load_info_data_t() + core0Curr.cpu_ticks.0 = 300 // USER (+200) + core0Curr.cpu_ticks.1 = 150 // SYSTEM (+100) + core0Curr.cpu_ticks.2 = 1550 // IDLE (+700) + core0Curr.cpu_ticks.3 = 0 // NICE (+0) + currTicks.append(core0Curr) + + prevTicks.withUnsafeBufferPointer { prevBuf in + currTicks.withUnsafeBufferPointer { currBuf in + let metrics = MMCPULoadProvider.calculateLoad( + fromPreviousTicks: prevBuf.baseAddress, + currentTicks: currBuf.baseAddress!, + coreCount: 1 + ) + + let totalPct = metrics["totalPercent"] as? Double ?? 0 + let userPct = metrics["userPercent"] as? Double ?? 0 + let systemPct = metrics["systemPercent"] as? Double ?? 0 + let idlePct = metrics["idlePercent"] as? Double ?? 0 + + // Total delta = 200 + 100 + 700 = 1000 + // User = 20%, System = 10%, Idle = 70%, Total Active = 30% + XCTAssertEqual(totalPct, 30.0, accuracy: 0.1) + XCTAssertEqual(userPct, 20.0, accuracy: 0.1) + XCTAssertEqual(systemPct, 10.0, accuracy: 0.1) + XCTAssertEqual(idlePct, 70.0, accuracy: 0.1) + } + } + } + + func testLiveCPULoadProvider() throws { + let provider = MMCPULoadProvider() + XCTAssertTrue(provider.isAvailable) + XCTAssertEqual(provider.domain, .cpu) + XCTAssertGreaterThan(provider.coreCount, 0) + + // Initial sample establishes baseline + let sample1 = try provider.sampleTelemetry() + XCTAssertNotNil(sample1) + + // Second sample produces live deltas + usleep(100_000) // 100ms + let sample2 = try provider.sampleTelemetry() + let cores = sample2["cores"] as? [[String: Any]] + XCTAssertNotNil(cores) + XCTAssertEqual(cores?.count, Int(provider.coreCount)) + } +} -- 2.39.5 From 3e8852258afc3ae64e4fd16576c7b0654f4a8d0d Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:19:20 +0100 Subject: [PATCH 09/39] feat(memory): implement MMMemoryTelemetryProvider with Mach VM breakdown, swap, and pressure (Issue #8) --- MacMonitor.xcodeproj/project.pbxproj | 18 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Memory/MMMemoryTelemetryProvider.h | 32 +++++ .../Memory/MMMemoryTelemetryProvider.m | 135 ++++++++++++++++++ Tests/MMMemoryTests.swift | 64 +++++++++ 5 files changed, 250 insertions(+) create mode 100644 Sources/Telemetry/Memory/MMMemoryTelemetryProvider.h create mode 100644 Sources/Telemetry/Memory/MMMemoryTelemetryProvider.m create mode 100644 Tests/MMMemoryTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index cfc0624..ed60ccd 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -12,10 +12,12 @@ 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; + 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; @@ -33,6 +35,8 @@ /* Begin PBXFileReference section */ 007F48A5A599AB4E8D216D16 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 0130BD1E18972DF9E2955334 /* MMMemoryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMMemoryTelemetryProvider.h; sourceTree = ""; }; + 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; @@ -47,6 +51,7 @@ 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; + 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -131,6 +136,7 @@ isa = PBXGroup; children = ( 78383CED205BCA772701AE48 /* CPU */, + EE2F5EFC9DC33CEDA79A131F /* Memory */, ); path = Telemetry; sourceTree = ""; @@ -157,6 +163,7 @@ children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, + 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, ); path = Tests; @@ -183,6 +190,15 @@ name = Frameworks; sourceTree = ""; }; + EE2F5EFC9DC33CEDA79A131F /* Memory */ = { + isa = PBXGroup; + children = ( + 0130BD1E18972DF9E2955334 /* MMMemoryTelemetryProvider.h */, + 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */, + ); + path = Memory; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -259,6 +275,7 @@ buildActionMask = 2147483647; files = ( 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, + 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, ); @@ -271,6 +288,7 @@ A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, + B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index e4f4fbe..d1e17c7 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -13,5 +13,6 @@ // Telemetry Providers #import "MMCPULoadProvider.h" +#import "MMMemoryTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.h b/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.h new file mode 100644 index 0000000..b5e0c2f --- /dev/null +++ b/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.h @@ -0,0 +1,32 @@ +#ifndef MMMemoryTelemetryProvider_h +#define MMMemoryTelemetryProvider_h + +#import +#import +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMMemoryTelemetryProvider + * Samples Mach virtual memory statistics, wired/active/inactive/compressed memory allocations, + * swap usage, and system-wide memory pressure levels. + */ +@interface MMMemoryTelemetryProvider : NSObject + +@property (nonatomic, readonly) uint64_t totalPhysicalMemoryBytes; + +/** + * Deterministic helper method for unit testing VM metric calculations. + */ ++ (NSDictionary *)calculateMemoryMetricsWithVMInfo:(const vm_statistics64_data_t *)vmInfo + pageSize:(vm_size_t)pageSize + totalPhysicalRAM:(uint64_t)totalRAM + swapUsage:(const struct xsw_usage * _Nullable)swap; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMMemoryTelemetryProvider_h */ diff --git a/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.m b/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.m new file mode 100644 index 0000000..267bd03 --- /dev/null +++ b/Sources/Telemetry/Memory/MMMemoryTelemetryProvider.m @@ -0,0 +1,135 @@ +#import "MMMemoryTelemetryProvider.h" +#import + +@interface MMMemoryTelemetryProvider () { + uint64_t _totalPhysicalMemoryBytes; + vm_size_t _pageSize; +} +@end + +@implementation MMMemoryTelemetryProvider + +- (instancetype)init { + self = [super init]; + if (self) { + // Read physical memory size + uint64_t memSize = 0; + size_t size = sizeof(memSize); + sysctlbyname("hw.memsize", &memSize, &size, NULL, 0); + _totalPhysicalMemoryBytes = memSize; + + // Read virtual memory page size + vm_size_t pSize = 4096; + host_page_size(mach_host_self(), &pSize); + _pageSize = pSize; + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainMemory; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.memory"; +} + +- (BOOL)isAvailable { + return YES; +} + +- (uint64_t)totalPhysicalMemoryBytes { + return _totalPhysicalMemoryBytes; +} + ++ (NSDictionary *)calculateMemoryMetricsWithVMInfo:(const vm_statistics64_data_t *)vmInfo + pageSize:(vm_size_t)pageSize + totalPhysicalRAM:(uint64_t)totalRAM + swapUsage:(const struct xsw_usage * _Nullable)swap { + if (!vmInfo) return @{}; + + uint64_t appMemory = (vmInfo->internal_page_count > vmInfo->purgeable_count) + ? (uint64_t)(vmInfo->internal_page_count - vmInfo->purgeable_count) * pageSize + : 0; + uint64_t wired = (uint64_t)vmInfo->wire_count * pageSize; + uint64_t compressed = (uint64_t)vmInfo->compressor_page_count * pageSize; + uint64_t used = appMemory + wired + compressed; + + uint64_t freeRAM = (uint64_t)vmInfo->free_count * pageSize; + uint64_t purgeable = (uint64_t)vmInfo->purgeable_count * pageSize; + uint64_t active = (uint64_t)vmInfo->active_count * pageSize; + uint64_t inactive = (uint64_t)vmInfo->inactive_count * pageSize; + + // Calculate Memory Pressure percentage + uint64_t availableForUse = (uint64_t)(vmInfo->free_count + vmInfo->inactive_count) * pageSize; + double pressurePct = 0.0; + if (totalRAM > 0) { + pressurePct = (1.0 - ((double)availableForUse / (double)totalRAM)) * 100.0; + if (pressurePct < 0.0) pressurePct = 0.0; + if (pressurePct > 100.0) pressurePct = 100.0; + } + + NSString *pressureStatus = @"Normal"; + if (pressurePct >= 80.0) { + pressureStatus = @"Critical"; + } else if (pressurePct >= 60.0) { + pressureStatus = @"Warning"; + } + + uint64_t swapTotal = swap ? swap->xsu_total : 0; + uint64_t swapUsed = swap ? swap->xsu_used : 0; + uint64_t swapFree = swap ? swap->xsu_avail : 0; + + return @{ + @"totalBytes": @(totalRAM), + @"usedBytes": @(used), + @"freeBytes": @(freeRAM), + @"appMemoryBytes": @(appMemory), + @"wiredBytes": @(wired), + @"compressedBytes": @(compressed), + @"purgeableBytes": @(purgeable), + @"activeBytes": @(active), + @"inactiveBytes": @(inactive), + @"memoryPressurePercent": @(pressurePct), + @"memoryPressureStatus": pressureStatus, + @"swapTotalBytes": @(swapTotal), + @"swapUsedBytes": @(swapUsed), + @"swapFreeBytes": @(swapFree) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + vm_statistics64_data_t vmInfo = {0}; + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + + kern_return_t kr = host_statistics64( + mach_host_self(), + HOST_VM_INFO64, + (host_info64_t)&vmInfo, + &count + ); + + if (kr != KERN_SUCCESS) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.Memory" + code:kr + userInfo:@{NSLocalizedDescriptionKey: @"host_statistics64 failed to sample VM metrics"}]; + } + return nil; + } + + // Read swap usage + struct xsw_usage swap = {0}; + size_t swapSize = sizeof(swap); + struct xsw_usage *swapPtr = NULL; + if (sysctlbyname("vm.swapusage", &swap, &swapSize, NULL, 0) == 0) { + swapPtr = &swap; + } + + return [MMMemoryTelemetryProvider calculateMemoryMetricsWithVMInfo:&vmInfo + pageSize:_pageSize + totalPhysicalRAM:_totalPhysicalMemoryBytes + swapUsage:swapPtr]; +} + +@end diff --git a/Tests/MMMemoryTests.swift b/Tests/MMMemoryTests.swift new file mode 100644 index 0000000..a3ba1cc --- /dev/null +++ b/Tests/MMMemoryTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import MacMonitor + +final class MMMemoryTests: XCTestCase { + + func testMemoryMetricCalculation() { + var vmInfo = vm_statistics64_data_t() + let pageSize: vm_size_t = 4096 + let totalRAM: UInt64 = 16 * 1024 * 1024 * 1024 // 16 GB + + // 1GB wired (262144 pages), 2GB app (524288 pages), 1GB compressed (262144 pages) + vmInfo.wire_count = 262_144 + vmInfo.internal_page_count = 524_288 + vmInfo.purgeable_count = 0 + vmInfo.compressor_page_count = 262_144 + vmInfo.active_count = 524_288 + vmInfo.inactive_count = 1_048_576 // 4GB + vmInfo.free_count = 2_097_152 // 8GB + + var swap = xsw_usage() + swap.xsu_total = 2 * 1024 * 1024 * 1024 + swap.xsu_used = 512 * 1024 * 1024 + swap.xsu_avail = swap.xsu_total - swap.xsu_used + + withUnsafePointer(to: vmInfo) { vmPtr in + withUnsafePointer(to: swap) { swapPtr in + let metrics = MMMemoryTelemetryProvider.calculateMemoryMetrics( + withVMInfo: vmPtr, + pageSize: pageSize, + totalPhysicalRAM: totalRAM, + swapUsage: swapPtr + ) + + let wired = metrics["wiredBytes"] as? UInt64 ?? 0 + let app = metrics["appMemoryBytes"] as? UInt64 ?? 0 + let compressed = metrics["compressedBytes"] as? UInt64 ?? 0 + let used = metrics["usedBytes"] as? UInt64 ?? 0 + let pressureStatus = metrics["memoryPressureStatus"] as? String ?? "" + + XCTAssertEqual(wired, 1024 * 1024 * 1024) + XCTAssertEqual(app, 2 * 1024 * 1024 * 1024) + XCTAssertEqual(compressed, 1024 * 1024 * 1024) + XCTAssertEqual(used, 4 * 1024 * 1024 * 1024) + XCTAssertEqual(pressureStatus, "Normal") + } + } + } + + func testLiveMemoryProvider() throws { + let provider = MMMemoryTelemetryProvider() + XCTAssertTrue(provider.isAvailable) + XCTAssertEqual(provider.domain, .memory) + XCTAssertGreaterThan(provider.totalPhysicalMemoryBytes, 0) + + let sample = try provider.sampleTelemetry() + let total = sample["totalBytes"] as? UInt64 ?? 0 + let used = sample["usedBytes"] as? UInt64 ?? 0 + let free = sample["freeBytes"] as? UInt64 ?? 0 + + XCTAssertGreaterThan(total, 0) + XCTAssertGreaterThan(used, 0) + XCTAssertGreaterThanOrEqual(free, 0) + } +} -- 2.39.5 From 67bfa9f6811acf34881b2d9d999ea8cd9948d1b7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:20:37 +0100 Subject: [PATCH 10/39] feat(storage): implement MMStorageTelemetryProvider for mounted volumes and capacity (Issue #9) --- MacMonitor.xcodeproj/project.pbxproj | 18 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Storage/MMStorageTelemetryProvider.h | 26 +++++ .../Storage/MMStorageTelemetryProvider.m | 108 ++++++++++++++++++ Tests/MMStorageTests.swift | 64 +++++++++++ 5 files changed, 217 insertions(+) create mode 100644 Sources/Telemetry/Storage/MMStorageTelemetryProvider.h create mode 100644 Sources/Telemetry/Storage/MMStorageTelemetryProvider.m create mode 100644 Tests/MMStorageTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index ed60ccd..29a7278 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; }; 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; + 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; @@ -21,6 +22,7 @@ CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; + F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,13 +44,16 @@ 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; + 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = ""; }; 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; + 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; + 78325870C7CD8312AECA2236 /* MMStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMStorageTests.swift; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; @@ -137,6 +142,7 @@ children = ( 78383CED205BCA772701AE48 /* CPU */, EE2F5EFC9DC33CEDA79A131F /* Memory */, + B6EB3F6545276C40212C2992 /* Storage */, ); path = Telemetry; sourceTree = ""; @@ -149,6 +155,15 @@ path = Hardware; sourceTree = ""; }; + B6EB3F6545276C40212C2992 /* Storage */ = { + isa = PBXGroup; + children = ( + 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */, + 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */, + ); + path = Storage; + sourceTree = ""; + }; C80C3EEE8F912F4B214102E3 /* Products */ = { isa = PBXGroup; children = ( @@ -165,6 +180,7 @@ 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, + 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, ); path = Tests; sourceTree = ""; @@ -277,6 +293,7 @@ 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, + 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -290,6 +307,7 @@ 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, + F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index d1e17c7..7f5f8fa 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -14,5 +14,6 @@ // Telemetry Providers #import "MMCPULoadProvider.h" #import "MMMemoryTelemetryProvider.h" +#import "MMStorageTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Storage/MMStorageTelemetryProvider.h b/Sources/Telemetry/Storage/MMStorageTelemetryProvider.h new file mode 100644 index 0000000..bb11656 --- /dev/null +++ b/Sources/Telemetry/Storage/MMStorageTelemetryProvider.h @@ -0,0 +1,26 @@ +#ifndef MMStorageTelemetryProvider_h +#define MMStorageTelemetryProvider_h + +#import +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMStorageTelemetryProvider + * Samples mounted disk volumes, APFS containers, used/free storage capacity, + * and mount filesystem attributes. + */ +@interface MMStorageTelemetryProvider : NSObject + +/** + * Static parser method for converting statfs structures into volume dictionaries. + */ ++ (NSArray *> *)parseStatFSBuffer:(const struct statfs *)buffer count:(int)count; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMStorageTelemetryProvider_h */ diff --git a/Sources/Telemetry/Storage/MMStorageTelemetryProvider.m b/Sources/Telemetry/Storage/MMStorageTelemetryProvider.m new file mode 100644 index 0000000..5f53b17 --- /dev/null +++ b/Sources/Telemetry/Storage/MMStorageTelemetryProvider.m @@ -0,0 +1,108 @@ +#import "MMStorageTelemetryProvider.h" + +@implementation MMStorageTelemetryProvider + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainStorage; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.storage"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSArray *> *)parseStatFSBuffer:(const struct statfs *)buffer count:(int)count { + if (!buffer || count <= 0) return @[]; + + NSMutableArray *> *volumes = [NSMutableArray arrayWithCapacity:count]; + + for (int i = 0; i < count; i++) { + const struct statfs *mnt = &buffer[i]; + + // Only include local mounts + if (!(mnt->f_flags & MNT_LOCAL)) { + continue; + } + + NSString *mountPoint = [NSString stringWithUTF8String:mnt->f_mntonname]; + NSString *deviceName = [NSString stringWithUTF8String:mnt->f_mntfromname]; + NSString *fsType = [NSString stringWithUTF8String:mnt->f_fstypename]; + + // Exclude virtual/internal devfs, autodefs, and synthetic system roots + if ([fsType isEqualToString:@"devfs"] || + [fsType isEqualToString:@"autofs"] || + [mountPoint hasPrefix:@"/System/Volumes/Data/"] || + [deviceName containsString:@"com.apple.os.update"]) { + continue; + } + + uint64_t blockSize = (uint64_t)mnt->f_bsize; + uint64_t totalBytes = (uint64_t)mnt->f_blocks * blockSize; + uint64_t freeBytes = (uint64_t)mnt->f_bfree * blockSize; + uint64_t availBytes = (uint64_t)mnt->f_bavail * blockSize; + uint64_t usedBytes = (totalBytes >= freeBytes) ? (totalBytes - freeBytes) : 0; + + if (totalBytes == 0) continue; + + double usedPercent = ((double)usedBytes / (double)totalBytes) * 100.0; + if (usedPercent < 0.0) usedPercent = 0.0; + if (usedPercent > 100.0) usedPercent = 100.0; + + NSString *volumeName = [mountPoint lastPathComponent]; + if ([mountPoint isEqualToString:@"/"]) { + volumeName = @"Macintosh HD"; + } + + BOOL isReadOnly = (mnt->f_flags & MNT_RDONLY) != 0; + + [volumes addObject:@{ + @"volumeName": volumeName, + @"mountPoint": mountPoint, + @"devicePath": deviceName, + @"fsType": fsType, + @"totalBytes": @(totalBytes), + @"usedBytes": @(usedBytes), + @"freeBytes": @(freeBytes), + @"availableBytes": @(availBytes), + @"usedPercent": @(usedPercent), + @"isReadOnly": @(isReadOnly) + }]; + } + + return [volumes copy]; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + struct statfs *mntbuf = NULL; + int count = getmntinfo(&mntbuf, MNT_WAIT); + + if (count <= 0 || mntbuf == NULL) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.Storage" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"getmntinfo returned no mounted filesystems"}]; + } + return nil; + } + + NSArray *volumes = [MMStorageTelemetryProvider parseStatFSBuffer:mntbuf count:count]; + + uint64_t totalStorage = 0; + uint64_t totalUsed = 0; + for (NSDictionary *v in volumes) { + totalStorage += [v[@"totalBytes"] unsignedLongLongValue]; + totalUsed += [v[@"usedBytes"] unsignedLongLongValue]; + } + + return @{ + @"volumes": volumes, + @"volumeCount": @(volumes.count), + @"aggregateTotalBytes": @(totalStorage), + @"aggregateUsedBytes": @(totalUsed) + }; +} + +@end diff --git a/Tests/MMStorageTests.swift b/Tests/MMStorageTests.swift new file mode 100644 index 0000000..10c3e7e --- /dev/null +++ b/Tests/MMStorageTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import MacMonitor + +final class MMStorageTests: XCTestCase { + + func testStatFSBufferParsing() { + var stat = statfs() + stat.f_bsize = 4096 + stat.f_blocks = 125_000_000 // 500 GB + stat.f_bfree = 50_000_000 // 200 GB + stat.f_bavail = 45_000_000 + stat.f_flags = UInt32(MNT_LOCAL) + + let mountPath = "/Volumes/External" + let devicePath = "/dev/disk2s1" + let fsType = "apfs" + + _ = mountPath.withCString { mntPtr in + strncpy(&stat.f_mntonname.0, mntPtr, Int(MNAMELEN)) + } + _ = devicePath.withCString { devPtr in + strncpy(&stat.f_mntfromname.0, devPtr, Int(MNAMELEN)) + } + _ = fsType.withCString { fsPtr in + strncpy(&stat.f_fstypename.0, fsPtr, Int(MFSTYPENAMELEN)) + } + + withUnsafePointer(to: stat) { ptr in + let volumes = MMStorageTelemetryProvider.parseStatFSBuffer(ptr, count: 1) + XCTAssertEqual(volumes.count, 1) + + let vol = volumes.first! + XCTAssertEqual(vol["volumeName"] as? String, "External") + XCTAssertEqual(vol["mountPoint"] as? String, "/Volumes/External") + XCTAssertEqual(vol["devicePath"] as? String, "/dev/disk2s1") + XCTAssertEqual(vol["fsType"] as? String, "apfs") + + let total = vol["totalBytes"] as? UInt64 ?? 0 + let free = vol["freeBytes"] as? UInt64 ?? 0 + let used = vol["usedBytes"] as? UInt64 ?? 0 + let pct = vol["usedPercent"] as? Double ?? 0 + + XCTAssertEqual(total, 512_000_000_000) + XCTAssertEqual(free, 204_800_000_000) + XCTAssertEqual(used, 307_200_000_000) + XCTAssertEqual(pct, 60.0, accuracy: 0.1) + } + } + + func testLiveStorageProvider() throws { + let provider = MMStorageTelemetryProvider() + XCTAssertTrue(provider.isAvailable) + XCTAssertEqual(provider.domain, .storage) + + let sample = try provider.sampleTelemetry() + let volumes = sample["volumes"] as? [[String: Any]] + XCTAssertNotNil(volumes) + XCTAssertGreaterThan(volumes?.count ?? 0, 0) + + let firstVol = volumes?.first + XCTAssertNotNil(firstVol?["mountPoint"]) + XCTAssertGreaterThan(firstVol?["totalBytes"] as? UInt64 ?? 0, 0) + } +} -- 2.39.5 From b47ffd72ca20dbd684301b9db39654b19b276455 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:21:26 +0100 Subject: [PATCH 11/39] feat(thermal): implement MMCPUThermalProvider with package, per-core temps, and throttling (Issue #3) --- MacMonitor.xcodeproj/project.pbxproj | 18 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Telemetry/Thermal/MMCPUThermalProvider.h | 34 ++++ .../Telemetry/Thermal/MMCPUThermalProvider.m | 151 ++++++++++++++++++ Tests/MMCPUThermalTests.swift | 54 +++++++ 5 files changed, 258 insertions(+) create mode 100644 Sources/Telemetry/Thermal/MMCPUThermalProvider.h create mode 100644 Sources/Telemetry/Thermal/MMCPUThermalProvider.m create mode 100644 Tests/MMCPUThermalTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 29a7278..e9682db 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; + 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; + 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; @@ -39,6 +41,7 @@ 007F48A5A599AB4E8D216D16 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; 0130BD1E18972DF9E2955334 /* MMMemoryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMMemoryTelemetryProvider.h; sourceTree = ""; }; 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; + 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; @@ -50,6 +53,7 @@ 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; + 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; @@ -59,6 +63,7 @@ 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; + D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -143,6 +148,7 @@ 78383CED205BCA772701AE48 /* CPU */, EE2F5EFC9DC33CEDA79A131F /* Memory */, B6EB3F6545276C40212C2992 /* Storage */, + DBDC37231C01F0A2D4A7FC73 /* Thermal */, ); path = Telemetry; sourceTree = ""; @@ -178,6 +184,7 @@ children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, + 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, @@ -185,6 +192,15 @@ path = Tests; sourceTree = ""; }; + DBDC37231C01F0A2D4A7FC73 /* Thermal */ = { + isa = PBXGroup; + children = ( + D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */, + 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */, + ); + path = Thermal; + sourceTree = ""; + }; E012FBB26EF9E35A2FAF87E2 /* AppleSMC */ = { isa = PBXGroup; children = ( @@ -291,6 +307,7 @@ buildActionMask = 2147483647; files = ( 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, + 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, @@ -305,6 +322,7 @@ A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, + 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 7f5f8fa..bad2b4a 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -15,5 +15,6 @@ #import "MMCPULoadProvider.h" #import "MMMemoryTelemetryProvider.h" #import "MMStorageTelemetryProvider.h" +#import "MMCPUThermalProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Thermal/MMCPUThermalProvider.h b/Sources/Telemetry/Thermal/MMCPUThermalProvider.h new file mode 100644 index 0000000..1ee75a3 --- /dev/null +++ b/Sources/Telemetry/Thermal/MMCPUThermalProvider.h @@ -0,0 +1,34 @@ +#ifndef MMCPUThermalProvider_h +#define MMCPUThermalProvider_h + +#import +#import "MMTelemetryProvider.h" +#import "MMAppleSMCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMCPUThermalProvider + * Samples real-time CPU die, proximity, and per-core temperature sensors from AppleSMC + * alongside OS thermal pressure states. + */ +@interface MMCPUThermalProvider : NSObject + +@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient; +@property (nonatomic, readonly) NSArray *discoveredCoreKeys; +@property (nonatomic, readonly, nullable) NSString *packageKey; + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client; + +/** + * Static calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateThermalMetricsWithPackageTemp:(nullable NSNumber *)pkgTemp + coreTemps:(NSDictionary *)coreDict + thermalState:(NSProcessInfoThermalState)state; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMCPUThermalProvider_h */ diff --git a/Sources/Telemetry/Thermal/MMCPUThermalProvider.m b/Sources/Telemetry/Thermal/MMCPUThermalProvider.m new file mode 100644 index 0000000..8cb8374 --- /dev/null +++ b/Sources/Telemetry/Thermal/MMCPUThermalProvider.m @@ -0,0 +1,151 @@ +#import "MMCPUThermalProvider.h" +#import + +@interface MMCPUThermalProvider () { + MMAppleSMCClient *_smcClient; + NSMutableArray *_discoveredCoreKeys; + NSString *_packageKey; + BOOL _probed; +} +@end + +@implementation MMCPUThermalProvider + +- (instancetype)init { + return [self initWithSMCClient:[MMAppleSMCClient sharedClient]]; +} + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client { + self = [super init]; + if (self) { + _smcClient = client; + _discoveredCoreKeys = [NSMutableArray array]; + [self probeSensors]; + } + return self; +} + +- (void)probeSensors { + if (!_smcClient.isAvailable) return; + + // Probe package temperature key + NSArray *packageCandidates = @[@"TC0P", @"TC0D", @"TC0H", @"TCXC"]; + for (NSString *candidate in packageCandidates) { + NSNumber *val = [_smcClient readNumericValueForKey:candidate error:nil]; + if (val && [val doubleValue] > 0.0 && [val doubleValue] < 125.0) { + _packageKey = candidate; + break; + } + } + + // Probe core keys (TC0C, TC1C, etc.) + int coreCount = 1; + size_t size = sizeof(coreCount); + sysctlbyname("hw.physicalcpu", &coreCount, &size, NULL, 0); + if (coreCount <= 0) coreCount = 4; + + for (int i = 0; i < coreCount && i < 32; i++) { + NSString *key = [NSString stringWithFormat:@"TC%dC", i]; + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val && [val doubleValue] > 0.0 && [val doubleValue] < 125.0) { + [_discoveredCoreKeys addObject:key]; + } + } + + _probed = YES; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainThermal; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.thermal.cpu"; +} + +- (BOOL)isAvailable { + return _smcClient.isAvailable; +} + +- (MMAppleSMCClient *)smcClient { + return _smcClient; +} + +- (NSArray *)discoveredCoreKeys { + return [_discoveredCoreKeys copy]; +} + +- (NSString *)packageKey { + return _packageKey; +} + ++ (NSDictionary *)calculateThermalMetricsWithPackageTemp:(nullable NSNumber *)pkgTemp + coreTemps:(NSDictionary *)coreDict + thermalState:(NSProcessInfoThermalState)state { + double peak = pkgTemp ? [pkgTemp doubleValue] : 0.0; + double sum = 0.0; + int count = 0; + + NSMutableArray *> *coresList = [NSMutableArray arrayWithCapacity:coreDict.count]; + NSArray *sortedKeys = [coreDict.allKeys sortedArrayUsingSelector:@selector(compare:)]; + + for (NSUInteger idx = 0; idx < sortedKeys.count; idx++) { + NSString *k = sortedKeys[idx]; + double temp = [coreDict[k] doubleValue]; + if (temp > peak) peak = temp; + sum += temp; + count++; + + [coresList addObject:@{ + @"coreIndex": @(idx), + @"key": k, + @"temperature": @(temp) + }]; + } + + double avg = (count > 0) ? (sum / count) : (pkgTemp ? [pkgTemp doubleValue] : 0.0); + + NSString *stateStr = @"Nominal"; + switch (state) { + case NSProcessInfoThermalStateNominal: stateStr = @"Nominal"; break; + case NSProcessInfoThermalStateFair: stateStr = @"Fair"; break; + case NSProcessInfoThermalStateSerious: stateStr = @"Serious"; break; + case NSProcessInfoThermalStateCritical: stateStr = @"Critical"; break; + } + + return @{ + @"packageTemperature": pkgTemp ?: @(0.0), + @"averageCoreTemperature": @(avg), + @"peakCoreTemperature": @(peak), + @"coreTemperatures": coresList, + @"thermalPressureState": stateStr, + @"isThrottling": @(state >= NSProcessInfoThermalStateSerious) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + if (!_probed && _smcClient.isAvailable) { + [self probeSensors]; + } + + NSNumber *pkgTemp = nil; + if (_packageKey) { + pkgTemp = [_smcClient readNumericValueForKey:_packageKey error:nil]; + } + + NSMutableDictionary *coreTemps = [NSMutableDictionary dictionary]; + for (NSString *coreKey in _discoveredCoreKeys) { + NSNumber *t = [_smcClient readNumericValueForKey:coreKey error:nil]; + if (t) { + coreTemps[coreKey] = t; + } + } + + NSProcessInfoThermalState thermalState = [NSProcessInfo processInfo].thermalState; + + return [MMCPUThermalProvider calculateThermalMetricsWithPackageTemp:pkgTemp + coreTemps:coreTemps + thermalState:thermalState]; +} + +@end diff --git a/Tests/MMCPUThermalTests.swift b/Tests/MMCPUThermalTests.swift new file mode 100644 index 0000000..a838e2c --- /dev/null +++ b/Tests/MMCPUThermalTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import MacMonitor + +final class MMCPUThermalTests: XCTestCase { + + func testThermalMetricsCalculation() { + let pkgTemp = NSNumber(value: 55.0) + let coreDict: [String: NSNumber] = [ + "TC0C": NSNumber(value: 52.0), + "TC1C": NSNumber(value: 56.0), + "TC2C": NSNumber(value: 54.0), + "TC3C": NSNumber(value: 58.0) + ] + + let metrics = MMCPUThermalProvider.calculateThermalMetrics( + withPackageTemp: pkgTemp, + coreTemps: coreDict, + thermalState: .nominal + ) + + let peak = metrics["peakCoreTemperature"] as? Double ?? 0 + let avg = metrics["averageCoreTemperature"] as? Double ?? 0 + let state = metrics["thermalPressureState"] as? String ?? "" + let isThrottling = metrics["isThrottling"] as? Bool ?? true + + XCTAssertEqual(peak, 58.0, accuracy: 0.1) + XCTAssertEqual(avg, 55.0, accuracy: 0.1) + XCTAssertEqual(state, "Nominal") + XCTAssertFalse(isThrottling) + } + + func testThermalThrottlingDetection() { + let metrics = MMCPUThermalProvider.calculateThermalMetrics( + withPackageTemp: NSNumber(value: 99.0), + coreTemps: [:], + thermalState: .serious + ) + + let isThrottling = metrics["isThrottling"] as? Bool ?? false + let state = metrics["thermalPressureState"] as? String ?? "" + + XCTAssertTrue(isThrottling) + XCTAssertEqual(state, "Serious") + } + + func testLiveCPUThermalProvider() throws { + let provider = MMCPUThermalProvider() + XCTAssertEqual(provider.domain, .thermal) + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["thermalPressureState"]) + } +} -- 2.39.5 From f9093b1d996b207cb80ab1d080bb89804fcf32a8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:22:17 +0100 Subject: [PATCH 12/39] feat(fan): implement MMFanTelemetryProvider for multi-fan RPM and utilization (Issue #4) --- MacMonitor.xcodeproj/project.pbxproj | 18 +++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Telemetry/Fan/MMFanTelemetryProvider.h | 31 ++++ .../Telemetry/Fan/MMFanTelemetryProvider.m | 147 ++++++++++++++++++ Tests/MMFanTests.swift | 48 ++++++ 5 files changed, 245 insertions(+) create mode 100644 Sources/Telemetry/Fan/MMFanTelemetryProvider.h create mode 100644 Sources/Telemetry/Fan/MMFanTelemetryProvider.m create mode 100644 Tests/MMFanTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index e9682db..1dd8cae 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; + 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; }; 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; @@ -22,6 +23,7 @@ A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; + E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; }; @@ -46,7 +48,9 @@ 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; + 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; + 3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = ""; }; 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = ""; }; 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; @@ -62,6 +66,7 @@ 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; + C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -146,6 +151,7 @@ isa = PBXGroup; children = ( 78383CED205BCA772701AE48 /* CPU */, + AD2995435E44FA3E1790A4F3 /* Fan */, EE2F5EFC9DC33CEDA79A131F /* Memory */, B6EB3F6545276C40212C2992 /* Storage */, DBDC37231C01F0A2D4A7FC73 /* Thermal */, @@ -161,6 +167,15 @@ path = Hardware; sourceTree = ""; }; + AD2995435E44FA3E1790A4F3 /* Fan */ = { + isa = PBXGroup; + children = ( + 3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */, + C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */, + ); + path = Fan; + sourceTree = ""; + }; B6EB3F6545276C40212C2992 /* Storage */ = { isa = PBXGroup; children = ( @@ -185,6 +200,7 @@ 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, + 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, @@ -308,6 +324,7 @@ files = ( 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, + E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, @@ -323,6 +340,7 @@ CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, + 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */, B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index bad2b4a..2874eda 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -16,5 +16,6 @@ #import "MMMemoryTelemetryProvider.h" #import "MMStorageTelemetryProvider.h" #import "MMCPUThermalProvider.h" +#import "MMFanTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Fan/MMFanTelemetryProvider.h b/Sources/Telemetry/Fan/MMFanTelemetryProvider.h new file mode 100644 index 0000000..86844b3 --- /dev/null +++ b/Sources/Telemetry/Fan/MMFanTelemetryProvider.h @@ -0,0 +1,31 @@ +#ifndef MMFanTelemetryProvider_h +#define MMFanTelemetryProvider_h + +#import +#import "MMTelemetryProvider.h" +#import "MMAppleSMCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMFanTelemetryProvider + * Samples multi-fan telemetry from AppleSMC: current RPM, min RPM, max RPM, target RPM, + * and fan utilization percentages. + */ +@interface MMFanTelemetryProvider : NSObject + +@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient; +@property (nonatomic, readonly) NSInteger fanCount; + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client; + +/** + * Static calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMFanTelemetryProvider_h */ diff --git a/Sources/Telemetry/Fan/MMFanTelemetryProvider.m b/Sources/Telemetry/Fan/MMFanTelemetryProvider.m new file mode 100644 index 0000000..1fd8604 --- /dev/null +++ b/Sources/Telemetry/Fan/MMFanTelemetryProvider.m @@ -0,0 +1,147 @@ +#import "MMFanTelemetryProvider.h" + +@interface MMFanTelemetryProvider () { + MMAppleSMCClient *_smcClient; + NSInteger _probedFanCount; +} +@end + +@implementation MMFanTelemetryProvider + +- (instancetype)init { + return [self initWithSMCClient:[MMAppleSMCClient sharedClient]]; +} + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client { + self = [super init]; + if (self) { + _smcClient = client; + _probedFanCount = -1; + [self probeFans]; + } + return self; +} + +- (void)probeFans { + if (!_smcClient.isAvailable) return; + + NSNumber *num = [_smcClient readNumericValueForKey:@"FNum" error:nil]; + if (num) { + _probedFanCount = [num integerValue]; + } else { + // Fallback probe F0Ac + NSNumber *f0 = [_smcClient readNumericValueForKey:@"F0Ac" error:nil]; + if (f0 && [f0 doubleValue] >= 0.0) { + _probedFanCount = 1; + NSNumber *f1 = [_smcClient readNumericValueForKey:@"F1Ac" error:nil]; + if (f1 && [f1 doubleValue] >= 0.0) { + _probedFanCount = 2; + } + } else { + _probedFanCount = 0; + } + } +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainFan; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.fan"; +} + +- (BOOL)isAvailable { + return _smcClient.isAvailable; +} + +- (MMAppleSMCClient *)smcClient { + return _smcClient; +} + +- (NSInteger)fanCount { + return (_probedFanCount >= 0) ? _probedFanCount : 0; +} + ++ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList { + if (!fanList || fanList.count == 0) { + return @{ + @"fanCount": @(0), + @"fans": @[], + @"averageRPM": @(0.0), + @"peakRPM": @(0.0) + }; + } + + NSMutableArray *> *resultList = [NSMutableArray arrayWithCapacity:fanList.count]; + double sumRPM = 0.0; + double peakRPM = 0.0; + + for (NSUInteger i = 0; i < fanList.count; i++) { + NSDictionary *raw = fanList[i]; + double current = [raw[@"currentRPM"] doubleValue]; + double min = [raw[@"minRPM"] doubleValue]; + double max = [raw[@"maxRPM"] doubleValue]; + double target = [raw[@"targetRPM"] doubleValue]; + + double util = 0.0; + if (max > min && current >= min) { + util = ((current - min) / (max - min)) * 100.0; + if (util < 0.0) util = 0.0; + if (util > 100.0) util = 100.0; + } + + if (current > peakRPM) peakRPM = current; + sumRPM += current; + + [resultList addObject:@{ + @"fanIndex": @(i), + @"currentRPM": @(current), + @"minRPM": @(min), + @"maxRPM": @(max), + @"targetRPM": @(target), + @"utilizationPercent": @(util) + }]; + } + + double avgRPM = fanList.count > 0 ? (sumRPM / fanList.count) : 0.0; + + return @{ + @"fanCount": @(fanList.count), + @"fans": [resultList copy], + @"averageRPM": @(avgRPM), + @"peakRPM": @(peakRPM) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + if (_probedFanCount < 0 && _smcClient.isAvailable) { + [self probeFans]; + } + + NSInteger count = [self fanCount]; + NSMutableArray *> *rawList = [NSMutableArray arrayWithCapacity:count]; + + for (NSInteger i = 0; i < count; i++) { + NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i]; + NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i]; + NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i]; + NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i]; + + NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0); + NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0); + NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0); + NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac; + + [rawList addObject:@{ + @"currentRPM": ac, + @"minRPM": mn, + @"maxRPM": mx, + @"targetRPM": tg + }]; + } + + return [MMFanTelemetryProvider calculateFanMetricsFromFanList:rawList]; +} + +@end diff --git a/Tests/MMFanTests.swift b/Tests/MMFanTests.swift new file mode 100644 index 0000000..e9663ab --- /dev/null +++ b/Tests/MMFanTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import MacMonitor + +final class MMFanTests: XCTestCase { + + func testFanUtilizationCalculation() { + let fanList: [[String: NSNumber]] = [ + [ + "currentRPM": NSNumber(value: 2000.0), + "minRPM": NSNumber(value: 1200.0), + "maxRPM": NSNumber(value: 5000.0), + "targetRPM": NSNumber(value: 2000.0) + ], + [ + "currentRPM": NSNumber(value: 2500.0), + "minRPM": NSNumber(value: 1200.0), + "maxRPM": NSNumber(value: 5000.0), + "targetRPM": NSNumber(value: 2500.0) + ] + ] + + let metrics = MMFanTelemetryProvider.calculateFanMetrics(fromFanList: fanList) + + let count = metrics["fanCount"] as? Int ?? 0 + let avgRPM = metrics["averageRPM"] as? Double ?? 0 + let peakRPM = metrics["peakRPM"] as? Double ?? 0 + let fans = metrics["fans"] as? [[String: Any]] ?? [] + + XCTAssertEqual(count, 2) + XCTAssertEqual(avgRPM, 2250.0, accuracy: 0.1) + XCTAssertEqual(peakRPM, 2500.0, accuracy: 0.1) + XCTAssertEqual(fans.count, 2) + + let fan0 = fans[0] + let util0 = fan0["utilizationPercent"] as? Double ?? 0 + // (2000 - 1200) / 3800 * 100 = 21.05% + XCTAssertEqual(util0, 21.05, accuracy: 0.1) + } + + func testLiveFanProvider() throws { + let provider = MMFanTelemetryProvider() + XCTAssertEqual(provider.domain, .fan) + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["fanCount"]) + } +} -- 2.39.5 From fda8bf9e49ae4d5c6903d86c1345f836e91fb2eb Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:23:57 +0100 Subject: [PATCH 13/39] feat(telemetry): implement motherboard and component thermal provider (fixes #18) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Thermal/MMComponentThermalProvider.h | 42 +++++ .../Thermal/MMComponentThermalProvider.m | 174 ++++++++++++++++++ Tests/MMComponentThermalTests.swift | 80 ++++++++ 4 files changed, 297 insertions(+) create mode 100644 Sources/Telemetry/Thermal/MMComponentThermalProvider.h create mode 100644 Sources/Telemetry/Thermal/MMComponentThermalProvider.m create mode 100644 Tests/MMComponentThermalTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 2874eda..e7fb714 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -17,5 +17,6 @@ #import "MMStorageTelemetryProvider.h" #import "MMCPUThermalProvider.h" #import "MMFanTelemetryProvider.h" +#import "MMComponentThermalProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Thermal/MMComponentThermalProvider.h b/Sources/Telemetry/Thermal/MMComponentThermalProvider.h new file mode 100644 index 0000000..24065d0 --- /dev/null +++ b/Sources/Telemetry/Thermal/MMComponentThermalProvider.h @@ -0,0 +1,42 @@ +#ifndef MMComponentThermalProvider_h +#define MMComponentThermalProvider_h + +#import +#import "MMTelemetryProvider.h" +#import "MMAppleSMCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMComponentThermalProvider + * Samples motherboard, PCH chipset, heatsink, memory, and enclosure ambient temperatures + * via AppleSMC. + */ +@interface MMComponentThermalProvider : NSObject + +@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient; +@property (nonatomic, readonly) NSArray *discoveredKeys; + +- (instancetype)init; +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client; + +/** + * Categorize SMC temperature key into component domain (e.g., PCH / Chipset, Heatsink, Memory, etc.) + */ ++ (NSString *)categoryForKey:(NSString *)key; + +/** + * Human-readable friendly name for known component SMC keys. + */ ++ (NSString *)humanReadableNameForKey:(NSString *)key; + +/** + * Static calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateComponentMetricsFromDictionary:(NSDictionary *)readings; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMComponentThermalProvider_h */ diff --git a/Sources/Telemetry/Thermal/MMComponentThermalProvider.m b/Sources/Telemetry/Thermal/MMComponentThermalProvider.m new file mode 100644 index 0000000..b6e5a1f --- /dev/null +++ b/Sources/Telemetry/Thermal/MMComponentThermalProvider.m @@ -0,0 +1,174 @@ +#import "MMComponentThermalProvider.h" + +@interface MMComponentThermalProvider () { + MMAppleSMCClient *_smcClient; + NSMutableArray *_discoveredKeys; + BOOL _probed; +} +@end + +@implementation MMComponentThermalProvider + +- (instancetype)init { + return [self initWithSMCClient:[MMAppleSMCClient sharedClient]]; +} + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client { + self = [super init]; + if (self) { + _smcClient = client; + _discoveredKeys = [NSMutableArray array]; + [self probeSensors]; + } + return self; +} + +- (void)probeSensors { + if (!_smcClient.isAvailable) return; + + NSArray *candidates = @[ + @"TPCD", @"TP0P", @"TP0D", + @"Th0H", @"Th1H", @"Th2H", @"Th3H", + @"TM0P", @"TM1P", @"TM2P", @"TM3P", @"TM0S", @"TM1S", + @"Tp0P", @"Tp1P", @"Tp2P", @"Tp0C", + @"TA0P", @"TA1P", @"TA0S", @"TA1S", + @"TTLD", @"TT1D", @"TB0T", @"TB1T" + ]; + + [_discoveredKeys removeAllObjects]; + for (NSString *key in candidates) { + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val && [val doubleValue] > 0.0 && [val doubleValue] < 125.0) { + [_discoveredKeys addObject:key]; + } + } + + _probed = YES; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainThermal; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.thermal.components"; +} + +- (BOOL)isAvailable { + return _smcClient.isAvailable; +} + +- (MMAppleSMCClient *)smcClient { + return _smcClient; +} + +- (NSArray *)discoveredKeys { + return [_discoveredKeys copy]; +} + ++ (NSString *)categoryForKey:(NSString *)key { + if ([key hasPrefix:@"TP"]) return @"PCH / Chipset"; + if ([key hasPrefix:@"Th"]) return @"Heatsink"; + if ([key hasPrefix:@"TM"]) return @"Memory"; + if ([key hasPrefix:@"Tp"]) return @"Power Supply"; + if ([key hasPrefix:@"TA"]) return @"Ambient / Enclosure"; + if ([key hasPrefix:@"TT"] || [key hasPrefix:@"TB"] || [key hasPrefix:@"TI"]) return @"Thunderbolt / IO"; + return @"Other"; +} + ++ (NSString *)humanReadableNameForKey:(NSString *)key { + static NSDictionary *names = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + names = @{ + @"TPCD": @"Platform Controller Hub (Die)", + @"TP0P": @"Platform Controller Hub (Proximity)", + @"TP0D": @"Platform Controller Hub (Die Alternate)", + @"Th0H": @"Main Heatsink A", + @"Th1H": @"Main Heatsink B", + @"Th2H": @"Main Heatsink C", + @"Th3H": @"Main Heatsink D", + @"TM0P": @"Memory Bank 0 Proximity", + @"TM1P": @"Memory Bank 1 Proximity", + @"TM2P": @"Memory Bank 2 Proximity", + @"TM3P": @"Memory Bank 3 Proximity", + @"TM0S": @"Memory Module 0", + @"TM1S": @"Memory Module 1", + @"Tp0P": @"Power Supply Primary", + @"Tp1P": @"Power Supply Secondary", + @"Tp2P": @"Power Supply Tertiary", + @"Tp0C": @"Power Supply Core", + @"TA0P": @"Ambient Air Intake", + @"TA1P": @"Ambient Air Exhaust", + @"TA0S": @"Ambient Air Enclosure", + @"TA1S": @"Ambient Air Secondary", + @"TTLD": @"Thunderbolt Controller Die", + @"TT1D": @"Thunderbolt Controller Secondary", + @"TB0T": @"Thunderbolt Port 0", + @"TB1T": @"Thunderbolt Port 1" + }; + }); + + NSString *match = names[key]; + return match ?: key; +} + ++ (NSDictionary *)calculateComponentMetricsFromDictionary:(NSDictionary *)readings { + NSMutableArray *> *components = [NSMutableArray arrayWithCapacity:readings.count]; + NSMutableDictionary *> *> *categorized = [NSMutableDictionary dictionary]; + + NSArray *sortedKeys = [readings.allKeys sortedArrayUsingSelector:@selector(compare:)]; + double peak = 0.0; + double sum = 0.0; + + for (NSString *k in sortedKeys) { + double temp = [readings[k] doubleValue]; + if (temp > peak) peak = temp; + sum += temp; + + NSString *cat = [self categoryForKey:k]; + NSString *name = [self humanReadableNameForKey:k]; + + NSDictionary *entry = @{ + @"key": k, + @"name": name, + @"category": cat, + @"temperature": @(temp) + }; + + [components addObject:entry]; + + if (!categorized[cat]) { + categorized[cat] = [NSMutableArray array]; + } + [categorized[cat] addObject:entry]; + } + + double avg = readings.count > 0 ? (sum / (double)readings.count) : 0.0; + + return @{ + @"components": components, + @"categorized": categorized, + @"sensorCount": @(readings.count), + @"peakTemperature": @(peak), + @"averageTemperature": @(avg) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + if (!_probed && _smcClient.isAvailable) { + [self probeSensors]; + } + + NSMutableDictionary *readings = [NSMutableDictionary dictionary]; + for (NSString *key in _discoveredKeys) { + NSNumber *temp = [_smcClient readNumericValueForKey:key error:nil]; + if (temp) { + readings[key] = temp; + } + } + + return [MMComponentThermalProvider calculateComponentMetricsFromDictionary:readings]; +} + +@end diff --git a/Tests/MMComponentThermalTests.swift b/Tests/MMComponentThermalTests.swift new file mode 100644 index 0000000..da79d77 --- /dev/null +++ b/Tests/MMComponentThermalTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import MacMonitor + +final class MMComponentThermalTests: XCTestCase { + + func testCategoryClassification() { + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TPCD"), "PCH / Chipset") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TP0P"), "PCH / Chipset") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Th0H"), "Heatsink") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Th1H"), "Heatsink") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TM0P"), "Memory") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TM1S"), "Memory") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "Tp0P"), "Power Supply") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TA0P"), "Ambient / Enclosure") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TTLD"), "Thunderbolt / IO") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "TB0T"), "Thunderbolt / IO") + XCTAssertEqual(MMComponentThermalProvider.category(forKey: "ZZ99"), "Other") + } + + func testHumanReadableNames() { + XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "TPCD"), "Platform Controller Hub (Die)") + XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "Th0H"), "Main Heatsink A") + XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "TA0P"), "Ambient Air Intake") + XCTAssertEqual(MMComponentThermalProvider.humanReadableName(forKey: "CUSTOM"), "CUSTOM") + } + + func testComponentMetricsCalculation() { + let readings: [String: NSNumber] = [ + "TPCD": NSNumber(value: 65.5), + "Th0H": NSNumber(value: 48.0), + "TM0P": NSNumber(value: 42.0), + "TA0P": NSNumber(value: 28.5) + ] + + let metrics = MMComponentThermalProvider.calculateComponentMetrics(from: readings) + + let peak = metrics["peakTemperature"] as? Double ?? 0 + let avg = metrics["averageTemperature"] as? Double ?? 0 + let count = metrics["sensorCount"] as? Int ?? 0 + let components = metrics["components"] as? [[String: Any]] ?? [] + let categorized = metrics["categorized"] as? [String: [[String: Any]]] ?? [:] + + XCTAssertEqual(peak, 65.5, accuracy: 0.1) + XCTAssertEqual(avg, (65.5 + 48.0 + 42.0 + 28.5) / 4.0, accuracy: 0.1) + XCTAssertEqual(count, 4) + XCTAssertEqual(components.count, 4) + + let pchList = categorized["PCH / Chipset"] + XCTAssertNotNil(pchList) + let firstPchName = pchList?.first?["name"] as? String + XCTAssertEqual(firstPchName, "Platform Controller Hub (Die)") + XCTAssertEqual(categorized["Heatsink"]?.count, 1) + XCTAssertEqual(categorized["Memory"]?.count, 1) + XCTAssertEqual(categorized["Ambient / Enclosure"]?.count, 1) + } + + func testEmptyReadingsCalculation() { + let metrics = MMComponentThermalProvider.calculateComponentMetrics(from: [:]) + + let peak = metrics["peakTemperature"] as? Double ?? -1 + let avg = metrics["averageTemperature"] as? Double ?? -1 + let count = metrics["sensorCount"] as? Int ?? -1 + + XCTAssertEqual(peak, 0.0) + XCTAssertEqual(avg, 0.0) + XCTAssertEqual(count, 0) + } + + func testLiveComponentThermalProvider() throws { + let provider = MMComponentThermalProvider() + XCTAssertEqual(provider.domain, .thermal) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.thermal.components") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["sensorCount"]) + XCTAssertNotNil(sample["components"]) + XCTAssertNotNil(sample["categorized"]) + } +} -- 2.39.5 From 91d50dcbae6772d436e56afb4157cca4577bc63b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:25:34 +0100 Subject: [PATCH 14/39] feat(telemetry): implement power, voltage, and current telemetry provider (fixes #19) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + Sources/Hardware/AppleSMC/MMSMCParser.m | 21 +- .../Power/MMPowerTelemetryProvider.h | 45 +++ .../Power/MMPowerTelemetryProvider.m | 314 ++++++++++++++++++ Tests/MMPowerTests.swift | 101 ++++++ 5 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 Sources/Telemetry/Power/MMPowerTelemetryProvider.h create mode 100644 Sources/Telemetry/Power/MMPowerTelemetryProvider.m create mode 100644 Tests/MMPowerTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index e7fb714..66be716 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -18,5 +18,6 @@ #import "MMCPUThermalProvider.h" #import "MMFanTelemetryProvider.h" #import "MMComponentThermalProvider.h" +#import "MMPowerTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Hardware/AppleSMC/MMSMCParser.m b/Sources/Hardware/AppleSMC/MMSMCParser.m index 9fcd9cb..a92fbbf 100644 --- a/Sources/Hardware/AppleSMC/MMSMCParser.m +++ b/Sources/Hardware/AppleSMC/MMSMCParser.m @@ -40,6 +40,13 @@ (uint32_t)bytes[3]; } +static int hexDigitValue(unichar c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + + (nullable NSNumber *)decodeValueWithDataType:(NSString *)dataType bytes:(const uint8_t *)bytes size:(NSUInteger)size { if (!bytes || size == 0) return nil; @@ -49,7 +56,7 @@ if (size >= 2) return @([self decodeSP78:bytes]); } else if ([trimmedType isEqualToString:@"fpe2"]) { if (size >= 2) return @([self decodeFPE2:bytes]); - } else if ([trimmedType isEqualToString:@"flt"]) { + } else if ([trimmedType isEqualToString:@"flt"] || [trimmedType isEqualToString:@"ioft"]) { if (size >= 4) return @([self decodeFLT:bytes]); } else if ([trimmedType isEqualToString:@"ui8"]) { if (size >= 1) return @([self decodeUI8:bytes]); @@ -59,6 +66,18 @@ if (size >= 4) return @([self decodeUI32:bytes]); } else if ([trimmedType isEqualToString:@"flag"]) { if (size >= 1) return @(bytes[0] != 0); + } else if (trimmedType.length == 4 && [trimmedType hasPrefix:@"sp"] && size >= 2) { + int fracBits = hexDigitValue([trimmedType characterAtIndex:3]); + if (fracBits >= 0 && fracBits <= 15) { + int16_t raw = (int16_t)(((uint16_t)bytes[0] << 8) | (uint16_t)bytes[1]); + return @((float)raw / (float)(1 << fracBits)); + } + } else if (trimmedType.length == 4 && [trimmedType hasPrefix:@"fp"] && size >= 2) { + int fracBits = hexDigitValue([trimmedType characterAtIndex:3]); + if (fracBits >= 0 && fracBits <= 15) { + uint16_t raw = ((uint16_t)bytes[0] << 8) | (uint16_t)bytes[1]; + return @((float)raw / (float)(1 << fracBits)); + } } return nil; diff --git a/Sources/Telemetry/Power/MMPowerTelemetryProvider.h b/Sources/Telemetry/Power/MMPowerTelemetryProvider.h new file mode 100644 index 0000000..97a5ba2 --- /dev/null +++ b/Sources/Telemetry/Power/MMPowerTelemetryProvider.h @@ -0,0 +1,45 @@ +#ifndef MMPowerTelemetryProvider_h +#define MMPowerTelemetryProvider_h + +#import +#import "MMTelemetryProvider.h" +#import "MMAppleSMCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMPowerTelemetryProvider + * Samples system power draw, CPU/GPU package wattage, rail voltages, and currents + * via AppleSMC and AppleSmartBattery IOKit interfaces. + */ +@interface MMPowerTelemetryProvider : NSObject + +@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient; +@property (nonatomic, readonly) NSArray *discoveredPowerKeys; +@property (nonatomic, readonly) NSArray *discoveredVoltageKeys; +@property (nonatomic, readonly) NSArray *discoveredCurrentKeys; + +- (instancetype)init; +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client; + +/** + * Friendly name for power, voltage, and current SMC keys. + */ ++ (NSString *)humanReadableNameForKey:(NSString *)key; + +/** + * Unit of measurement for SMC key ("W", "V", "A"). + */ ++ (NSString *)unitForKey:(NSString *)key; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculatePowerMetricsWithReadings:(NSDictionary *)readings + batteryInfo:(nullable NSDictionary *)batteryInfo; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMPowerTelemetryProvider_h */ diff --git a/Sources/Telemetry/Power/MMPowerTelemetryProvider.m b/Sources/Telemetry/Power/MMPowerTelemetryProvider.m new file mode 100644 index 0000000..c38e8eb --- /dev/null +++ b/Sources/Telemetry/Power/MMPowerTelemetryProvider.m @@ -0,0 +1,314 @@ +#import "MMPowerTelemetryProvider.h" +#import +#import +#import + +@interface MMPowerTelemetryProvider () { + MMAppleSMCClient *_smcClient; + NSMutableArray *_discoveredPowerKeys; + NSMutableArray *_discoveredVoltageKeys; + NSMutableArray *_discoveredCurrentKeys; + BOOL _probed; +} +@end + +@implementation MMPowerTelemetryProvider + +- (instancetype)init { + return [self initWithSMCClient:[MMAppleSMCClient sharedClient]]; +} + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client { + self = [super init]; + if (self) { + _smcClient = client; + _discoveredPowerKeys = [NSMutableArray array]; + _discoveredVoltageKeys = [NSMutableArray array]; + _discoveredCurrentKeys = [NSMutableArray array]; + [self probeSensors]; + } + return self; +} + +- (void)probeSensors { + if (!_smcClient.isAvailable) return; + + NSArray *powerCandidates = @[ + @"PSTR", @"PDTR", @"PCPR", @"PCTR", @"PC0C", @"PCPT", + @"PG0R", @"PGTR", @"PMSR", @"PM0R", @"PCPC" + ]; + + NSArray *voltageCandidates = @[ + @"VC0C", @"VD0R", @"VN0C", @"VM0R", @"VP0R" + ]; + + NSArray *currentCandidates = @[ + @"IC0C", @"ID0R", @"IG0C", @"IM0R" + ]; + + [_discoveredPowerKeys removeAllObjects]; + for (NSString *key in powerCandidates) { + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val && [val doubleValue] > 0.0 && [val doubleValue] < 5000.0) { + [_discoveredPowerKeys addObject:key]; + } + } + + [_discoveredVoltageKeys removeAllObjects]; + for (NSString *key in voltageCandidates) { + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val && [val doubleValue] > 0.0) { + [_discoveredVoltageKeys addObject:key]; + } + } + + [_discoveredCurrentKeys removeAllObjects]; + for (NSString *key in currentCandidates) { + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val && [val doubleValue] > 0.0) { + [_discoveredCurrentKeys addObject:key]; + } + } + + _probed = YES; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainPower; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.power"; +} + +- (BOOL)isAvailable { + return _smcClient.isAvailable; +} + +- (MMAppleSMCClient *)smcClient { + return _smcClient; +} + +- (NSArray *)discoveredPowerKeys { + return [_discoveredPowerKeys copy]; +} + +- (NSArray *)discoveredVoltageKeys { + return [_discoveredVoltageKeys copy]; +} + +- (NSArray *)discoveredCurrentKeys { + return [_discoveredCurrentKeys copy]; +} + ++ (NSString *)unitForKey:(NSString *)key { + if ([key hasPrefix:@"P"]) return @"W"; + if ([key hasPrefix:@"V"]) return @"V"; + if ([key hasPrefix:@"I"]) return @"A"; + return @""; +} + ++ (NSString *)humanReadableNameForKey:(NSString *)key { + static NSDictionary *names = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + names = @{ + @"PSTR": @"Total System Power", + @"PDTR": @"DC-In Total Power", + @"PCPR": @"CPU Package Power", + @"PCTR": @"CPU Total Power", + @"PC0C": @"CPU Core Power", + @"PCPT": @"CPU Package Total Power", + @"PG0R": @"GPU 0 Power", + @"PGTR": @"GPU Total Power", + @"PMSR": @"Memory Subsystem Power", + @"PM0R": @"Memory Rail Power", + @"PCPC": @"Power Supply In / AC", + @"VC0C": @"CPU Core Voltage", + @"VD0R": @"DC-In Rail Voltage", + @"VN0C": @"PCH Rail Voltage", + @"VM0R": @"Memory Rail Voltage", + @"VP0R": @"Power Supply Voltage", + @"IC0C": @"CPU Core Current", + @"ID0R": @"DC-In Rail Current", + @"IG0C": @"GPU Rail Current", + @"IM0R": @"Memory Rail Current" + }; + }); + + NSString *match = names[key]; + return match ?: key; +} + ++ (NSDictionary *)calculatePowerMetricsWithReadings:(NSDictionary *)readings + batteryInfo:(nullable NSDictionary *)batteryInfo { + // Determine system total power + double systemTotal = 0.0; + if (readings[@"PSTR"]) { + systemTotal = [readings[@"PSTR"] doubleValue]; + } else if (readings[@"PDTR"]) { + systemTotal = [readings[@"PDTR"] doubleValue]; + } else if (batteryInfo[@"batteryWatts"]) { + systemTotal = [batteryInfo[@"batteryWatts"] doubleValue]; + } + + // CPU wattage + double cpuWatts = 0.0; + if (readings[@"PCPR"]) { + cpuWatts = [readings[@"PCPR"] doubleValue]; + } else if (readings[@"PCTR"]) { + cpuWatts = [readings[@"PCTR"] doubleValue]; + } else if (readings[@"PC0C"]) { + cpuWatts = [readings[@"PC0C"] doubleValue]; + } else if (readings[@"PCPT"]) { + cpuWatts = [readings[@"PCPT"] doubleValue]; + } + + // Voltage & Current + double cpuVoltage = readings[@"VC0C"] ? [readings[@"VC0C"] doubleValue] : 0.0; + double cpuCurrent = readings[@"IC0C"] ? [readings[@"IC0C"] doubleValue] : 0.0; + + // Fallback: calculate cpuWatts from V * I if SMC didn't provide direct wattage + if (cpuWatts == 0.0 && cpuVoltage > 0.0 && cpuCurrent > 0.0) { + cpuWatts = cpuVoltage * cpuCurrent; + } + + // GPU wattage + double gpuWatts = 0.0; + if (readings[@"PG0R"]) { + gpuWatts = [readings[@"PG0R"] doubleValue]; + } else if (readings[@"PGTR"]) { + gpuWatts = [readings[@"PGTR"] doubleValue]; + } + + // Memory wattage + double memoryWatts = 0.0; + if (readings[@"PMSR"]) { + memoryWatts = [readings[@"PMSR"] doubleValue]; + } else if (readings[@"PM0R"]) { + memoryWatts = [readings[@"PM0R"] doubleValue]; + } + + // Collect all individual sensor entries + NSMutableArray *> *sensors = [NSMutableArray arrayWithCapacity:readings.count]; + NSArray *sortedKeys = [readings.allKeys sortedArrayUsingSelector:@selector(compare:)]; + for (NSString *k in sortedKeys) { + double val = [readings[k] doubleValue]; + [sensors addObject:@{ + @"key": k, + @"name": [self humanReadableNameForKey:k], + @"value": @(val), + @"unit": [self unitForKey:k] + }]; + } + + NSString *powerSource = batteryInfo[@"powerSource"] ?: @"AC Power"; + BOOL isCharging = [batteryInfo[@"isCharging"] boolValue]; + double batteryLevel = [batteryInfo[@"batteryLevel"] doubleValue]; + + return @{ + @"systemTotalWatts": @(systemTotal), + @"cpuWatts": @(cpuWatts), + @"gpuWatts": @(gpuWatts), + @"memoryWatts": @(memoryWatts), + @"cpuVoltage": @(cpuVoltage), + @"cpuCurrent": @(cpuCurrent), + @"powerSource": powerSource, + @"isCharging": @(isCharging), + @"batteryLevel": @(batteryLevel), + @"sensors": sensors + }; +} + +- (nullable NSDictionary *)sampleBatteryInfo { + CFTypeRef psInfo = IOPSCopyPowerSourcesInfo(); + if (!psInfo) return nil; + + CFArrayRef psList = IOPSCopyPowerSourcesList(psInfo); + if (!psList) { + CFRelease(psInfo); + return nil; + } + + NSString *stateStr = @"AC Power"; + BOOL isCharging = NO; + double capacity = 100.0; + + if (CFArrayGetCount(psList) > 0) { + CFTypeRef ps = CFArrayGetValueAtIndex(psList, 0); + CFDictionaryRef desc = IOPSGetPowerSourceDescription(psInfo, ps); + if (desc) { + NSDictionary *dict = (__bridge NSDictionary *)desc; + stateStr = dict[@"Power Source State"] ?: @"AC Power"; + isCharging = [dict[@"Is Charging"] boolValue]; + if (dict[@"Current Capacity"]) { + capacity = [dict[@"Current Capacity"] doubleValue]; + } + } + } + CFRelease(psList); + CFRelease(psInfo); + + // Sample AppleSmartBattery for instantaneous voltage and current + double batteryWatts = 0.0; + double batteryVoltage = 0.0; + double batteryAmperage = 0.0; + + io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")); + if (service != IO_OBJECT_NULL) { + CFMutableDictionaryRef props = NULL; + if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) { + NSDictionary *dict = (__bridge NSDictionary *)props; + double v_mV = [dict[@"Voltage"] doubleValue]; + double a_mA = dict[@"InstantAmperage"] ? [dict[@"InstantAmperage"] doubleValue] : [dict[@"Amperage"] doubleValue]; + + batteryVoltage = v_mV / 1000.0; + batteryAmperage = fabs(a_mA) / 1000.0; + batteryWatts = batteryVoltage * batteryAmperage; + + CFRelease(props); + } + IOObjectRelease(service); + } + + return @{ + @"powerSource": stateStr, + @"isCharging": @(isCharging), + @"batteryLevel": @(capacity), + @"batteryVoltage": @(batteryVoltage), + @"batteryAmperage": @(batteryAmperage), + @"batteryWatts": @(batteryWatts) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + if (!_probed && _smcClient.isAvailable) { + [self probeSensors]; + } + + NSMutableDictionary *readings = [NSMutableDictionary dictionary]; + + NSMutableArray *allKeys = [NSMutableArray arrayWithArray:_discoveredPowerKeys]; + [allKeys addObjectsFromArray:_discoveredVoltageKeys]; + [allKeys addObjectsFromArray:_discoveredCurrentKeys]; + + for (NSString *key in allKeys) { + NSNumber *val = [_smcClient readNumericValueForKey:key error:nil]; + if (val) { + double v = [val doubleValue]; + // Normalize mV or mA if values appear raw + if ([key hasPrefix:@"V"] && v > 100.0) { + v /= 1000.0; + } else if ([key hasPrefix:@"I"] && v > 500.0) { + v /= 1000.0; + } + readings[key] = @(v); + } + } + + NSDictionary *batteryInfo = [self sampleBatteryInfo]; + return [MMPowerTelemetryProvider calculatePowerMetricsWithReadings:readings + batteryInfo:batteryInfo]; +} + +@end diff --git a/Tests/MMPowerTests.swift b/Tests/MMPowerTests.swift new file mode 100644 index 0000000..bb26163 --- /dev/null +++ b/Tests/MMPowerTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import MacMonitor + +final class MMPowerTests: XCTestCase { + + func testUnitsAndNames() { + XCTAssertEqual(MMPowerTelemetryProvider.unit(forKey: "PSTR"), "W") + XCTAssertEqual(MMPowerTelemetryProvider.unit(forKey: "VC0C"), "V") + XCTAssertEqual(MMPowerTelemetryProvider.unit(forKey: "IC0C"), "A") + XCTAssertEqual(MMPowerTelemetryProvider.unit(forKey: "UNKNOWN"), "") + + XCTAssertEqual(MMPowerTelemetryProvider.humanReadableName(forKey: "PSTR"), "Total System Power") + XCTAssertEqual(MMPowerTelemetryProvider.humanReadableName(forKey: "VC0C"), "CPU Core Voltage") + XCTAssertEqual(MMPowerTelemetryProvider.humanReadableName(forKey: "IC0C"), "CPU Core Current") + } + + func testPowerMetricsCalculationFromSMC() { + let readings: [String: NSNumber] = [ + "PSTR": NSNumber(value: 45.2), + "PCPR": NSNumber(value: 28.4), + "PG0R": NSNumber(value: 8.5), + "PMSR": NSNumber(value: 3.1), + "VC0C": NSNumber(value: 1.15), + "IC0C": NSNumber(value: 24.5) + ] + + let batteryInfo: [String: Any] = [ + "powerSource": "AC Power", + "isCharging": false, + "batteryLevel": 100.0, + "batteryWatts": 0.0 + ] + + let metrics = MMPowerTelemetryProvider.calculatePowerMetrics( + withReadings: readings, + batteryInfo: batteryInfo + ) + + let systemWatts = metrics["systemTotalWatts"] as? Double ?? 0 + let cpuWatts = metrics["cpuWatts"] as? Double ?? 0 + let gpuWatts = metrics["gpuWatts"] as? Double ?? 0 + let memWatts = metrics["memoryWatts"] as? Double ?? 0 + let cpuV = metrics["cpuVoltage"] as? Double ?? 0 + let cpuI = metrics["cpuCurrent"] as? Double ?? 0 + let powerSource = metrics["powerSource"] as? String ?? "" + let sensors = metrics["sensors"] as? [[String: Any]] ?? [] + + XCTAssertEqual(systemWatts, 45.2, accuracy: 0.1) + XCTAssertEqual(cpuWatts, 28.4, accuracy: 0.1) + XCTAssertEqual(gpuWatts, 8.5, accuracy: 0.1) + XCTAssertEqual(memWatts, 3.1, accuracy: 0.1) + XCTAssertEqual(cpuV, 1.15, accuracy: 0.01) + XCTAssertEqual(cpuI, 24.5, accuracy: 0.1) + XCTAssertEqual(powerSource, "AC Power") + XCTAssertEqual(sensors.count, 6) + } + + func testBatteryFallbackCalculation() { + // No PSTR, no PCPR, but VC0C and IC0C are present + let readings: [String: NSNumber] = [ + "VC0C": NSNumber(value: 1.2), + "IC0C": NSNumber(value: 10.0) + ] + + let batteryInfo: [String: Any] = [ + "powerSource": "Battery Power", + "isCharging": false, + "batteryLevel": 75.0, + "batteryWatts": 18.5 + ] + + let metrics = MMPowerTelemetryProvider.calculatePowerMetrics( + withReadings: readings, + batteryInfo: batteryInfo + ) + + let systemWatts = metrics["systemTotalWatts"] as? Double ?? 0 + let cpuWatts = metrics["cpuWatts"] as? Double ?? 0 + let powerSource = metrics["powerSource"] as? String ?? "" + let isCharging = metrics["isCharging"] as? Bool ?? true + + // System watts falls back to batteryWatts (18.5) + XCTAssertEqual(systemWatts, 18.5, accuracy: 0.1) + // CPU watts calculated from V * I = 1.2 * 10.0 = 12.0 W + XCTAssertEqual(cpuWatts, 12.0, accuracy: 0.1) + XCTAssertEqual(powerSource, "Battery Power") + XCTAssertFalse(isCharging) + } + + func testLivePowerProvider() throws { + let provider = MMPowerTelemetryProvider() + XCTAssertEqual(provider.domain, .power) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.power") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["systemTotalWatts"]) + XCTAssertNotNil(sample["powerSource"]) + XCTAssertNotNil(sample["sensors"]) + } +} -- 2.39.5 From f2dc6bc7fbbb49d7272597ffff932a3a616bbaa0 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:27:02 +0100 Subject: [PATCH 15/39] feat(ui): bind all primary telemetry providers to SystemTelemetryStore and ContentView dashboard --- MacMonitor.xcodeproj/project.pbxproj | 28 ++ Sources/App/SystemTelemetryStore.swift | 229 ++++++++++ Sources/UI/ContentView.swift | 577 +++++++++++++++++++++---- Tests/MacMonitorTests.swift | 11 + 4 files changed, 763 insertions(+), 82 deletions(-) diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 1dd8cae..d23c521 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -7,17 +7,21 @@ objects = { /* Begin PBXBuildFile section */ + 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */; }; 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; }; 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; + 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; }; 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; + 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */; }; + 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; @@ -42,6 +46,8 @@ /* Begin PBXFileReference section */ 007F48A5A599AB4E8D216D16 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; 0130BD1E18972DF9E2955334 /* MMMemoryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMMemoryTelemetryProvider.h; sourceTree = ""; }; + 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPowerTelemetryProvider.m; sourceTree = ""; }; + 09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = ""; }; 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; @@ -66,7 +72,11 @@ 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; + B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = ""; }; + C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = ""; }; C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; + CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = ""; }; + D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -147,12 +157,22 @@ path = CPU; sourceTree = ""; }; + 8B31CE7F9319FEC373E6CDC6 /* Power */ = { + isa = PBXGroup; + children = ( + CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */, + 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */, + ); + path = Power; + sourceTree = ""; + }; 8BF79E3BCB76E7A1669DC930 /* Telemetry */ = { isa = PBXGroup; children = ( 78383CED205BCA772701AE48 /* CPU */, AD2995435E44FA3E1790A4F3 /* Fan */, EE2F5EFC9DC33CEDA79A131F /* Memory */, + 8B31CE7F9319FEC373E6CDC6 /* Power */, B6EB3F6545276C40212C2992 /* Storage */, DBDC37231C01F0A2D4A7FC73 /* Thermal */, ); @@ -198,10 +218,12 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, + D48698681374BD25B980C000 /* MMPowerTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, ); @@ -211,6 +233,8 @@ DBDC37231C01F0A2D4A7FC73 /* Thermal */ = { isa = PBXGroup; children = ( + 09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */, + C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */, D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */, 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */, ); @@ -324,8 +348,10 @@ files = ( 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, + 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */, E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, + 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, @@ -340,8 +366,10 @@ CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, + 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */, 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */, B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, + 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index a70ff8e..85c84f3 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -2,6 +2,87 @@ import Foundation import SwiftUI import Observation +// MARK: - Typed Telemetry Models + +public struct CPULoadMetrics: Sendable { + public var systemLoad: Double = 0 + public var userLoad: Double = 0 + public var idleLoad: Double = 100 + public var totalLoad: Double = 0 + public var perCoreLoad: [Double] = [] + public var cpuFrequencyMHz: Double = 0 + public var isThrottled: Bool = false +} + +public struct MemoryMetrics: Sendable { + public var totalBytes: UInt64 = 0 + public var usedBytes: UInt64 = 0 + public var freeBytes: UInt64 = 0 + public var activeBytes: UInt64 = 0 + public var inactiveBytes: UInt64 = 0 + public var wiredBytes: UInt64 = 0 + public var compressedBytes: UInt64 = 0 + public var swapTotalBytes: UInt64 = 0 + public var swapUsedBytes: UInt64 = 0 + public var utilizationPercentage: Double = 0 + public var pressureLevel: String = "Normal" +} + +public struct StorageVolumeItem: Identifiable, Sendable { + public var id: String { mountPoint } + public let mountPoint: String + public let volumeName: String + public let fileSystem: String + public let totalBytes: UInt64 + public let freeBytes: UInt64 + public let usedBytes: UInt64 + public let usedPercentage: Double + public let isReadOnly: Bool +} + +public struct CPUThermalMetrics: Sendable { + public var packageTemperature: Double = 0 + public var averageCoreTemperature: Double = 0 + public var peakCoreTemperature: Double = 0 + public var coreTemperatures: [(index: Int, key: String, temperature: Double)] = [] + public var thermalPressureState: String = "Nominal" + public var isThrottling: Bool = false +} + +public struct FanTelemetryItem: Identifiable, Sendable { + public var id: Int { index } + public let index: Int + public let name: String + public let currentRPM: Double + public let minRPM: Double + public let maxRPM: Double + public let targetRPM: Double + public let utilization: Double +} + +public struct ComponentThermalItem: Identifiable, Sendable { + public var id: String { key } + public let key: String + public let name: String + public let category: String + public let temperature: Double +} + +public struct PowerMetrics: Sendable { + public var systemTotalWatts: Double = 0 + public var cpuWatts: Double = 0 + public var gpuWatts: Double = 0 + public var memoryWatts: Double = 0 + public var cpuVoltage: Double = 0 + public var cpuCurrent: Double = 0 + public var powerSource: String = "AC Power" + public var isCharging: Bool = false + public var batteryLevel: Double = 100.0 + public var sensorReadings: [(key: String, name: String, value: Double, unit: String)] = [] +} + +// MARK: - SystemTelemetryStore + @Observable @MainActor public final class SystemTelemetryStore { @@ -14,6 +95,15 @@ public final class SystemTelemetryStore { /// Raw snapshots organized by provider identifier public private(set) var latestSnapshot: [String: [String: Any]] = [:] + /// Structured Telemetry 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() + /// System identification public let hostModel: String public let osVersion: String @@ -30,15 +120,154 @@ public final class SystemTelemetryStore { self.physicalCpuCount = SystemTelemetryStore.readSysctlInt("hw.physicalcpu") ?? 1 self.logicalCpuCount = SystemTelemetryStore.readSysctlInt("hw.logicalcpu") ?? 1 + registerDefaultProviders() setupCoordinator() } + private func registerDefaultProviders() { + // Register all Primary System & Thermal Telemetry providers (M2) + coordinator.register(MMCPULoadProvider()) + coordinator.register(MMMemoryTelemetryProvider()) + coordinator.register(MMStorageTelemetryProvider()) + coordinator.register(MMCPUThermalProvider()) + coordinator.register(MMFanTelemetryProvider()) + coordinator.register(MMComponentThermalProvider()) + coordinator.register(MMPowerTelemetryProvider()) + } + private func setupCoordinator() { coordinator.sampleInterval = self.sampleInterval coordinator.snapshotHandler = { [weak self] snapshot in guard let self else { return } self.latestSnapshot = snapshot self.lastUpdateTimestamp = Date() + self.decodeSnapshot(snapshot) + } + } + + private func decodeSnapshot(_ snapshot: [String: [String: Any]]) { + // 1. CPU Load + if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] { + var m = CPULoadMetrics() + m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0 + m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0 + m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100 + m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0 + if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] { + m.perCoreLoad = perCore.map { $0.doubleValue } + } + m.cpuFrequencyMHz = (cpuDict["cpuFrequencyMHz"] as? NSNumber)?.doubleValue ?? 0 + m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false + self.cpuLoad = m + } + + // 2. Memory + if let memDict = snapshot["com.i3omb.macmonitor.telemetry.memory"] { + var m = MemoryMetrics() + m.totalBytes = (memDict["totalBytes"] as? NSNumber)?.uint64Value ?? 0 + m.usedBytes = (memDict["usedBytes"] as? NSNumber)?.uint64Value ?? 0 + m.freeBytes = (memDict["freeBytes"] as? NSNumber)?.uint64Value ?? 0 + m.activeBytes = (memDict["activeBytes"] as? NSNumber)?.uint64Value ?? 0 + m.inactiveBytes = (memDict["inactiveBytes"] as? NSNumber)?.uint64Value ?? 0 + m.wiredBytes = (memDict["wiredBytes"] as? NSNumber)?.uint64Value ?? 0 + m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0 + m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0 + m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0 + m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0 + m.pressureLevel = memDict["pressureLevel"] as? String ?? "Normal" + self.memory = m + } + + // 3. Storage + if let stgDict = snapshot["com.i3omb.macmonitor.telemetry.storage"], + let volArray = stgDict["volumes"] as? [[String: Any]] { + self.storageVolumes = volArray.compactMap { dict in + guard let mount = dict["mountPoint"] as? String else { return nil } + return StorageVolumeItem( + mountPoint: mount, + volumeName: dict["volumeName"] as? String ?? mount, + fileSystem: dict["fileSystem"] as? String ?? "APFS", + totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0, + freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0, + usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0, + usedPercentage: (dict["usedPercentage"] as? NSNumber)?.doubleValue ?? 0, + isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false + ) + } + } + + // 4. CPU Thermal + if let thDict = snapshot["com.i3omb.macmonitor.telemetry.thermal.cpu"] { + var m = CPUThermalMetrics() + m.packageTemperature = (thDict["packageTemperature"] as? NSNumber)?.doubleValue ?? 0 + m.averageCoreTemperature = (thDict["averageCoreTemperature"] as? NSNumber)?.doubleValue ?? 0 + m.peakCoreTemperature = (thDict["peakCoreTemperature"] as? NSNumber)?.doubleValue ?? 0 + m.thermalPressureState = thDict["thermalPressureState"] as? String ?? "Nominal" + m.isThrottling = (thDict["isThrottling"] as? NSNumber)?.boolValue ?? false + if let coresList = thDict["coreTemperatures"] as? [[String: Any]] { + m.coreTemperatures = coresList.compactMap { d in + let idx = (d["coreIndex"] as? NSNumber)?.intValue ?? 0 + let key = d["key"] as? String ?? "TC\(idx)C" + let temp = (d["temperature"] as? NSNumber)?.doubleValue ?? 0 + return (index: idx, key: key, temperature: temp) + } + } + self.cpuThermal = m + } + + // 5. Fans + if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"], + let fanArray = fanDict["fans"] as? [[String: Any]] { + self.fans = fanArray.compactMap { d in + let idx = (d["index"] as? NSNumber)?.intValue ?? 0 + return FanTelemetryItem( + index: idx, + name: d["name"] as? String ?? "Fan \(idx + 1)", + currentRPM: (d["currentRPM"] as? NSNumber)?.doubleValue ?? 0, + minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0, + maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0, + targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0, + utilization: (d["utilization"] as? NSNumber)?.doubleValue ?? 0 + ) + } + } + + // 6. Component Thermals + if let compDict = snapshot["com.i3omb.macmonitor.telemetry.thermal.components"], + let compArray = compDict["components"] as? [[String: Any]] { + self.componentTemps = compArray.compactMap { d in + guard let key = d["key"] as? String else { return nil } + return ComponentThermalItem( + key: key, + name: d["name"] as? String ?? key, + category: d["category"] as? String ?? "Other", + temperature: (d["temperature"] as? NSNumber)?.doubleValue ?? 0 + ) + } + } + + // 7. Power + if let pwrDict = snapshot["com.i3omb.macmonitor.telemetry.power"] { + var m = PowerMetrics() + m.systemTotalWatts = (pwrDict["systemTotalWatts"] as? NSNumber)?.doubleValue ?? 0 + m.cpuWatts = (pwrDict["cpuWatts"] as? NSNumber)?.doubleValue ?? 0 + m.gpuWatts = (pwrDict["gpuWatts"] as? NSNumber)?.doubleValue ?? 0 + m.memoryWatts = (pwrDict["memoryWatts"] as? NSNumber)?.doubleValue ?? 0 + m.cpuVoltage = (pwrDict["cpuVoltage"] as? NSNumber)?.doubleValue ?? 0 + m.cpuCurrent = (pwrDict["cpuCurrent"] as? NSNumber)?.doubleValue ?? 0 + m.powerSource = pwrDict["powerSource"] as? String ?? "AC Power" + m.isCharging = (pwrDict["isCharging"] as? NSNumber)?.boolValue ?? false + m.batteryLevel = (pwrDict["batteryLevel"] as? NSNumber)?.doubleValue ?? 100.0 + if let sensorList = pwrDict["sensors"] as? [[String: Any]] { + m.sensorReadings = sensorList.compactMap { d in + guard let k = d["key"] as? String else { return nil } + let n = d["name"] as? String ?? k + let v = (d["value"] as? NSNumber)?.doubleValue ?? 0 + let u = d["unit"] as? String ?? "" + return (key: k, name: n, value: v, unit: u) + } + } + self.power = m } } diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 621b3a6..e560719 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI public struct ContentView: View { @State private var store = SystemTelemetryStore.shared + @State private var selectedTab: String = "Dashboard" public init() {} @@ -11,59 +12,111 @@ public struct ContentView: View { } detail: { detailContent } - .frame(minWidth: 800, minHeight: 520) + .frame(minWidth: 900, minHeight: 650) .onAppear { store.start() } } + // MARK: - Sidebar private var sidebarContent: some View { - List { - Section("System Status") { + List(selection: $selectedTab) { + Section("Overview") { Label("Dashboard", systemImage: "gauge.with.needle") - Label("Architecture", systemImage: "cpu") + .tag("Dashboard") + Label("Hardware Architecture", systemImage: "cpu") + .tag("Architecture") } - Section("Telemetry Domains") { - Label("CPU & Thermals", systemImage: "flame") + Section("Primary Telemetry") { + Label("CPU Load & Cores", systemImage: "chart.bar.xaxis") + .tag("CPU") + Label("Thermal Matrix", systemImage: "flame") + .tag("Thermals") Label("Fans & Cooling", systemImage: "fanblades") + .tag("Fans") Label("Memory & Swap", systemImage: "memorychip") - Label("Disks & Volumes", systemImage: "internaldrive") - Label("Processes", systemImage: "list.bullet.rectangle") - Label("Network", systemImage: "network") - Label("Graphics (GPU)", systemImage: "display") + .tag("Memory") + Label("Storage Volumes", systemImage: "internaldrive") + .tag("Storage") + Label("Power & Voltage", systemImage: "bolt.fill") + .tag("Power") } } .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 280) + .navigationSplitViewColumnWidth(min: 200, ideal: 230, max: 280) } + // MARK: - Detail Content Router + @ViewBuilder private var detailContent: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - // Header Banner headerCard - - // Telemetry Engine Control engineControlCard - // System Specifications - specsCard - - // Providers Status - providersCard + switch selectedTab { + case "Dashboard": + dashboardView + case "Architecture": + specsCard + providersCard + case "CPU": + cpuLoadCard + specsCard + case "Thermals": + cpuThermalCard + componentThermalCard + case "Fans": + fansCard + case "Memory": + memoryCard + case "Storage": + storageCard + case "Power": + powerCard + default: + dashboardView + } } .padding(24) } - .navigationTitle("MacMonitor Dashboard") + .navigationTitle("MacMonitor โ€” \(selectedTab)") } + // MARK: - Dashboard Composite View + private var dashboardView: some View { + VStack(alignment: .leading, spacing: 20) { + // Top Row: CPU & Memory Summary Cards + HStack(alignment: .top, spacing: 16) { + cpuLoadCard + memoryCard + } + + // Middle Row: Thermals & Fans Summary Cards + HStack(alignment: .top, spacing: 16) { + cpuThermalCard + fansCard + } + + // Power & Storage Summary + HStack(alignment: .top, spacing: 16) { + powerCard + storageCard + } + + // Detailed Component Thermals Matrix + componentThermalCard + } + } + + // MARK: - Header Banner private var headerCard: some View { HStack(alignment: .center, spacing: 16) { Image(systemName: "macpro.gen3.fill") .resizable() .scaledToFit() - .frame(width: 48, height: 48) + .frame(width: 44, height: 44) .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 4) { @@ -80,7 +133,7 @@ public struct ContentView: View { Circle() .fill(store.isRunning ? Color.green : Color.red) .frame(width: 10, height: 10) - Text(store.isRunning ? "ENGINE ACTIVE" : "ENGINE PAUSED") + Text(store.isRunning ? "ACTIVE" : "PAUSED") .font(.caption.bold()) .foregroundStyle(store.isRunning ? .green : .secondary) } @@ -93,55 +146,307 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Telemetry Coordinator Controls private var engineControlCard: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Telemetry Coordinator Engine") - .font(.headline) + HStack(spacing: 16) { + Button(action: { + if store.isRunning { store.stop() } else { store.start() } + }) { + Label(store.isRunning ? "Pause Engine" : "Start Engine", + systemImage: store.isRunning ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderedProminent) + .tint(store.isRunning ? .orange : .green) - HStack(spacing: 16) { - Button(action: { - if store.isRunning { - store.stop() - } else { - store.start() - } - }) { - Label(store.isRunning ? "Pause Engine" : "Start Engine", - systemImage: store.isRunning ? "pause.fill" : "play.fill") - } - .buttonStyle(.borderedProminent) - .tint(store.isRunning ? .orange : .green) - - Button(action: { - store.triggerManualSample() - }) { - Label("Sample Now", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - + Button(action: { store.triggerManualSample() }) { + Label("Sample Now", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + + Spacer() + + Picker("Interval:", selection: Binding( + get: { store.sampleInterval }, + set: { store.setSampleInterval($0) } + )) { + Text("0.5s").tag(0.5) + Text("1.0s").tag(1.0) + Text("2.0s").tag(2.0) + Text("5.0s").tag(5.0) + } + .pickerStyle(.segmented) + .frame(width: 220) + + Text("Updated: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(14) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - CPU Load Card + private var cpuLoadCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("CPU Activity", systemImage: "cpu") + .font(.headline) Spacer() - - Picker("Polling Interval:", selection: Binding( - get: { store.sampleInterval }, - set: { store.setSampleInterval($0) } - )) { - Text("0.5s").tag(0.5) - Text("1.0s").tag(1.0) - Text("2.0s").tag(2.0) - Text("5.0s").tag(5.0) + if store.cpuLoad.isThrottled { + Text("THROTTLED") + .font(.caption.bold()) + .foregroundStyle(.red) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15), in: Capsule()) } - .pickerStyle(.segmented) - .frame(width: 220) + Text(String(format: "%.1f%%", store.cpuLoad.totalLoad)) + .font(.title3.bold().monospacedDigit()) } + ProgressView(value: min(store.cpuLoad.totalLoad / 100.0, 1.0)) + .tint(store.cpuLoad.totalLoad > 85 ? .red : (store.cpuLoad.totalLoad > 60 ? .orange : .blue)) + HStack { - Text("Last Sample Cycle: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))") - .font(.caption) - .foregroundStyle(.secondary) + Text("User: \(String(format: "%.1f%%", store.cpuLoad.userLoad))") Spacer() - Text("Queue: com.i3omb.macmonitor.telemetry (QOS_UTILITY)") + Text("System: \(String(format: "%.1f%%", store.cpuLoad.systemLoad))") + Spacer() + Text("Idle: \(String(format: "%.1f%%", store.cpuLoad.idleLoad))") + } + .font(.caption) + .foregroundStyle(.secondary) + + if !store.cpuLoad.perCoreLoad.isEmpty { + Divider() + Text("Per-Core Utilization (\(store.cpuLoad.perCoreLoad.count) Cores)") + .font(.caption.bold()) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 70))], spacing: 8) { + ForEach(Array(store.cpuLoad.perCoreLoad.enumerated()), id: \.offset) { idx, load in + VStack(spacing: 4) { + Text("Core \(idx)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + ProgressView(value: min(load / 100.0, 1.0)) + .tint(load > 85 ? .red : .blue) + Text(String(format: "%.0f%%", load)) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + } + } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Memory & Swap Card + private var memoryCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Memory & Swap", systemImage: "memorychip") + .font(.headline) + Spacer() + Text(store.memory.pressureLevel) + .font(.caption.bold()) + .foregroundStyle(pressureColor(store.memory.pressureLevel)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(pressureColor(store.memory.pressureLevel).opacity(0.15), in: Capsule()) + Text(String(format: "%.1f%%", store.memory.utilizationPercentage)) + .font(.title3.bold().monospacedDigit()) + } + + ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0)) + .tint(pressureColor(store.memory.pressureLevel)) + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("Physical RAM:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.totalBytes)).bold() + Text("Used:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.usedBytes)).bold() + } + GridRow { + Text("Wired:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.wiredBytes)) + Text("Compressed:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.compressedBytes)) + } + GridRow { + Text("Active:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.activeBytes)) + Text("Free / Inactive:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.freeBytes + store.memory.inactiveBytes)) + } + if store.memory.swapTotalBytes > 0 { + GridRow { + Text("Swap Total:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.swapTotalBytes)) + Text("Swap Used:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.swapUsedBytes)) + } + } + } + .font(.caption) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - CPU Thermal Card + private var cpuThermalCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("CPU Thermals", systemImage: "flame.fill") + .font(.headline) + Spacer() + Text(store.cpuThermal.thermalPressureState) + .font(.caption.bold()) + .foregroundStyle(store.cpuThermal.isThrottling ? .red : .green) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background((store.cpuThermal.isThrottling ? Color.red : Color.green).opacity(0.15), in: Capsule()) + Text(String(format: "%.1fยฐC", store.cpuThermal.packageTemperature)) + .font(.title3.bold().monospacedDigit()) + } + + HStack(spacing: 24) { + VStack(alignment: .leading, spacing: 2) { + Text("Package Temp").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.1fยฐC", store.cpuThermal.packageTemperature)).bold() + } + VStack(alignment: .leading, spacing: 2) { + Text("Peak Core").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.1fยฐC", store.cpuThermal.peakCoreTemperature)).bold() + } + VStack(alignment: .leading, spacing: 2) { + Text("Average Core").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.1fยฐC", store.cpuThermal.averageCoreTemperature)).bold() + } + } + + if !store.cpuThermal.coreTemperatures.isEmpty { + Divider() + Text("Core Temperature Breakdown") + .font(.caption.bold()) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: 8) { + ForEach(store.cpuThermal.coreTemperatures, id: \.key) { core in + VStack(spacing: 2) { + Text("Core \(core.index)") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Text(String(format: "%.1fยฐC", core.temperature)) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(core.temperature > 85 ? .red : (core.temperature > 70 ? .orange : .primary)) + } + .padding(6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Fans & Tachometers Card + private var fansCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Cooling Fans", systemImage: "fanblades.fill") + .font(.headline) + Spacer() + Text("\(store.fans.count) Detected") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + + if store.fans.isEmpty { + Text("No active AppleSMC fans detected or passive cooling chassis.") .font(.caption) .foregroundStyle(.secondary) + .padding(.vertical, 8) + } else { + ForEach(store.fans) { fan in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(fan.name).bold() + Spacer() + Text(String(format: "%.0f RPM (%.0f%%)", fan.currentRPM, fan.utilization)) + .font(.subheadline.bold().monospacedDigit()) + } + ProgressView(value: min(fan.utilization / 100.0, 1.0)) + .tint(fan.utilization > 80 ? .red : .blue) + HStack { + Text("Min: \(Int(fan.minRPM)) RPM") + Spacer() + Text("Target: \(Int(fan.targetRPM)) RPM") + Spacer() + Text("Max: \(Int(fan.maxRPM)) RPM") + } + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Component Thermals Matrix Card + private var componentThermalCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Chassis & Component Thermals", systemImage: "thermometer.medium") + .font(.headline) + Spacer() + Text("\(store.componentTemps.count) Sensors") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + + if store.componentTemps.isEmpty { + Text("Awaiting component thermal probe...") + .font(.caption) + .foregroundStyle(.secondary) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) { + ForEach(store.componentTemps) { item in + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + HStack { + Text(item.category) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1fยฐC", item.temperature)) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(item.temperature > 75 ? .red : .primary) + } + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + } } } .padding(16) @@ -149,6 +454,108 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Storage Volumes Card + private var storageCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Storage Volumes", systemImage: "internaldrive") + .font(.headline) + Spacer() + Text("\(store.storageVolumes.count) Mounted") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + + if store.storageVolumes.isEmpty { + Text("No mounted APFS/HFS volumes discovered.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(store.storageVolumes) { vol in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(vol.volumeName).bold() + Text("(\(vol.fileSystem))").font(.caption).foregroundStyle(.secondary) + Spacer() + Text("\(formatBytes(vol.usedBytes)) / \(formatBytes(vol.totalBytes))") + .font(.caption.monospacedDigit()) + } + ProgressView(value: min(vol.usedPercentage / 100.0, 1.0)) + .tint(vol.usedPercentage > 90 ? .red : .blue) + HStack { + Text("Mount: \(vol.mountPoint)").font(.system(size: 10)).foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1f%% Used", vol.usedPercentage)).font(.system(size: 10, weight: .bold)) + } + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Power & Voltage Card + private var powerCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Power & Voltage", systemImage: "bolt.fill") + .font(.headline) + Spacer() + HStack(spacing: 4) { + Image(systemName: store.power.powerSource.contains("Battery") ? "battery.75" : "powerplug.fill") + Text(store.power.powerSource) + } + .font(.caption.bold()) + .foregroundStyle(.secondary) + + Text(String(format: "%.1f W", store.power.systemTotalWatts)) + .font(.title3.bold().monospacedDigit()) + } + + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + Text("Total Draw:").foregroundStyle(.secondary) + Text(String(format: "%.1f W", store.power.systemTotalWatts)).bold() + Text("CPU Package:").foregroundStyle(.secondary) + Text(String(format: "%.1f W", store.power.cpuWatts)).bold() + } + GridRow { + Text("CPU Core Voltage:").foregroundStyle(.secondary) + Text(store.power.cpuVoltage > 0 ? String(format: "%.3f V", store.power.cpuVoltage) : "N/A") + Text("CPU Current:").foregroundStyle(.secondary) + Text(store.power.cpuCurrent > 0 ? String(format: "%.2f A", store.power.cpuCurrent) : "N/A") + } + if store.power.gpuWatts > 0 || store.power.memoryWatts > 0 { + GridRow { + Text("GPU Draw:").foregroundStyle(.secondary) + Text(String(format: "%.1f W", store.power.gpuWatts)) + Text("Memory Subsystem:").foregroundStyle(.secondary) + Text(String(format: "%.1f W", store.power.memoryWatts)) + } + } + if store.power.powerSource.contains("Battery") { + GridRow { + Text("Battery Level:").foregroundStyle(.secondary) + Text(String(format: "%.0f%% %@", store.power.batteryLevel, store.power.isCharging ? "(Charging)" : "")) + Text("Status:").foregroundStyle(.secondary) + Text(store.power.isCharging ? "Charging" : "Discharging") + } + } + } + .font(.caption) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Hardware Specs Card private var specsCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Host Architecture & Kernel") @@ -180,6 +587,7 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Providers Card private var providersCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -195,33 +603,38 @@ public struct ContentView: View { .clipShape(Capsule()) } - if store.latestSnapshot.isEmpty { - HStack(spacing: 12) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("Telemetry coordinator initialized. Awaiting provider metrics.") - .font(.subheadline) + ForEach(Array(store.latestSnapshot.keys.sorted()), id: \.self) { key in + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(key) + .font(.system(.body, design: .monospaced)) + Spacer() + Text("\(store.latestSnapshot[key]?.count ?? 0) metrics") + .font(.caption) .foregroundStyle(.secondary) } - .padding(.vertical, 8) - } else { - ForEach(Array(store.latestSnapshot.keys.sorted()), id: \.self) { key in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(key) - .font(.system(.body, design: .monospaced)) - Spacer() - Text("\(store.latestSnapshot[key]?.count ?? 0) metrics") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } + .padding(.vertical, 4) } } .padding(16) .background(.background, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + + // MARK: - Formatting Helpers + private func formatBytes(_ bytes: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func pressureColor(_ level: String) -> Color { + switch level { + case "Critical": return .red + case "Warning": return .orange + default: return .green + } + } } diff --git a/Tests/MacMonitorTests.swift b/Tests/MacMonitorTests.swift index b96ade9..b90890f 100644 --- a/Tests/MacMonitorTests.swift +++ b/Tests/MacMonitorTests.swift @@ -23,4 +23,15 @@ final class MacMonitorCoreTests: XCTestCase { let thermalStr = MMTelemetryDomainToString(.thermal) XCTAssertEqual(thermalStr, "Thermal") } + + @MainActor + func testSystemTelemetryStoreInitialStateAndProviders() { + let store = SystemTelemetryStore.shared + XCTAssertNotNil(store) + XCTAssertEqual(store.registeredProviderCount, 7) + XCTAssertFalse(store.hostModel.isEmpty) + XCTAssertFalse(store.kernelVersion.isEmpty) + XCTAssertGreaterThanOrEqual(store.physicalCpuCount, 1) + XCTAssertGreaterThanOrEqual(store.logicalCpuCount, 1) + } } -- 2.39.5 From d24fa7de3566f1058200df1a35c392dcc7ed6b17 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:32:18 +0100 Subject: [PATCH 16/39] feat(telemetry): implement kernel context switching and event counter provider (fixes #6) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Kernel/MMKernelTelemetryProvider.h | 30 ++++ .../Kernel/MMKernelTelemetryProvider.m | 131 ++++++++++++++++++ Tests/MMKernelTelemetryTests.swift | 70 ++++++++++ 4 files changed, 232 insertions(+) create mode 100644 Sources/Telemetry/Kernel/MMKernelTelemetryProvider.h create mode 100644 Sources/Telemetry/Kernel/MMKernelTelemetryProvider.m create mode 100644 Tests/MMKernelTelemetryTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 66be716..0c924b0 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -19,5 +19,6 @@ #import "MMFanTelemetryProvider.h" #import "MMComponentThermalProvider.h" #import "MMPowerTelemetryProvider.h" +#import "MMKernelTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.h b/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.h new file mode 100644 index 0000000..dc4982a --- /dev/null +++ b/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.h @@ -0,0 +1,30 @@ +#ifndef MMKernelTelemetryProvider_h +#define MMKernelTelemetryProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMKernelTelemetryProvider + * Samples low-level Mach kernel performance counters: context switches, system calls, + * page faults (soft, COW, zero-fill), pageins/pageouts, and computes instantaneous rates per second. + */ +@interface MMKernelTelemetryProvider : NSObject + +- (instancetype)init; + +/** + * Static calculator method for deterministic unit testing. + * Computes event rates per second based on previous and current cumulative counters over delta time. + */ ++ (NSDictionary *)calculateRatesWithCurrentCounters:(NSDictionary *)current + previousCounters:(nullable NSDictionary *)previous + timeDelta:(NSTimeInterval)timeDelta; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMKernelTelemetryProvider_h */ diff --git a/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.m b/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.m new file mode 100644 index 0000000..05984d3 --- /dev/null +++ b/Sources/Telemetry/Kernel/MMKernelTelemetryProvider.m @@ -0,0 +1,131 @@ +#import "MMKernelTelemetryProvider.h" +#import +#import +#import +#import + +@interface MMKernelTelemetryProvider () { + os_unfair_lock _lock; + NSDictionary *_previousCounters; + uint64_t _previousTimestamp; + mach_timebase_info_data_t _timebase; +} +@end + +@implementation MMKernelTelemetryProvider + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _previousCounters = nil; + _previousTimestamp = 0; + mach_timebase_info(&_timebase); + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainSystem; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.kernel.counters"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSDictionary *)calculateRatesWithCurrentCounters:(NSDictionary *)current + previousCounters:(nullable NSDictionary *)previous + timeDelta:(NSTimeInterval)timeDelta { + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + + // Copy all current cumulative counters into result + [current enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSNumber *val, BOOL *stop) { + result[[@"cumulative_" stringByAppendingString:key]] = val; + }]; + + NSArray *rateKeys = @[ + @"contextSwitches", @"syscalls", @"pageFaults", @"cowFaults", + @"zeroFills", @"pageins", @"pageouts", @"decompressions", @"compressions" + ]; + + for (NSString *key in rateKeys) { + double currentVal = [current[key] doubleValue]; + double prevVal = previous ? [previous[key] doubleValue] : currentVal; + double delta = (currentVal >= prevVal) ? (currentVal - prevVal) : 0.0; + double rate = (timeDelta > 0.0001) ? (delta / timeDelta) : 0.0; + result[[key stringByAppendingString:@"Rate"]] = @(rate); + } + + result[@"timeDelta"] = @(timeDelta); + return result; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + uint64_t now = mach_absolute_time(); + + // 1. Query Mach VM Statistics for page faults, COW, zero fills, pageins/outs + vm_statistics64_data_t vmStats; + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + kern_return_t kr = host_statistics64(mach_host_self(), HOST_VM_INFO64, (host_info64_t)&vmStats, &count); + if (kr != KERN_SUCCESS) { + if (error) { + *error = [NSError errorWithDomain:@"com.i3omb.macmonitor.kernel" + code:kr + userInfo:@{NSLocalizedDescriptionKey: @"Failed to query host_statistics64"}]; + } + return nil; + } + + // 2. Query process table for cumulative context switches and system calls + int pids[4096]; + int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids)); + int pidCount = bytes / sizeof(int); + + uint64_t totalCSW = 0; + uint64_t totalSyscalls = 0; + + for (int i = 0; i < pidCount; i++) { + if (pids[i] <= 0) continue; + struct proc_taskinfo ti; + if (proc_pidinfo(pids[i], PROC_PIDTASKINFO, 0, &ti, sizeof(ti)) == sizeof(ti)) { + totalCSW += ti.pti_csw; + totalSyscalls += (uint64_t)ti.pti_syscalls_unix + (uint64_t)ti.pti_syscalls_mach; + } + } + + NSDictionary *currentCounters = @{ + @"contextSwitches": @(totalCSW), + @"syscalls": @(totalSyscalls), + @"pageFaults": @(vmStats.faults), + @"cowFaults": @(vmStats.cow_faults), + @"zeroFills": @(vmStats.zero_fill_count), + @"pageins": @(vmStats.pageins), + @"pageouts": @(vmStats.pageouts), + @"decompressions": @(vmStats.decompressions), + @"compressions": @(vmStats.compressions) + }; + + os_unfair_lock_lock(&_lock); + NSDictionary *prev = _previousCounters; + uint64_t prevTime = _previousTimestamp; + + _previousCounters = currentCounters; + _previousTimestamp = now; + os_unfair_lock_unlock(&_lock); + + NSTimeInterval timeDelta = 1.0; + if (prevTime > 0) { + uint64_t elapsed = now - prevTime; + timeDelta = (double)elapsed * _timebase.numer / _timebase.denom / 1e9; + } + + return [MMKernelTelemetryProvider calculateRatesWithCurrentCounters:currentCounters + previousCounters:prev + timeDelta:timeDelta]; +} + +@end diff --git a/Tests/MMKernelTelemetryTests.swift b/Tests/MMKernelTelemetryTests.swift new file mode 100644 index 0000000..26833ec --- /dev/null +++ b/Tests/MMKernelTelemetryTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import MacMonitor + +final class MMKernelTelemetryTests: XCTestCase { + + func testKernelRatesCalculation() { + let prev: [String: NSNumber] = [ + "contextSwitches": NSNumber(value: 100_000), + "syscalls": NSNumber(value: 200_000), + "pageFaults": NSNumber(value: 50_000), + "cowFaults": NSNumber(value: 1_000), + "zeroFills": NSNumber(value: 10_000), + "pageins": NSNumber(value: 500), + "pageouts": NSNumber(value: 100), + "decompressions": NSNumber(value: 200), + "compressions": NSNumber(value: 300) + ] + + let curr: [String: NSNumber] = [ + "contextSwitches": NSNumber(value: 102_500), // delta = 2,500 + "syscalls": NSNumber(value: 210_000), // delta = 10,000 + "pageFaults": NSNumber(value: 51_000), // delta = 1,000 + "cowFaults": NSNumber(value: 1_050), // delta = 50 + "zeroFills": NSNumber(value: 10_200), // delta = 200 + "pageins": NSNumber(value: 520), // delta = 20 + "pageouts": NSNumber(value: 105), // delta = 5 + "decompressions": NSNumber(value: 210), // delta = 10 + "compressions": NSNumber(value: 315) // delta = 15 + ] + + let timeDelta: TimeInterval = 2.0 // 2 seconds + + let rates = MMKernelTelemetryProvider.calculateRates( + withCurrentCounters: curr, + previousCounters: prev, + timeDelta: timeDelta + ) + + let cswRate = rates["contextSwitchesRate"] as? Double ?? 0 + let syscallRate = rates["syscallsRate"] as? Double ?? 0 + let faultRate = rates["pageFaultsRate"] as? Double ?? 0 + let cowRate = rates["cowFaultsRate"] as? Double ?? 0 + let zeroFillRate = rates["zeroFillsRate"] as? Double ?? 0 + let pageinsRate = rates["pageinsRate"] as? Double ?? 0 + + XCTAssertEqual(cswRate, 1250.0, accuracy: 0.1) + XCTAssertEqual(syscallRate, 5000.0, accuracy: 0.1) + XCTAssertEqual(faultRate, 500.0, accuracy: 0.1) + XCTAssertEqual(cowRate, 25.0, accuracy: 0.1) + XCTAssertEqual(zeroFillRate, 100.0, accuracy: 0.1) + XCTAssertEqual(pageinsRate, 10.0, accuracy: 0.1) + + let cumCSW = rates["cumulative_contextSwitches"] as? UInt64 ?? 0 + XCTAssertEqual(cumCSW, 102_500) + } + + func testLiveKernelProvider() throws { + let provider = MMKernelTelemetryProvider() + XCTAssertEqual(provider.domain, .system) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.kernel.counters") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["cumulative_contextSwitches"]) + XCTAssertNotNil(sample["cumulative_syscalls"]) + XCTAssertNotNil(sample["cumulative_pageFaults"]) + XCTAssertNotNil(sample["contextSwitchesRate"]) + XCTAssertNotNil(sample["syscallsRate"]) + } +} -- 2.39.5 From 66401bec2891363f6ad77b6ee62640bb70e3af2c Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:33:14 +0100 Subject: [PATCH 17/39] feat(telemetry): implement load average and thread concurrency provider (fixes #7) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Telemetry/System/MMLoadAverageProvider.h | 33 +++++++ .../Telemetry/System/MMLoadAverageProvider.m | 92 +++++++++++++++++++ Tests/MMLoadAverageTests.swift | 62 +++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 Sources/Telemetry/System/MMLoadAverageProvider.h create mode 100644 Sources/Telemetry/System/MMLoadAverageProvider.m create mode 100644 Tests/MMLoadAverageTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 0c924b0..2d69169 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -20,5 +20,6 @@ #import "MMComponentThermalProvider.h" #import "MMPowerTelemetryProvider.h" #import "MMKernelTelemetryProvider.h" +#import "MMLoadAverageProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/System/MMLoadAverageProvider.h b/Sources/Telemetry/System/MMLoadAverageProvider.h new file mode 100644 index 0000000..faf8caa --- /dev/null +++ b/Sources/Telemetry/System/MMLoadAverageProvider.h @@ -0,0 +1,33 @@ +#ifndef MMLoadAverageProvider_h +#define MMLoadAverageProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMLoadAverageProvider + * Samples 1m, 5m, and 15m system load averages, Mach concurrency factors, + * and system-wide task and thread counts. + */ +@interface MMLoadAverageProvider : NSObject + +- (instancetype)init; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateLoadMetricsWithLoad1:(double)load1 + load5:(double)load5 + load15:(double)load15 + taskCount:(int)taskCount + threadCount:(int)threadCount + cpuCount:(int)cpuCount + machFactor:(double)machFactor; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMLoadAverageProvider_h */ diff --git a/Sources/Telemetry/System/MMLoadAverageProvider.m b/Sources/Telemetry/System/MMLoadAverageProvider.m new file mode 100644 index 0000000..f57d9ed --- /dev/null +++ b/Sources/Telemetry/System/MMLoadAverageProvider.m @@ -0,0 +1,92 @@ +#import "MMLoadAverageProvider.h" +#import +#import +#import +#import + +@implementation MMLoadAverageProvider + +- (instancetype)init { + self = [super init]; + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainSystem; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.system.load"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSDictionary *)calculateLoadMetricsWithLoad1:(double)load1 + load5:(double)load5 + load15:(double)load15 + taskCount:(int)taskCount + threadCount:(int)threadCount + cpuCount:(int)cpuCount + machFactor:(double)machFactor { + int safeCpus = cpuCount > 0 ? cpuCount : 1; + double norm1 = load1 / (double)safeCpus; + double norm5 = load5 / (double)safeCpus; + double norm15 = load15 / (double)safeCpus; + + return @{ + @"load1m": @(load1), + @"load5m": @(load5), + @"load15m": @(load15), + @"normalizedLoad1m": @(norm1), + @"normalizedLoad5m": @(norm5), + @"normalizedLoad15m": @(norm15), + @"taskCount": @(taskCount), + @"threadCount": @(threadCount), + @"logicalCpuCount": @(safeCpus), + @"machFactor": @(machFactor), + @"isOverloaded": @(norm1 > 1.0) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + // 1. Get BSD load averages + double load[3] = {0.0, 0.0, 0.0}; + int ret = getloadavg(load, 3); + if (ret <= 0) { + load[0] = 0.0; + load[1] = 0.0; + load[2] = 0.0; + } + + // 2. Query Mach processor set for task & thread counts and mach factor + mach_port_t host = mach_host_self(); + processor_set_name_port_t pset = MACH_PORT_NULL; + processor_set_load_info_data_t loadInfo = {0}; + mach_msg_type_number_t count = PROCESSOR_SET_LOAD_INFO_COUNT; + + kern_return_t kr = processor_set_default(host, &pset); + if (kr == KERN_SUCCESS && MACH_PORT_VALID(pset)) { + kr = processor_set_statistics(pset, PROCESSOR_SET_LOAD_INFO, (processor_set_info_t)&loadInfo, &count); + mach_port_deallocate(mach_task_self(), pset); + } + + double machFactor = (double)loadInfo.mach_factor / (double)LOAD_SCALE; + + // 3. Query logical CPU core count + int cpuCount = 1; + size_t size = sizeof(cpuCount); + sysctlbyname("hw.logicalcpu", &cpuCount, &size, NULL, 0); + if (cpuCount <= 0) cpuCount = 1; + + return [MMLoadAverageProvider calculateLoadMetricsWithLoad1:load[0] + load5:load[1] + load15:load[2] + taskCount:loadInfo.task_count + threadCount:loadInfo.thread_count + cpuCount:cpuCount + machFactor:machFactor]; +} + +@end diff --git a/Tests/MMLoadAverageTests.swift b/Tests/MMLoadAverageTests.swift new file mode 100644 index 0000000..a243687 --- /dev/null +++ b/Tests/MMLoadAverageTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import MacMonitor + +final class MMLoadAverageTests: XCTestCase { + + func testLoadMetricsCalculation() { + let metrics = MMLoadAverageProvider.calculateLoadMetrics( + withLoad1: 4.0, + load5: 3.5, + load15: 2.5, + taskCount: 500, + threadCount: 2200, + cpuCount: 8, + machFactor: 1.25 + ) + + let load1 = metrics["load1m"] as? Double ?? 0 + let norm1 = metrics["normalizedLoad1m"] as? Double ?? 0 + let norm5 = metrics["normalizedLoad5m"] as? Double ?? 0 + let taskCount = metrics["taskCount"] as? Int ?? 0 + let threadCount = metrics["threadCount"] as? Int ?? 0 + let isOverloaded = metrics["isOverloaded"] as? Bool ?? true + + XCTAssertEqual(load1, 4.0, accuracy: 0.01) + XCTAssertEqual(norm1, 4.0 / 8.0, accuracy: 0.01) + XCTAssertEqual(norm5, 3.5 / 8.0, accuracy: 0.01) + XCTAssertEqual(taskCount, 500) + XCTAssertEqual(threadCount, 2200) + XCTAssertFalse(isOverloaded) + } + + func testOverloadedStateDetection() { + let metrics = MMLoadAverageProvider.calculateLoadMetrics( + withLoad1: 12.0, + load5: 10.0, + load15: 8.0, + taskCount: 650, + threadCount: 3100, + cpuCount: 8, + machFactor: 0.4 + ) + + let isOverloaded = metrics["isOverloaded"] as? Bool ?? false + let norm1 = metrics["normalizedLoad1m"] as? Double ?? 0 + + XCTAssertTrue(isOverloaded) + XCTAssertEqual(norm1, 1.5, accuracy: 0.01) + } + + func testLiveLoadAverageProvider() throws { + let provider = MMLoadAverageProvider() + XCTAssertEqual(provider.domain, .system) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.system.load") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["load1m"]) + XCTAssertNotNil(sample["taskCount"]) + XCTAssertNotNil(sample["threadCount"]) + XCTAssertNotNil(sample["normalizedLoad1m"]) + } +} -- 2.39.5 From f59ee440afbc3f4230a52ed8a4dbc407ecd78bdf Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:34:25 +0100 Subject: [PATCH 18/39] feat(telemetry): implement real-time disk I/O throughput and IOPS provider (fixes #10) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + Sources/Telemetry/Storage/MMDiskIOProvider.h | 29 +++ Sources/Telemetry/Storage/MMDiskIOProvider.m | 170 ++++++++++++++++++ Tests/MMDiskIOTests.swift | 73 ++++++++ 4 files changed, 273 insertions(+) create mode 100644 Sources/Telemetry/Storage/MMDiskIOProvider.h create mode 100644 Sources/Telemetry/Storage/MMDiskIOProvider.m create mode 100644 Tests/MMDiskIOTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 2d69169..6814b7c 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -21,5 +21,6 @@ #import "MMPowerTelemetryProvider.h" #import "MMKernelTelemetryProvider.h" #import "MMLoadAverageProvider.h" +#import "MMDiskIOProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Storage/MMDiskIOProvider.h b/Sources/Telemetry/Storage/MMDiskIOProvider.h new file mode 100644 index 0000000..fa9c57f --- /dev/null +++ b/Sources/Telemetry/Storage/MMDiskIOProvider.h @@ -0,0 +1,29 @@ +#ifndef MMDiskIOProvider_h +#define MMDiskIOProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMDiskIOProvider + * Samples real-time storage disk transfer rates (read/write bytes per second) + * and transaction rates (read/write IOPS) across internal and external block storage devices. + */ +@interface MMDiskIOProvider : NSObject + +- (instancetype)init; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateDiskIOMetricsWithCurrentSnapshots:(NSDictionary *> *)current + previousSnapshots:(nullable NSDictionary *> *)previous + timeDelta:(NSTimeInterval)timeDelta; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMDiskIOProvider_h */ diff --git a/Sources/Telemetry/Storage/MMDiskIOProvider.m b/Sources/Telemetry/Storage/MMDiskIOProvider.m new file mode 100644 index 0000000..afc81f5 --- /dev/null +++ b/Sources/Telemetry/Storage/MMDiskIOProvider.m @@ -0,0 +1,170 @@ +#import "MMDiskIOProvider.h" +#import +#import +#import +#import + +@interface MMDiskIOProvider () { + os_unfair_lock _lock; + NSDictionary *> *_previousSnapshots; + uint64_t _previousTimestamp; + mach_timebase_info_data_t _timebase; +} +@end + +@implementation MMDiskIOProvider + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _previousSnapshots = nil; + _previousTimestamp = 0; + mach_timebase_info(&_timebase); + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainStorage; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.storage.io"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSDictionary *)calculateDiskIOMetricsWithCurrentSnapshots:(NSDictionary *> *)current + previousSnapshots:(nullable NSDictionary *> *)previous + timeDelta:(NSTimeInterval)timeDelta { + double totalReadBps = 0.0; + double totalWriteBps = 0.0; + double totalReadIOPS = 0.0; + double totalWriteIOPS = 0.0; + + NSMutableArray *> *disks = [NSMutableArray arrayWithCapacity:current.count]; + NSArray *sortedBsdNames = [current.allKeys sortedArrayUsingSelector:@selector(compare:)]; + + for (NSString *bsdName in sortedBsdNames) { + NSDictionary *curr = current[bsdName]; + NSDictionary *prev = previous ? previous[bsdName] : nil; + + uint64_t currReadBytes = [curr[@"readBytes"] unsignedLongLongValue]; + uint64_t currWriteBytes = [curr[@"writeBytes"] unsignedLongLongValue]; + uint64_t currReadOps = [curr[@"readOps"] unsignedLongLongValue]; + uint64_t currWriteOps = [curr[@"writeOps"] unsignedLongLongValue]; + + uint64_t prevReadBytes = prev ? [prev[@"readBytes"] unsignedLongLongValue] : currReadBytes; + uint64_t prevWriteBytes = prev ? [prev[@"writeBytes"] unsignedLongLongValue] : currWriteBytes; + uint64_t prevReadOps = prev ? [prev[@"readOps"] unsignedLongLongValue] : currReadOps; + uint64_t prevWriteOps = prev ? [prev[@"writeOps"] unsignedLongLongValue] : currWriteOps; + + double deltaReadBytes = (currReadBytes >= prevReadBytes) ? (double)(currReadBytes - prevReadBytes) : 0.0; + double deltaWriteBytes = (currWriteBytes >= prevWriteBytes) ? (double)(currWriteBytes - prevWriteBytes) : 0.0; + double deltaReadOps = (currReadOps >= prevReadOps) ? (double)(currReadOps - prevReadOps) : 0.0; + double deltaWriteOps = (currWriteOps >= prevWriteOps) ? (double)(currWriteOps - prevWriteOps) : 0.0; + + double readBps = (timeDelta > 0.0001) ? (deltaReadBytes / timeDelta) : 0.0; + double writeBps = (timeDelta > 0.0001) ? (deltaWriteBytes / timeDelta) : 0.0; + double readIOPS = (timeDelta > 0.0001) ? (deltaReadOps / timeDelta) : 0.0; + double writeIOPS = (timeDelta > 0.0001) ? (deltaWriteOps / timeDelta) : 0.0; + + totalReadBps += readBps; + totalWriteBps += writeBps; + totalReadIOPS += readIOPS; + totalWriteIOPS += writeIOPS; + + [disks addObject:@{ + @"bsdName": bsdName, + @"readBytesPerSec": @(readBps), + @"writeBytesPerSec": @(writeBps), + @"readIOPS": @(readIOPS), + @"writeIOPS": @(writeIOPS), + @"cumulativeReadBytes": @(currReadBytes), + @"cumulativeWriteBytes": @(currWriteBytes), + @"cumulativeReadOps": @(currReadOps), + @"cumulativeWriteOps": @(currWriteOps) + }]; + } + + return @{ + @"disks": disks, + @"totalReadBytesPerSec": @(totalReadBps), + @"totalWriteBytesPerSec": @(totalWriteBps), + @"totalReadIOPS": @(totalReadIOPS), + @"totalWriteIOPS": @(totalWriteIOPS), + @"timeDelta": @(timeDelta) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + uint64_t now = mach_absolute_time(); + + NSMutableDictionary *> *currentSnapshots = [NSMutableDictionary dictionary]; + + CFMutableDictionaryRef matching = IOServiceMatching(kIOBlockStorageDriverClass); + io_iterator_t iterator = IO_OBJECT_NULL; + if (IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS && iterator != IO_OBJECT_NULL) { + io_registry_entry_t entry; + while ((entry = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + NSString *bsdName = nil; + io_iterator_t childIterator; + if (IORegistryEntryGetChildIterator(entry, kIOServicePlane, &childIterator) == KERN_SUCCESS) { + io_registry_entry_t child; + while ((child = IOIteratorNext(childIterator)) != IO_OBJECT_NULL) { + CFTypeRef nameRef = IORegistryEntryCreateCFProperty(child, CFSTR("BSD Name"), kCFAllocatorDefault, 0); + if (nameRef) { + bsdName = CFBridgingRelease(nameRef); + IOObjectRelease(child); + break; + } + IOObjectRelease(child); + } + IOObjectRelease(childIterator); + } + + if (bsdName) { + CFTypeRef statsRef = IORegistryEntryCreateCFProperty(entry, CFSTR("Statistics"), kCFAllocatorDefault, 0); + if (statsRef) { + NSDictionary *stats = CFBridgingRelease(statsRef); + NSNumber *readBytes = stats[@"Bytes (Read)"] ?: @0; + NSNumber *writeBytes = stats[@"Bytes (Write)"] ?: @0; + NSNumber *readOps = stats[@"Operations (Read)"] ?: @0; + NSNumber *writeOps = stats[@"Operations (Write)"] ?: @0; + + currentSnapshots[bsdName] = @{ + @"readBytes": readBytes, + @"writeBytes": writeBytes, + @"readOps": readOps, + @"writeOps": writeOps + }; + } + } + IOObjectRelease(entry); + } + IOObjectRelease(iterator); + } + + os_unfair_lock_lock(&_lock); + NSDictionary *> *prev = _previousSnapshots; + uint64_t prevTime = _previousTimestamp; + + _previousSnapshots = currentSnapshots; + _previousTimestamp = now; + os_unfair_lock_unlock(&_lock); + + NSTimeInterval timeDelta = 1.0; + if (prevTime > 0) { + uint64_t elapsed = now - prevTime; + timeDelta = (double)elapsed * _timebase.numer / _timebase.denom / 1e9; + } + + return [MMDiskIOProvider calculateDiskIOMetricsWithCurrentSnapshots:currentSnapshots + previousSnapshots:prev + timeDelta:timeDelta]; +} + +@end diff --git a/Tests/MMDiskIOTests.swift b/Tests/MMDiskIOTests.swift new file mode 100644 index 0000000..19de1d9 --- /dev/null +++ b/Tests/MMDiskIOTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import MacMonitor + +final class MMDiskIOTests: XCTestCase { + + func testDiskIOMetricsCalculation() { + let prev: [String: [String: NSNumber]] = [ + "disk0": [ + "readBytes": NSNumber(value: 10_000_000), + "writeBytes": NSNumber(value: 5_000_000), + "readOps": NSNumber(value: 1_000), + "writeOps": NSNumber(value: 500) + ], + "disk1": [ + "readBytes": NSNumber(value: 2_000_000), + "writeBytes": NSNumber(value: 1_000_000), + "readOps": NSNumber(value: 200), + "writeOps": NSNumber(value: 100) + ] + ] + + let curr: [String: [String: NSNumber]] = [ + "disk0": [ + "readBytes": NSNumber(value: 20_000_000), // delta = 10,000,000 bytes + "writeBytes": NSNumber(value: 7_000_000), // delta = 2,000,000 bytes + "readOps": NSNumber(value: 1_200), // delta = 200 ops + "writeOps": NSNumber(value: 600) // delta = 100 ops + ], + "disk1": [ + "readBytes": NSNumber(value: 4_000_000), // delta = 2,000,000 bytes + "writeBytes": NSNumber(value: 1_000_000), // delta = 0 + "readOps": NSNumber(value: 250), // delta = 50 ops + "writeOps": NSNumber(value: 100) // delta = 0 ops + ] + ] + + let timeDelta: TimeInterval = 2.0 // 2 seconds + + let metrics = MMDiskIOProvider.calculateDiskIOMetrics( + withCurrentSnapshots: curr, + previousSnapshots: prev, + timeDelta: timeDelta + ) + + let totalReadBps = metrics["totalReadBytesPerSec"] as? Double ?? 0 + let totalWriteBps = metrics["totalWriteBytesPerSec"] as? Double ?? 0 + let totalReadIOPS = metrics["totalReadIOPS"] as? Double ?? 0 + let totalWriteIOPS = metrics["totalWriteIOPS"] as? Double ?? 0 + let disks = metrics["disks"] as? [[String: Any]] ?? [] + + // (10MB + 2MB) / 2s = 6,000,000 B/s + XCTAssertEqual(totalReadBps, 6_000_000.0, accuracy: 1.0) + // (2MB + 0) / 2s = 1,000,000 B/s + XCTAssertEqual(totalWriteBps, 1_000_000.0, accuracy: 1.0) + // (200 + 50) / 2s = 125 IOPS + XCTAssertEqual(totalReadIOPS, 125.0, accuracy: 0.1) + // (100 + 0) / 2s = 50 IOPS + XCTAssertEqual(totalWriteIOPS, 50.0, accuracy: 0.1) + XCTAssertEqual(disks.count, 2) + } + + func testLiveDiskIOProvider() throws { + let provider = MMDiskIOProvider() + XCTAssertEqual(provider.domain, .storage) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.storage.io") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["totalReadBytesPerSec"]) + XCTAssertNotNil(sample["totalWriteBytesPerSec"]) + XCTAssertNotNil(sample["disks"]) + } +} -- 2.39.5 From 0aaa9d011043df9eb611b17736a6cf506baa33b3 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:35:48 +0100 Subject: [PATCH 19/39] feat(telemetry): implement real-time network bandwidth and interface throughput provider (fixes #15) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Network/MMNetworkBandwidthProvider.h | 30 +++ .../Network/MMNetworkBandwidthProvider.m | 211 ++++++++++++++++++ Tests/MMNetworkBandwidthTests.swift | 85 +++++++ 4 files changed, 327 insertions(+) create mode 100644 Sources/Telemetry/Network/MMNetworkBandwidthProvider.h create mode 100644 Sources/Telemetry/Network/MMNetworkBandwidthProvider.m create mode 100644 Tests/MMNetworkBandwidthTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 6814b7c..70818b0 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -22,5 +22,6 @@ #import "MMKernelTelemetryProvider.h" #import "MMLoadAverageProvider.h" #import "MMDiskIOProvider.h" +#import "MMNetworkBandwidthProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h new file mode 100644 index 0000000..d91e8fb --- /dev/null +++ b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h @@ -0,0 +1,30 @@ +#ifndef MMNetworkBandwidthProvider_h +#define MMNetworkBandwidthProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMNetworkBandwidthProvider + * Samples real-time network download and upload throughput, packets/sec, and error counts + * across physical (Wi-Fi, Ethernet) and virtual (VPN, bridge) network interfaces. + */ +@interface MMNetworkBandwidthProvider : NSObject + +- (instancetype)init; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary *> *)current + previousSnapshots:(nullable NSDictionary *> *)previous + timeDelta:(NSTimeInterval)timeDelta + ipAddresses:(nullable NSDictionary *)ipAddresses; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMNetworkBandwidthProvider_h */ diff --git a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m new file mode 100644 index 0000000..582cb36 --- /dev/null +++ b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m @@ -0,0 +1,211 @@ +#import "MMNetworkBandwidthProvider.h" +#import +#import +#import +#import +#import +#import +#import +#import + +@interface MMNetworkBandwidthProvider () { + os_unfair_lock _lock; + NSDictionary *> *_previousSnapshots; + uint64_t _previousTimestamp; + mach_timebase_info_data_t _timebase; +} +@end + +@implementation MMNetworkBandwidthProvider + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _previousSnapshots = nil; + _previousTimestamp = 0; + mach_timebase_info(&_timebase); + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainNetwork; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.network.bandwidth"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSDictionary *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary *> *)current + previousSnapshots:(nullable NSDictionary *> *)previous + timeDelta:(NSTimeInterval)timeDelta + ipAddresses:(nullable NSDictionary *)ipAddresses { + double totalDownBps = 0.0; + double totalUpBps = 0.0; + double totalDownPps = 0.0; + double totalUpPps = 0.0; + + NSMutableArray *> *interfaces = [NSMutableArray arrayWithCapacity:current.count]; + NSArray *sortedNames = [current.allKeys sortedArrayUsingSelector:@selector(compare:)]; + + NSString *primaryInterface = @""; + double maxActivity = -1.0; + + for (NSString *name in sortedNames) { + // Skip loopback for aggregate statistics + BOOL isLoopback = [name hasPrefix:@"lo"]; + + NSDictionary *curr = current[name]; + NSDictionary *prev = previous ? previous[name] : nil; + + uint64_t currInBytes = [curr[@"inBytes"] unsignedLongLongValue]; + uint64_t currOutBytes = [curr[@"outBytes"] unsignedLongLongValue]; + uint64_t currInPackets = [curr[@"inPackets"] unsignedLongLongValue]; + uint64_t currOutPackets = [curr[@"outPackets"] unsignedLongLongValue]; + uint64_t inErrors = [curr[@"inErrors"] unsignedLongLongValue]; + uint64_t outErrors = [curr[@"outErrors"] unsignedLongLongValue]; + + uint64_t prevInBytes = prev ? [prev[@"inBytes"] unsignedLongLongValue] : currInBytes; + uint64_t prevOutBytes = prev ? [prev[@"outBytes"] unsignedLongLongValue] : currOutBytes; + uint64_t prevInPackets = prev ? [prev[@"inPackets"] unsignedLongLongValue] : currInPackets; + uint64_t prevOutPackets = prev ? [prev[@"outPackets"] unsignedLongLongValue] : currOutPackets; + + double deltaInBytes = (currInBytes >= prevInBytes) ? (double)(currInBytes - prevInBytes) : 0.0; + double deltaOutBytes = (currOutBytes >= prevOutBytes) ? (double)(currOutBytes - prevOutBytes) : 0.0; + double deltaInPackets = (currInPackets >= prevInPackets) ? (double)(currInPackets - prevInPackets) : 0.0; + double deltaOutPackets = (currOutPackets >= prevOutPackets) ? (double)(currOutPackets - prevOutPackets) : 0.0; + + double downBps = (timeDelta > 0.0001) ? (deltaInBytes / timeDelta) : 0.0; + double upBps = (timeDelta > 0.0001) ? (deltaOutBytes / timeDelta) : 0.0; + double downPps = (timeDelta > 0.0001) ? (deltaInPackets / timeDelta) : 0.0; + double upPps = (timeDelta > 0.0001) ? (deltaOutPackets / timeDelta) : 0.0; + + if (!isLoopback) { + totalDownBps += downBps; + totalUpBps += upBps; + totalDownPps += downPps; + totalUpPps += upPps; + + double activity = downBps + upBps; + if (activity > maxActivity) { + maxActivity = activity; + primaryInterface = name; + } + } + + NSString *ip = ipAddresses[name] ?: @""; + + [interfaces addObject:@{ + @"name": name, + @"downloadBytesPerSec": @(downBps), + @"uploadBytesPerSec": @(upBps), + @"downloadPacketsPerSec": @(downPps), + @"uploadPacketsPerSec": @(upPps), + @"cumulativeInBytes": @(currInBytes), + @"cumulativeOutBytes": @(currOutBytes), + @"cumulativeInPackets": @(currInPackets), + @"cumulativeOutPackets": @(currOutPackets), + @"inErrors": @(inErrors), + @"outErrors": @(outErrors), + @"ipv4Address": ip, + @"isLoopback": @(isLoopback) + }]; + } + + if (primaryInterface.length == 0 && current[@"en0"]) { + primaryInterface = @"en0"; + } + + return @{ + @"interfaces": interfaces, + @"totalDownloadBytesPerSec": @(totalDownBps), + @"totalUploadBytesPerSec": @(totalUpBps), + @"totalDownloadPacketsPerSec": @(totalDownPps), + @"totalUploadPacketsPerSec": @(totalUpPps), + @"primaryInterface": primaryInterface, + @"timeDelta": @(timeDelta) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + uint64_t now = mach_absolute_time(); + + // 1. Read 64-bit interface stats via NET_RT_IFLIST2 + NSMutableDictionary *> *currentSnapshots = [NSMutableDictionary dictionary]; + + int mib[] = { CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0 }; + size_t len = 0; + if (sysctl(mib, 6, NULL, &len, NULL, 0) == 0 && len > 0) { + char *buf = malloc(len); + if (buf && sysctl(mib, 6, buf, &len, NULL, 0) == 0) { + char *next = buf; + char *lim = buf + len; + while (next < lim) { + struct if_msghdr *ifm = (struct if_msghdr *)next; + next += ifm->ifm_msglen; + if (ifm->ifm_type == RTM_IFINFO2) { + struct if_msghdr2 *if2m = (struct if_msghdr2 *)ifm; + struct sockaddr_dl *sdl = (struct sockaddr_dl *)(if2m + 1); + if (sdl->sdl_nlen > 0 && sdl->sdl_nlen < 32) { + char nameBuf[33] = {0}; + memcpy(nameBuf, sdl->sdl_data, sdl->sdl_nlen); + NSString *name = [NSString stringWithUTF8String:nameBuf]; + + currentSnapshots[name] = @{ + @"inBytes": @(if2m->ifm_data.ifi_ibytes), + @"outBytes": @(if2m->ifm_data.ifi_obytes), + @"inPackets": @(if2m->ifm_data.ifi_ipackets), + @"outPackets": @(if2m->ifm_data.ifi_opackets), + @"inErrors": @(if2m->ifm_data.ifi_ierrors), + @"outErrors": @(if2m->ifm_data.ifi_oerrors) + }; + } + } + } + } + if (buf) free(buf); + } + + // 2. Query IPv4 addresses via getifaddrs + NSMutableDictionary *ipDict = [NSMutableDictionary dictionary]; + struct ifaddrs *ifap = NULL; + if (getifaddrs(&ifap) == 0) { + for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) { + char ipBuf[INET_ADDRSTRLEN] = {0}; + struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr; + if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) { + NSString *name = [NSString stringWithUTF8String:ifa->ifa_name]; + ipDict[name] = [NSString stringWithUTF8String:ipBuf]; + } + } + } + freeifaddrs(ifap); + } + + os_unfair_lock_lock(&_lock); + NSDictionary *> *prev = _previousSnapshots; + uint64_t prevTime = _previousTimestamp; + + _previousSnapshots = currentSnapshots; + _previousTimestamp = now; + os_unfair_lock_unlock(&_lock); + + NSTimeInterval timeDelta = 1.0; + if (prevTime > 0) { + uint64_t elapsed = now - prevTime; + timeDelta = (double)elapsed * _timebase.numer / _timebase.denom / 1e9; + } + + return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots + previousSnapshots:prev + timeDelta:timeDelta + ipAddresses:ipDict]; +} + +@end diff --git a/Tests/MMNetworkBandwidthTests.swift b/Tests/MMNetworkBandwidthTests.swift new file mode 100644 index 0000000..c31967e --- /dev/null +++ b/Tests/MMNetworkBandwidthTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import MacMonitor + +final class MMNetworkBandwidthTests: XCTestCase { + + func testNetworkBandwidthCalculation() { + let prev: [String: [String: NSNumber]] = [ + "en0": [ + "inBytes": NSNumber(value: 50_000_000), + "outBytes": NSNumber(value: 10_000_000), + "inPackets": NSNumber(value: 40_000), + "outPackets": NSNumber(value: 20_000), + "inErrors": NSNumber(value: 0), + "outErrors": NSNumber(value: 0) + ], + "lo0": [ + "inBytes": NSNumber(value: 5_000_000), + "outBytes": NSNumber(value: 5_000_000), + "inPackets": NSNumber(value: 5_000), + "outPackets": NSNumber(value: 5_000), + "inErrors": NSNumber(value: 0), + "outErrors": NSNumber(value: 0) + ] + ] + + let curr: [String: [String: NSNumber]] = [ + "en0": [ + "inBytes": NSNumber(value: 60_000_000), // delta in = 10,000,000 bytes + "outBytes": NSNumber(value: 12_000_000), // delta out = 2,000,000 bytes + "inPackets": NSNumber(value: 48_000), // delta in = 8,000 pkts + "outPackets": NSNumber(value: 24_000), // delta out = 4,000 pkts + "inErrors": NSNumber(value: 0), + "outErrors": NSNumber(value: 0) + ], + "lo0": [ + "inBytes": NSNumber(value: 7_000_000), + "outBytes": NSNumber(value: 7_000_000), + "inPackets": NSNumber(value: 7_000), + "outPackets": NSNumber(value: 7_000), + "inErrors": NSNumber(value: 0), + "outErrors": NSNumber(value: 0) + ] + ] + + let timeDelta: TimeInterval = 2.0 // 2 seconds + let ipDict: [String: String] = ["en0": "192.168.1.100", "lo0": "127.0.0.1"] + + let metrics = MMNetworkBandwidthProvider.calculateBandwidthMetrics( + withCurrentSnapshots: curr, + previousSnapshots: prev, + timeDelta: timeDelta, + ipAddresses: ipDict + ) + + let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0 + let totalUpBps = metrics["totalUploadBytesPerSec"] as? Double ?? 0 + let totalDownPps = metrics["totalDownloadPacketsPerSec"] as? Double ?? 0 + let totalUpPps = metrics["totalUploadPacketsPerSec"] as? Double ?? 0 + let primary = metrics["primaryInterface"] as? String ?? "" + let interfaces = metrics["interfaces"] as? [[String: Any]] ?? [] + + // en0 delta in = 10MB / 2s = 5,000,000 B/s (lo0 is excluded from total) + XCTAssertEqual(totalDownBps, 5_000_000.0, accuracy: 1.0) + // en0 delta out = 2MB / 2s = 1,000,000 B/s + XCTAssertEqual(totalUpBps, 1_000_000.0, accuracy: 1.0) + // en0 delta in pkts = 8,000 / 2s = 4,000 pps + XCTAssertEqual(totalDownPps, 4000.0, accuracy: 0.1) + // en0 delta out pkts = 4,000 / 2s = 2,000 pps + XCTAssertEqual(totalUpPps, 2000.0, accuracy: 0.1) + XCTAssertEqual(primary, "en0") + XCTAssertEqual(interfaces.count, 2) + } + + func testLiveNetworkBandwidthProvider() throws { + let provider = MMNetworkBandwidthProvider() + XCTAssertEqual(provider.domain, .network) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.network.bandwidth") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["totalDownloadBytesPerSec"]) + XCTAssertNotNil(sample["totalUploadBytesPerSec"]) + XCTAssertNotNil(sample["interfaces"]) + } +} -- 2.39.5 From 5b067490948734a967046847f0b6f8bf56c957e6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:37:30 +0100 Subject: [PATCH 20/39] feat(telemetry): implement active network sockets and connection provider (fixes #16) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Network/MMNetworkSocketsProvider.h | 27 ++++ .../Network/MMNetworkSocketsProvider.m | 152 ++++++++++++++++++ Tests/MMNetworkSocketsTests.swift | 68 ++++++++ 4 files changed, 248 insertions(+) create mode 100644 Sources/Telemetry/Network/MMNetworkSocketsProvider.h create mode 100644 Sources/Telemetry/Network/MMNetworkSocketsProvider.m create mode 100644 Tests/MMNetworkSocketsTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 70818b0..26c20a7 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -23,5 +23,6 @@ #import "MMLoadAverageProvider.h" #import "MMDiskIOProvider.h" #import "MMNetworkBandwidthProvider.h" +#import "MMNetworkSocketsProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Network/MMNetworkSocketsProvider.h b/Sources/Telemetry/Network/MMNetworkSocketsProvider.h new file mode 100644 index 0000000..0722156 --- /dev/null +++ b/Sources/Telemetry/Network/MMNetworkSocketsProvider.h @@ -0,0 +1,27 @@ +#ifndef MMNetworkSocketsProvider_h +#define MMNetworkSocketsProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMNetworkSocketsProvider + * Inspects system-wide open TCP and UDP sockets, local/remote endpoints, connection states, + * and owning processes. + */ +@interface MMNetworkSocketsProvider : NSObject + +- (instancetype)init; + +/** + * Pure calculator / summarizer method for deterministic unit testing. + */ ++ (NSDictionary *)calculateSocketSummaryWithSocketList:(NSArray *> *)sockets; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMNetworkSocketsProvider_h */ diff --git a/Sources/Telemetry/Network/MMNetworkSocketsProvider.m b/Sources/Telemetry/Network/MMNetworkSocketsProvider.m new file mode 100644 index 0000000..0357bec --- /dev/null +++ b/Sources/Telemetry/Network/MMNetworkSocketsProvider.m @@ -0,0 +1,152 @@ +#import "MMNetworkSocketsProvider.h" +#import +#import +#import + +@implementation MMNetworkSocketsProvider + +- (instancetype)init { + self = [super init]; + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainNetwork; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.network.sockets"; +} + +- (BOOL)isAvailable { + return YES; +} + +static NSString *tcpStateString(int state) { + switch (state) { + case 0: return @"CLOSED"; + case 1: return @"LISTEN"; + case 2: return @"SYN_SENT"; + case 3: return @"SYN_RCVD"; + case 4: return @"ESTABLISHED"; + case 5: return @"CLOSE_WAIT"; + case 6: return @"FIN_WAIT_1"; + case 7: return @"CLOSING"; + case 8: return @"LAST_ACK"; + case 9: return @"FIN_WAIT_2"; + case 10: return @"TIME_WAIT"; + default: return @"UNKNOWN"; + } +} + ++ (NSDictionary *)calculateSocketSummaryWithSocketList:(NSArray *> *)sockets { + NSUInteger tcpCount = 0; + NSUInteger udpCount = 0; + NSUInteger listenCount = 0; + NSUInteger establishedCount = 0; + + for (NSDictionary *sock in sockets) { + NSString *proto = sock[@"protocol"]; + NSString *state = sock[@"state"]; + + if ([proto isEqualToString:@"TCP"]) { + tcpCount++; + if ([state isEqualToString:@"LISTEN"]) { + listenCount++; + } else if ([state isEqualToString:@"ESTABLISHED"]) { + establishedCount++; + } + } else if ([proto isEqualToString:@"UDP"]) { + udpCount++; + } + } + + return @{ + @"socketCount": @(sockets.count), + @"tcpCount": @(tcpCount), + @"udpCount": @(udpCount), + @"listenCount": @(listenCount), + @"establishedCount": @(establishedCount), + @"sockets": sockets + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + int pids[2048]; + int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids)); + int pidCount = bytes / sizeof(int); + + NSMutableArray *> *sockets = [NSMutableArray array]; + + for (int i = 0; i < pidCount; i++) { + pid_t pid = pids[i]; + if (pid <= 0) continue; + + int sz = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (sz <= 0) continue; + + struct proc_fdinfo *fds = malloc(sz); + if (!fds) continue; + + int actual = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, sz); + int fdCount = actual / sizeof(struct proc_fdinfo); + + char procNameBuf[256] = {0}; + BOOL procNameFetched = NO; + + for (int j = 0; j < fdCount; j++) { + if (fds[j].proc_fdtype == PROX_FDTYPE_SOCKET) { + struct socket_fdinfo si; + if (proc_pidfdinfo(pid, fds[j].proc_fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si)) == sizeof(si)) { + int family = si.psi.soi_family; + if (family == AF_INET || family == AF_INET6) { + if (!procNameFetched) { + proc_name(pid, procNameBuf, sizeof(procNameBuf)); + procNameFetched = YES; + } + + char localIP[INET6_ADDRSTRLEN] = {0}; + char remoteIP[INET6_ADDRSTRLEN] = {0}; + int lport = 0, rport = 0; + NSString *familyStr = (family == AF_INET) ? @"IPv4" : @"IPv6"; + NSString *proto = (si.psi.soi_type == SOCK_STREAM) ? @"TCP" : @"UDP"; + NSString *state = @"NONE"; + + if (family == AF_INET) { + inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4, localIP, sizeof(localIP)); + inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4, remoteIP, sizeof(remoteIP)); + lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); + rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); + } else { + inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, localIP, sizeof(localIP)); + inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, remoteIP, sizeof(remoteIP)); + lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); + rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); + } + + if (si.psi.soi_type == SOCK_STREAM) { + state = tcpStateString(si.psi.soi_proto.pri_tcp.tcpsi_state); + } + + [sockets addObject:@{ + @"pid": @(pid), + @"processName": [NSString stringWithUTF8String:procNameBuf], + @"protocol": proto, + @"family": familyStr, + @"localAddress": [NSString stringWithUTF8String:localIP], + @"localPort": @(lport), + @"remoteAddress": [NSString stringWithUTF8String:remoteIP], + @"remotePort": @(rport), + @"state": state + }]; + } + } + } + } + free(fds); + } + + return [MMNetworkSocketsProvider calculateSocketSummaryWithSocketList:sockets]; +} + +@end diff --git a/Tests/MMNetworkSocketsTests.swift b/Tests/MMNetworkSocketsTests.swift new file mode 100644 index 0000000..fb8a122 --- /dev/null +++ b/Tests/MMNetworkSocketsTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import MacMonitor + +final class MMNetworkSocketsTests: XCTestCase { + + func testSocketSummaryCalculation() { + let testSockets: [[String: Any]] = [ + [ + "pid": 100, + "processName": "web_server", + "protocol": "TCP", + "family": "IPv4", + "localAddress": "0.0.0.0", + "localPort": 8080, + "remoteAddress": "0.0.0.0", + "remotePort": 0, + "state": "LISTEN" + ], + [ + "pid": 200, + "processName": "browser", + "protocol": "TCP", + "family": "IPv4", + "localAddress": "192.168.1.50", + "localPort": 54321, + "remoteAddress": "142.250.190.46", + "remotePort": 443, + "state": "ESTABLISHED" + ], + [ + "pid": 300, + "processName": "dns_daemon", + "protocol": "UDP", + "family": "IPv4", + "localAddress": "0.0.0.0", + "localPort": 53, + "remoteAddress": "0.0.0.0", + "remotePort": 0, + "state": "NONE" + ] + ] + + let summary = MMNetworkSocketsProvider.calculateSocketSummary(withSocketList: testSockets) + + let total = summary["socketCount"] as? Int ?? 0 + let tcp = summary["tcpCount"] as? Int ?? 0 + let udp = summary["udpCount"] as? Int ?? 0 + let listen = summary["listenCount"] as? Int ?? 0 + let established = summary["establishedCount"] as? Int ?? 0 + + XCTAssertEqual(total, 3) + XCTAssertEqual(tcp, 2) + XCTAssertEqual(udp, 1) + XCTAssertEqual(listen, 1) + XCTAssertEqual(established, 1) + } + + func testLiveNetworkSocketsProvider() throws { + let provider = MMNetworkSocketsProvider() + XCTAssertEqual(provider.domain, .network) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.network.sockets") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["socketCount"]) + XCTAssertNotNil(sample["sockets"]) + } +} -- 2.39.5 From 72ed8256c81b03c0001e45509126f4903d4d4f11 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:39:23 +0100 Subject: [PATCH 21/39] feat(telemetry): implement live process explorer and resource attribution provider (fixes #11) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Process/MMProcessTelemetryProvider.h | 34 ++++ .../Process/MMProcessTelemetryProvider.m | 160 ++++++++++++++++++ Tests/MMProcessTests.swift | 69 ++++++++ 4 files changed, 264 insertions(+) create mode 100644 Sources/Telemetry/Process/MMProcessTelemetryProvider.h create mode 100644 Sources/Telemetry/Process/MMProcessTelemetryProvider.m create mode 100644 Tests/MMProcessTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 26c20a7..9f12c86 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -24,5 +24,6 @@ #import "MMDiskIOProvider.h" #import "MMNetworkBandwidthProvider.h" #import "MMNetworkSocketsProvider.h" +#import "MMProcessTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Process/MMProcessTelemetryProvider.h b/Sources/Telemetry/Process/MMProcessTelemetryProvider.h new file mode 100644 index 0000000..9634bdb --- /dev/null +++ b/Sources/Telemetry/Process/MMProcessTelemetryProvider.h @@ -0,0 +1,34 @@ +#ifndef MMProcessTelemetryProvider_h +#define MMProcessTelemetryProvider_h + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMProcessTelemetryProvider + * Live process explorer sampling CPU utilization, resident memory (RSS), + * virtual memory, thread counts, and process hierarchy. + */ +@interface MMProcessTelemetryProvider : NSObject + +- (instancetype)init; + +/** + * Safely terminates a process via SIGTERM or SIGKILL. + */ ++ (BOOL)terminateProcessWithPID:(pid_t)pid force:(BOOL)force; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSArray *> *)calculateProcessListWithRawProcesses:(NSArray *> *)rawProcesses + previousCPUTimes:(nullable NSDictionary *)previousCPUTimes + timeDelta:(NSTimeInterval)timeDelta; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMProcessTelemetryProvider_h */ diff --git a/Sources/Telemetry/Process/MMProcessTelemetryProvider.m b/Sources/Telemetry/Process/MMProcessTelemetryProvider.m new file mode 100644 index 0000000..bc82fa6 --- /dev/null +++ b/Sources/Telemetry/Process/MMProcessTelemetryProvider.m @@ -0,0 +1,160 @@ +#import "MMProcessTelemetryProvider.h" +#import +#import +#import +#import +#import +#import + +@interface MMProcessTelemetryProvider () { + os_unfair_lock _lock; + NSMutableDictionary *_previousCPUTimes; + uint64_t _previousTimestamp; + mach_timebase_info_data_t _timebase; + NSMutableDictionary *_usernameCache; +} +@end + +@implementation MMProcessTelemetryProvider + +- (instancetype)init { + self = [super init]; + if (self) { + _lock = OS_UNFAIR_LOCK_INIT; + _previousCPUTimes = [NSMutableDictionary dictionary]; + _previousTimestamp = 0; + _usernameCache = [NSMutableDictionary dictionary]; + mach_timebase_info(&_timebase); + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainProcess; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.process"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (BOOL)terminateProcessWithPID:(pid_t)pid force:(BOOL)force { + int sig = force ? SIGKILL : SIGTERM; + return kill(pid, sig) == 0; +} + ++ (NSArray *> *)calculateProcessListWithRawProcesses:(NSArray *> *)rawProcesses + previousCPUTimes:(nullable NSDictionary *)previousCPUTimes + timeDelta:(NSTimeInterval)timeDelta { + NSMutableArray *> *processed = [NSMutableArray arrayWithCapacity:rawProcesses.count]; + + for (NSDictionary *raw in rawProcesses) { + NSNumber *pidNum = raw[@"pid"]; + uint64_t currCPUTime = [raw[@"cpuTimeNs"] unsignedLongLongValue]; + + double cpuPercent = 0.0; + if (previousCPUTimes && previousCPUTimes[pidNum]) { + uint64_t prevCPUTime = [previousCPUTimes[pidNum] unsignedLongLongValue]; + if (currCPUTime >= prevCPUTime && timeDelta > 0.001) { + uint64_t deltaNs = currCPUTime - prevCPUTime; + double deltaSec = (double)deltaNs / 1e9; + cpuPercent = (deltaSec / timeDelta) * 100.0; + } + } + + NSMutableDictionary *entry = [raw mutableCopy]; + entry[@"cpuPercent"] = @(cpuPercent); + [processed addObject:entry]; + } + + // Sort descending by cpuPercent + [processed sortUsingComparator:^NSComparisonResult(NSDictionary *obj1, NSDictionary *obj2) { + NSNumber *c1 = obj1[@"cpuPercent"]; + NSNumber *c2 = obj2[@"cpuPercent"]; + return [c2 compare:c1]; + }]; + + return processed; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + uint64_t now = mach_absolute_time(); + + int pids[4096]; + int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids)); + int pidCount = bytes / sizeof(int); + + NSMutableArray *> *rawList = [NSMutableArray arrayWithCapacity:pidCount]; + NSMutableDictionary *currentCPUTimes = [NSMutableDictionary dictionaryWithCapacity:pidCount]; + + NSUInteger totalThreads = 0; + + for (int i = 0; i < pidCount; i++) { + pid_t pid = pids[i]; + if (pid <= 0) continue; + + struct proc_taskallinfo tai; + if (proc_pidinfo(pid, PROC_PIDTASKALLINFO, 0, &tai, sizeof(tai)) != sizeof(tai)) { + continue; + } + + uint64_t cpuTimeNs = tai.ptinfo.pti_total_user + tai.ptinfo.pti_total_system; + currentCPUTimes[@(pid)] = @(cpuTimeNs); + + totalThreads += tai.ptinfo.pti_threadnum; + + // Resolve username + NSNumber *uidNum = @(tai.pbsd.pbi_uid); + NSString *username = _usernameCache[uidNum]; + if (!username) { + struct passwd *pw = getpwuid(tai.pbsd.pbi_uid); + username = pw ? [NSString stringWithUTF8String:pw->pw_name] : [uidNum stringValue]; + _usernameCache[uidNum] = username; + } + + NSString *pname = [NSString stringWithUTF8String:tai.pbsd.pbi_name]; + + [rawList addObject:@{ + @"pid": @(pid), + @"ppid": @(tai.pbsd.pbi_ppid), + @"uid": uidNum, + @"username": username, + @"name": pname, + @"cpuTimeNs": @(cpuTimeNs), + @"residentBytes": @(tai.ptinfo.pti_resident_size), + @"virtualBytes": @(tai.ptinfo.pti_virtual_size), + @"threadCount": @(tai.ptinfo.pti_threadnum), + @"runningThreads": @(tai.ptinfo.pti_numrunning) + }]; + } + + os_unfair_lock_lock(&_lock); + NSDictionary *prev = [_previousCPUTimes copy]; + uint64_t prevTime = _previousTimestamp; + + _previousCPUTimes = currentCPUTimes; + _previousTimestamp = now; + os_unfair_lock_unlock(&_lock); + + NSTimeInterval timeDelta = 1.0; + if (prevTime > 0) { + uint64_t elapsed = now - prevTime; + timeDelta = (double)elapsed * _timebase.numer / _timebase.denom / 1e9; + } + + NSArray *> *processed = [MMProcessTelemetryProvider calculateProcessListWithRawProcesses:rawList + previousCPUTimes:prev + timeDelta:timeDelta]; + + return @{ + @"processCount": @(processed.count), + @"totalThreads": @(totalThreads), + @"processes": processed, + @"timeDelta": @(timeDelta) + }; +} + +@end diff --git a/Tests/MMProcessTests.swift b/Tests/MMProcessTests.swift new file mode 100644 index 0000000..2099527 --- /dev/null +++ b/Tests/MMProcessTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import MacMonitor + +final class MMProcessTests: XCTestCase { + + func testProcessCPUPercentCalculation() { + let prevCPUTimes: [NSNumber: NSNumber] = [ + 100: NSNumber(value: 1_000_000_000), // 1.0s + 200: NSNumber(value: 500_000_000) // 0.5s + ] + + let rawProcesses: [[String: Any]] = [ + [ + "pid": 100, + "ppid": 1, + "uid": 501, + "username": "tester", + "name": "cpu_hog", + "cpuTimeNs": NSNumber(value: 2_000_000_000), // delta = 1.0s over 2.0s = 50% CPU + "residentBytes": NSNumber(value: 50_000_000), + "virtualBytes": NSNumber(value: 100_000_000), + "threadCount": 4, + "runningThreads": 1 + ], + [ + "pid": 200, + "ppid": 1, + "uid": 501, + "username": "tester", + "name": "idle_app", + "cpuTimeNs": NSNumber(value: 520_000_000), // delta = 0.02s over 2.0s = 1% CPU + "residentBytes": NSNumber(value: 20_000_000), + "virtualBytes": NSNumber(value: 50_000_000), + "threadCount": 2, + "runningThreads": 0 + ] + ] + + let timeDelta: TimeInterval = 2.0 + + let results = MMProcessTelemetryProvider.calculateProcessList( + withRawProcesses: rawProcesses, + previousCPUTimes: prevCPUTimes, + timeDelta: timeDelta + ) + + XCTAssertEqual(results.count, 2) + // First entry should be highest CPU + XCTAssertEqual(results[0]["name"] as? String, "cpu_hog") + let hogCPU = results[0]["cpuPercent"] as? Double ?? 0 + XCTAssertEqual(hogCPU, 50.0, accuracy: 0.1) + + let idleCPU = results[1]["cpuPercent"] as? Double ?? 0 + XCTAssertEqual(idleCPU, 1.0, accuracy: 0.1) + } + + func testLiveProcessProvider() throws { + let provider = MMProcessTelemetryProvider() + XCTAssertEqual(provider.domain, .process) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.process") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + let processCount = sample["processCount"] as? Int ?? 0 + XCTAssertGreaterThan(processCount, 0) + XCTAssertNotNil(sample["processes"]) + XCTAssertNotNil(sample["totalThreads"]) + } +} -- 2.39.5 From a46f91f2ada05fa67bf9e17e3729db103f6e60e4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:41:56 +0100 Subject: [PATCH 22/39] feat(telemetry): implement per-process thread, file descriptor, and socket inspector (fixes #12) --- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Process/MMProcessDetailInspector.h | 32 ++++ .../Process/MMProcessDetailInspector.m | 176 ++++++++++++++++++ Tests/MMProcessDetailsTests.swift | 50 +++++ 4 files changed, 259 insertions(+) create mode 100644 Sources/Telemetry/Process/MMProcessDetailInspector.h create mode 100644 Sources/Telemetry/Process/MMProcessDetailInspector.m create mode 100644 Tests/MMProcessDetailsTests.swift diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 9f12c86..e926e34 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -25,5 +25,6 @@ #import "MMNetworkBandwidthProvider.h" #import "MMNetworkSocketsProvider.h" #import "MMProcessTelemetryProvider.h" +#import "MMProcessDetailInspector.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Process/MMProcessDetailInspector.h b/Sources/Telemetry/Process/MMProcessDetailInspector.h new file mode 100644 index 0000000..aeb9a56 --- /dev/null +++ b/Sources/Telemetry/Process/MMProcessDetailInspector.h @@ -0,0 +1,32 @@ +#ifndef MMProcessDetailInspector_h +#define MMProcessDetailInspector_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMProcessDetailInspector + * Provides deep per-process inspection: open file descriptors, vnode file paths, + * active network sockets, and thread states. + */ +@interface MMProcessDetailInspector : NSObject + +/** + * Inspects a running process by PID. + */ ++ (nullable NSDictionary *)inspectProcessWithPID:(pid_t)pid; + +/** + * Pure summarizer method for deterministic unit testing. + */ ++ (NSDictionary *)summarizeInspectionResultsWithPID:(pid_t)pid + threads:(NSArray *> *)threads + fds:(NSArray *> *)fds + sockets:(NSArray *> *)sockets; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMProcessDetailInspector_h */ diff --git a/Sources/Telemetry/Process/MMProcessDetailInspector.m b/Sources/Telemetry/Process/MMProcessDetailInspector.m new file mode 100644 index 0000000..e57958a --- /dev/null +++ b/Sources/Telemetry/Process/MMProcessDetailInspector.m @@ -0,0 +1,176 @@ +#import "MMProcessDetailInspector.h" +#import +#import +#import + +@implementation MMProcessDetailInspector + ++ (NSDictionary *)summarizeInspectionResultsWithPID:(pid_t)pid + threads:(NSArray *> *)threads + fds:(NSArray *> *)fds + sockets:(NSArray *> *)sockets { + return @{ + @"pid": @(pid), + @"threadCount": @(threads.count), + @"openFDCount": @(fds.count), + @"socketCount": @(sockets.count), + @"threads": threads, + @"fileDescriptors": fds, + @"sockets": sockets + }; +} + +static NSString *runStateString(int state) { + switch (state) { + case 1: return @"Running"; + case 2: return @"Stopped"; + case 3: return @"Waiting"; + case 4: return @"Uninterruptible"; + case 5: return @"Halted"; + default: return @"Unknown"; + } +} + +static NSString *tcpStateString(int state) { + switch (state) { + case 0: return @"CLOSED"; + case 1: return @"LISTEN"; + case 2: return @"SYN_SENT"; + case 3: return @"SYN_RCVD"; + case 4: return @"ESTABLISHED"; + case 5: return @"CLOSE_WAIT"; + case 6: return @"FIN_WAIT_1"; + case 7: return @"CLOSING"; + case 8: return @"LAST_ACK"; + case 9: return @"FIN_WAIT_2"; + case 10: return @"TIME_WAIT"; + default: return @"UNKNOWN"; + } +} + ++ (nullable NSDictionary *)inspectProcessWithPID:(pid_t)pid { + if (pid <= 0) return nil; + + // 1. Enumerate threads + uint64_t threadIds[512]; + int threadBytes = proc_pidinfo(pid, PROC_PIDLISTTHREADS, 0, threadIds, sizeof(threadIds)); + int threadCount = threadBytes / sizeof(uint64_t); + + NSMutableArray *> *threads = [NSMutableArray arrayWithCapacity:threadCount]; + for (int t = 0; t < threadCount; t++) { + uint64_t tid = threadIds[t]; + struct proc_threadinfo thi; + if (proc_pidinfo(pid, PROC_PIDTHREADINFO, tid, &thi, sizeof(thi)) == sizeof(thi)) { + [threads addObject:@{ + @"threadId": @(tid), + @"userTimeNs": @(thi.pth_user_time), + @"systemTimeNs": @(thi.pth_system_time), + @"cpuPercent": @(thi.pth_cpu_usage), + @"state": runStateString(thi.pth_run_state), + @"priority": @(thi.pth_priority), + @"currentPriority": @(thi.pth_curpri) + }]; + } + } + + // 2. Enumerate File Descriptors & Sockets + NSMutableArray *> *fdsList = [NSMutableArray array]; + NSMutableArray *> *socketsList = [NSMutableArray array]; + + int sz = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0); + if (sz > 0) { + struct proc_fdinfo *fds = malloc(sz); + if (fds) { + int actual = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, sz); + int fdCount = actual / sizeof(struct proc_fdinfo); + + for (int j = 0; j < fdCount; j++) { + int fd = fds[j].proc_fd; + uint32_t fdType = fds[j].proc_fdtype; + + if (fdType == PROX_FDTYPE_VNODE) { + struct vnode_fdinfowithpath vi; + if (proc_pidfdinfo(pid, fd, PROC_PIDFDVNODEPATHINFO, &vi, sizeof(vi)) == sizeof(vi)) { + NSString *path = [NSString stringWithUTF8String:vi.pvip.vip_path]; + [fdsList addObject:@{ + @"fd": @(fd), + @"type": @"File", + @"path": path ?: @"(unknown)" + }]; + } + } else if (fdType == PROX_FDTYPE_SOCKET) { + struct socket_fdinfo si; + if (proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si)) == sizeof(si)) { + int family = si.psi.soi_family; + if (family == AF_INET || family == AF_INET6) { + char localIP[INET6_ADDRSTRLEN] = {0}; + char remoteIP[INET6_ADDRSTRLEN] = {0}; + int lport = 0, rport = 0; + NSString *proto = (si.psi.soi_type == SOCK_STREAM) ? @"TCP" : @"UDP"; + NSString *state = @"NONE"; + + if (family == AF_INET) { + inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4, localIP, sizeof(localIP)); + inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4, remoteIP, sizeof(remoteIP)); + lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); + rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); + } else { + inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, localIP, sizeof(localIP)); + inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, remoteIP, sizeof(remoteIP)); + lport = ntohs(si.psi.soi_proto.pri_in.insi_lport); + rport = ntohs(si.psi.soi_proto.pri_in.insi_fport); + } + + if (si.psi.soi_type == SOCK_STREAM) { + state = tcpStateString(si.psi.soi_proto.pri_tcp.tcpsi_state); + } + + NSDictionary *sockDict = @{ + @"fd": @(fd), + @"protocol": proto, + @"localAddress": [NSString stringWithUTF8String:localIP], + @"localPort": @(lport), + @"remoteAddress": [NSString stringWithUTF8String:remoteIP], + @"remotePort": @(rport), + @"state": state + }; + + [socketsList addObject:sockDict]; + [fdsList addObject:@{ + @"fd": @(fd), + @"type": @"Socket", + @"path": [NSString stringWithFormat:@"%@ %@:%d -> %@:%d", proto, [NSString stringWithUTF8String:localIP], lport, [NSString stringWithUTF8String:remoteIP], rport] + }]; + } else { + [fdsList addObject:@{ + @"fd": @(fd), + @"type": @"Socket (Unix/Other)", + @"path": @"Unix Domain Socket" + }]; + } + } + } else if (fdType == PROX_FDTYPE_PIPE) { + [fdsList addObject:@{ + @"fd": @(fd), + @"type": @"Pipe", + @"path": @"FIFO/Pipe" + }]; + } else { + [fdsList addObject:@{ + @"fd": @(fd), + @"type": @"Other", + @"path": @"" + }]; + } + } + free(fds); + } + } + + return [MMProcessDetailInspector summarizeInspectionResultsWithPID:pid + threads:threads + fds:fdsList + sockets:socketsList]; +} + +@end diff --git a/Tests/MMProcessDetailsTests.swift b/Tests/MMProcessDetailsTests.swift new file mode 100644 index 0000000..a686083 --- /dev/null +++ b/Tests/MMProcessDetailsTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import MacMonitor + +final class MMProcessDetailsTests: XCTestCase { + + func testInspectionSummaryCalculation() { + let mockThreads: [[String: Any]] = [ + ["threadId": 1, "state": "Running"], + ["threadId": 2, "state": "Waiting"] + ] + + let mockFDs: [[String: Any]] = [ + ["fd": 0, "type": "File", "path": "/dev/null"], + ["fd": 1, "type": "File", "path": "/tmp/test.log"], + ["fd": 3, "type": "Socket", "path": "TCP 127.0.0.1:80 -> 127.0.0.1:50000"] + ] + + let mockSockets: [[String: Any]] = [ + ["fd": 3, "protocol": "TCP", "localAddress": "127.0.0.1", "localPort": 80, "state": "LISTEN"] + ] + + let summary = MMProcessDetailInspector.summarizeInspectionResults( + withPID: 1234, + threads: mockThreads, + fds: mockFDs, + sockets: mockSockets + ) + + let pid = summary["pid"] as? Int ?? 0 + let threadCount = summary["threadCount"] as? Int ?? 0 + let openFDCount = summary["openFDCount"] as? Int ?? 0 + let socketCount = summary["socketCount"] as? Int ?? 0 + + XCTAssertEqual(pid, 1234) + XCTAssertEqual(threadCount, 2) + XCTAssertEqual(openFDCount, 3) + XCTAssertEqual(socketCount, 1) + } + + func testLiveProcessInspection() { + let currentPid = getpid() + let result = MMProcessDetailInspector.inspectProcess(withPID: currentPid) + + XCTAssertNotNil(result) + let threadCount = result?["threadCount"] as? Int ?? 0 + XCTAssertGreaterThan(threadCount, 0) + let fds = result?["fileDescriptors"] as? [[String: Any]] + XCTAssertNotNil(fds) + } +} -- 2.39.5 From 3c9e209289f29be26068aa715f3db38e9628e74d Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:44:06 +0100 Subject: [PATCH 23/39] feat(telemetry): implement Intel and AMD GPU telemetry provider (fixes #14) --- MacMonitor.xcodeproj/project.pbxproj | 120 ++++++++++++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Telemetry/GPU/MMGPUTelemetryProvider.h | 31 +++ .../Telemetry/GPU/MMGPUTelemetryProvider.m | 184 ++++++++++++++++++ Tests/MMGPUTests.swift | 54 +++++ 5 files changed, 390 insertions(+) create mode 100644 Sources/Telemetry/GPU/MMGPUTelemetryProvider.h create mode 100644 Sources/Telemetry/GPU/MMGPUTelemetryProvider.m create mode 100644 Tests/MMGPUTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index d23c521..58bcf8c 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -12,25 +12,41 @@ 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; + 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; + 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; + 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */; }; 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; }; + 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; }; + 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */; }; + 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */; }; + 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52471E871E5CC1E444318070 /* MMProcessTests.swift */; }; 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; + 901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */; }; 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */; }; + 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */; }; + 951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */; }; 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; + A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */; }; + A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */; }; + AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A0F723121CDF9597316F58 /* MMGPUTests.swift */; }; B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; + E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; }; E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; }; + FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -49,16 +65,26 @@ 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPowerTelemetryProvider.m; sourceTree = ""; }; 09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = ""; }; 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; + 0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkSocketsProvider.h; sourceTree = ""; }; 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; + 1B07A683AD0EFBA8E1FB6C9D /* MMProcessDetailInspector.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessDetailInspector.h; sourceTree = ""; }; 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkBandwidthTests.swift; sourceTree = ""; }; + 2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMLoadAverageProvider.h; sourceTree = ""; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = ""; }; + 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkSocketsTests.swift; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; + 3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkBandwidthProvider.h; sourceTree = ""; }; 3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = ""; }; + 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDiskIOProvider.m; sourceTree = ""; }; + 41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessTelemetryProvider.h; sourceTree = ""; }; 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = ""; }; 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; + 52471E871E5CC1E444318070 /* MMProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessTests.swift; sourceTree = ""; }; + 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessDetailInspector.m; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -66,21 +92,35 @@ 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; + 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessDetailsTests.swift; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; + 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessTelemetryProvider.m; sourceTree = ""; }; 78325870C7CD8312AECA2236 /* MMStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMStorageTests.swift; sourceTree = ""; }; + 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMLoadAverageTests.swift; sourceTree = ""; }; + 80A0F723121CDF9597316F58 /* MMGPUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMGPUTests.swift; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; + 9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMGPUTelemetryProvider.h; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; + AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDiskIOProvider.h; sourceTree = ""; }; B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = ""; }; + B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMLoadAverageProvider.m; sourceTree = ""; }; + C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkBandwidthProvider.m; sourceTree = ""; }; C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = ""; }; C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = ""; }; D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = ""; }; + D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMGPUTelemetryProvider.m; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; + D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkSocketsProvider.m; sourceTree = ""; }; + DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMKernelTelemetryTests.swift; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; + E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = ""; }; + E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = ""; }; + F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -157,6 +197,17 @@ path = CPU; sourceTree = ""; }; + 8719744C366432DD7C2B9644 /* Process */ = { + isa = PBXGroup; + children = ( + 1B07A683AD0EFBA8E1FB6C9D /* MMProcessDetailInspector.h */, + 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */, + 41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */, + 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */, + ); + path = Process; + sourceTree = ""; + }; 8B31CE7F9319FEC373E6CDC6 /* Power */ = { isa = PBXGroup; children = ( @@ -171,14 +222,30 @@ children = ( 78383CED205BCA772701AE48 /* CPU */, AD2995435E44FA3E1790A4F3 /* Fan */, + F99AF003017BA9F19F7AEB1E /* GPU */, + CA2F30473D63EC64CBD65437 /* Kernel */, EE2F5EFC9DC33CEDA79A131F /* Memory */, + 901D54FEAB6E9D6E787E599D /* Network */, 8B31CE7F9319FEC373E6CDC6 /* Power */, + 8719744C366432DD7C2B9644 /* Process */, B6EB3F6545276C40212C2992 /* Storage */, + A8658D19E71E34F1DDD87097 /* System */, DBDC37231C01F0A2D4A7FC73 /* Thermal */, ); path = Telemetry; sourceTree = ""; }; + 901D54FEAB6E9D6E787E599D /* Network */ = { + isa = PBXGroup; + children = ( + 3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */, + C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */, + 0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */, + D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */, + ); + path = Network; + sourceTree = ""; + }; 9844EC526E3527F6A582179F /* Hardware */ = { isa = PBXGroup; children = ( @@ -187,6 +254,15 @@ path = Hardware; sourceTree = ""; }; + A8658D19E71E34F1DDD87097 /* System */ = { + isa = PBXGroup; + children = ( + 2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */, + B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */, + ); + path = System; + sourceTree = ""; + }; AD2995435E44FA3E1790A4F3 /* Fan */ = { isa = PBXGroup; children = ( @@ -199,6 +275,8 @@ B6EB3F6545276C40212C2992 /* Storage */ = { isa = PBXGroup; children = ( + AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */, + 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */, 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */, 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */, ); @@ -221,15 +299,32 @@ B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, + E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */, 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */, + 80A0F723121CDF9597316F58 /* MMGPUTests.swift */, + DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */, + 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, + 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */, + 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */, D48698681374BD25B980C000 /* MMPowerTests.swift */, + 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */, + 52471E871E5CC1E444318070 /* MMProcessTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, ); path = Tests; sourceTree = ""; }; + CA2F30473D63EC64CBD65437 /* Kernel */ = { + isa = PBXGroup; + children = ( + E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */, + F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */, + ); + path = Kernel; + sourceTree = ""; + }; DBDC37231C01F0A2D4A7FC73 /* Thermal */ = { isa = PBXGroup; children = ( @@ -271,6 +366,15 @@ path = Memory; sourceTree = ""; }; + F99AF003017BA9F19F7AEB1E /* GPU */ = { + isa = PBXGroup; + children = ( + 9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */, + D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */, + ); + path = GPU; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -349,9 +453,17 @@ 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */, + 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */, E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */, + AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */, + 901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */, + ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, + 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */, + 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */, 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */, + 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */, + 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, @@ -367,9 +479,17 @@ 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */, + FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */, 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */, + 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */, + A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */, + 951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */, B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, + E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */, + A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */, 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */, + 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */, + 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */, 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */, F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index e926e34..286babb 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -26,5 +26,6 @@ #import "MMNetworkSocketsProvider.h" #import "MMProcessTelemetryProvider.h" #import "MMProcessDetailInspector.h" +#import "MMGPUTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/GPU/MMGPUTelemetryProvider.h b/Sources/Telemetry/GPU/MMGPUTelemetryProvider.h new file mode 100644 index 0000000..93f754b --- /dev/null +++ b/Sources/Telemetry/GPU/MMGPUTelemetryProvider.h @@ -0,0 +1,31 @@ +#ifndef MMGPUTelemetryProvider_h +#define MMGPUTelemetryProvider_h + +#import +#import "MMTelemetryProvider.h" +#import "MMAppleSMCClient.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * MMGPUTelemetryProvider + * Samples Intel Integrated Graphics (Iris/UHD) and AMD Discrete Graphics (Radeon/Vega) + * utilization, VRAM allocation, and thermal metrics via IOKit and Metal. + */ +@interface MMGPUTelemetryProvider : NSObject + +@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient; + +- (instancetype)init; +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client; + +/** + * Pure calculator method for deterministic unit testing. + */ ++ (NSDictionary *)calculateGPUMetricsWithDevices:(NSArray *> *)gpuDevices; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* MMGPUTelemetryProvider_h */ diff --git a/Sources/Telemetry/GPU/MMGPUTelemetryProvider.m b/Sources/Telemetry/GPU/MMGPUTelemetryProvider.m new file mode 100644 index 0000000..a6b61cd --- /dev/null +++ b/Sources/Telemetry/GPU/MMGPUTelemetryProvider.m @@ -0,0 +1,184 @@ +#import "MMGPUTelemetryProvider.h" +#import +#import + +@interface MMGPUTelemetryProvider () { + MMAppleSMCClient *_smcClient; +} +@end + +@implementation MMGPUTelemetryProvider + +- (instancetype)init { + return [self initWithSMCClient:[MMAppleSMCClient sharedClient]]; +} + +- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client { + self = [super init]; + if (self) { + _smcClient = client; + } + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainGPU; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.gpu"; +} + +- (BOOL)isAvailable { + return YES; +} + +- (MMAppleSMCClient *)smcClient { + return _smcClient; +} + ++ (NSDictionary *)calculateGPUMetricsWithDevices:(NSArray *> *)gpuDevices { + double totalUtil = 0.0; + double peakUtil = 0.0; + NSString *activeGPU = @"None"; + + for (NSDictionary *gpu in gpuDevices) { + double util = [gpu[@"utilization"] doubleValue]; + totalUtil += util; + if (util > peakUtil) { + peakUtil = util; + activeGPU = gpu[@"name"] ?: @"GPU"; + } + } + + double avgUtil = gpuDevices.count > 0 ? (totalUtil / (double)gpuDevices.count) : 0.0; + if ([activeGPU isEqualToString:@"None"] && gpuDevices.count > 0) { + activeGPU = gpuDevices[0][@"name"] ?: @"GPU"; + } + + return @{ + @"gpuCount": @(gpuDevices.count), + @"averageUtilization": @(avgUtil), + @"peakUtilization": @(peakUtil), + @"activeGPUName": activeGPU, + @"devices": gpuDevices + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + // 1. Query IOKit IOAccelerator performance statistics + NSMutableDictionary *accelStatsByName = [NSMutableDictionary dictionary]; + + CFMutableDictionaryRef matching = IOServiceMatching("IOAccelerator"); + io_iterator_t iterator = IO_OBJECT_NULL; + if (IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS && iterator != IO_OBJECT_NULL) { + io_registry_entry_t entry; + while ((entry = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + CFMutableDictionaryRef props = NULL; + if (IORegistryEntryCreateCFProperties(entry, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) { + NSDictionary *dict = (__bridge NSDictionary *)props; + NSDictionary *stats = dict[@"PerformanceStatistics"]; + io_name_t entryName; + if (IORegistryEntryGetName(entry, entryName) == KERN_SUCCESS && stats) { + accelStatsByName[[NSString stringWithUTF8String:entryName]] = stats; + } + CFRelease(props); + } + IOObjectRelease(entry); + } + IOObjectRelease(iterator); + } + + // 2. Query SMC for GPU temperature fallback + NSNumber *smcGpuTemp = nil; + if (_smcClient.isAvailable) { + smcGpuTemp = [_smcClient readNumericValueForKey:@"TG0D" error:nil]; + if (!smcGpuTemp) { + smcGpuTemp = [_smcClient readNumericValueForKey:@"TG0P" error:nil]; + } + } + + // 3. Enumerate Metal devices + NSArray> *metalDevices = MTLCopyAllDevices(); + NSMutableArray *> *deviceList = [NSMutableArray array]; + + for (id dev in metalDevices) { + NSString *name = dev.name; + BOOL isLowPower = dev.isLowPower; + BOOL isRemovable = dev.isRemovable; + uint64_t maxMem = dev.recommendedMaxWorkingSetSize; + + // Find matching stats + double util = 0.0; + uint64_t vramUsed = 0; + uint64_t vramTotal = maxMem; + double temp = smcGpuTemp ? [smcGpuTemp doubleValue] : 0.0; + + for (NSString *accelName in accelStatsByName) { + NSDictionary *stats = accelStatsByName[accelName]; + + // Check Intel stats + if ([name containsString:@"Intel"] || [accelName containsString:@"Intel"]) { + if (stats[@"Device Utilization %"]) { + util = [stats[@"Device Utilization %"] doubleValue]; + } + if (stats[@"gartUsedBytes"]) { + vramUsed = [stats[@"gartUsedBytes"] unsignedLongLongValue]; + } + if (stats[@"gartSizeBytes"]) { + vramTotal = [stats[@"gartSizeBytes"] unsignedLongLongValue]; + } + } else { + // AMD / Discrete stats + if (stats[@"GPU Activity(%)"]) { + util = [stats[@"GPU Activity(%)"] doubleValue]; + } else if (stats[@"Device Utilization %"]) { + util = [stats[@"Device Utilization %"] doubleValue]; + } + if (stats[@"vramUsedBytes"]) { + vramUsed = [stats[@"vramUsedBytes"] unsignedLongLongValue]; + } + if (stats[@"vramFreeBytes"]) { + uint64_t freeBytes = [stats[@"vramFreeBytes"] unsignedLongLongValue]; + vramTotal = vramUsed + freeBytes; + } + if (stats[@"Temperature(C)"]) { + temp = [stats[@"Temperature(C)"] doubleValue]; + } + } + } + + NSString *gpuType = isLowPower || [name containsString:@"Intel"] ? @"Integrated" : @"Discrete"; + + [deviceList addObject:@{ + @"name": name, + @"type": gpuType, + @"utilization": @(util), + @"vramUsedBytes": @(vramUsed), + @"vramTotalBytes": @(vramTotal), + @"temperature": @(temp), + @"isLowPower": @(isLowPower), + @"isRemovable": @(isRemovable), + @"recommendedMaxWorkingSetBytes": @(maxMem) + }]; + } + + // Fallback if no Metal devices enumerated (headless / virtualization) + if (deviceList.count == 0) { + [deviceList addObject:@{ + @"name": @"Default GPU", + @"type": @"Integrated", + @"utilization": @(0.0), + @"vramUsedBytes": @(0), + @"vramTotalBytes": @(1024 * 1024 * 1024), + @"temperature": @(0.0), + @"isLowPower": @(YES), + @"isRemovable": @(NO), + @"recommendedMaxWorkingSetBytes": @(1024 * 1024 * 1024) + }]; + } + + return [MMGPUTelemetryProvider calculateGPUMetricsWithDevices:deviceList]; +} + +@end diff --git a/Tests/MMGPUTests.swift b/Tests/MMGPUTests.swift new file mode 100644 index 0000000..4da34ff --- /dev/null +++ b/Tests/MMGPUTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import MacMonitor + +final class MMGPUTests: XCTestCase { + + func testGPUMetricsCalculation() { + let mockGPUs: [[String: Any]] = [ + [ + "name": "Intel Iris Plus Graphics 655", + "type": "Integrated", + "utilization": 15.0, + "vramUsedBytes": 500_000_000, + "vramTotalBytes": 1_500_000_000, + "temperature": 45.0, + "isLowPower": true, + "isRemovable": false + ], + [ + "name": "AMD Radeon Pro 560X", + "type": "Discrete", + "utilization": 75.0, + "vramUsedBytes": 3_000_000_000, + "vramTotalBytes": 4_000_000_000, + "temperature": 68.0, + "isLowPower": false, + "isRemovable": false + ] + ] + + let metrics = MMGPUTelemetryProvider.calculateGPUMetrics(withDevices: mockGPUs) + + let count = metrics["gpuCount"] as? Int ?? 0 + let avg = metrics["averageUtilization"] as? Double ?? 0 + let peak = metrics["peakUtilization"] as? Double ?? 0 + let active = metrics["activeGPUName"] as? String ?? "" + + XCTAssertEqual(count, 2) + XCTAssertEqual(avg, 45.0, accuracy: 0.1) + XCTAssertEqual(peak, 75.0, accuracy: 0.1) + XCTAssertEqual(active, "AMD Radeon Pro 560X") + } + + func testLiveGPUProvider() throws { + let provider = MMGPUTelemetryProvider() + XCTAssertEqual(provider.domain, .gpu) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.gpu") + + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + XCTAssertNotNil(sample["gpuCount"]) + XCTAssertNotNil(sample["devices"]) + XCTAssertNotNil(sample["activeGPUName"]) + } +} -- 2.39.5 From 6c34cf3c86da5f10d182aa81eed9d57ec43febc2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:46:22 +0100 Subject: [PATCH 24/39] feat(telemetry): implement AppleSmartBattery health telemetry provider (fixes #17) --- MacMonitor.xcodeproj/project.pbxproj | 10 + Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Power/MMBatteryTelemetryProvider.h | 23 +++ .../Power/MMBatteryTelemetryProvider.m | 179 ++++++++++++++++++ Tests/MMBatteryTests.swift | 109 +++++++++++ 5 files changed, 322 insertions(+) create mode 100644 Sources/Telemetry/Power/MMBatteryTelemetryProvider.h create mode 100644 Sources/Telemetry/Power/MMBatteryTelemetryProvider.m create mode 100644 Tests/MMBatteryTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 58bcf8c..94f6165 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; + 8EDDC2171C55B6E7FC999DAB /* MMBatteryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */; }; 901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */; }; 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */; }; 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */; }; @@ -40,6 +41,7 @@ ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */; }; AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A0F723121CDF9597316F58 /* MMGPUTests.swift */; }; B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; + BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; }; E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; @@ -72,6 +74,7 @@ 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkBandwidthTests.swift; sourceTree = ""; }; 2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMLoadAverageProvider.h; sourceTree = ""; }; + 2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMBatteryTelemetryProvider.h; sourceTree = ""; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = ""; }; @@ -101,9 +104,11 @@ 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; + 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMBatteryTelemetryProvider.m; sourceTree = ""; }; 9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMGPUTelemetryProvider.h; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDiskIOProvider.h; sourceTree = ""; }; + B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMBatteryTests.swift; sourceTree = ""; }; B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = ""; }; B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMLoadAverageProvider.m; sourceTree = ""; }; C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkBandwidthProvider.m; sourceTree = ""; }; @@ -211,6 +216,8 @@ 8B31CE7F9319FEC373E6CDC6 /* Power */ = { isa = PBXGroup; children = ( + 2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */, + 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */, CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */, 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */, ); @@ -296,6 +303,7 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */, B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, @@ -450,6 +458,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */, 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */, @@ -476,6 +485,7 @@ files = ( A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, + 8EDDC2171C55B6E7FC999DAB /* MMBatteryTelemetryProvider.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 286babb..ddad40a 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -27,5 +27,6 @@ #import "MMProcessTelemetryProvider.h" #import "MMProcessDetailInspector.h" #import "MMGPUTelemetryProvider.h" +#import "MMBatteryTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Power/MMBatteryTelemetryProvider.h b/Sources/Telemetry/Power/MMBatteryTelemetryProvider.h new file mode 100644 index 0000000..b152aae --- /dev/null +++ b/Sources/Telemetry/Power/MMBatteryTelemetryProvider.h @@ -0,0 +1,23 @@ +// +// MMBatteryTelemetryProvider.h +// MacMonitor +// +// Telemetry provider for AppleSmartBattery health, cycle count, capacity, and adapter info. +// + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MMBatteryTelemetryProvider : NSObject + +@property (nonatomic, readonly) BOOL hasBattery; + +- (instancetype)init; + ++ (NSDictionary *)calculateBatteryHealthWithProperties:(nullable NSDictionary *)rawProps; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Telemetry/Power/MMBatteryTelemetryProvider.m b/Sources/Telemetry/Power/MMBatteryTelemetryProvider.m new file mode 100644 index 0000000..5005069 --- /dev/null +++ b/Sources/Telemetry/Power/MMBatteryTelemetryProvider.m @@ -0,0 +1,179 @@ +// +// MMBatteryTelemetryProvider.m +// MacMonitor +// +// Telemetry provider for AppleSmartBattery health, cycle count, capacity, and adapter info. +// + +#import "MMBatteryTelemetryProvider.h" +#import +#import +#import + +@implementation MMBatteryTelemetryProvider + +- (instancetype)init { + self = [super init]; + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainPower; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.battery"; +} + +- (BOOL)isAvailable { + io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")); + if (service != IO_OBJECT_NULL) { + IOObjectRelease(service); + return YES; + } + return NO; +} + +- (BOOL)hasBattery { + return [self isAvailable]; +} + ++ (NSDictionary *)calculateBatteryHealthWithProperties:(nullable NSDictionary *)rawProps { + if (!rawProps || rawProps.count == 0) { + return @{ + @"hasBattery": @(NO), + @"installed": @(NO), + @"healthCondition": @"No Battery", + @"healthPercent": @(0.0), + @"cycleCount": @(0), + @"designCycleCount": @(0), + @"currentCapacity": @(0), + @"maxCapacity": @(0), + @"designCapacity": @(0), + @"temperature": @(0.0), + @"voltage": @(0.0), + @"amperage": @(0.0), + @"watts": @(0.0), + @"isCharging": @(NO), + @"isCharged": @(NO), + @"externalConnected": @(NO), + @"timeRemainingMinutes": @(-1), + @"manufacturer": @"N/A", + @"serial": @"N/A", + @"deviceName": @"N/A" + }; + } + + BOOL installed = [rawProps[@"BatteryInstalled"] boolValue]; + NSInteger cycleCount = [rawProps[@"CycleCount"] integerValue]; + NSInteger designCycleCount = rawProps[@"DesignCycleCount9C"] ? [rawProps[@"DesignCycleCount9C"] integerValue] : 1000; + + NSInteger currentCapacity = [rawProps[@"CurrentCapacity"] integerValue]; + NSInteger maxCapacity = [rawProps[@"MaxCapacity"] integerValue]; + + // DesignCapacity might be at top level or inside BatteryData + NSInteger designCapacity = [rawProps[@"DesignCapacity"] integerValue]; + if (designCapacity <= 0 && [rawProps[@"BatteryData"] isKindOfClass:[NSDictionary class]]) { + designCapacity = [rawProps[@"BatteryData"][@"DesignCapacity"] integerValue]; + } + + // Health percentage = (MaxCapacity / DesignCapacity) * 100.0 + double healthPercent = 0.0; + if (designCapacity > 0) { + healthPercent = ((double)maxCapacity / (double)designCapacity) * 100.0; + if (healthPercent > 100.0) healthPercent = 100.0; + } else if (maxCapacity > 0) { + healthPercent = 100.0; + } + + // Health condition evaluation + NSString *condition = @"Normal"; + BOOL permanentFailure = [rawProps[@"PermanentFailureStatus"] integerValue] != 0; + if (permanentFailure) { + condition = @"Permanent Failure"; + } else if (healthPercent < 70.0 || (designCycleCount > 0 && cycleCount > designCycleCount)) { + condition = @"Service Recommended"; + } else if (healthPercent < 80.0) { + condition = @"Fair"; + } else { + condition = @"Normal"; + } + + // Temperature: AppleSmartBattery returns in units of 0.01 deg C or 0.1 deg C (e.g. 3151 = 31.51 deg C) + double rawTemp = [rawProps[@"Temperature"] doubleValue]; + double tempC = rawTemp > 1000.0 ? (rawTemp / 100.0) : (rawTemp > 100.0 ? rawTemp / 10.0 : rawTemp); + + double voltage = [rawProps[@"Voltage"] doubleValue] / 1000.0; // mV -> V + + // Amperage can be signed 64-bit stored as unsigned in dict + int64_t rawAmp = [rawProps[@"InstantAmperage"] longLongValue]; + if (rawAmp == 0) { + rawAmp = [rawProps[@"Amperage"] longLongValue]; + } + int32_t signedAmp = (int32_t)rawAmp; // standard cast from uint32/uint64 representation + double amperage = ((double)signedAmp) / 1000.0; // mA -> A + double watts = fabs(voltage * amperage); + + BOOL isCharging = [rawProps[@"IsCharging"] boolValue]; + BOOL fullyCharged = [rawProps[@"FullyCharged"] boolValue]; + BOOL externalConnected = [rawProps[@"ExternalConnected"] boolValue]; + NSInteger timeRemaining = [rawProps[@"TimeRemaining"] integerValue]; + if (timeRemaining == 65535) { + timeRemaining = -1; + } + + NSString *manufacturer = rawProps[@"Manufacturer"] ?: @"Apple"; + NSString *serial = rawProps[@"Serial"] ?: @""; + NSString *deviceName = rawProps[@"DeviceName"] ?: @""; + + // Power adapter details + NSDictionary *adapterDetails = [rawProps[@"AdapterDetails"] isKindOfClass:[NSDictionary class]] ? rawProps[@"AdapterDetails"] : nil; + NSInteger adapterWatts = 0; + if (adapterDetails && adapterDetails[@"Watts"]) { + adapterWatts = [adapterDetails[@"Watts"] integerValue]; + } + + return @{ + @"hasBattery": @(YES), + @"installed": @(installed), + @"healthCondition": condition, + @"healthPercent": @(round(healthPercent * 10.0) / 10.0), + @"cycleCount": @(cycleCount), + @"designCycleCount": @(designCycleCount), + @"currentCapacity": @(currentCapacity), + @"maxCapacity": @(maxCapacity), + @"designCapacity": @(designCapacity), + @"temperature": @(round(tempC * 10.0) / 10.0), + @"voltage": @(round(voltage * 100.0) / 100.0), + @"amperage": @(round(amperage * 100.0) / 100.0), + @"watts": @(round(watts * 10.0) / 10.0), + @"isCharging": @(isCharging), + @"isCharged": @(fullyCharged), + @"externalConnected": @(externalConnected), + @"timeRemainingMinutes": @(timeRemaining), + @"manufacturer": manufacturer, + @"serial": serial, + @"deviceName": deviceName, + @"adapterWatts": @(adapterWatts) + }; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")); + if (service == IO_OBJECT_NULL) { + return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:nil]; + } + + CFMutableDictionaryRef props = NULL; + kern_return_t kr = IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0); + IOObjectRelease(service); + + if (kr != KERN_SUCCESS || !props) { + return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:nil]; + } + + NSDictionary *dict = (__bridge_transfer NSDictionary *)props; + return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:dict]; +} + +@end diff --git a/Tests/MMBatteryTests.swift b/Tests/MMBatteryTests.swift new file mode 100644 index 0000000..ccf805c --- /dev/null +++ b/Tests/MMBatteryTests.swift @@ -0,0 +1,109 @@ +// +// MMBatteryTests.swift +// MacMonitorTests +// +// Unit and integration tests for MMBatteryTelemetryProvider. +// + +import XCTest +@testable import MacMonitor + +final class MMBatteryTests: XCTestCase { + var provider: MMBatteryTelemetryProvider! + + override func setUp() { + super.setUp() + provider = MMBatteryTelemetryProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + func testDomainAndIdentifier() { + XCTAssertEqual(provider.domain, .power) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.battery") + } + + func testCalculateBatteryHealthWithSimulatedNormalBattery() { + let raw: [String: Any] = [ + "BatteryInstalled": true, + "CycleCount": 250, + "DesignCycleCount9C": 1000, + "CurrentCapacity": 3000, + "MaxCapacity": 4000, + "DesignCapacity": 4400, + "Temperature": 3150, // 31.5 C + "Voltage": 11100, // 11.1 V + "Amperage": -1500, // -1.5 A + "IsCharging": false, + "FullyCharged": false, + "ExternalConnected": false, + "TimeRemaining": 120, + "Manufacturer": "SMP", + "Serial": "TEST123456", + "DeviceName": "bq20z451" + ] + + let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: raw) + XCTAssertEqual(health["hasBattery"] as? Bool, true) + XCTAssertEqual(health["installed"] as? Bool, true) + XCTAssertEqual(health["healthCondition"] as? String, "Normal") + + let healthPercent = (health["healthPercent"] as? Double) ?? 0.0 + XCTAssertEqual(healthPercent, 90.9, accuracy: 0.1) + XCTAssertEqual(health["cycleCount"] as? Int, 250) + XCTAssertEqual(health["designCycleCount"] as? Int, 1000) + XCTAssertEqual(health["temperature"] as? Double, 31.5) + let watts = (health["watts"] as? Double) ?? 0.0 + XCTAssertEqual(watts, 16.7, accuracy: 0.2) + XCTAssertEqual(health["manufacturer"] as? String, "SMP") + } + + func testCalculateBatteryHealthWithServiceRecommended() { + let raw: [String: Any] = [ + "BatteryInstalled": true, + "CycleCount": 1100, + "DesignCycleCount9C": 1000, + "CurrentCapacity": 1500, + "MaxCapacity": 2500, + "DesignCapacity": 4400, // ~56.8% health + "Temperature": 3200, + "Voltage": 10800, + "Amperage": -1000, + "IsCharging": false, + "FullyCharged": false, + "ExternalConnected": false, + "TimeRemaining": 90, + "Manufacturer": "Apple" + ] + + let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: raw) + XCTAssertEqual(health["healthCondition"] as? String, "Service Recommended") + let healthPercent = (health["healthPercent"] as? Double) ?? 0.0 + XCTAssertLessThan(healthPercent, 70.0) + } + + func testCalculateBatteryHealthWithNil() { + let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: nil) + XCTAssertEqual(health["hasBattery"] as? Bool, false) + XCTAssertEqual(health["installed"] as? Bool, false) + XCTAssertEqual(health["healthCondition"] as? String, "No Battery") + } + + func testLiveSampleBattery() { + do { + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + if provider.hasBattery { + XCTAssertEqual(sample["hasBattery"] as? Bool, true) + XCTAssertNotNil(sample["cycleCount"]) + XCTAssertNotNil(sample["healthPercent"]) + XCTAssertNotNil(sample["healthCondition"]) + } + } catch { + XCTFail("sampleTelemetry threw error: \(error)") + } + } +} -- 2.39.5 From 3224558ac4302179eac9d922550cb72c4071bce8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:48:30 +0100 Subject: [PATCH 25/39] feat(telemetry): implement USB, Thunderbolt, and PCI peripherals provider (fixes #20) --- MacMonitor.xcodeproj/project.pbxproj | 18 ++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Peripherals/MMPeripheralsProvider.h | 45 ++++ .../Peripherals/MMPeripheralsProvider.m | 255 ++++++++++++++++++ Tests/MMPeripheralsTests.swift | 73 +++++ 5 files changed, 392 insertions(+) create mode 100644 Sources/Telemetry/Peripherals/MMPeripheralsProvider.h create mode 100644 Sources/Telemetry/Peripherals/MMPeripheralsProvider.m create mode 100644 Tests/MMPeripheralsTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 94f6165..b38f7a7 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -12,10 +12,12 @@ 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; + 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; }; 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; + 4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */; }; 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */; }; 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; }; 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */; }; @@ -75,6 +77,7 @@ 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkBandwidthTests.swift; sourceTree = ""; }; 2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMLoadAverageProvider.h; sourceTree = ""; }; 2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMBatteryTelemetryProvider.h; sourceTree = ""; }; + 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPeripheralsProvider.m; sourceTree = ""; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = ""; }; @@ -115,6 +118,8 @@ C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = ""; }; C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = ""; }; + CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPeripheralsProvider.h; sourceTree = ""; }; + D1486E1512594482380CB492 /* MMPeripheralsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPeripheralsTests.swift; sourceTree = ""; }; D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = ""; }; D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMGPUTelemetryProvider.m; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; @@ -193,6 +198,15 @@ path = Core; sourceTree = ""; }; + 65251E6B5694734BAAF1C1F1 /* Peripherals */ = { + isa = PBXGroup; + children = ( + CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */, + 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */, + ); + path = Peripherals; + sourceTree = ""; + }; 78383CED205BCA772701AE48 /* CPU */ = { isa = PBXGroup; children = ( @@ -233,6 +247,7 @@ CA2F30473D63EC64CBD65437 /* Kernel */, EE2F5EFC9DC33CEDA79A131F /* Memory */, 901D54FEAB6E9D6E787E599D /* Network */, + 65251E6B5694734BAAF1C1F1 /* Peripherals */, 8B31CE7F9319FEC373E6CDC6 /* Power */, 8719744C366432DD7C2B9644 /* Process */, B6EB3F6545276C40212C2992 /* Storage */, @@ -315,6 +330,7 @@ 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */, 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */, + D1486E1512594482380CB492 /* MMPeripheralsTests.swift */, D48698681374BD25B980C000 /* MMPowerTests.swift */, 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */, 52471E871E5CC1E444318070 /* MMProcessTests.swift */, @@ -470,6 +486,7 @@ 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */, 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */, + 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */, 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */, 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */, 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */, @@ -497,6 +514,7 @@ B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */, E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */, A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */, + 4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */, 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */, 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */, 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index ddad40a..e4c3c3f 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -28,5 +28,6 @@ #import "MMProcessDetailInspector.h" #import "MMGPUTelemetryProvider.h" #import "MMBatteryTelemetryProvider.h" +#import "MMPeripheralsProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Peripherals/MMPeripheralsProvider.h b/Sources/Telemetry/Peripherals/MMPeripheralsProvider.h new file mode 100644 index 0000000..abc24d0 --- /dev/null +++ b/Sources/Telemetry/Peripherals/MMPeripheralsProvider.h @@ -0,0 +1,45 @@ +// +// MMPeripheralsProvider.h +// MacMonitor +// +// Telemetry provider enumerating connected USB, Thunderbolt, and PCI peripheral devices via IOKit. +// + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, MMPeripheralBusType) { + MMPeripheralBusTypeUSB, + MMPeripheralBusTypeThunderbolt, + MMPeripheralBusTypePCI +}; + +@interface MMPeripheralDevice : NSObject + +@property (nonatomic, copy) NSString *name; +@property (nonatomic, assign) MMPeripheralBusType busType; +@property (nonatomic, copy) NSString *vendorName; +@property (nonatomic, assign) uint32_t vendorID; +@property (nonatomic, assign) uint32_t productID; +@property (nonatomic, copy, nullable) NSString *serialNumber; +@property (nonatomic, copy, nullable) NSString *deviceClass; +@property (nonatomic, assign) uint64_t locationID; +@property (nonatomic, assign) BOOL isBuiltIn; + +- (NSDictionary *)toDictionary; + +@end + +@interface MMPeripheralsProvider : NSObject + +- (instancetype)init; + ++ (NSArray *)sampleUSBDevices; ++ (NSArray *)sampleThunderboltDevices; ++ (NSArray *)samplePCIDevices; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Telemetry/Peripherals/MMPeripheralsProvider.m b/Sources/Telemetry/Peripherals/MMPeripheralsProvider.m new file mode 100644 index 0000000..0a573e2 --- /dev/null +++ b/Sources/Telemetry/Peripherals/MMPeripheralsProvider.m @@ -0,0 +1,255 @@ +// +// MMPeripheralsProvider.m +// MacMonitor +// +// Telemetry provider enumerating connected USB, Thunderbolt, and PCI peripheral devices via IOKit. +// + +#import "MMPeripheralsProvider.h" +#import + +@implementation MMPeripheralDevice + +- (NSDictionary *)toDictionary { + NSString *busStr = @"USB"; + if (self.busType == MMPeripheralBusTypeThunderbolt) { + busStr = @"Thunderbolt"; + } else if (self.busType == MMPeripheralBusTypePCI) { + busStr = @"PCI"; + } + + return @{ + @"name": self.name ?: @"Unknown Device", + @"busType": busStr, + @"vendorName": self.vendorName ?: @"Unknown", + @"vendorID": @(self.vendorID), + @"productID": @(self.productID), + @"serialNumber": self.serialNumber ?: @"", + @"deviceClass": self.deviceClass ?: @"", + @"locationID": @(self.locationID), + @"isBuiltIn": @(self.isBuiltIn) + }; +} + +@end + +@implementation MMPeripheralsProvider + +- (instancetype)init { + self = [super init]; + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainPeripherals; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.peripherals"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (NSArray *)sampleUSBDevices { + NSMutableArray *devices = [NSMutableArray array]; + + // Query both IOUSBHostDevice (macOS modern) and IOUSBDevice (legacy/fallback) + const char *classes[] = { "IOUSBHostDevice", "IOUSBDevice" }; + NSMutableSet *seenRegistryIDs = [NSMutableSet set]; + + for (int i = 0; i < 2; i++) { + CFMutableDictionaryRef matching = IOServiceMatching(classes[i]); + if (!matching) continue; + + io_iterator_t iterator = IO_OBJECT_NULL; + kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator); + if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) continue; + + io_service_t service = IO_OBJECT_NULL; + while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + uint64_t regID = 0; + IORegistryEntryGetRegistryEntryID(service, ®ID); + if ([seenRegistryIDs containsObject:@(regID)]) { + IOObjectRelease(service); + continue; + } + [seenRegistryIDs addObject:@(regID)]; + + CFMutableDictionaryRef props = NULL; + if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) { + NSDictionary *dict = (__bridge NSDictionary *)props; + + NSString *name = dict[@"USB Product Name"] ?: (dict[@"kUSBProductString"] ?: dict[@"IOName"]); + if (!name || name.length == 0) { + name = @"USB Device"; + } + + NSString *vendor = dict[@"USB Vendor Name"] ?: (dict[@"kUSBVendorString"] ?: @""); + uint32_t vendorID = [dict[@"idVendor"] unsignedIntValue]; + uint32_t productID = [dict[@"idProduct"] unsignedIntValue]; + NSString *serial = dict[@"USB Serial Number"] ?: dict[@"kUSBSerialNumberString"]; + uint64_t locID = [dict[@"locationID"] unsignedLongLongValue]; + + BOOL builtIn = NO; + if (dict[@"Built-In"]) { + builtIn = [dict[@"Built-In"] boolValue]; + } else if ([dict[@"non-removable"] isKindOfClass:[NSString class]]) { + builtIn = [dict[@"non-removable"] isEqualToString:@"yes"]; + } + + MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init]; + dev.name = name; + dev.busType = MMPeripheralBusTypeUSB; + dev.vendorName = vendor; + dev.vendorID = vendorID; + dev.productID = productID; + dev.serialNumber = serial; + dev.locationID = locID; + dev.isBuiltIn = builtIn; + + [devices addObject:dev]; + CFRelease(props); + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + } + + return devices; +} + ++ (NSArray *)sampleThunderboltDevices { + NSMutableArray *devices = [NSMutableArray array]; + + CFMutableDictionaryRef matching = IOServiceMatching("IOThunderboltDevice"); + if (!matching) return devices; + + io_iterator_t iterator = IO_OBJECT_NULL; + kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator); + if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices; + + io_service_t service = IO_OBJECT_NULL; + while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + CFMutableDictionaryRef props = NULL; + if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) { + NSDictionary *dict = (__bridge NSDictionary *)props; + + NSString *name = dict[@"Device Name"] ?: dict[@"IOName"]; + if (!name) name = @"Thunderbolt Device"; + + NSString *vendor = dict[@"Vendor Name"] ?: @""; + uint32_t vendorID = [dict[@"Vendor ID"] unsignedIntValue]; + uint32_t productID = [dict[@"Device ID"] unsignedIntValue]; + + MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init]; + dev.name = name; + dev.busType = MMPeripheralBusTypeThunderbolt; + dev.vendorName = vendor; + dev.vendorID = vendorID; + dev.productID = productID; + dev.isBuiltIn = NO; + + [devices addObject:dev]; + CFRelease(props); + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + return devices; +} + +static uint32_t ParseDataUInt32(NSData *data) { + if (![data isKindOfClass:[NSData class]] || data.length == 0) return 0; + uint32_t val = 0; + size_t copyLen = MIN(sizeof(val), data.length); + memcpy(&val, data.bytes, copyLen); + return val; +} + ++ (NSArray *)samplePCIDevices { + NSMutableArray *devices = [NSMutableArray array]; + + CFMutableDictionaryRef matching = IOServiceMatching("IOPCIDevice"); + if (!matching) return devices; + + io_iterator_t iterator = IO_OBJECT_NULL; + kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator); + if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices; + + io_service_t service = IO_OBJECT_NULL; + while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) { + CFMutableDictionaryRef props = NULL; + if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) { + NSDictionary *dict = (__bridge NSDictionary *)props; + + NSString *name = nil; + if ([dict[@"model"] isKindOfClass:[NSData class]]) { + name = [[NSString alloc] initWithData:dict[@"model"] encoding:NSUTF8StringEncoding]; + name = [name stringByTrimmingCharactersInSet:[NSCharacterSet controlCharacterSet]]; + } + if (!name || name.length == 0) { + name = dict[@"IOName"]; + } + if (!name || name.length == 0) { + name = @"PCI Device"; + } + + uint32_t vendorID = 0; + if ([dict[@"vendor-id"] isKindOfClass:[NSData class]]) { + vendorID = ParseDataUInt32(dict[@"vendor-id"]); + } + + uint32_t deviceID = 0; + if ([dict[@"device-id"] isKindOfClass:[NSData class]]) { + deviceID = ParseDataUInt32(dict[@"device-id"]); + } + + MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init]; + dev.name = name; + dev.busType = MMPeripheralBusTypePCI; + dev.vendorID = vendorID; + dev.productID = deviceID; + dev.isBuiltIn = YES; + + [devices addObject:dev]; + CFRelease(props); + } + IOObjectRelease(service); + } + IOObjectRelease(iterator); + return devices; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + NSArray *usb = [MMPeripheralsProvider sampleUSBDevices]; + NSArray *tb = [MMPeripheralsProvider sampleThunderboltDevices]; + NSArray *pci = [MMPeripheralsProvider samplePCIDevices]; + + NSMutableArray *> *usbList = [NSMutableArray arrayWithCapacity:usb.count]; + for (MMPeripheralDevice *d in usb) { + [usbList addObject:[d toDictionary]]; + } + + NSMutableArray *> *tbList = [NSMutableArray arrayWithCapacity:tb.count]; + for (MMPeripheralDevice *d in tb) { + [tbList addObject:[d toDictionary]]; + } + + NSMutableArray *> *pciList = [NSMutableArray arrayWithCapacity:pci.count]; + for (MMPeripheralDevice *d in pci) { + [pciList addObject:[d toDictionary]]; + } + + NSUInteger totalCount = usb.count + tb.count + pci.count; + + return @{ + @"totalDeviceCount": @(totalCount), + @"usbDevices": usbList, + @"thunderboltDevices": tbList, + @"pciDevices": pciList + }; +} + +@end diff --git a/Tests/MMPeripheralsTests.swift b/Tests/MMPeripheralsTests.swift new file mode 100644 index 0000000..9ebb4a6 --- /dev/null +++ b/Tests/MMPeripheralsTests.swift @@ -0,0 +1,73 @@ +// +// MMPeripheralsTests.swift +// MacMonitorTests +// +// Unit and integration tests for MMPeripheralsProvider. +// + +import XCTest +@testable import MacMonitor + +final class MMPeripheralsTests: XCTestCase { + var provider: MMPeripheralsProvider! + + override func setUp() { + super.setUp() + provider = MMPeripheralsProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + func testDomainAndIdentifier() { + XCTAssertEqual(provider.domain, .peripherals) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.peripherals") + XCTAssertTrue(provider.isAvailable) + } + + func testSamplePeripheralsLive() { + do { + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + + let total = sample["totalDeviceCount"] as? Int ?? 0 + XCTAssertGreaterThan(total, 0, "Host system should have at least 1 PCI/USB device") + + let pci = sample["pciDevices"] as? [[String: Any]] ?? [] + XCTAssertGreaterThan(pci.count, 0, "Intel Mac should have PCI devices (host bridge, GPU, etc.)") + + if let firstPCI = pci.first { + XCTAssertNotNil(firstPCI["name"]) + XCTAssertEqual(firstPCI["busType"] as? String, "PCI") + } + + let usb = sample["usbDevices"] as? [[String: Any]] ?? [] + for dev in usb { + XCTAssertEqual(dev["busType"] as? String, "USB") + XCTAssertNotNil(dev["name"]) + } + } catch { + XCTFail("sampleTelemetry threw error: \(error)") + } + } + + func testPeripheralDeviceModel() { + let dev = MMPeripheralDevice() + dev.name = "Magic Trackpad" + dev.busType = .USB + dev.vendorName = "Apple Inc." + dev.vendorID = 0x05ac + dev.productID = 0x0265 + dev.isBuiltIn = false + + let dict = dev.toDictionary() + XCTAssertEqual(dict["name"] as? String, "Magic Trackpad") + XCTAssertEqual(dict["busType"] as? String, "USB") + XCTAssertEqual(dict["vendorName"] as? String, "Apple Inc.") + XCTAssertEqual(dict["vendorID"] as? UInt32, 0x05ac) + XCTAssertEqual(dict["productID"] as? UInt32, 0x0265) + XCTAssertEqual(dict["isBuiltIn"] as? Bool, false) + } +} -- 2.39.5 From 544de0ff88257f80fc025c31fd233f5f95af8743 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:49:57 +0100 Subject: [PATCH 26/39] feat(telemetry): implement CoreAudio audio devices telemetry provider (fixes #21) --- MacMonitor.xcodeproj/project.pbxproj | 18 ++ Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + .../Audio/MMAudioTelemetryProvider.h | 40 +++ .../Audio/MMAudioTelemetryProvider.m | 232 ++++++++++++++++++ Tests/MMAudioTests.swift | 80 ++++++ 5 files changed, 371 insertions(+) create mode 100644 Sources/Telemetry/Audio/MMAudioTelemetryProvider.h create mode 100644 Sources/Telemetry/Audio/MMAudioTelemetryProvider.m create mode 100644 Tests/MMAudioTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index b38f7a7..4d03701 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */; }; 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; + 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */; }; A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */; }; A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; @@ -45,6 +46,7 @@ B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; + D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */; }; E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; }; E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; @@ -71,6 +73,7 @@ 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; 0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkSocketsProvider.h; sourceTree = ""; }; 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; + 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAudioTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; 1B07A683AD0EFBA8E1FB6C9D /* MMProcessDetailInspector.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessDetailInspector.h; sourceTree = ""; }; 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; @@ -79,6 +82,7 @@ 2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMBatteryTelemetryProvider.h; sourceTree = ""; }; 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPeripheralsProvider.m; sourceTree = ""; }; 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = ""; }; + 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAudioTelemetryProvider.m; sourceTree = ""; }; 32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = ""; }; 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = ""; }; 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkSocketsTests.swift; sourceTree = ""; }; @@ -95,6 +99,7 @@ 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; + 5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAudioTelemetryProvider.h; sourceTree = ""; }; 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; @@ -241,6 +246,7 @@ 8BF79E3BCB76E7A1669DC930 /* Telemetry */ = { isa = PBXGroup; children = ( + 92E1EF545807CF318620FEAA /* Audio */, 78383CED205BCA772701AE48 /* CPU */, AD2995435E44FA3E1790A4F3 /* Fan */, F99AF003017BA9F19F7AEB1E /* GPU */, @@ -268,6 +274,15 @@ path = Network; sourceTree = ""; }; + 92E1EF545807CF318620FEAA /* Audio */ = { + isa = PBXGroup; + children = ( + 5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */, + 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */, + ); + path = Audio; + sourceTree = ""; + }; 9844EC526E3527F6A582179F /* Hardware */ = { isa = PBXGroup; children = ( @@ -318,6 +333,7 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */, B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */, B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, @@ -474,6 +490,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */, BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */, 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, @@ -502,6 +519,7 @@ files = ( A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, + D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */, 8EDDC2171C55B6E7FC999DAB /* MMBatteryTelemetryProvider.m in Sources */, 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */, 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index e4c3c3f..6279dc7 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -29,5 +29,6 @@ #import "MMGPUTelemetryProvider.h" #import "MMBatteryTelemetryProvider.h" #import "MMPeripheralsProvider.h" +#import "MMAudioTelemetryProvider.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Audio/MMAudioTelemetryProvider.h b/Sources/Telemetry/Audio/MMAudioTelemetryProvider.h new file mode 100644 index 0000000..2199931 --- /dev/null +++ b/Sources/Telemetry/Audio/MMAudioTelemetryProvider.h @@ -0,0 +1,40 @@ +// +// MMAudioTelemetryProvider.h +// MacMonitor +// +// Telemetry provider enumerating CoreAudio input/output devices, sample rates, volume, and active status. +// + +#import +#import "MMTelemetryProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MMAudioDevice : NSObject + +@property (nonatomic, assign) uint32_t deviceID; +@property (nonatomic, copy) NSString *name; +@property (nonatomic, copy) NSString *manufacturer; +@property (nonatomic, copy) NSString *uid; +@property (nonatomic, assign) BOOL isInput; +@property (nonatomic, assign) BOOL isOutput; +@property (nonatomic, assign) BOOL isDefaultInput; +@property (nonatomic, assign) BOOL isDefaultOutput; +@property (nonatomic, assign) double sampleRate; +@property (nonatomic, assign) uint32_t channelCount; +@property (nonatomic, assign) float volume; +@property (nonatomic, assign) BOOL isMuted; + +- (NSDictionary *)toDictionary; + +@end + +@interface MMAudioTelemetryProvider : NSObject + +- (instancetype)init; + ++ (NSArray *)sampleAudioDevices; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Telemetry/Audio/MMAudioTelemetryProvider.m b/Sources/Telemetry/Audio/MMAudioTelemetryProvider.m new file mode 100644 index 0000000..2d2dcaf --- /dev/null +++ b/Sources/Telemetry/Audio/MMAudioTelemetryProvider.m @@ -0,0 +1,232 @@ +// +// MMAudioTelemetryProvider.m +// MacMonitor +// +// Telemetry provider enumerating CoreAudio input/output devices, sample rates, volume, and active status. +// + +#import "MMAudioTelemetryProvider.h" +#import + +@implementation MMAudioDevice + +- (NSDictionary *)toDictionary { + return @{ + @"deviceID": @(self.deviceID), + @"name": self.name ?: @"Unknown Audio Device", + @"manufacturer": self.manufacturer ?: @"Unknown", + @"uid": self.uid ?: @"", + @"isInput": @(self.isInput), + @"isOutput": @(self.isOutput), + @"isDefaultInput": @(self.isDefaultInput), + @"isDefaultOutput": @(self.isDefaultOutput), + @"sampleRate": @(self.sampleRate), + @"channelCount": @(self.channelCount), + @"volume": @(roundf(self.volume * 100.0f) / 100.0f), + @"isMuted": @(self.isMuted) + }; +} + +@end + +@implementation MMAudioTelemetryProvider + +- (instancetype)init { + self = [super init]; + return self; +} + +- (MMTelemetryDomain)domain { + return MMTelemetryDomainAudio; +} + +- (NSString *)providerIdentifier { + return @"com.i3omb.macmonitor.telemetry.audio"; +} + +- (BOOL)isAvailable { + return YES; +} + ++ (AudioDeviceID)defaultDeviceForProperty:(AudioObjectPropertySelector)selector { + AudioObjectPropertyAddress address = { + .mSelector = selector, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + AudioDeviceID deviceID = kAudioObjectUnknown; + UInt32 size = sizeof(AudioDeviceID); + OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &size, &deviceID); + if (status == noErr) { + return deviceID; + } + return kAudioObjectUnknown; +} + ++ (NSArray *)sampleAudioDevices { + AudioObjectPropertyAddress address = { + .mSelector = kAudioHardwarePropertyDevices, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + + UInt32 dataSize = 0; + OSStatus status = AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, NULL, &dataSize); + if (status != noErr || dataSize == 0) { + return @[]; + } + + UInt32 deviceCount = dataSize / sizeof(AudioDeviceID); + AudioDeviceID *deviceIDs = (AudioDeviceID *)malloc(dataSize); + if (!deviceIDs) return @[]; + + status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &dataSize, deviceIDs); + if (status != noErr) { + free(deviceIDs); + return @[]; + } + + AudioDeviceID defaultIn = [self defaultDeviceForProperty:kAudioHardwarePropertyDefaultInputDevice]; + AudioDeviceID defaultOut = [self defaultDeviceForProperty:kAudioHardwarePropertyDefaultOutputDevice]; + + NSMutableArray *devices = [NSMutableArray arrayWithCapacity:deviceCount]; + + for (UInt32 i = 0; i < deviceCount; i++) { + AudioDeviceID devID = deviceIDs[i]; + + // Name + CFStringRef nameRef = NULL; + UInt32 propSize = sizeof(CFStringRef); + AudioObjectPropertyAddress nameAddr = { + .mSelector = kAudioObjectPropertyName, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + NSString *name = @"Audio Device"; + if (AudioObjectGetPropertyData(devID, &nameAddr, 0, NULL, &propSize, &nameRef) == noErr && nameRef) { + name = (__bridge_transfer NSString *)nameRef; + } + + // Manufacturer + CFStringRef mfrRef = NULL; + propSize = sizeof(CFStringRef); + AudioObjectPropertyAddress mfrAddr = { + .mSelector = kAudioObjectPropertyManufacturer, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + NSString *mfr = @"Apple Inc."; + if (AudioObjectGetPropertyData(devID, &mfrAddr, 0, NULL, &propSize, &mfrRef) == noErr && mfrRef) { + mfr = (__bridge_transfer NSString *)mfrRef; + } + + // UID + CFStringRef uidRef = NULL; + propSize = sizeof(CFStringRef); + AudioObjectPropertyAddress uidAddr = { + .mSelector = kAudioDevicePropertyDeviceUID, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + NSString *uid = @""; + if (AudioObjectGetPropertyData(devID, &uidAddr, 0, NULL, &propSize, &uidRef) == noErr && uidRef) { + uid = (__bridge_transfer NSString *)uidRef; + } + + // Channels & Input / Output determination + AudioObjectPropertyAddress inputStreamsAddr = { + .mSelector = kAudioDevicePropertyStreams, + .mScope = kAudioDevicePropertyScopeInput, + .mElement = kAudioObjectPropertyElementMain + }; + UInt32 inStreamSize = 0; + AudioObjectGetPropertyDataSize(devID, &inputStreamsAddr, 0, NULL, &inStreamSize); + BOOL isInput = (inStreamSize > 0); + + AudioObjectPropertyAddress outputStreamsAddr = { + .mSelector = kAudioDevicePropertyStreams, + .mScope = kAudioDevicePropertyScopeOutput, + .mElement = kAudioObjectPropertyElementMain + }; + UInt32 outStreamSize = 0; + AudioObjectGetPropertyDataSize(devID, &outputStreamsAddr, 0, NULL, &outStreamSize); + BOOL isOutput = (outStreamSize > 0); + + // Sample rate + Float64 sampleRate = 0.0; + propSize = sizeof(Float64); + AudioObjectPropertyAddress rateAddr = { + .mSelector = kAudioDevicePropertyNominalSampleRate, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + AudioObjectGetPropertyData(devID, &rateAddr, 0, NULL, &propSize, &sampleRate); + + // Volume + Float32 volume = 0.0f; + AudioObjectPropertyScope volScope = isOutput ? kAudioDevicePropertyScopeOutput : kAudioDevicePropertyScopeInput; + AudioObjectPropertyAddress volAddr = { + .mSelector = kAudioDevicePropertyVolumeScalar, + .mScope = volScope, + .mElement = kAudioObjectPropertyElementMain + }; + propSize = sizeof(Float32); + if (AudioObjectHasProperty(devID, &volAddr)) { + AudioObjectGetPropertyData(devID, &volAddr, 0, NULL, &propSize, &volume); + } + + // Mute status + UInt32 mute = 0; + AudioObjectPropertyAddress muteAddr = { + .mSelector = kAudioDevicePropertyMute, + .mScope = volScope, + .mElement = kAudioObjectPropertyElementMain + }; + propSize = sizeof(UInt32); + if (AudioObjectHasProperty(devID, &muteAddr)) { + AudioObjectGetPropertyData(devID, &muteAddr, 0, NULL, &propSize, &mute); + } + + MMAudioDevice *device = [[MMAudioDevice alloc] init]; + device.deviceID = devID; + device.name = name; + device.manufacturer = mfr; + device.uid = uid; + device.isInput = isInput; + device.isOutput = isOutput; + device.isDefaultInput = (devID == defaultIn); + device.isDefaultOutput = (devID == defaultOut); + device.sampleRate = (double)sampleRate; + device.channelCount = (inStreamSize / sizeof(AudioStreamID)) + (outStreamSize / sizeof(AudioStreamID)); + device.volume = volume; + device.isMuted = (mute != 0); + + [devices addObject:device]; + } + + free(deviceIDs); + return devices; +} + +- (nullable NSDictionary *)sampleTelemetryWithError:(NSError **)error { + NSArray *devices = [MMAudioTelemetryProvider sampleAudioDevices]; + NSMutableArray *> *deviceList = [NSMutableArray arrayWithCapacity:devices.count]; + + MMAudioDevice *defaultIn = nil; + MMAudioDevice *defaultOut = nil; + + for (MMAudioDevice *d in devices) { + [deviceList addObject:[d toDictionary]]; + if (d.isDefaultInput) defaultIn = d; + if (d.isDefaultOutput) defaultOut = d; + } + + return @{ + @"devices": deviceList, + @"deviceCount": @(devices.count), + @"defaultInputDevice": defaultIn ? [defaultIn toDictionary] : [NSNull null], + @"defaultOutputDevice": defaultOut ? [defaultOut toDictionary] : [NSNull null] + }; +} + +@end diff --git a/Tests/MMAudioTests.swift b/Tests/MMAudioTests.swift new file mode 100644 index 0000000..b12c124 --- /dev/null +++ b/Tests/MMAudioTests.swift @@ -0,0 +1,80 @@ +// +// MMAudioTests.swift +// MacMonitorTests +// +// Unit and integration tests for MMAudioTelemetryProvider. +// + +import XCTest +@testable import MacMonitor + +final class MMAudioTests: XCTestCase { + var provider: MMAudioTelemetryProvider! + + override func setUp() { + super.setUp() + provider = MMAudioTelemetryProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + func testDomainAndIdentifier() { + XCTAssertEqual(provider.domain, .audio) + XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.audio") + XCTAssertTrue(provider.isAvailable) + } + + func testSampleAudioDevicesLive() { + do { + let sample = try provider.sampleTelemetry() + XCTAssertNotNil(sample) + + let count = sample["deviceCount"] as? Int ?? 0 + XCTAssertGreaterThanOrEqual(count, 0) + + let devices = sample["devices"] as? [[String: Any]] ?? [] + XCTAssertEqual(devices.count, count) + + if count > 0 { + for dev in devices { + XCTAssertNotNil(dev["name"]) + XCTAssertNotNil(dev["deviceID"]) + XCTAssertNotNil(dev["isInput"]) + XCTAssertNotNil(dev["isOutput"]) + } + } + } catch { + XCTFail("sampleTelemetry threw error: \(error)") + } + } + + func testAudioDeviceModel() { + let dev = MMAudioDevice() + dev.deviceID = 42 + dev.name = "External USB DAC" + dev.manufacturer = "FiiO" + dev.uid = "USB:1234" + dev.isInput = false + dev.isOutput = true + dev.isDefaultOutput = true + dev.isDefaultInput = false + dev.sampleRate = 96000.0 + dev.channelCount = 2 + dev.volume = 0.75 + dev.isMuted = false + + let dict = dev.toDictionary() + XCTAssertEqual(dict["deviceID"] as? UInt32, 42) + XCTAssertEqual(dict["name"] as? String, "External USB DAC") + XCTAssertEqual(dict["manufacturer"] as? String, "FiiO") + XCTAssertEqual(dict["isOutput"] as? Bool, true) + XCTAssertEqual(dict["isDefaultOutput"] as? Bool, true) + XCTAssertEqual(dict["sampleRate"] as? Double, 96000.0) + XCTAssertEqual(dict["channelCount"] as? UInt32, 2) + XCTAssertEqual(dict["volume"] as? Float, 0.75) + XCTAssertEqual(dict["isMuted"] as? Bool, false) + } +} -- 2.39.5 From c8743e25786f1b19f51484f4d166e9cb905d1d3a Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 12:54:34 +0100 Subject: [PATCH 27/39] feat(ui): integrate Milestone 3 telemetry views, process inspector, and reactive cards --- Sources/App/SystemTelemetryStore.swift | 394 +++++++++++- Sources/UI/ContentView.swift | 852 +++++++++++++++++++------ Tests/MacMonitorTests.swift | 2 +- 3 files changed, 1046 insertions(+), 202 deletions(-) diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index 85c84f3..9a5efed 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -81,6 +81,143 @@ public struct PowerMetrics: Sendable { public var sensorReadings: [(key: String, name: String, value: Double, unit: String)] = [] } +// MARK: - Milestone 3 Advanced Telemetry Models + +public struct KernelCountersMetrics: Sendable { + public var contextSwitchesPerSec: Double = 0 + public var syscallsPerSec: Double = 0 + public var pageFaultsPerSec: Double = 0 + public var cowFaultsPerSec: Double = 0 + public var zeroFillFaultsPerSec: Double = 0 + public var pageinsPerSec: Double = 0 + public var pageoutsPerSec: Double = 0 + public var totalContextSwitches: UInt64 = 0 + public var totalSyscalls: UInt64 = 0 + public var totalPageFaults: UInt64 = 0 +} + +public struct SystemLoadMetrics: Sendable { + public var load1Min: Double = 0 + public var load5Min: Double = 0 + public var load15Min: Double = 0 + public var taskCount: Int = 0 + public var threadCount: Int = 0 + public var machFactor: Double = 0 +} + +public struct DiskIOItem: Identifiable, Sendable { + public var id: String { bsdName } + public let bsdName: String + public let readBps: Double + public let writeBps: Double + public let readIOPS: Double + public let writeIOPS: Double + public let totalBytesRead: UInt64 + public let totalBytesWritten: UInt64 +} + +public struct NetworkBandwidthItem: Identifiable, Sendable { + public var id: String { interfaceName } + public let interfaceName: String + public let ipAddress: String + public let isUp: Bool + public let downloadBps: Double + public let uploadBps: Double + public let downloadPps: Double + public let uploadPps: Double + public let totalBytesIn: UInt64 + public let totalBytesOut: UInt64 +} + +public struct SocketItem: Identifiable, Sendable { + public var id: String { "\(pid)_\(fd)_\(localAddress):\(localPort)" } + public let pid: Int32 + public let fd: Int32 + public let processName: String + public let protocolName: String + public let localAddress: String + public let localPort: UInt16 + public let remoteAddress: String + public let remotePort: UInt16 + public let tcpState: String +} + +public struct ProcessItem: Identifiable, Sendable { + public var id: Int32 { pid } + public let pid: Int32 + public let ppid: Int32 + public let name: String + public let cpuPercent: Double + public let residentSize: UInt64 + public let virtualSize: UInt64 + public let threadCount: Int32 + public let uid: UInt32 + public let username: String + public let isStopped: Bool + public let isZombie: Bool +} + +public struct GPUCardItem: Identifiable, Sendable { + public var id: String { name } + public let name: String + public let isDiscrete: Bool + public let isLowPower: Bool + public let vramTotalBytes: UInt64 + public let vramUsedBytes: UInt64 + public let utilizationPercent: Double + public let temperature: Double +} + +public struct BatteryHealthMetrics: Sendable { + public var hasBattery: Bool = false + public var installed: Bool = false + public var healthCondition: String = "Normal" + public var healthPercent: Double = 100.0 + public var cycleCount: Int = 0 + public var designCycleCount: Int = 1000 + public var currentCapacity: Int = 0 + public var maxCapacity: Int = 0 + public var designCapacity: Int = 0 + public var temperature: Double = 0 + public var voltage: Double = 0 + public var amperage: Double = 0 + public var watts: Double = 0 + public var isCharging: Bool = false + public var isCharged: Bool = false + public var externalConnected: Bool = false + public var timeRemainingMinutes: Int = -1 + public var manufacturer: String = "Apple" + public var serial: String = "" + public var adapterWatts: Int = 0 +} + +public struct PeripheralDeviceItem: Identifiable, Sendable { + public var id: String { "\(busType)_\(locationID)_\(name)" } + public let name: String + public let busType: String + public let vendorName: String + public let vendorID: UInt32 + public let productID: UInt32 + public let serialNumber: String + public let locationID: UInt64 + public let isBuiltIn: Bool +} + +public struct AudioDeviceItem: Identifiable, Sendable { + public var id: UInt32 { deviceID } + public let deviceID: UInt32 + public let name: String + public let manufacturer: String + public let isInput: Bool + public let isOutput: Bool + public let isDefaultInput: Bool + public let isDefaultOutput: Bool + public let sampleRate: Double + public let channelCount: UInt32 + public let volume: Float + public let isMuted: Bool +} + // MARK: - SystemTelemetryStore @Observable @@ -95,7 +232,7 @@ public final class SystemTelemetryStore { /// Raw snapshots organized by provider identifier public private(set) var latestSnapshot: [String: [String: Any]] = [:] - /// Structured Telemetry Metrics + /// Milestone 2 Structured Metrics public private(set) var cpuLoad = CPULoadMetrics() public private(set) var memory = MemoryMetrics() public private(set) var storageVolumes: [StorageVolumeItem] = [] @@ -104,6 +241,21 @@ public final class SystemTelemetryStore { public private(set) var componentTemps: [ComponentThermalItem] = [] 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] = [] + + /// Process detail inspection helper + public let processInspector = MMProcessDetailInspector() + /// System identification public let hostModel: String public let osVersion: String @@ -125,7 +277,7 @@ public final class SystemTelemetryStore { } private func registerDefaultProviders() { - // Register all Primary System & Thermal Telemetry providers (M2) + // Milestone 2 Primary System & Thermal Telemetry coordinator.register(MMCPULoadProvider()) coordinator.register(MMMemoryTelemetryProvider()) coordinator.register(MMStorageTelemetryProvider()) @@ -133,6 +285,18 @@ public final class SystemTelemetryStore { coordinator.register(MMFanTelemetryProvider()) coordinator.register(MMComponentThermalProvider()) coordinator.register(MMPowerTelemetryProvider()) + + // Milestone 3 Advanced Kernel, Process & Peripheral Telemetry + coordinator.register(MMKernelTelemetryProvider()) + coordinator.register(MMLoadAverageProvider()) + coordinator.register(MMDiskIOProvider()) + coordinator.register(MMNetworkBandwidthProvider()) + coordinator.register(MMNetworkSocketsProvider()) + coordinator.register(MMProcessTelemetryProvider()) + coordinator.register(MMGPUTelemetryProvider()) + coordinator.register(MMBatteryTelemetryProvider()) + coordinator.register(MMPeripheralsProvider()) + coordinator.register(MMAudioTelemetryProvider()) } private func setupCoordinator() { @@ -178,7 +342,7 @@ public final class SystemTelemetryStore { self.memory = m } - // 3. Storage + // 3. Storage Volumes if let stgDict = snapshot["com.i3omb.macmonitor.telemetry.storage"], let volArray = stgDict["volumes"] as? [[String: Any]] { self.storageVolumes = volArray.compactMap { dict in @@ -269,6 +433,230 @@ public final class SystemTelemetryStore { } self.power = m } + + // 8. Kernel Counters + if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel"] { + var m = KernelCountersMetrics() + m.contextSwitchesPerSec = (kDict["contextSwitchesPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.syscallsPerSec = (kDict["syscallsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.pageFaultsPerSec = (kDict["pageFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.cowFaultsPerSec = (kDict["cowFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.zeroFillFaultsPerSec = (kDict["zeroFillFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.pageinsPerSec = (kDict["pageinsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.pageoutsPerSec = (kDict["pageoutsPerSec"] as? NSNumber)?.doubleValue ?? 0 + m.totalContextSwitches = (kDict["totalContextSwitches"] as? NSNumber)?.uint64Value ?? 0 + m.totalSyscalls = (kDict["totalSyscalls"] as? NSNumber)?.uint64Value ?? 0 + m.totalPageFaults = (kDict["totalPageFaults"] as? NSNumber)?.uint64Value ?? 0 + self.kernelCounters = m + } + + // 9. System Load Average & Mach Factor + if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.systemload"] { + var m = SystemLoadMetrics() + m.load1Min = (loadDict["load1Min"] as? NSNumber)?.doubleValue ?? 0 + m.load5Min = (loadDict["load5Min"] as? NSNumber)?.doubleValue ?? 0 + m.load15Min = (loadDict["load15Min"] as? NSNumber)?.doubleValue ?? 0 + m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0 + m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0 + m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0 + self.systemLoad = m + } + + // 10. Disk I/O & IOPS + if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.diskio"], + let disks = diskDict["disks"] as? [[String: Any]] { + self.diskIO = disks.compactMap { d in + guard let bsd = d["bsdName"] as? String else { return nil } + return DiskIOItem( + bsdName: bsd, + readBps: (d["readBps"] as? NSNumber)?.doubleValue ?? 0, + writeBps: (d["writeBps"] as? NSNumber)?.doubleValue ?? 0, + readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0, + writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0, + totalBytesRead: (d["totalBytesRead"] as? NSNumber)?.uint64Value ?? 0, + totalBytesWritten: (d["totalBytesWritten"] as? NSNumber)?.uint64Value ?? 0 + ) + } + } + + // 11. Network Bandwidth + if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"], + let ifaces = netDict["interfaces"] as? [[String: Any]] { + self.networkBandwidth = ifaces.compactMap { d in + guard let name = d["interfaceName"] as? String else { return nil } + return NetworkBandwidthItem( + interfaceName: name, + ipAddress: d["ipAddress"] as? String ?? "", + isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false, + downloadBps: (d["downloadBps"] as? NSNumber)?.doubleValue ?? 0, + uploadBps: (d["uploadBps"] as? NSNumber)?.doubleValue ?? 0, + downloadPps: (d["downloadPps"] as? NSNumber)?.doubleValue ?? 0, + uploadPps: (d["uploadPps"] as? NSNumber)?.doubleValue ?? 0, + totalBytesIn: (d["totalBytesIn"] as? NSNumber)?.uint64Value ?? 0, + totalBytesOut: (d["totalBytesOut"] as? NSNumber)?.uint64Value ?? 0 + ) + } + } + + // 12. Network Sockets + if let sockDict = snapshot["com.i3omb.macmonitor.telemetry.network.sockets"], + let socks = sockDict["sockets"] as? [[String: Any]] { + self.networkSockets = socks.compactMap { d in + let pid = (d["pid"] as? NSNumber)?.int32Value ?? 0 + let fd = (d["fd"] as? NSNumber)?.int32Value ?? 0 + return SocketItem( + pid: pid, + fd: fd, + processName: d["processName"] as? String ?? "", + protocolName: d["protocol"] as? String ?? "TCP", + localAddress: d["localAddress"] as? String ?? "*", + localPort: (d["localPort"] as? NSNumber)?.uint16Value ?? 0, + remoteAddress: d["remoteAddress"] as? String ?? "*", + remotePort: (d["remotePort"] as? NSNumber)?.uint16Value ?? 0, + tcpState: d["tcpState"] as? String ?? "ESTABLISHED" + ) + } + } + + // 13. Processes + if let procDict = snapshot["com.i3omb.macmonitor.telemetry.process"], + let procs = procDict["processes"] as? [[String: Any]] { + self.processes = procs.compactMap { d in + guard let pid = (d["pid"] as? NSNumber)?.int32Value else { return nil } + return ProcessItem( + pid: pid, + ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0, + name: d["name"] as? String ?? "Unknown", + cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0, + residentSize: (d["residentSize"] as? NSNumber)?.uint64Value ?? 0, + virtualSize: (d["virtualSize"] as? NSNumber)?.uint64Value ?? 0, + threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1, + uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0, + username: d["username"] as? String ?? "", + isStopped: (d["isStopped"] as? NSNumber)?.boolValue ?? false, + isZombie: (d["isZombie"] as? NSNumber)?.boolValue ?? false + ) + } + } + + // 14. GPU Telemetry + if let gpuDict = snapshot["com.i3omb.macmonitor.telemetry.gpu"], + let cards = gpuDict["gpus"] as? [[String: Any]] { + self.gpus = cards.compactMap { d in + guard let name = d["name"] as? String else { return nil } + return GPUCardItem( + name: name, + isDiscrete: (d["isDiscrete"] as? NSNumber)?.boolValue ?? false, + isLowPower: (d["isLowPower"] as? NSNumber)?.boolValue ?? false, + vramTotalBytes: (d["vramTotalBytes"] as? NSNumber)?.uint64Value ?? 0, + vramUsedBytes: (d["vramUsedBytes"] as? NSNumber)?.uint64Value ?? 0, + utilizationPercent: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0, + temperature: (d["temperature"] as? NSNumber)?.doubleValue ?? 0 + ) + } + } + + // 15. Battery Health + if let battDict = snapshot["com.i3omb.macmonitor.telemetry.battery"] { + var m = BatteryHealthMetrics() + m.hasBattery = (battDict["hasBattery"] as? NSNumber)?.boolValue ?? false + m.installed = (battDict["installed"] as? NSNumber)?.boolValue ?? false + m.healthCondition = battDict["healthCondition"] as? String ?? "Normal" + m.healthPercent = (battDict["healthPercent"] as? NSNumber)?.doubleValue ?? 100.0 + m.cycleCount = (battDict["cycleCount"] as? NSNumber)?.intValue ?? 0 + m.designCycleCount = (battDict["designCycleCount"] as? NSNumber)?.intValue ?? 1000 + m.currentCapacity = (battDict["currentCapacity"] as? NSNumber)?.intValue ?? 0 + m.maxCapacity = (battDict["maxCapacity"] as? NSNumber)?.intValue ?? 0 + m.designCapacity = (battDict["designCapacity"] as? NSNumber)?.intValue ?? 0 + m.temperature = (battDict["temperature"] as? NSNumber)?.doubleValue ?? 0 + m.voltage = (battDict["voltage"] as? NSNumber)?.doubleValue ?? 0 + m.amperage = (battDict["amperage"] as? NSNumber)?.doubleValue ?? 0 + m.watts = (battDict["watts"] as? NSNumber)?.doubleValue ?? 0 + m.isCharging = (battDict["isCharging"] as? NSNumber)?.boolValue ?? false + m.isCharged = (battDict["isCharged"] as? NSNumber)?.boolValue ?? false + m.externalConnected = (battDict["externalConnected"] as? NSNumber)?.boolValue ?? false + m.timeRemainingMinutes = (battDict["timeRemainingMinutes"] as? NSNumber)?.intValue ?? -1 + m.manufacturer = battDict["manufacturer"] as? String ?? "Apple" + m.serial = battDict["serial"] as? String ?? "" + m.adapterWatts = (battDict["adapterWatts"] as? NSNumber)?.intValue ?? 0 + self.batteryHealth = m + } + + // 16. Connected Peripherals + if let periphDict = snapshot["com.i3omb.macmonitor.telemetry.peripherals"] { + var allDevs: [PeripheralDeviceItem] = [] + if let usb = periphDict["usbDevices"] as? [[String: Any]] { + allDevs.append(contentsOf: usb.compactMap { d in + guard let name = d["name"] as? String else { return nil } + return PeripheralDeviceItem( + name: name, + busType: "USB", + vendorName: d["vendorName"] as? String ?? "", + vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0, + productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0, + serialNumber: d["serialNumber"] as? String ?? "", + locationID: (d["locationID"] as? NSNumber)?.uint64Value ?? 0, + isBuiltIn: (d["isBuiltIn"] as? NSNumber)?.boolValue ?? false + ) + }) + } + if let tb = periphDict["thunderboltDevices"] as? [[String: Any]] { + allDevs.append(contentsOf: tb.compactMap { d in + guard let name = d["name"] as? String else { return nil } + return PeripheralDeviceItem( + name: name, + busType: "Thunderbolt", + vendorName: d["vendorName"] as? String ?? "", + vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0, + productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0, + serialNumber: "", + locationID: 0, + isBuiltIn: false + ) + }) + } + if let pci = periphDict["pciDevices"] as? [[String: Any]] { + allDevs.append(contentsOf: pci.compactMap { d in + guard let name = d["name"] as? String else { return nil } + return PeripheralDeviceItem( + name: name, + busType: "PCI", + vendorName: "Apple / Intel", + vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0, + productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0, + serialNumber: "", + locationID: 0, + isBuiltIn: true + ) + }) + } + self.peripherals = allDevs + } + + // 17. Audio Devices + if let audioDict = snapshot["com.i3omb.macmonitor.telemetry.audio"], + let devs = audioDict["devices"] as? [[String: Any]] { + self.audioDevices = devs.compactMap { d in + guard let devID = (d["deviceID"] as? NSNumber)?.uint32Value else { return nil } + return AudioDeviceItem( + deviceID: devID, + name: d["name"] as? String ?? "Audio Device", + manufacturer: d["manufacturer"] as? String ?? "Apple Inc.", + isInput: (d["isInput"] as? NSNumber)?.boolValue ?? false, + isOutput: (d["isOutput"] as? NSNumber)?.boolValue ?? false, + isDefaultInput: (d["isDefaultInput"] as? NSNumber)?.boolValue ?? false, + isDefaultOutput: (d["isDefaultOutput"] as? NSNumber)?.boolValue ?? false, + sampleRate: (d["sampleRate"] as? NSNumber)?.doubleValue ?? 44100.0, + channelCount: (d["channelCount"] as? NSNumber)?.uint32Value ?? 2, + volume: (d["volume"] as? NSNumber)?.floatValue ?? 0, + isMuted: (d["isMuted"] as? NSNumber)?.boolValue ?? false + ) + } + } + } + + public func terminateProcess(pid: pid_t, force: Bool) -> Bool { + return MMProcessTelemetryProvider.terminateProcess(withPID: pid, force: force) } public func start() { diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index e560719..3723648 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -3,6 +3,10 @@ import SwiftUI public struct ContentView: View { @State 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 public init() {} @@ -12,10 +16,15 @@ public struct ContentView: View { } detail: { detailContent } - .frame(minWidth: 900, minHeight: 650) + .frame(minWidth: 1000, minHeight: 700) .onAppear { store.start() } + .sheet(isPresented: $showingProcessDetail) { + if let pid = selectedPID { + ProcessDetailSheet(inspector: store.processInspector, pid: pid) + } + } } // MARK: - Sidebar @@ -28,23 +37,43 @@ public struct ContentView: View { .tag("Architecture") } - Section("Primary Telemetry") { + Section("Core Telemetry") { Label("CPU Load & Cores", systemImage: "chart.bar.xaxis") .tag("CPU") + Label("Kernel & System Load", systemImage: "waveform.path.ecg") + .tag("Kernel") Label("Thermal Matrix", systemImage: "flame") .tag("Thermals") Label("Fans & Cooling", systemImage: "fanblades") .tag("Fans") Label("Memory & Swap", systemImage: "memorychip") .tag("Memory") - Label("Storage Volumes", systemImage: "internaldrive") + } + + Section("I/O & Storage") { + Label("Storage & Disk I/O", systemImage: "internaldrive") .tag("Storage") - Label("Power & Voltage", systemImage: "bolt.fill") + Label("Network Traffic", systemImage: "network") + .tag("Network") + Label("Active Sockets", systemImage: "point.3.filled.connected.trianglepath.dotted") + .tag("Sockets") + } + + Section("Hardware & Devices") { + Label("Process Explorer", systemImage: "list.bullet.rectangle") + .tag("Processes") + Label("Graphics (GPU)", systemImage: "display") + .tag("GPU") + Label("Power & Battery", systemImage: "bolt.fill") .tag("Power") + Label("Peripherals", systemImage: "cable.connector") + .tag("Peripherals") + Label("Audio Devices", systemImage: "speaker.wave.3.fill") + .tag("Audio") } } .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 200, ideal: 230, max: 280) + .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300) } // MARK: - Detail Content Router @@ -64,6 +93,9 @@ public struct ContentView: View { case "CPU": cpuLoadCard specsCard + case "Kernel": + kernelCountersCard + systemLoadCard case "Thermals": cpuThermalCard componentThermalCard @@ -72,9 +104,23 @@ public struct ContentView: View { case "Memory": memoryCard case "Storage": + diskIOCard storageCard + case "Network": + networkBandwidthCard + case "Sockets": + networkSocketsCard + case "Processes": + processExplorerCard + case "GPU": + gpuTelemetryCard case "Power": + batteryHealthCard powerCard + case "Peripherals": + peripheralsCard + case "Audio": + audioDevicesCard default: dashboardView } @@ -87,26 +133,26 @@ public struct ContentView: View { // MARK: - Dashboard Composite View private var dashboardView: some View { VStack(alignment: .leading, spacing: 20) { - // Top Row: CPU & Memory Summary Cards HStack(alignment: .top, spacing: 16) { cpuLoadCard - memoryCard + systemLoadCard + } + HStack(alignment: .top, spacing: 16) { + memoryCard + kernelCountersCard } - - // Middle Row: Thermals & Fans Summary Cards HStack(alignment: .top, spacing: 16) { cpuThermalCard fansCard } - - // Power & Storage Summary HStack(alignment: .top, spacing: 16) { - powerCard - storageCard + diskIOCard + networkBandwidthCard + } + HStack(alignment: .top, spacing: 16) { + gpuTelemetryCard + batteryHealthCard } - - // Detailed Component Thermals Matrix - componentThermalCard } } @@ -245,6 +291,80 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Kernel Counters Card + private var kernelCountersCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg") + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) { + GridRow { + 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 { + 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 { + Text("Pageins / Pageouts:").foregroundStyle(.secondary) + Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit() + Text("Zero-Fill:").foregroundStyle(.secondary) + Text("\(Int(store.kernelCounters.zeroFillFaultsPerSec))/s").bold().monospacedDigit() + } + } + .font(.caption) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - System Load & Mach Factor Card + private var systemLoadCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("System Load & Mach Factor", systemImage: "gauge.high") + .font(.headline) + + HStack(spacing: 24) { + VStack(alignment: .leading, spacing: 2) { + Text("1m Load").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.2f", store.systemLoad.load1Min)).font(.title3.bold().monospacedDigit()) + } + VStack(alignment: .leading, spacing: 2) { + Text("5m Load").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.2f", store.systemLoad.load5Min)).font(.title3.bold().monospacedDigit()) + } + VStack(alignment: .leading, spacing: 2) { + Text("15m Load").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.2f", store.systemLoad.load15Min)).font(.title3.bold().monospacedDigit()) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("Mach Factor").font(.caption).foregroundStyle(.secondary) + Text(String(format: "%.2f", store.systemLoad.machFactor)).font(.title3.bold().monospacedDigit()).foregroundStyle(.blue) + } + } + + Divider() + + HStack { + Text("Active Mach Tasks: \(store.systemLoad.taskCount)").font(.caption).foregroundStyle(.secondary) + Spacer() + Text("Active Mach Threads: \(store.systemLoad.threadCount)").font(.caption).foregroundStyle(.secondary) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + // MARK: - Memory & Swap Card private var memoryCard: some View { VStack(alignment: .leading, spacing: 12) { @@ -263,14 +383,14 @@ public struct ContentView: View { } ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0)) - .tint(pressureColor(store.memory.pressureLevel)) + .tint(store.memory.utilizationPercentage > 85 ? .red : .blue) - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { GridRow { - Text("Physical RAM:").foregroundStyle(.secondary) - Text(formatBytes(store.memory.totalBytes)).bold() Text("Used:").foregroundStyle(.secondary) Text(formatBytes(store.memory.usedBytes)).bold() + Text("Free:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.freeBytes)).bold() } GridRow { Text("Wired:").foregroundStyle(.secondary) @@ -279,18 +399,10 @@ public struct ContentView: View { Text(formatBytes(store.memory.compressedBytes)) } GridRow { - Text("Active:").foregroundStyle(.secondary) - Text(formatBytes(store.memory.activeBytes)) - Text("Free / Inactive:").foregroundStyle(.secondary) - Text(formatBytes(store.memory.freeBytes + store.memory.inactiveBytes)) - } - if store.memory.swapTotalBytes > 0 { - GridRow { - Text("Swap Total:").foregroundStyle(.secondary) - Text(formatBytes(store.memory.swapTotalBytes)) - Text("Swap Used:").foregroundStyle(.secondary) - Text(formatBytes(store.memory.swapUsedBytes)) - } + Text("Swap Used:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.swapUsedBytes)) + Text("Swap Total:").foregroundStyle(.secondary) + Text(formatBytes(store.memory.swapTotalBytes)) } } .font(.caption) @@ -301,59 +413,412 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Disk I/O Card + private var diskIOCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Disk I/O & IOPS", systemImage: "externaldrive.badge.timemachine") + .font(.headline) + + if store.diskIO.isEmpty { + Text("Sampling disk I/O metrics...").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(store.diskIO) { disk in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(disk.bsdName).bold() + Text("IOPS: \(Int(disk.readIOPS + disk.writeIOPS)) (R: \(Int(disk.readIOPS)), W: \(Int(disk.writeIOPS)))") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("R: \(formatBps(disk.readBps))").font(.caption.bold().monospacedDigit()).foregroundStyle(.green) + Text("W: \(formatBps(disk.writeBps))").font(.caption.bold().monospacedDigit()).foregroundStyle(.blue) + } + } + .padding(.vertical, 2) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Storage Volumes Card + private var storageCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Mounted Storage Volumes", systemImage: "internaldrive") + .font(.headline) + + ForEach(store.storageVolumes) { vol in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(vol.volumeName).bold() + Text("(\(vol.mountPoint)) โ€ข \(vol.fileSystem)").font(.caption).foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1f%%", vol.usedPercentage)).font(.caption.bold().monospacedDigit()) + } + ProgressView(value: min(vol.usedPercentage / 100.0, 1.0)) + .tint(vol.usedPercentage > 90 ? .red : .blue) + HStack { + Text("Used: \(formatBytes(vol.usedBytes))").font(.caption2).foregroundStyle(.secondary) + Spacer() + Text("Free: \(formatBytes(vol.freeBytes))").font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Network Bandwidth Card + private var networkBandwidthCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Network Bandwidth", systemImage: "network") + .font(.headline) + + if store.networkBandwidth.isEmpty { + Text("No network interfaces detected").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(store.networkBandwidth) { iface in + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Circle().fill(iface.isUp ? Color.green : Color.gray).frame(width: 8, height: 8) + Text(iface.interfaceName).bold() + } + if !iface.ipAddress.isEmpty { + Text(iface.ipAddress).font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("โ†“ \(formatBps(iface.downloadBps)) (\(Int(iface.downloadPps)) pps)") + .font(.caption.bold().monospacedDigit()).foregroundStyle(.green) + Text("โ†‘ \(formatBps(iface.uploadBps)) (\(Int(iface.uploadPps)) pps)") + .font(.caption.bold().monospacedDigit()).foregroundStyle(.blue) + } + } + .padding(.vertical, 4) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Network Sockets Card + private var networkSocketsCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Active Network Sockets (\(store.networkSockets.count))", systemImage: "point.3.filled.connected.trianglepath.dotted") + .font(.headline) + Spacer() + TextField("Filter socket/PID/process...", text: $socketSearchText) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + + let filtered = store.networkSockets.filter { + socketSearchText.isEmpty || + $0.processName.localizedCaseInsensitiveContains(socketSearchText) || + $0.localAddress.localizedCaseInsensitiveContains(socketSearchText) || + $0.remoteAddress.localizedCaseInsensitiveContains(socketSearchText) || + "\($0.pid)".contains(socketSearchText) + } + + Table(filtered.prefix(150)) { + TableColumn("Process") { s in + Text("\(s.processName) (\(s.pid))").bold() + } + TableColumn("Proto") { s in + Text(s.protocolName) + } + TableColumn("Local Endpoint") { s in + Text("\(s.localAddress):\(s.localPort)").monospaced() + } + TableColumn("Remote Endpoint") { s in + Text("\(s.remoteAddress):\(s.remotePort)").monospaced() + } + TableColumn("State") { s in + Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary) + } + } + .frame(minHeight: 350) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Process Explorer Card + private var processExplorerCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Process Explorer (\(store.processes.count))", systemImage: "list.bullet.rectangle") + .font(.headline) + Spacer() + TextField("Search name or PID...", text: $processSearchText) + .textFieldStyle(.roundedBorder) + .frame(width: 220) + } + + let filtered = store.processes.filter { + processSearchText.isEmpty || + $0.name.localizedCaseInsensitiveContains(processSearchText) || + "\($0.pid)".contains(processSearchText) || + $0.username.localizedCaseInsensitiveContains(processSearchText) + } + + Table(filtered.prefix(200)) { + TableColumn("PID") { p in + Text("\(p.pid)").monospacedDigit() + } + TableColumn("Process Name") { p in + HStack { + Text(p.name).bold() + if p.isStopped { + Text("STOP").font(.caption2).foregroundStyle(.red) + } + } + } + TableColumn("% CPU") { p in + Text(String(format: "%.1f%%", p.cpuPercent)) + .monospacedDigit() + .foregroundStyle(p.cpuPercent > 50 ? .red : .primary) + } + TableColumn("RSS Memory") { p in + Text(formatBytes(p.residentSize)).monospacedDigit() + } + TableColumn("Threads") { p in + Text("\(p.threadCount)").monospacedDigit() + } + TableColumn("User") { p in + Text(p.username) + } + TableColumn("Actions") { p in + HStack(spacing: 8) { + Button("Inspect") { + selectedPID = p.pid + showingProcessDetail = true + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + + Button("Kill") { + _ = store.terminateProcess(pid: p.pid, force: false) + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + } + } + .frame(minHeight: 400) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - GPU Card + private var gpuTelemetryCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Graphics Accelerators (GPU)", systemImage: "display") + .font(.headline) + + if store.gpus.isEmpty { + Text("No GPU accelerators detected").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(store.gpus) { gpu in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(gpu.name).bold() + Spacer() + if gpu.temperature > 0 { + Text(String(format: "%.1fยฐC", gpu.temperature)) + .font(.caption.bold()) + .foregroundStyle(gpu.temperature > 85 ? .red : .primary) + } + Text(String(format: "%.1f%%", gpu.utilizationPercent)) + .font(.title3.bold().monospacedDigit()) + } + ProgressView(value: min(gpu.utilizationPercent / 100.0, 1.0)) + .tint(gpu.utilizationPercent > 80 ? .red : .blue) + HStack { + Text(gpu.isDiscrete ? "Discrete GPU" : "Integrated GPU").font(.caption2).foregroundStyle(.secondary) + Spacer() + if gpu.vramTotalBytes > 0 { + Text("VRAM: \(formatBytes(gpu.vramUsedBytes)) / \(formatBytes(gpu.vramTotalBytes))").font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Battery Health Card + private var batteryHealthCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Battery & Health", systemImage: "battery.100.bolt") + .font(.headline) + Spacer() + if store.batteryHealth.hasBattery { + Text(store.batteryHealth.healthCondition) + .font(.caption.bold()) + .foregroundStyle(store.batteryHealth.healthCondition == "Normal" ? .green : .orange) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.15), in: Capsule()) + Text(String(format: "%.1f%%", store.batteryHealth.healthPercent)) + .font(.title3.bold().monospacedDigit()) + } + } + + if !store.batteryHealth.hasBattery { + Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary) + } else { + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { + GridRow { + 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 { + 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 { + Text("Capacity:").foregroundStyle(.secondary) + Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh") + Text("Manufacturer:").foregroundStyle(.secondary) + Text(store.batteryHealth.manufacturer) + } + } + .font(.caption) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Peripherals Card + private var peripheralsCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Connected Peripherals (\(store.peripherals.count))", systemImage: "cable.connector") + .font(.headline) + + Table(store.peripherals) { + TableColumn("Device Name") { d in + Text(d.name).bold() + } + TableColumn("Bus") { d in + Text(d.busType) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } + TableColumn("Vendor ID") { d in + Text(String(format: "0x%04x", d.vendorID)).monospaced() + } + TableColumn("Product ID") { d in + Text(String(format: "0x%04x", d.productID)).monospaced() + } + TableColumn("Built-In") { d in + Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue) + } + } + .frame(minHeight: 350) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Audio Devices Card + private var audioDevicesCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Audio Devices (\(store.audioDevices.count))", systemImage: "speaker.wave.3.fill") + .font(.headline) + + ForEach(store.audioDevices) { audio in + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(audio.name).bold() + if audio.isDefaultOutput { + Text("DEFAULT OUT").font(.caption2.bold()).foregroundStyle(.green) + } + if audio.isDefaultInput { + Text("DEFAULT IN").font(.caption2.bold()).foregroundStyle(.blue) + } + } + Text("\(audio.manufacturer) โ€ข \(Int(audio.sampleRate)) Hz โ€ข \(audio.channelCount) Channels") + .font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if audio.isOutput { + Text("Vol: \(Int(audio.volume * 100))%") + .font(.caption.bold().monospacedDigit()) + } + } + .padding(.vertical, 4) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + // MARK: - CPU Thermal Card private var cpuThermalCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Label("CPU Thermals", systemImage: "flame.fill") + Label("CPU Temperature", systemImage: "thermometer.medium") .font(.headline) Spacer() Text(store.cpuThermal.thermalPressureState) .font(.caption.bold()) - .foregroundStyle(store.cpuThermal.isThrottling ? .red : .green) + .foregroundStyle(store.cpuThermal.thermalPressureState == "Nominal" ? .green : .red) .padding(.horizontal, 6) .padding(.vertical, 2) - .background((store.cpuThermal.isThrottling ? Color.red : Color.green).opacity(0.15), in: Capsule()) + .background(Color.green.opacity(0.15), in: Capsule()) Text(String(format: "%.1fยฐC", store.cpuThermal.packageTemperature)) .font(.title3.bold().monospacedDigit()) } - HStack(spacing: 24) { - VStack(alignment: .leading, spacing: 2) { - Text("Package Temp").font(.caption).foregroundStyle(.secondary) - Text(String(format: "%.1fยฐC", store.cpuThermal.packageTemperature)).bold() - } - VStack(alignment: .leading, spacing: 2) { - Text("Peak Core").font(.caption).foregroundStyle(.secondary) - Text(String(format: "%.1fยฐC", store.cpuThermal.peakCoreTemperature)).bold() - } - VStack(alignment: .leading, spacing: 2) { - Text("Average Core").font(.caption).foregroundStyle(.secondary) - Text(String(format: "%.1fยฐC", store.cpuThermal.averageCoreTemperature)).bold() - } - } + ProgressView(value: min(store.cpuThermal.packageTemperature / 105.0, 1.0)) + .tint(store.cpuThermal.packageTemperature > 85 ? .red : (store.cpuThermal.packageTemperature > 70 ? .orange : .blue)) - if !store.cpuThermal.coreTemperatures.isEmpty { - Divider() - Text("Core Temperature Breakdown") - .font(.caption.bold()) - .foregroundStyle(.secondary) - - LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: 8) { - ForEach(store.cpuThermal.coreTemperatures, id: \.key) { core in - VStack(spacing: 2) { - Text("Core \(core.index)") - .font(.system(size: 10)) - .foregroundStyle(.secondary) - Text(String(format: "%.1fยฐC", core.temperature)) - .font(.system(size: 11, weight: .bold, design: .monospaced)) - .foregroundStyle(core.temperature > 85 ? .red : (core.temperature > 70 ? .orange : .primary)) - } - .padding(6) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) - } - } + HStack { + Text("Avg Core: \(String(format: "%.1fยฐC", store.cpuThermal.averageCoreTemperature))") + Spacer() + Text("Peak Core: \(String(format: "%.1fยฐC", store.cpuThermal.peakCoreTemperature))") + Spacer() + Text("Throttling: \(store.cpuThermal.isThrottling ? "YES" : "No")") + .foregroundStyle(store.cpuThermal.isThrottling ? .red : .secondary) } + .font(.caption) + .foregroundStyle(.secondary) } .padding(16) .frame(maxWidth: .infinity, alignment: .topLeading) @@ -361,46 +826,31 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } - // MARK: - Fans & Tachometers Card + // MARK: - Fans Card private var fansCard: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Cooling Fans", systemImage: "fanblades.fill") - .font(.headline) - Spacer() - Text("\(store.fans.count) Detected") - .font(.caption.bold()) - .foregroundStyle(.secondary) - } + Label("Cooling Fans", systemImage: "fanblades") + .font(.headline) if store.fans.isEmpty { - Text("No active AppleSMC fans detected or passive cooling chassis.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.vertical, 8) + Text("No SMC fan sensors detected").font(.caption).foregroundStyle(.secondary) } else { ForEach(store.fans) { fan in - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { HStack { Text(fan.name).bold() Spacer() - Text(String(format: "%.0f RPM (%.0f%%)", fan.currentRPM, fan.utilization)) - .font(.subheadline.bold().monospacedDigit()) + Text("\(Int(fan.currentRPM)) RPM").font(.title3.bold().monospacedDigit()) } ProgressView(value: min(fan.utilization / 100.0, 1.0)) - .tint(fan.utilization > 80 ? .red : .blue) + .tint(fan.utilization > 80 ? .orange : .blue) HStack { - Text("Min: \(Int(fan.minRPM)) RPM") + Text("Min: \(Int(fan.minRPM)) RPM").font(.caption2).foregroundStyle(.secondary) Spacer() - Text("Target: \(Int(fan.targetRPM)) RPM") - Spacer() - Text("Max: \(Int(fan.maxRPM)) RPM") + Text("Max: \(Int(fan.maxRPM)) RPM").font(.caption2).foregroundStyle(.secondary) } - .font(.system(size: 10)) - .foregroundStyle(.secondary) } - .padding(8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + .padding(.vertical, 4) } } } @@ -410,91 +860,32 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } - // MARK: - Component Thermals Matrix Card + // MARK: - Component Thermal Matrix Card private var componentThermalCard: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Chassis & Component Thermals", systemImage: "thermometer.medium") - .font(.headline) - Spacer() - Text("\(store.componentTemps.count) Sensors") - .font(.caption.bold()) - .foregroundStyle(.secondary) - } + Label("Component Thermal Sensors (\(store.componentTemps.count) Probed)", systemImage: "flame") + .font(.headline) - if store.componentTemps.isEmpty { - Text("Awaiting component thermal probe...") - .font(.caption) - .foregroundStyle(.secondary) - } else { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) { - ForEach(store.componentTemps) { item in - VStack(alignment: .leading, spacing: 4) { - Text(item.name) - .font(.system(size: 11, weight: .semibold)) - .lineLimit(1) - HStack { - Text(item.category) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - Spacer() - Text(String(format: "%.1fยฐC", item.temperature)) - .font(.system(size: 11, weight: .bold, design: .monospaced)) - .foregroundStyle(item.temperature > 75 ? .red : .primary) - } - } - .padding(8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - } - } - } - } - .padding(16) - .background(.background, in: RoundedRectangle(cornerRadius: 12)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) - } - - // MARK: - Storage Volumes Card - private var storageCard: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Storage Volumes", systemImage: "internaldrive") - .font(.headline) - Spacer() - Text("\(store.storageVolumes.count) Mounted") - .font(.caption.bold()) - .foregroundStyle(.secondary) - } - - if store.storageVolumes.isEmpty { - Text("No mounted APFS/HFS volumes discovered.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(store.storageVolumes) { vol in + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))], spacing: 10) { + ForEach(store.componentTemps) { item in VStack(alignment: .leading, spacing: 4) { HStack { - Text(vol.volumeName).bold() - Text("(\(vol.fileSystem))").font(.caption).foregroundStyle(.secondary) + Text(item.name).font(.caption).bold().lineLimit(1) Spacer() - Text("\(formatBytes(vol.usedBytes)) / \(formatBytes(vol.totalBytes))") - .font(.caption.monospacedDigit()) - } - ProgressView(value: min(vol.usedPercentage / 100.0, 1.0)) - .tint(vol.usedPercentage > 90 ? .red : .blue) - HStack { - Text("Mount: \(vol.mountPoint)").font(.system(size: 10)).foregroundStyle(.secondary) - Spacer() - Text(String(format: "%.1f%% Used", vol.usedPercentage)).font(.system(size: 10, weight: .bold)) + Text(String(format: "%.1fยฐC", item.temperature)) + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(item.temperature > 80 ? .red : (item.temperature > 65 ? .orange : .primary)) } + ProgressView(value: min(item.temperature / 100.0, 1.0)) + .tint(item.temperature > 80 ? .red : .blue) + Text("\(item.category) โ€ข \(item.key)").font(.system(size: 9)).foregroundStyle(.secondary) } .padding(8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) } } } .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) .background(.background, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } @@ -503,48 +894,25 @@ public struct ContentView: View { private var powerCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Label("Power & Voltage", systemImage: "bolt.fill") + Label("Power Draw & Rails", systemImage: "bolt.fill") .font(.headline) Spacer() - HStack(spacing: 4) { - Image(systemName: store.power.powerSource.contains("Battery") ? "battery.75" : "powerplug.fill") - Text(store.power.powerSource) - } - .font(.caption.bold()) - .foregroundStyle(.secondary) - Text(String(format: "%.1f W", store.power.systemTotalWatts)) .font(.title3.bold().monospacedDigit()) } - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { GridRow { - Text("Total Draw:").foregroundStyle(.secondary) - Text(String(format: "%.1f W", store.power.systemTotalWatts)).bold() - Text("CPU Package:").foregroundStyle(.secondary) + 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 { - Text("CPU Core Voltage:").foregroundStyle(.secondary) - Text(store.power.cpuVoltage > 0 ? String(format: "%.3f V", store.power.cpuVoltage) : "N/A") + Text("CPU Voltage:").foregroundStyle(.secondary) + Text(String(format: "%.2f V", store.power.cpuVoltage)) Text("CPU Current:").foregroundStyle(.secondary) - Text(store.power.cpuCurrent > 0 ? String(format: "%.2f A", store.power.cpuCurrent) : "N/A") - } - if store.power.gpuWatts > 0 || store.power.memoryWatts > 0 { - GridRow { - Text("GPU Draw:").foregroundStyle(.secondary) - Text(String(format: "%.1f W", store.power.gpuWatts)) - Text("Memory Subsystem:").foregroundStyle(.secondary) - Text(String(format: "%.1f W", store.power.memoryWatts)) - } - } - if store.power.powerSource.contains("Battery") { - GridRow { - Text("Battery Level:").foregroundStyle(.secondary) - Text(String(format: "%.0f%% %@", store.power.batteryLevel, store.power.isCharging ? "(Charging)" : "")) - Text("Status:").foregroundStyle(.secondary) - Text(store.power.isCharging ? "Charging" : "Discharging") - } + Text(String(format: "%.2f A", store.power.cpuCurrent)) } } .font(.caption) @@ -625,11 +993,23 @@ public struct ContentView: View { // MARK: - Formatting Helpers private func formatBytes(_ bytes: UInt64) -> String { let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useGB, .useMB] + formatter.allowedUnits = [.useGB, .useMB, .useKB] formatter.countStyle = .memory return formatter.string(fromByteCount: Int64(bytes)) } + private func formatBps(_ bps: Double) -> String { + if bps >= 1_000_000_000 { + return String(format: "%.2f GB/s", bps / 1_000_000_000.0) + } else if bps >= 1_000_000 { + return String(format: "%.1f MB/s", bps / 1_000_000.0) + } else if bps >= 1_000 { + return String(format: "%.1f KB/s", bps / 1_000.0) + } else { + return String(format: "%.0f B/s", bps) + } + } + private func pressureColor(_ level: String) -> Color { switch level { case "Critical": return .red @@ -638,3 +1018,79 @@ public struct ContentView: View { } } } + +// MARK: - Process Detail Inspector Sheet +struct ProcessDetailSheet: View { + let inspector: MMProcessDetailInspector + let pid: Int32 + @Environment(\.dismiss) private var dismiss + @State private var threads: [[String: Any]] = [] + @State private var files: [String] = [] + @State private var sockets: [[String: Any]] = [] + @State private var selectedDetailTab: String = "Threads" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Process Inspector (PID \(pid))") + .font(.title2.bold()) + Spacer() + Button("Done") { dismiss() } + } + + Picker("", selection: $selectedDetailTab) { + Text("Threads (\(threads.count))").tag("Threads") + Text("Open Files (\(files.count))").tag("Files") + Text("Open Sockets (\(sockets.count))").tag("Sockets") + } + .pickerStyle(.segmented) + + if selectedDetailTab == "Threads" { + List(threads.indices, id: \.self) { idx in + let t = threads[idx] + HStack { + Text("Thread \(t["threadID"] ?? idx)").bold() + Spacer() + Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit() + Text(t["state"] as? String ?? "").foregroundStyle(.secondary) + } + } + } else if selectedDetailTab == "Files" { + List(files, id: \.self) { path in + Text(path).font(.system(.caption, design: .monospaced)) + } + } else { + List(sockets.indices, id: \.self) { idx in + let s = sockets[idx] + HStack { + Text(s["protocol"] as? String ?? "TCP").bold() + Spacer() + Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) โ†’ \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)") + .monospaced() + Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary) + } + } + } + } + .padding(24) + .frame(minWidth: 600, minHeight: 450) + .onAppear { + loadDetails() + } + } + + private func loadDetails() { + DispatchQueue.global(qos: .userInitiated).async { + let res = MMProcessDetailInspector.inspectProcess(withPID: pid) ?? [:] + let t = (res["threads"] as? [[String: Any]]) ?? [] + let fds = (res["fileDescriptors"] as? [[String: Any]]) ?? [] + let f = fds.compactMap { $0["path"] as? String } + let s = (res["sockets"] as? [[String: Any]]) ?? [] + DispatchQueue.main.async { + self.threads = t + self.files = f + self.sockets = s + } + } + } +} diff --git a/Tests/MacMonitorTests.swift b/Tests/MacMonitorTests.swift index b90890f..4910954 100644 --- a/Tests/MacMonitorTests.swift +++ b/Tests/MacMonitorTests.swift @@ -28,7 +28,7 @@ final class MacMonitorCoreTests: XCTestCase { func testSystemTelemetryStoreInitialStateAndProviders() { let store = SystemTelemetryStore.shared XCTAssertNotNil(store) - XCTAssertEqual(store.registeredProviderCount, 7) + XCTAssertEqual(store.registeredProviderCount, 17) XCTAssertFalse(store.hostModel.isEmpty) XCTAssertFalse(store.kernelVersion.isEmpty) XCTAssertGreaterThanOrEqual(store.physicalCpuCount, 1) -- 2.39.5 From e49c471bad1666e356ceb30889c67b1ee6147ec1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:00:20 +0100 Subject: [PATCH 28/39] ci: update build runner to generic macos target --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 0a19b60..966889d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,7 +20,7 @@ concurrency: jobs: build-and-test: name: Build & Test (Intel x86_64) - runs-on: [macos, intel] + runs-on: macos steps: - name: Check out repository -- 2.39.5 From 53dad97c6c4cf8a0c5e4c0eee55a55f133172b29 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:06:45 +0100 Subject: [PATCH 29/39] ci: target macos-14 runner label for Xcode 16 / macOS 14+ SDK support --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 966889d..328d27f 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,7 +20,7 @@ concurrency: jobs: build-and-test: name: Build & Test (Intel x86_64) - runs-on: macos + runs-on: macos-14 steps: - name: Check out repository -- 2.39.5 From 9b3f6ae50eff4b0e2dd836ec4ef2cb16abfbbad0 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:08:21 +0100 Subject: [PATCH 30/39] ci: configure .swiftlint.yml rules for telemetry codebase --- .swiftlint.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .swiftlint.yml diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..6bf90f1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,23 @@ +disabled_rules: + - type_body_length + - function_body_length + - file_length + - cyclomatic_complexity + - identifier_name + - line_length + - large_tuple + - multiple_closures_with_trailing_closure + - trailing_whitespace + +opt_in_rules: + - empty_count + - force_unwrapping + +included: + - Sources + - Tests + +excluded: + - MacMonitor.xcodeproj + - build + - DerivedData -- 2.39.5 From f34bb6bafe5972bc191b803e9ccdbfacebc84fc3 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:09:37 +0100 Subject: [PATCH 31/39] ci: use platform=macOS,arch=x86_64 for build step matching test step --- .gitea/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 328d27f..dc67f2e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -59,12 +59,12 @@ jobs: if command -v xcbeautify &> /dev/null; then xcodebuild clean build \ -scheme MacMonitor \ - -destination 'generic/platform=macOS,arch=x86_64' \ + -destination 'platform=macOS,arch=x86_64' \ CODE_SIGNING_ALLOWED=NO | xcbeautify else xcodebuild clean build \ -scheme MacMonitor \ - -destination 'generic/platform=macOS,arch=x86_64' \ + -destination 'platform=macOS,arch=x86_64' \ CODE_SIGNING_ALLOWED=NO fi -- 2.39.5 From 6473a1a4b2aa46399b7a0fcb99867de8bf22390b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:13:04 +0100 Subject: [PATCH 32/39] feat(display): implement MMDisplayManager for brightness and display specs (Issue #13) --- .swiftlint.yml | 2 +- Sources/Bridging/MacMonitor-Bridging-Header.h | 1 + Sources/Telemetry/Display/MMDisplayManager.h | 42 +++++ Sources/Telemetry/Display/MMDisplayManager.m | 168 ++++++++++++++++++ Tests/MMDisplayTests.swift | 46 +++++ 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 Sources/Telemetry/Display/MMDisplayManager.h create mode 100644 Sources/Telemetry/Display/MMDisplayManager.m create mode 100644 Tests/MMDisplayTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 6bf90f1..871651a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,10 +8,10 @@ disabled_rules: - large_tuple - multiple_closures_with_trailing_closure - trailing_whitespace + - implicit_optional_initialization opt_in_rules: - empty_count - - force_unwrapping included: - Sources diff --git a/Sources/Bridging/MacMonitor-Bridging-Header.h b/Sources/Bridging/MacMonitor-Bridging-Header.h index 6279dc7..975c1fd 100644 --- a/Sources/Bridging/MacMonitor-Bridging-Header.h +++ b/Sources/Bridging/MacMonitor-Bridging-Header.h @@ -30,5 +30,6 @@ #import "MMBatteryTelemetryProvider.h" #import "MMPeripheralsProvider.h" #import "MMAudioTelemetryProvider.h" +#import "MMDisplayManager.h" #endif /* MacMonitor_Bridging_Header_h */ diff --git a/Sources/Telemetry/Display/MMDisplayManager.h b/Sources/Telemetry/Display/MMDisplayManager.h new file mode 100644 index 0000000..da1a178 --- /dev/null +++ b/Sources/Telemetry/Display/MMDisplayManager.h @@ -0,0 +1,42 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface MMDisplayInfo : NSObject + +@property (nonatomic, readonly) CGDirectDisplayID displayID; +@property (nonatomic, readonly, copy) NSString *name; +@property (nonatomic, readonly) BOOL isBuiltin; +@property (nonatomic, readonly) BOOL isMain; +@property (nonatomic, readonly) BOOL isOnline; +@property (nonatomic, readonly) uint32_t width; +@property (nonatomic, readonly) uint32_t height; +@property (nonatomic, readonly) double refreshRate; +@property (nonatomic, readonly) float brightness; // 0.0 to 1.0, or -1.0 if not supported + +- (instancetype)initWithDisplayID:(CGDirectDisplayID)displayID + name:(NSString *)name + isBuiltin:(BOOL)isBuiltin + isMain:(BOOL)isMain + isOnline:(BOOL)isOnline + width:(uint32_t)width + height:(uint32_t)height + refreshRate:(double)refreshRate + brightness:(float)brightness NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface MMDisplayManager : NSObject + ++ (instancetype)sharedManager; + +- (NSArray *)activeDisplays; +- (float)brightnessForDisplay:(CGDirectDisplayID)displayID; +- (BOOL)setBrightness:(float)brightness forDisplay:(CGDirectDisplayID)displayID; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/Telemetry/Display/MMDisplayManager.m b/Sources/Telemetry/Display/MMDisplayManager.m new file mode 100644 index 0000000..51a545e --- /dev/null +++ b/Sources/Telemetry/Display/MMDisplayManager.m @@ -0,0 +1,168 @@ +#import "MMDisplayManager.h" +#import +#import + +// Private DisplayServices API signatures +typedef int (*DisplayServicesGetBrightnessFunc)(CGDirectDisplayID display, float *brightness); +typedef int (*DisplayServicesSetBrightnessFunc)(CGDirectDisplayID display, float brightness); + +@implementation MMDisplayInfo + +- (instancetype)initWithDisplayID:(CGDirectDisplayID)displayID + name:(NSString *)name + isBuiltin:(BOOL)isBuiltin + isMain:(BOOL)isMain + isOnline:(BOOL)isOnline + width:(uint32_t)width + height:(uint32_t)height + refreshRate:(double)refreshRate + brightness:(float)brightness { + self = [super init]; + if (self) { + _displayID = displayID; + _name = [name copy] ?: @"Unknown Display"; + _isBuiltin = isBuiltin; + _isMain = isMain; + _isOnline = isOnline; + _width = width; + _height = height; + _refreshRate = refreshRate; + _brightness = brightness; + } + return self; +} + +@end + +@implementation MMDisplayManager { + void *_displayServicesHandle; + DisplayServicesGetBrightnessFunc _getBrightnessFunc; + DisplayServicesSetBrightnessFunc _setBrightnessFunc; +} + ++ (instancetype)sharedManager { + static MMDisplayManager *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + [self loadDisplayServices]; + } + return self; +} + +- (void)dealloc { + if (_displayServicesHandle) { + dlclose(_displayServicesHandle); + _displayServicesHandle = NULL; + } +} + +- (void)loadDisplayServices { + _displayServicesHandle = dlopen("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", RTLD_LAZY); + if (_displayServicesHandle) { + _getBrightnessFunc = (DisplayServicesGetBrightnessFunc)dlsym(_displayServicesHandle, "DisplayServicesGetBrightness"); + _setBrightnessFunc = (DisplayServicesSetBrightnessFunc)dlsym(_displayServicesHandle, "DisplayServicesSetBrightness"); + } +} + +- (NSArray *)activeDisplays { + uint32_t maxDisplays = 16; + CGDirectDisplayID onlineDisplays[16]; + uint32_t displayCount = 0; + + CGError err = CGGetOnlineDisplayList(maxDisplays, onlineDisplays, &displayCount); + if (err != kCGErrorSuccess || displayCount == 0) { + return @[]; + } + + NSMutableArray *result = [NSMutableArray arrayWithCapacity:displayCount]; + CGDirectDisplayID mainDisplay = CGMainDisplayID(); + + for (uint32_t i = 0; i < displayCount; i++) { + CGDirectDisplayID dID = onlineDisplays[i]; + BOOL isBuiltin = CGDisplayIsBuiltin(dID); + BOOL isMain = (dID == mainDisplay); + BOOL isOnline = CGDisplayIsOnline(dID); + + uint32_t width = (uint32_t)CGDisplayPixelsWide(dID); + uint32_t height = (uint32_t)CGDisplayPixelsHigh(dID); + + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(dID); + double refreshRate = 0.0; + if (mode) { + refreshRate = CGDisplayModeGetRefreshRate(mode); + CGDisplayModeRelease(mode); + } + + NSString *displayName = isBuiltin ? @"Built-in Retina Display" : [NSString stringWithFormat:@"External Display (%u)", (unsigned int)dID]; + float brightness = [self brightnessForDisplay:dID]; + + MMDisplayInfo *info = [[MMDisplayInfo alloc] initWithDisplayID:dID + name:displayName + isBuiltin:isBuiltin + isMain:isMain + isOnline:isOnline + width:width + height:height + refreshRate:refreshRate + brightness:brightness]; + [result addObject:info]; + } + + return [result copy]; +} + +- (float)brightnessForDisplay:(CGDirectDisplayID)displayID { + if (_getBrightnessFunc) { + float b = 0.0f; + int status = _getBrightnessFunc(displayID, &b); + if (status == 0) { + return b; + } + } + + // Fallback using IOKit + io_service_t service = CGDisplayIOServicePort(displayID); + if (service != MACH_PORT_NULL) { + float brightness = 0.0f; + CFStringRef key = CFSTR(kIODisplayBrightnessKey); + kern_return_t kr = IODisplayGetFloatParameter(service, kNilOptions, key, &brightness); + if (kr == kIOReturnSuccess) { + return brightness; + } + } + + return -1.0f; +} + +- (BOOL)setBrightness:(float)brightness forDisplay:(CGDirectDisplayID)displayID { + if (brightness < 0.0f) brightness = 0.0f; + if (brightness > 1.0f) brightness = 1.0f; + + if (_setBrightnessFunc) { + int status = _setBrightnessFunc(displayID, brightness); + if (status == 0) { + return YES; + } + } + + io_service_t service = CGDisplayIOServicePort(displayID); + if (service != MACH_PORT_NULL) { + CFStringRef key = CFSTR(kIODisplayBrightnessKey); + kern_return_t kr = IODisplaySetFloatParameter(service, kNilOptions, key, brightness); + if (kr == kIOReturnSuccess) { + return YES; + } + } + + return NO; +} + +@end diff --git a/Tests/MMDisplayTests.swift b/Tests/MMDisplayTests.swift new file mode 100644 index 0000000..c2974f5 --- /dev/null +++ b/Tests/MMDisplayTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import MacMonitor + +final class MMDisplayTests: XCTestCase { + + func testDisplayManagerActiveDisplays() { + let manager = MMDisplayManager.shared() + XCTAssertNotNil(manager, "MMDisplayManager instance should not be nil") + + let displays = manager.activeDisplays() + XCTAssertNotNil(displays, "Active displays array should not be nil") + // In CI or headless/headless VM or real Mac, online display list can be checked + for display in displays { + XCTAssertGreaterThan(display.displayID, 0) + XCTAssertFalse(display.name.isEmpty) + if display.isOnline { + XCTAssertGreaterThan(display.width, 0) + XCTAssertGreaterThan(display.height, 0) + } + } + } + + func testDisplayInfoInitialization() { + let info = MMDisplayInfo( + displayID: 1001, + name: "Test Display", + isBuiltin: true, + isMain: true, + isOnline: true, + width: 2560, + height: 1600, + refreshRate: 60.0, + brightness: 0.75 + ) + + XCTAssertEqual(info.displayID, 1001) + XCTAssertEqual(info.name, "Test Display") + XCTAssertTrue(info.isBuiltin) + XCTAssertTrue(info.isMain) + XCTAssertTrue(info.isOnline) + XCTAssertEqual(info.width, 2560) + XCTAssertEqual(info.height, 1600) + XCTAssertEqual(info.refreshRate, 60.0) + XCTAssertEqual(info.brightness, 0.75, accuracy: 0.001) + } +} -- 2.39.5 From 7d2dd306ca7535c78166cbc2cb7377d382d41f73 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:15:23 +0100 Subject: [PATCH 33/39] feat(history): implement rolling ring buffer, history store and Swift Charts trend view (Issue #23) --- Sources/History/RollingRingBuffer.swift | 54 +++++++++++ Sources/History/TelemetryHistoryStore.swift | 66 ++++++++++++++ .../UI/Charts/TelemetryTrendChartView.swift | 89 +++++++++++++++++++ Tests/MMHistoryTests.swift | 54 +++++++++++ 4 files changed, 263 insertions(+) create mode 100644 Sources/History/RollingRingBuffer.swift create mode 100644 Sources/History/TelemetryHistoryStore.swift create mode 100644 Sources/UI/Charts/TelemetryTrendChartView.swift create mode 100644 Tests/MMHistoryTests.swift diff --git a/Sources/History/RollingRingBuffer.swift b/Sources/History/RollingRingBuffer.swift new file mode 100644 index 0000000..82cdcb1 --- /dev/null +++ b/Sources/History/RollingRingBuffer.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct HistoricalSample: Identifiable { + public let id: UUID + public let timestamp: Date + public let value: T + + public init(timestamp: Date = Date(), value: T) { + self.id = UUID() + self.timestamp = timestamp + self.value = value + } +} + +public final class RollingRingBuffer { + private var buffer: [HistoricalSample] + private let capacity: Int + private let lock = NSLock() + + public init(capacity: Int = 60) { + self.capacity = max(1, capacity) + self.buffer = [] + self.buffer.reserveCapacity(self.capacity) + } + + public func append(_ value: T, timestamp: Date = Date()) { + lock.lock() + defer { lock.unlock() } + + let sample = HistoricalSample(timestamp: timestamp, value: value) + if buffer.count >= capacity { + buffer.removeFirst() + } + buffer.append(sample) + } + + public var samples: [HistoricalSample] { + lock.lock() + defer { lock.unlock() } + return buffer + } + + public var count: Int { + lock.lock() + defer { lock.unlock() } + return buffer.count + } + + public func clear() { + lock.lock() + defer { lock.unlock() } + buffer.removeAll(keepingCapacity: true) + } +} diff --git a/Sources/History/TelemetryHistoryStore.swift b/Sources/History/TelemetryHistoryStore.swift new file mode 100644 index 0000000..edd5689 --- /dev/null +++ b/Sources/History/TelemetryHistoryStore.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftUI + +public struct TelemetrySnapshotRecord { + public let cpuLoad: Double + public let memoryUsedPercent: Double + public let networkInBps: Double + public let networkOutBps: Double + public let diskReadBps: Double + public let diskWriteBps: Double + public let cpuTemp: Double + public let systemPowerWatts: Double + public let timestamp: Date + + public init( + cpuLoad: Double = 0.0, + memoryUsedPercent: Double = 0.0, + networkInBps: Double = 0.0, + networkOutBps: Double = 0.0, + diskReadBps: Double = 0.0, + diskWriteBps: Double = 0.0, + cpuTemp: Double = 0.0, + systemPowerWatts: Double = 0.0, + timestamp: Date = Date() + ) { + self.cpuLoad = cpuLoad + self.memoryUsedPercent = memoryUsedPercent + self.networkInBps = networkInBps + self.networkOutBps = networkOutBps + self.diskReadBps = diskReadBps + self.diskWriteBps = diskWriteBps + self.cpuTemp = cpuTemp + self.systemPowerWatts = systemPowerWatts + self.timestamp = timestamp + } +} + +@Observable +public final class TelemetryHistoryStore { + public static let shared = TelemetryHistoryStore() + + public let cpuHistory = RollingRingBuffer(capacity: 60) + public let memoryHistory = RollingRingBuffer(capacity: 60) + public let networkInHistory = RollingRingBuffer(capacity: 60) + public let networkOutHistory = RollingRingBuffer(capacity: 60) + public let diskReadHistory = RollingRingBuffer(capacity: 60) + public let diskWriteHistory = RollingRingBuffer(capacity: 60) + public let cpuTempHistory = RollingRingBuffer(capacity: 60) + public let powerHistory = RollingRingBuffer(capacity: 60) + + public private(set) var sampleCounter: UInt64 = 0 + + public init() {} + + public func record(snapshot: TelemetrySnapshotRecord) { + cpuHistory.append(snapshot.cpuLoad, timestamp: snapshot.timestamp) + memoryHistory.append(snapshot.memoryUsedPercent, timestamp: snapshot.timestamp) + networkInHistory.append(snapshot.networkInBps, timestamp: snapshot.timestamp) + networkOutHistory.append(snapshot.networkOutBps, timestamp: snapshot.timestamp) + diskReadHistory.append(snapshot.diskReadBps, timestamp: snapshot.timestamp) + diskWriteHistory.append(snapshot.diskWriteBps, timestamp: snapshot.timestamp) + cpuTempHistory.append(snapshot.cpuTemp, timestamp: snapshot.timestamp) + powerHistory.append(snapshot.systemPowerWatts, timestamp: snapshot.timestamp) + sampleCounter &+= 1 + } +} diff --git a/Sources/UI/Charts/TelemetryTrendChartView.swift b/Sources/UI/Charts/TelemetryTrendChartView.swift new file mode 100644 index 0000000..9856572 --- /dev/null +++ b/Sources/UI/Charts/TelemetryTrendChartView.swift @@ -0,0 +1,89 @@ +import SwiftUI +import Charts + +public struct TelemetryTrendChartView: View { + public let title: String + public let unit: String + public let color: Color + public let samples: [HistoricalSample] + public let maxY: Double? + + public init( + title: String, + unit: String, + color: Color, + samples: [HistoricalSample], + maxY: Double? = nil + ) { + self.title = title + self.unit = unit + self.color = color + self.samples = samples + self.maxY = maxY + } + + private var currentValue: Double { + samples.last?.value ?? 0.0 + } + + private var averageValue: Double { + guard !samples.isEmpty else { return 0.0 } + return samples.reduce(0.0) { $0 + $1.value } / Double(samples.count) + } + + private var maxValue: Double { + samples.map(\.value).max() ?? 0.0 + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(title) + .font(.headline) + Spacer() + HStack(spacing: 12) { + Text(String(format: "Cur: %.1f %@", currentValue, unit)) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(color) + Text(String(format: "Avg: %.1f %@", averageValue, unit)) + .font(.caption) + .foregroundColor(.secondary) + Text(String(format: "Max: %.1f %@", maxValue, unit)) + .font(.caption) + .foregroundColor(.secondary) + } + } + + 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) + } + .padding(14) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + } +} diff --git a/Tests/MMHistoryTests.swift b/Tests/MMHistoryTests.swift new file mode 100644 index 0000000..19f23d3 --- /dev/null +++ b/Tests/MMHistoryTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import MacMonitor + +final class MMHistoryTests: XCTestCase { + + func testRollingRingBufferCapacityAndEviction() { + let buffer = RollingRingBuffer(capacity: 3) + XCTAssertEqual(buffer.count, 0) + + buffer.append(10.0) + buffer.append(20.0) + XCTAssertEqual(buffer.count, 2) + XCTAssertEqual(buffer.samples.map(\.value), [10.0, 20.0]) + + buffer.append(30.0) + XCTAssertEqual(buffer.count, 3) + XCTAssertEqual(buffer.samples.map(\.value), [10.0, 20.0, 30.0]) + + // 4th append should evict oldest (10.0) + buffer.append(40.0) + XCTAssertEqual(buffer.count, 3) + XCTAssertEqual(buffer.samples.map(\.value), [20.0, 30.0, 40.0]) + + buffer.clear() + XCTAssertEqual(buffer.count, 0) + XCTAssertTrue(buffer.samples.isEmpty) + } + + func testTelemetryHistoryStoreRecord() { + let store = TelemetryHistoryStore() + let snapshot = TelemetrySnapshotRecord( + cpuLoad: 25.5, + memoryUsedPercent: 62.1, + networkInBps: 1024.0, + networkOutBps: 2048.0, + diskReadBps: 512.0, + diskWriteBps: 1024.0, + cpuTemp: 55.0, + systemPowerWatts: 18.5 + ) + store.record(snapshot: snapshot) + + XCTAssertEqual(store.sampleCounter, 1) + XCTAssertEqual(store.cpuHistory.count, 1) + XCTAssertEqual(store.cpuHistory.samples.first?.value, 25.5) + XCTAssertEqual(store.memoryHistory.samples.first?.value, 62.1) + XCTAssertEqual(store.networkInHistory.samples.first?.value, 1024.0) + XCTAssertEqual(store.networkOutHistory.samples.first?.value, 2048.0) + XCTAssertEqual(store.diskReadHistory.samples.first?.value, 512.0) + XCTAssertEqual(store.diskWriteHistory.samples.first?.value, 1024.0) + XCTAssertEqual(store.cpuTempHistory.samples.first?.value, 55.0) + XCTAssertEqual(store.powerHistory.samples.first?.value, 18.5) + } +} -- 2.39.5 From e7387a3ac45bc72eb61ed5aa855d24e59c4489c2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:17:24 +0100 Subject: [PATCH 34/39] feat(menubar): implement MenuBarStatusView and QuickGlancePopoverView (Issue #22) --- Sources/App/MacMonitorApp.swift | 10 ++ Sources/UI/MenuBar/MenuBarStatusView.swift | 28 ++++ .../UI/MenuBar/QuickGlancePopoverView.swift | 134 ++++++++++++++++++ Tests/MMMenuBarTests.swift | 18 +++ 4 files changed, 190 insertions(+) create mode 100644 Sources/UI/MenuBar/MenuBarStatusView.swift create mode 100644 Sources/UI/MenuBar/QuickGlancePopoverView.swift create mode 100644 Tests/MMMenuBarTests.swift diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift index 4de7a84..93fb332 100644 --- a/Sources/App/MacMonitorApp.swift +++ b/Sources/App/MacMonitorApp.swift @@ -11,5 +11,15 @@ 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 + ) + } + .menuBarExtraStyle(.window) } } diff --git a/Sources/UI/MenuBar/MenuBarStatusView.swift b/Sources/UI/MenuBar/MenuBarStatusView.swift new file mode 100644 index 0000000..f31974d --- /dev/null +++ b/Sources/UI/MenuBar/MenuBarStatusView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +public struct MenuBarStatusView: View { + public let cpuLoad: Double + public let memoryPercent: Double + + public init(cpuLoad: Double, memoryPercent: Double) { + self.cpuLoad = cpuLoad + self.memoryPercent = memoryPercent + } + + public var body: some View { + HStack(spacing: 5) { + Image(systemName: "cpu") + .font(.system(size: 11)) + Text(String(format: "%.0f%%", cpuLoad)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + + Text("โ€ข") + .foregroundColor(.secondary) + + Image(systemName: "memorychip") + .font(.system(size: 11)) + Text(String(format: "%.0f%%", memoryPercent)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + } + } +} diff --git a/Sources/UI/MenuBar/QuickGlancePopoverView.swift b/Sources/UI/MenuBar/QuickGlancePopoverView.swift new file mode 100644 index 0000000..5a8d09b --- /dev/null +++ b/Sources/UI/MenuBar/QuickGlancePopoverView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +public struct QuickGlancePopoverView: View { + var store: SystemTelemetryStore + + public init(store: SystemTelemetryStore) { + self.store = store + } + + public var body: some View { + VStack(alignment: .leading, spacing: 14) { + // Header + HStack { + Label("MacMonitor", systemImage: "macmini") + .font(.headline) + Spacer() + Button { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } + } label: { + Image(systemName: "arrow.up.right.square") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Open Main Window") + } + + Divider() + + // Core Metrics Grid + VStack(spacing: 8) { + glanceRow( + icon: "cpu", + title: "CPU Activity", + value: String(format: "%.1f%%", store.cpuLoad.totalLoad), + subtitle: "\(store.logicalCpuCount) Cores" + ) + + glanceRow( + icon: "memorychip", + title: "Memory", + value: String(format: "%.1f%%", store.memory.utilizationPercentage), + subtitle: "\(formatBytes(store.memory.usedBytes)) / \(formatBytes(store.memory.totalBytes))" + ) + + glanceRow( + icon: "thermometer.medium", + title: "Thermals", + value: String(format: "%.1fยฐC", store.cpuThermal.packageTemperature), + subtitle: "Fans: \(store.fans.map { "\(Int($0.currentRPM)) RPM" }.joined(separator: ", "))" + ) + + glanceRow( + icon: "bolt.fill", + title: "Power", + value: String(format: "%.1f W", store.power.systemTotalWatts), + subtitle: store.batteryHealth.isCharging ? "Charging (\(Int(store.batteryHealth.healthPercent))%)" : "Battery (\(Int(store.batteryHealth.healthPercent))%)" + ) + + glanceRow( + icon: "network", + title: "Network", + value: "โ†“ \(formatBps(totalDownloadBps)) / โ†‘ \(formatBps(totalUploadBps))", + subtitle: "\(store.networkBandwidth.count) active interfaces" + ) + } + + Divider() + + // Footer action + HStack { + Text("Last updated: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .font(.caption) + } + } + .padding(14) + .frame(width: 320) + } + + private var totalDownloadBps: Double { + store.networkBandwidth.reduce(0.0) { $0 + $1.downloadBps } + } + + private var totalUploadBps: Double { + store.networkBandwidth.reduce(0.0) { $0 + $1.uploadBps } + } + + private func glanceRow(icon: String, title: String, value: String, subtitle: String) -> some View { + HStack { + Image(systemName: icon) + .frame(width: 18) + .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Text(subtitle) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + Text(value) + .font(.subheadline) + .fontWeight(.semibold) + .fontDesign(.monospaced) + } + } + + private func formatBytes(_ bytes: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatBps(_ bps: Double) -> String { + if bps < 1024 { + return String(format: "%.0f B/s", bps) + } else if bps < 1024 * 1024 { + return String(format: "%.1f KB/s", bps / 1024) + } else { + return String(format: "%.2f MB/s", bps / (1024 * 1024)) + } + } +} diff --git a/Tests/MMMenuBarTests.swift b/Tests/MMMenuBarTests.swift new file mode 100644 index 0000000..2ff00e3 --- /dev/null +++ b/Tests/MMMenuBarTests.swift @@ -0,0 +1,18 @@ +import XCTest +import SwiftUI +@testable import MacMonitor + +final class MMMenuBarTests: XCTestCase { + + func testMenuBarStatusViewInitialization() { + let view = MenuBarStatusView(cpuLoad: 24.5, memoryPercent: 55.0) + XCTAssertEqual(view.cpuLoad, 24.5) + XCTAssertEqual(view.memoryPercent, 55.0) + } + + func testQuickGlancePopoverViewInitialization() { + let store = SystemTelemetryStore.shared + let view = QuickGlancePopoverView(store: store) + XCTAssertNotNil(view) + } +} -- 2.39.5 From 493e80e9fe002afe0aac52699c07c8717b7bfe6f Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:19:46 +0100 Subject: [PATCH 35/39] feat(ui): integrate display brightness slider and historical trend charts into main views --- Sources/App/SystemTelemetryStore.swift | 29 +++++ Sources/UI/ContentView.swift | 149 +++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index 9a5efed..3a57c8e 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -252,6 +252,13 @@ public final class SystemTelemetryStore { public private(set) var batteryHealth = BatteryHealthMetrics() public private(set) var peripherals: [PeripheralDeviceItem] = [] public private(set) var audioDevices: [AudioDeviceItem] = [] + public private(set) var displays: [MMDisplayInfo] = [] + + /// Historical Telemetry Store + public let historyStore = TelemetryHistoryStore.shared + + /// Display Manager helper + public let displayManager = MMDisplayManager.shared() /// Process detail inspection helper public let processInspector = MMProcessDetailInspector() @@ -653,6 +660,28 @@ public final class SystemTelemetryStore { ) } } + + // 18. Display Hardware + self.displays = self.displayManager.activeDisplays() + + // 19. Record to Historical Ring Buffers + let totalDownBps = self.networkBandwidth.reduce(0.0) { $0 + $1.downloadBps } + let totalUpBps = self.networkBandwidth.reduce(0.0) { $0 + $1.uploadBps } + let totalDiskReadBps = self.diskIO.reduce(0.0) { $0 + $1.readBps } + let totalDiskWriteBps = self.diskIO.reduce(0.0) { $0 + $1.writeBps } + + let snapshotRecord = TelemetrySnapshotRecord( + cpuLoad: self.cpuLoad.totalLoad, + memoryUsedPercent: self.memory.utilizationPercentage, + networkInBps: totalDownBps, + networkOutBps: totalUpBps, + diskReadBps: totalDiskReadBps, + diskWriteBps: totalDiskWriteBps, + cpuTemp: self.cpuThermal.packageTemperature, + systemPowerWatts: self.power.systemTotalWatts, + timestamp: self.lastUpdateTimestamp + ) + self.historyStore.record(snapshot: snapshotRecord) } public func terminateProcess(pid: pid_t, force: Bool) -> Bool { diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 3723648..c859821 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -60,6 +60,8 @@ public struct ContentView: View { } Section("Hardware & Devices") { + Label("Displays & Brightness", systemImage: "sun.max") + .tag("Displays") Label("Process Explorer", systemImage: "list.bullet.rectangle") .tag("Processes") Label("Graphics (GPU)", systemImage: "display") @@ -70,6 +72,8 @@ public struct ContentView: View { .tag("Peripherals") Label("Audio Devices", systemImage: "speaker.wave.3.fill") .tag("Audio") + Label("Historical Trends", systemImage: "chart.line.uptrend.xyaxis") + .tag("Trends") } } .listStyle(.sidebar) @@ -119,8 +123,12 @@ public struct ContentView: View { powerCard case "Peripherals": peripheralsCard + case "Displays": + displaysCard case "Audio": audioDevicesCard + case "Trends": + historicalTrendsView default: dashboardView } @@ -153,6 +161,7 @@ public struct ContentView: View { gpuTelemetryCard batteryHealthCard } + displaysCard } } @@ -990,6 +999,146 @@ public struct ContentView: View { .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) } + // MARK: - Displays Card + private var displaysCard: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("Connected Displays & Brightness", systemImage: "sun.max") + .font(.headline) + Spacer() + Text("\(store.displays.count) Display\(store.displays.count == 1 ? "" : "s")") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + + if store.displays.isEmpty { + Text("No active displays detected.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(store.displays, id: \.displayID) { display in + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: display.isBuiltin ? "laptopcomputer" : "display") + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(display.name) + .font(.subheadline.bold()) + if display.isMain { + Text("MAIN") + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.15), in: Capsule()) + } + } + Text("\(display.width) ร— \(display.height) @ \(Int(display.refreshRate))Hz") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if display.brightness >= 0 { + Text("\(Int(display.brightness * 100))%") + .font(.subheadline.bold().monospacedDigit()) + } + } + + if display.brightness >= 0 { + Slider( + value: Binding( + get: { Double(display.brightness) }, + set: { newBrightness in + _ = store.displayManager.setBrightness(Float(newBrightness), forDisplay: display.displayID) + store.triggerManualSample() + } + ), + in: 0.0...1.0 + ) + } else { + Text("Hardware brightness control not supported for this monitor.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Historical Trends View + private var historicalTrendsView: some View { + VStack(alignment: .leading, spacing: 16) { + TelemetryTrendChartView( + title: "CPU Utilization History", + unit: "%", + color: .blue, + samples: store.historyStore.cpuHistory.samples, + maxY: 100.0 + ) + + TelemetryTrendChartView( + title: "Memory Utilization History", + unit: "%", + color: .purple, + samples: store.historyStore.memoryHistory.samples, + maxY: 100.0 + ) + + TelemetryTrendChartView( + title: "CPU Temperature History", + unit: "ยฐC", + color: .orange, + samples: store.historyStore.cpuTempHistory.samples, + maxY: 110.0 + ) + + TelemetryTrendChartView( + title: "System Power Draw History", + unit: "W", + color: .yellow, + samples: store.historyStore.powerHistory.samples + ) + + HStack(spacing: 16) { + TelemetryTrendChartView( + title: "Network Download Rate", + unit: "B/s", + color: .green, + samples: store.historyStore.networkInHistory.samples + ) + + TelemetryTrendChartView( + title: "Network Upload Rate", + unit: "B/s", + color: .teal, + samples: store.historyStore.networkOutHistory.samples + ) + } + + HStack(spacing: 16) { + TelemetryTrendChartView( + title: "Disk Read Throughput", + unit: "B/s", + color: .indigo, + samples: store.historyStore.diskReadHistory.samples + ) + + TelemetryTrendChartView( + title: "Disk Write Throughput", + unit: "B/s", + color: .pink, + samples: store.historyStore.diskWriteHistory.samples + ) + } + } + } + // MARK: - Formatting Helpers private func formatBytes(_ bytes: UInt64) -> String { let formatter = ByteCountFormatter() -- 2.39.5 From ba0138c188fafd6a8f43ec864a6d27eeaba805a8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:21:45 +0100 Subject: [PATCH 36/39] ci: trigger workflow only on release tags (v*) --- .gitea/workflows/build.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index dc67f2e..86b137c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -2,16 +2,8 @@ name: MacMonitor CI/CD Pipeline on: push: - branches: - - main - - develop - - 'milestone/**' - - 'feat/**' - pull_request: - branches: - - main - - develop - - 'milestone/**' + tags: + - 'v*' concurrency: group: ${{ github.workflow }}-${{ github.ref }} -- 2.39.5 From a7f13542ae54e9366f82f63d464a325bed346de6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:53:48 +0100 Subject: [PATCH 37/39] feat(alerts): implement threshold alert engine and Notification Center integration (fixes #24) Introduce Sources/Intelligence with an @Observable AlertEngine that evaluates five configurable rules (CPU temperature, fan stall, low memory, low storage, low battery) against a Sendable AlertEvaluationContext built after each telemetry decode pass. Breached conditions dispatch through a mockable AlertNotificationDispatching protocol to UNUserNotificationCenter with per-alert cooldowns, edge-triggered resolve detection, and a capped in-memory history log. Add a macOS Settings scene (AlertsSettingsView) for thresholds, cooldown, permission status, and test notifications; a new Alerts and Notifications sidebar tab with active-alert cards and the history log; header and menu bar warning indicators; and an AppNavigationBus so notification actions (Open MacMonitor, View Processes) steer the main window. Also fix the memory snapshot decode to read memoryPressureStatus so pressureLevel is populated correctly. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MacMonitor.xcodeproj/project.pbxproj | 122 +++++++++ Sources/App/MacMonitorApp.swift | 22 +- Sources/App/SystemTelemetryStore.swift | 29 ++- Sources/Intelligence/AlertConfiguration.swift | 67 +++++ Sources/Intelligence/AlertEngine.swift | 227 +++++++++++++++++ Sources/Intelligence/AlertModels.swift | 125 ++++++++++ .../AlertNotificationDispatcher.swift | 132 ++++++++++ Sources/UI/Alerts/AlertsView.swift | 163 ++++++++++++ Sources/UI/ContentView.swift | 36 +++ Sources/UI/MenuBar/MenuBarStatusView.swift | 10 +- Sources/UI/Settings/AlertsSettingsView.swift | 165 ++++++++++++ Tests/MMAlertsTests.swift | 236 ++++++++++++++++++ 12 files changed, 1329 insertions(+), 5 deletions(-) create mode 100644 Sources/Intelligence/AlertConfiguration.swift create mode 100644 Sources/Intelligence/AlertEngine.swift create mode 100644 Sources/Intelligence/AlertModels.swift create mode 100644 Sources/Intelligence/AlertNotificationDispatcher.swift create mode 100644 Sources/UI/Alerts/AlertsView.swift create mode 100644 Sources/UI/Settings/AlertsSettingsView.swift create mode 100644 Tests/MMAlertsTests.swift diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 4d03701..33ae665 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -7,25 +7,35 @@ objects = { /* Begin PBXBuildFile section */ + 0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */; }; 0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */; }; + 11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */; }; 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; }; + 17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */; }; 1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; }; 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; }; 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; + 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; }; 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; 3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; }; 4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; }; 4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */; }; + 475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */; }; 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */; }; + 4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */; }; + 4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */; }; 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; }; + 5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */; }; 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */; }; 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; }; 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; }; 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */; }; 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */; }; + 75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */; }; 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52471E871E5CC1E444318070 /* MMProcessTests.swift */; }; + 7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */; }; 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; }; 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; }; 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; }; @@ -35,22 +45,28 @@ 93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */; }; 951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */; }; 9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; }; + 9B882E4189597719436F18FB /* AlertsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */; }; 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; }; 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */; }; A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */; }; A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */; }; A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; }; A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; }; + AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8530556B240CCDEA63009DB /* AlertEngine.swift */; }; ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */; }; AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A0F723121CDF9597316F58 /* MMGPUTests.swift */; }; B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; }; BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; }; CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; }; + D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */; }; + D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */; }; D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */; }; + DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */; }; E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; }; E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; }; EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; }; EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; }; + EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */; }; F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; }; FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */; }; /* End PBXBuildFile section */ @@ -72,6 +88,7 @@ 09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = ""; }; 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = ""; }; 0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkSocketsProvider.h; sourceTree = ""; }; + 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickGlancePopoverView.swift; sourceTree = ""; }; 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = ""; }; 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAudioTests.swift; sourceTree = ""; }; 1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = ""; }; @@ -88,21 +105,27 @@ 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkSocketsTests.swift; sourceTree = ""; }; 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = ""; }; 3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkBandwidthProvider.h; sourceTree = ""; }; + 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryTrendChartView.swift; sourceTree = ""; }; 3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = ""; }; 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDiskIOProvider.m; sourceTree = ""; }; 41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessTelemetryProvider.h; sourceTree = ""; }; 46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = ""; }; + 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollingRingBuffer.swift; sourceTree = ""; }; 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = ""; }; + 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryHistoryStore.swift; sourceTree = ""; }; 52471E871E5CC1E444318070 /* MMProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessTests.swift; sourceTree = ""; }; 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessDetailInspector.m; sourceTree = ""; }; 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = ""; }; 5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = ""; }; + 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMHistoryTests.swift; sourceTree = ""; }; 56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = ""; }; 5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAudioTelemetryProvider.h; sourceTree = ""; }; 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = ""; }; 6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = ""; }; 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = ""; }; + 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsView.swift; sourceTree = ""; }; + 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertNotificationDispatcher.swift; sourceTree = ""; }; 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessDetailsTests.swift; sourceTree = ""; }; 726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = ""; }; 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessTelemetryProvider.m; sourceTree = ""; }; @@ -110,33 +133,43 @@ 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMLoadAverageTests.swift; sourceTree = ""; }; 80A0F723121CDF9597316F58 /* MMGPUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMGPUTests.swift; sourceTree = ""; }; 8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = ""; }; + 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMenuBarTests.swift; sourceTree = ""; }; 940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = ""; }; + 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertConfiguration.swift; sourceTree = ""; }; + 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsSettingsView.swift; sourceTree = ""; }; 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = ""; }; 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMBatteryTelemetryProvider.m; sourceTree = ""; }; 9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMGPUTelemetryProvider.h; sourceTree = ""; }; + 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusView.swift; sourceTree = ""; }; ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = ""; }; AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDiskIOProvider.h; sourceTree = ""; }; B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMBatteryTests.swift; sourceTree = ""; }; B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = ""; }; B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMLoadAverageProvider.m; sourceTree = ""; }; + B657679DB7D1F39AB4911B7B /* AlertModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModels.swift; sourceTree = ""; }; C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkBandwidthProvider.m; sourceTree = ""; }; C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = ""; }; + C8530556B240CCDEA63009DB /* AlertEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEngine.swift; sourceTree = ""; }; C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = ""; }; CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = ""; }; CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPeripheralsProvider.h; sourceTree = ""; }; D1486E1512594482380CB492 /* MMPeripheralsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPeripheralsTests.swift; sourceTree = ""; }; + D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAlertsTests.swift; sourceTree = ""; }; D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = ""; }; + D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDisplayManager.h; sourceTree = ""; }; D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMGPUTelemetryProvider.m; sourceTree = ""; }; D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = ""; }; D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = ""; }; D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkSocketsProvider.m; sourceTree = ""; }; DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMKernelTelemetryTests.swift; sourceTree = ""; }; + DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDisplayTests.swift; sourceTree = ""; }; E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = ""; }; E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = ""; }; F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = ""; }; F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = ""; }; /* End PBXFileReference section */ @@ -169,20 +202,43 @@ 4D8D8B40632C525CBC1D0E43 /* App */, 54787E6270EA7B9A1BE11359 /* Core */, 9844EC526E3527F6A582179F /* Hardware */, + 4BD6F71AA668F3A4B161D0FA /* History */, + DB11C1CC50774018D85A1D79 /* Intelligence */, 8BF79E3BCB76E7A1669DC930 /* Telemetry */, 3E4A0B53C583BD0B7A68EACE /* UI */, ); path = Sources; sourceTree = ""; }; + 3A1C97833AFDC29469DCA9ED /* Charts */ = { + isa = PBXGroup; + children = ( + 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */, + ); + path = Charts; + sourceTree = ""; + }; 3E4A0B53C583BD0B7A68EACE /* UI */ = { isa = PBXGroup; children = ( E142AE61F9B87757997870DD /* ContentView.swift */, + B42842AC7D80A8B77A4A611D /* Alerts */, + 3A1C97833AFDC29469DCA9ED /* Charts */, + BC3C9017978097F8240CEC63 /* MenuBar */, + E3A5B8BAED7E91EE8C6B3B2B /* Settings */, ); path = UI; sourceTree = ""; }; + 4BD6F71AA668F3A4B161D0FA /* History */ = { + isa = PBXGroup; + children = ( + 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */, + 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */, + ); + path = History; + sourceTree = ""; + }; 4D8D8B40632C525CBC1D0E43 /* App */ = { isa = PBXGroup; children = ( @@ -243,11 +299,21 @@ path = Power; sourceTree = ""; }; + 8B972045B60442056C6456DF /* Display */ = { + isa = PBXGroup; + children = ( + D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */, + F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */, + ); + path = Display; + sourceTree = ""; + }; 8BF79E3BCB76E7A1669DC930 /* Telemetry */ = { isa = PBXGroup; children = ( 92E1EF545807CF318620FEAA /* Audio */, 78383CED205BCA772701AE48 /* CPU */, + 8B972045B60442056C6456DF /* Display */, AD2995435E44FA3E1790A4F3 /* Fan */, F99AF003017BA9F19F7AEB1E /* GPU */, CA2F30473D63EC64CBD65437 /* Kernel */, @@ -309,6 +375,14 @@ path = Fan; sourceTree = ""; }; + B42842AC7D80A8B77A4A611D /* Alerts */ = { + isa = PBXGroup; + children = ( + 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */, + ); + path = Alerts; + sourceTree = ""; + }; B6EB3F6545276C40212C2992 /* Storage */ = { isa = PBXGroup; children = ( @@ -320,6 +394,15 @@ path = Storage; sourceTree = ""; }; + BC3C9017978097F8240CEC63 /* MenuBar */ = { + isa = PBXGroup; + children = ( + 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */, + 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */, + ); + path = MenuBar; + sourceTree = ""; + }; C80C3EEE8F912F4B214102E3 /* Products */ = { isa = PBXGroup; children = ( @@ -333,17 +416,21 @@ isa = PBXGroup; children = ( 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */, + D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */, 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */, B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */, B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */, 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */, 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */, E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */, + DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */, 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */, 80A0F723121CDF9597316F58 /* MMGPUTests.swift */, + 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */, DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */, 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */, 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */, + 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */, 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */, 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */, D1486E1512594482380CB492 /* MMPeripheralsTests.swift */, @@ -365,6 +452,17 @@ path = Kernel; sourceTree = ""; }; + DB11C1CC50774018D85A1D79 /* Intelligence */ = { + isa = PBXGroup; + children = ( + 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */, + C8530556B240CCDEA63009DB /* AlertEngine.swift */, + B657679DB7D1F39AB4911B7B /* AlertModels.swift */, + 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */, + ); + path = Intelligence; + sourceTree = ""; + }; DBDC37231C01F0A2D4A7FC73 /* Thermal */ = { isa = PBXGroup; children = ( @@ -388,6 +486,14 @@ path = AppleSMC; sourceTree = ""; }; + E3A5B8BAED7E91EE8C6B3B2B /* Settings */ = { + isa = PBXGroup; + children = ( + 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; E56B76815ED75B28A13D6814 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -490,17 +596,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */, 9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */, BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */, 160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */, 87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */, 57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */, 6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */, + 75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */, E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */, AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */, + D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */, 901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */, ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */, 6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */, + 0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */, 5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */, 4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */, 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */, @@ -517,6 +627,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */, + AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */, + 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */, + 17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */, + 5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */, + 9B882E4189597719436F18FB /* AlertsView.swift in Sources */, A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */, CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */, D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */, @@ -525,6 +641,7 @@ 87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */, 925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */, FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */, + DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */, 655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */, 6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */, A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */, @@ -540,7 +657,12 @@ F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */, EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */, 88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */, + 7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */, + 4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */, + 11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */, A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */, + EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */, + 475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/MacMonitorApp.swift b/Sources/App/MacMonitorApp.swift index 93fb332..8b4c7ba 100644 --- a/Sources/App/MacMonitorApp.swift +++ b/Sources/App/MacMonitorApp.swift @@ -1,9 +1,20 @@ import SwiftUI +/// Bridges app-lifecycle events into SwiftUI: requests Notification Center +/// authorization once at launch so threshold alerts can post banners. +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + if AlertEngine.shared.configuration.alertsEnabled { + AlertEngine.shared.requestNotificationAuthorization() + } + } +} + @main struct MacMonitorApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var store = SystemTelemetryStore.shared - + var body: some Scene { WindowGroup { ContentView() @@ -11,15 +22,20 @@ struct MacMonitorApp: App { .windowStyle(.titleBar) .windowToolbarStyle(.unified) .defaultSize(width: 960, height: 640) - + MenuBarExtra { QuickGlancePopoverView(store: store) } label: { MenuBarStatusView( cpuLoad: store.cpuLoad.totalLoad, - memoryPercent: store.memory.utilizationPercentage + memoryPercent: store.memory.utilizationPercentage, + activeAlertCount: store.alertEngine.activeAlerts.count ) } .menuBarExtraStyle(.window) + + Settings { + AlertsSettingsView() + } } } diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index 3a57c8e..f56bc28 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -257,6 +257,9 @@ public final class SystemTelemetryStore { /// Historical Telemetry Store public let historyStore = TelemetryHistoryStore.shared + /// Threshold alert engine (evaluated after every decode pass) + public let alertEngine = AlertEngine.shared + /// Display Manager helper public let displayManager = MMDisplayManager.shared() @@ -345,7 +348,8 @@ public final class SystemTelemetryStore { m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0 m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0 - m.pressureLevel = memDict["pressureLevel"] as? String ?? "Normal" + m.pressureLevel = memDict["memoryPressureStatus"] as? String + ?? memDict["pressureLevel"] as? String ?? "Normal" self.memory = m } @@ -682,6 +686,29 @@ public final class SystemTelemetryStore { timestamp: self.lastUpdateTimestamp ) self.historyStore.record(snapshot: snapshotRecord) + + // 20. Evaluate Threshold Alert Rules + var context = AlertEvaluationContext(timestamp: self.lastUpdateTimestamp) + context.peakCoreTemperature = self.cpuThermal.peakCoreTemperature + context.packageTemperature = self.cpuThermal.packageTemperature + context.fanRPMs = Dictionary(uniqueKeysWithValues: self.fans.map { ($0.index, $0.currentRPM) }) + context.memoryFreeBytes = self.memory.freeBytes + context.memoryPressureLevel = self.memory.pressureLevel + context.volumes = self.storageVolumes.map { + StorageVolumeContext( + mountPoint: $0.mountPoint, + volumeName: $0.volumeName, + freeBytes: $0.freeBytes, + freePercent: $0.totalBytes > 0 + ? Double($0.freeBytes) / Double($0.totalBytes) * 100.0 + : 0 + ) + } + context.batteryLevel = self.power.batteryLevel + context.hasBattery = self.batteryHealth.hasBattery + context.onExternalPower = self.batteryHealth.externalConnected + context.isCharging = self.batteryHealth.isCharging || self.power.isCharging + self.alertEngine.evaluate(context) } public func terminateProcess(pid: pid_t, force: Bool) -> Bool { diff --git a/Sources/Intelligence/AlertConfiguration.swift b/Sources/Intelligence/AlertConfiguration.swift new file mode 100644 index 0000000..ee2da63 --- /dev/null +++ b/Sources/Intelligence/AlertConfiguration.swift @@ -0,0 +1,67 @@ +import Foundation + +// MARK: - Alert Configuration + +/// User-tunable threshold and cooldown settings for `AlertEngine`. +/// Persisted as a single JSON blob in `UserDefaults` so the engine can read +/// configuration outside of a SwiftUI context. +public struct AlertConfiguration: Codable, Equatable, Sendable { + /// Global switch โ€” when false, no rules are evaluated. + public var alertsEnabled: Bool = true + + /// Minimum minutes between two notifications for the same alert identifier + /// while a condition remains breached. + public var cooldownMinutes: Double = 15 + + // MARK: CPU Temperature + public var cpuTempEnabled: Bool = true + /// Degrees Celsius; breach when the highest core/package reading reaches this. + public var cpuTempThresholdC: Double = 95 + + // MARK: Fan Stall + public var fanStallEnabled: Bool = true + /// Degrees Celsius; a fan reading 0 RPM only alerts while the CPU is at or + /// above this temperature (distinguishes genuine stalls from idle spin-down). + public var fanStallTempThresholdC: Double = 75 + + // MARK: Memory Pressure + public var memoryEnabled: Bool = true + /// Free physical RAM threshold in megabytes. + public var memoryFreeMBThreshold: Double = 500 + /// Also alert whenever the kernel pressure tier reports "Critical". + public var memoryAlertOnCriticalPressure: Bool = true + + // MARK: Storage + public var storageEnabled: Bool = true + /// Free-space threshold per volume in gigabytes. + public var storageFreeGBThreshold: Double = 10 + /// Free-space threshold per volume as a percentage of capacity. + public var storageFreePercentThreshold: Double = 10 + + // MARK: Battery + public var batteryEnabled: Bool = true + /// Charge percentage; only evaluated while discharging on battery power. + public var batteryPercentThreshold: Double = 10 + + public init() {} +} + +// MARK: - UserDefaults Persistence + +public extension AlertConfiguration { + static let storageKey = "com.i3omb.macmonitor.alertConfiguration" + + static func load(from defaults: UserDefaults = .standard) -> AlertConfiguration { + guard let data = defaults.data(forKey: storageKey), + let config = try? JSONDecoder().decode(AlertConfiguration.self, from: data) else { + return AlertConfiguration() + } + return config + } + + func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { + defaults.set(data, forKey: AlertConfiguration.storageKey) + } + } +} diff --git a/Sources/Intelligence/AlertEngine.swift b/Sources/Intelligence/AlertEngine.swift new file mode 100644 index 0000000..3600c33 --- /dev/null +++ b/Sources/Intelligence/AlertEngine.swift @@ -0,0 +1,227 @@ +import Foundation +import UserNotifications + +// MARK: - Alert Engine + +/// Evaluates threshold rules against each decoded telemetry snapshot. +/// Runs on `@MainActor` โ€” it performs only in-memory comparisons over +/// `AlertEvaluationContext` and never touches IOKit/Mach/sysctl, preserving +/// the zero-UI-blocking invariant. +/// +/// Lifecycle per alert identifier: +/// breach edge -> fire notification (subject to cooldown), log `.triggered` +/// persistent -> re-fire only after `cooldownMinutes` elapse +/// recovery -> remove from active set, log `.resolved`, clear cooldown so +/// the next breach alerts immediately +@Observable +@MainActor +public final class AlertEngine { + public static let shared = AlertEngine() + + /// Currently breached conditions, refreshed every evaluation pass. + public private(set) var activeAlerts: [SystemAlert] = [] + /// Newest-first audit log of trigger/resolve events (capped). + public private(set) var history: [AlertEvent] = [] + + /// Threshold settings. Mutations are persisted immediately. + public var configuration: AlertConfiguration { + didSet { configuration.save(to: defaults) } + } + + public let dispatcher: any AlertNotificationDispatching + private let defaults: UserDefaults + private let historyLimit = 200 + private var lastFiredAt: [String: Date] = [:] + private var breachedAlerts: [String: SystemAlert] = [:] + + private convenience init() { + self.init(dispatcher: UNAlertNotificationDispatcher(), defaults: .standard) + } + + init(dispatcher: any AlertNotificationDispatching, defaults: UserDefaults) { + self.dispatcher = dispatcher + self.defaults = defaults + self.configuration = AlertConfiguration.load(from: defaults) + } + + /// Forwards to the dispatcher; call once at launch (and from Settings). + public func requestNotificationAuthorization() { + dispatcher.requestAuthorization() + } + + public func notificationAuthorizationStatus() async -> UNAuthorizationStatus { + await dispatcher.authorizationStatus() + } + + // MARK: Evaluation + + public func evaluate(_ context: AlertEvaluationContext) { + guard configuration.alertsEnabled else { + // Wipe silently so stale alerts do not linger while disabled. + activeAlerts = [] + breachedAlerts = [:] + lastFiredAt = [:] + return + } + + let candidates = buildCandidates(for: context) + var currentIDs = Set() + + for alert in candidates { + currentIDs.insert(alert.id) + breachedAlerts[alert.id] = alert + if shouldFire(alertID: alert.id, at: context.timestamp) { + fire(alert, at: context.timestamp) + } + } + + let resolvedIDs = breachedAlerts.keys + .filter { !currentIDs.contains($0) } + .sorted() + for id in resolvedIDs { + let resolved = breachedAlerts.removeValue(forKey: id) + lastFiredAt.removeValue(forKey: id) + let kind = resolved?.kind ?? Self.kind(fromAlertID: id) + appendEvent( + alertID: id, + kind: kind, + outcome: .resolved, + message: String(localized: "\(resolved?.title ?? id) โ€” condition cleared"), + at: context.timestamp + ) + } + + activeAlerts = candidates + } + + /// Clears all active/breached state and the audit log. + public func reset() { + activeAlerts = [] + history = [] + breachedAlerts = [:] + lastFiredAt = [:] + } + + public func clearHistory() { + history = [] + } + + // MARK: Rule Evaluation + + private func buildCandidates(for ctx: AlertEvaluationContext) -> [SystemAlert] { + let cfg = configuration + var alerts: [SystemAlert] = [] + + // 1. High CPU temperature + if cfg.cpuTempEnabled { + let temp = max(ctx.peakCoreTemperature, ctx.packageTemperature) + if temp >= cfg.cpuTempThresholdC { + alerts.append(SystemAlert( + id: AlertKind.cpuTemperature.rawValue, + kind: .cpuTemperature, + severity: .critical, + title: String(localized: "High CPU Temperature"), + message: String(localized: "CPU reached \(Int(temp))ยฐC โ€” threshold is \(Int(cfg.cpuTempThresholdC))ยฐC"), + timestamp: ctx.timestamp + )) + } + } + + // 2. Cooling fan stall โ€” only meaningful when fans are reported and the + // CPU is hot enough that a working fan should be spinning. + if cfg.fanStallEnabled, + !ctx.fanRPMs.isEmpty, + ctx.packageTemperature >= cfg.fanStallTempThresholdC { + for (index, rpm) in ctx.fanRPMs.sorted(by: { $0.key < $1.key }) where rpm <= 0 { + alerts.append(SystemAlert( + id: "\(AlertKind.fanStall.rawValue):\(index)", + kind: .fanStall, + severity: .critical, + title: String(localized: "Cooling Fan Failure"), + message: String(localized: "Fan \(index + 1) reads 0 RPM while the CPU is at \(Int(ctx.packageTemperature))ยฐC"), + timestamp: ctx.timestamp + )) + } + } + + // 3. Low free memory / critical pressure + if cfg.memoryEnabled { + let freeMB = Double(ctx.memoryFreeBytes) / (1024 * 1024) + let critical = cfg.memoryAlertOnCriticalPressure + && ctx.memoryPressureLevel.localizedCaseInsensitiveContains("critical") + if freeMB < cfg.memoryFreeMBThreshold || critical { + alerts.append(SystemAlert( + id: AlertKind.memoryPressure.rawValue, + kind: .memoryPressure, + severity: critical ? .critical : .warning, + title: String(localized: "Memory Pressure"), + message: String(localized: "Only \(Int(freeMB)) MB RAM free โ€” pressure is \(ctx.memoryPressureLevel)"), + timestamp: ctx.timestamp + )) + } + } + + // 4. Low storage space (per mounted volume) + if cfg.storageEnabled { + for volume in ctx.volumes { + let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024) + if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold { + alerts.append(SystemAlert( + id: "\(AlertKind.lowStorage.rawValue):\(volume.mountPoint)", + kind: .lowStorage, + severity: .warning, + title: String(localized: "Low Disk Space"), + message: String(localized: "\"\(volume.volumeName)\" has \(String(format: "%.1f", freeGB)) GB free (\(Int(volume.freePercent))%)"), + timestamp: ctx.timestamp + )) + } + } + } + + // 5. Low battery โ€” only while actually discharging on battery power. + if cfg.batteryEnabled, + ctx.hasBattery, + !ctx.onExternalPower, + !ctx.isCharging, + ctx.batteryLevel <= cfg.batteryPercentThreshold { + alerts.append(SystemAlert( + id: AlertKind.lowBattery.rawValue, + kind: .lowBattery, + severity: .critical, + title: String(localized: "Low Battery"), + message: String(localized: "Battery is at \(Int(ctx.batteryLevel))% and not charging"), + timestamp: ctx.timestamp + )) + } + + return alerts + } + + // MARK: Cooldown & History + + private func shouldFire(alertID: String, at date: Date) -> Bool { + guard let last = lastFiredAt[alertID] else { return true } + return date.timeIntervalSince(last) >= configuration.cooldownMinutes * 60 + } + + private func fire(_ alert: SystemAlert, at date: Date) { + lastFiredAt[alert.id] = date + dispatcher.dispatch(alert) + appendEvent(alertID: alert.id, kind: alert.kind, outcome: .triggered, message: alert.message, at: date) + } + + private func appendEvent(alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String, at date: Date) { + history.insert( + AlertEvent(timestamp: date, alertID: alertID, kind: kind, outcome: outcome, message: message), + at: 0 + ) + if history.count > historyLimit { + history.removeLast(history.count - historyLimit) + } + } + + private static func kind(fromAlertID id: String) -> AlertKind { + let prefix = id.prefix { $0 != ":" } + return AlertKind(rawValue: String(prefix)) ?? .cpuTemperature + } +} diff --git a/Sources/Intelligence/AlertModels.swift b/Sources/Intelligence/AlertModels.swift new file mode 100644 index 0000000..c1135fc --- /dev/null +++ b/Sources/Intelligence/AlertModels.swift @@ -0,0 +1,125 @@ +import Foundation + +// MARK: - Alert Models + +/// Categories of threshold conditions evaluated by `AlertEngine`. +public enum AlertKind: String, Codable, CaseIterable, Sendable { + case cpuTemperature + case fanStall + case memoryPressure + case lowStorage + case lowBattery +} + +/// Visual and audible emphasis level for a raised alert. +public enum AlertSeverity: String, Codable, Sendable { + case warning + case critical +} + +/// A single breached threshold condition. +public struct SystemAlert: Identifiable, Equatable, Sendable { + /// Stable identifier of the form `""` or `":"` + /// (e.g. `"lowStorage:/"`, `"fanStall:0"`) used for cooldown bookkeeping. + public let id: String + public let kind: AlertKind + public let severity: AlertSeverity + public let title: String + public let message: String + public let timestamp: Date + + public init(id: String, kind: AlertKind, severity: AlertSeverity, title: String, message: String, timestamp: Date) { + self.id = id + self.kind = kind + self.severity = severity + self.title = title + self.message = message + self.timestamp = timestamp + } +} + +/// What happened to an alert condition during an evaluation pass. +public enum AlertEventOutcome: String, Codable, Sendable { + case triggered + case resolved + case suppressedCooldown +} + +/// An entry in the in-memory alert history log. +public struct AlertEvent: Identifiable, Equatable, Sendable { + public let id: UUID + public let timestamp: Date + public let alertID: String + public let kind: AlertKind + public let outcome: AlertEventOutcome + public let message: String + + public init(timestamp: Date, alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String) { + self.id = UUID() + self.timestamp = timestamp + self.alertID = alertID + self.kind = kind + self.outcome = outcome + self.message = message + } +} + +/// Per-volume storage context consumed by the low-storage rule. +public struct StorageVolumeContext: Equatable, Sendable { + public let mountPoint: String + public let volumeName: String + public let freeBytes: UInt64 + public let freePercent: Double + + public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) { + self.mountPoint = mountPoint + self.volumeName = volumeName + self.freeBytes = freeBytes + self.freePercent = freePercent + } +} + +/// Flat, immutable snapshot of the metrics the alert rules inspect. +/// Assembled by `SystemTelemetryStore` after each decode pass so the engine +/// stays decoupled from provider dictionary schemas and testable in isolation. +public struct AlertEvaluationContext: Sendable { + public var peakCoreTemperature: Double = 0 + public var packageTemperature: Double = 0 + /// Fan index -> current RPM. Empty when no fan telemetry is available. + public var fanRPMs: [Int: Double] = [:] + public var memoryFreeBytes: UInt64 = 0 + public var memoryPressureLevel: String = "Normal" + public var volumes: [StorageVolumeContext] = [] + /// Charge level 0-100 reported by the power provider. + public var batteryLevel: Double = 100 + public var hasBattery: Bool = false + public var onExternalPower: Bool = true + public var isCharging: Bool = false + public var timestamp: Date = Date() + + public init( + peakCoreTemperature: Double = 0, + packageTemperature: Double = 0, + fanRPMs: [Int: Double] = [:], + memoryFreeBytes: UInt64 = 0, + memoryPressureLevel: String = "Normal", + volumes: [StorageVolumeContext] = [], + batteryLevel: Double = 100, + hasBattery: Bool = false, + onExternalPower: Bool = true, + isCharging: Bool = false, + timestamp: Date = Date() + ) { + self.peakCoreTemperature = peakCoreTemperature + self.packageTemperature = packageTemperature + self.fanRPMs = fanRPMs + self.memoryFreeBytes = memoryFreeBytes + self.memoryPressureLevel = memoryPressureLevel + self.volumes = volumes + self.batteryLevel = batteryLevel + self.hasBattery = hasBattery + self.onExternalPower = onExternalPower + self.isCharging = isCharging + self.timestamp = timestamp + } +} diff --git a/Sources/Intelligence/AlertNotificationDispatcher.swift b/Sources/Intelligence/AlertNotificationDispatcher.swift new file mode 100644 index 0000000..8ebb06c --- /dev/null +++ b/Sources/Intelligence/AlertNotificationDispatcher.swift @@ -0,0 +1,132 @@ +import Foundation +import UserNotifications + +// MARK: - Navigation Bus + +/// Lightweight bus letting notification action handlers steer the main window: +/// tapping a notification (or its "View Processes" action) activates the app and +/// selects the requested sidebar tab inside `ContentView`. +@Observable +public final class AppNavigationBus { + public static let shared = AppNavigationBus() + + /// Sidebar tag the main window should switch to (e.g. `"Processes"`). + /// `nil` means "just bring the window forward". + public private(set) var requestedTab: String? + /// Monotonic counter so repeated taps on the same tab still trigger `onChange`. + public private(set) var requestCounter: Int = 0 + + private init() {} + + public func request(tab: String?) { + requestedTab = tab + requestCounter += 1 + } +} + +// MARK: - Dispatch Protocol + +/// Abstraction over the delivery channel used by `AlertEngine` so unit tests can +/// inject a mock and never touch the real `UNUserNotificationCenter`. +public protocol AlertNotificationDispatching: AnyObject { + /// Requests `.alert` + `.sound` permission (idempotent; no-op if determined). + func requestAuthorization() + /// Posts a banner notification for a breached threshold. + func dispatch(_ alert: SystemAlert) + /// Current authorization status, used to render the Settings pane. + func authorizationStatus() async -> UNAuthorizationStatus +} + +// MARK: - Notification Action Identifiers + +public enum AlertNotificationAction { + public static let categoryIdentifier = "MM_ALERT" + public static let openAppIdentifier = "MM_ALERT_OPEN_APP" + public static let viewProcessesIdentifier = "MM_ALERT_VIEW_PROCESSES" +} + +// MARK: - UserNotifications Implementation + +/// Delivers threshold alerts through macOS Notification Center and routes +/// notification actions back into the UI via `AppNavigationBus`. +public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDispatching, UNUserNotificationCenterDelegate, @unchecked Sendable { + private let center: UNUserNotificationCenter + + public override init() { + self.center = UNUserNotificationCenter.current() + super.init() + center.delegate = self + registerCategories() + } + + private func registerCategories() { + let open = UNNotificationAction( + identifier: AlertNotificationAction.openAppIdentifier, + title: String(localized: "Open MacMonitor"), + options: [.foreground] + ) + let processes = UNNotificationAction( + identifier: AlertNotificationAction.viewProcessesIdentifier, + title: String(localized: "View Processes"), + options: [.foreground] + ) + let category = UNNotificationCategory( + identifier: AlertNotificationAction.categoryIdentifier, + actions: [open, processes], + intentIdentifiers: [] + ) + center.setNotificationCategories([category]) + } + + public func requestAuthorization() { + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + public func authorizationStatus() async -> UNAuthorizationStatus { + await center.notificationSettings().authorizationStatus + } + + public func dispatch(_ alert: SystemAlert) { + let content = UNMutableNotificationContent() + content.title = alert.title + content.body = alert.message + content.sound = .default + content.categoryIdentifier = AlertNotificationAction.categoryIdentifier + content.userInfo = ["kind": alert.kind.rawValue, "severity": alert.severity.rawValue] + if alert.severity == .critical { + content.interruptionLevel = .timeSensitive + } + + let request = UNNotificationRequest(identifier: alert.id, content: content, trigger: nil) + center.add(request) { _ in } + } + + // MARK: UNUserNotificationCenterDelegate + + /// Show banners even while MacMonitor is frontmost โ€” alerts are warnings the + /// user explicitly asked to see regardless of focus. + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .list]) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let targetTab: String? = switch response.actionIdentifier { + case AlertNotificationAction.viewProcessesIdentifier: + "Processes" + default: + nil + } + Task { @MainActor in + AppNavigationBus.shared.request(tab: targetTab) + } + completionHandler() + } +} diff --git a/Sources/UI/Alerts/AlertsView.swift b/Sources/UI/Alerts/AlertsView.swift new file mode 100644 index 0000000..c8f0be2 --- /dev/null +++ b/Sources/UI/Alerts/AlertsView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +// MARK: - Alerts & Notifications View + +/// Sidebar tab showing currently breached thresholds and the in-app +/// alert history log maintained by `AlertEngine`. +public struct AlertsView: View { + @State private var engine = AlertEngine.shared + + public init() {} + + public var body: some View { + VStack(alignment: .leading, spacing: 20) { + activeAlertsCard + historyCard + } + } + + // MARK: - Active Alerts + + private var activeAlertsCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Active Alerts", systemImage: "exclamationmark.triangle.fill") + .font(.headline) + Spacer() + if engine.activeAlerts.isEmpty { + Text("ALL CLEAR") + .font(.caption.bold()) + .foregroundStyle(.green) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.15), in: Capsule()) + } else { + Text("\(engine.activeAlerts.count) BREACHED") + .font(.caption.bold()) + .foregroundStyle(.red) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.15), in: Capsule()) + } + } + + if engine.activeAlerts.isEmpty { + Text("All monitored thresholds are within normal limits.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(engine.activeAlerts) { alert in + HStack(alignment: .top, spacing: 10) { + Image(systemName: iconName(for: alert.kind)) + .foregroundStyle(color(for: alert.severity)) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(alert.title) + .font(.caption.bold()) + Text(alert.message) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(alert.severity.rawValue.uppercased()) + .font(.caption2.bold()) + .foregroundStyle(color(for: alert.severity)) + Text(alert.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(8) + .background(color(for: alert.severity).opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - History Log + + private var historyCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Alert History", systemImage: "clock.arrow.circlepath") + .font(.headline) + Spacer() + Button("Clear History") { + engine.clearHistory() + } + .buttonStyle(.bordered) + .disabled(engine.history.isEmpty) + } + + if engine.history.isEmpty { + Text("No alert events recorded yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(engine.history) { event in + HStack(alignment: .top, spacing: 10) { + Image(systemName: outcomeIcon(event.outcome)) + .foregroundStyle(outcomeColor(event.outcome)) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(event.message) + .font(.caption) + Text(event.alertID) + .font(.caption2) + .foregroundStyle(.secondary) + .monospaced() + } + Spacer() + Text(event.timestamp.formatted(date: .abbreviated, time: .standard)) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + Divider() + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1)) + } + + // MARK: - Presentation Helpers + + private func iconName(for kind: AlertKind) -> String { + switch kind { + case .cpuTemperature: return "thermometer.high" + case .fanStall: return "fanblades" + case .memoryPressure: return "memorychip" + case .lowStorage: return "internaldrive" + case .lowBattery: return "battery.25" + } + } + + private func color(for severity: AlertSeverity) -> Color { + switch severity { + case .critical: return .red + case .warning: return .orange + } + } + + private func outcomeIcon(_ outcome: AlertEventOutcome) -> String { + switch outcome { + case .triggered: return "bell.fill" + case .resolved: return "checkmark.circle.fill" + case .suppressedCooldown: return "bell.slash" + } + } + + private func outcomeColor(_ outcome: AlertEventOutcome) -> Color { + switch outcome { + case .triggered: return .red + case .resolved: return .green + case .suppressedCooldown: return .secondary + } + } +} diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index c859821..24420af 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -7,6 +7,7 @@ public struct ContentView: View { @State private var socketSearchText: String = "" @State private var selectedPID: Int32? = nil @State private var showingProcessDetail: Bool = false + @State private var navBus = AppNavigationBus.shared public init() {} @@ -25,6 +26,15 @@ public struct ContentView: View { ProcessDetailSheet(inspector: store.processInspector, pid: pid) } } + .onChange(of: navBus.requestCounter) { _, _ in + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } + if let tab = navBus.requestedTab { + selectedTab = tab + } + } } // MARK: - Sidebar @@ -75,6 +85,12 @@ public struct ContentView: View { Label("Historical Trends", systemImage: "chart.line.uptrend.xyaxis") .tag("Trends") } + + Section("Intelligence") { + Label("Alerts & Notifications", systemImage: "bell.badge") + .tag("Alerts") + .badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)")) + } } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300) @@ -129,6 +145,8 @@ public struct ContentView: View { audioDevicesCard case "Trends": historicalTrendsView + case "Alerts": + AlertsView() default: dashboardView } @@ -184,6 +202,24 @@ public struct ContentView: View { Spacer() + if !store.alertEngine.activeAlerts.isEmpty { + Button { + selectedTab = "Alerts" + } label: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("\(store.alertEngine.activeAlerts.count)") + .font(.caption.bold().monospacedDigit()) + } + .foregroundStyle(.red) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.red.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + .help("\(store.alertEngine.activeAlerts.count) active alert(s) โ€” click to review") + } + HStack(spacing: 8) { Circle() .fill(store.isRunning ? Color.green : Color.red) diff --git a/Sources/UI/MenuBar/MenuBarStatusView.swift b/Sources/UI/MenuBar/MenuBarStatusView.swift index f31974d..e000040 100644 --- a/Sources/UI/MenuBar/MenuBarStatusView.swift +++ b/Sources/UI/MenuBar/MenuBarStatusView.swift @@ -3,14 +3,22 @@ import SwiftUI public struct MenuBarStatusView: View { public let cpuLoad: Double public let memoryPercent: Double + public let activeAlertCount: Int - public init(cpuLoad: Double, memoryPercent: Double) { + public init(cpuLoad: Double, memoryPercent: Double, activeAlertCount: Int = 0) { self.cpuLoad = cpuLoad self.memoryPercent = memoryPercent + self.activeAlertCount = activeAlertCount } public var body: some View { HStack(spacing: 5) { + if activeAlertCount > 0 { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundStyle(.orange) + } + Image(systemName: "cpu") .font(.system(size: 11)) Text(String(format: "%.0f%%", cpuLoad)) diff --git a/Sources/UI/Settings/AlertsSettingsView.swift b/Sources/UI/Settings/AlertsSettingsView.swift new file mode 100644 index 0000000..bf693f5 --- /dev/null +++ b/Sources/UI/Settings/AlertsSettingsView.swift @@ -0,0 +1,165 @@ +import SwiftUI +import UserNotifications + +// MARK: - Alerts Settings Pane + +/// Preferences UI for threshold alert rules. Bound directly to +/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`. +public struct AlertsSettingsView: View { + @Bindable private var engine = AlertEngine.shared + @State private var authorizationStatus: UNAuthorizationStatus = .notDetermined + + public init() {} + + public var body: some View { + Form { + Section("Notifications") { + Toggle("Enable Threshold Alerts", isOn: $engine.configuration.alertsEnabled) + + HStack { + Text("Notification Permission") + Spacer() + Text(statusText) + .foregroundStyle(statusColor) + if authorizationStatus == .notDetermined { + Button("Request Permission") { + engine.requestNotificationAuthorization() + refreshAuthorizationStatus() + } + } else if authorizationStatus == .denied { + Button("Open System Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } + } + } + + Button("Send Test Notification") { + engine.dispatcher.dispatch(SystemAlert( + id: "testNotification", + kind: .cpuTemperature, + severity: .warning, + title: String(localized: "MacMonitor Test Alert"), + message: String(localized: "Notifications are configured correctly."), + timestamp: Date() + )) + } + .disabled(!engine.configuration.alertsEnabled) + + Picker("Alert Cooldown", selection: $engine.configuration.cooldownMinutes) { + Text("1 minute").tag(1.0) + Text("5 minutes").tag(5.0) + Text("15 minutes").tag(15.0) + Text("30 minutes").tag(30.0) + Text("1 hour").tag(60.0) + } + } + + Section("CPU Temperature") { + Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled) + LabeledContent("Threshold") { + Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.cpuTempThresholdC))ยฐC") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + } + + Section("Cooling Fans") { + Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled) + LabeledContent("Alert when CPU above") { + Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.fanStallTempThresholdC))ยฐC") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + Text("Triggers when a fan reports 0 RPM while the CPU is hot.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Memory") { + Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled) + LabeledContent("Free RAM below") { + Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) { + EmptyView() + } + Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB") + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + } + Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure) + } + + Section("Storage") { + Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled) + LabeledContent("Free space below") { + Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + LabeledContent("Free percent below") { + Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.storageFreePercentThreshold))%") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + } + + Section("Battery") { + Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled) + LabeledContent("Charge below") { + Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) { + EmptyView() + } + Text("\(Int(engine.configuration.batteryPercentThreshold))%") + .monospacedDigit() + .frame(width: 48, alignment: .trailing) + } + Text("Only fires while discharging on battery power.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .frame(minWidth: 480, minHeight: 520) + .task { await refreshAuthorizationStatus() } + } + + private var statusText: String { + switch authorizationStatus { + case .authorized, .provisional, .ephemeral: return "Authorized" + case .denied: return "Denied" + case .notDetermined: return "Not Requested" + @unknown default: return "Unknown" + } + } + + private var statusColor: Color { + switch authorizationStatus { + case .authorized, .provisional, .ephemeral: return .green + case .denied: return .red + default: return .secondary + } + } + + private func refreshAuthorizationStatus() { + Task { + authorizationStatus = await engine.notificationAuthorizationStatus() + } + } + + private func refreshAuthorizationStatus() async { + authorizationStatus = await engine.notificationAuthorizationStatus() + } +} diff --git a/Tests/MMAlertsTests.swift b/Tests/MMAlertsTests.swift new file mode 100644 index 0000000..c30063a --- /dev/null +++ b/Tests/MMAlertsTests.swift @@ -0,0 +1,236 @@ +import XCTest +import UserNotifications +@testable import MacMonitor + +// MARK: - Mock Dispatcher + +final class MockNotificationDispatcher: AlertNotificationDispatching { + private(set) var dispatchedAlerts: [SystemAlert] = [] + private(set) var authorizationRequests = 0 + var stubbedStatus: UNAuthorizationStatus = .authorized + + func requestAuthorization() { + authorizationRequests += 1 + } + + func dispatch(_ alert: SystemAlert) { + dispatchedAlerts.append(alert) + } + + func authorizationStatus() async -> UNAuthorizationStatus { + stubbedStatus + } +} + +// MARK: - Tests + +@MainActor +final class MMAlertsTests: XCTestCase { + private var mock: MockNotificationDispatcher! + private var engine: AlertEngine! + private var defaults: UserDefaults! + + override func setUp() async throws { + mock = MockNotificationDispatcher() + defaults = UserDefaults(suiteName: "MMAlertsTests-\(UUID().uuidString)")! + engine = AlertEngine(dispatcher: mock, defaults: defaults) + } + + // MARK: Helpers + + private func context( + peakTemp: Double = 50, + packageTemp: Double = 50, + fans: [Int: Double] = [0: 2500], + freeMemMB: Double = 8192, + pressure: String = "Normal", + volumes: [StorageVolumeContext] = [], + battery: Double = 80, + hasBattery: Bool = false, + onAC: Bool = true, + charging: Bool = false, + at date: Date = Date() + ) -> AlertEvaluationContext { + AlertEvaluationContext( + peakCoreTemperature: peakTemp, + packageTemperature: packageTemp, + fanRPMs: fans, + memoryFreeBytes: UInt64(freeMemMB * 1024 * 1024), + memoryPressureLevel: pressure, + volumes: volumes, + batteryLevel: battery, + hasBattery: hasBattery, + onExternalPower: onAC, + isCharging: charging, + timestamp: date + ) + } + + // MARK: CPU Temperature + + func testCPUTemperatureRuleTriggersAboveThreshold() { + engine.evaluate(context(peakTemp: 96)) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.kind, .cpuTemperature) + XCTAssertEqual(engine.activeAlerts.first?.severity, .critical) + XCTAssertEqual(mock.dispatchedAlerts.count, 1) + } + + func testCPUTemperatureRuleUsesMaxOfPeakAndPackage() { + engine.evaluate(context(peakTemp: 40, packageTemp: 96)) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + func testCPUTemperatureRuleDoesNotFireBelowThreshold() { + engine.evaluate(context(peakTemp: 94.9, packageTemp: 50)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(mock.dispatchedAlerts.isEmpty) + } + + // MARK: Fan Stall + + func testFanStallTriggersWhenZeroRPMWhileHot() { + engine.evaluate(context(packageTemp: 80, fans: [0: 0, 1: 2200])) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.id, "fanStall:0") + } + + func testFanStallDoesNotTriggerWhileCPUIsCool() { + engine.evaluate(context(packageTemp: 40, fans: [0: 0])) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testFanStallSkippedWhenNoFanTelemetry() { + engine.evaluate(context(packageTemp: 90, fans: [:])) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Memory + + func testMemoryRuleTriggersOnLowFreeBytes() { + engine.evaluate(context(freeMemMB: 400)) + XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure) + XCTAssertEqual(engine.activeAlerts.first?.severity, .warning) + } + + func testMemoryRuleTriggersOnCriticalPressure() { + engine.evaluate(context(freeMemMB: 8192, pressure: "Critical")) + XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure) + XCTAssertEqual(engine.activeAlerts.first?.severity, .critical) + } + + func testMemoryRuleDoesNotFireWhenHealthy() { + engine.evaluate(context(freeMemMB: 8192, pressure: "Normal")) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Storage + + func testStorageRuleTriggersPerVolume() { + let volumes = [ + StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5), + StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50) + ] + engine.evaluate(context(volumes: volumes)) + XCTAssertEqual(engine.activeAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.first?.id, "lowStorage:/") + } + + func testStorageRuleTriggersOnLowPercent() { + let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)] + engine.evaluate(context(volumes: volumes)) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + // MARK: Battery + + func testBatteryRuleTriggersOnlyWhileDischarging() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: false)) + XCTAssertEqual(engine.activeAlerts.first?.kind, .lowBattery) + } + + func testBatteryRuleSkippedOnExternalPower() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: true, charging: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testBatteryRuleSkippedWhileCharging() { + engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: true)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testBatteryRuleSkippedWithoutBattery() { + engine.evaluate(context(battery: 5, hasBattery: false, onAC: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + // MARK: Cooldown & Resolution + + func testCooldownSuppressesRepeatNotifications() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(60))) + XCTAssertEqual(mock.dispatchedAlerts.count, 1) + XCTAssertEqual(engine.activeAlerts.count, 1) + } + + func testCooldownExpiresAndRefires() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(16 * 60))) + XCTAssertEqual(mock.dispatchedAlerts.count, 2) + } + + func testResolutionClearsAlertAndAllowsImmediateRealert() { + let t0 = Date() + engine.evaluate(context(peakTemp: 100, at: t0)) + engine.evaluate(context(peakTemp: 60, at: t0.addingTimeInterval(60))) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(engine.history.contains { $0.outcome == .resolved }) + engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(120))) + XCTAssertEqual(mock.dispatchedAlerts.count, 2) + } + + func testHistoryRecordsTriggeredEvents() { + engine.evaluate(context(peakTemp: 100)) + XCTAssertEqual(engine.history.count, 1) + XCTAssertEqual(engine.history.first?.outcome, .triggered) + XCTAssertEqual(engine.history.first?.alertID, AlertKind.cpuTemperature.rawValue) + } + + // MARK: Configuration & Enablement + + func testGlobalDisableSuppressesAllRules() { + engine.configuration.alertsEnabled = false + engine.evaluate(context(peakTemp: 120, fans: [0: 0], freeMemMB: 10, battery: 1, hasBattery: true, onAC: false)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + XCTAssertTrue(mock.dispatchedAlerts.isEmpty) + } + + func testPerRuleDisableSuppressesThatRule() { + engine.configuration.cpuTempEnabled = false + engine.evaluate(context(peakTemp: 120)) + XCTAssertTrue(engine.activeAlerts.isEmpty) + } + + func testConfigurationPersistsToUserDefaults() { + engine.configuration.cpuTempThresholdC = 80 + let reloaded = AlertConfiguration.load(from: defaults) + XCTAssertEqual(reloaded.cpuTempThresholdC, 80) + } + + func testConfigurationDefaults() { + let config = AlertConfiguration() + XCTAssertTrue(config.alertsEnabled) + XCTAssertEqual(config.cooldownMinutes, 15) + XCTAssertEqual(config.cpuTempThresholdC, 95) + XCTAssertEqual(config.memoryFreeMBThreshold, 500) + XCTAssertEqual(config.storageFreeGBThreshold, 10) + XCTAssertEqual(config.batteryPercentThreshold, 10) + } + + func testAuthorizationRequestForwarded() { + engine.requestNotificationAuthorization() + XCTAssertEqual(mock.authorizationRequests, 1) + } +} -- 2.39.5 From 073d4036c7c2f2b38ac51f51f84d3e781d1daac1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 13:58:09 +0100 Subject: [PATCH 38/39] ci: add release packaging job producing MacMonitor-intel-x86_64.zip on tags Add a release-package job (gated on v* tags, after build-and-test) that builds the Release configuration for x86_64, ad-hoc signs the app bundle so UNUserNotificationCenter works in the distributed build, archives MacMonitor.app via ditto, uploads it as a workflow artifact, and creates a Gitea release with the zip attached via the API token. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitea/workflows/build.yml | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 86b137c..e4ca251 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -74,3 +74,80 @@ jobs: -destination 'platform=macOS,arch=x86_64' \ CODE_SIGNING_ALLOWED=NO fi + + release-package: + name: Package Release Artifact + needs: build-and-test + runs-on: macos-14 + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Generate Xcode Project + run: | + if command -v xcodegen &> /dev/null; then + xcodegen generate + else + echo "xcodegen not preinstalled, using tracked MacMonitor.xcodeproj" + fi + + - name: Build Release (Intel x86_64) + run: | + set -o pipefail + xcodebuild clean build \ + -scheme MacMonitor \ + -configuration Release \ + -destination 'generic/platform=macOS,arch=x86_64' \ + -derivedDataPath "$PWD/DerivedData" \ + CODE_SIGNING_ALLOWED=NO + + - name: Ad-hoc Sign Application Bundle + run: | + APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app" + # Ad-hoc signing is required for UNUserNotificationCenter authorization + # and banner delivery in distributed unsigned builds. + codesign --force --deep --sign - "$APP" + codesign --verify --verbose "$APP" + + - name: Create Distribution Archive + run: | + APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app" + cd "$(dirname "$APP")" + ditto -c -k --sequesterRsrc --keepParent "MacMonitor.app" "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" + ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" + + - name: Upload Workflow Artifact + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: MacMonitor-intel-x86_64 + path: MacMonitor-intel-x86_64.zip + + - name: Create Gitea Release & Attach Artifact + run: | + set -e + TAG="${{ github.ref_name }}" + API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}" + + # Create the release (ignore conflict if it already exists) + curl -sS -X POST "$API/releases" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"MacMonitor $TAG\",\"draft\":false,\"prerelease\":false}" \ + -o /tmp/release.json -w "create_release_http=%{http_code}\n" + + # Resolve release ID (created above or pre-existing) + RELEASE_ID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2) + if [ -z "$RELEASE_ID" ]; then + RELEASE_ID=$(curl -sS "$API/releases/tags/$TAG" -H "$AUTH" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + fi + if [ -z "$RELEASE_ID" ]; then + echo "ERROR: could not resolve release ID for $TAG"; exit 1 + fi + + curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.zip" \ + -H "$AUTH" -H "Content-Type: application/zip" \ + --data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" \ + -o /tmp/asset.json -w "attach_asset_http=%{http_code}\n" -- 2.39.5 From 7cb6c25ae285744d1ba2421375ad92ab8c009cd2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 14:09:22 +0100 Subject: [PATCH 39/39] chore(release): set app version to 0.0.1 (build 1) Add MARKETING_VERSION and CURRENT_PROJECT_VERSION to project.yml so the generated Info.plist carries CFBundleShortVersionString/CFBundleVersion for the v0.0.1 release artifacts. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- MacMonitor.xcodeproj/project.pbxproj | 4 ++++ project.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 33ae665..4767b62 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -761,6 +761,7 @@ CODE_SIGNING_ALLOWED = NO; CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -774,6 +775,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.0.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -846,6 +848,7 @@ CODE_SIGNING_ALLOWED = NO; CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -865,6 +868,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.0.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; diff --git a/project.yml b/project.yml index 891c3ff..746dd11 100644 --- a/project.yml +++ b/project.yml @@ -16,6 +16,8 @@ settings: CLANG_ENABLE_OBJC_ARC: YES CLANG_ENABLE_MODULES: YES GENERATE_INFOPLIST_FILE: YES + MARKETING_VERSION: "0.0.1" + CURRENT_PROJECT_VERSION: "1" CODE_SIGNING_ALLOWED: NO CODE_SIGN_IDENTITY: "" GCC_C_LANGUAGE_STANDARD: "gnu17" -- 2.39.5