Refresh puter UI, power telemetry and demand-aware monitoring
Build and test / macos (push) Waiting to run
Signed release / release (push) Waiting to run

This commit is contained in:
2026-09-22 20:03:16 -04:00
parent 0da94715be
commit 4bfa239aa0
38 changed files with 1728 additions and 492 deletions
+9 -1
View File
@@ -76,6 +76,7 @@ struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor
@EnvironmentObject private var updates: UpdateController
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
@@ -96,6 +97,11 @@ struct SettingsView: View {
PageHeader(title: "Settings", subtitle: "Customize puter")
Form {
Section("General") {
Picker("Appearance", selection: $appearance) {
ForEach(PuterAppearance.allCases) { appearance in
Text(appearance.rawValue).tag(appearance)
}
}
Picker("Default start page", selection: $defaultStartPage) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Text(section.rawValue).tag(section)
@@ -113,9 +119,11 @@ struct SettingsView: View {
}
Section("Process data") {
LabeledContent("Refresh interval", value: intervalLabel)
LabeledContent("Live-view interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table")
LabeledContent("Memory units", value: "Automatic")
Text("Process and service scans run only for visible consumers or recording. Power-source and volume changes use system notifications. With no live consumers, scheduled sampling stops.")
.font(.caption).foregroundStyle(.secondary)
}
Section("Menu bar") {
+73
View File
@@ -0,0 +1,73 @@
import Foundation
import IOKit
/// Read typed registry values; `ioreg`'s human-readable indentation is not an API.
enum BatteryRegistryReader {
static func read() -> BatterySnapshot {
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
guard service != 0 else { return BatterySnapshot() }
defer { IOObjectRelease(service) }
var properties: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS,
let values = properties?.takeRetainedValue() as? [String: Any] else { return BatterySnapshot() }
return parse(values)
}
static func parse(_ values: [String: Any]) -> BatterySnapshot {
let batteryData = values["BatteryData"] as? [String: Any] ?? [:]
func number(_ key: String) -> Double { (values[key] as? NSNumber)?.doubleValue ?? 0 }
func capacity(_ top: String, _ nested: String) -> Double {
(values[top] as? NSNumber)?.doubleValue ?? (batteryData[nested] as? NSNumber)?.doubleValue ?? 0
}
func flag(_ key: String) -> Bool { (values[key] as? NSNumber)?.boolValue ?? false }
let telemetry = values["PowerTelemetryData"] as? [String: Any] ?? [:]
func power(_ key: String) -> Double? {
guard let value = telemetry[key] as? NSNumber else { return nil }
let watts = value.doubleValue / 1_000
return watts.isFinite && (0...1_000).contains(watts) ? watts : nil
}
// IORegistry sometimes represents negative current as an unsigned two's-complement integer.
let rawCurrent = (values["Amperage"] as? NSNumber)?.int64Value ?? 0
let amps = Double(rawCurrent) / 1_000
let voltage = number("Voltage") / 1_000
let hasCurrent = values["Amperage"] != nil && abs(amps) <= 100 && voltage > 0 && voltage < 100
let external = flag("ExternalConnected")
let temperature = number("Temperature")
let time = Int(number("TimeRemaining"))
var battery = BatterySnapshot(
isPresent: flag("BatteryInstalled"), chargePercent: min(100, max(0, number("CurrentCapacity"))),
isCharging: flag("IsCharging"), isFullyCharged: flag("FullyCharged"),
externalPowerConnected: external, cycleCount: Int(number("CycleCount")),
designCycleCount: Int(number("DesignCycleCount9C")),
currentCapacityMAh: capacity("AppleRawCurrentCapacity", "RemainingCapacity"),
fullChargeCapacityMAh: capacity("NominalChargeCapacity", "NominalChargeCapacity"),
designCapacityMAh: capacity("DesignCapacity", "DesignCapacity"),
voltageVolts: voltage, currentAmps: hasCurrent ? amps : 0,
temperatureCelsius: temperature > 1_000 ? temperature / 10 - 273.15 : temperature,
timeRemainingMinutes: time > 0 && time < 65_535 ? time : nil,
serial: values["Serial"] as? String ?? "",
systemPowerWatts: power("SystemLoad") ?? 0,
adapterInputWatts: external ? (power("SystemPowerIn") ?? 0) : 0
)
battery.hasBatteryCurrent = hasCurrent
battery.hasBatteryTemperature = values["Temperature"] != nil
battery.hasAdapterInput = external && power("SystemPowerIn") != nil
battery.hasSystemPower = power("SystemLoad") != nil
if external, let adapter = values["AdapterDetails"] as? [String: Any], !adapter.isEmpty {
func n(_ key: String) -> Double { (adapter[key] as? NSNumber)?.doubleValue ?? 0 }
let profiles = (adapter["UsbHvcMenu"] as? [[String: Any]] ?? []).compactMap { entry -> PowerProfile? in
guard let voltage = entry["MaxVoltage"] as? NSNumber,
let current = entry["MaxCurrent"] as? NSNumber else { return nil }
return PowerProfile(voltageVolts: voltage.doubleValue / 1_000, currentAmps: current.doubleValue / 1_000)
}
battery.adapter = PowerAdapterSnapshot(
name: adapter["Name"] as? String ?? "Power adapter",
manufacturer: adapter["Manufacturer"] as? String ?? "Not reported",
serial: adapter["SerialString"] as? String ?? "", ratedWatts: n("Watts"),
negotiatedVoltage: n("AdapterVoltage") / 1_000,
negotiatedCurrent: n("Current") / 1_000, profiles: profiles
)
}
return battery
}
}
+108 -19
View File
@@ -12,17 +12,32 @@ struct PageHeader<Trailing: View>: View {
}
var body: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.title.weight(.semibold))
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
heading.fixedSize(horizontal: true, vertical: false)
Spacer(minLength: 0)
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
}
VStack(alignment: .leading, spacing: 8) {
heading.frame(maxWidth: .infinity, alignment: .leading)
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
}
}
.padding(.horizontal, PuterLayout.pageInset)
.padding(.vertical, PuterLayout.sectionSpacing)
}
private var heading: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.title.weight(.semibold)).lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
.help(subtitle)
}
Spacer()
trailing()
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 14)
}
}
@@ -30,19 +45,93 @@ struct ResourceSummaryBar: View {
let snapshot: SystemSnapshot
var body: some View {
HStack(spacing: 22) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
Spacer()
ViewThatFits(in: .horizontal) {
HStack(spacing: PuterLayout.sectionSpacing) {
cpu
memory
disk
network
}
Grid(alignment: .leading, horizontalSpacing: PuterLayout.sectionSpacing, verticalSpacing: 8) {
GridRow {
cpu
memory
}
GridRow {
disk
network
}
}
}
.font(.callout.weight(.medium))
.padding(.horizontal, 20)
.padding(.vertical, 9)
.background(Color.accentColor.opacity(0.06))
.font(.callout)
.monospacedDigit()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, PuterLayout.pageInset)
.padding(.bottom, PuterLayout.sectionSpacing)
.padding(.top, PuterLayout.controlSpacing)
.overlay(alignment: .bottom) { Divider() }
}
private var cpu: some View {
ResourceSummaryMetric(title: "CPU", symbol: "cpu", value: Formatters.percent(snapshot.cpuPercent), color: .blue, percent: snapshot.cpuPercent)
}
private var memory: some View {
ResourceSummaryMetric(title: "Memory", symbol: "memorychip", value: Formatters.percent(snapshot.memoryPercent), color: .purple, percent: snapshot.memoryPercent)
}
private var disk: some View {
ResourceSummaryMetric(title: "Disk activity", symbol: "internaldrive", value: Formatters.percent(snapshot.diskActivePercent), color: .orange, percent: snapshot.diskActivePercent)
}
private var network: some View {
ResourceSummaryMetric(title: "Network", symbol: "network", value: Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate), color: .teal, percent: nil)
}
}
private struct ResourceSummaryMetric: View {
let title: String
let symbol: String
let value: String
let color: Color
let percent: Double?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Label {
Text(title).foregroundStyle(.secondary)
} icon: {
Image(systemName: symbol).foregroundStyle(color)
}
.font(.caption.weight(.medium))
Text(value).font(.title2.weight(.semibold)).lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
if let percent {
ProgressView(value: min(max(percent, 0), 100), total: 100)
.tint(color)
.accessibilityHidden(true)
} else {
Text("Receive + send").font(.caption2).foregroundStyle(.secondary)
}
}
.frame(minWidth: 112, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
struct ProcessInventoryPlaceholder: View {
let isLoading: Bool
let searchText: String
var body: some View {
if isLoading {
ProgressView("Collecting processes…")
.controlSize(.small)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
ContentUnavailableView("No processes available", systemImage: "list.bullet.rectangle", description: Text("Use Refresh to request a new process snapshot."))
}
}
}
struct UpdateStatus: View {
+102 -134
View File
@@ -9,6 +9,7 @@ struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection?
@State private var searchText = ""
@State private var sectionSearches: [TaskSection: String] = [:]
@State private var showingNewTask = false
@State private var detailSelection: Int32?
@AppStorage("sidebarCompact") private var sidebarCompact = false
@@ -19,23 +20,29 @@ struct ContentView: View {
}
var body: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
HStack(spacing: 0) {
Sidebar(selection: $selection, compact: sidebarCompact)
.navigationSplitViewColumnWidth(
min: sidebarCompact ? 68 : 190,
ideal: sidebarCompact ? 68 : 210,
max: sidebarCompact ? 68 : 260
)
} detail: {
.frame(width: (sidebarCompact ? PuterLayout.compactSidebarWidth : PuterLayout.expandedSidebarWidth) - 16)
.modifier(NavigationGlass())
.padding(8)
detailView
.toolbar(removing: .sidebarToggle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background, in: .rect(cornerRadius: 16))
.clipShape(.rect(cornerRadius: 16))
.padding(.vertical, 8)
.padding(.trailing, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.regularMaterial)
.toolbarBackground(.regularMaterial, for: .windowToolbar)
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask, sidebarCompact: $sidebarCompact,
searchText: $searchText, searchPrompt: searchPrompt)
}
.navigationSplitViewStyle(.balanced)
.toolbar(removing: .sidebarToggle)
.navigationTitle("")
.background(WindowToolbarCleaner())
.onChange(of: selection) { _, section in
searchText = ""
.onChange(of: selection) { previous, section in
sectionSearches[previous ?? .processes] = searchText
searchText = sectionSearches[section ?? .processes] ?? ""
monitor.setActiveSection(section ?? .processes)
}
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
@@ -64,34 +71,26 @@ struct ContentView: View {
switch selection ?? .processes {
case .processes:
ProcessesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
showDetails(for: process)
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance:
PerformanceView()
case .history:
AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
showDetails(for: process)
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup:
StartupAppsView()
case .users:
UsersView { process in
detailSelection = process.pid
selection = .details
showDetails(for: process)
}
case .details:
DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services:
ServicesView(searchText: searchText) { process in
detailSelection = process.pid
selection = .details
showDetails(for: process)
}
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .energy:
EnergyView()
case .diagnostics:
@@ -102,132 +101,99 @@ struct ContentView: View {
SettingsView()
}
}
.toolbar {
TaskToolbarContent(showingNewTask: $showingNewTask)
}
private var searchPrompt: String? {
switch selection ?? .processes {
case .processes: "Search processes"
case .history: "Search app history"
case .details: "Search details"
case .services: "Search services"
default: nil
}
}
}
private struct WindowToolbarCleaner: NSViewRepresentable {
func makeNSView(context: Context) -> CleanerView {
CleanerView()
private func showDetails(for process: ProcessRecord) {
sectionSearches[.details] = ""
detailSelection = process.pid
selection = .details
}
func updateNSView(_ nsView: CleanerView, context: Context) {
nsView.removeAutomaticSidebarToggle()
nsView.installCenteredBrand()
}
final class CleanerView: NSView {
private weak var installedTitlebar: NSView?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeAutomaticSidebarToggle()
installCenteredBrand()
DispatchQueue.main.async { [weak self] in
self?.removeAutomaticSidebarToggle()
self?.installCenteredBrand()
}
}
func installCenteredBrand() {
guard installedTitlebar == nil,
let closeButton = window?.standardWindowButton(.closeButton),
let titlebar = closeButton.superview else { return }
let brand = PassthroughHostingView(rootView: PuterBrand(compactTitlebar: true))
brand.translatesAutoresizingMaskIntoConstraints = false
titlebar.addSubview(brand)
NSLayoutConstraint.activate([
brand.centerXAnchor.constraint(equalTo: titlebar.centerXAnchor),
brand.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor)
])
installedTitlebar = titlebar
}
func removeAutomaticSidebarToggle() {
guard let toolbar = window?.toolbar,
let index = toolbar.items.firstIndex(where: {
$0.itemIdentifier == .toggleSidebar
|| $0.action == NSSelectorFromString("toggleSidebar:")
|| $0.label == "Hide Sidebar"
|| $0.label == "Show Sidebar"
|| $0.toolTip == "Hide Sidebar"
|| $0.toolTip == "Show Sidebar"
|| $0.view?.accessibilityLabel() == "Hide Sidebar"
|| $0.view?.accessibilityLabel() == "Show Sidebar"
}) else { return }
toolbar.removeItem(at: index)
}
}
}
private final class PassthroughHostingView<Content: View>: NSHostingView<Content> {
override func hitTest(_ point: NSPoint) -> NSView? { nil }
}
private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool
@AppStorage("sidebarCompact") private var sidebarCompact = false
@Binding var sidebarCompact: Bool
@Binding var searchText: String
let searchPrompt: String?
var body: some ToolbarContent {
@Bindable var monitor = monitor
ToolbarItem(placement: .navigation) {
Button {
sidebarCompact.toggle()
} label: {
Image(systemName: "sidebar.left")
GlassActions {
Button {
sidebarCompact.toggle()
} label: {
Image(systemName: "sidebar.left")
.frame(width: 20, height: 20)
}
.buttonStyle(ToolbarIconButtonStyle())
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
}
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
showingNewTask = true
} label: {
Label("Run new task", systemImage: "plus.square")
}
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
ToolbarItem(placement: .principal) {
PuterBrand()
}
}
}
private struct PuterBrand: View {
let compactTitlebar: Bool
var body: some View {
HStack(spacing: 9) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.scaledToFit()
.frame(width: compactTitlebar ? 21 : 30, height: compactTitlebar ? 21 : 30)
Text("puter")
.font(compactTitlebar ? .headline : .title2.weight(.semibold))
if #available(macOS 26, *) {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
.padding(.horizontal, compactTitlebar ? 8 : 14)
.frame(height: compactTitlebar ? 32 : 52)
.fixedSize()
.accessibilityElement(children: .combine)
.accessibilityLabel("puter")
.accessibilityAddTraits(.isHeader)
ToolbarItem(placement: .primaryAction) {
GlassActions {
HStack(spacing: 8) {
Button {
showingNewTask = true
} label: {
Label("Run new task", systemImage: "plus.square")
.frame(width: 20, height: 20)
}
.accessibilityLabel("Run new task")
.buttonStyle(ToolbarIconButtonStyle())
.help("Launch an application or executable")
Button {
monitor.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.frame(width: 20, height: 20)
}
.accessibilityLabel("Refresh")
.buttonStyle(ToolbarIconButtonStyle())
.help("Refresh now (⌘R)")
Menu {
UpdateSpeedPicker(selection: $monitor.updateSpeed)
} label: {
Label("Update speed", systemImage: "ellipsis")
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.horizontal, 12)
.frame(height: PuterLayout.toolbarControlHeight)
.modifier(SearchGlassSurface())
if let searchPrompt {
ToolbarSearchField(text: $searchText, prompt: searchPrompt)
}
}
.labelStyle(.iconOnly)
.buttonBorderShape(.circle)
}
}
}
}
@@ -247,6 +213,7 @@ private struct Sidebar: View {
}
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
.scrollIndicators(compact ? .hidden : .automatic)
.accessibilityLabel("Navigation")
}
@@ -261,11 +228,12 @@ private struct Sidebar: View {
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, minHeight: compact ? 28 : nil, alignment: compact ? .center : .leading)
.frame(maxWidth: .infinity, alignment: compact ? .center : .leading)
.frame(height: PuterLayout.sidebarRowHeight)
.contentShape(Rectangle())
.tag(section)
.help(section.rawValue)
.accessibilityLabel(section.rawValue)
.listRowInsets(compact ? EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) : nil)
.listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
}
}
+11 -8
View File
@@ -65,7 +65,7 @@ struct HardwareView: 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("System power", value: watts(battery.systemPowerWatts), detail: battery.powerSourceDescription, icon: "bolt.fill", color: .orange)
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
}
}
@@ -102,19 +102,19 @@ struct HardwareView: View {
HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent))
Spacer()
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
metric("Capacity status", battery.designCapacityMAh > 0 ? (battery.healthPercent > 80 ? "Normal capacity" : "Reduced capacity") : "Not reported")
Spacer()
metric("Health", Formatters.percent(battery.healthPercent))
metric("Health", battery.designCapacityMAh > 0 ? Formatters.percent(battery.healthPercent) : "Not reported")
}
HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText)
Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
metric("Temperature", battery.hasBatteryTemperature ? String(format: "%.1f °C", battery.temperatureCelsius) : "Not reported")
Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
}
HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts))
metric(battery.batteryFlowTitle, battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "Not reported")
Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer()
@@ -145,14 +145,17 @@ struct HardwareView: View {
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
Text("Reported capability").font(.caption).foregroundStyle(.secondary)
}
}
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("Adapter input", watts(battery.adapterInputWatts))
metric("Charger → Mac", battery.hasAdapterInput ? String(format: "%.1f W", battery.adapterInputWatts) : "Not reported")
metric("System draw", watts(battery.systemPowerWatts))
Spacer()
}
@@ -465,7 +468,7 @@ struct HardwareView: View {
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 "macOS reports \(Int(adapter.ratedWatts.rounded())) W available and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Measured charger input is \(battery.hasAdapterInput ? watts(battery.adapterInputWatts) : "not reported"); system load is \(watts(battery.systemPowerWatts)). Input includes charging and is not a wall-socket measurement."
}
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
}
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
/// NSPopover owns the outer material and corner geometry. Adding a second
/// glass rectangle creates mismatched corner arcs inside the native shell.
struct MenuBarGlassSurface: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background)
} else {
content
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import SwiftUI
struct MenuBarMonitorActions: View {
@Environment(SystemMonitor.self) private var monitor
@AppStorage("menuBarMetric") private var metric: MenuBarMetric = .cpu
var body: some View {
@Bindable var monitor = monitor
Picker("Menu bar reading", selection: $metric) {
ForEach(MenuBarMetric.allCases) { Text($0.rawValue).tag($0) }
}.pickerStyle(.menu)
Picker("Update speed", selection: $monitor.updateSpeed) {
ForEach(UpdateSpeed.allCases) { Text($0.rawValue).tag($0) }
}.pickerStyle(.menu)
Divider()
Button(monitor.isPaused ? "Resume updates" : "Pause updates") { monitor.isPaused.toggle() }
Button("Refresh now", systemImage: "arrow.clockwise", action: monitor.refresh)
}
}
+38 -73
View File
@@ -2,95 +2,60 @@ import AppKit
import SwiftUI
struct MenuBarMonitorView: View {
var openMain: (() -> Void)? = nil
@Environment(SystemMonitor.self) private var monitor
@Environment(\.openWindow) private var openWindow
private var topProcesses: [ProcessRecord] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
}
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 2) {
Text("puter").font(.headline)
if let updated = monitor.lastUpdated {
Text("Updated \(updated, style: .relative)").font(.caption).foregroundStyle(.secondary)
} else { Text("Collecting…").font(.caption).foregroundStyle(.secondary) }
}
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
Text("puter").font(.system(size: 23, weight: .bold, design: .rounded))
Spacer()
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 8, height: 8)
Label(monitor.isPaused ? "Paused" : "Live", systemImage: monitor.isPaused ? "pause.circle" : "waveform.path")
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
Menu { MenuBarMonitorActions() } label: {
Image(systemName: "ellipsis").accessibilityLabel("Monitor options")
}
.modifier(GlassMenuSurface())
}
HStack(spacing: 24) {
MenuBarResourceValue(title: "CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), symbol: "cpu", color: .blue)
MenuBarResourceValue(title: "Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), symbol: "memorychip", color: .purple)
}
.padding(16)
.background(.background.opacity(0.65), in: .rect(cornerRadius: 16))
HStack(spacing: 8) {
metric("CPU", Formatters.percent(monitor.snapshot.cpuPercent), .blue)
metric("Memory", Formatters.percent(monitor.snapshot.memoryPercent), pressureColor)
}
HStack(spacing: 8) {
metric("Network", Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate), .teal)
metric("Power", powerText, .orange)
}
MenuBarPowerView(battery: monitor.hardware.battery, capturedAt: monitor.hardware.capturedAt, paused: monitor.isPaused)
HStack {
Label("Memory pressure", systemImage: "memorychip")
Label("Network", systemImage: "network")
Spacer()
Text(monitor.snapshot.memoryPressure.rawValue).foregroundStyle(pressureColor)
Text(Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)).monospacedDigit()
}
.font(.caption.weight(.medium))
.font(.callout).foregroundStyle(.secondary)
Divider()
Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
if topProcesses.isEmpty {
Text("No process data yet").font(.caption).foregroundStyle(.secondary)
} else {
ForEach(topProcesses) { process in
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath, size: 18)
Text(process.displayName).font(.caption).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
}
GlassActions {
HStack(spacing: 8) {
Button(monitor.isPaused ? "Resume" : "Pause", systemImage: monitor.isPaused ? "play" : "pause") {
monitor.isPaused.toggle()
}.labelStyle(.iconOnly)
Button("Refresh", systemImage: "arrow.clockwise", action: monitor.refresh).labelStyle(.iconOnly)
Spacer()
Button("Open puter", systemImage: "arrow.up.right", action: showMainWindow)
}
}
Divider()
HStack {
Button(monitor.isPaused ? "Resume" : "Pause") { monitor.isPaused.toggle() }
Button("Refresh") { monitor.refresh() }
Spacer()
Button("Open puter") {
openWindow(id: "main")
NSApp.activate()
}
.buttonStyle(.borderedProminent)
}
.controlSize(.small)
}
.padding(14)
.frame(width: 320)
.padding(20)
.frame(width: 368)
.modifier(MenuBarGlassSurface())
.preferredColorScheme(appearance.colorScheme)
.contextMenu { MenuBarMonitorActions() }
}
private func metric(_ title: String, _ value: String, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(9)
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8))
}
private var pressureColor: Color {
switch monitor.snapshot.memoryPressure {
case .normal: .green
case .warning: .orange
case .critical: .red
}
}
private var powerText: String {
let watts = monitor.hardware.battery.systemPowerWatts
return watts > 0 ? String(format: "%.1f W", watts) : "Not reported"
private func showMainWindow() {
if let openMain { openMain(); return }
openWindow(id: "main")
NSApp.activate()
}
}
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct MenuBarPowerView: View {
let battery: BatterySnapshot
let capturedAt: Date
let paused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Power flow").font(.headline)
Spacer()
if battery.isPresent {
Label("\(Int(battery.chargePercent))%", systemImage: battery.isCharging ? "battery.100percent.bolt" : "battery.75percent")
.font(.callout.weight(.medium))
}
}
Text(battery.powerSourceDescription).font(.caption).foregroundStyle(.secondary)
PowerReadingRow(title: "System load", symbol: "desktopcomputer",
watts: battery.hasSystemPower ? battery.systemPowerWatts : nil,
detail: "Power used by the Mac, excluding battery charging.")
if battery.isPresent {
PowerReadingRow(title: battery.batteryFlowTitle,
symbol: battery.currentAmps < 0 ? "arrow.up.right" : "arrow.down.left",
watts: battery.measuredBatteryWatts,
detail: "Battery voltage × signed current; charging adds energy, discharging supplies the Mac.")
}
if battery.externalPowerConnected {
PowerReadingRow(title: "Charger → Mac", symbol: "powerplug",
watts: battery.hasAdapterInput ? battery.adapterInputWatts : nil,
detail: "Measured input at the Mac, including charging. Not wall-socket consumption or the charger's rated maximum.")
}
if let adapter = battery.adapter {
Divider()
Text(adapter.name).font(.callout.weight(.medium)).fixedSize(horizontal: false, vertical: true)
Text(String(format: "%.0f W available · %.1f V × %.2f A limit", adapter.ratedWatts, adapter.negotiatedVoltage, adapter.negotiatedCurrent))
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
}
Text(paused ? "Readings paused" : (capturedAt == .distantPast ? "Reading power…" : "Power sampled \(capturedAt.formatted(date: .omitted, time: .standard))"))
.font(.caption2).foregroundStyle(.secondary)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import SwiftUI
struct MenuBarResourceValue: View {
let title: String
let value: String
let symbol: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: symbol).font(.caption.weight(.medium)).foregroundStyle(color)
Text(value).font(.system(size: 28, weight: .medium, design: .rounded)).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
+26
View File
@@ -81,6 +81,27 @@ struct BatterySnapshot: Hashable, Sendable {
var systemPowerWatts = 0.0
var sensorBatteryPowerWatts = 0.0
var adapterInputWatts = 0.0
var hasBatteryCurrent = false
var hasBatteryTemperature = false
var hasAdapterInput = false
var hasSystemPower = false
var batteryFlowTitle: String {
if !isPresent { return "Battery unavailable" }
if currentAmps < 0 { return "Battery discharge" }
if isCharging || currentAmps > 0 { return "Battery charging" }
return "Battery idle"
}
var powerSourceDescription: String {
!isPresent ? "External power" : (externalPowerConnected ? "Charger connected" : "On battery")
}
var measuredBatteryWatts: Double? {
guard isPresent else { return nil }
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
return sensorBatteryPowerWatts > 0 ? sensorBatteryPowerWatts : nil
}
var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 }
@@ -88,6 +109,7 @@ struct BatterySnapshot: Hashable, Sendable {
}
var batteryPowerWatts: Double {
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0
@@ -256,7 +278,10 @@ enum MenuBarMetric: String, CaseIterable, Identifiable {
case memory = "Memory"
case network = "Network"
case power = "Power"
case batteryPower = "Battery power"
case chargerInput = "Charger input"
var id: String { rawValue }
var requiresPower: Bool { [.power, .batteryPower, .chargerInput].contains(self) }
}
struct ProcessRecord: Identifiable, Hashable, Sendable {
@@ -505,6 +530,7 @@ enum Formatters {
static let bytes: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.countStyle = .memory
formatter.allowsNonnumericFormatting = false
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
return formatter
}()
+40 -37
View File
@@ -65,7 +65,7 @@ struct PerformanceView: View {
}
HStack(spacing: 0) {
ScrollView {
VStack(spacing: 10) {
VStack(spacing: 4) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal)))\(monitor.snapshot.memoryPressure.rawValue) pressure")
metricCard(
@@ -89,10 +89,10 @@ struct PerformanceView: View {
detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))"
)
}
.padding(14)
.padding(10)
}
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
.frame(minWidth: 220, idealWidth: 245, maxWidth: 280)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.25))
Divider()
VStack(spacing: 0) {
detail
@@ -184,56 +184,59 @@ struct PerformanceView: View {
selectedVolumeID = nil
selectedMetric = metric
} label: {
HStack(spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: metric.icon)
.font(.title3)
.font(.body)
.foregroundStyle(metric.color)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(metric.rawValue).font(.headline)
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
.frame(width: 22)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(metric.rawValue).font(.headline)
Spacer(minLength: 0)
Text(valueText ?? Formatters.percent(value))
.font(.callout.monospacedDigit())
}
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
}
.padding(12)
.padding(.horizontal, 10)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
)
}
.clipShape(RoundedRectangle(cornerRadius: 9))
.background(selectedVolumeID == nil && selectedMetric == metric ? Color.accentColor.opacity(0.13) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
}
private func volumeCard(_ volume: VolumeSnapshot) -> some View {
Button { selectedVolumeID = volume.id } label: {
HStack(spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
.font(.title3)
.font(.body)
.foregroundStyle(.orange)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(volume.name).font(.headline).lineLimit(1)
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
.frame(width: 22)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(volume.name).font(.headline).lineLimit(1)
Spacer(minLength: 0)
Text(Formatters.percent(volume.usedPercent))
.font(.callout.monospacedDigit())
}
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity)))\(volume.fileSystem)")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
}
.padding(12)
.padding(.horizontal, 10)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 9)
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
}
.clipShape(RoundedRectangle(cornerRadius: 9))
.background(selectedVolumeID == volume.id ? Color.accentColor.opacity(0.13) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
.accessibilityLabel("\(volume.name), removable disk")
+33
View File
@@ -0,0 +1,33 @@
import AppKit
import SwiftUI
struct PowerReadingRow: View {
let title: String
let symbol: String
let watts: Double?
let detail: String
private var value: String { watts.map { String(format: "%.1f W", $0) } ?? "Not reported" }
var body: some View {
HStack(spacing: 12) {
Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 20)
Text(title).font(.callout)
Spacer(minLength: 8)
Text(value).font(.callout.weight(.semibold)).monospacedDigit()
}
.padding(.vertical, 4)
.contentShape(.rect)
.help(detail)
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
.accessibilityValue(value)
.accessibilityHint(detail)
.contextMenu {
Button("Copy \(title)", systemImage: "doc.on.doc") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(title): \(value)", forType: .string)
}
Text(detail)
}
}
}
+50 -23
View File
@@ -70,7 +70,7 @@ private enum ProcessColumn: String, CaseIterable, Codable {
}
}
private enum ProcessCategory: String, CaseIterable, Identifiable {
enum ProcessCategory: String, CaseIterable, Identifiable {
case apps = "Apps"
case background = "Background processes"
case system = "macOS processes"
@@ -100,7 +100,7 @@ struct ProcessesView: View {
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var grouped: [ProcessGroup] {
let groups = ProcessGrouping.groups(from: monitor.processes, searchText: searchText)
let groups = ProcessGrouping.filtered(monitor.processGroups, searchText: searchText)
if groupByType {
return groups.sorted {
let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0
@@ -150,7 +150,6 @@ struct ProcessesView: View {
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
.buttonStyle(.bordered)
.help(previewPaneVisible ? "Hide process preview" : "Show process preview")
}
}
@@ -211,6 +210,11 @@ struct ProcessesView: View {
}
private var processList: some View {
let visibleGroups = displayedGroups
return processList(groups: visibleGroups)
}
private func processList(groups visibleGroups: [ProcessGroup]) -> some View {
VStack(spacing: 0) {
ScrollView(.horizontal) {
VStack(spacing: 0) {
@@ -236,7 +240,7 @@ struct ProcessesView: View {
LazyVStack(spacing: 0) {
if groupByType {
ForEach(ProcessCategory.allCases) { category in
let categoryGroups = displayedGroups.filter { $0.category == category }
let categoryGroups = visibleGroups.filter { $0.category == category }
if !categoryGroups.isEmpty {
ProcessCategoryHeader(
category: category,
@@ -258,7 +262,7 @@ struct ProcessesView: View {
}
}
} else {
ForEach(displayedGroups) { group in
ForEach(visibleGroups) { group in
processGroup(group)
}
}
@@ -268,6 +272,11 @@ struct ProcessesView: View {
.frame(width: processTableWidth, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
if visibleGroups.isEmpty {
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
}
}
Divider()
HStack {
UpdateStatus()
@@ -447,7 +456,7 @@ private struct ProcessCategoryHeader: View {
}
}
private struct ProcessGroup: Identifiable {
struct ProcessGroup: Identifiable {
let id: String
let name: String
let appPath: String?
@@ -476,7 +485,11 @@ private struct ProcessGroup: Identifiable {
}
}
private enum ProcessGrouping {
@MainActor
enum ProcessGrouping {
// Cache both hits and misses; executable paths are immutable keys. Bound
// storage so long monitoring sessions don't retain every historical path.
private static var appPaths: [String: String] = [:]
static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] {
let foregroundPIDs = Set(NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
@@ -484,13 +497,22 @@ private enum ProcessGrouping {
let grouped = Dictionary(grouping: processes) { process -> String in
appPath(for: process.executablePath) ?? "pid:\(process.pid)"
}
return grouped.compactMap { key, members in
let groups = grouped.map { key, members in
let path = key.hasPrefix("pid:") ? nil : key
let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
?? members[0].displayName
let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs)
let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category)
guard !searchText.isEmpty else { return group }
return group
}
return filtered(groups, searchText: searchText)
}
static func filtered(_ groups: [ProcessGroup], searchText: String) -> [ProcessGroup] {
guard !searchText.isEmpty else { return groups }
return groups.compactMap { group in
let name = group.name
let members = group.processes
let groupMatches = name.localizedCaseInsensitiveContains(searchText)
let matchingMembers = members.filter {
$0.displayName.localizedCaseInsensitiveContains(searchText)
@@ -499,11 +521,11 @@ private enum ProcessGrouping {
}
guard groupMatches || !matchingMembers.isEmpty else { return nil }
return ProcessGroup(
id: key,
id: group.id,
name: name,
appPath: path,
appPath: group.appPath,
processes: groupMatches ? members : matchingMembers,
category: category
category: group.category
)
}
}
@@ -527,8 +549,12 @@ private enum ProcessGrouping {
}
private static func appPath(for executablePath: String) -> String? {
guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil }
return String(executablePath[..<range.lowerBound]) + ".app"
if let cached = appPaths[executablePath] { return cached.isEmpty ? nil : cached }
let path = executablePath.range(of: ".app/Contents/", options: .caseInsensitive)
.map { String(executablePath[..<$0.lowerBound]) + ".app" }
if appPaths.count >= 4096 { appPaths.removeAll(keepingCapacity: true) }
appPaths[executablePath] = path ?? ""
return path
}
}
@@ -702,7 +728,7 @@ private struct ProcessRow: View {
} else {
Color.clear.frame(width: 16)
}
ProcessIcon(name: process.displayName, path: process.executablePath)
ProcessIcon(name: process.displayName, path: process.executablePath, size: 26)
VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).lineLimit(1).truncationMode(.tail)
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
@@ -728,7 +754,7 @@ private struct ProcessRow: View {
}
.font(.callout)
.padding(.horizontal, 16)
.frame(minHeight: 48)
.frame(minHeight: 42)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
}
@@ -777,17 +803,18 @@ private struct ProcessGroupRow: View {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 16, height: 44)
.frame(width: 16, height: 38)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath)
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath, size: 26)
VStack(alignment: .leading, spacing: 2) {
Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail)
Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
Text(group.name).lineLimit(1).truncationMode(.tail)
Text("\(group.processes.count) processes")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -811,7 +838,7 @@ private struct ProcessGroupRow: View {
}
.font(.callout.weight(.medium))
.padding(.horizontal, 16)
.frame(minHeight: 48)
.frame(minHeight: 42)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine)
@@ -931,11 +958,11 @@ private struct HeatCell: View {
.lineLimit(1)
.minimumScaleFactor(0.72)
.truncationMode(.tail)
.padding(.vertical, 9)
.padding(.vertical, 6)
.padding(.horizontal, processNumericTextInset)
.frame(maxWidth: .infinity, alignment: .trailing)
.clipped()
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
.background(Color.accentColor.opacity(min(0.22, max(0, intensity) * 0.22)))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
+15 -9
View File
@@ -19,21 +19,27 @@ final class PuterAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificatio
struct PuterApp: App {
@NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor()
@State private var statusBar = StatusBarController()
@StateObject private var updates = UpdateController()
@AppStorage("sidebarCompact") private var sidebarCompact = false
@AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false
@AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
@AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
var body: some Scene {
WindowGroup("puter", id: "main") {
ContentView()
.environment(monitor)
.environmentObject(updates)
.preferredColorScheme(appearance.colorScheme)
.frame(minWidth: 820, minHeight: 560)
.task { monitor.start() }
.background(StatusBarBridge(controller: statusBar, monitor: monitor, visible: showMenuBarMonitor, value: menuBarValue))
}
.defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updates.checkForUpdates() }
@@ -45,8 +51,11 @@ struct PuterApp: App {
}
.keyboardShortcut("n", modifiers: .command)
}
SidebarCommands()
CommandMenu("Options") {
Button("Show menu-bar monitor") { statusBar.togglePanel() }
.keyboardShortcut("m", modifiers: [.command, .shift])
.disabled(!showMenuBarMonitor)
Divider()
Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command)
Divider()
@@ -61,14 +70,6 @@ struct PuterApp: App {
}
}
MenuBarExtra(isInserted: $showMenuBarMonitor) {
MenuBarMonitorView()
.environment(monitor)
.environmentObject(updates)
} label: {
Label(menuBarValue, systemImage: "gauge.with.dots.needle.50percent")
}
.menuBarExtraStyle(.window)
}
private var menuBarValue: String {
@@ -79,6 +80,11 @@ struct PuterApp: App {
case .power:
monitor.hardware.battery.systemPowerWatts > 0
? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
case .batteryPower:
monitor.hardware.battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "— W"
case .chargerInput:
monitor.hardware.battery.hasAdapterInput
? String(format: "%.1f W", monitor.hardware.battery.adapterInputWatts) : "— W"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import AppKit
import SwiftUI
/// A quiet wordmark, not a button: the icon supplies the glass depth.
struct PuterBrand: View {
var body: some View {
HStack(spacing: 8) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
.accessibilityHidden(true)
Text("puter")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.tracking(-0.4)
.foregroundStyle(.primary)
}
.padding(.horizontal, 16)
.frame(height: PuterLayout.toolbarControlHeight)
.fixedSize()
.accessibilityElement(children: .ignore)
.accessibilityLabel("puter")
.accessibilityAddTraits(.isHeader)
}
}
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
/// Shared geometry for the window shell and data pages. Colors and controls
/// follow the system appearance, including accessibility contrast settings.
enum PuterLayout {
static let pageInset: CGFloat = 20
static let sectionSpacing: CGFloat = 16
static let controlSpacing: CGFloat = 8
static let compactSidebarWidth: CGFloat = 84
static let expandedSidebarWidth: CGFloat = 210
static let sidebarRowHeight: CGFloat = 28
static let toolbarControlHeight: CGFloat = 36
}
enum PuterAppearance: String, CaseIterable, Identifiable {
case system = "System"
case light = "Light"
case dark = "Dark"
var id: String { rawValue }
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
/// A single navigation surface; data views deliberately do not use glass.
struct NavigationGlass: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background, in: .rect(cornerRadius: 16))
} else if #available(macOS 26, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: 16))
} else {
content.background(.regularMaterial, in: .rect(cornerRadius: 16))
}
}
}
struct GlassActions<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
if #available(macOS 26, *) {
GlassEffectContainer(spacing: 8) {
content()
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
.controlSize(.large)
.menuStyle(.button)
}
} else {
content().buttonStyle(.bordered)
}
}
}
/// Preserve native menu semantics without the legacy pop-up button bezel.
struct GlassMenuSurface: ViewModifier {
@ViewBuilder func body(content: Content) -> some View {
if #available(macOS 26, *) {
content
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.horizontal, 12)
.frame(minHeight: 32)
.glassEffect(.regular.interactive(), in: .capsule)
} else {
content.menuStyle(.button)
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import Foundation
/// Only counters with a live consumer need periodic samples. Inventory changes
/// continue to arrive through workspace and power-source notifications.
struct SamplingDemand: Equatable {
let processInventory: Bool
let hardware: Bool
let services: Bool
let telemetry: Bool
let interval: Duration?
init(active: Bool, section: TaskSection, speed: UpdateSpeed,
recording: Bool, alerts: Bool, menuBar: Bool, powerMenu: Bool, menuPresented: Bool = false) {
processInventory = recording || (active && section.usesLiveProcessInventory)
hardware = recording || menuPresented || (menuBar && powerMenu)
|| (active && [.hardware, .performance, .energy].contains(section))
services = active && section == .services
telemetry = processInventory || alerts || menuPresented || (menuBar && !powerMenu)
if speed == .paused || !(telemetry || hardware || services) {
interval = nil
} else if recording || (active && section.usesLiveProcessInventory) {
interval = speed.interval
} else if services {
interval = .seconds(3)
} else if active || alerts || hardware {
interval = .seconds(5)
} else {
interval = .seconds(10)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import SwiftUI
struct SearchGlassSurface: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ViewBuilder func body(content: Content) -> some View {
if reduceTransparency {
content.background(.background, in: .capsule)
.overlay { Capsule().strokeBorder(.secondary.opacity(0.3)) }
} else if #available(macOS 26, *) {
content.glassEffect(.regular.interactive(), in: .capsule)
} else {
content.background(.regularMaterial, in: .capsule)
.overlay { Capsule().strokeBorder(.secondary.opacity(0.2)) }
}
}
}
+51 -23
View File
@@ -835,7 +835,8 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
switch self {
case .name: 260
case .user: 140
case .architecture, .state: 105
case .architecture: 125
case .state: 105
case .cpuTime, .memory, .disk, .network, .elapsed: 112
case .parentPID: 92
default: 78
@@ -846,6 +847,7 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .name: 170
case .user: 90
case .cpuTime, .memory, .disk, .network, .elapsed: 82
case .architecture: 110
default: 62
}
}
@@ -888,12 +890,16 @@ struct DetailsView: View {
} label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
}
.buttonStyle(.bordered)
Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess
}
.disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") {
Button("Show essential columns") {
visibleColumnStorage = [DetailColumn.name, .pid, .user, .cpu, .memory]
.map(\.rawValue).joined(separator: ",")
}
Divider()
ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column))
.disabled(column == .name || column == .pid)
@@ -905,6 +911,8 @@ struct DetailsView: View {
persistColumnWidths()
}
}
.modifier(GlassMenuSurface())
.fixedSize()
}
}
ResourceSummaryBar(snapshot: monitor.snapshot)
@@ -942,6 +950,11 @@ struct DetailsView: View {
}
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
}
.overlay {
if filtered.isEmpty {
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -964,7 +977,7 @@ struct DetailsView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.weight(.medium))
.font(.callout)
.foregroundStyle(.secondary)
.background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() }
@@ -986,11 +999,6 @@ struct DetailsView: View {
.background(rowBackground(process: process, index: index))
.contentShape(Rectangle())
.onTapGesture { selection = process.pid }
.focusable()
.onKeyPress(.return) {
selection = process.pid
return .handled
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))")
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads")
@@ -1011,10 +1019,17 @@ struct DetailsView: View {
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column {
case .name:
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1).truncationMode(.tail)
Button {
selection = process.pid
} label: {
HStack(spacing: 8) {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).lineLimit(1).truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1)
@@ -1076,25 +1091,38 @@ struct DetailsView: View {
Divider()
HStack(spacing: 10) {
if let process = selectedProcess {
ProcessIcon(name: process.displayName, path: process.executablePath)
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
Text("").foregroundStyle(.tertiary)
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
Spacer()
ProcessIcon(name: process.displayName, path: process.executablePath, size: 24)
VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
Text("PID \(process.pid)")
Text("CPU \(Formatters.percent(process.cpu))")
Text("Memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))")
}
.fixedSize(horizontal: true, vertical: false)
Text("PID \(process.pid)").fixedSize(horizontal: true, vertical: false)
}
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Properties") { inspectedProcess = process }
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer()
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
Text("Select a process to inspect it").foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.font(.caption)
.font(.callout)
.padding(.horizontal, 14)
.frame(minHeight: 48)
.frame(minHeight: 52)
}
.background(.bar)
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
struct StatusBarBridge: NSViewRepresentable {
@Environment(\.openWindow) private var openWindow
let controller: StatusBarController
let monitor: SystemMonitor
let visible: Bool
let value: String
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
controller.configure(monitor: monitor, visible: visible, value: value) { openWindow(id: "main") }
}
}
+150
View File
@@ -0,0 +1,150 @@
import AppKit
import SwiftUI
import Observation
/// AppKit owns status-item click semantics and popover lifetime; SwiftUI owns content.
@MainActor
final class StatusBarController: NSObject, NSPopoverDelegate {
private var item: NSStatusItem?
private let popover = NSPopover()
private weak var monitor: SystemMonitor?
private var openMain: (() -> Void)?
private var observing = false
func configure(monitor: SystemMonitor, visible: Bool, value: String, openMain: @escaping () -> Void) {
self.monitor = monitor
self.openMain = openMain
if !observing {
observing = true
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(menuTrackingBegan), name: NSMenu.didBeginTrackingNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(menuTrackingEnded), name: NSMenu.didEndTrackingNotification, object: nil)
observeReadings()
}
guard visible else {
popover.performClose(nil)
if let item { NSStatusBar.system.removeStatusItem(item) }
item = nil
return
}
if item == nil {
let status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
status.autosaveName = "puter.monitor"
status.button?.target = self
status.button?.action = #selector(clicked)
status.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
status.button?.image = NSImage(systemSymbolName: "gauge.with.dots.needle.50percent", accessibilityDescription: "puter")
status.button?.imagePosition = .imageLeading
item = status
popover.behavior = .transient
popover.animates = false
popover.delegate = self
popover.contentViewController = NSHostingController(rootView:
MenuBarMonitorView(openMain: { [weak self] in self?.openMainWindow() }).environment(monitor))
}
if item?.button?.title != value { item?.button?.title = value }
item?.button?.toolTip = "puter · click for readings, right-click for options"
item?.button?.setAccessibilityLabel("puter monitor, \(value)")
}
@objc private func clicked() {
if NSApp.currentEvent?.type == .rightMouseUp { showOptions() }
else { togglePanel() }
}
func togglePanel() {
guard let button = item?.button else { return }
if popover.isShown { popover.performClose(nil) }
else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
private func showOptions() {
guard let item, let button = item.button, let monitor else { return }
popover.performClose(nil)
let menu = NSMenu()
let metrics = NSMenu()
for metric in MenuBarMetric.allCases {
let entry = NSMenuItem(title: metric.rawValue, action: #selector(selectMetric(_:)), keyEquivalent: "")
entry.target = self
entry.representedObject = metric.rawValue
entry.state = UserDefaults.standard.string(forKey: "menuBarMetric") == metric.rawValue
|| (UserDefaults.standard.string(forKey: "menuBarMetric") == nil && metric == .cpu) ? .on : .off
metrics.addItem(entry)
}
let metricRoot = menu.addItem(withTitle: "Menu bar reading", action: nil, keyEquivalent: "")
metricRoot.submenu = metrics
let speeds = NSMenu()
for speed in UpdateSpeed.allCases {
let entry = NSMenuItem(title: speed.rawValue, action: #selector(selectSpeed(_:)), keyEquivalent: "")
entry.target = self
entry.representedObject = speed.rawValue
entry.state = monitor.updateSpeed == speed ? .on : .off
speeds.addItem(entry)
}
menu.addItem(withTitle: "Update speed", action: nil, keyEquivalent: "").submenu = speeds
menu.addItem(.separator())
let open = menu.addItem(withTitle: "Open puter", action: #selector(openMainWindow), keyEquivalent: "")
open.target = self
item.menu = menu
button.performClick(nil)
item.menu = nil
}
@objc private func selectMetric(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String else { return }
UserDefaults.standard.set(raw, forKey: "menuBarMetric")
}
@objc private func selectSpeed(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String, let speed = UpdateSpeed(rawValue: raw) else { return }
monitor?.updateSpeed = speed
}
@objc private func openMainWindow() {
popover.performClose(nil)
openMain?()
NSApp.activate()
}
func popoverDidShow(_ notification: Notification) { monitor?.menuBarPresented = true }
func popoverDidClose(_ notification: Notification) { monitor?.menuBarPresented = false }
@objc private func menuTrackingBegan() {
// Native submenus extend outside the panel; don't dismiss their parent.
if popover.isShown { popover.behavior = .applicationDefined }
}
@objc private func menuTrackingEnded() {
popover.behavior = .transient
}
@objc private func preferencesChanged() {
guard let monitor, let openMain else { return }
configure(monitor: monitor,
visible: UserDefaults.standard.object(forKey: "showMenuBarMonitor") as? Bool ?? true,
value: reading(monitor), openMain: openMain)
}
private func reading(_ monitor: SystemMonitor) -> String {
switch MenuBarMetric(rawValue: UserDefaults.standard.string(forKey: "menuBarMetric") ?? "") ?? .cpu {
case .cpu: return Formatters.percent(monitor.snapshot.cpuPercent)
case .memory: return Formatters.percent(monitor.snapshot.memoryPercent)
case .network: return Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)
case .power: return monitor.hardware.battery.hasSystemPower ? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
case .batteryPower: return monitor.hardware.battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "— W"
case .chargerInput: return monitor.hardware.battery.hasAdapterInput ? String(format: "%.1f W", monitor.hardware.battery.adapterInputWatts) : "— W"
}
}
private func observeReadings() {
guard let monitor else { return }
withObservationTracking {
// Track both sources, even when the selected metric changes with no main window.
_ = monitor.snapshot
_ = monitor.hardware
let value = reading(monitor)
if item?.button?.title != value { item?.button?.title = value }
} onChange: { [weak self] in
Task { @MainActor in self?.observeReadings() }
}
}
}
+148 -151
View File
@@ -9,6 +9,7 @@ import UserNotifications
@Observable
final class SystemMonitor {
var processes: [ProcessRecord] = []
var processGroups: [ProcessGroup] = []
var services: [ServiceRecord] = []
var appHistory: [AppUsageRecord] = []
var appHistoryStartDate = Date()
@@ -29,18 +30,34 @@ final class SystemMonitor {
var hardware = HardwareSnapshot()
var systemPowerHistory: [MetricSample] = []
var updateSpeed: UpdateSpeed {
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
didSet {
UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed")
rescheduleSampling()
}
}
var lastUpdated: Date?
var errorMessage: String?
var isRecording = false
var isRecording = false { didSet { rescheduleSampling() } }
var menuBarPresented = false {
didSet {
guard menuBarPresented != oldValue else { return }
rescheduleSampling()
if menuBarPresented && !isPaused {
requestTelemetryRefresh()
requestHardwareRefresh(forcePowerRefresh: true)
}
}
}
var recordingStartDate: Date?
var recordingSamples: [TelemetryRecordingSample] = []
var recentAlerts: [ResourceAlertEvent] = []
private var updateTask: Task<Void, Never>?
private var hardwareUpdateTask: Task<Void, Never>?
private var serviceUpdateTask: Task<Void, Never>?
private var started = false
private var scheduledDemand: SamplingDemand?
private var processEventTask: Task<Void, Never>?
private var lastHardwareRequest = Date.distantPast
private var lastServiceRequest = Date.distantPast
private var telemetryRefreshTask: Task<Void, Never>?
private var hardwareRefreshTask: Task<Void, Never>?
private var serviceRefreshTask: Task<Void, Never>?
@@ -55,7 +72,14 @@ final class SystemMonitor {
private var serviceRefreshPending = false
private var eventObservers: [NSObjectProtocol] = []
private var activeSection: TaskSection = .processes
private var appIsActive = true
private var appIsActive: Bool {
let app = NSRunningApplication.current
return !app.isHidden && NSApp.windows.contains {
!($0 is NSPanel) && $0.styleMask.contains(.titled) && $0.isVisible && !$0.isMiniaturized
&& $0.occlusionState.contains(.visible)
}
}
private var lastDiskCapacitySample: Date?
private var memoryPressureLevel = MemoryPressureLevel.normal
private var lastProcessDiagnosticsSample: Date?
private var lastProcessInventorySample: Date?
@@ -97,38 +121,55 @@ final class SystemMonitor {
}
func start() {
guard updateTask == nil else { return }
appIsActive = NSApp.isActive
guard !started else { return }
started = true
installEventObservers()
installMemoryPressureSource()
installPowerSourceObserver()
refresh()
requestTelemetryRefresh(forceInventory: true)
if samplingDemand.hardware { requestHardwareRefresh() }
if samplingDemand.services { requestServiceRefresh() }
rescheduleSampling()
}
private var samplingDemand: SamplingDemand {
let defaults = UserDefaults.standard
return SamplingDemand(active: appIsActive, section: activeSection, speed: updateSpeed,
recording: isRecording, alerts: defaults.bool(forKey: "resourceAlertsEnabled"),
menuBar: defaults.object(forKey: "showMenuBarMonitor") as? Bool ?? true,
powerMenu: MenuBarMetric(rawValue: defaults.string(forKey: "menuBarMetric") ?? "")?.requiresPower ?? false,
menuPresented: menuBarPresented)
}
private func rescheduleSampling() {
guard started else { return }
let demand = samplingDemand
guard demand != scheduledDemand else { return }
scheduledDemand = demand
updateTask?.cancel()
updateTask = nil
guard let interval = demand.interval else { return }
updateTask = Task { [weak self] in
while !Task.isCancelled {
let interval = self?.telemetryInterval ?? .seconds(2)
try? await Task.sleep(for: interval)
guard let self, !self.isPaused else { continue }
self.requestTelemetryRefresh()
}
}
hardwareUpdateTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let seconds = self.appIsActive
? ([.hardware, .performance, .energy].contains(self.activeSection) ? 5 : 30)
: 60
try? await Task.sleep(for: .seconds(seconds))
guard !Task.isCancelled, !self.isPaused else { continue }
self.requestHardwareRefresh()
}
}
serviceUpdateTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let seconds = self.appIsActive ? (self.activeSection == .services ? 3 : 30) : 60
try? await Task.sleep(for: .seconds(seconds))
guard !Task.isCancelled, !self.isPaused else { continue }
self.requestServiceRefresh()
do { try await Task.sleep(for: interval, tolerance: .milliseconds(250)) }
catch { return }
guard !Task.isCancelled, let self else { return }
// Reconcile with live AppKit state before executing captured demand.
// Notifications can coalesce while the app is being hidden.
guard self.samplingDemand == demand else {
self.rescheduleSampling()
return
}
let now = Date()
if demand.telemetry { self.requestTelemetryRefresh() }
if demand.hardware && now.timeIntervalSince(self.lastHardwareRequest) >= 5 {
self.lastHardwareRequest = now
self.requestHardwareRefresh()
}
if demand.services && now.timeIntervalSince(self.lastServiceRequest) >= 3 {
self.lastServiceRequest = now
self.requestServiceRefresh()
}
}
}
}
@@ -136,7 +177,8 @@ final class SystemMonitor {
func refresh() {
processInventoryRefreshRequested = true
volumeInventoryRefreshRequested = true
requestTelemetryRefresh()
lastDiskCapacitySample = nil
requestTelemetryRefresh(forceInventory: true)
requestHardwareRefresh(
forcePortRefresh: true,
forcePowerRefresh: true,
@@ -149,26 +191,18 @@ final class SystemMonitor {
func setActiveSection(_ section: TaskSection) {
guard activeSection != section else { return }
activeSection = section
rescheduleSampling()
guard !isPaused else { return }
if section == .performance { volumeInventoryRefreshRequested = true }
if section.usesLiveProcessInventory {
processInventoryRefreshRequested = true
requestTelemetryRefresh()
}
if section == .performance { volumeInventoryRefreshRequested = true }
if section == .hardware || section == .performance || section == .energy { requestHardwareRefresh() }
if section == .services { requestServiceRefresh() }
}
private var telemetryInterval: Duration {
guard appIsActive else { return .seconds(10) }
switch activeSection {
case .processes, .performance, .history, .users, .details, .energy:
return updateSpeed.interval
case .startup, .services, .hardware, .diagnostics, .settings:
return .seconds(5)
}
}
private func requestTelemetryRefresh() {
private func requestTelemetryRefresh(forceInventory: Bool = false) {
guard telemetryRefreshTask == nil else {
telemetryRefreshPending = true
return
@@ -177,28 +211,33 @@ final class SystemMonitor {
let processInventoryInterval: TimeInterval = activeSection.usesLiveProcessInventory
? 0
: (appIsActive ? 15 : 30)
let includeProcessInventory = processInventoryRefreshRequested
let includeProcessInventory = forceInventory || (samplingDemand.processInventory && (processInventoryRefreshRequested
|| processes.isEmpty
|| lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true
|| lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true))
if includeProcessInventory { processInventoryRefreshRequested = false }
let diagnosticInterval: TimeInterval = activeSection == .details ? 5 : 15
let includeDiagnostics = includeProcessInventory && (lastProcessDiagnosticsSample.map {
let includeDiagnostics = includeProcessInventory && activeSection == .details && (lastProcessDiagnosticsSample.map {
now.timeIntervalSince($0) >= diagnosticInterval
} ?? true)
let deviceMetricsInterval: TimeInterval = activeSection == .performance ? 3 : 20
let includeDeviceMetrics = lastDeviceMetricsSample.map {
let deviceMetricsNeeded = isRecording || UserDefaults.standard.bool(forKey: "resourceAlertsEnabled")
|| (appIsActive && activeSection.usesLiveProcessInventory)
let includeDeviceMetrics = deviceMetricsNeeded && (lastDeviceMetricsSample.map {
Date().timeIntervalSince($0) >= deviceMetricsInterval
} ?? true
} ?? true)
let includeProcessIO: Bool
switch activeSection {
case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
includeProcessIO = includeProcessInventory && appIsActive
includeProcessIO = includeProcessInventory && (appIsActive || isRecording)
case .startup, .services, .hardware, .settings:
includeProcessIO = false
}
let includeVolumeInventory = volumeInventoryRefreshRequested
|| (activeSection == .performance && (lastVolumeInventorySample.map { Date().timeIntervalSince($0) >= 10 } ?? true))
let includeVolumeInventory = volumeInventoryRefreshRequested && appIsActive && activeSection == .performance
if includeVolumeInventory { volumeInventoryRefreshRequested = false }
let includeDiskCapacity = lastDiskCapacitySample == nil || includeVolumeInventory
|| (appIsActive && activeSection == .performance
&& now.timeIntervalSince(lastDiskCapacitySample!) >= 60)
let cachedDiskCapacity = (free: snapshot.diskFree, total: snapshot.diskTotal)
let knownProcesses = includeProcessInventory
? Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
: [:]
@@ -210,15 +249,17 @@ final class SystemMonitor {
includeDeviceMetrics: includeDeviceMetrics,
includeProcessIO: includeProcessIO,
includeVolumeInventory: includeVolumeInventory,
cachedDiskCapacity: includeDiskCapacity ? nil : cachedDiskCapacity,
knownProcesses: knownProcesses
)
}.value
guard let self, !Task.isCancelled else { return }
self.applyTelemetry(result)
if includeDiskCapacity { self.lastDiskCapacitySample = now }
self.telemetryRefreshTask = nil
if self.telemetryRefreshPending {
self.telemetryRefreshPending = false
self.requestTelemetryRefresh()
if !self.isPaused && self.samplingDemand.telemetry { self.requestTelemetryRefresh() }
}
}
}
@@ -238,15 +279,16 @@ final class SystemMonitor {
return
}
let now = Date()
let refreshPorts = forcePortRefresh || lastPortSample.map { Date().timeIntervalSince($0) >= 60 } ?? true
let powerIsVisible = [.hardware, .performance, .energy].contains(activeSection)
|| UserDefaults.standard.string(forKey: "menuBarMetric") == MenuBarMetric.power.rawValue
let detailedHardware = appIsActive && [.hardware, .performance, .energy].contains(activeSection)
let refreshPorts = forcePortRefresh || (detailedHardware && (lastPortSample.map { now.timeIntervalSince($0) >= 60 } ?? true))
let powerIsVisible = menuBarPresented || detailedHardware
|| (MenuBarMetric(rawValue: UserDefaults.standard.string(forKey: "menuBarMetric") ?? "")?.requiresPower ?? false)
let powerInterval: TimeInterval = powerIsVisible ? 5 : 30
let refreshPower = forcePowerRefresh || lastPowerSensorSample.map { now.timeIntervalSince($0) >= powerInterval } ?? true
let fanInterval: TimeInterval = activeSection == .hardware ? 5 : 30
let refreshFans = forceFanRefresh || lastFanSample.map { now.timeIntervalSince($0) >= fanInterval } ?? true
let refreshFans = forceFanRefresh || ((detailedHardware || isRecording) && (lastFanSample.map { now.timeIntervalSince($0) >= fanInterval } ?? true))
let slowInterval: TimeInterval = [.hardware, .energy].contains(activeSection) ? 15 : 60
let refreshSlow = forceSlowRefresh || lastSlowHardwareSample.map { now.timeIntervalSince($0) >= slowInterval } ?? true
let refreshSlow = forceSlowRefresh || (detailedHardware && (lastSlowHardwareSample.map { now.timeIntervalSince($0) >= slowInterval } ?? true))
let existingHardware = hardware
hardwareRefreshTask = Task { [weak self] in
let result = await Task.detached(priority: .utility) {
@@ -323,6 +365,7 @@ final class SystemMonitor {
let activePIDs = Set(currentProcesses.map(\.pid))
processDiagnostics = processDiagnostics.filter { activePIDs.contains($0.key) }
processes = currentProcesses
processGroups = ProcessGrouping.groups(from: currentProcesses, searchText: "")
updateCPUHistory(with: currentProcesses)
updateProcessDiskActivity(result.processIO, current: currentProcesses)
lastProcessInventorySample = Date()
@@ -437,7 +480,16 @@ final class SystemMonitor {
eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
self?.processInventoryRefreshRequested = true
self?.requestTelemetryRefresh()
if self?.samplingDemand.processInventory == true, self?.isPaused == false {
self?.processEventTask?.cancel()
self?.processEventTask = Task { [weak self] in
do { try await Task.sleep(for: .milliseconds(200)) }
catch { return }
guard let self, !self.isPaused, self.samplingDemand.processInventory else { return }
self.requestTelemetryRefresh()
self.processEventTask = nil
}
}
}
})
}
@@ -470,8 +522,8 @@ final class SystemMonitor {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.appIsActive = true
self?.requestTelemetryRefresh()
self?.rescheduleSampling()
if self?.isPaused == false { self?.requestTelemetryRefresh() }
}
})
eventObservers.append(NotificationCenter.default.addObserver(
@@ -479,7 +531,24 @@ final class SystemMonitor {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.appIsActive = false }
Task { @MainActor in
self?.rescheduleSampling()
}
})
for notification in [NSApplication.didHideNotification, NSApplication.didUnhideNotification,
NSWindow.didChangeOcclusionStateNotification,
NSWindow.didMiniaturizeNotification, NSWindow.didDeminiaturizeNotification,
NSWindow.willCloseNotification] {
eventObservers.append(NotificationCenter.default.addObserver(
forName: notification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.rescheduleSampling() }
})
}
eventObservers.append(NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.rescheduleSampling() }
})
eventObservers.append(NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
@@ -750,7 +819,7 @@ final class SystemMonitor {
}
private func sampleAppNetworkIfNeeded() {
guard appNetworkTask == nil,
guard samplingDemand.processInventory, !isPaused, appNetworkTask == nil,
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
lastAppNetworkSample = Date()
appNetworkTask = Task { [weak self] in
@@ -1171,6 +1240,7 @@ private enum ProcessScanner {
includeDeviceMetrics: Bool,
includeProcessIO: Bool,
includeVolumeInventory: Bool,
cachedDiskCapacity: (free: UInt64, total: UInt64)? = nil,
knownProcesses: [Int32: ProcessRecord] = [:]
) -> TelemetryResult {
let records = includeProcessInventory
@@ -1178,7 +1248,7 @@ private enum ProcessScanner {
: []
let totalMemory = ProcessInfo.processInfo.physicalMemory
let memory = memoryCounters(total: totalMemory)
let disk = diskUsage()
let disk = cachedDiskCapacity ?? diskUsage()
var snapshot = SystemSnapshot()
snapshot.cpuPercent = 0
snapshot.memoryUsed = memory.used
@@ -1683,16 +1753,23 @@ private enum HardwareScanner {
refreshFans: Bool,
refreshSlowDiagnostics: Bool
) -> HardwareSnapshot {
var battery = batterySnapshot()
var battery = BatteryRegistryReader.read()
if refreshPowerSensors {
let power = powerSensorSnapshot()
if power.systemWatts > 0 { battery.systemPowerWatts = power.systemWatts }
if !battery.hasSystemPower, power.systemWatts > 0 {
battery.systemPowerWatts = power.systemWatts
battery.hasSystemPower = true
}
battery.sensorBatteryPowerWatts = power.batteryWatts
battery.adapterInputWatts = power.adapterWatts
if battery.externalPowerConnected, !battery.hasAdapterInput, power.adapterWatts > 0 {
battery.adapterInputWatts = power.adapterWatts
battery.hasAdapterInput = true
}
} else {
battery.systemPowerWatts = existing.battery.systemPowerWatts
battery.sensorBatteryPowerWatts = existing.battery.sensorBatteryPowerWatts
battery.adapterInputWatts = existing.battery.adapterInputWatts
if !battery.hasSystemPower {
battery.systemPowerWatts = existing.battery.systemPowerWatts
battery.hasSystemPower = existing.battery.hasSystemPower
}
}
return HardwareSnapshot(
battery: battery,
@@ -1738,86 +1815,6 @@ private enum HardwareScanner {
}.sorted { $0.id < $1.id }
}
private static func batterySnapshot() -> BatterySnapshot {
let output = run("/usr/sbin/ioreg", arguments: ["-r", "-c", "AppleSmartBattery", "-l"])
guard output.contains("AppleSmartBattery") else { return BatterySnapshot() }
func topNumber(_ key: String) -> Double {
Double(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0") ?? 0
}
func topSignedNumber(_ key: String) -> Double {
let raw = output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0"
if let signed = Int64(raw) { return Double(signed) }
if let unsigned = UInt64(raw) { return Double(Int64(bitPattern: unsigned)) }
return 0
}
func topBool(_ key: String) -> Bool {
(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (Yes|No)") ?? "No") == "Yes"
}
func topString(_ key: String) -> String {
output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = \"([^\"]*)\"") ?? ""
}
let adapterLine = output.firstMatch(#"(?m)^ "AdapterDetails" = \{([^\n]+)\}"#) ?? ""
let telemetryLine = output.firstMatch(#"(?m)^ "PowerTelemetryData" = \{([^\n]+)\}"#) ?? ""
let externalPowerConnected = topBool("ExternalConnected")
let parsedAdapter = adapterLine.isEmpty ? nil : adapterSnapshot(from: adapterLine)
let adapter = externalPowerConnected && (parsedAdapter?.ratedWatts ?? 0) > 0 ? parsedAdapter : nil
let rawTime = Int(topNumber("TimeRemaining"))
let rawTemperature = topNumber("Temperature")
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemLoad"=([0-9]+)"#) ?? "0") ?? 0
return BatterySnapshot(
isPresent: topBool("BatteryInstalled"),
chargePercent: topNumber("CurrentCapacity"),
isCharging: topBool("IsCharging"),
isFullyCharged: topBool("FullyCharged"),
externalPowerConnected: externalPowerConnected,
cycleCount: Int(topNumber("CycleCount")),
designCycleCount: Int(topNumber("DesignCycleCount9C")),
currentCapacityMAh: topNumber("AppleRawCurrentCapacity"),
fullChargeCapacityMAh: topNumber("NominalChargeCapacity"),
designCapacityMAh: topNumber("DesignCapacity"),
voltageVolts: topNumber("Voltage") / 1_000,
currentAmps: {
let amps = topSignedNumber("Amperage") / 1_000
return abs(amps) <= 100 ? amps : 0
}(),
temperatureCelsius: temperature,
timeRemainingMinutes: rawTime > 0 && rawTime < 65_535 ? rawTime : nil,
serial: topString("Serial"),
adapter: adapter,
systemPowerWatts: systemPowerMW / 1_000
)
}
private static func adapterSnapshot(from line: String) -> PowerAdapterSnapshot {
func number(_ key: String) -> Double {
Double(line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=([0-9]+)") ?? "0") ?? 0
}
func string(_ key: String) -> String {
line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=\"([^\"]*)\"") ?? ""
}
let regex = try? NSRegularExpression(pattern: #""MaxCurrent"=([0-9]+),"MaxVoltage"=([0-9]+)"#)
let range = NSRange(line.startIndex..<line.endIndex, in: line)
let profiles = (regex?.matches(in: line, range: range) ?? []).compactMap { match -> PowerProfile? in
guard let currentRange = Range(match.range(at: 1), in: line),
let voltageRange = Range(match.range(at: 2), in: line),
let current = Double(line[currentRange]),
let voltage = Double(line[voltageRange]) else { return nil }
return PowerProfile(voltageVolts: voltage / 1_000, currentAmps: current / 1_000)
}
return PowerAdapterSnapshot(
name: string("Name").isEmpty ? "USB-C power adapter" : string("Name"),
manufacturer: string("Manufacturer").isEmpty ? "Not reported" : string("Manufacturer"),
serial: string("SerialString"),
ratedWatts: number("Watts"),
negotiatedVoltage: number("AdapterVoltage") / 1_000,
negotiatedCurrent: number("Current") / 1_000,
profiles: profiles
)
}
private static func thermalState() -> String {
switch ProcessInfo.processInfo.thermalState {
@@ -0,0 +1,12 @@
import SwiftUI
/// Own the outer glass dimensions instead of inheriting bezel padding.
struct ToolbarIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(width: PuterLayout.toolbarControlHeight, height: PuterLayout.toolbarControlHeight)
.contentShape(.circle)
.modifier(SearchGlassSurface())
.opacity(configuration.isPressed ? 0.7 : 1)
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
struct ToolbarSearchField: View {
@Binding var text: String
let prompt: String
@State private var focused = false
var body: some View {
HStack(spacing: 8) {
Button("Find", systemImage: "magnifyingglass") { focused = true }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.keyboardShortcut("f", modifiers: .command)
.help("Find (⌘F)")
ToolbarSearchInput(text: $text, focused: $focused, prompt: prompt)
Button("Clear search", systemImage: "xmark.circle.fill") { text = ""; focused = true }
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.disabled(text.isEmpty)
.opacity(text.isEmpty ? 0 : 1)
.accessibilityHidden(text.isEmpty)
}
.padding(.horizontal, 12)
.frame(width: 256, height: PuterLayout.toolbarControlHeight)
.modifier(SearchGlassSurface())
.overlay {
Capsule().strokeBorder(focused ? Color.accentColor : .clear, lineWidth: 2)
.allowsHitTesting(false)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import AppKit
import SwiftUI
/// Native field-editor focus is reliable inside a hosted macOS toolbar item.
struct ToolbarSearchInput: NSViewRepresentable {
@Binding var text: String
@Binding var focused: Bool
let prompt: String
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSTextField {
let field = NSTextField()
field.isBordered = false
field.drawsBackground = false
field.focusRingType = .none
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.delegate = context.coordinator
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return field
}
func updateNSView(_ field: NSTextField, context: Context) {
context.coordinator.parent = self
if field.stringValue != text { field.stringValue = text }
field.placeholderString = prompt
field.setAccessibilityLabel(prompt)
if focused, field.currentEditor() == nil {
field.window?.makeFirstResponder(field)
} else if !focused, field.currentEditor() != nil {
field.window?.makeFirstResponder(nil)
}
}
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: ToolbarSearchInput
init(_ parent: ToolbarSearchInput) { self.parent = parent }
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
parent.text = field.stringValue
}
func controlTextDidBeginEditing(_ notification: Notification) {
parent.focused = true
}
func controlTextDidEndEditing(_ notification: Notification) {
parent.focused = false
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy command: Selector) -> Bool {
guard command == #selector(NSResponder.cancelOperation(_:)) else { return false }
if parent.text.isEmpty {
parent.focused = false
} else {
parent.text = ""
}
return true
}
}
}