89 lines
3.4 KiB
Swift
89 lines
3.4 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import Combine
|
|
|
|
/// Captures SwiftUI's `openWindow` action so AppKit surfaces (the menu-bar
|
|
/// popover, which lives outside the scene graph and has no working `openWindow`
|
|
/// environment) can reopen the main window after it has been closed. The main
|
|
/// window records the action on appear; the closure stays valid for the app's
|
|
/// lifetime even once the window is gone.
|
|
@MainActor
|
|
final class WindowOpener {
|
|
static let shared = WindowOpener()
|
|
var open: ((String) -> Void)?
|
|
private init() {}
|
|
}
|
|
|
|
/// AppKit-backed menu-bar system monitor. A SwiftUI `MenuBarExtra` was avoided
|
|
/// here: a conditional `.window`-style `MenuBarExtra` fails to type-check, and
|
|
/// an unconditional one stalls the XCTest host's run loop. An `NSStatusItem`
|
|
/// driving an `NSPopover` (which hosts the existing SwiftUI `MenuBarMonitorView`)
|
|
/// gives the same UI with full create/destroy control and no test-host impact.
|
|
@MainActor
|
|
final class MenuBarController: NSObject, NSPopoverDelegate {
|
|
private let statusItem: NSStatusItem
|
|
private let popover = NSPopover()
|
|
private let monitor = SystemMonitor.shared
|
|
private var cancellable: AnyCancellable?
|
|
|
|
override init() {
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
super.init()
|
|
|
|
// Persist the user's show/hide choice and ensure the item is requested
|
|
// visible (it defaults hidden when restored from a prior autosave state).
|
|
statusItem.autosaveName = "PureMacSystemMonitor"
|
|
statusItem.isVisible = true
|
|
|
|
monitor.start()
|
|
|
|
if let button = statusItem.button {
|
|
button.image = NSImage(
|
|
systemSymbolName: "gauge.with.dots.needle.67percent",
|
|
accessibilityDescription: "System Monitor"
|
|
)
|
|
button.imagePosition = .imageLeading
|
|
button.target = self
|
|
button.action = #selector(togglePopover)
|
|
updateTitle()
|
|
}
|
|
|
|
popover.behavior = .transient
|
|
popover.contentSize = NSSize(width: 248, height: 230)
|
|
popover.contentViewController = NSHostingController(rootView: MenuBarMonitorView())
|
|
popover.delegate = self
|
|
|
|
// Refresh the menu-bar CPU readout each time the monitor samples.
|
|
cancellable = monitor.$cpuUsage
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] _ in self?.updateTitle() }
|
|
}
|
|
|
|
/// Remove the status item and release the monitor observer. Called by
|
|
/// AppDelegate before dropping the controller so teardown runs on the main
|
|
/// actor (a `@MainActor` deinit cannot touch isolated state safely).
|
|
func teardown() {
|
|
cancellable?.cancel()
|
|
cancellable = nil
|
|
if popover.isShown { popover.performClose(nil) }
|
|
NSStatusBar.system.removeStatusItem(statusItem)
|
|
monitor.stop()
|
|
}
|
|
|
|
private func updateTitle() {
|
|
guard let button = statusItem.button else { return }
|
|
button.title = " \(Int((monitor.cpuUsage * 100).rounded()))%"
|
|
}
|
|
|
|
@objc private func togglePopover() {
|
|
guard let button = statusItem.button else { return }
|
|
if popover.isShown {
|
|
popover.performClose(nil)
|
|
} else {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
popover.contentViewController?.view.window?.makeKey()
|
|
}
|
|
}
|
|
}
|