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