469 lines
23 KiB
Swift
469 lines
23 KiB
Swift
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) }
|
||
}
|
||
}
|