Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Sources/Argus/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ struct DashboardView: View {
@State private var searchText = ""
@State private var activeSeverities: Set<Severity> = 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) {
Expand All @@ -41,6 +44,7 @@ struct DashboardView: View {
Divider().background(Theme.border)
eventFeed
}
.renderedOnlyWhenVisible($isWindowVisible)
.background(Theme.bg)
.foregroundStyle(Theme.text)
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/Argus/DockerWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions Sources/Argus/MenuBarPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion Sources/Argus/ProcessMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
73 changes: 73 additions & 0 deletions Sources/Argus/WindowVisibility.swift
Original file line number Diff line number Diff line change
@@ -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<Bool>) -> some View {
ZStack {
if isVisible.wrappedValue {
self
} else {
Color.clear
}
}
.background(WindowVisibilityReader(isVisible: isVisible))
}
}
Loading