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
10 changes: 10 additions & 0 deletions .changeset/menu-panel-rebuilt.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions Sources/eWiz/ChargeLimitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 50 additions & 41 deletions Sources/eWiz/EWizApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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") {
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
176 changes: 176 additions & 0 deletions Sources/eWiz/MenuBarController.swift
Original file line number Diff line number Diff line change
@@ -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<AnyView> {
var onSizeChange: (() -> Void)?

override func hitTest(_ point: NSPoint) -> NSView? { nil }

override func invalidateIntrinsicContentSize() {
super.invalidateIntrinsicContentSize()
DispatchQueue.main.async { [weak self] in self?.onSizeChange?() }
}
}
15 changes: 10 additions & 5 deletions Sources/eWiz/MenuContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand Down
Loading