Refresh puter UI, power telemetry and demand-aware monitoring
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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 650–690 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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -76,6 +76,7 @@ struct SettingsView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@EnvironmentObject private var updates: UpdateController
|
||||
@AppStorage("alwaysOnTop") private var alwaysOnTop = false
|
||||
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
|
||||
@AppStorage("defaultStartPage") private var defaultStartPage: TaskSection = .processes
|
||||
@AppStorage("diagnosticReportDuration") private var diagnosticDuration = 5
|
||||
@AppStorage("diagnosticAskEveryTime") private var diagnosticAskEveryTime = true
|
||||
@@ -96,6 +97,11 @@ struct SettingsView: View {
|
||||
PageHeader(title: "Settings", subtitle: "Customize puter")
|
||||
Form {
|
||||
Section("General") {
|
||||
Picker("Appearance", selection: $appearance) {
|
||||
ForEach(PuterAppearance.allCases) { appearance in
|
||||
Text(appearance.rawValue).tag(appearance)
|
||||
}
|
||||
}
|
||||
Picker("Default start page", selection: $defaultStartPage) {
|
||||
ForEach(TaskSection.allCases.filter { $0 != .settings }) { section in
|
||||
Text(section.rawValue).tag(section)
|
||||
@@ -113,9 +119,11 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section("Process data") {
|
||||
LabeledContent("Refresh interval", value: intervalLabel)
|
||||
LabeledContent("Live-view interval", value: intervalLabel)
|
||||
LabeledContent("Process source", value: "macOS process table")
|
||||
LabeledContent("Memory units", value: "Automatic")
|
||||
Text("Process and service scans run only for visible consumers or recording. Power-source and volume changes use system notifications. With no live consumers, scheduled sampling stops.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Menu bar") {
|
||||
|
||||
@@ -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
@@ -12,17 +12,32 @@ struct PageHeader<Trailing: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.title.weight(.semibold))
|
||||
if let subtitle { Text(subtitle).font(.callout).foregroundStyle(.secondary) }
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
heading.fixedSize(horizontal: true, vertical: false)
|
||||
Spacer(minLength: 0)
|
||||
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
heading.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassActions { trailing() }.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, PuterLayout.pageInset)
|
||||
.padding(.vertical, PuterLayout.sectionSpacing)
|
||||
}
|
||||
|
||||
private var heading: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.title.weight(.semibold)).lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.help(subtitle)
|
||||
}
|
||||
Spacer()
|
||||
trailing()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,19 +45,93 @@ struct ResourceSummaryBar: View {
|
||||
let snapshot: SystemSnapshot
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 22) {
|
||||
Label("\(Formatters.percent(snapshot.cpuPercent)) CPU", systemImage: "cpu")
|
||||
Label("\(Formatters.percent(snapshot.memoryPercent)) Memory", systemImage: "memorychip")
|
||||
Label("\(Formatters.percent(snapshot.diskActivePercent)) Disk activity", systemImage: "internaldrive")
|
||||
Label("\(Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate)) Network", systemImage: "network")
|
||||
Spacer()
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: PuterLayout.sectionSpacing) {
|
||||
cpu
|
||||
memory
|
||||
disk
|
||||
network
|
||||
}
|
||||
Grid(alignment: .leading, horizontalSpacing: PuterLayout.sectionSpacing, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
GridRow {
|
||||
disk
|
||||
network
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color.accentColor.opacity(0.06))
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, PuterLayout.pageInset)
|
||||
.padding(.bottom, PuterLayout.sectionSpacing)
|
||||
.padding(.top, PuterLayout.controlSpacing)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
|
||||
private var cpu: some View {
|
||||
ResourceSummaryMetric(title: "CPU", symbol: "cpu", value: Formatters.percent(snapshot.cpuPercent), color: .blue, percent: snapshot.cpuPercent)
|
||||
}
|
||||
private var memory: some View {
|
||||
ResourceSummaryMetric(title: "Memory", symbol: "memorychip", value: Formatters.percent(snapshot.memoryPercent), color: .purple, percent: snapshot.memoryPercent)
|
||||
}
|
||||
private var disk: some View {
|
||||
ResourceSummaryMetric(title: "Disk activity", symbol: "internaldrive", value: Formatters.percent(snapshot.diskActivePercent), color: .orange, percent: snapshot.diskActivePercent)
|
||||
}
|
||||
private var network: some View {
|
||||
ResourceSummaryMetric(title: "Network", symbol: "network", value: Formatters.rate(snapshot.networkReceiveRate + snapshot.networkSendRate), color: .teal, percent: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResourceSummaryMetric: View {
|
||||
let title: String
|
||||
let symbol: String
|
||||
let value: String
|
||||
let color: Color
|
||||
let percent: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label {
|
||||
Text(title).foregroundStyle(.secondary)
|
||||
} icon: {
|
||||
Image(systemName: symbol).foregroundStyle(color)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
Text(value).font(.title2.weight(.semibold)).lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
if let percent {
|
||||
ProgressView(value: min(max(percent, 0), 100), total: 100)
|
||||
.tint(color)
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
Text("Receive + send").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 112, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessInventoryPlaceholder: View {
|
||||
let isLoading: Bool
|
||||
let searchText: String
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
ProgressView("Collecting processes…")
|
||||
.controlSize(.small)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if !searchText.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
ContentUnavailableView("No processes available", systemImage: "list.bullet.rectangle", description: Text("Use Refresh to request a new process snapshot."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdateStatus: View {
|
||||
|
||||
+102
-134
@@ -9,6 +9,7 @@ struct ContentView: View {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@State private var selection: TaskSection?
|
||||
@State private var searchText = ""
|
||||
@State private var sectionSearches: [TaskSection: String] = [:]
|
||||
@State private var showingNewTask = false
|
||||
@State private var detailSelection: Int32?
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
@@ -19,23 +20,29 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
HStack(spacing: 0) {
|
||||
Sidebar(selection: $selection, compact: sidebarCompact)
|
||||
.navigationSplitViewColumnWidth(
|
||||
min: sidebarCompact ? 68 : 190,
|
||||
ideal: sidebarCompact ? 68 : 210,
|
||||
max: sidebarCompact ? 68 : 260
|
||||
)
|
||||
} detail: {
|
||||
.frame(width: (sidebarCompact ? PuterLayout.compactSidebarWidth : PuterLayout.expandedSidebarWidth) - 16)
|
||||
.modifier(NavigationGlass())
|
||||
.padding(8)
|
||||
detailView
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.background, in: .rect(cornerRadius: 16))
|
||||
.clipShape(.rect(cornerRadius: 16))
|
||||
.padding(.vertical, 8)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.regularMaterial)
|
||||
.toolbarBackground(.regularMaterial, for: .windowToolbar)
|
||||
.toolbar {
|
||||
TaskToolbarContent(showingNewTask: $showingNewTask, sidebarCompact: $sidebarCompact,
|
||||
searchText: $searchText, searchPrompt: searchPrompt)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
.navigationTitle("")
|
||||
.background(WindowToolbarCleaner())
|
||||
.onChange(of: selection) { _, section in
|
||||
searchText = ""
|
||||
.onChange(of: selection) { previous, section in
|
||||
sectionSearches[previous ?? .processes] = searchText
|
||||
searchText = sectionSearches[section ?? .processes] ?? ""
|
||||
monitor.setActiveSection(section ?? .processes)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .runNewTask)) { _ in showingNewTask = true }
|
||||
@@ -64,34 +71,26 @@ struct ContentView: View {
|
||||
switch selection ?? .processes {
|
||||
case .processes:
|
||||
ProcessesView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
showDetails(for: process)
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search processes by name, PID, or user")
|
||||
case .performance:
|
||||
PerformanceView()
|
||||
case .history:
|
||||
AppHistoryView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
showDetails(for: process)
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search app history")
|
||||
case .startup:
|
||||
StartupAppsView()
|
||||
case .users:
|
||||
UsersView { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
showDetails(for: process)
|
||||
}
|
||||
case .details:
|
||||
DetailsView(searchText: searchText, selection: $detailSelection)
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search details by name, PID, or user")
|
||||
case .services:
|
||||
ServicesView(searchText: searchText) { process in
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
showDetails(for: process)
|
||||
}
|
||||
.searchable(text: $searchText, placement: .toolbar, prompt: "Search services by name, PID, or identifier")
|
||||
case .energy:
|
||||
EnergyView()
|
||||
case .diagnostics:
|
||||
@@ -102,132 +101,99 @@ struct ContentView: View {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
TaskToolbarContent(showingNewTask: $showingNewTask)
|
||||
}
|
||||
|
||||
private var searchPrompt: String? {
|
||||
switch selection ?? .processes {
|
||||
case .processes: "Search processes"
|
||||
case .history: "Search app history"
|
||||
case .details: "Search details"
|
||||
case .services: "Search services"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct WindowToolbarCleaner: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> CleanerView {
|
||||
CleanerView()
|
||||
private func showDetails(for process: ProcessRecord) {
|
||||
sectionSearches[.details] = ""
|
||||
detailSelection = process.pid
|
||||
selection = .details
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: CleanerView, context: Context) {
|
||||
nsView.removeAutomaticSidebarToggle()
|
||||
nsView.installCenteredBrand()
|
||||
}
|
||||
|
||||
final class CleanerView: NSView {
|
||||
private weak var installedTitlebar: NSView?
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
removeAutomaticSidebarToggle()
|
||||
installCenteredBrand()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.removeAutomaticSidebarToggle()
|
||||
self?.installCenteredBrand()
|
||||
}
|
||||
}
|
||||
|
||||
func installCenteredBrand() {
|
||||
guard installedTitlebar == nil,
|
||||
let closeButton = window?.standardWindowButton(.closeButton),
|
||||
let titlebar = closeButton.superview else { return }
|
||||
let brand = PassthroughHostingView(rootView: PuterBrand(compactTitlebar: true))
|
||||
brand.translatesAutoresizingMaskIntoConstraints = false
|
||||
titlebar.addSubview(brand)
|
||||
NSLayoutConstraint.activate([
|
||||
brand.centerXAnchor.constraint(equalTo: titlebar.centerXAnchor),
|
||||
brand.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor)
|
||||
])
|
||||
installedTitlebar = titlebar
|
||||
}
|
||||
|
||||
func removeAutomaticSidebarToggle() {
|
||||
guard let toolbar = window?.toolbar,
|
||||
let index = toolbar.items.firstIndex(where: {
|
||||
$0.itemIdentifier == .toggleSidebar
|
||||
|| $0.action == NSSelectorFromString("toggleSidebar:")
|
||||
|| $0.label == "Hide Sidebar"
|
||||
|| $0.label == "Show Sidebar"
|
||||
|| $0.toolTip == "Hide Sidebar"
|
||||
|| $0.toolTip == "Show Sidebar"
|
||||
|| $0.view?.accessibilityLabel() == "Hide Sidebar"
|
||||
|| $0.view?.accessibilityLabel() == "Show Sidebar"
|
||||
}) else { return }
|
||||
toolbar.removeItem(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class PassthroughHostingView<Content: View>: NSHostingView<Content> {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
}
|
||||
|
||||
private struct TaskToolbarContent: ToolbarContent {
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@Binding var showingNewTask: Bool
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
@Binding var sidebarCompact: Bool
|
||||
@Binding var searchText: String
|
||||
let searchPrompt: String?
|
||||
|
||||
var body: some ToolbarContent {
|
||||
@Bindable var monitor = monitor
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
sidebarCompact.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
GlassActions {
|
||||
Button {
|
||||
sidebarCompact.toggle()
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.buttonStyle(ToolbarIconButtonStyle())
|
||||
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
|
||||
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
|
||||
}
|
||||
.help(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
|
||||
.accessibilityLabel(sidebarCompact ? "Expand Sidebar" : "Compact Sidebar")
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
showingNewTask = true
|
||||
} label: {
|
||||
Label("Run new task", systemImage: "plus.square")
|
||||
}
|
||||
.help("Launch an application or executable")
|
||||
|
||||
Button {
|
||||
monitor.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh now (⌘R)")
|
||||
|
||||
Menu {
|
||||
UpdateSpeedPicker(selection: $monitor.updateSpeed)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
ToolbarItem(placement: .principal) {
|
||||
PuterBrand()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private struct PuterBrand: View {
|
||||
let compactTitlebar: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: compactTitlebar ? 21 : 30, height: compactTitlebar ? 21 : 30)
|
||||
Text("puter")
|
||||
.font(compactTitlebar ? .headline : .title2.weight(.semibold))
|
||||
if #available(macOS 26, *) {
|
||||
ToolbarSpacer(.flexible, placement: .primaryAction)
|
||||
}
|
||||
.padding(.horizontal, compactTitlebar ? 8 : 14)
|
||||
.frame(height: compactTitlebar ? 32 : 52)
|
||||
.fixedSize()
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("puter")
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
GlassActions {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
showingNewTask = true
|
||||
} label: {
|
||||
Label("Run new task", systemImage: "plus.square")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.accessibilityLabel("Run new task")
|
||||
.buttonStyle(ToolbarIconButtonStyle())
|
||||
.help("Launch an application or executable")
|
||||
|
||||
Button {
|
||||
monitor.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.accessibilityLabel("Refresh")
|
||||
.buttonStyle(ToolbarIconButtonStyle())
|
||||
.help("Refresh now (⌘R)")
|
||||
|
||||
Menu {
|
||||
UpdateSpeedPicker(selection: $monitor.updateSpeed)
|
||||
} label: {
|
||||
Label("Update speed", systemImage: "ellipsis")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: PuterLayout.toolbarControlHeight)
|
||||
.modifier(SearchGlassSurface())
|
||||
|
||||
if let searchPrompt {
|
||||
ToolbarSearchField(text: $searchText, prompt: searchPrompt)
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonBorderShape(.circle)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +213,7 @@ private struct Sidebar: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollIndicators(compact ? .hidden : .automatic)
|
||||
.accessibilityLabel("Navigation")
|
||||
}
|
||||
@@ -261,11 +228,12 @@ private struct Sidebar: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 28 : nil, alignment: compact ? .center : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: compact ? .center : .leading)
|
||||
.frame(height: PuterLayout.sidebarRowHeight)
|
||||
.contentShape(Rectangle())
|
||||
.tag(section)
|
||||
.help(section.rawValue)
|
||||
.accessibilityLabel(section.rawValue)
|
||||
.listRowInsets(compact ? EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) : nil)
|
||||
.listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ struct HardwareView: View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 10)], spacing: 10) {
|
||||
summaryCard("CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), detail: "\(monitor.snapshot.corePercents.count) logical processors", icon: "cpu", color: .blue)
|
||||
summaryCard("Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), detail: Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed)), icon: "memorychip", color: .purple)
|
||||
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.externalPowerConnected ? "External power" : "Battery power", icon: "bolt.fill", color: .orange)
|
||||
summaryCard("System power", value: watts(battery.systemPowerWatts), detail: battery.powerSourceDescription, icon: "bolt.fill", color: .orange)
|
||||
summaryCard("Thermals", value: monitor.hardware.thermalState, detail: thermalDetail, icon: "thermometer.medium", color: thermalColor)
|
||||
}
|
||||
}
|
||||
@@ -102,19 +102,19 @@ struct HardwareView: View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Charge", Formatters.percent(battery.chargePercent))
|
||||
Spacer()
|
||||
metric("Condition", battery.healthPercent > 80 ? "Normal" : "Service recommended")
|
||||
metric("Capacity status", battery.designCapacityMAh > 0 ? (battery.healthPercent > 80 ? "Normal capacity" : "Reduced capacity") : "Not reported")
|
||||
Spacer()
|
||||
metric("Health", Formatters.percent(battery.healthPercent))
|
||||
metric("Health", battery.designCapacityMAh > 0 ? Formatters.percent(battery.healthPercent) : "Not reported")
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Cycles", cycleText)
|
||||
Spacer()
|
||||
metric("Temperature", String(format: "%.1f °C", battery.temperatureCelsius))
|
||||
metric("Temperature", battery.hasBatteryTemperature ? String(format: "%.1f °C", battery.temperatureCelsius) : "Not reported")
|
||||
Spacer()
|
||||
metric("Voltage", String(format: "%.2f V", battery.voltageVolts))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
metric("Battery power", watts(battery.batteryPowerWatts))
|
||||
metric(battery.batteryFlowTitle, battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "Not reported")
|
||||
Spacer()
|
||||
metric("Full capacity", capacity(battery.fullChargeCapacityMAh))
|
||||
Spacer()
|
||||
@@ -145,14 +145,17 @@ struct HardwareView: View {
|
||||
Text(adapter.manufacturer).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(Int(adapter.ratedWatts.rounded())) W").font(.title2.weight(.semibold)).monospacedDigit()
|
||||
Text("Reported capability").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
HStack(spacing: 24) {
|
||||
metric("Negotiated voltage", String(format: "%.1f V", adapter.negotiatedVoltage))
|
||||
metric("Current limit", String(format: "%.2f A", adapter.negotiatedCurrent))
|
||||
metric("Negotiated ceiling", watts(adapter.negotiatedWatts))
|
||||
metric("Adapter input", watts(battery.adapterInputWatts))
|
||||
metric("Charger → Mac", battery.hasAdapterInput ? String(format: "%.1f W", battery.adapterInputWatts) : "Not reported")
|
||||
metric("System draw", watts(battery.systemPowerWatts))
|
||||
Spacer()
|
||||
}
|
||||
@@ -465,7 +468,7 @@ struct HardwareView: View {
|
||||
private var verdictDetail: String {
|
||||
guard battery.isPresent else { return "Battery-specific fields are hidden because this Mac reports no internal battery." }
|
||||
if let adapter = battery.adapter {
|
||||
return "macOS reports a \(Int(adapter.ratedWatts.rounded())) W adapter and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Current system input is \(watts(battery.systemPowerWatts))."
|
||||
return "macOS reports \(Int(adapter.ratedWatts.rounded())) W available and a \(watts(adapter.negotiatedWatts)) negotiated ceiling. Measured charger input is \(battery.hasAdapterInput ? watts(battery.adapterInputWatts) : "not reported"); system load is \(watts(battery.systemPowerWatts)). Input includes charging and is not a wall-socket measurement."
|
||||
}
|
||||
return "Current battery draw is \(watts(battery.batteryPowerWatts)). Charger and cable capability are shown only when macOS publishes them."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,95 +2,60 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarMonitorView: View {
|
||||
var openMain: (() -> Void)? = nil
|
||||
@Environment(SystemMonitor.self) private var monitor
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
private var topProcesses: [ProcessRecord] {
|
||||
monitor.processes.sorted { $0.cpu > $1.cpu }.prefix(5).map { $0 }
|
||||
}
|
||||
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Image(nsImage: NSApp.applicationIconImage).resizable().scaledToFit().frame(width: 30, height: 30)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("puter").font(.headline)
|
||||
if let updated = monitor.lastUpdated {
|
||||
Text("Updated \(updated, style: .relative)").font(.caption).foregroundStyle(.secondary)
|
||||
} else { Text("Collecting…").font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
Text("puter").font(.system(size: 23, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
Circle().fill(monitor.isPaused ? .orange : .green).frame(width: 8, height: 8)
|
||||
Label(monitor.isPaused ? "Paused" : "Live", systemImage: monitor.isPaused ? "pause.circle" : "waveform.path")
|
||||
.font(.caption.weight(.medium)).foregroundStyle(.secondary)
|
||||
Menu { MenuBarMonitorActions() } label: {
|
||||
Image(systemName: "ellipsis").accessibilityLabel("Monitor options")
|
||||
}
|
||||
.modifier(GlassMenuSurface())
|
||||
}
|
||||
HStack(spacing: 24) {
|
||||
MenuBarResourceValue(title: "CPU", value: Formatters.percent(monitor.snapshot.cpuPercent), symbol: "cpu", color: .blue)
|
||||
MenuBarResourceValue(title: "Memory", value: Formatters.percent(monitor.snapshot.memoryPercent), symbol: "memorychip", color: .purple)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.background.opacity(0.65), in: .rect(cornerRadius: 16))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
metric("CPU", Formatters.percent(monitor.snapshot.cpuPercent), .blue)
|
||||
metric("Memory", Formatters.percent(monitor.snapshot.memoryPercent), pressureColor)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
metric("Network", Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate), .teal)
|
||||
metric("Power", powerText, .orange)
|
||||
}
|
||||
MenuBarPowerView(battery: monitor.hardware.battery, capturedAt: monitor.hardware.capturedAt, paused: monitor.isPaused)
|
||||
|
||||
HStack {
|
||||
Label("Memory pressure", systemImage: "memorychip")
|
||||
Label("Network", systemImage: "network")
|
||||
Spacer()
|
||||
Text(monitor.snapshot.memoryPressure.rawValue).foregroundStyle(pressureColor)
|
||||
Text(Formatters.rate(monitor.snapshot.networkReceiveRate + monitor.snapshot.networkSendRate)).monospacedDigit()
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
Text("Top CPU processes").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
if topProcesses.isEmpty {
|
||||
Text("No process data yet").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(topProcesses) { process in
|
||||
HStack(spacing: 8) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath, size: 18)
|
||||
Text(process.displayName).font(.caption).lineLimit(1)
|
||||
Spacer()
|
||||
Text(Formatters.percent(process.cpu)).font(.caption.monospacedDigit())
|
||||
}
|
||||
GlassActions {
|
||||
HStack(spacing: 8) {
|
||||
Button(monitor.isPaused ? "Resume" : "Pause", systemImage: monitor.isPaused ? "play" : "pause") {
|
||||
monitor.isPaused.toggle()
|
||||
}.labelStyle(.iconOnly)
|
||||
Button("Refresh", systemImage: "arrow.clockwise", action: monitor.refresh).labelStyle(.iconOnly)
|
||||
Spacer()
|
||||
Button("Open puter", systemImage: "arrow.up.right", action: showMainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Button(monitor.isPaused ? "Resume" : "Pause") { monitor.isPaused.toggle() }
|
||||
Button("Refresh") { monitor.refresh() }
|
||||
Spacer()
|
||||
Button("Open puter") {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 320)
|
||||
.padding(20)
|
||||
.frame(width: 368)
|
||||
.modifier(MenuBarGlassSurface())
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.contextMenu { MenuBarMonitorActions() }
|
||||
}
|
||||
|
||||
private func metric(_ title: String, _ value: String, _ color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.caption2).foregroundStyle(.secondary)
|
||||
Text(value).font(.callout.weight(.semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(9)
|
||||
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var pressureColor: Color {
|
||||
switch monitor.snapshot.memoryPressure {
|
||||
case .normal: .green
|
||||
case .warning: .orange
|
||||
case .critical: .red
|
||||
}
|
||||
}
|
||||
|
||||
private var powerText: String {
|
||||
let watts = monitor.hardware.battery.systemPowerWatts
|
||||
return watts > 0 ? String(format: "%.1f W", watts) : "Not reported"
|
||||
private func showMainWindow() {
|
||||
if let openMain { openMain(); return }
|
||||
openWindow(id: "main")
|
||||
NSApp.activate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,27 @@ struct BatterySnapshot: Hashable, Sendable {
|
||||
var systemPowerWatts = 0.0
|
||||
var sensorBatteryPowerWatts = 0.0
|
||||
var adapterInputWatts = 0.0
|
||||
var hasBatteryCurrent = false
|
||||
var hasBatteryTemperature = false
|
||||
var hasAdapterInput = false
|
||||
var hasSystemPower = false
|
||||
|
||||
var batteryFlowTitle: String {
|
||||
if !isPresent { return "Battery unavailable" }
|
||||
if currentAmps < 0 { return "Battery discharge" }
|
||||
if isCharging || currentAmps > 0 { return "Battery charging" }
|
||||
return "Battery idle"
|
||||
}
|
||||
|
||||
var powerSourceDescription: String {
|
||||
!isPresent ? "External power" : (externalPowerConnected ? "Charger connected" : "On battery")
|
||||
}
|
||||
|
||||
var measuredBatteryWatts: Double? {
|
||||
guard isPresent else { return nil }
|
||||
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
|
||||
return sensorBatteryPowerWatts > 0 ? sensorBatteryPowerWatts : nil
|
||||
}
|
||||
|
||||
var healthPercent: Double {
|
||||
guard designCapacityMAh > 0 else { return 0 }
|
||||
@@ -88,6 +109,7 @@ struct BatterySnapshot: Hashable, Sendable {
|
||||
}
|
||||
|
||||
var batteryPowerWatts: Double {
|
||||
if hasBatteryCurrent { return abs(voltageVolts * currentAmps) }
|
||||
if sensorBatteryPowerWatts > 0 { return sensorBatteryPowerWatts }
|
||||
let watts = abs(voltageVolts * currentAmps)
|
||||
return watts.isFinite && watts <= 500 ? watts : 0
|
||||
@@ -256,7 +278,10 @@ enum MenuBarMetric: String, CaseIterable, Identifiable {
|
||||
case memory = "Memory"
|
||||
case network = "Network"
|
||||
case power = "Power"
|
||||
case batteryPower = "Battery power"
|
||||
case chargerInput = "Charger input"
|
||||
var id: String { rawValue }
|
||||
var requiresPower: Bool { [.power, .batteryPower, .chargerInput].contains(self) }
|
||||
}
|
||||
|
||||
struct ProcessRecord: Identifiable, Hashable, Sendable {
|
||||
@@ -505,6 +530,7 @@ enum Formatters {
|
||||
static let bytes: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .memory
|
||||
formatter.allowsNonnumericFormatting = false
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@@ -65,7 +65,7 @@ struct PerformanceView: View {
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 4) {
|
||||
metricCard(.cpu, value: monitor.snapshot.cpuPercent, detail: "\(ProcessInfo.processInfo.activeProcessorCount) logical processors")
|
||||
metricCard(.memory, value: monitor.snapshot.memoryPercent, detail: "\(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryUsed))) of \(Formatters.bytes.string(fromByteCount: Int64(monitor.snapshot.memoryTotal))) • \(monitor.snapshot.memoryPressure.rawValue) pressure")
|
||||
metricCard(
|
||||
@@ -89,10 +89,10 @@ struct PerformanceView: View {
|
||||
detail: "↓ \(Formatters.rate(monitor.snapshot.networkReceiveRate)) ↑ \(Formatters.rate(monitor.snapshot.networkSendRate))"
|
||||
)
|
||||
}
|
||||
.padding(14)
|
||||
.padding(10)
|
||||
}
|
||||
.frame(minWidth: 190, idealWidth: 245, maxWidth: 245)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.45))
|
||||
.frame(minWidth: 220, idealWidth: 245, maxWidth: 280)
|
||||
.background(Color(nsColor: .controlBackgroundColor).opacity(0.25))
|
||||
Divider()
|
||||
VStack(spacing: 0) {
|
||||
detail
|
||||
@@ -184,56 +184,59 @@ struct PerformanceView: View {
|
||||
selectedVolumeID = nil
|
||||
selectedMetric = metric
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: metric.icon)
|
||||
.font(.title3)
|
||||
.font(.body)
|
||||
.foregroundStyle(metric.color)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(metric.rawValue).font(.headline)
|
||||
Text(valueText ?? Formatters.percent(value)).font(.title3.monospacedDigit())
|
||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
.frame(width: 22)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(metric.rawValue).font(.headline)
|
||||
Spacer(minLength: 0)
|
||||
Text(valueText ?? Formatters.percent(value))
|
||||
.font(.callout.monospacedDigit())
|
||||
}
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selectedVolumeID == nil && selectedMetric == metric ? metric.color.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke(
|
||||
selectedVolumeID == nil && selectedMetric == metric ? metric.color : Color.secondary.opacity(0.15),
|
||||
lineWidth: selectedVolumeID == nil && selectedMetric == metric ? 1.5 : 1
|
||||
)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.background(selectedVolumeID == nil && selectedMetric == metric ? Color.accentColor.opacity(0.13) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func volumeCard(_ volume: VolumeSnapshot) -> some View {
|
||||
Button { selectedVolumeID = volume.id } label: {
|
||||
HStack(spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: volume.isEjectable ? "externaldrive.badge.checkmark" : "externaldrive")
|
||||
.font(.title3)
|
||||
.font(.body)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(volume.name).font(.headline).lineLimit(1)
|
||||
Text(Formatters.percent(volume.usedPercent)).font(.title3.monospacedDigit())
|
||||
.frame(width: 22)
|
||||
.padding(.top, 2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(volume.name).font(.headline).lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
Text(Formatters.percent(volume.usedPercent))
|
||||
.font(.callout.monospacedDigit())
|
||||
}
|
||||
Text("\(Formatters.bytes.string(fromByteCount: Int64(volume.capacity))) • \(volume.fileSystem)")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(selectedVolumeID == volume.id ? Color.orange.opacity(0.13) : Color(nsColor: .windowBackgroundColor))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.stroke(selectedVolumeID == volume.id ? Color.orange : Color.secondary.opacity(0.15), lineWidth: selectedVolumeID == volume.id ? 1.5 : 1)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.background(selectedVolumeID == volume.id ? Color.accentColor.opacity(0.13) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(volume.name), removable disk")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ private enum ProcessColumn: String, CaseIterable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessCategory: String, CaseIterable, Identifiable {
|
||||
enum ProcessCategory: String, CaseIterable, Identifiable {
|
||||
case apps = "Apps"
|
||||
case background = "Background processes"
|
||||
case system = "macOS processes"
|
||||
@@ -100,7 +100,7 @@ struct ProcessesView: View {
|
||||
@AppStorage("processPreviewPaneVisible") private var previewPaneVisible = false
|
||||
|
||||
private var grouped: [ProcessGroup] {
|
||||
let groups = ProcessGrouping.groups(from: monitor.processes, searchText: searchText)
|
||||
let groups = ProcessGrouping.filtered(monitor.processGroups, searchText: searchText)
|
||||
if groupByType {
|
||||
return groups.sorted {
|
||||
let leftCategory = ProcessCategory.allCases.firstIndex(of: $0.category) ?? 0
|
||||
@@ -150,7 +150,6 @@ struct ProcessesView: View {
|
||||
} label: {
|
||||
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help(previewPaneVisible ? "Hide process preview" : "Show process preview")
|
||||
}
|
||||
}
|
||||
@@ -211,6 +210,11 @@ struct ProcessesView: View {
|
||||
}
|
||||
|
||||
private var processList: some View {
|
||||
let visibleGroups = displayedGroups
|
||||
return processList(groups: visibleGroups)
|
||||
}
|
||||
|
||||
private func processList(groups visibleGroups: [ProcessGroup]) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal) {
|
||||
VStack(spacing: 0) {
|
||||
@@ -236,7 +240,7 @@ struct ProcessesView: View {
|
||||
LazyVStack(spacing: 0) {
|
||||
if groupByType {
|
||||
ForEach(ProcessCategory.allCases) { category in
|
||||
let categoryGroups = displayedGroups.filter { $0.category == category }
|
||||
let categoryGroups = visibleGroups.filter { $0.category == category }
|
||||
if !categoryGroups.isEmpty {
|
||||
ProcessCategoryHeader(
|
||||
category: category,
|
||||
@@ -258,7 +262,7 @@ struct ProcessesView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(displayedGroups) { group in
|
||||
ForEach(visibleGroups) { group in
|
||||
processGroup(group)
|
||||
}
|
||||
}
|
||||
@@ -268,6 +272,11 @@ struct ProcessesView: View {
|
||||
.frame(width: processTableWidth, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.overlay {
|
||||
if visibleGroups.isEmpty {
|
||||
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
UpdateStatus()
|
||||
@@ -447,7 +456,7 @@ private struct ProcessCategoryHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessGroup: Identifiable {
|
||||
struct ProcessGroup: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let appPath: String?
|
||||
@@ -476,7 +485,11 @@ private struct ProcessGroup: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ProcessGrouping {
|
||||
@MainActor
|
||||
enum ProcessGrouping {
|
||||
// Cache both hits and misses; executable paths are immutable keys. Bound
|
||||
// storage so long monitoring sessions don't retain every historical path.
|
||||
private static var appPaths: [String: String] = [:]
|
||||
static func groups(from processes: [ProcessRecord], searchText: String) -> [ProcessGroup] {
|
||||
let foregroundPIDs = Set(NSWorkspace.shared.runningApplications
|
||||
.filter { $0.activationPolicy == .regular }
|
||||
@@ -484,13 +497,22 @@ private enum ProcessGrouping {
|
||||
let grouped = Dictionary(grouping: processes) { process -> String in
|
||||
appPath(for: process.executablePath) ?? "pid:\(process.pid)"
|
||||
}
|
||||
return grouped.compactMap { key, members in
|
||||
let groups = grouped.map { key, members in
|
||||
let path = key.hasPrefix("pid:") ? nil : key
|
||||
let name = path.map { URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent }
|
||||
?? members[0].displayName
|
||||
let category = category(for: members, appPath: path, foregroundPIDs: foregroundPIDs)
|
||||
let group = ProcessGroup(id: key, name: name, appPath: path, processes: members, category: category)
|
||||
guard !searchText.isEmpty else { return group }
|
||||
return group
|
||||
}
|
||||
return filtered(groups, searchText: searchText)
|
||||
}
|
||||
|
||||
static func filtered(_ groups: [ProcessGroup], searchText: String) -> [ProcessGroup] {
|
||||
guard !searchText.isEmpty else { return groups }
|
||||
return groups.compactMap { group in
|
||||
let name = group.name
|
||||
let members = group.processes
|
||||
let groupMatches = name.localizedCaseInsensitiveContains(searchText)
|
||||
let matchingMembers = members.filter {
|
||||
$0.displayName.localizedCaseInsensitiveContains(searchText)
|
||||
@@ -499,11 +521,11 @@ private enum ProcessGrouping {
|
||||
}
|
||||
guard groupMatches || !matchingMembers.isEmpty else { return nil }
|
||||
return ProcessGroup(
|
||||
id: key,
|
||||
id: group.id,
|
||||
name: name,
|
||||
appPath: path,
|
||||
appPath: group.appPath,
|
||||
processes: groupMatches ? members : matchingMembers,
|
||||
category: category
|
||||
category: group.category
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -527,8 +549,12 @@ private enum ProcessGrouping {
|
||||
}
|
||||
|
||||
private static func appPath(for executablePath: String) -> String? {
|
||||
guard let range = executablePath.range(of: ".app/Contents/", options: .caseInsensitive) else { return nil }
|
||||
return String(executablePath[..<range.lowerBound]) + ".app"
|
||||
if let cached = appPaths[executablePath] { return cached.isEmpty ? nil : cached }
|
||||
let path = executablePath.range(of: ".app/Contents/", options: .caseInsensitive)
|
||||
.map { String(executablePath[..<$0.lowerBound]) + ".app" }
|
||||
if appPaths.count >= 4096 { appPaths.removeAll(keepingCapacity: true) }
|
||||
appPaths[executablePath] = path ?? ""
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,7 +728,7 @@ private struct ProcessRow: View {
|
||||
} else {
|
||||
Color.clear.frame(width: 16)
|
||||
}
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath, size: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(process.displayName).lineLimit(1).truncationMode(.tail)
|
||||
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
|
||||
@@ -728,7 +754,7 @@ private struct ProcessRow: View {
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minHeight: 48)
|
||||
.frame(minHeight: 42)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
}
|
||||
@@ -777,17 +803,18 @@ private struct ProcessGroupRow: View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
.frame(width: 16, height: 44)
|
||||
.frame(width: 16, height: 38)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
|
||||
.accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)")
|
||||
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath)
|
||||
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath, size: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail)
|
||||
Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
|
||||
Text(group.name).lineLimit(1).truncationMode(.tail)
|
||||
Text("\(group.processes.count) processes")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
@@ -811,7 +838,7 @@ private struct ProcessGroupRow: View {
|
||||
}
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minHeight: 48)
|
||||
.frame(minHeight: 42)
|
||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -931,11 +958,11 @@ private struct HeatCell: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.truncationMode(.tail)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, processNumericTextInset)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.clipped()
|
||||
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
|
||||
.background(Color.accentColor.opacity(min(0.22, max(0, intensity) * 0.22)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,27 @@ final class PuterAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificatio
|
||||
struct PuterApp: App {
|
||||
@NSApplicationDelegateAdaptor(PuterAppDelegate.self) private var appDelegate
|
||||
@State private var monitor = SystemMonitor()
|
||||
@State private var statusBar = StatusBarController()
|
||||
@StateObject private var updates = UpdateController()
|
||||
@AppStorage("sidebarCompact") private var sidebarCompact = false
|
||||
@AppStorage("processPreviewPaneVisible") private var processPreviewPaneVisible = false
|
||||
@AppStorage("showMenuBarMonitor") private var showMenuBarMonitor = true
|
||||
@AppStorage("menuBarMetric") private var menuBarMetric: MenuBarMetric = .cpu
|
||||
@AppStorage("appAppearance") private var appearance: PuterAppearance = .system
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("puter", id: "main") {
|
||||
ContentView()
|
||||
.environment(monitor)
|
||||
.environmentObject(updates)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.frame(minWidth: 820, minHeight: 560)
|
||||
.task { monitor.start() }
|
||||
.background(StatusBarBridge(controller: statusBar, monitor: monitor, visible: showMenuBarMonitor, value: menuBarValue))
|
||||
}
|
||||
.defaultSize(width: 1180, height: 760)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") { updates.checkForUpdates() }
|
||||
@@ -45,8 +51,11 @@ struct PuterApp: App {
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
SidebarCommands()
|
||||
CommandMenu("Options") {
|
||||
Button("Show menu-bar monitor") { statusBar.togglePanel() }
|
||||
.keyboardShortcut("m", modifiers: [.command, .shift])
|
||||
.disabled(!showMenuBarMonitor)
|
||||
Divider()
|
||||
Button("Refresh now") { monitor.refresh() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
Divider()
|
||||
@@ -61,14 +70,6 @@ struct PuterApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra(isInserted: $showMenuBarMonitor) {
|
||||
MenuBarMonitorView()
|
||||
.environment(monitor)
|
||||
.environmentObject(updates)
|
||||
} label: {
|
||||
Label(menuBarValue, systemImage: "gauge.with.dots.needle.50percent")
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
|
||||
private var menuBarValue: String {
|
||||
@@ -79,6 +80,11 @@ struct PuterApp: App {
|
||||
case .power:
|
||||
monitor.hardware.battery.systemPowerWatts > 0
|
||||
? String(format: "%.0f W", monitor.hardware.battery.systemPowerWatts) : "— W"
|
||||
case .batteryPower:
|
||||
monitor.hardware.battery.measuredBatteryWatts.map { String(format: "%.1f W", $0) } ?? "— W"
|
||||
case .chargerInput:
|
||||
monitor.hardware.battery.hasAdapterInput
|
||||
? String(format: "%.1f W", monitor.hardware.battery.adapterInputWatts) : "— W"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,7 +835,8 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .name: 260
|
||||
case .user: 140
|
||||
case .architecture, .state: 105
|
||||
case .architecture: 125
|
||||
case .state: 105
|
||||
case .cpuTime, .memory, .disk, .network, .elapsed: 112
|
||||
case .parentPID: 92
|
||||
default: 78
|
||||
@@ -846,6 +847,7 @@ private enum DetailColumn: String, CaseIterable, Identifiable {
|
||||
case .name: 170
|
||||
case .user: 90
|
||||
case .cpuTime, .memory, .disk, .network, .elapsed: 82
|
||||
case .architecture: 110
|
||||
default: 62
|
||||
}
|
||||
}
|
||||
@@ -888,12 +890,16 @@ struct DetailsView: View {
|
||||
} label: {
|
||||
Label(previewPaneVisible ? "Hide preview" : "Show preview", systemImage: "sidebar.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Properties", systemImage: "info.circle") {
|
||||
inspectedProcess = selectedProcess
|
||||
}
|
||||
.disabled(selectedProcess == nil)
|
||||
Menu("Columns", systemImage: "rectangle.split.3x1") {
|
||||
Button("Show essential columns") {
|
||||
visibleColumnStorage = [DetailColumn.name, .pid, .user, .cpu, .memory]
|
||||
.map(\.rawValue).joined(separator: ",")
|
||||
}
|
||||
Divider()
|
||||
ForEach(DetailColumn.allCases) { column in
|
||||
Toggle(column.title, isOn: columnBinding(column))
|
||||
.disabled(column == .name || column == .pid)
|
||||
@@ -905,6 +911,8 @@ struct DetailsView: View {
|
||||
persistColumnWidths()
|
||||
}
|
||||
}
|
||||
.modifier(GlassMenuSurface())
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||
@@ -942,6 +950,11 @@ struct DetailsView: View {
|
||||
}
|
||||
.frame(width: max(tableWidth, proxy.size.width), alignment: .leading)
|
||||
}
|
||||
.overlay {
|
||||
if filtered.isEmpty {
|
||||
ProcessInventoryPlaceholder(isLoading: monitor.lastUpdated == nil, searchText: searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -964,7 +977,7 @@ struct DetailsView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption.weight(.medium))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
@@ -986,11 +999,6 @@ struct DetailsView: View {
|
||||
.background(rowBackground(process: process, index: index))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selection = process.pid }
|
||||
.focusable()
|
||||
.onKeyPress(.return) {
|
||||
selection = process.pid
|
||||
return .handled
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(process.displayName), PID \(process.pid), \(process.user), \(readableState(process.state))")
|
||||
.accessibilityValue("CPU \(Formatters.percent(process.cpu)), memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))), \(process.threadCount) threads")
|
||||
@@ -1011,10 +1019,17 @@ struct DetailsView: View {
|
||||
private func detailCell(_ process: ProcessRecord, column: DetailColumn) -> some View {
|
||||
switch column {
|
||||
case .name:
|
||||
HStack(spacing: 8) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).lineLimit(1).truncationMode(.tail)
|
||||
Button {
|
||||
selection = process.pid
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).lineLimit(1).truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case .pid: Text("\(process.pid)").monospacedDigit()
|
||||
case .parentPID: Text("\(process.parentPID)").monospacedDigit()
|
||||
case .user: Text(process.user).lineLimit(1)
|
||||
@@ -1076,25 +1091,38 @@ struct DetailsView: View {
|
||||
Divider()
|
||||
HStack(spacing: 10) {
|
||||
if let process = selectedProcess {
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
Text("PID \(process.pid)").foregroundStyle(.secondary).monospacedDigit()
|
||||
Text("•").foregroundStyle(.tertiary)
|
||||
Text(Formatters.percent(process.cpu) + " CPU").monospacedDigit()
|
||||
Text("•").foregroundStyle(.tertiary)
|
||||
Text(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))).monospacedDigit()
|
||||
Spacer()
|
||||
ProcessIcon(name: process.displayName, path: process.executablePath, size: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(process.displayName).font(.callout.weight(.semibold)).lineLimit(1)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 10) {
|
||||
Text("PID \(process.pid)")
|
||||
Text("CPU \(Formatters.percent(process.cpu))")
|
||||
Text("Memory \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)))")
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Text("PID \(process.pid)").fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("End task") { terminationRequest = .init(process: process, kind: .normal) }
|
||||
Button("Properties") { inspectedProcess = process }.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button("Properties") { inspectedProcess = process }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text("\(filtered.count) processes").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("Select a process for actions and live details").foregroundStyle(.tertiary)
|
||||
Text("Select a process to inspect it").foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.font(.callout)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(minHeight: 48)
|
||||
.frame(minHeight: 52)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -9,6 +9,7 @@ import UserNotifications
|
||||
@Observable
|
||||
final class SystemMonitor {
|
||||
var processes: [ProcessRecord] = []
|
||||
var processGroups: [ProcessGroup] = []
|
||||
var services: [ServiceRecord] = []
|
||||
var appHistory: [AppUsageRecord] = []
|
||||
var appHistoryStartDate = Date()
|
||||
@@ -29,18 +30,34 @@ final class SystemMonitor {
|
||||
var hardware = HardwareSnapshot()
|
||||
var systemPowerHistory: [MetricSample] = []
|
||||
var updateSpeed: UpdateSpeed {
|
||||
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
|
||||
didSet {
|
||||
UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed")
|
||||
rescheduleSampling()
|
||||
}
|
||||
}
|
||||
var lastUpdated: Date?
|
||||
var errorMessage: String?
|
||||
var isRecording = false
|
||||
var isRecording = false { didSet { rescheduleSampling() } }
|
||||
var menuBarPresented = false {
|
||||
didSet {
|
||||
guard menuBarPresented != oldValue else { return }
|
||||
rescheduleSampling()
|
||||
if menuBarPresented && !isPaused {
|
||||
requestTelemetryRefresh()
|
||||
requestHardwareRefresh(forcePowerRefresh: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
var recordingStartDate: Date?
|
||||
var recordingSamples: [TelemetryRecordingSample] = []
|
||||
var recentAlerts: [ResourceAlertEvent] = []
|
||||
|
||||
private var updateTask: Task<Void, Never>?
|
||||
private var hardwareUpdateTask: Task<Void, Never>?
|
||||
private var serviceUpdateTask: Task<Void, Never>?
|
||||
private var started = false
|
||||
private var scheduledDemand: SamplingDemand?
|
||||
private var processEventTask: Task<Void, Never>?
|
||||
private var lastHardwareRequest = Date.distantPast
|
||||
private var lastServiceRequest = Date.distantPast
|
||||
private var telemetryRefreshTask: Task<Void, Never>?
|
||||
private var hardwareRefreshTask: Task<Void, Never>?
|
||||
private var serviceRefreshTask: Task<Void, Never>?
|
||||
@@ -55,7 +72,14 @@ final class SystemMonitor {
|
||||
private var serviceRefreshPending = false
|
||||
private var eventObservers: [NSObjectProtocol] = []
|
||||
private var activeSection: TaskSection = .processes
|
||||
private var appIsActive = true
|
||||
private var appIsActive: Bool {
|
||||
let app = NSRunningApplication.current
|
||||
return !app.isHidden && NSApp.windows.contains {
|
||||
!($0 is NSPanel) && $0.styleMask.contains(.titled) && $0.isVisible && !$0.isMiniaturized
|
||||
&& $0.occlusionState.contains(.visible)
|
||||
}
|
||||
}
|
||||
private var lastDiskCapacitySample: Date?
|
||||
private var memoryPressureLevel = MemoryPressureLevel.normal
|
||||
private var lastProcessDiagnosticsSample: Date?
|
||||
private var lastProcessInventorySample: Date?
|
||||
@@ -97,38 +121,55 @@ final class SystemMonitor {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard updateTask == nil else { return }
|
||||
appIsActive = NSApp.isActive
|
||||
guard !started else { return }
|
||||
started = true
|
||||
installEventObservers()
|
||||
installMemoryPressureSource()
|
||||
installPowerSourceObserver()
|
||||
refresh()
|
||||
requestTelemetryRefresh(forceInventory: true)
|
||||
if samplingDemand.hardware { requestHardwareRefresh() }
|
||||
if samplingDemand.services { requestServiceRefresh() }
|
||||
rescheduleSampling()
|
||||
}
|
||||
|
||||
private var samplingDemand: SamplingDemand {
|
||||
let defaults = UserDefaults.standard
|
||||
return SamplingDemand(active: appIsActive, section: activeSection, speed: updateSpeed,
|
||||
recording: isRecording, alerts: defaults.bool(forKey: "resourceAlertsEnabled"),
|
||||
menuBar: defaults.object(forKey: "showMenuBarMonitor") as? Bool ?? true,
|
||||
powerMenu: MenuBarMetric(rawValue: defaults.string(forKey: "menuBarMetric") ?? "")?.requiresPower ?? false,
|
||||
menuPresented: menuBarPresented)
|
||||
}
|
||||
|
||||
private func rescheduleSampling() {
|
||||
guard started else { return }
|
||||
let demand = samplingDemand
|
||||
guard demand != scheduledDemand else { return }
|
||||
scheduledDemand = demand
|
||||
updateTask?.cancel()
|
||||
updateTask = nil
|
||||
guard let interval = demand.interval else { return }
|
||||
updateTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
let interval = self?.telemetryInterval ?? .seconds(2)
|
||||
try? await Task.sleep(for: interval)
|
||||
guard let self, !self.isPaused else { continue }
|
||||
self.requestTelemetryRefresh()
|
||||
}
|
||||
}
|
||||
hardwareUpdateTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let seconds = self.appIsActive
|
||||
? ([.hardware, .performance, .energy].contains(self.activeSection) ? 5 : 30)
|
||||
: 60
|
||||
try? await Task.sleep(for: .seconds(seconds))
|
||||
guard !Task.isCancelled, !self.isPaused else { continue }
|
||||
self.requestHardwareRefresh()
|
||||
}
|
||||
}
|
||||
serviceUpdateTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let seconds = self.appIsActive ? (self.activeSection == .services ? 3 : 30) : 60
|
||||
try? await Task.sleep(for: .seconds(seconds))
|
||||
guard !Task.isCancelled, !self.isPaused else { continue }
|
||||
self.requestServiceRefresh()
|
||||
do { try await Task.sleep(for: interval, tolerance: .milliseconds(250)) }
|
||||
catch { return }
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
// Reconcile with live AppKit state before executing captured demand.
|
||||
// Notifications can coalesce while the app is being hidden.
|
||||
guard self.samplingDemand == demand else {
|
||||
self.rescheduleSampling()
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
if demand.telemetry { self.requestTelemetryRefresh() }
|
||||
if demand.hardware && now.timeIntervalSince(self.lastHardwareRequest) >= 5 {
|
||||
self.lastHardwareRequest = now
|
||||
self.requestHardwareRefresh()
|
||||
}
|
||||
if demand.services && now.timeIntervalSince(self.lastServiceRequest) >= 3 {
|
||||
self.lastServiceRequest = now
|
||||
self.requestServiceRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +177,8 @@ final class SystemMonitor {
|
||||
func refresh() {
|
||||
processInventoryRefreshRequested = true
|
||||
volumeInventoryRefreshRequested = true
|
||||
requestTelemetryRefresh()
|
||||
lastDiskCapacitySample = nil
|
||||
requestTelemetryRefresh(forceInventory: true)
|
||||
requestHardwareRefresh(
|
||||
forcePortRefresh: true,
|
||||
forcePowerRefresh: true,
|
||||
@@ -149,26 +191,18 @@ final class SystemMonitor {
|
||||
func setActiveSection(_ section: TaskSection) {
|
||||
guard activeSection != section else { return }
|
||||
activeSection = section
|
||||
rescheduleSampling()
|
||||
guard !isPaused else { return }
|
||||
if section == .performance { volumeInventoryRefreshRequested = true }
|
||||
if section.usesLiveProcessInventory {
|
||||
processInventoryRefreshRequested = true
|
||||
requestTelemetryRefresh()
|
||||
}
|
||||
if section == .performance { volumeInventoryRefreshRequested = true }
|
||||
if section == .hardware || section == .performance || section == .energy { requestHardwareRefresh() }
|
||||
if section == .services { requestServiceRefresh() }
|
||||
}
|
||||
|
||||
private var telemetryInterval: Duration {
|
||||
guard appIsActive else { return .seconds(10) }
|
||||
switch activeSection {
|
||||
case .processes, .performance, .history, .users, .details, .energy:
|
||||
return updateSpeed.interval
|
||||
case .startup, .services, .hardware, .diagnostics, .settings:
|
||||
return .seconds(5)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestTelemetryRefresh() {
|
||||
private func requestTelemetryRefresh(forceInventory: Bool = false) {
|
||||
guard telemetryRefreshTask == nil else {
|
||||
telemetryRefreshPending = true
|
||||
return
|
||||
@@ -177,28 +211,33 @@ final class SystemMonitor {
|
||||
let processInventoryInterval: TimeInterval = activeSection.usesLiveProcessInventory
|
||||
? 0
|
||||
: (appIsActive ? 15 : 30)
|
||||
let includeProcessInventory = processInventoryRefreshRequested
|
||||
let includeProcessInventory = forceInventory || (samplingDemand.processInventory && (processInventoryRefreshRequested
|
||||
|| processes.isEmpty
|
||||
|| lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true
|
||||
|| lastProcessInventorySample.map { now.timeIntervalSince($0) >= processInventoryInterval } ?? true))
|
||||
if includeProcessInventory { processInventoryRefreshRequested = false }
|
||||
let diagnosticInterval: TimeInterval = activeSection == .details ? 5 : 15
|
||||
let includeDiagnostics = includeProcessInventory && (lastProcessDiagnosticsSample.map {
|
||||
let includeDiagnostics = includeProcessInventory && activeSection == .details && (lastProcessDiagnosticsSample.map {
|
||||
now.timeIntervalSince($0) >= diagnosticInterval
|
||||
} ?? true)
|
||||
let deviceMetricsInterval: TimeInterval = activeSection == .performance ? 3 : 20
|
||||
let includeDeviceMetrics = lastDeviceMetricsSample.map {
|
||||
let deviceMetricsNeeded = isRecording || UserDefaults.standard.bool(forKey: "resourceAlertsEnabled")
|
||||
|| (appIsActive && activeSection.usesLiveProcessInventory)
|
||||
let includeDeviceMetrics = deviceMetricsNeeded && (lastDeviceMetricsSample.map {
|
||||
Date().timeIntervalSince($0) >= deviceMetricsInterval
|
||||
} ?? true
|
||||
} ?? true)
|
||||
let includeProcessIO: Bool
|
||||
switch activeSection {
|
||||
case .processes, .performance, .history, .users, .details, .energy, .diagnostics:
|
||||
includeProcessIO = includeProcessInventory && appIsActive
|
||||
includeProcessIO = includeProcessInventory && (appIsActive || isRecording)
|
||||
case .startup, .services, .hardware, .settings:
|
||||
includeProcessIO = false
|
||||
}
|
||||
let includeVolumeInventory = volumeInventoryRefreshRequested
|
||||
|| (activeSection == .performance && (lastVolumeInventorySample.map { Date().timeIntervalSince($0) >= 10 } ?? true))
|
||||
let includeVolumeInventory = volumeInventoryRefreshRequested && appIsActive && activeSection == .performance
|
||||
if includeVolumeInventory { volumeInventoryRefreshRequested = false }
|
||||
let includeDiskCapacity = lastDiskCapacitySample == nil || includeVolumeInventory
|
||||
|| (appIsActive && activeSection == .performance
|
||||
&& now.timeIntervalSince(lastDiskCapacitySample!) >= 60)
|
||||
let cachedDiskCapacity = (free: snapshot.diskFree, total: snapshot.diskTotal)
|
||||
let knownProcesses = includeProcessInventory
|
||||
? Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
|
||||
: [:]
|
||||
@@ -210,15 +249,17 @@ final class SystemMonitor {
|
||||
includeDeviceMetrics: includeDeviceMetrics,
|
||||
includeProcessIO: includeProcessIO,
|
||||
includeVolumeInventory: includeVolumeInventory,
|
||||
cachedDiskCapacity: includeDiskCapacity ? nil : cachedDiskCapacity,
|
||||
knownProcesses: knownProcesses
|
||||
)
|
||||
}.value
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.applyTelemetry(result)
|
||||
if includeDiskCapacity { self.lastDiskCapacitySample = now }
|
||||
self.telemetryRefreshTask = nil
|
||||
if self.telemetryRefreshPending {
|
||||
self.telemetryRefreshPending = false
|
||||
self.requestTelemetryRefresh()
|
||||
if !self.isPaused && self.samplingDemand.telemetry { self.requestTelemetryRefresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,15 +279,16 @@ final class SystemMonitor {
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
let refreshPorts = forcePortRefresh || lastPortSample.map { Date().timeIntervalSince($0) >= 60 } ?? true
|
||||
let powerIsVisible = [.hardware, .performance, .energy].contains(activeSection)
|
||||
|| UserDefaults.standard.string(forKey: "menuBarMetric") == MenuBarMetric.power.rawValue
|
||||
let detailedHardware = appIsActive && [.hardware, .performance, .energy].contains(activeSection)
|
||||
let refreshPorts = forcePortRefresh || (detailedHardware && (lastPortSample.map { now.timeIntervalSince($0) >= 60 } ?? true))
|
||||
let powerIsVisible = menuBarPresented || detailedHardware
|
||||
|| (MenuBarMetric(rawValue: UserDefaults.standard.string(forKey: "menuBarMetric") ?? "")?.requiresPower ?? false)
|
||||
let powerInterval: TimeInterval = powerIsVisible ? 5 : 30
|
||||
let refreshPower = forcePowerRefresh || lastPowerSensorSample.map { now.timeIntervalSince($0) >= powerInterval } ?? true
|
||||
let fanInterval: TimeInterval = activeSection == .hardware ? 5 : 30
|
||||
let refreshFans = forceFanRefresh || lastFanSample.map { now.timeIntervalSince($0) >= fanInterval } ?? true
|
||||
let refreshFans = forceFanRefresh || ((detailedHardware || isRecording) && (lastFanSample.map { now.timeIntervalSince($0) >= fanInterval } ?? true))
|
||||
let slowInterval: TimeInterval = [.hardware, .energy].contains(activeSection) ? 15 : 60
|
||||
let refreshSlow = forceSlowRefresh || lastSlowHardwareSample.map { now.timeIntervalSince($0) >= slowInterval } ?? true
|
||||
let refreshSlow = forceSlowRefresh || (detailedHardware && (lastSlowHardwareSample.map { now.timeIntervalSince($0) >= slowInterval } ?? true))
|
||||
let existingHardware = hardware
|
||||
hardwareRefreshTask = Task { [weak self] in
|
||||
let result = await Task.detached(priority: .utility) {
|
||||
@@ -323,6 +365,7 @@ final class SystemMonitor {
|
||||
let activePIDs = Set(currentProcesses.map(\.pid))
|
||||
processDiagnostics = processDiagnostics.filter { activePIDs.contains($0.key) }
|
||||
processes = currentProcesses
|
||||
processGroups = ProcessGrouping.groups(from: currentProcesses, searchText: "")
|
||||
updateCPUHistory(with: currentProcesses)
|
||||
updateProcessDiskActivity(result.processIO, current: currentProcesses)
|
||||
lastProcessInventorySample = Date()
|
||||
@@ -437,7 +480,16 @@ final class SystemMonitor {
|
||||
eventObservers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.processInventoryRefreshRequested = true
|
||||
self?.requestTelemetryRefresh()
|
||||
if self?.samplingDemand.processInventory == true, self?.isPaused == false {
|
||||
self?.processEventTask?.cancel()
|
||||
self?.processEventTask = Task { [weak self] in
|
||||
do { try await Task.sleep(for: .milliseconds(200)) }
|
||||
catch { return }
|
||||
guard let self, !self.isPaused, self.samplingDemand.processInventory else { return }
|
||||
self.requestTelemetryRefresh()
|
||||
self.processEventTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -470,8 +522,8 @@ final class SystemMonitor {
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.appIsActive = true
|
||||
self?.requestTelemetryRefresh()
|
||||
self?.rescheduleSampling()
|
||||
if self?.isPaused == false { self?.requestTelemetryRefresh() }
|
||||
}
|
||||
})
|
||||
eventObservers.append(NotificationCenter.default.addObserver(
|
||||
@@ -479,7 +531,24 @@ final class SystemMonitor {
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.appIsActive = false }
|
||||
Task { @MainActor in
|
||||
self?.rescheduleSampling()
|
||||
}
|
||||
})
|
||||
for notification in [NSApplication.didHideNotification, NSApplication.didUnhideNotification,
|
||||
NSWindow.didChangeOcclusionStateNotification,
|
||||
NSWindow.didMiniaturizeNotification, NSWindow.didDeminiaturizeNotification,
|
||||
NSWindow.willCloseNotification] {
|
||||
eventObservers.append(NotificationCenter.default.addObserver(
|
||||
forName: notification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.rescheduleSampling() }
|
||||
})
|
||||
}
|
||||
eventObservers.append(NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.rescheduleSampling() }
|
||||
})
|
||||
eventObservers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.willTerminateNotification,
|
||||
@@ -750,7 +819,7 @@ final class SystemMonitor {
|
||||
}
|
||||
|
||||
private func sampleAppNetworkIfNeeded() {
|
||||
guard appNetworkTask == nil,
|
||||
guard samplingDemand.processInventory, !isPaused, appNetworkTask == nil,
|
||||
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
|
||||
lastAppNetworkSample = Date()
|
||||
appNetworkTask = Task { [weak self] in
|
||||
@@ -1171,6 +1240,7 @@ private enum ProcessScanner {
|
||||
includeDeviceMetrics: Bool,
|
||||
includeProcessIO: Bool,
|
||||
includeVolumeInventory: Bool,
|
||||
cachedDiskCapacity: (free: UInt64, total: UInt64)? = nil,
|
||||
knownProcesses: [Int32: ProcessRecord] = [:]
|
||||
) -> TelemetryResult {
|
||||
let records = includeProcessInventory
|
||||
@@ -1178,7 +1248,7 @@ private enum ProcessScanner {
|
||||
: []
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
let memory = memoryCounters(total: totalMemory)
|
||||
let disk = diskUsage()
|
||||
let disk = cachedDiskCapacity ?? diskUsage()
|
||||
var snapshot = SystemSnapshot()
|
||||
snapshot.cpuPercent = 0
|
||||
snapshot.memoryUsed = memory.used
|
||||
@@ -1683,16 +1753,23 @@ private enum HardwareScanner {
|
||||
refreshFans: Bool,
|
||||
refreshSlowDiagnostics: Bool
|
||||
) -> HardwareSnapshot {
|
||||
var battery = batterySnapshot()
|
||||
var battery = BatteryRegistryReader.read()
|
||||
if refreshPowerSensors {
|
||||
let power = powerSensorSnapshot()
|
||||
if power.systemWatts > 0 { battery.systemPowerWatts = power.systemWatts }
|
||||
if !battery.hasSystemPower, power.systemWatts > 0 {
|
||||
battery.systemPowerWatts = power.systemWatts
|
||||
battery.hasSystemPower = true
|
||||
}
|
||||
battery.sensorBatteryPowerWatts = power.batteryWatts
|
||||
battery.adapterInputWatts = power.adapterWatts
|
||||
if battery.externalPowerConnected, !battery.hasAdapterInput, power.adapterWatts > 0 {
|
||||
battery.adapterInputWatts = power.adapterWatts
|
||||
battery.hasAdapterInput = true
|
||||
}
|
||||
} else {
|
||||
battery.systemPowerWatts = existing.battery.systemPowerWatts
|
||||
battery.sensorBatteryPowerWatts = existing.battery.sensorBatteryPowerWatts
|
||||
battery.adapterInputWatts = existing.battery.adapterInputWatts
|
||||
if !battery.hasSystemPower {
|
||||
battery.systemPowerWatts = existing.battery.systemPowerWatts
|
||||
battery.hasSystemPower = existing.battery.hasSystemPower
|
||||
}
|
||||
}
|
||||
return HardwareSnapshot(
|
||||
battery: battery,
|
||||
@@ -1738,86 +1815,6 @@ private enum HardwareScanner {
|
||||
}.sorted { $0.id < $1.id }
|
||||
}
|
||||
|
||||
private static func batterySnapshot() -> BatterySnapshot {
|
||||
let output = run("/usr/sbin/ioreg", arguments: ["-r", "-c", "AppleSmartBattery", "-l"])
|
||||
guard output.contains("AppleSmartBattery") else { return BatterySnapshot() }
|
||||
|
||||
func topNumber(_ key: String) -> Double {
|
||||
Double(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0") ?? 0
|
||||
}
|
||||
func topSignedNumber(_ key: String) -> Double {
|
||||
let raw = output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (-?[0-9]+)") ?? "0"
|
||||
if let signed = Int64(raw) { return Double(signed) }
|
||||
if let unsigned = UInt64(raw) { return Double(Int64(bitPattern: unsigned)) }
|
||||
return 0
|
||||
}
|
||||
func topBool(_ key: String) -> Bool {
|
||||
(output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = (Yes|No)") ?? "No") == "Yes"
|
||||
}
|
||||
func topString(_ key: String) -> String {
|
||||
output.firstMatch("(?m)^ \"\(NSRegularExpression.escapedPattern(for: key))\" = \"([^\"]*)\"") ?? ""
|
||||
}
|
||||
|
||||
let adapterLine = output.firstMatch(#"(?m)^ "AdapterDetails" = \{([^\n]+)\}"#) ?? ""
|
||||
let telemetryLine = output.firstMatch(#"(?m)^ "PowerTelemetryData" = \{([^\n]+)\}"#) ?? ""
|
||||
let externalPowerConnected = topBool("ExternalConnected")
|
||||
let parsedAdapter = adapterLine.isEmpty ? nil : adapterSnapshot(from: adapterLine)
|
||||
let adapter = externalPowerConnected && (parsedAdapter?.ratedWatts ?? 0) > 0 ? parsedAdapter : nil
|
||||
let rawTime = Int(topNumber("TimeRemaining"))
|
||||
let rawTemperature = topNumber("Temperature")
|
||||
let temperature = rawTemperature > 1_000 ? rawTemperature / 10 - 273.15 : rawTemperature
|
||||
let systemPowerMW = Double(telemetryLine.firstMatch(#""SystemLoad"=([0-9]+)"#) ?? "0") ?? 0
|
||||
|
||||
return BatterySnapshot(
|
||||
isPresent: topBool("BatteryInstalled"),
|
||||
chargePercent: topNumber("CurrentCapacity"),
|
||||
isCharging: topBool("IsCharging"),
|
||||
isFullyCharged: topBool("FullyCharged"),
|
||||
externalPowerConnected: externalPowerConnected,
|
||||
cycleCount: Int(topNumber("CycleCount")),
|
||||
designCycleCount: Int(topNumber("DesignCycleCount9C")),
|
||||
currentCapacityMAh: topNumber("AppleRawCurrentCapacity"),
|
||||
fullChargeCapacityMAh: topNumber("NominalChargeCapacity"),
|
||||
designCapacityMAh: topNumber("DesignCapacity"),
|
||||
voltageVolts: topNumber("Voltage") / 1_000,
|
||||
currentAmps: {
|
||||
let amps = topSignedNumber("Amperage") / 1_000
|
||||
return abs(amps) <= 100 ? amps : 0
|
||||
}(),
|
||||
temperatureCelsius: temperature,
|
||||
timeRemainingMinutes: rawTime > 0 && rawTime < 65_535 ? rawTime : nil,
|
||||
serial: topString("Serial"),
|
||||
adapter: adapter,
|
||||
systemPowerWatts: systemPowerMW / 1_000
|
||||
)
|
||||
}
|
||||
|
||||
private static func adapterSnapshot(from line: String) -> PowerAdapterSnapshot {
|
||||
func number(_ key: String) -> Double {
|
||||
Double(line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=([0-9]+)") ?? "0") ?? 0
|
||||
}
|
||||
func string(_ key: String) -> String {
|
||||
line.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\"=\"([^\"]*)\"") ?? ""
|
||||
}
|
||||
let regex = try? NSRegularExpression(pattern: #""MaxCurrent"=([0-9]+),"MaxVoltage"=([0-9]+)"#)
|
||||
let range = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
let profiles = (regex?.matches(in: line, range: range) ?? []).compactMap { match -> PowerProfile? in
|
||||
guard let currentRange = Range(match.range(at: 1), in: line),
|
||||
let voltageRange = Range(match.range(at: 2), in: line),
|
||||
let current = Double(line[currentRange]),
|
||||
let voltage = Double(line[voltageRange]) else { return nil }
|
||||
return PowerProfile(voltageVolts: voltage / 1_000, currentAmps: current / 1_000)
|
||||
}
|
||||
return PowerAdapterSnapshot(
|
||||
name: string("Name").isEmpty ? "USB-C power adapter" : string("Name"),
|
||||
manufacturer: string("Manufacturer").isEmpty ? "Not reported" : string("Manufacturer"),
|
||||
serial: string("SerialString"),
|
||||
ratedWatts: number("Watts"),
|
||||
negotiatedVoltage: number("AdapterVoltage") / 1_000,
|
||||
negotiatedCurrent: number("Current") / 1_000,
|
||||
profiles: profiles
|
||||
)
|
||||
}
|
||||
|
||||
private static func thermalState() -> String {
|
||||
switch ProcessInfo.processInfo.thermalState {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Own the outer glass dimensions instead of inheriting bezel padding.
|
||||
struct ToolbarIconButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.frame(width: PuterLayout.toolbarControlHeight, height: PuterLayout.toolbarControlHeight)
|
||||
.contentShape(.circle)
|
||||
.modifier(SearchGlassSurface())
|
||||
.opacity(configuration.isPressed ? 0.7 : 1)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.36538,0.78082,0.24349,1.00000",
|
||||
"display-p3:0.22503,0.48767,0.15523,1.00000"
|
||||
"display-p3:0.14000,0.43000,0.33000,1.00000",
|
||||
"display-p3:0.03500,0.18000,0.16000,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
@@ -19,30 +19,32 @@
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "puter-white.png",
|
||||
"name" : "puter-white",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 0.6
|
||||
}
|
||||
],
|
||||
"fill" : {
|
||||
"solid" : "display-p3:0.95000,1.00000,0.97000,1.00000"
|
||||
},
|
||||
"image-name" : "loop-p-geometric.svg",
|
||||
"name" : "Circular loop p · 112pt",
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "meter-needle-geometric.svg",
|
||||
"name" : "Meter needle",
|
||||
"fill" : { "solid" : "display-p3:0.95000,1.00000,0.97000,1.00000" }
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
"opacity" : 0.35
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
"value" : 0.25
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user