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
+50
View File
@@ -0,0 +1,50 @@
# Puter interface rules
Puter is a compact macOS system utility. Its visual hierarchy is the page title,
live data, and the selected item's details. Keep the user's attention on those.
## Direction
The chosen direction pairs a Liquid Glass navigation layer with a compact,
readable data workspace. The earlier flat utility treatment was too bland;
a full glass dashboard would distract from the dense process table. Glass belongs
to navigation and actions, while resource color and larger values give data hierarchy.
- Use the system font, semantic text styles, and tabular digits for telemetry.
- Use the system accent for selection and actions. Resource/chart colors convey
resource identity; status colors also require a text or symbol label.
- Keep data surfaces opaque and readable. Native toolbar controls receive the
platform's material treatment; the inset sidebar uses regular Liquid Glass.
Related page actions use glass buttons inside a GlassEffectContainer.
Never layer glass on table rows or resource readings.
- Honor Reduce Transparency with an opaque sidebar. Older systems use regular
material and bordered controls. Resource meters show actual percentages,
not decorative or simulated activity.
- Retain macOS 14 support. Newer APIs require macOS availability guards.
- Shared geometry lives in `PuterLayout`: 20-point page inset, 16-point section
spacing, 8-point control spacing. Native control metrics take precedence.
## Shell and interaction
- One native toolbar sits above the content. The sidebar is inset eight points
below it, with one 16-point rounded navigation surface instead of a hard divider.
- The window uses a unified hidden-title toolbar with native traffic lights and
outer corners. A regular-material surround connects the toolbar to the inset,
opaque workspace. Launch, refresh, and update actions share a glass container.
Do not replace native window chrome with a custom draggable header.
- Compact and expanded navigation use the same List, row heights, and ordering.
Width changes are immediate; frequently updated process data is not animated.
- Each page retains its search while switching pages. An explicit "Go to details"
clears that destination's filter so it cannot hide the requested process.
- Window-local selection and search belong to ContentView. Shared telemetry
remains in the observable monitor; do not add a forwarding view-model layer.
- Appearance is System, Light, or Dark. System is the default.
- Resource summaries adapt to the available width; tables keep their user-sized
columns and horizontal scrolling. Search-empty and collecting states are explicit.
## Verification
Check both appearances, compact/expanded navigation, the inspector open/closed,
search-empty states, keyboard navigation, context menus, and sidebar toggles.
Check resizing and motion in the running app. A screenshot verifies the captured
layout, not frame rate. Do not claim animation performance without measuring it.
+53
View File
@@ -0,0 +1,53 @@
# Power reporting
Battery telemetry now uses IORegistryEntryCreateCFProperties on AppleSmartBattery,
not regular expressions over human-readable `ioreg` output. On this macOS build,
the tree-prefix indentation caused the old reader to miss BatteryInstalled and
every top-level battery/adapter field.
Readings have separate meanings:
- System load: PowerTelemetryData.SystemLoad in milliwatts, converted to watts.
- Charger → Mac: SystemPowerIn, only while ExternalConnected. This includes
charging; it is not wall-socket consumption or the charger's rated maximum.
- Battery: voltage × signed current. Positive current charges, negative current
discharges. Two's-complement unsigned current is decoded as signed Int64.
- Reported capability: AdapterDetails.Watts. The charger's model name may say
140 W while the current connection reports only 94 W available.
- Negotiated ceiling: AdapterVoltage × Current, not measured current draw.
Unknown readings are not rendered as zero. Zero battery current remains a valid
idle reading. Disconnected adapters are dropped even if stale registry details
remain. Capacity falls back to nested BatteryData on newer registry layouts.
Missing capacity/temperature no longer implies a dead battery or 0 °C.
Available SMC power readings remain a fallback; fresh registry data takes priority.
Registry keys are hardware/OS-dependent and are not a universal power-meter API.
Values are point-in-time samples; their sum may differ because of sampling times,
conversion losses and sensor coverage. No wall-power or cable e-marker claims.
The dropdown is a native NSPopover anchored to an NSStatusItem. Left-click toggles
the panel, right-click presents native metric/update-speed submenus. Command-Shift-M
opens the same panel from the Options menu. Power rows have contextual copy actions
and explanatory help. The native popover owns its outer material and rounding;
actions use Liquid Glass on macOS 26+. Reduce Transparency uses an opaque content
background. There is no second glass rectangle with competing corner radii.
Popover show/close events join the existing demand scheduler. Opening it requests
power/system samples at five-second cadence, without full process inventory,
port enumeration, fan scanning or slow hardware diagnostics. Closing it drops
that demand. Menu-bar readings remain observed even with the main window closed.
Tests cover charging, discharging, two's-complement current, zero vs unknown,
invalid values, nested capacities and dropdown-only sampling demand.
Validation on 22 September 2026: 38 tests passed and Release built successfully.
Computer use verified the panel's live battery/charger values, removed duplicate
corner arcs, and opened the native reading submenu with Battery power and Charger
input choices. The submenu originally dismissed its parent; native menu-tracking
notifications now suspend transient popover dismissal while menus are active.
Actual right-click status-button behavior and all appearance/accessibility modes
still need broader manual coverage; automated tests do not establish those.
Native API reference:
https://developer.apple.com/documentation/iokit/1514310-ioregistryentrycreatecfpropertie
+132
View File
@@ -0,0 +1,132 @@
# Release profiling — 22 September 2026
## Setup
Apple M4 Pro, 48 GiB RAM, macOS 27.0 (26A428), Xcode 27.
Optimized SwiftPM Release build of the current uncommitted working tree.
Isolated ad-hoc signed app: `/private/tmp/puter-profile-release.app`.
Processes page, default Normal cadence, grouped categories, about 650690 processes.
Installed application was not replaced. No application source was changed during profiling.
## Measurements
Each counter measurement lasted 20 seconds, without Instruments attached.
CPU is a percentage of **one core**, unlike puter's machine-normalized CPU column.
Counters came from `proc_pid_rusage` v4, converting CPU Mach ticks with
`mach_timebase_info`; memory is physical footprint, not resident size.
| State | CPU (one core) | Interrupt wake-ups / 20 s | Physical footprint, start → end |
| --- | ---: | ---: | ---: |
| Processes visible, updating | 9.032% | 35 | 66.55 → 65.72 MiB |
| After minimize action (activity not independently verified) | 8.992% | 52 | 71.64 → 66.84 MiB |
| Hidden/inactive, menu-bar consumer enabled | 8.929% | 58 | 70.09 → 69.81 MiB |
| Paused | 0.014% | 3 | 69.83 → 69.36 MiB |
For the hidden measurement, NSRunningApplication reported `isActive=false` and
`isHidden=true`; those flags were checked again after its separate trace.
The 3-second `sample` capture reported 81.6 MiB footprint, 95.4 MiB lifetime peak.
These short observations do not establish a memory leak or long-term plateau.
## Trace-backed findings
Two separate 15-second Time Profiler traces were captured. Percentages below are
inclusive sampled CPU weights; nested paths overlap and must not be added.
1. **Repeated process grouping dominates the UI work.** In the visible trace,
85.9% of sampled CPU weight belonged to the main thread; 42.0% passed through
`ProcessesView.grouped`, and 29.6% through `ProcessGrouping.appPath(for:)`.
`displayedGroups` invokes this computed property from category filtering,
table rendering and empty-state checks. Each computation enumerates running
applications, groups processes, parses app paths, and sorts the result.
Next fix: prepare one stable grouped/sorted snapshot per relevant input change,
cache executable-path → application identity, and reuse category partitions.
2. **Background demand is not behaving as intended at runtime.** The hidden trace
still includes full `nativeProcessRecords` scans (5.1% inclusive), grouping
(38.6%) and SwiftUI graph transactions (58.0%). CPU did not materially fall when
hidden. Pure SamplingDemand unit tests therefore do not validate lifecycle
integration. The precise reason for the stale demand is not established.
Next fix: instrument scheduler demand transitions and connect them to actual
window visibility/occlusion and application activity; add integration tests.
3. **Disk-capacity lookup remains in every telemetry capture.** `diskUsage()` is
unconditional even on the lightweight path. It accounts for 8.4% of visible
trace weight and 14.4% of hidden trace weight. It requests available capacity
for important usage, which is not a high-frequency activity counter.
Next fix: cache capacity and refresh it on a slower, disk-view-specific cadence.
4. **Pause provides a genuinely quiet baseline.** It reduced measured CPU to
0.014% and produced no process-attributed disk I/O during the sample. This makes
always-on rendering alone unlikely to explain the updating-state cost.
## Evidence
- Visible trace: `/private/tmp/puter-foreground-release.trace`
- Hidden trace: `/private/tmp/puter-background-release.trace`
- Exported tables: `/private/tmp/puter-time-profile.xml`, `/private/tmp/puter-background-profile.xml`
- Stack sample: `/private/tmp/puter-release-sample.txt`
- Counter helper: `/private/tmp/puter-profile-counters.c`
- Trace aggregation: `/private/tmp/analyze-puter-trace.py`
Traces are local only and may contain process names, paths and environment metadata.
They were not uploaded. Temporary artifacts may be removed by macOS.
## Limits and next validation
This was a short live-machine study, not a before/after comparison against the old
poller. Other applications and an earlier preview were running; system memory
utilization was high. No GPU energy, scroll hitch rate or frame-time claim is made.
Instrumented samples are used for attribution, not as the low-overhead CPU metric.
Fix lifecycle integration first, then repeated grouping and capacity lookup.
Repeat these exact 20-second scenarios and add a SwiftUI scrolling trace before
calling the performance work complete.
## Fix pass — 22 September 2026
Implemented window-visibility-driven demand with notification-driven rescheduling
and live-state reconciliation before a scheduled sample. Visible unfocused windows
remain live; hidden/minimized/occluded windows do not request process inventories.
Recording and menu-bar consumers retain their separate demand policies.
Initial load and explicit Refresh may request a single inventory.
Moved process grouping to inventory application, added a bounded app-path cache
(including negative results), and shared each table projection across category
rendering and empty-state checks. Cached disk capacity separately from activity
counters, refreshing on initial/manual requests, volume enumeration and every
60 seconds on visible Performance.
Release preview: `/private/tmp/puter-profile-fixed2.app`, PID 31379.
32 tests passed, including new grouped-search, identity, totals and path-cache
regressions. `git diff --check` passed. Installed app was not replaced.
| Scenario, 20 seconds | Before, one-core CPU | After, one-core CPU |
| --- | ---: | ---: |
| Processes visible, updating | 9.032% | 3.541% |
| Hidden, menu-bar consumer enabled | 8.929% | 0.914% |
Visible wake-ups: 34; footprint 73.41 → 75.24 MiB. Hidden wake-ups: 19;
footprint 141.45 → 118.56 MiB, after visiting Hardware. Hidden state was checked
before and after the valid run (`active=false`, `hidden=true`). An earlier hidden
attempt was discarded because the preview was externally changed to Hardware
and unhidden mid-measurement. No memory-improvement claim is made.
Visible 15-second trace: 963 ms sampled CPU versus the earlier 2114 ms.
`ProcessesView.grouped` fell from 42.0% to 2.8% of sampled weight;
`ProcessGrouping.groups` was 6.5%, and `appPath` about 0.1% (previously 29.6%).
No `diskUsage` stack was sampled. Percentages are inclusive, overlapping weights.
Trace: `/private/tmp/puter-fixed-visible.trace`; export:
`/private/tmp/puter-fixed-visible.xml`.
The separate hidden trace sampled 300 ms CPU in 15 seconds (previously 1444 ms).
It contained no sampled process-inventory, process-group construction or disk
capacity stacks. The remaining sampled work is mostly SwiftUI/AppKit updates;
menu-bar system counters intentionally remain enabled. Hidden state was again
verified after capture. Evidence: `/private/tmp/puter-fixed-hidden.trace` and
`/private/tmp/puter-fixed-hidden.xml`.
These are short live-machine observations, not a controlled benchmark. Process
counts were around 700, system memory pressure differed from the first pass,
and a test build ran during the visible counter sample. The savings are supported
by the changed call stacks, but precise percentages will vary with workload.
Scrolling/frame-time and long-term energy measurements remain separate work.
+16
View File
@@ -0,0 +1,16 @@
# puter 1.2.0
- Refreshed app shell, Liquid Glass controls, aligned toolbar actions and search.
- New geometric loop-p Icon Composer icon and updated header branding.
- Demand-aware sampling and process-group caching to reduce unnecessary monitoring work.
- Improved battery power reporting, distinguishing measured power from charger capability.
- Updated menu-bar monitor with resource summaries and power details.
- Expanded battery, sampling-demand and process-group regression coverage.
## Distribution
Apple Silicon build for macOS 14 or later. Liquid Glass styling requires macOS 26 or later.
The DMG and app ZIP are Developer ID signed and Apple notarized. Install manually from this release; Sparkle automatic updates are not configured in this build.
Validation: 38 tests pass. Package validation, notarization and Gatekeeper checks are performed before publication. Live UI checks were performed on the development preview; a fresh packaged UI smoke test was blocked by repeated computer-use service crashes.
+42
View File
@@ -0,0 +1,42 @@
# Sampling and events
One cancellable scheduler follows `SamplingDemand`. No timer is scheduled while
paused, or when the app has no visible window, menu-bar, alert, or recording consumer.
Settings and startup pages do not request process inventory. Services are sampled
only while their page is active. Background CPU/memory/network menu-bar readings
use lightweight system counters, not process enumeration or per-app nettop.
CPU time, I/O rates, fan speeds and service state still require samples: public
macOS notifications are not a replacement for continuously changing counters.
Recording deliberately retains system/process sampling in the background.
App-history collection is opportunistic rather than an always-running daemon.
Existing workspace notifications invalidate app and volume inventory; app-event
bursts are coalesced over 200ms. Volume enumeration no longer runs every ten
seconds. IOKit power-source, memory-pressure and thermal notifications remain.
USB/port inventory still reconciles while hardware is being consumed, because
the current implementation does not have an IOKit device-matching observer.
Validation: eight SamplingDemand tests cover idle, pause, services, menu-bar,
recording and alerts. The native sampler budget test passed: full process sample
averaged 65.5ms versus 7.5ms for system-only telemetry on this machine. These are
scan-cost measurements, not before/after whole-app CPU or energy measurements.
## Profiling-driven corrections
Demand now reads current application hidden state and the visibility, minimized
state and occlusion of titled windows. A visible unfocused window remains live.
Hide/unhide and window occlusion/minimize notifications reschedule the existing
task; each scheduled wake also reconciles live demand before doing any work.
There is no added visibility polling timer. Startup and explicit Refresh can
request one inventory even without a visible consumer.
Process groups are prepared once per inventory result, outside view evaluation.
A bounded executable-path cache includes misses. Table rendering shares one
sorted/filtered projection among category rows and the empty-state check; search
still preserves matching-member behavior and group identity.
Disk capacity is cached independently of disk activity: initial/manual refresh,
volume enumeration, and at most once per minute on visible Performance. Normal
telemetry samples reuse the cached capacity instead of querying the filesystem.
See PROFILING.md for Release measurements and caveats.
+9 -1
View File
@@ -76,6 +76,7 @@ struct SettingsView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@EnvironmentObject private var updates: UpdateController @EnvironmentObject private var updates: UpdateController
@AppStorage("alwaysOnTop") private var alwaysOnTop = false @AppStorage("alwaysOnTop") private var alwaysOnTop = false
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes @AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5 @AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true @AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
@@ -96,6 +97,11 @@ struct SettingsView: View {
PageHeader(title: "Settings", subtitle: "Customize puter") PageHeader(title: "Settings", subtitle: "Customize puter")
Form { Form {
Section("General") { Section("General") {
Picker("Appearance", selection: $appearance) {
ForEach(PuterAppearance.allCases) { appearance in
Text(appearance.rawValue).tag(appearance)
}
}
Picker("Default start page", selection: $defaultStartPage) { Picker("Default start page", selection: $defaultStartPage) {
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
Text(section.rawValue).tag(section) Text(section.rawValue).tag(section)
@@ -113,9 +119,11 @@ struct SettingsView: View {
} }
Section("Process data") { Section("Process data") {
LabeledContent("Refresh interval", value: intervalLabel) LabeledContent("Live-view interval", value: intervalLabel)
LabeledContent("Process source", value: "macOS process table") LabeledContent("Process source", value: "macOS process table")
LabeledContent("Memory units", value: "Automatic") 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") { 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 { var body: some View {
HStack(alignment: .firstTextBaseline) { ViewThatFits(in: .horizontal) {
VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(title).font(.title.weight(.semibold)) heading.fixedSize(horizontal: true, vertical: false)
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) } 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 let snapshot: SystemSnapshot
var body: some View { var body: some View {
HStack(spacing: 22) { ViewThatFits(in: .horizontal) {
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu") HStack(spacing: PuterLayout.sectionSpacing) {
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip") cpu
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive") memory
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network") disk
Spacer() network
}
Grid(alignment: .leading, horizontalSpacing: PuterLayout.sectionSpacing, verticalSpacing: 8) {
GridRow {
cpu
memory
}
GridRow {
disk
network
}
}
} }
.font(.callout.weight(.medium)) .font(.callout)
.padding(.horizontal, 20) .monospacedDigit()
.padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.06)) .padding(.horizontal, PuterLayout.pageInset)
.padding(.bottom, PuterLayout.sectionSpacing)
.padding(.top, PuterLayout.controlSpacing)
.overlay(alignment: .bottom) { Divider() } .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 { struct UpdateStatus: View {
+102 -134
View File
@@ -9,6 +9,7 @@ struct ContentView: View {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@State private var selection: TaskSection? @State private var selection: TaskSection?
@State private var searchText = "" @State private var searchText = ""
@State private var sectionSearches: [TaskSection: String] = [:]
@State private var showingNewTask = false @State private var showingNewTask = false
@State private var detailSelection: Int32? @State private var detailSelection: Int32?
@AppStorage("sidebarCompact") private var sidebarCompact = false @AppStorage("sidebarCompact") private var sidebarCompact = false
@@ -19,23 +20,29 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
NavigationSplitView(columnVisibility: .constant(.all)) { HStack(spacing: 0) {
Sidebar(selection: $selection, compact: sidebarCompact) Sidebar(selection: $selection, compact: sidebarCompact)
.navigationSplitViewColumnWidth( .frame(width: (sidebarCompact ? PuterLayout.compactSidebarWidth : PuterLayout.expandedSidebarWidth) - 16)
min: sidebarCompact ? 68 : 190, .modifier(NavigationGlass())
ideal: sidebarCompact ? 68 : 210, .padding(8)
max: sidebarCompact ? 68 : 260
)
} detail: {
detailView 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("") .navigationTitle("")
.background(WindowToolbarCleaner()) .onChange(of: selection) { previous, section in
.onChange(of: selection) { _, section in sectionSearches[previous ?? .processes] = searchText
searchText = "" searchText = sectionSearches[section ?? .processes] ?? ""
monitor.setActiveSection(section ?? .processes) monitor.setActiveSection(section ?? .processes)
} }
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true } .onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
@@ -64,34 +71,26 @@ struct ContentView: View {
switch selection ?? .processes { switch selection ?? .processes {
case .processes: case .processes:
ProcessesView(searchText: searchText) { process in ProcessesView(searchText: searchText) { process in
detailSelection = process.pid showDetails(for: process)
selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
case .performance: case .performance:
PerformanceView() PerformanceView()
case .history: case .history:
AppHistoryView(searchText: searchText) { process in AppHistoryView(searchText: searchText) { process in
detailSelection = process.pid showDetails(for: process)
selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
case .startup: case .startup:
StartupAppsView() StartupAppsView()
case .users: case .users:
UsersView { process in UsersView { process in
detailSelection = process.pid showDetails(for: process)
selection = .details
} }
case .details: case .details:
DetailsView(searchText: searchText, selection: $detailSelection) DetailsView(searchText: searchText, selection: $detailSelection)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
case .services: case .services:
ServicesView(searchText: searchText) { process in ServicesView(searchText: searchText) { process in
detailSelection = process.pid showDetails(for: process)
selection = .details
} }
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
case .energy: case .energy:
EnergyView() EnergyView()
case .diagnostics: case .diagnostics:
@@ -102,132 +101,99 @@ struct ContentView: View {
SettingsView() 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 func showDetails(for process: ProcessRecord) {
sectionSearches[.details] = ""
private struct WindowToolbarCleaner: NSViewRepresentable { detailSelection = process.pid
func makeNSView(context: Context) -> CleanerView { selection = .details
CleanerView()
} }
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 { private struct TaskToolbarContent: ToolbarContent {
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@Binding var showingNewTask: Bool @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 { var body: some ToolbarContent {
@Bindable var monitor = monitor @Bindable var monitor = monitor
ToolbarItem(placement: .navigation) { ToolbarItem(placement: .navigation) {
Button { GlassActions {
sidebarCompact.toggle() Button {
} label: { sidebarCompact.toggle()
Image(systemName: "sidebar.left") } 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) { ToolbarItem(placement: .principal) {
Button { PuterBrand()
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)
} }
} if #available(macOS 26, *) {
} ToolbarSpacer(.flexible, placement: .primaryAction)
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))
} }
.padding(.horizontal, compactTitlebar ? 8 : 14)
.frame(height: compactTitlebar ? 32 : 52) ToolbarItem(placement: .primaryAction) {
.fixedSize() GlassActions {
.accessibilityElement(children: .combine) HStack(spacing: 8) {
.accessibilityLabel("puter") Button {
.accessibilityAddTraits(.isHeader) 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) .listStyle(.sidebar)
.scrollContentBackground(.hidden)
.scrollIndicators(compact ? .hidden : .automatic) .scrollIndicators(compact ? .hidden : .automatic)
.accessibilityLabel("Navigation") .accessibilityLabel("Navigation")
} }
@@ -261,11 +228,12 @@ private struct Sidebar: View {
Spacer(minLength: 0) 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()) .contentShape(Rectangle())
.tag(section) .tag(section)
.help(section.rawValue) .help(section.rawValue)
.accessibilityLabel(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) { 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("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("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) summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
} }
} }
@@ -102,19 +102,19 @@ struct HardwareView: View {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
metric("Charge", Formatters.percent(battery.chargePercent)) metric("Charge", Formatters.percent(battery.chargePercent))
Spacer() 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() Spacer()
metric("Health", Formatters.percent(battery.healthPercent)) metric("Health", battery.designCapacityMAh > 0 ? Formatters.percent(battery.healthPercent) : "Not reported")
} }
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
metric("Cycles", cycleText) metric("Cycles", cycleText)
Spacer() Spacer()
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius)) metric("Temperature", battery.hasBatteryTemperature ? String(format: "%.1f °C", battery.temperatureCelsius) : "Not reported")
Spacer() Spacer()
metric("Voltage", String(format: "%.2f V", battery.voltageVolts)) metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
} }
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
metric("Battery power", watts(battery.batteryPowerWatts)) metric(battery.batteryFlowTitle, battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "Not reported")
Spacer() Spacer()
metric("Full capacity", capacity(battery.fullChargeCapacityMAh)) metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
Spacer() Spacer()
@@ -145,14 +145,17 @@ struct HardwareView: View {
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary) Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
} }
Spacer() 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() Divider()
HStack(spacing: 24) { HStack(spacing: 24) {
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage)) metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent)) metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
metric("Negotiated ceiling", watts(adapter.negotiatedWatts)) 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)) metric("System draw", watts(battery.systemPowerWatts))
Spacer() Spacer()
} }
@@ -465,7 +468,7 @@ struct HardwareView: View {
private var verdictDetail: String { private var verdictDetail: String {
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." } guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
if let adapter = battery.adapter { 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." 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 import SwiftUI
struct MenuBarMonitorView: View { struct MenuBarMonitorView: View {
var openMain: (() -> Void)? = nil
@Environment(SystemMonitor.self) private var monitor @Environment(SystemMonitor.self) private var monitor
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
private var topProcesses: [ProcessRecord] {
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) { HStack(spacing: 12) {
Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30) Text("puter").font(.system(size: 23, weight: .bold, design: .rounded))
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) }
}
Spacer() 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) { MenuBarPowerView(battery: monitor.hardware.battery, capturedAt: monitor.hardware.capturedAt, paused: monitor.isPaused)
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)
}
HStack { HStack {
Label("Memory pressure", systemImage: "memorychip") Label("Network", systemImage: "network")
Spacer() 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() GlassActions {
Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary) HStack(spacing: 8) {
if topProcesses.isEmpty { Button(monitor.isPaused ? "Resume" : "Pause", systemImage: monitor.isPaused ? "play" : "pause") {
Text("No process data yet").font(.caption).foregroundStyle(.secondary) monitor.isPaused.toggle()
} else { }.labelStyle(.iconOnly)
ForEach(topProcesses) { process in Button("Refresh", systemImage: "arrow.clockwise", action: monitor.refresh).labelStyle(.iconOnly)
HStack(spacing: 8) { Spacer()
ProcessIcon(name: process.displayName, path: process.executablePath, size: 18) Button("Open puter", systemImage: "arrow.up.right", action: showMainWindow)
Text(process.displayName).font(.caption).lineLimit(1)
Spacer()
Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
}
} }
} }
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) .padding(20)
.frame(width: 320) .frame(width: 368)
.modifier(MenuBarGlassSurface())
.preferredColorScheme(appearance.colorScheme)
.contextMenu { MenuBarMonitorActions() }
} }
private func metric(_ title: String, _ value: String, _ color: Color) -> some View { private func showMainWindow() {
VStack(alignment: .leading, spacing: 3) { if let openMain { openMain(); return }
Text(title).font(.caption2).foregroundStyle(.secondary) openWindow(id: "main")
Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7) NSApp.activate()
}
.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"
} }
} }
+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 systemPowerWatts = 0.0
var sensorBatteryPowerWatts = 0.0 var sensorBatteryPowerWatts = 0.0
var adapterInputWatts = 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 { var healthPercent: Double {
guard designCapacityMAh > 0 else { return 0 } guard designCapacityMAh > 0 else { return 0 }
@@ -88,6 +109,7 @@ struct BatterySnapshot: Hashable, Sendable {
} }
var batteryPowerWatts: Double { var batteryPowerWatts: Double {
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts } if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
let watts = abs(voltageVolts * currentAmps) let watts = abs(voltageVolts * currentAmps)
return watts.isFinite && watts <= 500 ? watts : 0 return watts.isFinite && watts <= 500 ? watts : 0
@@ -256,7 +278,10 @@ enum MenuBarMetric: String, CaseIterable, Identifiable {
case memory = "Memory" case memory = "Memory"
case network = "Network" case network = "Network"
case power = "Power" case power = "Power"
case batteryPower = "Battery power"
case chargerInput = "Charger input"
var id: String { rawValue } var id: String { rawValue }
var requiresPower: Bool { [.power, .batteryPower, .chargerInput].contains(self) }
} }
struct ProcessRecord: Identifiable, Hashable, Sendable { struct ProcessRecord: Identifiable, Hashable, Sendable {
@@ -505,6 +530,7 @@ enum Formatters {
static let bytes: ByteCountFormatter = { static let bytes: ByteCountFormatter = {
let formatter = ByteCountFormatter() let formatter = ByteCountFormatter()
formatter.countStyle = .memory formatter.countStyle = .memory
formatter.allowsNonnumericFormatting = false
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB] formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
return formatter return formatter
}() }()
+40 -37
View File
@@ -65,7 +65,7 @@ struct PerformanceView: View {
} }
HStack(spacing: 0) { HStack(spacing: 0) {
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 4) {
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors") 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(.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( metricCard(
@@ -89,10 +89,10 @@ struct PerformanceView: View {
detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))" detail: "\(Formatters.rate(monitor.snapshot.networkReceiveRate))\(Formatters.rate(monitor.snapshot.networkSendRate))"
) )
} }
.padding(14) .padding(10)
} }
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245) .frame(minWidth: 220, idealWidth: 245, maxWidth: 280)
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45)) .background(Color(nsColor: .controlBackgroundColor).opacity(0.25))
Divider() Divider()
VStack(spacing: 0) { VStack(spacing: 0) {
detail detail
@@ -184,56 +184,59 @@ struct PerformanceView: View {
selectedVolumeID = nil selectedVolumeID = nil
selectedMetric = metric selectedMetric = metric
} label: { } label: {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 10) {
Image(systemName: metric.icon) Image(systemName: metric.icon)
.font(.title3) .font(.body)
.foregroundStyle(metric.color) .foregroundStyle(metric.color)
.frame(width: 30) .frame(width: 22)
VStack(alignment: .leading, spacing: 3) { .padding(.top, 2)
Text(metric.rawValue).font(.headline) VStack(alignment: .leading, spacing: 4) {
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit()) HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1) 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) .frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor)) .background(selectedVolumeID == nil && selectedMetric == metric ? Color.accentColor.opacity(0.13) : .clear)
.overlay { .clipShape(RoundedRectangle(cornerRadius: 7))
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))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private func volumeCard(_ volume: VolumeSnapshot) -> some View { private func volumeCard(_ volume: VolumeSnapshot) -> some View {
Button { selectedVolumeID = volume.id } label: { Button { selectedVolumeID = volume.id } label: {
HStack(spacing: 12) { HStack(alignment: .top, spacing: 10) {
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive") Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
.font(.title3) .font(.body)
.foregroundStyle(.orange) .foregroundStyle(.orange)
.frame(width: 30) .frame(width: 22)
VStack(alignment: .leading, spacing: 3) { .padding(.top, 2)
Text(volume.name).font(.headline).lineLimit(1) VStack(alignment: .leading, spacing: 4) {
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit()) 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)") 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) .frame(maxWidth: .infinity, alignment: .leading)
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor)) .background(selectedVolumeID == volume.id ? Color.accentColor.opacity(0.13) : .clear)
.overlay { .clipShape(RoundedRectangle(cornerRadius: 7))
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))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("\(volume.name), removable disk") .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 apps = "Apps"
case background = "Background processes" case background = "Background processes"
case system = "macOS processes" case system = "macOS processes"
@@ -100,7 +100,7 @@ struct ProcessesView: View {
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false @AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
private var grouped: [ProcessGroup] { private var grouped: [ProcessGroup] {
let groups = ProcessGrouping.groups(from: monitor.processes, searchText: searchText) let groups = ProcessGrouping.filtered(monitor.processGroups, searchText: searchText)
if groupByType { if groupByType {
return groups.sorted { return groups.sorted {
let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0 let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0
@@ -150,7 +150,6 @@ struct ProcessesView: View {
} label: { } label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right") Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
} }
.buttonStyle(.bordered)
.help(previewPaneVisible ? "Hide process preview" : "Show process preview") .help(previewPaneVisible ? "Hide process preview" : "Show process preview")
} }
} }
@@ -211,6 +210,11 @@ struct ProcessesView: View {
} }
private var processList: some View { private var processList: some View {
let visibleGroups = displayedGroups
return processList(groups: visibleGroups)
}
private func processList(groups visibleGroups: [ProcessGroup]) -> some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollView(.horizontal) { ScrollView(.horizontal) {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -236,7 +240,7 @@ struct ProcessesView: View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
if groupByType { if groupByType {
ForEach(ProcessCategory.allCases) { category in ForEach(ProcessCategory.allCases) { category in
let categoryGroups = displayedGroups.filter { $0.category == category } let categoryGroups = visibleGroups.filter { $0.category == category }
if !categoryGroups.isEmpty { if !categoryGroups.isEmpty {
ProcessCategoryHeader( ProcessCategoryHeader(
category: category, category: category,
@@ -258,7 +262,7 @@ struct ProcessesView: View {
} }
} }
} else { } else {
ForEach(displayedGroups) { group in ForEach(visibleGroups) { group in
processGroup(group) processGroup(group)
} }
} }
@@ -268,6 +272,11 @@ struct ProcessesView: View {
.frame(width: processTableWidth, alignment: .leading) .frame(width: processTableWidth, alignment: .leading)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
if visibleGroups.isEmpty {
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
}
}
Divider() Divider()
HStack { HStack {
UpdateStatus() UpdateStatus()
@@ -447,7 +456,7 @@ private struct ProcessCategoryHeader: View {
} }
} }
private struct ProcessGroup: Identifiable { struct ProcessGroup: Identifiable {
let id: String let id: String
let name: String let name: String
let appPath: 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] { static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] {
let foregroundPIDs = Set(NSWorkspace.shared.runningApplications let foregroundPIDs = Set(NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular } .filter { $0.activationPolicy == .regular }
@@ -484,13 +497,22 @@ private enum ProcessGrouping {
let grouped = Dictionary(grouping: processes) { process -> String in let grouped = Dictionary(grouping: processes) { process -> String in
appPath(for: process.executablePath) ?? "pid:\(process.pid)" 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 path = key.hasPrefix("pid:") ? nil : key
let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent } let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
?? members[0].displayName ?? members[0].displayName
let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs) let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs)
let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category) 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 groupMatches = name.localizedCaseInsensitiveContains(searchText)
let matchingMembers = members.filter { let matchingMembers = members.filter {
$0.displayName.localizedCaseInsensitiveContains(searchText) $0.displayName.localizedCaseInsensitiveContains(searchText)
@@ -499,11 +521,11 @@ private enum ProcessGrouping {
} }
guard groupMatches || !matchingMembers.isEmpty else { return nil } guard groupMatches || !matchingMembers.isEmpty else { return nil }
return ProcessGroup( return ProcessGroup(
id: key, id: group.id,
name: name, name: name,
appPath: path, appPath: group.appPath,
processes: groupMatches ? members : matchingMembers, processes: groupMatches ? members : matchingMembers,
category: category category: group.category
) )
} }
} }
@@ -527,8 +549,12 @@ private enum ProcessGrouping {
} }
private static func appPath(for executablePath: String) -> String? { private static func appPath(for executablePath: String) -> String? {
guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil } if let cached = appPaths[executablePath] { return cached.isEmpty ? nil : cached }
return String(executablePath[..<range.lowerBound]) + ".app" 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 { } else {
Color.clear.frame(width: 16) 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) { VStack(alignment: .leading, spacing: 2) {
Text(process.displayName).lineLimit(1).truncationMode(.tail) Text(process.displayName).lineLimit(1).truncationMode(.tail)
Text(process.user).font(.caption).foregroundStyle(.secondary).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) .font(.callout)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(minHeight: 48) .frame(minHeight: 42)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
} }
@@ -777,17 +803,18 @@ private struct ProcessGroupRow: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0)) .rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 16, height: 44) .frame(width: 16, height: 38)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)") .help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
.accessibilityLabel(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) { VStack(alignment: .leading, spacing: 2) {
Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail) Text(group.name).lineLimit(1).truncationMode(.tail)
Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) Text("\(group.processes.count) processes")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -811,7 +838,7 @@ private struct ProcessGroupRow: View {
} }
.font(.callout.weight(.medium)) .font(.callout.weight(.medium))
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(minHeight: 48) .frame(minHeight: 42)
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
.overlay(alignment: .bottom) { Divider().opacity(0.45) } .overlay(alignment: .bottom) { Divider().opacity(0.45) }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -931,11 +958,11 @@ private struct HeatCell: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.72) .minimumScaleFactor(0.72)
.truncationMode(.tail) .truncationMode(.tail)
.padding(.vertical, 9) .padding(.vertical, 6)
.padding(.horizontal, processNumericTextInset) .padding(.horizontal, processNumericTextInset)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.clipped() .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)) .clipShape(RoundedRectangle(cornerRadius: 4))
} }
} }
+15 -9
View File
@@ -19,21 +19,27 @@ final class PuterAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificatio
struct PuterApp: App { struct PuterApp: App {
@NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate
@State private var monitor = SystemMonitor() @State private var monitor = SystemMonitor()
@State private var statusBar = StatusBarController()
@StateObject private var updates = UpdateController() @StateObject private var updates = UpdateController()
@AppStorage("sidebarCompact") private var sidebarCompact = false @AppStorage("sidebarCompact") private var sidebarCompact = false
@AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false @AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false
@AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true @AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
@AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu @AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
var body: some Scene { var body: some Scene {
WindowGroup("puter", id: "main") { WindowGroup("puter", id: "main") {
ContentView() ContentView()
.environment(monitor) .environment(monitor)
.environmentObject(updates) .environmentObject(updates)
.preferredColorScheme(appearance.colorScheme)
.frame(minWidth: 820, minHeight: 560) .frame(minWidth: 820, minHeight: 560)
.task { monitor.start() } .task { monitor.start() }
.background(StatusBarBridge(controller: statusBar, monitor: monitor, visible: showMenuBarMonitor, value: menuBarValue))
} }
.defaultSize(width: 1180, height: 760) .defaultSize(width: 1180, height: 760)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updates.checkForUpdates() } Button("Check for Updates…") { updates.checkForUpdates() }
@@ -45,8 +51,11 @@ struct PuterApp: App {
} }
.keyboardShortcut("n", modifiers: .command) .keyboardShortcut("n", modifiers: .command)
} }
SidebarCommands()
CommandMenu("Options") { CommandMenu("Options") {
Button("Show menu-bar monitor") { statusBar.togglePanel() }
.keyboardShortcut("m", modifiers: [.command, .shift])
.disabled(!showMenuBarMonitor)
Divider()
Button("Refresh now") { monitor.refresh() } Button("Refresh now") { monitor.refresh() }
.keyboardShortcut("r", modifiers: .command) .keyboardShortcut("r", modifiers: .command)
Divider() 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 { private var menuBarValue: String {
@@ -79,6 +80,11 @@ struct PuterApp: App {
case .power: case .power:
monitor.hardware.battery.systemPowerWatts > 0 monitor.hardware.battery.systemPowerWatts > 0
? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W" ? 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 { switch self {
case .name: 260 case .name: 260
case .user: 140 case .user: 140
case .architecture, .state: 105 case .architecture: 125
case .state: 105
case .cpuTime, .memory, .disk, .network, .elapsed: 112 case .cpuTime, .memory, .disk, .network, .elapsed: 112
case .parentPID: 92 case .parentPID: 92
default: 78 default: 78
@@ -846,6 +847,7 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
case .name: 170 case .name: 170
case .user: 90 case .user: 90
case .cpuTime, .memory, .disk, .network, .elapsed: 82 case .cpuTime, .memory, .disk, .network, .elapsed: 82
case .architecture: 110
default: 62 default: 62
} }
} }
@@ -888,12 +890,16 @@ struct DetailsView: View {
} label: { } label: {
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right") Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
} }
.buttonStyle(.bordered)
Button("Properties", systemImage: "info.circle") { Button("Properties", systemImage: "info.circle") {
inspectedProcess = selectedProcess inspectedProcess = selectedProcess
} }
.disabled(selectedProcess == nil) .disabled(selectedProcess == nil)
Menu("Columns", systemImage: "rectangle.split.3x1") { 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 ForEach(DetailColumn.allCases) { column in
Toggle(column.title, isOn: columnBinding(column)) Toggle(column.title, isOn: columnBinding(column))
.disabled(column == .name || column == .pid) .disabled(column == .name || column == .pid)
@@ -905,6 +911,8 @@ struct DetailsView: View {
persistColumnWidths() persistColumnWidths()
} }
} }
.modifier(GlassMenuSurface())
.fixedSize()
} }
} }
ResourceSummaryBar(snapshot: monitor.snapshot) ResourceSummaryBar(snapshot: monitor.snapshot)
@@ -942,6 +950,11 @@ struct DetailsView: View {
} }
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
@@ -964,7 +977,7 @@ struct DetailsView: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.font(.caption.weight(.medium)) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.background(.regularMaterial) .background(.regularMaterial)
.overlay(alignment: .bottom) { Divider() } .overlay(alignment: .bottom) { Divider() }
@@ -986,11 +999,6 @@ struct DetailsView: View {
.background(rowBackground(process: process, index: index)) .background(rowBackground(process: process, index: index))
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { selection = process.pid } .onTapGesture { selection = process.pid }
.focusable()
.onKeyPress(.return) {
selection = process.pid
return .handled
}
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))") .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") .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 { private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
switch column { switch column {
case .name: case .name:
HStack(spacing: 8) { Button {
ProcessIcon(name: process.displayName, path: process.executablePath) selection = process.pid
Text(process.displayName).lineLimit(1).truncationMode(.tail) } 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 .pid: Text("\(process.pid)").monospacedDigit()
case .parentPID: Text("\(process.parentPID)").monospacedDigit() case .parentPID: Text("\(process.parentPID)").monospacedDigit()
case .user: Text(process.user).lineLimit(1) case .user: Text(process.user).lineLimit(1)
@@ -1076,25 +1091,38 @@ struct DetailsView: View {
Divider() Divider()
HStack(spacing: 10) { HStack(spacing: 10) {
if let process = selectedProcess { if let process = selectedProcess {
ProcessIcon(name: process.displayName, path: process.executablePath) ProcessIcon(name: process.displayName, path: process.executablePath, size: 24)
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1) VStack(alignment: .leading, spacing: 2) {
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit() Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
Text("").foregroundStyle(.tertiary) ViewThatFits(in: .horizontal) {
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit() HStack(spacing: 10) {
Text("").foregroundStyle(.tertiary) Text("PID \(process.pid)")
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit() Text("CPU \(Formatters.percent(process.cpu))")
Spacer() 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("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 { } else {
Text("\(filtered.count) processes").foregroundStyle(.secondary) Text("\(filtered.count) processes").foregroundStyle(.secondary)
Spacer() 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) .padding(.horizontal, 14)
.frame(minHeight: 48) .frame(minHeight: 52)
} }
.background(.bar) .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 @Observable
final class SystemMonitor { final class SystemMonitor {
var processes: [ProcessRecord] = [] var processes: [ProcessRecord] = []
var processGroups: [ProcessGroup] = []
var services: [ServiceRecord] = [] var services: [ServiceRecord] = []
var appHistory: [AppUsageRecord] = [] var appHistory: [AppUsageRecord] = []
var appHistoryStartDate = Date() var appHistoryStartDate = Date()
@@ -29,18 +30,34 @@ final class SystemMonitor {
var hardware = HardwareSnapshot() var hardware = HardwareSnapshot()
var systemPowerHistory: [MetricSample] = [] var systemPowerHistory: [MetricSample] = []
var updateSpeed: UpdateSpeed { var updateSpeed: UpdateSpeed {
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") } didSet {
UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed")
rescheduleSampling()
}
} }
var lastUpdated: Date? var lastUpdated: Date?
var errorMessage: String? 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 recordingStartDate: Date?
var recordingSamples: [TelemetryRecordingSample] = [] var recordingSamples: [TelemetryRecordingSample] = []
var recentAlerts: [ResourceAlertEvent] = [] var recentAlerts: [ResourceAlertEvent] = []
private var updateTask: Task<Void, Never>? private var updateTask: Task<Void, Never>?
private var hardwareUpdateTask: Task<Void, Never>? private var started = false
private var serviceUpdateTask: Task<Void, Never>? 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 telemetryRefreshTask: Task<Void, Never>?
private var hardwareRefreshTask: Task<Void, Never>? private var hardwareRefreshTask: Task<Void, Never>?
private var serviceRefreshTask: Task<Void, Never>? private var serviceRefreshTask: Task<Void, Never>?
@@ -55,7 +72,14 @@ final class SystemMonitor {
private var serviceRefreshPending = false private var serviceRefreshPending = false
private var eventObservers: [NSObjectProtocol] = [] private var eventObservers: [NSObjectProtocol] = []
private var activeSection: TaskSection = .processes 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 memoryPressureLevel = MemoryPressureLevel.normal
private var lastProcessDiagnosticsSample: Date? private var lastProcessDiagnosticsSample: Date?
private var lastProcessInventorySample: Date? private var lastProcessInventorySample: Date?
@@ -97,38 +121,55 @@ final class SystemMonitor {
} }
func start() { func start() {
guard updateTask == nil else { return } guard !started else { return }
appIsActive = NSApp.isActive started = true
installEventObservers() installEventObservers()
installMemoryPressureSource() installMemoryPressureSource()
installPowerSourceObserver() 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 updateTask = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
let interval = self?.telemetryInterval ?? .seconds(2) do { try await Task.sleep(for: interval, tolerance: .milliseconds(250)) }
try? await Task.sleep(for: interval) catch { return }
guard let self, !self.isPaused else { continue } guard !Task.isCancelled, let self else { return }
self.requestTelemetryRefresh() // Reconcile with live AppKit state before executing captured demand.
} // Notifications can coalesce while the app is being hidden.
} guard self.samplingDemand == demand else {
hardwareUpdateTask = Task { [weak self] in self.rescheduleSampling()
while !Task.isCancelled { return
guard let self else { return } }
let seconds = self.appIsActive let now = Date()
? ([.hardware, .performance, .energy].contains(self.activeSection) ? 5 : 30) if demand.telemetry { self.requestTelemetryRefresh() }
: 60 if demand.hardware && now.timeIntervalSince(self.lastHardwareRequest) >= 5 {
try? await Task.sleep(for: .seconds(seconds)) self.lastHardwareRequest = now
guard !Task.isCancelled, !self.isPaused else { continue } self.requestHardwareRefresh()
self.requestHardwareRefresh() }
} if demand.services && now.timeIntervalSince(self.lastServiceRequest) >= 3 {
} self.lastServiceRequest = now
serviceUpdateTask = Task { [weak self] in self.requestServiceRefresh()
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()
} }
} }
} }
@@ -136,7 +177,8 @@ final class SystemMonitor {
func refresh() { func refresh() {
processInventoryRefreshRequested = true processInventoryRefreshRequested = true
volumeInventoryRefreshRequested = true volumeInventoryRefreshRequested = true
requestTelemetryRefresh() lastDiskCapacitySample = nil
requestTelemetryRefresh(forceInventory: true)
requestHardwareRefresh( requestHardwareRefresh(
forcePortRefresh: true, forcePortRefresh: true,
forcePowerRefresh: true, forcePowerRefresh: true,
@@ -149,26 +191,18 @@ final class SystemMonitor {
func setActiveSection(_ section: TaskSection) { func setActiveSection(_ section: TaskSection) {
guard activeSection != section else { return } guard activeSection != section else { return }
activeSection = section activeSection = section
rescheduleSampling()
guard !isPaused else { return }
if section == .performance { volumeInventoryRefreshRequested = true }
if section.usesLiveProcessInventory { if section.usesLiveProcessInventory {
processInventoryRefreshRequested = true processInventoryRefreshRequested = true
requestTelemetryRefresh() requestTelemetryRefresh()
} }
if section == .performance { volumeInventoryRefreshRequested = true }
if section == .hardware || section == .performance || section == .energy { requestHardwareRefresh() } if section == .hardware || section == .performance || section == .energy { requestHardwareRefresh() }
if section == .services { requestServiceRefresh() } if section == .services { requestServiceRefresh() }
} }
private var telemetryInterval: Duration { private func requestTelemetryRefresh(forceInventory: Bool = false) {
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() {
guard telemetryRefreshTask == nil else { guard telemetryRefreshTask == nil else {
telemetryRefreshPending = true telemetryRefreshPending = true
return return
@@ -177,28 +211,33 @@ final class SystemMonitor {
let processInventoryInterval: TimeInterval = activeSection.usesLiveProcessInventory let processInventoryInterval: TimeInterval = activeSection.usesLiveProcessInventory
? 0 ? 0
: (appIsActive ? 15 : 30) : (appIsActive ? 15 : 30)
let includeProcessInventory = processInventoryRefreshRequested let includeProcessInventory = forceInventory || (samplingDemand.processInventory && (processInventoryRefreshRequested
|| processes.isEmpty || processes.isEmpty
|| lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true || lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true))
if includeProcessInventory { processInventoryRefreshRequested = false } if includeProcessInventory { processInventoryRefreshRequested = false }
let diagnosticInterval: TimeInterval = activeSection == .details ? 5 : 15 let diagnosticInterval: TimeInterval = activeSection == .details ? 5 : 15
let includeDiagnostics = includeProcessInventory && (lastProcessDiagnosticsSample.map { let includeDiagnostics = includeProcessInventory && activeSection == .details && (lastProcessDiagnosticsSample.map {
now.timeIntervalSince($0) >= diagnosticInterval now.timeIntervalSince($0) >= diagnosticInterval
} ?? true) } ?? true)
let deviceMetricsInterval: TimeInterval = activeSection == .performance ? 3 : 20 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 Date().timeIntervalSince($0) >= deviceMetricsInterval
} ?? true } ?? true)
let includeProcessIO: Bool let includeProcessIO: Bool
switch activeSection { switch activeSection {
case .processes, .performance, .history, .users, .details, .energy, .diagnostics: case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
includeProcessIO = includeProcessInventory && appIsActive includeProcessIO = includeProcessInventory && (appIsActive || isRecording)
case .startup, .services, .hardware, .settings: case .startup, .services, .hardware, .settings:
includeProcessIO = false includeProcessIO = false
} }
let includeVolumeInventory = volumeInventoryRefreshRequested let includeVolumeInventory = volumeInventoryRefreshRequested && appIsActive && activeSection == .performance
|| (activeSection == .performance && (lastVolumeInventorySample.map { Date().timeIntervalSince($0) >= 10 } ?? true))
if includeVolumeInventory { volumeInventoryRefreshRequested = false } 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 let knownProcesses = includeProcessInventory
? Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) ? Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
: [:] : [:]
@@ -210,15 +249,17 @@ final class SystemMonitor {
includeDeviceMetrics: includeDeviceMetrics, includeDeviceMetrics: includeDeviceMetrics,
includeProcessIO: includeProcessIO, includeProcessIO: includeProcessIO,
includeVolumeInventory: includeVolumeInventory, includeVolumeInventory: includeVolumeInventory,
cachedDiskCapacity: includeDiskCapacity ? nil : cachedDiskCapacity,
knownProcesses: knownProcesses knownProcesses: knownProcesses
) )
}.value }.value
guard let self, !Task.isCancelled else { return } guard let self, !Task.isCancelled else { return }
self.applyTelemetry(result) self.applyTelemetry(result)
if includeDiskCapacity { self.lastDiskCapacitySample = now }
self.telemetryRefreshTask = nil self.telemetryRefreshTask = nil
if self.telemetryRefreshPending { if self.telemetryRefreshPending {
self.telemetryRefreshPending = false self.telemetryRefreshPending = false
self.requestTelemetryRefresh() if !self.isPaused && self.samplingDemand.telemetry { self.requestTelemetryRefresh() }
} }
} }
} }
@@ -238,15 +279,16 @@ final class SystemMonitor {
return return
} }
let now = Date() let now = Date()
let refreshPorts = forcePortRefresh || lastPortSample.map { Date().timeIntervalSince($0) >= 60 } ?? true let detailedHardware = appIsActive && [.hardware, .performance, .energy].contains(activeSection)
let powerIsVisible = [.hardware, .performance, .energy].contains(activeSection) let refreshPorts = forcePortRefresh || (detailedHardware && (lastPortSample.map { now.timeIntervalSince($0) >= 60 } ?? true))
|| UserDefaults.standard.string(forKey: "menuBarMetric") == MenuBarMetric.power.rawValue let powerIsVisible = menuBarPresented || detailedHardware
|| (MenuBarMetric(rawValue: UserDefaults.standard.string(forKey: "menuBarMetric") ?? "")?.requiresPower ?? false)
let powerInterval: TimeInterval = powerIsVisible ? 5 : 30 let powerInterval: TimeInterval = powerIsVisible ? 5 : 30
let refreshPower = forcePowerRefresh || lastPowerSensorSample.map { now.timeIntervalSince($0) >= powerInterval } ?? true let refreshPower = forcePowerRefresh || lastPowerSensorSample.map { now.timeIntervalSince($0) >= powerInterval } ?? true
let fanInterval: TimeInterval = activeSection == .hardware ? 5 : 30 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 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 let existingHardware = hardware
hardwareRefreshTask = Task { [weak self] in hardwareRefreshTask = Task { [weak self] in
let result = await Task.detached(priority: .utility) { let result = await Task.detached(priority: .utility) {
@@ -323,6 +365,7 @@ final class SystemMonitor {
let activePIDs = Set(currentProcesses.map(\.pid)) let activePIDs = Set(currentProcesses.map(\.pid))
processDiagnostics = processDiagnostics.filter { activePIDs.contains($0.key) } processDiagnostics = processDiagnostics.filter { activePIDs.contains($0.key) }
processes = currentProcesses processes = currentProcesses
processGroups = ProcessGrouping.groups(from: currentProcesses, searchText: "")
updateCPUHistory(with: currentProcesses) updateCPUHistory(with: currentProcesses)
updateProcessDiskActivity(result.processIO, current: currentProcesses) updateProcessDiskActivity(result.processIO, current: currentProcesses)
lastProcessInventorySample = Date() lastProcessInventorySample = Date()
@@ -437,7 +480,16 @@ final class SystemMonitor {
eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
self?.processInventoryRefreshRequested = true 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 queue: .main
) { [weak self] _ in ) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
self?.appIsActive = true self?.rescheduleSampling()
self?.requestTelemetryRefresh() if self?.isPaused == false { self?.requestTelemetryRefresh() }
} }
}) })
eventObservers.append(NotificationCenter.default.addObserver( eventObservers.append(NotificationCenter.default.addObserver(
@@ -479,7 +531,24 @@ final class SystemMonitor {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [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( eventObservers.append(NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification, forName: NSApplication.willTerminateNotification,
@@ -750,7 +819,7 @@ final class SystemMonitor {
} }
private func sampleAppNetworkIfNeeded() { private func sampleAppNetworkIfNeeded() {
guard appNetworkTask == nil, guard samplingDemand.processInventory, !isPaused, appNetworkTask == nil,
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return } lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
lastAppNetworkSample = Date() lastAppNetworkSample = Date()
appNetworkTask = Task { [weak self] in appNetworkTask = Task { [weak self] in
@@ -1171,6 +1240,7 @@ private enum ProcessScanner {
includeDeviceMetrics: Bool, includeDeviceMetrics: Bool,
includeProcessIO: Bool, includeProcessIO: Bool,
includeVolumeInventory: Bool, includeVolumeInventory: Bool,
cachedDiskCapacity: (free: UInt64, total: UInt64)? = nil,
knownProcesses: [Int32: ProcessRecord] = [:] knownProcesses: [Int32: ProcessRecord] = [:]
) -> TelemetryResult { ) -> TelemetryResult {
let records = includeProcessInventory let records = includeProcessInventory
@@ -1178,7 +1248,7 @@ private enum ProcessScanner {
: [] : []
let totalMemory = ProcessInfo.processInfo.physicalMemory let totalMemory = ProcessInfo.processInfo.physicalMemory
let memory = memoryCounters(total: totalMemory) let memory = memoryCounters(total: totalMemory)
let disk = diskUsage() let disk = cachedDiskCapacity ?? diskUsage()
var snapshot = SystemSnapshot() var snapshot = SystemSnapshot()
snapshot.cpuPercent = 0 snapshot.cpuPercent = 0
snapshot.memoryUsed = memory.used snapshot.memoryUsed = memory.used
@@ -1683,16 +1753,23 @@ private enum HardwareScanner {
refreshFans: Bool, refreshFans: Bool,
refreshSlowDiagnostics: Bool refreshSlowDiagnostics: Bool
) -> HardwareSnapshot { ) -> HardwareSnapshot {
var battery = batterySnapshot() var battery = BatteryRegistryReader.read()
if refreshPowerSensors { if refreshPowerSensors {
let power = powerSensorSnapshot() 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.sensorBatteryPowerWatts = power.batteryWatts
battery.adapterInputWatts = power.adapterWatts if battery.externalPowerConnected, !battery.hasAdapterInput, power.adapterWatts > 0 {
battery.adapterInputWatts = power.adapterWatts
battery.hasAdapterInput = true
}
} else { } else {
battery.systemPowerWatts = existing.battery.systemPowerWatts if !battery.hasSystemPower {
battery.sensorBatteryPowerWatts = existing.battery.sensorBatteryPowerWatts battery.systemPowerWatts = existing.battery.systemPowerWatts
battery.adapterInputWatts = existing.battery.adapterInputWatts battery.hasSystemPower = existing.battery.hasSystemPower
}
} }
return HardwareSnapshot( return HardwareSnapshot(
battery: battery, battery: battery,
@@ -1738,86 +1815,6 @@ private enum HardwareScanner {
}.sorted { $0.id < $1.id } }.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 { private static func thermalState() -> String {
switch ProcessInfo.processInfo.thermalState { 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
}
}
}
@@ -0,0 +1,77 @@
import XCTest
@testable import puter
final class BatteryRegistryTests: XCTestCase {
func testChargingSeparatesLoadInputAndCapability() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "ExternalConnected": true, "IsCharging": true,
"CurrentCapacity": 77, "Voltage": 12_500, "Amperage": 3_600,
"PowerTelemetryData": ["SystemLoad": 15_000, "SystemPowerIn": 61_000],
"AdapterDetails": ["Name": "140W USB-C Power Adapter", "Watts": 94,
"AdapterVoltage": 20_000, "Current": 4_700,
"UsbHvcMenu": [["MaxVoltage": 20_000, "MaxCurrent": 4_700]]]
])
XCTAssertTrue(result.isPresent)
XCTAssertEqual(result.systemPowerWatts, 15)
XCTAssertEqual(result.adapterInputWatts, 61)
XCTAssertEqual(result.measuredBatteryWatts, 45)
XCTAssertEqual(result.batteryFlowTitle, "Battery charging")
XCTAssertEqual(result.adapter?.negotiatedWatts, 94)
XCTAssertEqual(result.adapter?.profiles.count, 1)
}
func testUnsignedNegativeCurrentAndDisconnectedStaleAdapter() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "ExternalConnected": false, "Voltage": 12_000,
"Amperage": NSNumber(value: UInt64(bitPattern: -2_000)),
"PowerTelemetryData": ["SystemPowerIn": 50_000],
"AdapterDetails": ["Watts": 94]
])
XCTAssertEqual(result.currentAmps, -2)
XCTAssertEqual(result.measuredBatteryWatts, 24)
XCTAssertEqual(result.batteryFlowTitle, "Battery discharge")
XCTAssertNil(result.adapter)
XCTAssertFalse(result.hasAdapterInput)
XCTAssertEqual(result.adapterInputWatts, 0)
}
func testZeroIsDifferentFromMissing() {
let idle = BatteryRegistryReader.parse(["BatteryInstalled": true, "Voltage": 12_000, "Amperage": 0])
XCTAssertEqual(idle.measuredBatteryWatts, 0)
XCTAssertEqual(idle.batteryFlowTitle, "Battery idle")
let absent = BatteryRegistryReader.parse([:])
XCTAssertNil(absent.measuredBatteryWatts)
XCTAssertFalse(absent.hasSystemPower)
XCTAssertEqual(absent.powerSourceDescription, "External power")
}
func testNestedCapacityOnNewerBatteryRegistry() {
let result = BatteryRegistryReader.parse(["BatteryInstalled": true,
"BatteryData": ["NominalChargeCapacity": 5630, "DesignCapacity": 6249, "RemainingCapacity": 4600]])
XCTAssertEqual(result.fullChargeCapacityMAh, 5630)
XCTAssertEqual(result.designCapacityMAh, 6249)
XCTAssertEqual(result.currentCapacityMAh, 4600)
XCTAssertEqual(result.healthPercent, 90.09, accuracy: 0.01)
XCTAssertFalse(result.hasBatteryTemperature)
}
func testInvalidPowerAndCurrentAreNotMeasurements() {
let result = BatteryRegistryReader.parse([
"BatteryInstalled": true, "Voltage": 12_000, "Amperage": 999_999,
"PowerTelemetryData": ["SystemLoad": Double.nan, "SystemPowerIn": -10]
])
XCTAssertNil(result.measuredBatteryWatts)
XCTAssertFalse(result.hasSystemPower)
XCTAssertFalse(result.hasAdapterInput)
}
func testOpenDropdownRequestsPowerButNotFullInventory() {
let demand = SamplingDemand(active: false, section: .processes, speed: .normal,
recording: false, alerts: false, menuBar: true, powerMenu: false,
menuPresented: true)
XCTAssertTrue(demand.hardware)
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertEqual(demand.interval, .seconds(5))
}
}
@@ -0,0 +1,37 @@
import XCTest
@testable import puter
final class ProcessGroupingTests: XCTestCase {
@MainActor
func testPrecomputedGroupsPreserveMemberSearchAndTotals() {
let records = [record(41, path: "/Applications/Example.app/Contents/MacOS/Example"),
record(42, path: "/Applications/Example.app/Contents/Helpers/Worker"),
record(43, path: "/usr/bin/tool")]
let groups = ProcessGrouping.groups(from: records, searchText: "")
XCTAssertEqual(groups.count, 2)
let app = ProcessGrouping.filtered(groups, searchText: "Example")
XCTAssertEqual(app.count, 1)
XCTAssertEqual(app.first?.processes.count, 2)
XCTAssertEqual(app.first?.cpu, 4)
let member = ProcessGrouping.filtered(groups, searchText: "42")
XCTAssertEqual(member.first?.processes.map(\.pid), [42])
XCTAssertEqual(member.first?.id, app.first?.id)
XCTAssertTrue(ProcessGrouping.filtered(groups, searchText: "unmatched").isEmpty)
}
@MainActor
func testPathCachePreservesSingletonsAndFreshTelemetry() {
let first = ProcessGrouping.groups(from: [record(41, path: "/usr/bin/tool")], searchText: "")
let second = ProcessGrouping.groups(from: [record(42, path: "/usr/bin/tool")], searchText: "")
XCTAssertNotEqual(first.first?.id, second.first?.id)
let mixedCase = ProcessGrouping.groups(from: [record(43, path: "/Applications/Test.APP/Contents/MacOS/Test")], searchText: "")
XCTAssertEqual(mixedCase.first?.appPath, "/Applications/Test.app")
}
private func record(_ pid: Int32, path: String) -> ProcessRecord {
ProcessRecord(pid: pid, parentPID: 1, name: "worker", executablePath: path,
user: "tester", cpu: 2, memoryPercent: 1, residentBytes: 1024,
state: "S", elapsed: "00:01", cpuTime: 0, threadCount: 1,
openFileCount: 0, architecture: "ARM64", priority: 31, nice: 0)
}
}
@@ -0,0 +1,66 @@
import XCTest
@testable import puter
final class SamplingDemandTests: XCTestCase {
func testNoConsumerMeansNoTimer() {
let demand = make(active: false)
XCTAssertNil(demand.interval)
XCTAssertFalse(demand.processInventory)
XCTAssertFalse(demand.hardware)
XCTAssertFalse(demand.services)
}
func testBackgroundMenuUsesLightweightCountersOnly() {
let demand = make(active: false, menu: true)
XCTAssertEqual(demand.interval, .seconds(10))
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertFalse(demand.services)
XCTAssertFalse(demand.hardware)
}
func testSettingsWithoutConsumersIsIdle() {
XCTAssertNil(make(section: .settings).interval)
}
func testServicesOnlyScansVisibleServices() {
let demand = make(section: .services)
XCTAssertTrue(demand.services)
XCTAssertFalse(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertEqual(demand.interval, .seconds(3))
}
func testRecordingKeepsBackgroundTelemetry() {
let demand = make(active: false, recording: true)
XCTAssertTrue(demand.processInventory)
XCTAssertTrue(demand.telemetry)
XCTAssertTrue(demand.hardware)
XCTAssertEqual(demand.interval, UpdateSpeed.normal.interval)
}
func testPauseStopsEvenRecordingTimer() {
XCTAssertNil(make(speed: .paused, recording: true, menu: true).interval)
}
func testPowerMenuOnlyNeedsHardware() {
let demand = make(active: false, menu: true, power: true)
XCTAssertTrue(demand.hardware)
XCTAssertFalse(demand.telemetry)
XCTAssertFalse(demand.processInventory)
}
func testAlertsRetainSystemSamplingWithoutProcessScan() {
let demand = make(active: false, alerts: true)
XCTAssertTrue(demand.telemetry)
XCTAssertFalse(demand.processInventory)
XCTAssertNotNil(demand.interval)
}
private func make(active: Bool = true, section: TaskSection = .processes,
speed: UpdateSpeed = .normal, recording: Bool = false,
alerts: Bool = false, menu: Bool = false, power: Bool = false) -> SamplingDemand {
SamplingDemand(active: active, section: section, speed: speed,
recording: recording, alerts: alerts, menuBar: menu, powerMenu: power)
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- Circular loop: concentric radii 300 and 188, exactly 112 units thick.
Stem: x=212...324, also 112 units, tangent to the outer circle.
A single outline avoids overlapping glass surfaces at the join. -->
<path fill="#f2fff7" fill-rule="evenodd" d="
M 212 454
A 300 300 0 1 1 324 687.760561259
L 324 814
A 56 56 0 0 1 212 814
Z
M 324 454
A 188 188 0 1 0 700 454
A 188 188 0 1 0 324 454
Z"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<!-- Constant-width 48-unit capsule, separate editable Composer layer. -->
<path d="M 504 462 L 574 392" fill="none" stroke="#f2fff7" stroke-width="48" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+15 -13
View File
@@ -1,8 +1,8 @@
{ {
"fill" : { "fill" : {
"linear-gradient" : [ "linear-gradient" : [
"display-p3:0.36538,0.78082,0.24349,1.00000", "display-p3:0.14000,0.43000,0.33000,1.00000",
"display-p3:0.22503,0.48767,0.15523,1.00000" "display-p3:0.03500,0.18000,0.16000,1.00000"
], ],
"orientation" : { "orientation" : {
"start" : { "start" : {
@@ -19,30 +19,32 @@
{ {
"layers" : [ "layers" : [
{ {
"image-name" : "puter-white.png", "fill" : {
"name" : "puter-white", "solid" : "display-p3:0.95000,1.00000,0.97000,1.00000"
"opacity-specializations" : [ },
{ "image-name" : "loop-p-geometric.svg",
"appearance" : "dark", "name" : "Circular loop p · 112pt",
"value" : 0.6
}
],
"position" : { "position" : {
"scale" : 0.9, "scale" : 1,
"translation-in-points" : [ "translation-in-points" : [
0, 0,
0 0
] ]
} }
},
{
"image-name" : "meter-needle-geometric.svg",
"name" : "Meter needle",
"fill" : { "solid" : "display-p3:0.95000,1.00000,0.97000,1.00000" }
} }
], ],
"shadow" : { "shadow" : {
"kind" : "neutral", "kind" : "neutral",
"opacity" : 0.5 "opacity" : 0.35
}, },
"translucency" : { "translucency" : {
"enabled" : true, "enabled" : true,
"value" : 0.5 "value" : 0.25
} }
} }
], ],