Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org) and the

## [Unreleased]

### Added
- **System Monitor** now shows where the load is coming from, not just how much
of it there is. Each of the CPU, memory and network cards carries a sparkline
of the last minute, and a new card at the foot of the panel lists the five
busiest processes — ranked by CPU or by memory, whichever you picked last.
CPU and memory are drawn against a fixed 0–100% axis so an idle machine looks
idle; the two network traces share one axis so upstream is not flattered into
looking like downstream. The history is a fixed-size ring of sixty samples,
so a monitor left running for months uses exactly as much memory on day two
hundred as it did on day one, and the process list is read every fifth poll
on a background thread rather than once a second on the main one.

## [0.14.0] — 2026-07-21

### Added
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteCore/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public enum DefaultsKey {
public static let systemMonitorTemperatureUnit = "tool.systemMonitor.temperatureUnit"
public static let systemMonitorOpenAtLogin = "tool.systemMonitor.openAtLogin"
public static let systemMonitorShowsTrayIcon = "tool.systemMonitor.showsTrayIcon"
/// Which column the popover's top-process list is ranked by. Persisted because whichever of
/// the two a user cares about, they care about it every time they open the panel.
public static let systemMonitorProcessSortKey = "tool.systemMonitor.processSortKey"
public static let clipboardOpenAtLogin = "tool.clipboard.openAtLogin"
public static let clipboardMaxHistory = "tool.clipboard.maxHistory"
public static let grabTextCopyAutomatically = "tool.grabText.copyAutomatically"
Expand Down Expand Up @@ -114,6 +117,7 @@ public enum AppDefaults {
DefaultsKey.systemMonitorTemperatureUnit: TemperatureUnitPreference.celsius.rawValue,
DefaultsKey.systemMonitorOpenAtLogin: false,
DefaultsKey.systemMonitorShowsTrayIcon: true,
DefaultsKey.systemMonitorProcessSortKey: TopProcessSortKey.cpu.rawValue,
DefaultsKey.clipboardOpenAtLogin: false,
DefaultsKey.clipboardMaxHistory: 200,
DefaultsKey.grabTextCopyAutomatically: true,
Expand Down
140 changes: 140 additions & 0 deletions Sources/DMonteCore/Sparkline.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import SwiftUI

/// How a sparkline's vertical axis is derived from the series it is drawing.
public enum SparklineScale: Equatable, Sendable {
/// Stretch the series' own minimum and maximum across the full height. Right for network
/// rates, where the interesting thing is the shape of the burst and there is no meaningful
/// ceiling to plot against.
case fitToSeries

/// Pin the axis to a known domain. Right for CPU and memory, which are already fractions of
/// one: auto-scaling them would turn a flat 3% idle trace into a dramatic-looking mountain.
case fixed(lower: Double, upper: Double)

/// The 0...1 domain shared by the CPU and memory series.
public static let unitInterval = SparklineScale.fixed(lower: 0, upper: 1)
}

/// The series-to-points mapping behind `SparklineShape`.
///
/// Split out from the `Shape` and kept `nonisolated` and pure so the awkward cases — an empty
/// history at launch, a single sample one tick later, a perfectly flat series whose range is
/// zero — are testable without a view hierarchy.
public enum Sparkline {

/// Evenly spaced points across `rect`, oldest sample at the leading edge.
///
/// Returns an empty array for an empty series, and exactly one point for one sample: a single
/// reading has no horizontal extent to spread over, so it is placed at the centre and the
/// `Shape` decides how to draw it.
public nonisolated static func points(
for series: [Double],
in rect: CGRect,
scale: SparklineScale = .fitToSeries
) -> [CGPoint] {
guard !series.isEmpty, rect.width.isFinite, rect.height.isFinite else {
return []
}

let normalized = normalizedValues(series, scale: scale)

guard series.count > 1 else {
return [CGPoint(x: rect.midX, y: yPosition(forNormalized: normalized[0], in: rect))]
}

let step = rect.width / CGFloat(series.count - 1)

return normalized.enumerated().map { index, value in
CGPoint(
x: rect.minX + CGFloat(index) * step,
y: yPosition(forNormalized: value, in: rect)
)
}
}

/// Each sample mapped onto 0...1, where 0 is the bottom of the plot and 1 the top.
///
/// The flat-series case is the one that matters: an idle machine reports the same CPU figure
/// for a minute straight, and dividing by that zero range would put a NaN into the `Path`,
/// which CoreGraphics answers by dropping the whole shape rather than by drawing something
/// wrong. A range that is zero, negative or not finite therefore falls back to a line drawn
/// down the middle.
nonisolated static func normalizedValues(_ series: [Double], scale: SparklineScale) -> [Double] {
// A metric that arrived as a NaN or an infinity would poison the min/max for every other
// sample too, so it is neutralised here rather than at the point of use.
let sanitized = series.map { $0.isFinite ? $0 : 0 }

let lower: Double
let upper: Double

switch scale {
case .fitToSeries:
lower = sanitized.min() ?? 0
upper = sanitized.max() ?? 0
case let .fixed(fixedLower, fixedUpper):
lower = fixedLower
upper = fixedUpper
}

let range = upper - lower

guard range.isFinite, range > .ulpOfOne else {
return Array(repeating: 0.5, count: sanitized.count)
}

return sanitized.map { min(max(($0 - lower) / range, 0), 1) }
}

private nonisolated static func yPosition(forNormalized value: Double, in rect: CGRect) -> CGFloat {
// View coordinates grow downwards, so the largest sample belongs at the smallest y.
rect.maxY - CGFloat(value) * rect.height
}
}

/// The sparkline itself: a polyline across the series, optionally closed down to the baseline so
/// it can be filled as well as stroked.
public struct SparklineShape: Shape {
public var series: [Double]
public var scale: SparklineScale
/// When true the path runs on to the bottom corners and closes, turning the line into an area
/// the caller can fill. The stroked and filled variants are drawn as two shapes so the fill
/// never picks up the baseline as a visible edge.
public var isFilled: Bool

public init(series: [Double], scale: SparklineScale = .fitToSeries, isFilled: Bool = false) {
self.series = series
self.scale = scale
self.isFilled = isFilled
}

public func path(in rect: CGRect) -> Path {
var path = Path()
let points = Sparkline.points(for: series, in: rect, scale: scale)

guard let first = points.first else {
return path
}

if points.count == 1 {
// One sample is not a line, but leaving the card blank for the first second after
// launch reads as a broken chart. Draw the reading as a flat trace instead.
path.move(to: CGPoint(x: rect.minX, y: first.y))
path.addLine(to: CGPoint(x: rect.maxX, y: first.y))
} else {
path.move(to: first)
for point in points.dropFirst() {
path.addLine(to: point)
}
}

guard isFilled else {
return path
}

path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()

return path
}
}
103 changes: 101 additions & 2 deletions Sources/DMonteCore/SystemMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,36 @@ import Foundation
@MainActor
public final class SystemMonitor: ObservableObject {
@Published public private(set) var snapshot = MetricSnapshot.placeholder
/// The last minute of samples, for the popover's sparklines.
@Published public private(set) var history = MetricHistory()
/// The busiest processes, refreshed on a slower cadence than the metrics themselves.
@Published public private(set) var topProcesses = TopProcessSnapshot.empty

/// How the busiest processes are read. Injectable so the panel-visibility gate below can be
/// proved in a test without launching a single subprocess.
public typealias TopProcessSampler = @Sendable () async -> TopProcessSnapshot

private let provider = SystemMetricsProvider()
private let processSampler: TopProcessSampler
private var timer: Timer?
private var isRunning = false

public init(snapshot: MetricSnapshot = .placeholder) {
/// Polls remaining before the next `ps` run. Counts down and resets, so unlike a tick total it
/// has no value it can grow into over a months-long uptime.
private var pollsUntilProcessRefresh = 0
/// Guards against a second `ps` being launched while the first is still running, which a
/// momentarily slow listing would otherwise cause once per poll.
private var isSamplingProcesses = false
/// Whether anything is displaying the process list. Starts false because the panel starts
/// closed, and a tool installed as a login item may never be opened at all.
private var isProcessListVisible = false

public init(
snapshot: MetricSnapshot = .placeholder,
processSampler: @escaping TopProcessSampler = { await TopProcessKit.sample() }
) {
self.snapshot = snapshot
self.processSampler = processSampler
}

public func start() {
Expand All @@ -19,6 +42,10 @@ public final class SystemMonitor: ObservableObject {
}

isRunning = true
// A stopped-then-restarted monitor has a gap in the middle of its history; splicing the
// two halves together would draw a jump that never happened.
history.removeAll()
pollsUntilProcessRefresh = 0
refresh()

let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Expand All @@ -43,7 +70,79 @@ public final class SystemMonitor: ObservableObject {
timer = nil
}

/// Tells the monitor whether the process list is on screen.
///
/// The metrics themselves are Mach counters that cost next to nothing, but the process list
/// shells out to `ps -A`, and a login item left running for a week that is never opened would
/// otherwise spawn it seventeen thousand times a day to compute a snapshot nothing reads. The
/// panel's own show/close hooks drive this, so a dismissal by outside click or by a display
/// change stops the sampling just as a deliberate close does.
public func setProcessListVisible(_ isVisible: Bool) {
guard isVisible != isProcessListVisible else {
return
}

isProcessListVisible = isVisible

// Opening the panel should not have to sit in front of a stale list — or, on the first
// opening of a session, an empty one — while the countdown drains, so a show primes the
// listing immediately and the countdown then resumes from full. Zeroing the countdown
// rather than trusting it to already be zero covers the case where the panel is closed and
// reopened between two polls, which leaves whatever count the last visible poll wrote.
if isVisible {
pollsUntilProcessRefresh = 0
refreshTopProcessesIfDue()
}
}

public func refresh() {
snapshot = provider.sample()
let snapshot = provider.sample()
self.snapshot = snapshot
history.append(snapshot: snapshot)
refreshTopProcessesIfDue()
}

/// Runs `ps` every `TopProcessKit.pollDivider`-th poll. The metrics timer is the only timer in
/// the app; the process list rides on it at a fraction of its rate rather than getting one of
/// its own.
private func refreshTopProcessesIfDue() {
let decision = TopProcessKit.processRefreshDecision(
pollsRemaining: pollsUntilProcessRefresh,
isListVisible: isProcessListVisible
)

guard decision.shouldRefresh else {
pollsUntilProcessRefresh = decision.pollsRemaining
return
}

// A listing still running when the next one comes due is left to finish rather than
// stacked on top of, and the countdown is left at zero so the retry is the very next poll.
guard !isSamplingProcesses else {
return
}

pollsUntilProcessRefresh = decision.pollsRemaining
isSamplingProcesses = true

Task { [weak self, processSampler] in
// The default sampler detaches internally, so the subprocess and its exit wait stay off
// the main actor; only the assignment below comes back here.
let processes = await processSampler()

guard let self else {
return
}

self.isSamplingProcesses = false

// A run that finished after the user closed the panel is still worth keeping — but a
// failed listing should not blank a list that is merely a few seconds stale.
guard !processes.isEmpty || self.topProcesses.isEmpty else {
return
}

self.topProcesses = processes
}
}
}
Loading
Loading