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 power = PowerReading() public var topMemoryProcesses: [ProcessMemory] = [] public var asOf = Date() } /// Wohin die Leistung geht. /// /// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus /// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der /// Gesamtleistung. public struct PowerReading: Equatable, Sendable { public var total: Double? public var cpu: Double? public var display: Double? public var adapter: Double? public var busVoltage: Double? } 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 swapTotal: UInt64 = 0 /// Von Programmen belegt (aktiv + inaktiv). public var app: UInt64 = 0 /// Vom Kernel festgehalten, nicht auslagerbar. public var wired: UInt64 = 0 public var free: 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? /// Energiesparmodus. Erklärt, warum die Maschine langsamer wirkt — und /// gehört deshalb sichtbar dorthin, wo man nach dem Grund sucht. public var isLowPower = false /// Temperatur der Zelle, falls der SMC sie hergibt. public var celsius: Double? } /// Ein Programm mit seinem Speicherverbrauch. public struct ProcessMemory: Equatable, Sendable, Identifiable { public let pid: pid_t public let name: String public let bytes: UInt64 public var id: pid_t { pid } } 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 name: String 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.topMemoryProcesses = readTopMemoryProcesses() snapshot.power = readPower() snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total 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 app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize let wired = UInt64(statistics.wire_count) * pageSize let freeBytes = UInt64(statistics.free_count) * pageSize let free = total > 0 ? 1 - Double(used) / Double(total) : 0 return MemoryReading(used: used, total: total, compressed: compressed, swapUsed: swap.xsu_used, swapTotal: swap.xsu_total, app: app, wired: wired, free: freeBytes, 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. reading.isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled // TB0T ist die Akkutemperatur; nicht jede Maschine führt sie. reading.celsius = smc?.float("TB0T").map { Double($0) } if let watts = smc?.float("PPBR") { reading.watts = reading.isCharging ? watts : -watts } return reading } /// Bei zwei Lüftern sind es links und rechts — so beschriftet Apple sie /// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung /// dann nicht bekannt ist. private static func fanName(index: Int, of count: Int) -> String { guard count == 2 else { return "Lüfter \(index + 1)" } return index == 0 ? "Lüfter links" : "Lüfter rechts" } // MARK: - Leistung private func readPower() -> PowerReading { guard let smc else { return PowerReading() } return PowerReading(total: smc.float("PSTR"), cpu: smc.float("PHPC"), display: smc.float("PDBR"), adapter: smc.float("PDTR"), busVoltage: smc.float("VP0R")) } // MARK: - Speicher je Programm /// Die größten Speicherverbraucher. /// /// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter /// „Speicher" zeigt — nicht der virtuelle Adressraum, der bei modernen /// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür /// bräuchte es root, und für einen Blick aufs eigene System reicht das hier. private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] { var pids = [pid_t](repeating: 0, count: 4096) let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout.size)) guard size > 0 else { return [] } let count = Int(size) / MemoryLayout.size var results: [ProcessMemory] = [] for pid in pids.prefix(count) where pid > 0 { var usage = rusage_info_v4() let result = withUnsafeMutablePointer(to: &usage) { $0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { proc_pid_rusage(pid, RUSAGE_INFO_V4, $0) } } guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue } var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) proc_name(pid, &nameBuffer, UInt32(nameBuffer.count)) let name = String(cString: nameBuffer) guard !name.isEmpty else { continue } results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint)) } return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit)) } // 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)] = [ ("TB0T", "Akku"), ("Ts0P", "Handballenauflage"), ("Ts1P", "Gehäuse hinten"), ("TW0P", "WLAN"), ("TH0x", "SSD"), ("TCMb", "Luftstrom"), ("TDeL", "Thunderbolt links"), ("TDeR", "Thunderbolt rechts"), ] 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 [] } let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil } return SensorReading(key: key, name: name, celsius: celsius) } // CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut. let temperatures = readTemperatures() var result: [SensorReading] = [] if let cpu = temperatures.cpu { result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu)) } if let gpu = temperatures.gpu { result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu)) } return result + named } private func readFans() -> [FanReading] { guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] } return (0..