Rename app and repository to Puter

This commit is contained in:
2026-08-14 23:55:16 -04:00
parent b7faf0ac70
commit 053bb71da2
28 changed files with 1804 additions and 302 deletions
+468
View File
@@ -0,0 +1,468 @@
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) }
}
}