From 30c7d51fc34f1858c4a1ce503d41f753bafa198f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 31 Oct 2026 21:28:13 +0545 Subject: [PATCH] Menu bar: own the status item and rebuild the panel on every open MenuBarExtra(.window) builds its panel once and reuses it for the process's life. After ~10 hours one went deaf: AppKit tracked clicks and sent actions, but no SwiftUI action ran and nothing redrew, until eWiz was relaunched. MenuBarController now owns an NSStatusItem hosting MenuBarLabel, and builds a borderless NSPanel + NSHostingView on each open, torn down on close (resign key, Esc, outside click, icon click). The window sizes from the content's own constraints in the same layout pass, and setFrame pins the top edge and clamps to the screen, which fixes the mode-switch flicker and the off-screen right edge. Follow-ons of rebuilding the panel each open: - a hidden MenuBarExtra(isInserted: false) keeps SwiftUI from opening Settings at launch, and the no-Cmd-Q command moves to the Settings scene - the panel's helper-install error lives in ChargeLimitStore, not view state - the panel seeds its height from the last measurement instead of growing in --- .changeset/menu-panel-rebuilt.md | 10 ++ Sources/eWiz/ChargeLimitStore.swift | 7 ++ Sources/eWiz/EWizApp.swift | 91 +++++++------- Sources/eWiz/MenuBarController.swift | 176 +++++++++++++++++++++++++++ Sources/eWiz/MenuContentView.swift | 15 ++- 5 files changed, 253 insertions(+), 46 deletions(-) create mode 100644 .changeset/menu-panel-rebuilt.md create mode 100644 Sources/eWiz/MenuBarController.swift 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) } }