import Foundation import IOKit import IOKit.ps import Darwin /// Ein vollständiger Messwertsatz. public struct MetricsSnapshot: Equatable, Sendable { public var cpu = CPUReading() public var gpu: Double? /// Wärmster Punkt der GPU in Grad Celsius. public var gpuTemperature: Double? public var memory = MemoryReading() public var battery: BatteryReading? public var sensors: [SensorReading] = [] public var fans: [FanReading] = [] public var asOf = Date() } public struct CPUReading: Equatable, Sendable { /// Gesamtauslastung, 0 bis 1. public var total: Double = 0 /// Auslastung je Kern, in der Reihenfolge des Systems. public var perCore: [Double] = [] /// Leistungsaufnahme in Watt, falls messbar. public var watts: Double? /// Der wärmste Kern in Grad Celsius. /// /// Apple Silicon hat pro Kern einen Fühler — auf dem M5 Pro sind es /// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird /// nach dem heißesten Punkt, nicht nach dem Durchschnitt. public var temperature: Double? } public struct MemoryReading: Equatable, Sendable { public var used: UInt64 = 0 public var total: UInt64 = 0 public var compressed: UInt64 = 0 public var swapUsed: UInt64 = 0 public var pressure: MemoryPressure = .normal public var usedFraction: Double { total > 0 ? Double(used) / Double(total) : 0 } } public struct BatteryReading: Equatable, Sendable { public var charge: Double = 0 // 0 bis 1 public var isCharging = false public var isPluggedIn = false public var cycleCount: Int? public var health: Double? // 0 bis 1 public var watts: Double? // negativ = Entladung public var timeRemaining: TimeInterval? } public struct SensorReading: Equatable, Sendable, Identifiable { public let key: String public let name: String public let celsius: Double public var id: String { key } } public struct FanReading: Equatable, Sendable, Identifiable { public let index: Int public let rpm: Double public let minimum: Double public let maximum: Double public var id: Int { index } /// Anteil zwischen Minimum und Maximum, 0 bis 1. public var fraction: Double { guard maximum > minimum else { return 0 } return min(max((rpm - minimum) / (maximum - minimum), 0), 1) } } /// Liest die Hardwarewerte des Systems. /// /// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil /// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell /// sie es brauchen. public final class SystemMetrics: @unchecked Sendable { private let smc = SMCReader() private var previousCPUTicks: [CPUTicks] = [] /// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die /// CPU-Kerne, `Tg*` die GPU-Cluster — die Konvention gilt für die ganze /// M-Serie. /// /// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und /// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine /// spürbare Startverzögerung für etwas, das nach dem ersten Messwert /// niemandem auffällt. private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], []) private let keysLock = NSLock() /// Zwischenspeicher für die Temperaturen. /// /// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb /// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen — deshalb /// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum. private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil) private var temperaturesReadAt = Date.distantPast private static let temperatureInterval: TimeInterval = 2 /// Seit dem Start unveränderlich — einmal ermitteln reicht. private static let pageSize: UInt64 = { var size: UInt64 = 0 var length = MemoryLayout.size guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else { return 16384 // Apple Silicon } return size }() public init() { // Nicht im Initialisierer blockieren: der läuft beim App-Start. // `DispatchQueue` statt `Task.detached`: die Suche ist blockierende // IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den // Nebenläufigkeitspool von Swift — und `NSLock` ist aus einem // asynchronen Kontext ohnehin nicht erlaubt. DispatchQueue.global(qos: .utility).async { [weak self] in guard let self, let smc else { return } let cpu = smc.keys(matching: ["Tp"]) let gpu = smc.keys(matching: ["Tg"]) keysLock.lock() temperatureKeys = (cpu, gpu) keysLock.unlock() } } public func sample() -> MetricsSnapshot { var snapshot = MetricsSnapshot() snapshot.cpu = readCPU() snapshot.gpu = readGPU() snapshot.memory = readMemory() snapshot.battery = readBattery() snapshot.sensors = readSensors() snapshot.fans = readFans() snapshot.cpu.watts = smc?.float("PSTR") let temperatures = readTemperatures() snapshot.cpu.temperature = temperatures.cpu snapshot.gpuTemperature = temperatures.gpu return snapshot } // MARK: - CPU private func readCPU() -> CPUReading { var count: natural_t = 0 var info: processor_info_array_t? var infoCount: mach_msg_type_number_t = 0 guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &count, &info, &infoCount) == KERN_SUCCESS, let info else { return CPUReading() } defer { vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info), vm_size_t(infoCount) * vm_size_t(MemoryLayout.stride)) } var current: [CPUTicks] = [] current.reserveCapacity(Int(count)) for core in 0.. Double? { var iterator: io_iterator_t = 0 guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOAccelerator"), &iterator) == kIOReturnSuccess else { return nil } defer { IOObjectRelease(iterator) } while case let entry = IOIteratorNext(iterator), entry != 0 { defer { IOObjectRelease(entry) } var unmanaged: Unmanaged? guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0) == kIOReturnSuccess, let properties = unmanaged?.takeRetainedValue() as? [String: Any], let statistics = properties["PerformanceStatistics"] as? [String: Any] else { continue } // Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple // Silicon ist es „Device Utilization %". for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] { if let value = statistics[name] as? Int { return Double(value) / 100 } } } return nil } // MARK: - Arbeitsspeicher private func readMemory() -> MemoryReading { var statistics = vm_statistics64_data_t() var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) let result = withUnsafeMutablePointer(to: &statistics) { $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) } } guard result == KERN_SUCCESS else { return MemoryReading() } // `vm_kernel_page_size` ist eine globale Variable und damit unter // strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start // fest, also einmal über sysctl holen. let pageSize = Self.pageSize let total = ProcessInfo.processInfo.physicalMemory let compressed = UInt64(statistics.compressor_page_count) * pageSize // „Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert. // Der Dateicache (`external_page_count`) zählt nicht mit — er wird bei // Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll // aussehen. let used = UInt64(statistics.active_count) * pageSize + UInt64(statistics.wire_count) * pageSize + compressed var swap = xsw_usage() var swapSize = MemoryLayout.size sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) let free = total > 0 ? 1 - Double(used) / Double(total) : 0 return MemoryReading(used: used, total: total, compressed: compressed, swapUsed: swap.xsu_used, pressure: MemoryPressure.classify( free: free, compressed: total > 0 ? Double(compressed) / Double(total) : 0, swapUsed: swap.xsu_used)) } // MARK: - Akku private func readBattery() -> BatteryReading? { guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef], let source = sources.first, let description = IOPSGetPowerSourceDescription(blob, source)? .takeUnretainedValue() as? [String: Any] else { return nil } var reading = BatteryReading() if let current = description[kIOPSCurrentCapacityKey] as? Int, let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 { reading.charge = Double(current) / Double(maximum) } reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String) == kIOPSACPowerValue if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 { reading.timeRemaining = TimeInterval(seconds * 60) } if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 { reading.timeRemaining = TimeInterval(seconds * 60) } // Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung, // sondern in der Batterie selbst. let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) if service != 0 { defer { IOObjectRelease(service) } var unmanaged: Unmanaged? if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0) == kIOReturnSuccess, let properties = unmanaged?.takeRetainedValue() as? [String: Any] { reading.cycleCount = properties["CycleCount"] as? Int if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 { reading.health = Double(maximum) / 100 } } } // Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen. if let watts = smc?.float("PPBR") { reading.watts = reading.isCharging ? watts : -watts } return reading } // MARK: - Sensoren und Lüfter /// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren. /// /// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind /// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt, /// fällt beim Lesen einfach weg. private static let temperatureKeys: [(String, String)] = [ ("Ts0P", "Gehäuse vorn"), ("Ts1P", "Gehäuse hinten"), ("TB0T", "Akku"), ("TW0P", "WLAN"), ("TH0x", "SSD"), ] private func readTemperatures() -> (cpu: Double?, gpu: Double?) { guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else { return cachedTemperatures } keysLock.lock() let keys = temperatureKeys keysLock.unlock() // Solange die Suche im Hintergrund läuft, bleiben die Werte leer — die // Oberfläche zeigt dann „—" statt einer erfundenen Zahl. guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) } cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu)) temperaturesReadAt = Date() return cachedTemperatures } /// Der höchste plausible Messwert einer Fühlergruppe. /// /// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich /// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die /// Anzeige übernehmen, weil hier das Maximum gebildet wird. private func hottest(of keys: [String]) -> Double? { guard let smc, !keys.isEmpty else { return nil } return keys.compactMap { key -> Double? in guard let value = smc.float(key), value > 5, value < 150 else { return nil } return value }.max() } private func readSensors() -> [SensorReading] { guard let smc else { return [] } return Self.temperatureKeys.compactMap { key, name in guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil } return SensorReading(key: key, name: name, celsius: celsius) } } private func readFans() -> [FanReading] { guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] } return (0..