Files
puter/Sources/Puter/HardwareView.swift
T

469 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Charts
import SwiftUI
struct HardwareView: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("showEmptyHardwarePorts") private var showEmptyPorts = true
@State private var fanTargets: [Int: Double] = [:]
@State private var fanCommandInProgress = false
@State private var fanError: String?
@State private var fanStatus: String?
@State private var showingFanConfirmation = false
private var battery: BatterySnapshot { monitor.hardware.battery }
private var visiblePorts: [HardwarePortSnapshot] {
showEmptyPorts ? monitor.hardware.ports : monitor.hardware.ports.filter(\.isConnected)
}
var body: some View {
VStack(spacing: 0) {
PageHeader(title: "Hardware", subtitle: "Power, battery, thermals, ports, and connection diagnostics") {
HStack(spacing: 10) {
Button("Export", systemImage: "square.and.arrow.up") {
SystemReportExporter.export(monitor: monitor, selectedResource: "Hardware")
}
Button("Power Settings", systemImage: "gear") { openPowerSettings() }
}
}
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
summaryGrid
verdictCard
if battery.isPresent { batterySection }
if let adapter = battery.adapter { adapterSection(adapter) }
powerSection
portsSection
coolingSection
topConsumersSection
}
.padding(20)
}
}
.onChange(of: monitor.hardware.fans) { _, fans in
seedFanTargets(fans)
}
.onAppear { seedFanTargets(monitor.hardware.fans) }
.confirmationDialog("Apply manual fan targets?", isPresented: $showingFanConfirmation) {
Button("Apply Targets") { updateFans(manual: true) }
Button("Cancel", role: .cancel) { }
} message: {
Text("\(fanTargetSummary). macOS automatic fan control will be disabled until you choose Automatic.")
}
.alert("Fan control failed", isPresented: Binding(
get: { fanError != nil },
set: { if !$0 { fanError = nil } }
)) {
Button("OK", role: .cancel) { fanError = nil }
} message: {
Text(fanError ?? "")
}
}
private var summaryGrid: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
}
}
private func summaryCard(_ title: String, value: String, detail: String, icon: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(color)
Text(value).font(.title2.weight(.semibold)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, minHeight: 86, alignment: .leading)
.padding(13)
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.18)) }
}
private var verdictCard: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: verdictIcon).font(.title2).foregroundStyle(verdictColor).frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(verdictTitle).font(.headline)
Text(verdictDetail).font(.callout).foregroundStyle(.secondary)
}
Spacer()
}
.padding(15)
.background(verdictColor.opacity(0.09), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(verdictColor.opacity(0.22)) }
}
private var batterySection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Battery").font(.headline)
HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent))
Spacer()
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
Spacer()
metric("Health", Formatters.percent(battery.healthPercent))
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts))
Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer()
metric("Design capacity", capacity(battery.designCapacityMAh))
Spacer()
metric("Time remaining", timeRemaining)
}
Divider()
HStack {
Label(powerSourceText, systemImage: battery.externalPowerConnected ? "powerplug.fill" : "battery.75percent")
Spacer()
Text(batteryStateText).foregroundStyle(.secondary)
}
.font(.callout.weight(.medium))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func adapterSection(_ adapter: PowerAdapterSnapshot) -> some View {
VStack(alignment: .leading, spacing: 13) {
Text("Charger and USB Power Delivery").font(.headline)
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(adapter.name).font(.headline)
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
}
Divider()
HStack(spacing: 24) {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
if !adapter.profiles.isEmpty {
VStack(alignment: .leading, spacing: 7) {
Text("Advertised power profiles").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
HStack(spacing: 7) {
ForEach(adapter.profiles) { profile in
Text(String(format: "%.0f V × %.2f A · %.0f W", profile.voltageVolts, profile.currentAmps, profile.watts))
.font(.caption.monospacedDigit())
.padding(.horizontal, 9).padding(.vertical, 5)
.background(Color.accentColor.opacity(profile.voltageVolts == adapter.negotiatedVoltage ? 0.18 : 0.07), in: Capsule())
}
}
}
}
Label(cableEvidence(adapter), systemImage: "info.circle")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var powerSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Power history — 60 seconds").font(.headline)
Chart(monitor.systemPowerHistory) { sample in
AreaMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange.opacity(0.13))
LineMark(x: .value("Time", sample.date), y: .value("Watts", sample.value))
.foregroundStyle(.orange).lineStyle(.init(lineWidth: 2))
}
.chartYAxisLabel("W")
.chartXAxis(.hidden)
.frame(height: 150)
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var portsSection: some View {
VStack(spacing: 0) {
HStack {
Text("USB-C, USB, and Thunderbolt").font(.headline)
Spacer()
Toggle("Show available ports", isOn: $showEmptyPorts).toggleStyle(.switch).controlSize(.small)
}
.padding(8)
if !visiblePorts.isEmpty { Divider() }
ForEach(visiblePorts) { port in
portRow(port)
if port.id != visiblePorts.last?.id { Divider() }
}
}
.padding(8)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func portRow(_ port: HardwarePortSnapshot) -> some View {
VStack(alignment: .leading, spacing: 9) {
HStack {
Image(systemName: port.isConnected ? "cable.connector" : "bolt.horizontal.circle")
.foregroundStyle(port.isConnected ? Color.green : Color.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(port.name).font(.callout.weight(.semibold))
Text("\(port.transport)\(port.maximumSpeed)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(port.status).font(.caption.weight(.medium)).foregroundStyle(port.isConnected ? Color.green : Color.secondary)
}
ForEach(port.devices) { device in
HStack(spacing: 8) {
Image(systemName: "arrow.turn.down.right").foregroundStyle(.tertiary)
VStack(alignment: .leading, spacing: 2) {
Text(device.name).lineLimit(1)
Text(deviceDetail(device)).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
.padding(.leading, CGFloat(device.depth * 18 + 30))
}
}
.padding(10)
}
private var topConsumersSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top live consumers").font(.headline)
VStack(spacing: 0) {
ForEach(Array(monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5))) { process in
HStack {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).monospacedDigit().frame(width: 64, alignment: .trailing)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit().frame(width: 90, alignment: .trailing)
}
.padding(.vertical, 6)
}
}
}
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private var coolingSection: some View {
VStack(alignment: .leading, spacing: 13) {
HStack {
Label("Cooling and fan control", systemImage: "fan")
.font(.headline)
Spacer()
Text(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? "Automatic" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(monitor.hardware.fans.allSatisfy(\.isAutomatic) ? .green : .orange)
}
Divider()
if monitor.hardware.fans.isEmpty {
HStack(spacing: 12) {
Image(systemName: "fan.slash").font(.title2).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Fan telemetry unavailable").font(.callout.weight(.medium))
Text("This Mac is not publishing compatible SMC fan data.")
.font(.caption).foregroundStyle(.secondary)
}
}
} else {
ForEach(monitor.hardware.fans) { fan in
fanRow(fan)
if fan.id != monitor.hardware.fans.last?.id { Divider().opacity(0.55) }
}
}
HStack(spacing: 28) {
metric("Thermal pressure", monitor.hardware.thermalState)
metric("Low Power Mode", ProcessInfo.processInfo.isLowPowerModeEnabled ? "On" : "Off")
metric("Control backend", FanControlService.isAvailable ? "Ready" : "Unavailable")
Spacer()
}
Text("Puter reads fan sensors directly through the Mac's SMC interface. Manual targets are clamped to each fan's reported safe range; Automatic returns control to macOS.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Apply targets", systemImage: "speedometer") {
showingFanConfirmation = true
}
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Automatic", systemImage: "arrow.counterclockwise") { updateFans(manual: false) }
.disabled(monitor.hardware.fans.isEmpty || fanCommandInProgress || !FanControlService.isAvailable)
Button("Reduce monitor refresh", systemImage: "tortoise") {
monitor.updateSpeed = .low
}
Spacer()
if fanCommandInProgress {
ProgressView().controlSize(.small).accessibilityLabel("Applying fan settings")
} else if let fanStatus {
Label(fanStatus, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay { RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)) }
}
private func fanRow(_ fan: FanSnapshot) -> some View {
let target = Binding(
get: { safeTarget(for: fan) },
set: { fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, $0)) }
)
return HStack(spacing: 16) {
ZStack {
Circle().fill(Color.cyan.opacity(0.12)).frame(width: 40, height: 40)
Image(systemName: "fan.fill").foregroundStyle(.cyan)
}
VStack(alignment: .leading, spacing: 3) {
Text(fan.name).font(.callout.weight(.semibold))
Text("\(Int(fan.actualRPM.rounded())) RPM now · \(Int(fan.minimumRPM))\(Int(fan.maximumRPM)) RPM")
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
.frame(width: 220, alignment: .leading)
Slider(value: target, in: fan.minimumRPM...max(fan.minimumRPM + 1, fan.maximumRPM), step: 25)
.accessibilityLabel("\(fan.name) target speed")
.accessibilityValue("\(Int(target.wrappedValue.rounded())) RPM")
.accessibilityHint("Adjusts from \(Int(fan.minimumRPM)) to \(Int(fan.maximumRPM)) RPM")
Text("\(Int(target.wrappedValue.rounded())) RPM")
.font(.callout.monospacedDigit()).frame(width: 82, alignment: .trailing)
Text(fan.isAutomatic ? "Auto" : "Manual")
.font(.caption.weight(.medium))
.foregroundStyle(fan.isAutomatic ? .green : .orange)
.frame(width: 52, alignment: .trailing)
}
}
private func updateFans(manual: Bool) {
fanCommandInProgress = true
fanStatus = nil
let targets = Dictionary(uniqueKeysWithValues: monitor.hardware.fans.map { fan in
(fan.id, safeTarget(for: fan))
})
Task {
do {
try await Task.detached {
if manual { try FanControlService.setManualTargets(targets) }
else { try FanControlService.restoreAutomatic() }
}.value
fanStatus = manual ? "Targets applied" : "Automatic control restored"
monitor.refresh()
} catch {
fanError = error.localizedDescription
}
fanCommandInProgress = false
}
}
private var fanTargetSummary: String {
monitor.hardware.fans.map { fan in
let target = safeTarget(for: fan)
return "\(fan.name): \(Int(target.rounded())) RPM"
}.joined(separator: ", ")
}
private func seedFanTargets(_ fans: [FanSnapshot]) {
for fan in fans where fanTargets[fan.id] == nil {
let reported = fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM
fanTargets[fan.id] = min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
}
private func safeTarget(for fan: FanSnapshot) -> Double {
let reported = fanTargets[fan.id] ?? (fan.targetRPM > 0 ? fan.targetRPM : fan.actualRPM)
return min(fan.maximumRPM, max(fan.minimumRPM, reported))
}
private func metric(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.medium)).monospacedDigit().lineLimit(1).minimumScaleFactor(0.75)
}
}
private var verdictTitle: String {
if !battery.isPresent { return "Power telemetry available for desktop hardware" }
if battery.isFullyCharged && battery.externalPowerConnected { return "Battery full — running from external power" }
if battery.isCharging { return "Battery charging normally" }
if battery.externalPowerConnected { return "External power connected — battery is not charging" }
return "Running on battery"
}
private var verdictDetail: String {
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
if let adapter = battery.adapter {
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
}
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
}
private var verdictIcon: String {
battery.externalPowerConnected ? "checkmark.circle.fill" : "battery.75percent"
}
private var verdictColor: Color {
monitor.hardware.thermalState == "Serious" || monitor.hardware.thermalState == "Critical" ? .red : .green
}
private var thermalColor: Color {
switch monitor.hardware.thermalState {
case "Critical", "Serious": .red
case "Fair": .orange
default: .green
}
}
private var thermalDetail: String {
switch monitor.hardware.thermalState {
case "Critical": "Performance is heavily constrained"
case "Serious": "Performance may be reduced"
case "Fair": "Elevated thermal pressure"
default: "No thermal pressure"
}
}
private var batteryColor: Color { battery.chargePercent < 20 ? .red : (battery.isCharging ? .green : .accentColor) }
private var cycleText: String { battery.designCycleCount > 0 ? "\(battery.cycleCount) of \(battery.designCycleCount)" : "\(battery.cycleCount)" }
private var timeRemaining: String {
guard let minutes = battery.timeRemainingMinutes else { return battery.isFullyCharged ? "Full" : "Calculating" }
return "\(minutes / 60)h \(minutes % 60)m"
}
private var powerSourceText: String { battery.externalPowerConnected ? "Power adapter" : "Internal battery" }
private var batteryStateText: String { battery.isFullyCharged ? "Fully charged" : (battery.isCharging ? "Charging" : "Not charging") }
private func cableEvidence(_ adapter: PowerAdapterSnapshot) -> String {
"Observed USB-PD contract: \(String(format: "%.1f V at %.2f A", adapter.negotiatedVoltage, adapter.negotiatedCurrent)). Cable e-marker identity is not claimed unless macOS exposes it."
}
private func deviceDetail(_ device: ConnectedHardwareDevice) -> String {
var parts = [device.vendor, device.speed].filter { $0 != "Not reported" }
if let required = device.currentRequiredMA { parts.append("requests \(required) mA") }
if let available = device.currentAvailableMA { parts.append("\(available) mA available") }
return parts.isEmpty ? "Technical details not reported by macOS" : parts.joined(separator: " • ")
}
private func watts(_ value: Double) -> String { value > 0 ? String(format: "%.1f W", value) : "—" }
private func capacity(_ value: Double) -> String { value > 0 ? String(format: "%.0f mAh", value) : "Not reported" }
private func openPowerSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { NSWorkspace.shared.open(url) }
}
}