Show the CPU temperature on Apple Silicon

Power & Storage only had macOS's thermal state (a level, no degrees) and
the battery temperature. The CPU die sensors are now read through
IOHIDEventSystemClient (no root or entitlement needed outside the
sandbox): "PMU tdie*" from M2 on, "pACC/eACC MTR Temp Sensor*" on M1.
System Status shows the hottest one, with the average and sensor count in
the tooltip, coloured from 80/90/100 °C. Intel Macs have no such sensors
and the row stays hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hariel1985
2026-10-05 21:51:42 +02:00
szülő 2685ed9667
commit 15c8b18b48
6 fájl változott, egészen pontosan 240 új sor hozzáadva és 3 régi sor törölve

Fájl megtekintése

@@ -23,12 +23,14 @@
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; }; 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; };
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; }; 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; };
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; }; 501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; };
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */; };
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; }; 67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; }; 6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; }; 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; }; 7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; };
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9632A456D316A019CFA313 /* ThermalMathTests.swift */; };
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; }; 833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; }; 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; }; 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
@@ -43,6 +45,7 @@
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; }; CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A81F77752CFF49D5038B06E /* ThermalInfo.swift */; };
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; }; D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; }; D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; }; DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
@@ -76,6 +79,7 @@
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; }; 110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; };
126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; }; 126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; };
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = "<group>"; };
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; }; 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; }; 279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; }; 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
@@ -95,6 +99,7 @@
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; }; 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; }; 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; }; 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalInfo.swift; sourceTree = "<group>"; };
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; }; 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; }; 813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; };
892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; }; 892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
@@ -120,6 +125,7 @@
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; }; E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; };
F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; }; F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; };
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; }; F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalMathTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -138,6 +144,7 @@
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */, 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
279B972C08A878053821285D /* SystemMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */,
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */, 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
); );
path = Services; path = Services;
@@ -164,6 +171,7 @@
3010F605712E09175D9D8878 /* NetworkInfo.swift */, 3010F605712E09175D9D8878 /* NetworkInfo.swift */,
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */, 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */, 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -263,6 +271,7 @@
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
332290DABD5326C87258723C /* ProcessDataTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */,
01C19ABF49697A97F0C40378 /* SettingsTests.swift */, 01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
); );
path = TopManagerTests; path = TopManagerTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -387,6 +396,7 @@
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -430,6 +440,8 @@
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */, B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */,
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */,
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */, 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,

Fájl megtekintése

@@ -0,0 +1,53 @@
import Foundation
/// One temperature sensor as reported by the system (name as the hardware names it).
struct TemperatureReading: Equatable {
let name: String
let celsius: Double
}
/// CPU temperature summarised from the die sensors.
struct ThermalInfo: Equatable {
let cpuMax: Double // °C, hottest CPU die sensor
let cpuAverage: Double // °C, mean of the CPU die sensors
let cpuSensorCount: Int
}
/// Pure, testable selection of CPU sensors (no IOKit).
enum ThermalMath {
/// CPU die sensors on Apple Silicon: "PMU tdie*" / "PMU2 tdie*" (M2 and later)
/// and "pACC/eACC MTR Temp Sensor*" (M1). The "tdev" ones sit elsewhere on the
/// package, "tcal" is a calibration value, "gas gauge battery" is the battery.
static func isCPUDie(_ name: String) -> Bool {
let n = name.lowercased()
return n.contains("tdie") || n.contains("acc mtr temp")
}
/// Readings no real sensor produces (unconnected ones report ≤ 0).
static func isPlausible(_ celsius: Double) -> Bool {
celsius > 0 && celsius < 150
}
/// How hot the CPU die is. Apple Silicon runs into the 90s under sustained load
/// and only throttles near 105 °C, so the bands sit high on purpose.
static func level(cpuCelsius t: Double) -> ThermalLevel {
switch t {
case 100...: return .critical
case 90..<100: return .serious
case 80..<90: return .fair
default: return .nominal
}
}
static func summarize(_ readings: [TemperatureReading]) -> ThermalInfo? {
let cpu = readings
.filter { isCPUDie($0.name) && isPlausible($0.celsius) }
.map(\.celsius)
guard let hottest = cpu.max() else { return nil }
return ThermalInfo(
cpuMax: hottest,
cpuAverage: cpu.reduce(0, +) / Double(cpu.count),
cpuSensorCount: cpu.count
)
}
}

