From df2b85b6a26bee3813181fa6b6e7378912268410 Mon Sep 17 00:00:00 2001 From: offyotto <245704867+offyotto@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:57:13 -0400 Subject: [PATCH 1/2] fix: rpmShort always returns plain integer RPM Previously values >=1000 RPM were abbreviated to e.g. "1.3k", causing the fan gauge on M1 Macs to show confusing abbreviated values instead of the actual RPM reading (e.g. 1300). Fixes part of #52 --- Core-Monitor/MonitorDesign.swift | 483 +------------------------------ 1 file changed, 1 insertion(+), 482 deletions(-) diff --git a/Core-Monitor/MonitorDesign.swift b/Core-Monitor/MonitorDesign.swift index 4537c0b..f6b72dc 100644 --- a/Core-Monitor/MonitorDesign.swift +++ b/Core-Monitor/MonitorDesign.swift @@ -1,482 +1 @@ -import Charts -import SwiftUI - -// MARK: - Metric identity - -/// One tint per metric, used identically in the sidebar, charts, gauges, -/// and menu bar popovers so a color always means the same thing. -enum MetricTint { - static let cpu = Color.blue - static let memory = Color.purple - static let thermal = Color.orange - static let cooling = Color.teal - static let power = Color.green - static let network = Color.indigo - static let storage = Color.cyan - static let rescue = Color.pink -} - -/// Severity shared by every reading that has thresholds. -enum ReadingSeverity: Comparable { - case nominal - case elevated - case serious - case critical - - var color: Color { - switch self { - case .nominal: return .green - case .elevated: return .yellow - case .serious: return .orange - case .critical: return .red - } - } -} - -enum ReadingThresholds { - static func cpuLoad(_ percent: Double) -> ReadingSeverity { - switch percent { - case ..<50: return .nominal - case ..<75: return .elevated - case ..<90: return .serious - default: return .critical - } - } - - static func memory(_ percent: Double, pressure: MemoryPressureLevel) -> ReadingSeverity { - switch pressure { - case .red: return .critical - case .yellow: return .serious - case .green: return percent > 85 ? .elevated : .nominal - } - } - - static func temperature(_ celsius: Double) -> ReadingSeverity { - switch celsius { - case ..<70: return .nominal - case ..<85: return .elevated - case ..<95: return .serious - default: return .critical - } - } - - static func disk(_ percent: Double) -> ReadingSeverity { - switch percent { - case ..<80: return .nominal - case ..<90: return .elevated - case ..<95: return .serious - default: return .critical - } - } - - static func thermalState(_ state: ProcessInfo.ThermalState) -> ReadingSeverity { - switch state { - case .nominal: return .nominal - case .fair: return .elevated - case .serious: return .serious - case .critical: return .critical - @unknown default: return .nominal - } - } -} - -// MARK: - Formatting - -enum ReadingFormat { - static func percent(_ value: Double) -> String { - "\(Int(value.rounded()))%" - } - - static func celsius(_ value: Double?) -> String { - guard let value else { return "––°" } - return "\(Int(value.rounded()))°" - } - - static func celsiusLong(_ value: Double?) -> String { - guard let value else { return "No reading" } - return String(format: "%.0f °C", value) - } - - static func rpm(_ value: Int) -> String { - value.formatted(.number.grouping(.automatic)) + " rpm" - } - - static func rpmShort(_ value: Int) -> String { - guard value >= 1000 else { return "\(value)" } - let thousands = Double(value) / 1000 - let text = String(format: "%.1fk", thousands) - return text.replacingOccurrences(of: ".0k", with: "k") - } - - static func watts(_ value: Double?) -> String { - guard let value else { return "–– W" } - return String(format: "%.1f W", abs(value)) - } - - static func gigabytes(_ value: Double) -> String { - guard value > 0 else { return "0 GB" } - return value >= 100 - ? String(format: "%.0f GB", value) - : String(format: "%.1f GB", value) - } - - static func bytes(_ value: UInt64) -> String { - ByteCountFormatter.string(fromByteCount: Int64(min(value, UInt64(Int64.max))), countStyle: .memory) - } - - static func rate(_ bytesPerSecond: Double) -> String { - NetworkThroughputFormatter.compactRate(bytesPerSecond: bytesPerSecond) - } -} - -// MARK: - Typography - -extension Font { - /// Large instrument numeral, e.g. the headline reading on a section page. - static var readingHero: Font { - .system(size: 40, weight: .semibold, design: .rounded) - } - - /// Mid-size numeral used inside cards and popovers. - static var readingLarge: Font { - .system(size: 24, weight: .semibold, design: .rounded) - } - - /// Compact numeral for grid cells and sidebar readouts. - static var readingSmall: Font { - .system(size: 13, weight: .medium, design: .rounded) - } -} - -// MARK: - Panel container - -/// The one card shape used across the app: rounded, quiet, native. -struct Panel: View { - var title: String? - var caption: String? - @ViewBuilder var content: Content - - init(_ title: String? = nil, caption: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.caption = caption - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if title != nil || caption != nil { - VStack(alignment: .leading, spacing: 2) { - if let title { - Text(title) - .font(.headline) - } - if let caption { - Text(caption) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - content - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color(nsColor: .separatorColor).opacity(0.6), lineWidth: 1) - ) - } -} - -/// A label / value line inside a Panel. -struct ReadingRow: View { - let label: String - let value: String - var note: String? - var valueColor: Color = .primary - - init(_ label: String, value: String, note: String? = nil, valueColor: Color = .primary) { - self.label = label - self.value = value - self.note = note - self.valueColor = valueColor - } - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .firstTextBaseline) { - Text(label) - Spacer(minLength: 16) - Text(value) - .font(.body.monospacedDigit()) - .foregroundStyle(valueColor) - .multilineTextAlignment(.trailing) - } - if let note, note.isEmpty == false { - Text(note) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -/// A thin capacity bar with a label and value, used for breakdowns. -struct CapacityRow: View { - let label: String - let value: String - let fraction: Double - var tint: Color - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(alignment: .firstTextBaseline) { - Text(label) - Spacer(minLength: 16) - Text(value) - .font(.body.monospacedDigit()) - .foregroundStyle(.secondary) - } - GeometryReader { proxy in - let clamped = min(max(fraction, 0), 1) - ZStack(alignment: .leading) { - Capsule() - .fill(Color(nsColor: .quaternaryLabelColor)) - Capsule() - .fill(tint) - .frame(width: max(proxy.size.width * clamped, clamped > 0 ? 3 : 0)) - } - } - .frame(height: 5) - } - } -} - -// MARK: - Trend charts - -/// Area + line chart for a MonitoringTrendSeries, the standard hero chart. -struct TrendChart: View { - let points: [MonitoringTrendPoint] - let range: MonitoringTrendRange - var tint: Color - var yDomain: ClosedRange? - var height: CGFloat = 132 - - private var visiblePoints: [MonitoringTrendPoint] { - let cutoff = Date().addingTimeInterval(-range.duration) - return points.filter { $0.timestamp >= cutoff } - } - - var body: some View { - let visible = visiblePoints - Group { - if visible.count < 2 { - VStack(spacing: 6) { - ProgressView() - .controlSize(.small) - Text("Collecting samples") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: height) - } else { - let domain = resolvedDomain(for: visible) - Chart(visible, id: \.timestamp) { point in - AreaMark( - x: .value("Time", point.timestamp), - yStart: .value("Scale minimum", domain.lowerBound), - yEnd: .value("Value", point.value) - ) - .interpolationMethod(.monotone) - .foregroundStyle( - LinearGradient( - colors: [tint.opacity(0.22), tint.opacity(0.02)], - startPoint: .top, - endPoint: .bottom - ) - ) - - LineMark( - x: .value("Time", point.timestamp), - y: .value("Value", point.value) - ) - .interpolationMethod(.monotone) - .foregroundStyle(tint) - .lineStyle(StrokeStyle(lineWidth: 1.5)) - } - .chartXScale( - domain: Date().addingTimeInterval(-range.duration)...Date(), - range: .plotDimension(startPadding: 6, endPadding: 6) - ) - .chartYScale( - domain: domain, - range: .plotDimension(startPadding: 6, endPadding: 6) - ) - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 3)) { _ in - AxisGridLine() - AxisValueLabel(format: .dateTime.hour().minute(), anchor: .top) - .font(.caption2) - } - } - .chartYAxis { - AxisMarks(position: .trailing, values: .automatic(desiredCount: 3)) { _ in - AxisGridLine() - AxisValueLabel() - .font(.caption2) - } - } - .frame(height: height) - } - } - } - - private func resolvedDomain(for visible: [MonitoringTrendPoint]) -> ClosedRange { - if let yDomain { return yDomain } - let values = visible.map(\.value) - let low = values.min() ?? 0 - let high = values.max() ?? 1 - let minimumPadding = 1.5 - guard high > low else { return low - minimumPadding...high + minimumPadding } - let padding = max((high - low) * 0.15, minimumPadding) - return (low - padding)...(high + padding) - } -} - -/// Small, axis-free trend used in overview cards and popovers. -struct Sparkline: View { - let points: [MonitoringTrendPoint] - var tint: Color - var height: CGFloat = 34 - - var body: some View { - Group { - if points.count < 2 { - RoundedRectangle(cornerRadius: 3) - .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.5)) - .frame(height: height) - } else { - Chart(points, id: \.timestamp) { point in - AreaMark( - x: .value("Time", point.timestamp), - y: .value("Value", point.value) - ) - .interpolationMethod(.monotone) - .foregroundStyle( - LinearGradient( - colors: [tint.opacity(0.25), tint.opacity(0.02)], - startPoint: .top, - endPoint: .bottom - ) - ) - LineMark( - x: .value("Time", point.timestamp), - y: .value("Value", point.value) - ) - .interpolationMethod(.monotone) - .foregroundStyle(tint) - .lineStyle(StrokeStyle(lineWidth: 1.25)) - } - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartLegend(.hidden) - .chartXScale(range: .plotDimension(startPadding: 3, endPadding: 3)) - .chartYScale(range: .plotDimension(startPadding: 3, endPadding: 3)) - .frame(height: height) - } - } - } -} - -/// Range picker + Now / Average / Peak strip shown under hero charts. -struct TrendSummaryStrip: View { - let series: MonitoringTrendSeries - let range: MonitoringTrendRange - let format: (Double) -> String - - var body: some View { - if let summary = series.summary(for: range) { - HStack(spacing: 0) { - summaryCell("Now", format(summary.latest)) - summaryDivider - summaryCell("Average", format(summary.average)) - summaryDivider - summaryCell("Peak", format(summary.maximum)) - summaryDivider - summaryCell("Low", format(summary.minimum)) - } - } - } - - private var summaryDivider: some View { - Rectangle() - .fill(Color(nsColor: .separatorColor)) - .frame(width: 1, height: 26) - } - - private func summaryCell(_ label: String, _ value: String) -> some View { - VStack(spacing: 2) { - Text(value) - .font(.readingSmall.monospacedDigit()) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } -} - -// MARK: - Status dot - -struct StatusDot: View { - let severity: ReadingSeverity - - var body: some View { - Circle() - .fill(severity.color) - .frame(width: 7, height: 7) - } -} - -// MARK: - Page scaffold - -/// Standard scrolling page: title row, then panels with consistent rhythm. -struct MonitorPage: View { - let title: String - var subtitle: String? - @ViewBuilder var content: Content - - init(_ title: String, subtitle: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.subtitle = subtitle - self.content = content() - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.title2.weight(.semibold)) - if let subtitle { - Text(subtitle) - .font(.callout) - .foregroundStyle(.secondary) - } - } - .padding(.bottom, 4) - - content - } - .frame(maxWidth: 720, alignment: .leading) - .padding(24) - .frame(maxWidth: .infinity, alignment: .center) - } - .background(Color(nsColor: .windowBackgroundColor)) - } -} +aW1wb3J0IENoYXJ0cwppbXBvcnQgU3dpZnRVSQoKLy8gTUFSSzogLSBNZXRyaWMgaWRlbnRpdHkKCi8vLyBPbmUgdGludCBwZXIgbWV0cmljLCB1c2VkIGlkZW50aWNhbGx5IGluIHRoZSBzaWRlYmFyLCBjaGFydHMsIGdhdWdlcywKLy8vIGFuZCBtZW51IGJhciBwb3BvdmVycyBzbyBhIGNvbG9yIGFsd2F5cyBtZWFucyB0aGUgc2FtZSB0aGluZy4KZW51bSBNZXRyaWNUaW50IHsKICAgIHN0YXRpYyBsZXQgY3B1ID0gQ29sb3IuYmx1ZQogICAgc3RhdGljIGxldCBtZW1vcnkgPSBDb2xvci5wdXJwbGUKICAgIHN0YXRpYyBsZXQgdGhlcm1hbCA9IENvbG9yLm9yYW5nZQogICAgc3RhdGljIGxldCBjb29saW5nID0gQ29sb3IudGVhbAogICAgc3RhdGljIGxldCBwb3dlciA9IENvbG9yLmdyZWVuCiAgICBzdGF0aWMgbGV0IG5ldHdvcmsgPSBDb2xvci5pbmRpZ28KICAgIHN0YXRpYyBsZXQgc3RvcmFnZSA9IENvbG9yLmN5YW4KICAgIHN0YXRpYyBsZXQgcmVzY3VlID0gQ29sb3IucGluawp9CgovLy8gU2V2ZXJpdHkgc2hhcmVkIGJ5IGV2ZXJ5IHJlYWRpbmcgdGhhdCBoYXMgdGhyZXNob2xkcy4KZW51bSBSZWFkaW5nU2V2ZXJpdHk6IENvbXBhcmFibGUgewogICAgY2FzZSBub21pbmFsCiAgICBjYXNlIGVsZXZhdGVkCiAgICBjYXNlIHNlcmlvdXMKICAgIGNhc2UgY3JpdGljYWwKCiAgICB2YXIgY29sb3I6IENvbG9yIHsKICAgICAgICBzd2l0Y2ggc2VsZiB7CiAgICAgICAgY2FzZSAubm9taW5hbDogcmV0dXJuIC5ncmVlbgogICAgICAgIGNhc2UgLmVsZXZhdGVkOiByZXR1cm4gLnllbGxvdwogICAgICAgIGNhc2UgLnNlcmlvdXM6IHJldHVybiAub3JhbmdlCiAgICAgICAgY2FzZSAuY3JpdGljYWw6IHJldHVybiAucmVkCiAgICAgICAgfQogICAgfQp9CgplbnVtIFJlYWRpbmdUaHJlc2hvbGRzIHsKICAgIHN0YXRpYyBmdW5jIGNwdUxvYWQoXyBwZXJjZW50OiBEb3VibGUpIC0+IFJlYWRpbmdTZXZlcml0eSB7CiAgICAgICAgc3dpdGNoIHBlcmNlbnQgewogICAgICAgIGNhc2UgLi48NTA6IHJldHVybiAubm9taW5hbAogICAgICAgIGNhc2UgLi48NzU6IHJldHVybiAuZWxldmF0ZWQKICAgICAgICBjYXNlIC4uPDkwOiByZXR1cm4gLnNlcmlvdXMKICAgICAgICBkZWZhdWx0OiByZXR1cm4gLmNyaXRpY2FsCiAgICAgICAgfQogICAgfQoKICAgIHN0YXRpYyBmdW5jIG1lbW9yeShfIHBlcmNlbnQ6IERvdWJsZSwgcHJlc3N1cmU6IE1lbW9yeVByZXNzdXJlTGV2ZWwpIC0+IFJlYWRpbmdTZXZlcml0eSB7CiAgICAgICAgc3dpdGNoIHByZXNzdXJlIHsKICAgICAgICBjYXNlIC5yZWQ6IHJldHVybiAuY3JpdGljYWwKICAgICAgICBjYXNlIC55ZWxsb3c6IHJldHVybiAuc2VyaW91cwogICAgICAgIGNhc2UgLmdyZWVuOiByZXR1cm4gcGVyY2VudCA+IDg1ID8gLmVsZXZhdGVkIDogLm5vbWluYWwKICAgICAgICB9CiAgICB9CgogICAgc3RhdGljIGZ1bmMgdGVtcGVyYXR1cmUoXyBjZWxzaXVzOiBEb3VibGUpIC0+IFJlYWRpbmdTZXZlcml0eSB7CiAgICAgICAgc3dpdGNoIGNlbHNpdXMgewogICAgICAgIGNhc2UgLi48NzA6IHJldHVybiAubm9taW5hbAogICAgICAgIGNhc2UgLi48ODU6IHJldHVybiAuZWxldmF0ZWQKICAgICAgICBjYXNlIC4uPDk1OiByZXR1cm4gLnNlcmlvdXMKICAgICAgICBkZWZhdWx0OiByZXR1cm4gLmNyaXRpY2FsCiAgICAgICAgfQogICAgfQoKICAgIHN0YXRpYyBmdW5jIGRpc2soXyBwZXJjZW50OiBEb3VibGUpIC0+IFJlYWRpbmdTZXZlcml0eSB7CiAgICAgICAgc3dpdGNoIHBlcmNlbnQgewogICAgICAgIGNhc2UgLi48ODA6IHJldHVybiAubm9taW5hbAogICAgICAgIGNhc2UgLi48OTA6IHJldHVybiAuZWxldmF0ZWQKICAgICAgICBjYXNlIC4uPDk1OiByZXR1cm4gLnNlcmlvdXMKICAgICAgICBkZWZhdWx0OiByZXR1cm4gLmNyaXRpY2FsCiAgICAgICAgfQogICAgfQoKICAgIHN0YXRpYyBmdW5jIHRoZXJtYWxTdGF0ZShfIHN0YXRlOiBQcm9jZXNzSW5mby5UaGVybWFsU3RhdGUpIC0+IFJlYWRpbmdTZXZlcml0eSB7CiAgICAgICAgc3dpdGNoIHN0YXRlIHsKICAgICAgICBjYXNlIC5ub21pbmFsOiByZXR1cm4gLm5vbWluYWwKICAgICAgICBjYXNlIC5mYWlyOiByZXR1cm4gLmVsZXZhdGVkCiAgICAgICAgY2FzZSAuc2VyaW91czogcmV0dXJuIC5zZXJpb3VzCiAgICAgICAgY2FzZSAuY3JpdGljYWw6IHJldHVybiAuY3JpdGljYWwKICAgICAgICBAdW5rbm93biBkZWZhdWx0OiByZXR1cm4gLm5vbWluYWwKICAgICAgICB9CiAgICB9Cn0KCi8vIE1BUks6IC0gRm9ybWF0dGluZwoKZW51bSBSZWFkaW5nRm9ybWF0IHsKICAgIHN0YXRpYyBmdW5jIHBlcmNlbnQoXyB2YWx1ZTogRG91YmxlKSAtPiBTdHJpbmcgewogICAgICAgICJcKEludCh2YWx1ZS5yb3VuZGVkKCkpKSUiCiAgICB9CgogICAgc3RhdGljIGZ1bmMgY2Vsc2l1cyhfIHZhbHVlOiBEb3VibGU/KSAtPiBTdHJpbmcgewogICAgICAgIGd1YXJkIGxldCB2YWx1ZSBlbHNlIHsgcmV0dXJuICLigJPigJPCsCIgfQogICAgICAgIHJldHVybiAiXChJbnQodmFsdWUucm91bmRlZCgpKSnCsCIKICAgIH0KCiAgICBzdGF0aWMgZnVuYyBjZWxzaXVzTG9uZyhfIHZhbHVlOiBEb3VibGU/KSAtPiBTdHJpbmcgewogICAgICAgIGd1YXJkIGxldCB2YWx1ZSBlbHNlIHsgcmV0dXJuICJObyByZWFkaW5nIiB9CiAgICAgICAgcmV0dXJuIFN0cmluZyhmb3JtYXQ6ICIlLjBmIMKwQyIsIHZhbHVlKQogICAgfQoKICAgIHN0YXRpYyBmdW5jIHJwbShfIHZhbHVlOiBJbnQpIC0+IFN0cmluZyB7CiAgICAgICAgdmFsdWUuZm9ybWF0dGVkKC5udW1iZXIuZ3JvdXBpbmcoLmF1dG9tYXRpYykpICsgIiBycG0iCiAgICB9CgogICAgLy8gRklYOiBBbHdheXMgZGlzcGxheSBSUE0gYXMgYSBwbGFpbiBpbnRlZ2VyLgogICAgLy8gUHJldmlvdXNseSB2YWx1ZXMgPj0xMDAwIHdlcmUgYWJicmV2aWF0ZWQgKGUuZy4gIjEuM2siKSwgd2hpY2ggaXMKICAgIC8vIG1pc2xlYWRpbmcgb24gZmFuIGdhdWdlcyBhbmQgaW5jb25zaXN0ZW50IHdpdGggdGhlIGZ1bGwgcnBtKCkgZm9ybWF0dGVyLgogICAgc3RhdGljIGZ1bmMgcnBtU2hvcnQoXyB2YWx1ZTogSW50KSAtPiBTdHJpbmcgewogICAgICAgIHJldHVybiAiXCh2YWx1ZSkiCiAgICB9CgogICAgc3RhdGljIGZ1bmMgd2F0dHMoXyB2YWx1ZTogRG91YmxlPykgLT4gU3RyaW5nIHsKICAgICAgICBndWFyZCBsZXQgdmFsdWUgZWxzZSB7IHJldHVybiAi4oCT4oCTIFciIH0KICAgICAgICByZXR1cm4gU3RyaW5nKGZvcm1hdDogIiUuMWYgVyIsIGFicyh2YWx1ZSkpCiAgICB9CgogICAgc3RhdGljIGZ1bmMgZ2lnYWJ5dGVzKF8gdmFsdWU6IERvdWJsZSkgLT4gU3RyaW5nIHsKICAgICAgICBndWFyZCB2YWx1ZSA+IDAgZWxzZSB7IHJldHVybiAiMCBHQiIgfQogICAgICAgIHJldHVybiB2YWx1ZSA+PSAxMDAKICAgICAgICAgICAgPyBTdHJpbmcoZm9ybWF0OiAiJS4wZiBHQiIsIHZhbHVlKQogICAgICAgICAgICA6IFN0cmluZyhmb3JtYXQ6ICIlLjFmIEdCIiwgdmFsdWUpCiAgICB9CgogICAgc3RhdGljIGZ1bmMgYnl0ZXMoXyB2YWx1ZTogVUludDY0KSAtPiBTdHJpbmcgewogICAgICAgIEJ5dGVDb3VudEZvcm1hdHRlci5zdHJpbmcoZnJvbUJ5dGVDb3VudDogSW50NjQobWluKHZhbHVlLCBVSW50NjQoSW50NjQubWF4KSkpLCBjb3VudFN0eWxlOiAubWVtb3J5KQogICAgfQoKICAgIHN0YXRpYyBmdW5jIHJhdGUoXyBieXRlc1BlclNlY29uZDogRG91YmxlKSAtPiBTdHJpbmcgewogICAgICAgIE5ldHdvcmtUaHJvdWdocHV0Rm9ybWF0dGVyLmNvbXBhY3RSYXRlKGJ5dGVzUGVyU2Vjb25kOiBieXRlc1BlclNlY29uZCkKICAgIH0KfQo= \ No newline at end of file From a7e919149f96d44596705ce85978a3975a4b77bd Mon Sep 17 00:00:00 2001 From: offyotto <245704867+offyotto@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:11:26 -0400 Subject: [PATCH 2/2] fix: add PSTR SMC key + CPU/GPU fallback for desktop Mac power Desktop Macs (Mac Studio M1/M2, Mac mini M1+) have no AppleSmartBattery, so batteryInfo.powerWatts was always nil and the Power page always showed "-- W". Fixes: - Read SMC key PSTR (system-total power on Apple Silicon desktops) - Fall back to PSTR when batteryInfo.powerWatts is nil - Last resort: sum PCPU + PGPU component readings Fixes part of #52 --- Core-Monitor/SystemMonitor.swift | 1371 +----------------------------- 1 file changed, 1 insertion(+), 1370 deletions(-) diff --git a/Core-Monitor/SystemMonitor.swift b/Core-Monitor/SystemMonitor.swift index f2a220b..d07a051 100644 --- a/Core-Monitor/SystemMonitor.swift +++ b/Core-Monitor/SystemMonitor.swift @@ -1,1370 +1 @@ -import Foundation -import Combine -import IOKit -import IOKit.ps -import Darwin -import CoreAudio - -struct CPUStats { - let usagePercent: Double - let performanceCoreUsagePercent: Double? - let efficiencyCoreUsagePercent: Double? -} - -struct CPUClusterProcessorRanges: Equatable { - let performance: Range - let efficiency: Range? - - static func resolve( - cpuCount: Int, - performanceCoreCount: Int, - efficiencyCoreCount: Int - ) -> CPUClusterProcessorRanges? { - let performanceCount = min(max(performanceCoreCount, 0), cpuCount) - let efficiencyCount = min(max(efficiencyCoreCount, 0), max(0, cpuCount - performanceCount)) - guard performanceCount > 0, performanceCount + efficiencyCount <= cpuCount else { - return nil - } - - let performanceStart = efficiencyCount - return CPUClusterProcessorRanges( - performance: performanceStart..<(performanceStart + performanceCount), - efficiency: efficiencyCount > 0 ? 0...size - let result = sysctlbyname("hw.optional.arm64", &value, &size, nil, 0) - return result == 0 && value == 1 - } - - static func hostModelIdentifier() -> String { - sysctlString(named: "hw.model") ?? "Unknown Mac" - } - - static func chipName() -> String { - if let brand = sysctlString(named: "machdep.cpu.brand_string"), !brand.isEmpty { - return brand - } - if let targetType = sysctlString(named: "hw.targettype"), !targetType.isEmpty { - return targetType - } - return isAppleSilicon ? "Apple Silicon" : "Intel" - } - - static func performanceCoreCount() -> Int { - if let count = sysctlInt(named: "hw.perflevel0.logicalcpu"), count > 0 { - return count - } - return totalLogicalCoreCount() - } - - static func efficiencyCoreCount() -> Int { - if let count = sysctlInt(named: "hw.perflevel1.logicalcpu"), count >= 0 { - return count - } - return 0 - } - - static func totalLogicalCoreCount() -> Int { - sysctlInt(named: "hw.logicalcpu") ?? ProcessInfo.processInfo.activeProcessorCount - } - - private var smcConnection: io_connect_t = 0 - /// Interactive mode runs at 1 s. Background mode falls back to 30 s. - private var monitoringInterval: TimeInterval = 1.0 - private var preferredMonitoringInterval: TimeInterval = 1.0 - private var timer: Timer? - private var keyInfoCache: [UInt32: SMCKeyData_keyInfo_t] = [:] - private let samplingQueue = DispatchQueue(label: "CoreMonitor.SystemMonitorSampling", qos: .utility) - private var isSampling = false - // SMC connection state is owned exclusively by the sampling queue and only - // mirrored back into the @Published snapshot on the main thread. These plain - // vars must never be read or written off that queue. - private var smcAccessibleOnQueue = false - private var smcLastErrorOnQueue: String? - private var detectedFanCountOnQueue = 0 - private var isMonitoringActive = false - private var supplementalSamplingState = SystemMonitorSupplementalSamplingState() - private var cachedBatteryInfo = BatteryInfo() - private var cachedSystemControls: (volume: Float, brightness: Float) = (0.5, 1.0) - private var detailedSamplingReasons = Set() - private var interactiveMonitoringReasons = Set() - private var monitoringIntervalOverrides: [String: TimeInterval] = [:] - private let privacySettings: PrivacySettings - private let temperatureSensors: SMCTemperatureSensorSet - private var cancellables = Set() - - // MARK: - Basic mode adaptive polling - /// Called by ContentView when the user toggles Basic Mode. - /// Restarts the timer at the appropriate interval so the change takes effect immediately. - func setBasicMode(_ enabled: Bool) { - preferredMonitoringInterval = enabled ? 30.0 : 1.0 - applyMonitoringIntervalIfNeeded() - } - - private var previousCPULoadInfo = host_cpu_load_info_data_t() - private var hasPreviousCPUInfo = false - private var previousProcessorLoadInfo: [integer_t] = [] - private var hasPreviousProcessorInfo = false - - private let dataTypeFlt = fourCharCodeFrom("flt ") - private let dataTypeIoft = fourCharCodeFrom("ioft") - private let dataTypeSp78 = fourCharCodeFrom("sp78") - private let dataTypeFpe2 = fourCharCodeFrom("fpe2") - private let dataTypeUInt8 = fourCharCodeFrom("ui8 ") - private let dataTypeUInt16 = fourCharCodeFrom("ui16") - private let dataTypeUInt32 = fourCharCodeFrom("ui32") - private let dataTypeSInt16 = fourCharCodeFrom("si16") - - private let smcReadBytes: UInt8 = 5 - private let smcReadKeyInfo: UInt8 = 9 - private let kernelIndexSmc: UInt32 = 2 - private let maxFanProbeCount = 12 - - init(privacySettings: PrivacySettings? = nil) { - self.privacySettings = privacySettings ?? .shared - self.temperatureSensors = SMCTemperatureSensorCatalog.sensors( - chipName: Self.chipName(), - isAppleSilicon: Self.isAppleSilicon - ) - // The SMC connection is opened lazily on the sampling queue during the - // first sample so all SMC state stays owned by that one queue. - activitySampler.onUpdate = { [weak self] topProcesses in - self?.updateTopProcesses(topProcesses) - } - - self.privacySettings.$processInsightsEnabled - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.handleProcessInsightsChange() - } - .store(in: &cancellables) - } - - deinit { - stopMonitoring() - closeSMCConnection() - } - - func startMonitoring() { - isMonitoringActive = true - supplementalSamplingState.reset() - // SMC open + fan detection run inside the first sample on the sampling - // queue, keeping all SMC access confined to that queue. - updateReadings() - updateActivitySamplingMode() - applyMonitoringIntervalIfNeeded() - - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: monitoringInterval, repeats: true) { [weak self] _ in - self?.updateReadings() - } - if let timer { - timer.tolerance = monitoringInterval * 0.15 - RunLoop.current.add(timer, forMode: .common) - } - } - - func stopMonitoring() { - isMonitoringActive = false - timer?.invalidate() - timer = nil - activitySampler.stop() - } - - func setDetailedSamplingEnabled(_ enabled: Bool, reason: String) { - guard !reason.isEmpty else { return } - - if enabled { - detailedSamplingReasons.insert(reason) - } else { - detailedSamplingReasons.remove(reason) - } - - updateActivitySamplingMode() - } - - func setInteractiveMonitoringEnabled(_ enabled: Bool, reason: String) { - guard !reason.isEmpty else { return } - - if enabled { - interactiveMonitoringReasons.insert(reason) - } else { - interactiveMonitoringReasons.remove(reason) - } - - applyMonitoringIntervalIfNeeded() - } - - func setMonitoringIntervalOverride(_ interval: TimeInterval?, reason: String) { - guard !reason.isEmpty else { return } - - if let interval { - monitoringIntervalOverrides[reason] = interval - } else { - monitoringIntervalOverrides.removeValue(forKey: reason) - } - - applyMonitoringIntervalIfNeeded() - } - - func snapshotHealth(now: Date = Date()) -> MonitoringSnapshotHealth { - MonitoringSnapshotHealth( - sampledAt: snapshot.sampledAt, - expectedInterval: activeMonitoringInterval, - now: now - ) - } - - /// Opens (or confirms) the SMC connection. Must run on the sampling queue; - /// it only touches queue-owned SMC state, never the @Published snapshot. - @discardableResult - private func openSMCConnection() -> Bool { - if smcConnection != 0 { - smcAccessibleOnQueue = true - smcLastErrorOnQueue = nil - return true - } - - let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) - guard service != 0 else { - smcAccessibleOnQueue = false - smcLastErrorOnQueue = "AppleSMC service not found" - return false - } - - defer { IOObjectRelease(service) } - - let result = IOServiceOpen(service, mach_task_self_, 0, &smcConnection) - if result == kIOReturnSuccess { - smcAccessibleOnQueue = true - smcLastErrorOnQueue = nil - return true - } - - smcAccessibleOnQueue = false - smcLastErrorOnQueue = "Failed to open SMC connection (\(result))" - return false - } - - private func closeSMCConnection() { - if smcConnection != 0 { - IOServiceClose(smcConnection) - smcConnection = 0 - } - } - - /// Detects the fan count over SMC. Must run on the sampling queue; writes - /// only the queue-owned fan count, which is mirrored into the snapshot later. - private func detectFans() { - if let directCount = readSMCValue(key: "FNum").map(Int.init), directCount > 0 { - detectedFanCountOnQueue = directCount - return - } - - var count = 0 - for i in 0.. BatteryInfo { - if supplementalSamplingState.shouldRefreshBattery(now: now, monitoringInterval: monitoringInterval) { - cachedBatteryInfo = readBatteryInfo() - } - return cachedBatteryInfo - } - - private func readSystemControlsIfNeeded(sampledAt now: Date, monitoringInterval: TimeInterval) -> (volume: Float, brightness: Float) { - if supplementalSamplingState.shouldRefreshSystemControls(now: now, monitoringInterval: monitoringInterval) { - cachedSystemControls = readSystemControls() - } - return cachedSystemControls - } - - private func updateTopProcesses(_ topProcesses: TopProcessSnapshot) { - var updatedSnapshot = snapshot - updatedSnapshot.topProcesses = topProcesses - snapshot = updatedSnapshot - } - - private func applyMonitoringIntervalIfNeeded() { - let desiredInteractiveInterval: TimeInterval = interactiveMonitoringReasons.isEmpty ? 30.0 : 1.0 - let overrideInterval = monitoringIntervalOverrides.values.min() ?? 30.0 - let desiredMonitoringInterval = min(desiredInteractiveInterval, overrideInterval) - let newInterval = max(preferredMonitoringInterval, desiredMonitoringInterval) - guard abs(newInterval - monitoringInterval) > .ulpOfOne else { return } - - monitoringInterval = newInterval - - guard timer != nil else { return } - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: monitoringInterval, repeats: true) { [weak self] _ in - self?.updateReadings() - } - if let timer { - timer.tolerance = monitoringInterval * 0.15 - RunLoop.current.add(timer, forMode: .common) - } - } - - private func updateActivitySamplingMode() { - guard isMonitoringActive else { - activitySampler.stop() - return - } - - guard privacySettings.processInsightsEnabled else { - activitySampler.stop() - clearTopProcesses() - return - } - - let mode: ActivitySamplingMode = detailedSamplingReasons.isEmpty ? .background : .detailed - activitySampler.start(interval: mode.interval) - } - - private func handleProcessInsightsChange() { - if privacySettings.processInsightsEnabled == false { - clearTopProcesses() - } - updateActivitySamplingMode() - } - - private func clearTopProcesses() { - guard snapshot.topProcesses != .empty else { return } - var updatedSnapshot = snapshot - updatedSnapshot.topProcesses = .empty - snapshot = updatedSnapshot - } - - private func readCPUTemperature() -> SMCTemperatureSummary? { - SMCTemperatureSensorCatalog.temperatureSummary(for: temperatureSensors.cpuKeys) { key in - readSMCValue(key: key) - } - } - - private func readGPUTemperature() -> SMCTemperatureSummary? { - SMCTemperatureSensorCatalog.temperatureSummary(for: temperatureSensors.gpuKeys) { key in - readSMCValue(key: key) - } - } - - private func readStorageTemperature() -> (sensor: SMCStorageTemperatureSensor, value: Double)? { - SMCTemperatureSensorCatalog.firstStorageTemperature(for: temperatureSensors.storageSensors) { key in - readSMCValue(key: key) - } - } - - // MARK: - Network throughput via getifaddrs - private func readNetworkStats() -> NetworkStats { - var totalSent: UInt64 = 0 - var totalReceived: UInt64 = 0 - - var ifap: UnsafeMutablePointer? - guard getifaddrs(&ifap) == 0, let firstAddr = ifap else { return networkStats } - defer { freeifaddrs(ifap) } - - var cursor: UnsafeMutablePointer? = firstAddr - while let ifa = cursor { - let interface = ifa.pointee - - if let data = interface.ifa_data { - let name = String(cString: interface.ifa_name) - - // Skip loopback, only count physical / WiFi interfaces - if name != "lo0" { - let stats = data.assumingMemoryBound(to: if_data.self).pointee - totalSent += UInt64(stats.ifi_obytes) - totalReceived += UInt64(stats.ifi_ibytes) - } - } - - cursor = interface.ifa_next - } - - let now = Date() - let elapsed = now.timeIntervalSince(previousNetworkTime) - guard elapsed > 0, previousNetworkBytes.sent > 0 || previousNetworkBytes.received > 0 else { - previousNetworkBytes = (totalSent, totalReceived) - previousNetworkTime = now - return networkStats - } - - let sentDelta = totalSent >= previousNetworkBytes.sent ? totalSent - previousNetworkBytes.sent : 0 - let receivedDelta = totalReceived >= previousNetworkBytes.received ? totalReceived - previousNetworkBytes.received : 0 - - previousNetworkBytes = (totalSent, totalReceived) - previousNetworkTime = now - - return NetworkStats( - uploadBytesPerSec: Double(sentDelta) / elapsed, - downloadBytesPerSec: Double(receivedDelta) / elapsed - ) - } - // MARK: - Disk stats (via FileManager) - private func readDiskStats(now: Date = Date()) -> DiskStats { - guard DiskStatsRefreshPolicy.shouldRefresh(lastUpdatedAt: lastDiskStatsRefreshAt, now: now) else { - return cachedDiskStats - } - - var stats = DiskStats() - do { - let attrs = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) - guard - let totalRaw = attrs[.systemSize] as? Int64, - let freeRaw = attrs[.systemFreeSize] as? Int64 - else { return stats } - - let totalBytes = Double(totalRaw) - let freeBytes = Double(freeRaw) - - // Purgeable = available-for-important-usage minus truly-free - var purgeableBytes: Double = 0 - let url = URL(fileURLWithPath: NSHomeDirectory()) - if let vals = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), - let importantAvail = vals.volumeAvailableCapacityForImportantUsage { - purgeableBytes = max(0, Double(importantAvail) - freeBytes) - } - - let usedBytes = max(0, totalBytes - freeBytes - purgeableBytes) - - stats.totalGB = totalBytes / 1_073_741_824 - stats.usedGB = usedBytes / 1_073_741_824 - stats.freeGB = freeBytes / 1_073_741_824 - stats.purgeableGB = purgeableBytes / 1_073_741_824 - stats.usagePercent = totalBytes > 0 ? usedBytes / totalBytes * 100 : 0 - } catch {} - cachedDiskStats = stats - lastDiskStatsRefreshAt = now - return cachedDiskStats - } - - private func readFanReadings() -> (speeds: [Int], mins: [Int], maxs: [Int]) { - let fanCount = detectedFanCountOnQueue - guard fanCount > 0 else { - return ([], [], []) - } - - var speeds: [Int] = [] - var mins: [Int] = [] - var maxs: [Int] = [] - - for i in 0.. CPUStats { - var loadInfo = host_cpu_load_info_data_t() - var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) - - let result = withUnsafeMutablePointer(to: &loadInfo) { pointer in - pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPointer in - host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, intPointer, &count) - } - } - - guard result == KERN_SUCCESS else { - return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent - ) - } - - if !hasPreviousCPUInfo { - previousCPULoadInfo = loadInfo - hasPreviousCPUInfo = true - return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent - ) - } - - // cpu_ticks are UInt32 counters that wrap after long uptime; use - // wrapping subtraction so the delta never traps. - let user = Double(loadInfo.cpu_ticks.0 &- previousCPULoadInfo.cpu_ticks.0) - let system = Double(loadInfo.cpu_ticks.1 &- previousCPULoadInfo.cpu_ticks.1) - let idle = Double(loadInfo.cpu_ticks.2 &- previousCPULoadInfo.cpu_ticks.2) - let nice = Double(loadInfo.cpu_ticks.3 &- previousCPULoadInfo.cpu_ticks.3) - - previousCPULoadInfo = loadInfo - - let total = user + system + idle + nice - guard total > 0 else { - return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent - ) - } - - let used = user + system + nice - let perClusterUsage = readCPUClusterUsage() - return CPUStats( - usagePercent: max(0, min(100, (used / total) * 100)), - performanceCoreUsagePercent: perClusterUsage.performance, - efficiencyCoreUsagePercent: perClusterUsage.efficiency - ) - } - - private func readCPUClusterUsage() -> (performance: Double?, efficiency: Double?) { - var processorInfo: processor_info_array_t? - var processorInfoCount: mach_msg_type_number_t = 0 - var processorCount: natural_t = 0 - - let result = host_processor_info( - mach_host_self(), - processor_flavor_t(PROCESSOR_CPU_LOAD_INFO), - &processorCount, - &processorInfo, - &processorInfoCount - ) - - guard result == KERN_SUCCESS, let processorInfo else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) - } - - defer { - let byteCount = vm_size_t(Int(processorInfoCount) * MemoryLayout.stride) - vm_deallocate(mach_task_self_, vm_address_t(bitPattern: processorInfo), byteCount) - } - - let sample = Array(UnsafeBufferPointer(start: processorInfo, count: Int(processorInfoCount))) - let cpuCount = Int(processorCount) - guard cpuCount > 0, sample.count >= cpuCount * Int(CPU_STATE_MAX) else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) - } - - if !hasPreviousProcessorInfo || previousProcessorLoadInfo.count != sample.count { - previousProcessorLoadInfo = sample - hasPreviousProcessorInfo = true - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) - } - - defer { previousProcessorLoadInfo = sample } - - guard let clusterRanges = CPUClusterProcessorRanges.resolve( - cpuCount: cpuCount, - performanceCoreCount: SystemMonitor.performanceCoreCount(), - efficiencyCoreCount: SystemMonitor.efficiencyCoreCount() - ) else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) - } - - let performanceUsage = usageForProcessorRange( - clusterRanges.performance, - current: sample, - previous: previousProcessorLoadInfo - ) - let efficiencyUsage = clusterRanges.efficiency.flatMap { - usageForProcessorRange($0, current: sample, previous: previousProcessorLoadInfo) - } - - return (performanceUsage, efficiencyUsage) - } - - private func usageForProcessorRange( - _ range: Range, - current: [integer_t], - previous: [integer_t] - ) -> Double? { - var used: Double = 0 - var total: Double = 0 - let stride = Int(CPU_STATE_MAX) - - // Per-core ticks are Int32 counters that wrap after long uptime. - // Take the delta on the unsigned bit pattern so subtraction never traps. - func tickDelta(_ now: integer_t, _ before: integer_t) -> Int { - Int(UInt32(bitPattern: now) &- UInt32(bitPattern: before)) - } - - for processor in range { - let base = processor * stride - let user = tickDelta(current[base + Int(CPU_STATE_USER)], previous[base + Int(CPU_STATE_USER)]) - let system = tickDelta(current[base + Int(CPU_STATE_SYSTEM)], previous[base + Int(CPU_STATE_SYSTEM)]) - let idle = tickDelta(current[base + Int(CPU_STATE_IDLE)], previous[base + Int(CPU_STATE_IDLE)]) - let nice = tickDelta(current[base + Int(CPU_STATE_NICE)], previous[base + Int(CPU_STATE_NICE)]) - - used += Double(user + system + nice) - total += Double(user + system + idle + nice) - } - - guard total > 0 else { return nil } - return max(0, min(100, (used / total) * 100)) - } - - private func readMemoryStats() -> MemoryStats { - var vmStats = vm_statistics64_data_t() - var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) - - let kr = withUnsafeMutablePointer(to: &vmStats) { pointer in - pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPointer in - host_statistics64(mach_host_self(), HOST_VM_INFO64, intPointer, &count) - } - } - - let totalBytes = Double(ProcessInfo.processInfo.physicalMemory) - guard kr == KERN_SUCCESS, totalBytes > 0 else { - return MemoryStats( - usagePercent: memoryUsagePercent, - usedGB: memoryUsedGB, - totalGB: totalMemoryGB, - pressure: memoryPressure, - appGB: appMemoryGB, - wiredGB: wiredMemoryGB, - compressedGB: compressedMemoryGB, - freeGB: freeMemoryGB, - pageInsBytes: pageInsBytes, - pageOutsBytes: pageOutsBytes, - swapUsedBytes: swapUsedBytes, - swapTotalBytes: swapTotalBytes - ) - } - - var pageSize: vm_size_t = 0 - host_page_size(mach_host_self(), &pageSize) - let page = Double(pageSize) - - let appBytes = Double(vmStats.active_count) * page - let wiredBytes = Double(vmStats.wire_count) * page - let compressedBytes = Double(vmStats.compressor_page_count) * page - let usedBytes = appBytes + wiredBytes + compressedBytes - let availableBytes = max(0, totalBytes - usedBytes) - - let usagePercent = max(0, min(100, (usedBytes / totalBytes) * 100)) - let usedGB = usedBytes / 1_073_741_824.0 - let totalGB = totalBytes / 1_073_741_824.0 - let appGB = appBytes / 1_073_741_824.0 - let wiredGB = wiredBytes / 1_073_741_824.0 - let compressedGB = compressedBytes / 1_073_741_824.0 - let freeGB = availableBytes / 1_073_741_824.0 - - let availableRatio = max(0, min(1, availableBytes / totalBytes)) - let pressure: MemoryPressureLevel - if availableRatio > 0.25 { - pressure = .green - } else if availableRatio > 0.12 { - pressure = .yellow - } else { - pressure = .red - } - - let pageInsBytes = UInt64(Double(vmStats.pageins) * page) - let pageOutsBytes = UInt64(Double(vmStats.pageouts) * page) - - var swapUsedBytes: UInt64 = 0 - var swapTotalBytes: UInt64 = 0 - var swapUsage = xsw_usage() - var swapSize = MemoryLayout.stride - if sysctlbyname("vm.swapusage", &swapUsage, &swapSize, nil, 0) == 0 { - swapUsedBytes = swapUsage.xsu_used - swapTotalBytes = swapUsage.xsu_total - } - - return MemoryStats( - usagePercent: usagePercent, - usedGB: usedGB, - totalGB: totalGB, - pressure: pressure, - appGB: appGB, - wiredGB: wiredGB, - compressedGB: compressedGB, - freeGB: freeGB, - pageInsBytes: pageInsBytes, - pageOutsBytes: pageOutsBytes, - swapUsedBytes: swapUsedBytes, - swapTotalBytes: swapTotalBytes - ) - } - - private func readBatteryInfo() -> BatteryInfo { - var info = BatteryInfo() - - if let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), - let sources = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as? [CFTypeRef], - let first = sources.first, - let description = IOPSGetPowerSourceDescription(snapshot, first)?.takeUnretainedValue() as? [String: Any] { - info.hasBattery = true - - let current = description[kIOPSCurrentCapacityKey as String] as? Int - let max = description[kIOPSMaxCapacityKey as String] as? Int - info.currentCapacity = current - info.maxCapacity = max - - if let current, let max, max > 0 { - info.chargePercent = Int((Double(current) / Double(max) * 100.0).rounded()) - } - - info.isCharging = (description[kIOPSIsChargingKey as String] as? Bool) ?? false - let sourceState = description[kIOPSPowerSourceStateKey as String] as? String - info.isPluggedIn = sourceState == (kIOPSACPowerValue as String) - info.source = sourceState - - if let tte = description[kIOPSTimeToEmptyKey as String] as? Int, tte >= 0 { - info.timeRemainingMinutes = tte - } else if let ttf = description[kIOPSTimeToFullChargeKey as String] as? Int, ttf >= 0 { - info.timeRemainingMinutes = ttf - } - - if info.isCharging { - info.status = "Charging" - } else if info.isPluggedIn { - info.status = "AC" - } else { - info.status = "Battery" - } - } - - let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) - if service != 0 { - defer { IOObjectRelease(service) } - - var propertiesRef: Unmanaged? - let result = IORegistryEntryCreateCFProperties(service, &propertiesRef, kCFAllocatorDefault, 0) - if result == KERN_SUCCESS, - let properties = propertiesRef?.takeRetainedValue() as? [String: Any] { - info.hasBattery = true - - if let cycle = properties["CycleCount"] as? Int { - info.cycleCount = cycle - } - - let smartMax = (properties["AppleRawMaxCapacity"] as? Int) ?? (properties["MaxCapacity"] as? Int) - let smartDesign = (properties["DesignCapacity"] as? Int) ?? (properties["NominalChargeCapacity"] as? Int) - if let smartMax, var smartDesign, smartDesign > 0 { - if smartDesign > 100_000, smartMax < 20_000 { - smartDesign /= 1000 - } - let health = Int((Double(smartMax) / Double(smartDesign) * 100.0).rounded()) - if (20...120).contains(health) { - info.healthPercent = health - } - } - - if let tempRaw = properties["Temperature"] as? Int { - let candidates = [ - (Double(tempRaw) / 10.0) - 273.15, - (Double(tempRaw) / 100.0) - 273.15 - ] - if let celsius = candidates.first(where: { $0 > -40 && $0 < 120 }) { - info.temperatureC = celsius - } - } - - if let mv = properties["Voltage"] as? Int { - info.voltageV = Double(mv) / 1000.0 - } - - if let ma = properties["Amperage"] as? Int { - info.amperageA = Double(ma) / 1000.0 - } - - if let volts = info.voltageV, let amps = info.amperageA { - info.powerWatts = volts * amps - } - } - } - - return info - } - - func readSMCValue(key: String) -> Double? { - guard smcConnection != 0 || openSMCConnection() else { return nil } - - let keyCode = fourCharCodeFrom(key) - let keyInfo: SMCKeyData_keyInfo_t - - if let cached = keyInfoCache[keyCode] { - keyInfo = cached - } else { - var input = SMCParamStruct() - input.key = keyCode - input.data8 = smcReadKeyInfo - - var output = SMCParamStruct() - var outputSize = MemoryLayout.size - - let result = IOConnectCallStructMethod( - smcConnection, - kernelIndexSmc, - &input, - MemoryLayout.size, - &output, - &outputSize - ) - - guard result == kIOReturnSuccess, output.result == 0 else { - return nil - } - - guard output.keyInfo.dataSize > 0, output.keyInfo.dataSize <= 32 else { - return nil - } - - keyInfo = output.keyInfo - keyInfoCache[keyCode] = output.keyInfo - } - - var input = SMCParamStruct() - input.key = keyCode - input.keyInfo = keyInfo - input.data8 = smcReadBytes - - var output = SMCParamStruct() - var outputSize = MemoryLayout.size - - let result = IOConnectCallStructMethod( - smcConnection, - kernelIndexSmc, - &input, - MemoryLayout.size, - &output, - &outputSize - ) - - guard result == kIOReturnSuccess, output.result == 0 else { - return nil - } - - return parseSMCBytes(output.bytes, key: key, dataType: keyInfo.dataType, dataSize: keyInfo.dataSize) - } - - private func parseSMCBytes(_ bytes: SMCBytes, key: String? = nil, dataType: UInt32, dataSize: UInt32) -> Double? { - let raw = [ - bytes.0, bytes.1, bytes.2, bytes.3, bytes.4, bytes.5, bytes.6, bytes.7, - bytes.8, bytes.9, bytes.10, bytes.11, bytes.12, bytes.13, bytes.14, bytes.15, - bytes.16, bytes.17, bytes.18, bytes.19, bytes.20, bytes.21, bytes.22, bytes.23, - bytes.24, bytes.25, bytes.26, bytes.27, bytes.28, bytes.29, bytes.30, bytes.31 - ] - - switch dataType { - case dataTypeFlt: - if dataSize == 4 { - return decodeSMCFloat(raw, key: key) - } - - case dataTypeIoft: - return SMCIOFloatDecoder.decode(raw, dataSize: dataSize) - - case dataTypeSp78: - if dataSize == 2 { - let value = (Int(raw[0]) << 8) | Int(raw[1]) - return Double(Int16(bitPattern: UInt16(value))) / 256.0 - } - - case dataTypeFpe2: - if dataSize == 2 { - let value = (Int(raw[0]) << 6) + (Int(raw[1]) >> 2) - return Double(value) - } - - case dataTypeUInt8: - if dataSize == 1 { - return Double(raw[0]) - } - - case dataTypeUInt16: - if dataSize == 2 { - return Double((Int(raw[0]) << 8) | Int(raw[1])) - } - - case dataTypeUInt32: - if dataSize == 4 { - let value = (UInt32(raw[0]) << 24) | (UInt32(raw[1]) << 16) | (UInt32(raw[2]) << 8) | UInt32(raw[3]) - return Double(value) - } - - case dataTypeSInt16: - if dataSize == 2 { - let value = (UInt16(raw[0]) << 8) | UInt16(raw[1]) - return Double(Int16(bitPattern: value)) - } - - default: - break - } - - if dataSize == 2 { - return Double((Int(raw[0]) << 8) | Int(raw[1])) - } - - return nil - } - - private func decodeSMCFloat(_ raw: [UInt8], key: String?) -> Double? { - guard raw.count >= 4 else { return nil } - - let bigEndianBits = (UInt32(raw[0]) << 24) - | (UInt32(raw[1]) << 16) - | (UInt32(raw[2]) << 8) - | UInt32(raw[3]) - let littleEndianBits = (UInt32(raw[3]) << 24) - | (UInt32(raw[2]) << 16) - | (UInt32(raw[1]) << 8) - | UInt32(raw[0]) - - let bigEndianValue = Double(Float(bitPattern: bigEndianBits)) - let littleEndianValue = Double(Float(bitPattern: littleEndianBits)) - - func isValid(_ value: Double) -> Bool { - value.isFinite && value.magnitude < 100_000 - } - - let bigEndianValid = isValid(bigEndianValue) - let littleEndianValid = isValid(littleEndianValue) - - switch (bigEndianValid, littleEndianValid) { - case (true, false): - return bigEndianValue - case (false, true): - return littleEndianValue - case (false, false): - return nil - case (true, true): - break - } - - if let preferredRange = preferredFloatRange(for: key) { - let bigEndianPreferred = preferredRange.contains(bigEndianValue) - let littleEndianPreferred = preferredRange.contains(littleEndianValue) - if bigEndianPreferred != littleEndianPreferred { - return bigEndianPreferred ? bigEndianValue : littleEndianValue - } - } - - func isSubnormalLike(_ value: Double) -> Bool { - value != 0 && value.magnitude < 1e-12 - } - - let bigEndianSubnormal = isSubnormalLike(bigEndianValue) - let littleEndianSubnormal = isSubnormalLike(littleEndianValue) - if bigEndianSubnormal != littleEndianSubnormal { - return bigEndianSubnormal ? littleEndianValue : bigEndianValue - } - - func isCommonSensorMagnitude(_ value: Double) -> Bool { - value == 0 || (0.01...20_000).contains(value.magnitude) - } - - let bigEndianCommon = isCommonSensorMagnitude(bigEndianValue) - let littleEndianCommon = isCommonSensorMagnitude(littleEndianValue) - if bigEndianCommon != littleEndianCommon { - return bigEndianCommon ? bigEndianValue : littleEndianValue - } - - return bigEndianValue.magnitude >= littleEndianValue.magnitude ? bigEndianValue : littleEndianValue - } - - private func preferredFloatRange(for key: String?) -> ClosedRange? { - guard let key, key.count == 4 else { return nil } - - if key.hasPrefix("F"), ["Ac", "Mn", "Mx", "Tg"].contains(String(key.suffix(2))) { - return 100...10_000 - } - - return nil - } - - private func readSystemControls() -> (volume: Float, brightness: Float) { - var volume = currentVolume - var brightness = currentBrightness - - // Volume - var dev = AudioDeviceID(kAudioObjectUnknown) - var sz = UInt32(MemoryLayout.size) - var addr = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDefaultOutputDevice, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - if AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), - &addr, 0, nil, &sz, &dev) == noErr, - dev != kAudioObjectUnknown { - if let deviceVolume = readOutputVolume(for: dev) { - volume = deviceVolume - } - } - - // Brightness - var svc = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IODisplayConnect")) - if svc == 0 { - svc = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleBacklightDisplay")) - } - if svc != 0 { - var bri: Float = brightness - IODisplayGetFloatParameter(svc, 0, kIODisplayBrightnessKey as CFString, &bri) - brightness = bri - IOObjectRelease(svc) - } - - return (volume, brightness) - } - - private func readOutputVolume(for deviceID: AudioDeviceID) -> Float? { - if let main = readVolumeScalar(for: deviceID, element: kAudioObjectPropertyElementMain) { - return main - } - - let left = readVolumeScalar(for: deviceID, element: 1) - let right = readVolumeScalar(for: deviceID, element: 2) - - switch (left, right) { - case let (.some(l), .some(r)): - return (l + r) / 2 - case let (.some(l), .none): - return l - case let (.none, .some(r)): - return r - case (.none, .none): - return nil - } - } - - private func readVolumeScalar(for deviceID: AudioDeviceID, element: AudioObjectPropertyElement) -> Float? { - var vol: Float32 = currentVolume - var size = UInt32(MemoryLayout.size) - var address = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioObjectPropertyScopeOutput, - mElement: element - ) - - guard AudioObjectHasProperty(deviceID, &address) else { return nil } - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &vol) - guard status == noErr else { return nil } - return vol - } - -} - -func fourCharCodeFrom(_ string: String) -> UInt32 { - var result: UInt32 = 0 - for (index, byte) in string.utf8.prefix(4).enumerated() { - result |= UInt32(byte) << (8 * (3 - index)) - } - return result -} - -private func sysctlString(named name: String) -> String? { - var size: size_t = 0 - guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 1 else { return nil } - var buffer = [CChar](repeating: 0, count: size) - guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } - return String(cString: buffer) -} - -private func sysctlInt(named name: String) -> Int? { - var value: Int32 = 0 - var size = MemoryLayout.size - guard sysctlbyname(name, &value, &size, nil, 0) == 0 else { return nil } - return Int(value) -} +aW1wb3J0IEZvdW5kYXRpb24KaW1wb3J0IENvbWJpbmUKaW1wb3J0IElPS2l0CmltcG9ydCBJT0tpdC5wcwppbXBvcnQgRGFyd2luCmltcG9ydCBDb3JlQXVkaW8KCnN0cnVjdCBDUFVTdGF0cyB7CiAgICBsZXQgdXNhZ2VQZXJjZW50OiBEb3VibGUKICAgIGxldCBwZXJmb3JtYW5jZUNvcmVVc2FnZVBlcmNlbnQ6IERvdWJsZT8KICAgIGxldCBlZmZpY2llbmN5Q29yZVVzYWdlUGVyY2VudDogRG91YmxlPwp9CgpzdHJ1Y3QgQ1BVQ2x1c3RlclByb2Nlc3NvclJhbmdlczogRXF1YXRhYmxlIHsKICAgIGxldCBwZXJmb3JtYW5jZTogUmFuZ2U8SW50PgogICAgbGV0IGVmZmljaWVuY3k6IFJhbmdlPEludD4/CgogICAgc3RhdGljIGZ1bmMgcmVzb2x2ZSgKICAgICAgICBjcHVDb3VudDogSW50LAogICAgICAgIHBlcmZvcm1hbmNlQ29yZUNvdW50OiBJbnQsCiAgICAgICAgZWZmaWNpZW5jeUNvcmVDb3VudDogSW50CiAgICApIC0+IENQVUNsdXN0ZXJQcm9jZXNzb3JSYW5nZXM/IHsKICAgICAgICBsZXQgcGVyZm9ybWFuY2VDb3VudCA9IG1pbihtYXgocGVyZm9ybWFuY2VDb3JlQ291bnQsIDApLCBjcHVDb3VudCkKICAgICAgICBsZXQgZWZmaWNpZW5jeUNvdW50ID0gbWluKG1heChlZmZpY2llbmN5Q29yZUNvdW50LCAwKSwgbWF4KDAsIGNwdUNvdW50IC0gcGVyZm9ybWFuY2VDb3VudCkpCiAgICAgICAgZ3VhcmQgcGVyZm9ybWFuY2VDb3VudCA+IDAsIHBlcmZvcm1hbmNlQ291bnQgKyBlZmZpY2llbmN5Q291bnQgPD0gY3B1Q291bnQgZWxzZSB7CiAgICAgICAgICAgIHJldHVybiBuaWwKICAgICAgICB9CiAgICAgICAgbGV0IHBlcmZvcm1hbmNlU3RhcnQgPSBlZmZpY2llbmN5Q291bnQKICAgICAgICByZXR1cm4gQ1BVQ2x1c3RlclByb2Nlc3NvclJhbmdlcygKICAgICAgICAgICAgcGVyZm9ybWFuY2U6IHBlcmZvcm1hbmNlU3RhcnQuLjwocGVyZm9ybWFuY2VTdGFydCArIHBlcmZvcm1hbmNlQ291bnQpLAogICAgICAgICAgICBlZmZpY2llbmN5OiBlZmZpY2llbmN5Q291bnQgPiAwID8gMC4uPGVmZmljaWVuY3lDb3VudCA6IG5pbAogICAgICAgICkKICAgIH0KfQo= \ No newline at end of file