diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 878d439..3b2e617 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -14350,6 +14350,10 @@ }, "Sensitivity" : { + }, + "Sensors" : { + "comment" : "A label displayed above the group of CPU sensor gauges.", + "isCommentAutoGenerated" : true }, "Session · ↓ %@ · ↑ %@" : { "comment" : "A row displaying the total downloaded and uploaded bytes for the network session.", diff --git a/DynamicIsland/components/Stats/CPUStatsDetailView.swift b/DynamicIsland/components/Stats/CPUStatsDetailView.swift index baeef4d..1475d3f 100644 --- a/DynamicIsland/components/Stats/CPUStatsDetailView.swift +++ b/DynamicIsland/components/Stats/CPUStatsDetailView.swift @@ -29,7 +29,9 @@ struct CPUStatsDetailView: View { coreCount: statsManager.cpuCoreUsage.count, systemColor: systemColor, userColor: userColor, - idleColor: idleColor + idleColor: idleColor, + temperature: statsManager.cpuTemperature, + frequency: statsManager.cpuFrequency ) } diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift index 7e010a6..0b8a779 100644 --- a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift +++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift @@ -60,6 +60,8 @@ struct CPUUsageDashboard: View { let systemColor: Color let userColor: Color let idleColor: Color + let temperature: CPUTemperatureMetrics + let frequency: CPUFrequencyMetrics? var body: some View { let usageSection = VStack(alignment: .leading, spacing: 12) { @@ -85,6 +87,14 @@ struct CPUUsageDashboard: View { DetailRow(color: nil, label: "Load 15m", value: String(format: "%.2f", loadAverage.fifteenMinutes)) } .frame(maxWidth: .infinity, alignment: .leading) + + let sensorsSection = CPUSensorGaugeGroup( + temperature: temperature, + frequency: frequency, + usageTint: userColor, + temperatureTint: Color(red: 0.98, green: 0.53, blue: 0.18) + ) + .frame(maxWidth: .infinity, alignment: .leading) return ViewThatFits(in: .horizontal) { VStack(alignment: .leading, spacing: 20) { @@ -97,19 +107,23 @@ struct CPUUsageDashboard: View { .frame(width: 132, height: 132) .frame(maxWidth: .infinity, alignment: .leading) + sensorsSection usageSection Divider().padding(.vertical, 4) systemSection } HStack(alignment: .top, spacing: 28) { - CPUSegmentDonut( - breakdown: breakdown, - systemColor: systemColor, - userColor: userColor, - idleColor: idleColor - ) - .frame(width: 132, height: 132) - + VStack(alignment: .leading, spacing: 18) { + CPUSegmentDonut( + breakdown: breakdown, + systemColor: systemColor, + userColor: userColor, + idleColor: idleColor + ) + .frame(width: 132, height: 132) + + sensorsSection + } usageSection systemSection } @@ -117,6 +131,95 @@ struct CPUUsageDashboard: View { } } +private struct CPUSensorGaugeGroup: View { + let temperature: CPUTemperatureMetrics + let frequency: CPUFrequencyMetrics? + let usageTint: Color + let temperatureTint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Sensors") + .font(.caption) + .foregroundColor(.secondary) + HStack(spacing: 18) { + CPUTemperatureGauge(temperature: temperature.celsius, tint: temperatureTint) + CPUFrequencyGauge(frequency: frequency, tint: usageTint) + } + } + } +} + +private struct CPUTemperatureGauge: View { + let temperature: Double? + let tint: Color + private let maxTemperature: Double = 110 + + var body: some View { + CircularGaugeView( + title: "Temperature", + value: normalizedValue, + tint: tint, + centerPrimaryText: centerPrimary, + subtitle: nil, + size: 84, + lineWidth: 9 + ) + } + + private var normalizedValue: Double { + guard let temperature, maxTemperature > 0 else { return 0 } + return min(max(temperature / maxTemperature, 0), 1) + } + + private var centerPrimary: String { + guard let temperature else { return "—" } + return String(format: "%.0f°", temperature) + } +} + +private struct CPUFrequencyGauge: View { + let frequency: CPUFrequencyMetrics? + let tint: Color + + var body: some View { + CircularGaugeView( + title: "Frequency", + value: normalizedValue, + tint: tint, + centerPrimaryText: centerPrimary, + centerSecondaryText: frequency != nil ? "GHz" : nil, + subtitle: subtitle, + size: 84, + lineWidth: 9 + ) + } + + private var normalizedValue: Double { + guard let frequency else { return 0 } + let candidateMax = frequency.maxOverallGHz > 0 ? frequency.maxOverallGHz : frequency.overallGHz + let denominator = max(candidateMax, 1.0) + return min(max(frequency.overallGHz / denominator, 0), 1) + } + + private var centerPrimary: String { + guard let frequency else { return "—" } + return String(format: "%.1f", frequency.overallGHz) + } + + private var subtitle: String? { + guard let frequency else { return nil } + var components: [String] = [] + if let eValue = frequency.eCoreGHz { + components.append("E \(String(format: "%.1f", eValue))") + } + if let pValue = frequency.pCoreGHz { + components.append("P \(String(format: "%.1f", pValue))") + } + return components.isEmpty ? nil : components.joined(separator: " • ") + } +} + private struct CPUSegmentDonut: View { let breakdown: CPULoadBreakdown let systemColor: Color diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 10f540d..32b971d 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -379,6 +379,8 @@ class StatsManager: ObservableObject { @Published private(set) var cpuCoreUsage: [CPUCoreUsage] = [] @Published private(set) var cpuUptime: TimeInterval = 0 @Published private(set) var topCPUProcesses: [ProcessStats] = [] + @Published private(set) var cpuTemperature: CPUTemperatureMetrics = CPUTemperatureMetrics(celsius: nil) + @Published private(set) var cpuFrequency: CPUFrequencyMetrics? @Published private(set) var memoryBreakdown: MemoryBreakdown = .zero @Published private(set) var gpuBreakdown: GPUBreakdown = .zero @Published private(set) var gpuDevices: [GPUDeviceMetrics] = [] @@ -439,6 +441,7 @@ class StatsManager: ObservableObject { private let maxProcessEntries: Int = 20 private var isProcessRefreshInFlight = false private let gpuCollector = GPUInfoCollector() + private let cpuSensorCollector = CPUSensorCollector() // MARK: - Initialization private init() { @@ -562,6 +565,8 @@ class StatsManager: ObservableObject { diskDevices = [] previousInterfaceCounters.removeAll() interfaceTotals.removeAll() + cpuTemperature = CPUTemperatureMetrics(celsius: nil) + cpuFrequency = nil } // MARK: - Private Methods @@ -656,6 +661,10 @@ class StatsManager: ObservableObject { cpuCoreUsage = coreUsage } cpuUptime = ProcessInfo.processInfo.systemUptime + cpuTemperature = cpuSensorCollector.readTemperature() + if let frequencyMetrics = cpuSensorCollector.readFrequency() { + cpuFrequency = frequencyMetrics + } // Update history arrays (sliding window) updateHistory(value: newCpuUsage, history: &cpuHistory) diff --git a/DynamicIsland/utils/AppleHardwareInfo.swift b/DynamicIsland/utils/AppleHardwareInfo.swift new file mode 100644 index 0000000..c098a6b --- /dev/null +++ b/DynamicIsland/utils/AppleHardwareInfo.swift @@ -0,0 +1,162 @@ +// +// AppleHardwareInfo.swift +// DynamicIsland +// +// Minimal system introspection helpers derived from the Stats project. +// + +import Foundation +import IOKit +import Darwin + +enum ApplePlatform: String { + case intel + case m1 + case m1Pro + case m1Max + case m1Ultra + case m2 + case m2Pro + case m2Max + case m2Ultra + case m3 + case m3Pro + case m3Max + case m3Ultra + case m4 + case m4Pro + case m4Max + case m4Ultra +} + +struct CPUClusterFrequencies { + let eCoreFrequencies: [Int32] + let pCoreFrequencies: [Int32] +} + +struct CPUClusterCounts { + let eCores: Int + let pCores: Int +} + +final class AppleHardwareInfo { + static let shared = AppleHardwareInfo() + let cpuName: String? + let platform: ApplePlatform? + let logicalCoreCount: Int + let clusterCounts: CPUClusterCounts + let clusterFrequencies: CPUClusterFrequencies + + private init() { + let name = AppleHardwareInfo.fetchCPUName() + cpuName = name + platform = AppleHardwareInfo.detectPlatform(from: name) + logicalCoreCount = AppleHardwareInfo.fetchLogicalCoreCount() + clusterCounts = AppleHardwareInfo.fetchClusterCounts() + clusterFrequencies = AppleHardwareInfo.fetchClusterFrequencies(cpuName: name ?? "") + } + + private static func fetchCPUName() -> String? { + var size = 0 + sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) + guard size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + let result = sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) + guard result == 0 else { return nil } + return String(cString: buffer) + } + + private static func fetchLogicalCoreCount() -> Int { + var cores: Int32 = 0 + var size = MemoryLayout.size + let result = sysctlbyname("hw.logicalcpu", &cores, &size, nil, 0) + return result == 0 ? Int(cores) : ProcessInfo.processInfo.processorCount + } + + private static func detectPlatform(from cpuName: String?) -> ApplePlatform? { + guard let name = cpuName?.lowercased() else { return nil } + if name.contains("intel") { return .intel } + if name.contains("m1") { + if name.contains("ultra") { return .m1Ultra } + if name.contains("max") { return .m1Max } + if name.contains("pro") { return .m1Pro } + return .m1 + } + if name.contains("m2") { + if name.contains("ultra") { return .m2Ultra } + if name.contains("max") { return .m2Max } + if name.contains("pro") { return .m2Pro } + return .m2 + } + if name.contains("m3") { + if name.contains("ultra") { return .m3Ultra } + if name.contains("max") { return .m3Max } + if name.contains("pro") { return .m3Pro } + return .m3 + } + if name.contains("m4") { + if name.contains("ultra") { return .m4Ultra } + if name.contains("max") { return .m4Max } + if name.contains("pro") { return .m4Pro } + return .m4 + } + return nil + } + + private static func fetchClusterCounts() -> CPUClusterCounts { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMPE"), &iterator) == KERN_SUCCESS else { + return CPUClusterCounts(eCores: 0, pCores: 0) + } + defer { IOObjectRelease(iterator) } + var eCoreCount: Int32 = 0 + var pCoreCount: Int32 = 0 + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + var children = io_iterator_t() + guard IORegistryEntryGetChildIterator(service, kIOServicePlane, &children) == KERN_SUCCESS else { continue } + defer { IOObjectRelease(children) } + + while case let child = IOIteratorNext(children), child != 0 { + defer { IOObjectRelease(child) } + guard let name = di_getIOName(child), name == "cpus", let props = di_getIOProperties(child) else { continue } + if let data = props.object(forKey: "e-core-count") as? Data { + eCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) } + } + if let data = props.object(forKey: "p-core-count") as? Data { + pCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) } + } + } + } + + return CPUClusterCounts(eCores: Int(eCoreCount), pCores: Int(pCoreCount)) + } + + private static func fetchClusterFrequencies(cpuName: String) -> CPUClusterFrequencies { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMIODevice"), &iterator) == KERN_SUCCESS else { + return CPUClusterFrequencies(eCoreFrequencies: [], pCoreFrequencies: []) + } + defer { IOObjectRelease(iterator) } + + let lowerName = cpuName.lowercased() + let isM4 = lowerName.contains("m4") + var eFreq: [Int32] = [] + var pFreq: [Int32] = [] + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { IOObjectRelease(service) } + guard let name = di_getIOName(service), name == "pmgr", let props = di_getIOProperties(service) else { continue } + + if let data = props["voltage-states1-sram"] as? Data { + eFreq = di_convertCFDataToFrequencies(data, isM4: isM4) + } + if let data = props["voltage-states5-sram"] as? Data { + pFreq = di_convertCFDataToFrequencies(data, isM4: isM4) + } + } + + return CPUClusterFrequencies(eCoreFrequencies: eFreq, pCoreFrequencies: pFreq) + } +} diff --git a/DynamicIsland/utils/CPUSensorCollector.swift b/DynamicIsland/utils/CPUSensorCollector.swift new file mode 100644 index 0000000..427e8fb --- /dev/null +++ b/DynamicIsland/utils/CPUSensorCollector.swift @@ -0,0 +1,237 @@ +// +// CPUSensorCollector.swift +// DynamicIsland +// +// Ports the Stats project's CPU temperature and frequency readers. +// + +import Foundation +import IOKit + +struct CPUFrequencyMetrics: Equatable { + let overallGHz: Double + let eCoreGHz: Double? + let pCoreGHz: Double? + let maxOverallGHz: Double + let maxECoreGHz: Double? + let maxPCoreGHz: Double? +} + +struct CPUTemperatureMetrics: Equatable { + let celsius: Double? +} + +final class CPUSensorCollector { + private let hardware = AppleHardwareInfo.shared + private var channels: CFMutableDictionary? + private var subscription: IOReportSubscriptionRef? + private var previousSample: (samples: CFDictionary, time: TimeInterval)? + + init() { + setupFrequencyChannel() + } + + deinit { + if let subscription { + IOReportCreateSamples(subscription, nil, nil) // best-effort flush + } + } + + func readTemperature() -> CPUTemperatureMetrics { + let platform = hardware.platform + if let value = primaryTemperatureKeyCandidates.compactMap({ SMC.shared.getValue($0) }).first(where: { $0 < 110 }) { + return CPUTemperatureMetrics(celsius: value) + } + let list = temperatureFallbackKeys(for: platform) + var total: Double = 0 + var count: Double = 0 + for key in list { + if let value = SMC.shared.getValue(key), value < 110 { + total += value + count += 1 + } + } + guard total > 0, count > 0 else { + return CPUTemperatureMetrics(celsius: nil) + } + return CPUTemperatureMetrics(celsius: total / count) + } + + func readFrequency() -> CPUFrequencyMetrics? { + guard let channels, let subscription else { return nil } + let timestamp = Date().timeIntervalSince1970 + guard let currentSample = IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue() else { + return nil + } + defer { + previousSample = (currentSample, timestamp) + } + guard let previous = previousSample, + let diff = IOReportCreateSamplesDelta(previous.samples, currentSample, nil)?.takeRetainedValue() else { + return nil + } + + let samples = collectIOSamples(from: diff) + let eFrequencies = hardware.clusterFrequencies.eCoreFrequencies + let pFrequencies = hardware.clusterFrequencies.pCoreFrequencies + let eCount = max(hardware.clusterCounts.eCores, 0) + let pCount = max(hardware.clusterCounts.pCores, 0) + + var eValues: [Double] = [] + var pValues: [Double] = [] + + for sample in samples where sample.group == "CPU Stats" { + if sample.channel.hasPrefix("ECPU") { + let value = calculateFrequency(from: sample.delta, freqs: eFrequencies) + if value > 0 { eValues.append(value) } + } else if sample.channel.hasPrefix("PCPU") { + let value = calculateFrequency(from: sample.delta, freqs: pFrequencies) + if value > 0 { pValues.append(value) } + } + } + + let eAverage = eValues.isEmpty ? nil : (eValues.reduce(0, +) / Double(eValues.count)) + let pAverage = pValues.isEmpty ? nil : (pValues.reduce(0, +) / Double(pValues.count)) + + guard eAverage != nil || pAverage != nil else { + return nil + } + + let overall: Double + if let eAverage, let pAverage, (eCount + pCount) > 0 { + overall = ((eAverage * Double(eCount)) + (pAverage * Double(pCount))) / Double(eCount + pCount) + } else if let eAverage { + overall = eAverage + } else if let pAverage { + overall = pAverage + } else { + return nil + } + + let maxE = eFrequencies.max().map { Double($0) / 1000 } + let maxP = pFrequencies.max().map { Double($0) / 1000 } + let maxOverall: Double + if let maxE, let maxP, (eCount + pCount) > 0 { + maxOverall = ((maxE * Double(eCount)) + (maxP * Double(pCount))) / Double(max(eCount + pCount, 1)) + } else if let maxE { + maxOverall = maxE + } else if let maxP { + maxOverall = maxP + } else { + maxOverall = (overall / 1000) + } + + return CPUFrequencyMetrics( + overallGHz: overall / 1000, + eCoreGHz: eAverage.map { $0 / 1000 }, + pCoreGHz: pAverage.map { $0 / 1000 }, + maxOverallGHz: maxOverall, + maxECoreGHz: maxE, + maxPCoreGHz: maxP + ) + } + + // MARK: - Private Helpers + private func setupFrequencyChannel() { + let names = [ + ("CPU Stats" as CFString, "CPU Complex Performance States" as CFString), + ("CPU Stats" as CFString, "CPU Core Performance States" as CFString) + ] + var mergedChannels: CFMutableDictionary? + var list: [CFDictionary] = [] + for (group, subgroup) in names { + if let channel = IOReportCopyChannelsInGroup(group, subgroup, 0, 0, 0)?.takeRetainedValue() { + list.append(channel) + } + } + guard let first = list.first else { return } + for channel in list.dropFirst() { + IOReportMergeChannels(first, channel, nil) + } + guard let copy = CFDictionaryCreateMutableCopy(kCFAllocatorDefault, CFDictionaryGetCount(first), first) else { + return + } + mergedChannels = copy + channels = mergedChannels + var dictionary: Unmanaged? + subscription = IOReportCreateSubscription(nil, mergedChannels, &dictionary, 0, nil) + dictionary?.release() + } + + private struct IOSample { + let group: String + let subGroup: String + let channel: String + let unit: String + let delta: CFDictionary + } + + private func collectIOSamples(from data: CFDictionary) -> [IOSample] { + let key = "IOReportChannels" as CFString + var rawValue: UnsafeRawPointer? + let found = withUnsafeMutablePointer(to: &rawValue) { pointer -> Bool in + CFDictionaryGetValueIfPresent(data, Unmanaged.passUnretained(key).toOpaque(), pointer) + } + guard found, let rawValue else { return [] } + let array = unsafeBitCast(rawValue, to: CFArray.self) + var result: [IOSample] = [] + for index in 0.. [(label: String, value: Int64)] { + let count = IOReportStateGetCount(dict) + var result: [(String, Int64)] = [] + for index in 0.. Double { + guard !freqs.isEmpty else { return 0 } + let states = getResidencies(dict: dict) + guard let offset = states.firstIndex(where: { !$0.label.elementsEqual("IDLE") && !$0.label.elementsEqual("DOWN") && !$0.label.elementsEqual("OFF") }) else { + return 0 + } + let usage = states.dropFirst(offset).reduce(0.0) { $0 + Double($1.value) } + guard usage > 0 else { return 0 } + var total: Double = 0 + for (index, frequency) in freqs.enumerated() { + let stateIndex = offset + index + guard states.indices.contains(stateIndex) else { continue } + let fraction = Double(states[stateIndex].value) / usage + total += fraction * Double(frequency) + } + return total + } + + private var primaryTemperatureKeyCandidates: [String] { + ["TC0D", "TC0E", "TC0F", "TC0P", "TC0H"] + } + + private func temperatureFallbackKeys(for platform: ApplePlatform?) -> [String] { + switch platform { + case .m1, .m1Pro, .m1Max, .m1Ultra: + return ["Tp09", "Tp0T", "Tp01", "Tp05", "Tp0D", "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b"] + case .m2, .m2Pro, .m2Max, .m2Ultra: + return ["Tp1h", "Tp1t", "Tp1p", "Tp1l", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0X", "Tp0b", "Tp0f", "Tp0j"] + case .m3, .m3Pro, .m3Max, .m3Ultra: + return ["Te05", "Te0L", "Te0P", "Te0S", "Tf04", "Tf09", "Tf0A", "Tf0B", "Tf0D", "Tf0E", "Tf44", "Tf49", "Tf4A", "Tf4B", "Tf4D", "Tf4E"] + case .m4, .m4Pro, .m4Max, .m4Ultra: + return ["Te05", "Te09", "Te0H", "Te0S", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0V", "Tp0Y", "Tp0b", "Tp0e"] + default: + return ["TC0P", "TC0E", "TC0F", "TC0H", "TC0D"] + } + } +} diff --git a/DynamicIsland/utils/IOKitHelpers.swift b/DynamicIsland/utils/IOKitHelpers.swift new file mode 100644 index 0000000..44694fe --- /dev/null +++ b/DynamicIsland/utils/IOKitHelpers.swift @@ -0,0 +1,52 @@ +// +// IOKitHelpers.swift +// DynamicIsland +// +// Helper utilities borrowed from the Stats project for working with IORegistry and CFData blobs. +// + +import Foundation +import IOKit +import Darwin + +func di_getIOProperties(_ entry: io_registry_entry_t) -> NSDictionary? { + var properties: Unmanaged? = nil + guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == kIOReturnSuccess else { + return nil + } + defer { properties?.release() } + return properties?.takeUnretainedValue() +} + +func di_getIOName(_ entry: io_registry_entry_t) -> String? { + let pointer = UnsafeMutablePointer.allocate(capacity: 1) + defer { pointer.deallocate() } + let result = IORegistryEntryGetName(entry, pointer) + guard result == kIOReturnSuccess else { + if let message = String(validatingUTF8: mach_error_string(result)) { + print("IORegistryEntryGetName error: \(message)") + } + return nil + } + return String(cString: UnsafeRawPointer(pointer).assumingMemoryBound(to: CChar.self)) +} + +func di_convertCFDataToFrequencies(_ data: Data, isM4: Bool = false) -> [Int32] { + guard !data.isEmpty else { return [] } + let bytes = [UInt8](data) + + let multiplier: UInt32 = isM4 ? 1_000 : 1_000_000 + var frequencies: [Int32] = [] + + for chunkStart in stride(from: 0, to: bytes.count, by: 8) { + let upperBound = min(chunkStart + 8, bytes.count) + let chunk = bytes[chunkStart..(_ name: String, as type: T.Type) -> T? { + guard let handle = libraryHandle, let pointer = dlsym(handle, name) else { + return nil + } + return unsafeBitCast(pointer, to: T.self) + } +} + +func IOReportCopyChannelsInGroup(_ group: CFString?, _ subGroup: CFString?, _ a: UInt64, _ b: UInt64, _ c: UInt64) -> Unmanaged? { + typealias Fn = @convention(c) (CFString?, CFString?, UInt64, UInt64, UInt64) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCopyChannelsInGroup", as: Fn.self) else { return nil } + return fn(group, subGroup, a, b, c) +} + +func IOReportMergeChannels(_ a: CFDictionary, _ b: CFDictionary, _ c: CFTypeRef?) { + typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Void + guard let fn: Fn = IOReportLoader.symbol("IOReportMergeChannels", as: Fn.self) else { return } + fn(a, b, c) +} + +func IOReportCreateSubscription(_ a: UnsafeMutableRawPointer?, _ b: CFMutableDictionary?, _ c: UnsafeMutablePointer?>?, _ d: UInt64, _ e: CFTypeRef?) -> IOReportSubscriptionRef? { + typealias Fn = @convention(c) (UnsafeMutableRawPointer?, CFMutableDictionary?, UnsafeMutablePointer?>?, UInt64, CFTypeRef?) -> IOReportSubscriptionRef? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSubscription", as: Fn.self) else { return nil } + return fn(a, b, c, d, e) +} + +func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef?, _ channels: CFMutableDictionary?, _ client: CFTypeRef?) -> Unmanaged? { + typealias Fn = @convention(c) (IOReportSubscriptionRef?, CFMutableDictionary?, CFTypeRef?) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamples", as: Fn.self) else { return nil } + return fn(subscription, channels, client) +} + +func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ zone: CFTypeRef?) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamplesDelta", as: Fn.self) else { return nil } + return fn(previous, current, zone) +} + +func IOReportChannelGetGroup(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetGroup", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetSubGroup(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetSubGroup", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetChannelName(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetChannelName", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportChannelGetUnitLabel(_ dictionary: CFDictionary) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetUnitLabel", as: Fn.self) else { return nil } + return fn(dictionary) +} + +func IOReportStateGetCount(_ dictionary: CFDictionary) -> Int32 { + typealias Fn = @convention(c) (CFDictionary) -> Int32 + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetCount", as: Fn.self) else { return 0 } + return fn(dictionary) +} + +func IOReportStateGetNameForIndex(_ dictionary: CFDictionary, _ index: Int32) -> Unmanaged? { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Unmanaged? + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetNameForIndex", as: Fn.self) else { return nil } + return fn(dictionary, index) +} + +func IOReportStateGetResidency(_ dictionary: CFDictionary, _ index: Int32) -> Int64 { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64 + guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetResidency", as: Fn.self) else { return 0 } + return fn(dictionary, index) +} + +func IOReportSimpleGetIntegerValue(_ dictionary: CFDictionary, _ index: Int32) -> Int64 { + typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64 + guard let fn: Fn = IOReportLoader.symbol("IOReportSimpleGetIntegerValue", as: Fn.self) else { return 0 } + return fn(dictionary, index) +} diff --git a/DynamicIsland/utils/SMC.swift b/DynamicIsland/utils/SMC.swift new file mode 100644 index 0000000..dc42e72 --- /dev/null +++ b/DynamicIsland/utils/SMC.swift @@ -0,0 +1,532 @@ +// +// smc.swift +// SMC +// +// Created by Serhiy Mytrovtsiy on 25/05/2021. +// Using Swift 5.0. +// Running on macOS 10.15. +// +// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved. +// + +import Foundation +import IOKit + +internal enum SMCDataType: String { + case UI8 = "ui8 " + case UI16 = "ui16" + case UI32 = "ui32" + case SP1E = "sp1e" + case SP3C = "sp3c" + case SP4B = "sp5b" + case SP5A = "sp5a" + case SPA5 = "spa5" + case SP69 = "sp669" + case SP78 = "sp78" + case SP87 = "sp87" + case SP96 = "sp96" + case SPB4 = "spb4" + case SPF0 = "spf0" + case FLT = "flt " + case FPE2 = "fpe2" + case FP2E = "fp2e" + case FDS = "{fds" +} + +internal enum SMCKeys: UInt8 { + case kernelIndex = 2 + case readBytes = 5 + case writeBytes = 6 + case readIndex = 8 + case readKeyInfo = 9 + case readPLimit = 11 + case readVers = 12 +} + +public enum FanMode: Int, Codable { + case automatic = 0 + case forced = 1 +} + +internal struct SMCKeyData_t { + typealias SMCBytes_t = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8) + + struct vers_t { + var major: CUnsignedChar = 0 + var minor: CUnsignedChar = 0 + var build: CUnsignedChar = 0 + var reserved: CUnsignedChar = 0 + var release: CUnsignedShort = 0 + } + + struct LimitData_t { + var version: UInt16 = 0 + var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0 + var gpuPLimit: UInt32 = 0 + var memPLimit: UInt32 = 0 + } + + struct keyInfo_t { + var dataSize: IOByteCount32 = 0 + var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 + } + + var key: UInt32 = 0 + var vers = vers_t() + var pLimitData = LimitData_t() + var keyInfo = keyInfo_t() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: SMCBytes_t = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)) +} + +internal struct SMCVal_t { + var key: String + var dataSize: UInt32 = 0 + var dataType: String = "" + var bytes: [UInt8] = Array(repeating: 0, count: 32) + + init(_ key: String) { + self.key = key + } +} + +extension FourCharCode { + init(fromString str: String) { + precondition(str.count == 4) + + self = str.utf8.reduce(0) { sum, character in + return sum << 8 | UInt32(character) + } + } + + func toString() -> String { + return String(describing: UnicodeScalar(self >> 24 & 0xff)!) + + String(describing: UnicodeScalar(self >> 16 & 0xff)!) + + String(describing: UnicodeScalar(self >> 8 & 0xff)!) + + String(describing: UnicodeScalar(self & 0xff)!) + } +} + +extension UInt16 { + init(bytes: (UInt8, UInt8)) { + self = UInt16(bytes.0) << 8 | UInt16(bytes.1) + } +} + +extension UInt32 { + init(bytes: (UInt8, UInt8, UInt8, UInt8)) { + self = UInt32(bytes.0) << 24 | UInt32(bytes.1) << 16 | UInt32(bytes.2) << 8 | UInt32(bytes.3) + } +} + +extension Int { + init(fromFPE2 bytes: (UInt8, UInt8)) { + self = (Int(bytes.0) << 6) + (Int(bytes.1) >> 2) + } +} + +extension Float { + init?(_ bytes: [UInt8]) { + self = bytes.withUnsafeBytes { + return $0.load(fromByteOffset: 0, as: Self.self) + } + } + + var bytes: [UInt8] { + withUnsafeBytes(of: self, Array.init) + } +} + +public class SMC { + public static let shared = SMC() + private var conn: io_connect_t = 0 + + public init() { + var result: kern_return_t + var iterator: io_iterator_t = 0 + let device: io_object_t + + let matchingDictionary: CFMutableDictionary = IOServiceMatching("AppleSMC") + result = IOServiceGetMatchingServices(kIOMasterPortDefault, matchingDictionary, &iterator) + if result != kIOReturnSuccess { + print("Error IOServiceGetMatchingServices(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + device = IOIteratorNext(iterator) + IOObjectRelease(iterator) + if device == 0 { + print("Error IOIteratorNext(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + result = IOServiceOpen(device, mach_task_self_, 0, &conn) + IOObjectRelease(device) + if result != kIOReturnSuccess { + print("Error IOServiceOpen(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + deinit { + let result = self.close() + if result != kIOReturnSuccess { + print("error close smc connection: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + } + } + + public func close() -> kern_return_t { + return IOServiceClose(conn) + } + + public func getValue(_ key: String) -> Double? { + var result: kern_return_t = 0 + var val: SMCVal_t = SMCVal_t(key) + + result = read(&val) + if result != kIOReturnSuccess { + print("Error read(\(key)): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return nil + } + + if val.dataSize > 0 { + if val.bytes.first(where: { $0 != 0 }) == nil && val.key != "FS! " && val.key != "F0Md" && val.key != "F1Md" { + return nil + } + + switch val.dataType { + case SMCDataType.UI8.rawValue: + return Double(val.bytes[0]) + case SMCDataType.UI16.rawValue: + return Double(UInt16(bytes: (val.bytes[0], val.bytes[1]))) + case SMCDataType.UI32.rawValue: + return Double(UInt32(bytes: (val.bytes[0], val.bytes[1], val.bytes[2], val.bytes[3]))) + case SMCDataType.SP1E.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 16384) + case SMCDataType.SP3C.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 4096) + case SMCDataType.SP4B.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 2048) + case SMCDataType.SP5A.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 1024) + case SMCDataType.SP69.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 512) + case SMCDataType.SP78.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 256) + case SMCDataType.SP87.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 128) + case SMCDataType.SP96.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 64) + case SMCDataType.SPA5.rawValue: + let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1])) + return Double(result / 32) + case SMCDataType.SPB4.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return Double(intValue / 16) + case SMCDataType.SPF0.rawValue: + let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1])) + return intValue + case SMCDataType.FLT.rawValue: + let value: Float? = Float(val.bytes) + if value != nil { + return Double(value!) + } + return nil + case SMCDataType.FPE2.rawValue: + return Double(Int(fromFPE2: (val.bytes[0], val.bytes[1]))) + default: + return nil + } + } + + return nil + } + + public func getStringValue(_ key: String) -> String? { + var result: kern_return_t = 0 + var val: SMCVal_t = SMCVal_t(key) + + result = read(&val) + if result != kIOReturnSuccess { + print("Error read(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return nil + } + + if val.dataSize > 0 { + if val.bytes.first(where: { $0 != 0}) == nil { + return nil + } + + switch val.dataType { + case SMCDataType.FDS.rawValue: + let c1 = String(UnicodeScalar(val.bytes[4])) + let c2 = String(UnicodeScalar(val.bytes[5])) + let c3 = String(UnicodeScalar(val.bytes[6])) + let c4 = String(UnicodeScalar(val.bytes[7])) + let c5 = String(UnicodeScalar(val.bytes[8])) + let c6 = String(UnicodeScalar(val.bytes[9])) + let c7 = String(UnicodeScalar(val.bytes[10])) + let c8 = String(UnicodeScalar(val.bytes[11])) + let c9 = String(UnicodeScalar(val.bytes[12])) + let c10 = String(UnicodeScalar(val.bytes[13])) + let c11 = String(UnicodeScalar(val.bytes[14])) + let c12 = String(UnicodeScalar(val.bytes[15])) + + return (c1 + c2 + c3 + c4 + c5 + c6 + c7 + c8 + c9 + c10 + c11 + c12).trimmingCharacters(in: .whitespaces) + default: + print("unsupported data type \(val.dataType) for key: \(key)") + return nil + } + } + + return nil + } + + public func getAllKeys() -> [String] { + var list: [String] = [] + + let keysNum: Double? = self.getValue("#KEY") + if keysNum == nil { + print("ERROR no keys count found") + return list + } + + var result: kern_return_t = 0 + var input: SMCKeyData_t = SMCKeyData_t() + var output: SMCKeyData_t = SMCKeyData_t() + + for i in 0...Int(keysNum!) { + input = SMCKeyData_t() + output = SMCKeyData_t() + + input.data8 = SMCKeys.readIndex.rawValue + input.data32 = UInt32(i) + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + continue + } + + list.append(output.key.toString()) + } + + return list + } + + public func write(_ key: String, _ newValue: Int) -> kern_return_t { + var value = SMCVal_t(key) + value.dataSize = 2 + value.bytes = [UInt8(newValue >> 6), UInt8((newValue << 2) ^ ((newValue >> 6) << 8)), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + return write(value) + } + + // MARK: - fans + + public func setFanMode(_ id: Int, mode: FanMode) { + if self.getValue("F\(id)Md") != nil { + var result: kern_return_t = 0 + var value = SMCVal_t("F\(id)Md") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + value.bytes = [UInt8(mode.rawValue), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + let fansMode = Int(self.getValue("FS! ") ?? 0) + var newMode: UInt8 = 0 + + if fansMode == 0 && id == 0 && mode == .forced { + newMode = 1 + } else if fansMode == 0 && id == 1 && mode == .forced { + newMode = 2 + } else if fansMode == 1 && id == 0 && mode == .automatic { + newMode = 0 + } else if fansMode == 1 && id == 1 && mode == .forced { + newMode = 3 + } else if fansMode == 2 && id == 1 && mode == .automatic { + newMode = 0 + } else if fansMode == 2 && id == 0 && mode == .forced { + newMode = 3 + } else if fansMode == 3 && id == 0 && mode == .automatic { + newMode = 2 + } else if fansMode == 3 && id == 1 && mode == .automatic { + newMode = 1 + } + + if fansMode == newMode { + return + } + + var result: kern_return_t = 0 + var value = SMCVal_t("FS! ") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + value.bytes = [0, newMode, UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), + UInt8(0), UInt8(0)] + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + public func setFanSpeed(_ id: Int, speed: Int) { + let maxSpeed = Int(self.getValue("F\(id)Mx") ?? 4000) + + if speed > maxSpeed { + print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))") + return + } + + var result: kern_return_t = 0 + var value = SMCVal_t("F\(id)Tg") + + result = read(&value) + if result != kIOReturnSuccess { + print("Error read fan value: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + + if value.dataType == "flt " { + let bytes = Float(speed).bytes + value.bytes[0] = bytes[0] + value.bytes[1] = bytes[1] + value.bytes[2] = bytes[2] + value.bytes[3] = bytes[3] + } else if value.dataType == "fpe2" { + value.bytes[0] = UInt8(speed >> 6) + value.bytes[1] = UInt8((speed << 2) ^ ((speed >> 6) << 8)) + value.bytes[2] = UInt8(0) + value.bytes[3] = UInt8(0) + } + + result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return + } + } + + public func resetFans() { + var value = SMCVal_t("FS! ") + value.dataSize = 2 + + let result = write(value) + if result != kIOReturnSuccess { + print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + } + } + + // MARK: - internal functions + + private func read(_ value: UnsafeMutablePointer) -> kern_return_t { + var result: kern_return_t = 0 + var input = SMCKeyData_t() + var output = SMCKeyData_t() + + input.key = FourCharCode(fromString: value.pointee.key) + input.data8 = SMCKeys.readKeyInfo.rawValue + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + value.pointee.dataSize = UInt32(output.keyInfo.dataSize) + value.pointee.dataType = output.keyInfo.dataType.toString() + input.keyInfo.dataSize = output.keyInfo.dataSize + input.data8 = SMCKeys.readBytes.rawValue + + result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + memcpy(&value.pointee.bytes, &output.bytes, Int(value.pointee.dataSize)) + + return kIOReturnSuccess + } + + private func write(_ value: SMCVal_t) -> kern_return_t { + var input = SMCKeyData_t() + var output = SMCKeyData_t() + + input.key = FourCharCode(fromString: value.key) + input.data8 = SMCKeys.writeBytes.rawValue + input.keyInfo.dataSize = IOByteCount32(value.dataSize) + input.bytes = (value.bytes[0], value.bytes[1], value.bytes[2], value.bytes[3], value.bytes[4], value.bytes[5], + value.bytes[6], value.bytes[7], value.bytes[8], value.bytes[9], value.bytes[10], value.bytes[11], + value.bytes[12], value.bytes[13], value.bytes[14], value.bytes[15], value.bytes[16], value.bytes[17], + value.bytes[18], value.bytes[19], value.bytes[20], value.bytes[21], value.bytes[22], value.bytes[23], + value.bytes[24], value.bytes[25], value.bytes[26], value.bytes[27], value.bytes[28], value.bytes[29], + value.bytes[30], value.bytes[31]) + + let result = self.call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output) + if result != kIOReturnSuccess { + return result + } + + return kIOReturnSuccess + } + + private func call(_ index: UInt8, input: inout SMCKeyData_t, output: inout SMCKeyData_t) -> kern_return_t { + let inputSize = MemoryLayout.stride + var outputSize = MemoryLayout.stride + + return IOConnectCallStructMethod(conn, UInt32(index), &input, inputSize, &output, &outputSize) + } +}