Fájl megtekintése

@@ -15,6 +15,7 @@ final class SystemMonitor: ObservableObject {
@MainActor @Published var networkInfo: NetworkInfo? @MainActor @Published var networkInfo: NetworkInfo?
@MainActor @Published var gpuInfo: GPUInfo? @MainActor @Published var gpuInfo: GPUInfo?
@MainActor @Published var powerInfo: PowerInfo? @MainActor @Published var powerInfo: PowerInfo?
@MainActor @Published var thermalInfo: ThermalInfo?
@MainActor @Published var lastError: String? @MainActor @Published var lastError: String?
// History for charts // History for charts
@@ -33,6 +34,7 @@ final class SystemMonitor: ObservableObject {
var networkInfo: NetworkInfo? var networkInfo: NetworkInfo?
var gpuInfo: GPUInfo? var gpuInfo: GPUInfo?
var powerInfo: PowerInfo? var powerInfo: PowerInfo?
var thermalInfo: ThermalInfo?
var cpuHistory: [CPUHistoryPoint] = [] var cpuHistory: [CPUHistoryPoint] = []
var coreHistories: [Int: [CoreHistoryPoint]] = [:] var coreHistories: [Int: [CoreHistoryPoint]] = [:]
var memoryHistory: [MemoryHistoryPoint] = [] var memoryHistory: [MemoryHistoryPoint] = []
@@ -49,6 +51,7 @@ final class SystemMonitor: ObservableObject {
private let networkMonitor = NetworkMonitor() private let networkMonitor = NetworkMonitor()
private let gpuMonitor = GPUMonitor() private let gpuMonitor = GPUMonitor()
private let powerMonitor = PowerMonitor() private let powerMonitor = PowerMonitor()
private let temperatureMonitor = TemperatureMonitor()
// Timer // Timer
private var timer: Timer? private var timer: Timer?
@@ -87,10 +90,12 @@ final class SystemMonitor: ObservableObject {
let diskData = self.diskMonitor.fetchDiskInfo() let diskData = self.diskMonitor.fetchDiskInfo()
let gpuData = self.gpuMonitor.fetchGPUInfo() let gpuData = self.gpuMonitor.fetchGPUInfo()
let powerData = self.powerMonitor.fetchPowerInfo() let powerData = self.powerMonitor.fetchPowerInfo()
let thermalData = self.temperatureMonitor.fetchThermalInfo()
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateUI(cpu: cpuData, memory: memData, network: netData, self.updateUI(cpu: cpuData, memory: memData, network: netData,
processes: processData, disk: diskData, gpu: gpuData, power: powerData) processes: processData, disk: diskData, gpu: gpuData, power: powerData,
thermal: thermalData)
} }
} }
@@ -127,6 +132,7 @@ final class SystemMonitor: ObservableObject {
let cpuData = cpuMonitor.fetchCPUInfo() let cpuData = cpuMonitor.fetchCPUInfo()
let memData = memoryMonitor.fetchMemoryInfo() let memData = memoryMonitor.fetchMemoryInfo()
let netData = networkMonitor.fetchNetworkInfo() let netData = networkMonitor.fetchNetworkInfo()
let thermalData = temperatureMonitor.fetchThermalInfo()
// Fetch processes: first 3 cycles always, then every other cycle // Fetch processes: first 3 cycles always, then every other cycle
var processData: [ProcessItem]? = nil var processData: [ProcessItem]? = nil
@@ -147,13 +153,14 @@ final class SystemMonitor: ObservableObject {
// Update UI on main thread // Update UI on main thread
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.updateUI(cpu: cpuData, memory: memData, network: netData, self?.updateUI(cpu: cpuData, memory: memData, network: netData,
processes: processData, disk: diskData, gpu: gpuData, power: powerData) processes: processData, disk: diskData, gpu: gpuData, power: powerData,
thermal: thermalData)
} }
} }
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?, @MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?, processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
power: PowerInfo?) { power: PowerInfo?, thermal: ThermalInfo?) {
if let info = cpu { if let info = cpu {
live.cpuInfo = info live.cpuInfo = info
live.cpuHistory.append(CPUHistoryPoint( live.cpuHistory.append(CPUHistoryPoint(
@@ -203,6 +210,7 @@ final class SystemMonitor: ObservableObject {
if let info = disk { live.diskInfo = info } if let info = disk { live.diskInfo = info }
if let info = gpu { live.gpuInfo = info } if let info = gpu { live.gpuInfo = info }
if let info = power { live.powerInfo = info } if let info = power { live.powerInfo = info }
if let info = thermal { live.thermalInfo = info }
if isUIVisible { publishLive() } if isUIVisible { publishLive() }
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo) MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
@@ -246,6 +254,7 @@ final class SystemMonitor: ObservableObject {
networkInfo = live.networkInfo networkInfo = live.networkInfo
gpuInfo = live.gpuInfo gpuInfo = live.gpuInfo
powerInfo = live.powerInfo powerInfo = live.powerInfo
thermalInfo = live.thermalInfo
cpuHistory = live.cpuHistory cpuHistory = live.cpuHistory
coreHistories = live.coreHistories coreHistories = live.coreHistories
memoryHistory = live.memoryHistory memoryHistory = live.memoryHistory

Fájl megtekintése

@@ -0,0 +1,71 @@
import Foundation
import IOKit
// IOHIDEventSystemClient is the interface macOS itself uses for its thermal
// sensors. It is not in the public SDK headers, but the symbols are exported by
// IOKit and need neither root nor an entitlement (the app is not sandboxed).
@_silgen_name("IOHIDEventSystemClientCreate")
private func IOHIDEventSystemClientCreate(_ allocator: CFAllocator?) -> Unmanaged<AnyObject>?
@_silgen_name("IOHIDEventSystemClientSetMatching")
private func IOHIDEventSystemClientSetMatching(_ client: AnyObject, _ matching: CFDictionary) -> Int32
@_silgen_name("IOHIDEventSystemClientCopyServices")
private func IOHIDEventSystemClientCopyServices(_ client: AnyObject) -> Unmanaged<CFArray>?
@_silgen_name("IOHIDServiceClientCopyProperty")
private func IOHIDServiceClientCopyProperty(_ service: AnyObject, _ key: CFString) -> Unmanaged<AnyObject>?
@_silgen_name("IOHIDServiceClientCopyEvent")
private func IOHIDServiceClientCopyEvent(_ service: AnyObject, _ type: Int64, _ options: Int32,
_ timestamp: Int64) -> Unmanaged<AnyObject>?
@_silgen_name("IOHIDEventGetFloatValue")
private func IOHIDEventGetFloatValue(_ event: AnyObject, _ field: Int32) -> Double
/// Reads the Apple Silicon temperature sensors. On an Intel Mac there are no
/// such sensors (those sit behind the SMC) and it reports nil.
final class TemperatureMonitor {
private static let temperatureEvent: Int64 = 15 // kIOHIDEventTypeTemperature
private static let temperatureField = Int32(temperatureEvent << 16) // IOHIDEventFieldBase(type)
private let client: AnyObject?
private var cachedSensors: [(name: String, service: AnyObject)]?
init() {
client = IOHIDEventSystemClientCreate(kCFAllocatorDefault)?.takeRetainedValue()
if let client {
// Usage page 0xff00 (Apple vendor), usage 5: temperature sensors.
_ = IOHIDEventSystemClientSetMatching(
client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary)
}
}
func readSensors() -> [TemperatureReading] {
sensors().compactMap { sensor in
guard let event = IOHIDServiceClientCopyEvent(sensor.service, Self.temperatureEvent, 0, 0)?
.takeRetainedValue() else { return nil }
return TemperatureReading(name: sensor.name,
celsius: IOHIDEventGetFloatValue(event, Self.temperatureField))
}
}
func fetchThermalInfo() -> ThermalInfo? {
ThermalMath.summarize(readSensors())
}
/// The sensor list doesn't change while the system runs, so it is built once.
private func sensors() -> [(name: String, service: AnyObject)] {
if let cachedSensors { return cachedSensors }
guard let client,
let services = IOHIDEventSystemClientCopyServices(client)?.takeRetainedValue() as? [AnyObject]
else { return [] }
let found = services.map { service in
let name = IOHIDServiceClientCopyProperty(service, "Product" as CFString)?
.takeRetainedValue() as? String
return (name: name ?? "", service: service)
}
cachedSensors = found
return found
}
}

Fájl megtekintése

@@ -37,6 +37,18 @@ struct PowerStorageView: View {
ThermalStateView(state: monitor.thermalState) ThermalStateView(state: monitor.thermalState)
} }
if let thermal = monitor.thermalInfo {
HStack {
Text("CPU Temperature:")
Spacer()
Text(String(format: "%.0f °C", thermal.cpuMax))
.monospacedDigit()
.foregroundColor(cpuTemperatureColor(thermal.cpuMax))
}
.help(String(format: "Hottest of %d CPU die sensors · average %.0f °C",
thermal.cpuSensorCount, thermal.cpuAverage))
}
if let cpu = monitor.cpuInfo { if let cpu = monitor.cpuInfo {
HStack { HStack {
Text("CPU Usage:") Text("CPU Usage:")
@@ -190,6 +202,15 @@ struct PowerStorageView: View {
.padding() .padding()
} }
} }
private func cpuTemperatureColor(_ celsius: Double) -> Color {
switch ThermalMath.level(cpuCelsius: celsius) {
case .nominal: return .primary
case .fair: return .yellow
case .serious: return .orange
case .critical: return .red
}
}
} }
struct BatteryStatusView: View { struct BatteryStatusView: View {

Fájl megtekintése

@@ -0,0 +1,71 @@
import XCTest
@testable import TopManager
final class ThermalMathTests: XCTestCase {
// Names as an M3 reports them (PMU / PMU2 dies, package, calibration, NAND, battery).
private let m3: [TemperatureReading] = [
.init(name: "PMU tdie1", celsius: 48.8),
.init(name: "PMU tdie2", celsius: 46.4),
.init(name: "PMU2 tdie1", celsius: 43.4),
.init(name: "PMU tdev2", celsius: 36.2),
.init(name: "PMU tdev1", celsius: -1.5),
.init(name: "PMU tcal", celsius: 51.9),
.init(name: "NAND CH0 temp", celsius: 34.0),
.init(name: "gas gauge battery", celsius: 31.3),
]
func testOnlyDieSensorsCount() {
let info = ThermalMath.summarize(m3)
XCTAssertEqual(info?.cpuSensorCount, 3)
// tcal (51.9) is hotter than every die but is a calibration value, not the CPU.
XCTAssertEqual(info?.cpuMax ?? 0, 48.8, accuracy: 0.001)
XCTAssertEqual(info?.cpuAverage ?? 0, (48.8 + 46.4 + 43.4) / 3, accuracy: 0.001)
}
func testM1ClusterSensorsCount() {
let m1: [TemperatureReading] = [
.init(name: "pACC MTR Temp Sensor0", celsius: 55),
.init(name: "eACC MTR Temp Sensor1", celsius: 41),
.init(name: "GPU MTR Temp Sensor1", celsius: 60),
.init(name: "SOC MTR Temp Sensor0", celsius: 50),
]
let info = ThermalMath.summarize(m1)
XCTAssertEqual(info?.cpuSensorCount, 2)
XCTAssertEqual(info?.cpuMax ?? 0, 55, accuracy: 0.001)
}
func testImplausibleDieReadingsAreDropped() {
let readings: [TemperatureReading] = [
.init(name: "PMU tdie1", celsius: 0),
.init(name: "PMU tdie2", celsius: -2),
.init(name: "PMU tdie3", celsius: 170),
.init(name: "PMU tdie4", celsius: 44),
]
XCTAssertEqual(ThermalMath.summarize(readings)?.cpuSensorCount, 1)
}
func testNoCPUSensorsMeansNil() {
// An Intel Mac: no IOHID die sensors at all, or only non-CPU ones.
XCTAssertNil(ThermalMath.summarize([]))
XCTAssertNil(ThermalMath.summarize([.init(name: "gas gauge battery", celsius: 30)]))
}
func testReadsRealSensorsOnAppleSilicon() throws {
#if !arch(arm64)
throw XCTSkip("Apple Silicon only: Intel Macs expose these sensors through the SMC")
#else
let info = try XCTUnwrap(TemperatureMonitor().fetchThermalInfo())
XCTAssertGreaterThan(info.cpuSensorCount, 0)
XCTAssertTrue((15...110).contains(info.cpuMax), "implausible CPU temperature \(info.cpuMax)")
XCTAssertLessThanOrEqual(info.cpuAverage, info.cpuMax)
#endif
}
func testLevels() {
XCTAssertEqual(ThermalMath.level(cpuCelsius: 48), .nominal)
XCTAssertEqual(ThermalMath.level(cpuCelsius: 85), .fair)
XCTAssertEqual(ThermalMath.level(cpuCelsius: 95), .serious)
XCTAssertEqual(ThermalMath.level(cpuCelsius: 104), .critical)
}
}