From 0fc7728e3cae1620e7c89451da9bd17e215c5932 Mon Sep 17 00:00:00 2001 From: Mark Watts <36508964+MarkRWatts@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:19:08 +0100 Subject: [PATCH 1/2] Stop rendering hidden windows and settle the decayed risk score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the dashboard only hides it, and the menu-bar flyout is dismissed with orderOut, so SwiftUI kept re-evaluating, animating, and committing frames for both off-screen on every poll — measured at 21-28% CPU with neither window visible, dominated by the gauge's shadowed conic gradient. WindowVisibilityReader tracks the hosting window's occlusion state; the dashboard and the flyout's gauge render a placeholder while hidden. riskScore now snaps to 0 below 0.5 (where the gauge already shows 0): pure exponential decay never reached 0, so the gauge re-animated every tick indefinitely. Hidden-window CPU drops to ~1%. Co-Authored-By: Claude Opus 5.5 --- Sources/Argus/DashboardView.swift | 4 ++ Sources/Argus/MenuBarPanel.swift | 4 ++ Sources/Argus/ProcessMonitor.swift | 7 ++- Sources/Argus/WindowVisibility.swift | 73 ++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Sources/Argus/WindowVisibility.swift diff --git a/Sources/Argus/DashboardView.swift b/Sources/Argus/DashboardView.swift index 6d35762..415ea64 100644 --- a/Sources/Argus/DashboardView.swift +++ b/Sources/Argus/DashboardView.swift @@ -19,6 +19,9 @@ struct DashboardView: View { @State private var searchText = "" @State private var activeSeverities: Set = Set(Severity.allCases) @State private var focusedSessionPPID: Int32? + /// See `WindowVisibilityReader` — the dashboard window is hidden, not + /// destroyed, on close, and would otherwise keep rendering off-screen. + @State private var isWindowVisible = true var body: some View { VStack(spacing: 0) { @@ -41,6 +44,7 @@ struct DashboardView: View { Divider().background(Theme.border) eventFeed } + .renderedOnlyWhenVisible($isWindowVisible) .background(Theme.bg) .foregroundStyle(Theme.text) } diff --git a/Sources/Argus/MenuBarPanel.swift b/Sources/Argus/MenuBarPanel.swift index 6f422da..33aafaf 100644 --- a/Sources/Argus/MenuBarPanel.swift +++ b/Sources/Argus/MenuBarPanel.swift @@ -7,6 +7,9 @@ struct MenuBarPanel: View { @ObservedObject var monitor: ProcessMonitor var dismissFlyout: () -> Void = {} @Environment(\.openWindow) private var openWindow + /// The flyout is dismissed with `orderOut(nil)`, which leaves it alive + /// off-screen; the gauge's animated gradient is the one costly thing in it. + @State private var isWindowVisible = true var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -35,6 +38,7 @@ struct MenuBarPanel: View { GaugeView(score: monitor.riskScore, level: monitor.riskLevel) .scaleEffect(0.7) + .renderedOnlyWhenVisible($isWindowVisible) .frame(height: 90) Divider().background(Theme.border) diff --git a/Sources/Argus/ProcessMonitor.swift b/Sources/Argus/ProcessMonitor.swift index 3f83d90..462e157 100644 --- a/Sources/Argus/ProcessMonitor.swift +++ b/Sources/Argus/ProcessMonitor.swift @@ -257,7 +257,12 @@ final class ProcessMonitor: ObservableObject { private func processSample(_ raw: [RawProcess]) { let halfLife = settings?.riskDecayHalfLifeSeconds ?? defaultHalfLifeSeconds let decayFactor = pow(0.5, currentPollInterval / halfLife) - riskScore = max(0, riskScore * decayFactor) + // Snap to exactly 0 once the gauge would display 0 anyway: pure + // exponential decay never reaches 0, so without this the score (and + // the gauge animation keyed on it) changes on every tick forever. + let decayed = riskScore * decayFactor + let nextScore = decayed < 0.5 ? 0 : decayed + if nextScore != riskScore { riskScore = nextScore } pruneOrbitNodes() let currentPIDs = Set(raw.map(\.id)) diff --git a/Sources/Argus/WindowVisibility.swift b/Sources/Argus/WindowVisibility.swift new file mode 100644 index 0000000..7bce3e6 --- /dev/null +++ b/Sources/Argus/WindowVisibility.swift @@ -0,0 +1,73 @@ +import SwiftUI +import AppKit + +/// Reports whether the `NSWindow` hosting this view is actually on screen. +/// +/// SwiftUI keeps a hidden window's view graph alive: the dashboard after it's +/// closed (Argus hides rather than destroys it) and the menu-bar flyout after +/// `orderOut(nil)` both keep re-evaluating bodies, running `TimelineView` +/// ticks and committing Core Animation frames on every `ProcessMonitor` +/// publish — measured at ~25–50% CPU with neither window visible. Views use +/// this to swap in a placeholder while hidden so nothing renders for nobody. +/// +/// Driven by `NSWindow.occlusionState`, which flips on `orderOut`, close, +/// minimize, and being fully covered by other windows alike. +struct WindowVisibilityReader: NSViewRepresentable { + @Binding var isVisible: Bool + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onChange = { visible in + if isVisible != visible { isVisible = visible } + } + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) {} + + final class TrackingView: NSView { + var onChange: ((Bool) -> Void)? + private var observer: NSObjectProtocol? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + guard let window else { return } + observer = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, object: window, queue: .main + ) { [weak self] _ in + self?.report() + } + report() + } + + private func report() { + guard let window else { return } + let visible = window.occlusionState.contains(.visible) + // Deferred: this can fire mid view-update, where mutating + // SwiftUI state directly is undefined behavior. + DispatchQueue.main.async { [weak self] in self?.onChange?(visible) } + } + + deinit { + if let observer { NotificationCenter.default.removeObserver(observer) } + } + } +} + +extension View { + /// Renders `self` only while the hosting window is on screen; otherwise + /// an empty placeholder of the same frame, so a hidden window stops + /// laying out, animating, and drawing entirely. + func renderedOnlyWhenVisible(_ isVisible: Binding) -> some View { + ZStack { + if isVisible.wrappedValue { + self + } else { + Color.clear + } + } + .background(WindowVisibilityReader(isVisible: isVisible)) + } +} From 13e54483b93f0aa373a66d9e78a5b52286b93342 Mon Sep 17 00:00:00 2001 From: Mark Watts <36508964+MarkRWatts@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:19:08 +0100 Subject: [PATCH 2/2] Log a Docker daemon outage once, not once per retry hasLoggedExit was reset on every successful spawn of `docker events`, but the CLI spawns fine with no daemon and exits immediately, so the "exited" line was written every 60s for as long as Docker was down (~12.6k lines). Reset it only once the daemon actually produces output. Co-Authored-By: Claude Opus 5.5 --- Sources/Argus/DockerWatcher.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Argus/DockerWatcher.swift b/Sources/Argus/DockerWatcher.swift index cabb3a6..ee9a705 100644 --- a/Sources/Argus/DockerWatcher.swift +++ b/Sources/Argus/DockerWatcher.swift @@ -282,7 +282,6 @@ final class DockerWatcher { do { try proc.run() process = proc - hasLoggedExit = false } catch { DiagnosticsLog.write("docker watcher — failed to launch docker events: \(error)") scheduleRetry() @@ -292,6 +291,11 @@ final class DockerWatcher { private func handle(chunk: String) { queue.async { [weak self] in guard let self else { return } + // Output means the daemon is really connected — only now does a + // later exit count as a new outage worth logging. Resetting on + // every successful spawn instead logged once per retry forever + // while the daemon was down, since the CLI itself always spawns. + self.hasLoggedExit = false let lines = LineBuffer.consume(chunk, buffer: &self.lineBuffer) for line in lines { guard let event = DockerLineProcessor.processLine(line) else { continue }