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