diff --git a/.changeset/menu-panel-rebuilt.md b/.changeset/menu-panel-rebuilt.md new file mode 100644 index 0000000..47fb9ed --- /dev/null +++ b/.changeset/menu-panel-rebuilt.md @@ -0,0 +1,10 @@ +--- +"ewiz": patch +--- + +**The menu-bar panel no longer stops responding after eWiz has been running a while.** +After hours of running, the panel could open looking normal while every button, toggle +and mode in it did nothing until eWiz was restarted. The panel is now built fresh each +time it opens, so it can't go stale. It also opens fully on screen at the right edge, no +longer flickers when switching modes, and keeps a failed helper install's error visible +after the password prompt closes it. diff --git a/Sources/eWiz/ChargeLimitStore.swift b/Sources/eWiz/ChargeLimitStore.swift index 316cb58..34bc3f6 100644 --- a/Sources/eWiz/ChargeLimitStore.swift +++ b/Sources/eWiz/ChargeLimitStore.swift @@ -338,6 +338,13 @@ final class ChargeLimitStore: ObservableObject { helperInstallFailure = nil } + /// A failed install from a button. Kept here rather than in the view: the menu panel is + /// rebuilt on every open, and the admin prompt closes it, so a view's own state would + /// lose the message before anyone could read it. + func recordInstallFailure(_ message: String) { + helperInstallFailure = message + } + /// Uninstalling is an explicit no, and has to be recorded as one. /// /// Otherwise the very next status poll finds no daemon, decides that's a fresh machine diff --git a/Sources/eWiz/EWizApp.swift b/Sources/eWiz/EWizApp.swift index 6c18b28..cedebd2 100644 --- a/Sources/eWiz/EWizApp.swift +++ b/Sources/eWiz/EWizApp.swift @@ -3,6 +3,8 @@ import AppKit import EWizKit final class AppDelegate: NSObject, NSApplicationDelegate { + @MainActor let menuBar = MenuBarController() + func applicationDidFinishLaunching(_ notification: Notification) { // Menu-bar-only app: no Dock icon, no app-switcher entry. NSApp.setActivationPolicy(.accessory) @@ -39,46 +41,48 @@ struct EWizApp: App { @StateObject private var idleSaver = IdleSaverStore() var body: some Scene { - MenuBarExtra { - MenuContentView() - .environmentObject(battery) - .environmentObject(chargeLimit) - .environmentObject(automation) - .environmentObject(processes) - .environmentObject(license) - .environmentObject(startup) - .environmentObject(updater) - .environmentObject(actions) - .environmentObject(caffeine) - .environmentObject(settings) - .environmentObject(notifier) - .environmentObject(network) - .environmentObject(triggers) - .environmentObject(hotkeys) - .environmentObject(restReminder) - .environmentObject(idleSaver) - .environmentObject(overlay) - .onAppear { - network.chargeLimit = chargeLimit - automation.chargeLimit = chargeLimit - } - } label: { - // Its own observing view so it re-renders reliably — a label closure that - // reads the store inline can render once and go stale. It also renders at - // launch, which is where the automation rules get started (the dropdown's - // `onAppear` wouldn't run until you first opened the menu). - MenuBarLabel(battery: battery, chargeLimit: chargeLimit, - settings: settings, notifier: notifier, triggers: triggers, - hotkeys: hotkeys, caffeine: caffeine, actions: actions, - license: license, restReminder: restReminder, overlay: overlay, - idleSaver: idleSaver) - } - .menuBarExtraStyle(.window) - // No ⌘Q. Clicking the panel or a window makes eWiz the active app, so a ⌘Q meant for - // the app you were just in quit eWiz instead — silently, taking hotkeys, automations - // and keep-awake with it until it was next opened by hand. The panel's power button - // is the way out. - .commands { CommandGroup(replacing: .appTermination) {} } + // The menu-bar item and its panel are AppKit-owned — see `MenuBarController`. Set up + // from here because this is the first place the stores exist; `install` is + // idempotent, so the body being evaluated again is harmless. + let _ = appDelegate.menuBar.install( + label: { + // Its own observing view so it re-renders reliably. It also exists from + // launch, which is where the automation rules, shortcuts and the rest of + // the always-on behaviour get started. + AnyView(MenuBarLabel(battery: battery, chargeLimit: chargeLimit, + settings: settings, notifier: notifier, triggers: triggers, + hotkeys: hotkeys, caffeine: caffeine, actions: actions, + license: license, restReminder: restReminder, + overlay: overlay, idleSaver: idleSaver)) + }, + panel: { [self] in + AnyView(MenuContentView() + .environmentObject(battery) + .environmentObject(chargeLimit) + .environmentObject(automation) + .environmentObject(processes) + .environmentObject(license) + .environmentObject(startup) + .environmentObject(updater) + .environmentObject(actions) + .environmentObject(caffeine) + .environmentObject(settings) + .environmentObject(notifier) + .environmentObject(network) + .environmentObject(triggers) + .environmentObject(hotkeys) + .environmentObject(restReminder) + .environmentObject(idleSaver) + .environmentObject(overlay) + .onAppear { + network.chargeLimit = chargeLimit + automation.chargeLimit = chargeLimit + }) + }) + + // Never shown: no status item, no panel. Without a menu-bar scene SwiftUI treats eWiz + // as a windowed app and opens Settings, the first window below, at every launch. + MenuBarExtra("eWiz", isInserted: .constant(false)) { EmptyView() } // Set-once controls live here so the menu dropdown stays focused on daily use. Window("eWiz Settings", id: "settings") { @@ -99,6 +103,11 @@ struct EWizApp: App { .environmentObject(idleSaver) } .windowResizability(.contentSize) + // No ⌘Q. Clicking the panel or a window makes eWiz the active app, so a ⌘Q meant for + // the app you were just in quit eWiz instead — silently, taking hotkeys, automations + // and keep-awake with it until it was next opened by hand. The panel's power button + // is the way out. (Commands are app-wide, whichever scene carries them.) + .commands { CommandGroup(replacing: .appTermination) {} } Window("Battery Details", id: "details") { DetailsView() @@ -128,7 +137,7 @@ struct EWizApp: App { } /// Its own View with @ObservedObject stores so SwiftUI re-renders on snapshot/charge -/// changes — a MenuBarExtra label closure reading a store inline can go stale/blank. +/// changes. Hosted in the status item by `MenuBarController`. struct MenuBarLabel: View { @ObservedObject var battery: BatteryStore @ObservedObject var chargeLimit: ChargeLimitStore diff --git a/Sources/eWiz/MenuBarController.swift b/Sources/eWiz/MenuBarController.swift new file mode 100644 index 0000000..91c95ca --- /dev/null +++ b/Sources/eWiz/MenuBarController.swift @@ -0,0 +1,176 @@ +import SwiftUI +import AppKit + +/// The menu-bar item and its dropdown, owned here rather than by SwiftUI's `MenuBarExtra`. +/// +/// `MenuBarExtra(.window)` builds its panel once and reuses that window for the life of the +/// process. After hours of running, that window could come loose from the view graph behind +/// it: clicks still arrived (AppKit tracked them and sent the action) but no SwiftUI action +/// ran and nothing redrew, so every control in the panel was dead until eWiz was relaunched. +/// Here the panel and its hosting view are built on every open and thrown away on close, so +/// there is no long-lived panel to go stale. +@MainActor +final class MenuBarController: NSObject { + private var statusItem: NSStatusItem? + private var labelHost: StatusLabelHostingView? + private var panel: MenuPanel? + private var makePanelContent: (() -> AnyView)? + private var outsideClickMonitor: Any? + /// When the panel last closed because it lost focus. Clicking the status item while the + /// panel is open takes focus first, closing it, and then delivers the click — which + /// would open it straight back up. + private var lastResignClose = Date.distantPast + + /// Idempotent: the App's body, which calls this, is evaluated more than once. + func install(label: () -> AnyView, panel content: @escaping () -> AnyView) { + guard statusItem == nil else { return } + makePanelContent = content + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + guard let button = item.button else { return } + button.target = self + button.action = #selector(togglePanel) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + + let host = StatusLabelHostingView(rootView: label()) + host.translatesAutoresizingMaskIntoConstraints = false + host.onSizeChange = { [weak self] in self?.fitStatusItem() } + button.addSubview(host) + NSLayoutConstraint.activate([ + host.centerXAnchor.constraint(equalTo: button.centerXAnchor), + host.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) + statusItem = item + labelHost = host + fitStatusItem() + } + + /// The status item is as wide as the label plus the menu bar's usual side padding. + private func fitStatusItem() { + guard let item = statusItem, let host = labelHost else { return } + let width = ceil(host.fittingSize.width) + 8 + if abs(item.length - width) > 0.5 { item.length = width } + // A length change redraws the button and drops the open-panel highlight. + if panel != nil { item.button?.highlight(true) } + } + + @objc private func togglePanel() { + if panel != nil { + closePanel() + } else if Date().timeIntervalSince(lastResignClose) > 0.3 { + openPanel() + } + } + + private func openPanel() { + guard let content = makePanelContent, let button = statusItem?.button, + let buttonWindow = button.window else { return } + + // The window takes its size from the content's own constraints, in the same layout + // pass as the content changes. Left to `WindowSizer`, which resizes on the next + // runloop turn, every mode switch drew one frame of content in the old-size window. + let host = NSHostingView(rootView: content()) + host.sizingOptions = [.minSize, .maxSize] + let size = host.fittingSize + + let anchor = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) + let screen = buttonWindow.screen ?? NSScreen.main + var origin = NSPoint(x: anchor.minX, y: anchor.minY - 2 - size.height) + if let visible = screen?.visibleFrame { + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) + } + + let panel = MenuPanel(contentRect: NSRect(origin: origin, size: size)) + panel.top = anchor.minY - 2 + panel.contentView = host + panel.onResignKey = { [weak self] in + self?.lastResignClose = Date() + self?.closePanel() + } + panel.onCancel = { [weak self] in self?.closePanel() } + self.panel = panel + + button.highlight(true) + NSApp.activate() + panel.makeKeyAndOrderFront(nil) + + // Clicks in another app don't always take focus from us first (a menu bar, the + // desktop), so close on any click outside our own windows too. + outsideClickMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + Task { @MainActor in self?.closePanel() } + } + } + + private func closePanel() { + guard let panel else { return } + self.panel = nil + if let monitor = outsideClickMonitor { + NSEvent.removeMonitor(monitor) + outsideClickMonitor = nil + } + statusItem?.button?.highlight(false) + panel.onResignKey = nil + panel.orderOut(nil) + // Detach the content so its `onDisappear` runs and the view graph goes with it. + panel.contentView = nil + } +} + +/// Borderless, so the content draws its own rounded surface (`dsMenuPanel`), but able to +/// become key so the controls inside take clicks and keyboard focus. +private final class MenuPanel: NSPanel { + var onResignKey: (() -> Void)? + var onCancel: (() -> Void)? + /// Where the panel hangs from, in screen coordinates. Every resize keeps this edge put, + /// whatever drives it, so the panel grows and shrinks downwards from the menu bar. + var top: CGFloat? + + init(contentRect: NSRect) { + super.init(contentRect: contentRect, styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + level = .popUpMenu + isOpaque = false + backgroundColor = .clear + hasShadow = true + isReleasedWhenClosed = false + hidesOnDeactivate = false + collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary, .transient] + } + + override var canBecomeKey: Bool { true } + + /// Keep it on screen whatever sets the frame. The content measures near zero before its + /// first layout, so a clamp at open time saw no width, and `WindowSizer` then grew the + /// panel rightwards off the edge. + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + var frame = frameRect + if let top { frame.origin.y = top - frame.height } + if let visible = (screen ?? NSScreen.main)?.visibleFrame { + frame.origin.x = min(max(frame.origin.x, visible.minX + 8), + visible.maxX - frame.width - 8) + } + super.setFrame(frame, display: flag) + } + + override func resignKey() { + super.resignKey() + onResignKey?() + } + + /// Esc closes it, as it does a menu. + override func cancelOperation(_ sender: Any?) { onCancel?() } +} + +/// The SwiftUI label inside the status item's button. Clicks pass through to the button, +/// and a size change re-fits the item so the percentage text never gets clipped. +private final class StatusLabelHostingView: NSHostingView { + var onSizeChange: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + DispatchQueue.main.async { [weak self] in self?.onSizeChange?() } + } +} diff --git a/Sources/eWiz/MenuContentView.swift b/Sources/eWiz/MenuContentView.swift index a2b2618..7f9eed9 100644 --- a/Sources/eWiz/MenuContentView.swift +++ b/Sources/eWiz/MenuContentView.swift @@ -16,14 +16,16 @@ struct MenuContentView: View { @EnvironmentObject private var idleSaver: IdleSaverStore @EnvironmentObject private var settings: AppSettings @Environment(\.openWindow) private var openWindow - @State private var installError: String? /// A performance mode waiting on confirmation — see `requestMode`. @State private var pendingPerformanceMode: SaveMode? // Deliberately an under-estimate. `MenuBarExtra(.window)` sizes its window to this // frame, and a window taller than the panel drawn in it leaves a transparent band above // the content — which is exactly what a too-generous starting guess produced. Growing // into the measured height on the first layout pass is invisible; shrinking is not. - @State private var contentHeight: CGFloat = 320 + @State private var contentHeight: CGFloat = Self.lastContentHeight + /// The height the panel last measured. The panel is rebuilt on every open, so without + /// this each one would start at the under-estimate above and visibly grow into place. + private static var lastContentHeight: CGFloat = 320 // The system's own menu-bar panels (Bluetooth, Wi-Fi, Sound) are ~320pt wide with a // 16pt margin. Matching them is most of what makes a panel read as part of the menu @@ -102,7 +104,10 @@ struct MenuContentView: View { // appears. No system menu-bar panel does that, so the ring reads as a rendering // artifact rather than as focus. Drop it panel-wide. .focusEffectDisabled() - .onPreferenceChange(ContentHeightKey.self) { contentHeight = $0 } + .onPreferenceChange(ContentHeightKey.self) { + contentHeight = $0 + Self.lastContentHeight = $0 + } // Re-sync every time the menu opens so it never shows stale state. .onAppear { battery.refresh() @@ -604,7 +609,7 @@ struct MenuContentView: View { chargeLimit.clearAutoInstallRefusal() DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { chargeLimit.refresh() } } else { - installError = result.message + chargeLimit.recordInstallFailure(result.message) } } .controlSize(.small) @@ -613,7 +618,7 @@ struct MenuContentView: View { Text("Or run scripts/install-helper.sh") .font(DS.Typo.note).foregroundStyle(.secondary) } - if let installError { + if let installError = chargeLimit.helperInstallFailure { Text(installError).font(DS.Typo.note).foregroundStyle(.red).lineLimit(3) } }