From cd9d32a6e5c3e9249df2c26a4cdc87062a129c04 Mon Sep 17 00:00:00 2001 From: samhenrigold <49251320+samhenrigold@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:54:44 -0400 Subject: [PATCH] Support macOS 27 expanded interface session machinery --- .../MenuBarExtraAccess.swift | 9 +- .../MenuBarExtraUtils/MenuBarExtraUtils.swift | 20 ++- .../NSStatusItem Extensions.swift | 45 ++++- ...SStatusItem+ExpandedInterfaceSession.swift | 155 ++++++++++++++++++ 4 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 Sources/MenuBarExtraAccess/NSStatusItem+ExpandedInterfaceSession.swift diff --git a/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift b/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift index 11a9b72..7355c8f 100644 --- a/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift +++ b/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift @@ -204,7 +204,14 @@ struct MenuBarExtraAccess: Scene { // it's possible for a window to resign key without actually closing, so let's // close it as a failsafe. - if window.isVisible { + if let statusItem = MenuBarExtraUtils.statusItem(for: .index(index)), + statusItem.usesExpandedInterfaceSession + { + // on macOS 27+, closing the window directly would leave the status item's + // expanded interface session active; cancel the session instead + // (a no-op if the session already ended) + statusItem.cancelExpandedInterfaceSession() + } else if window.isVisible { #if MENUBAREXTRAACCESS_DEBUG_LOGGING print("Closing MenuBarExtra index \(index) drop-down window as a result of it resigning as key.") #endif diff --git a/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift b/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift index 878e4c8..a1ce9c2 100644 --- a/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift +++ b/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift @@ -272,6 +272,16 @@ extension NSStatusItem { MenuBarExtraUtils.statusItems.firstIndex(of: self) ?? 0 } + /// The SwiftUI internal behavior object backing a window-based `MenuBarExtra` + /// (`SwiftUI.WindowMenuBarExtraBehavior`), or `nil` for a menu-based `MenuBarExtra`. + /// + /// On macOS 26 and earlier the behavior is the status item button's `target`; + /// on macOS 27+ it is the status item's `expandedInterfaceDelegate` + /// (or stashed by this library while the status item is disabled). + fileprivate var menuBarExtraBehavior: AnyObject? { + button?.target ?? expandedInterfaceDelegate ?? suspendedExpandedInterfaceDelegate + } + /// Returns the ID string for the status item. /// Returns `nil` if the status item does not contain a `MacControlCenterMenu` fileprivate var menuBarExtraID: String? { @@ -299,11 +309,11 @@ extension NSStatusItem { // this may require a less brittle solution if the child path may change, such as grabbing // String(dump: behavior) and using RegEx to find the value - guard let behavior = button?.target, // SwiftUI.WindowMenuBarExtraBehavior <- internal + guard let behavior = menuBarExtraBehavior, // SwiftUI.WindowMenuBarExtraBehavior <- internal let mirror = Mirror(reflecting: behavior).superclassMirror else { #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Could not find status item's target.") + print("Could not find status item's behavior object.") #endif return nil @@ -313,9 +323,9 @@ extension NSStatusItem { } var isMenuBarExtraMenuBased: Bool { - // if window-based, target will be the internal type SwiftUI.WindowMenuBarExtraBehavior - // if menu-based, target will be nil - guard let behavior = button?.target + // if window-based, the behavior object will be the internal type + // SwiftUI.WindowMenuBarExtraBehavior; if menu-based, there is none + guard let behavior = menuBarExtraBehavior else { return true } diff --git a/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift b/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift index e8ada9f..9289506 100644 --- a/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift +++ b/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift @@ -18,6 +18,19 @@ extension NSStatusItem { print("NSStatusItem.\(#function) called") #endif + // on macOS 27+, window-based MenuBarExtra presentation is driven by AppKit's + // expanded interface session machinery and the button's target/action are nil, + // so a simulated button click no longer presents the window. + // see `NSStatusItem+ExpandedInterfaceSession.swift` for details. + if usesExpandedInterfaceSession { + if expandedInterfaceSession != nil { + cancelExpandedInterfaceSession() + } else { + beginExpandedInterfaceSession() + } + return + } + // this also works but only for window-based MenuBarExtra // (button.target and button.action are nil when menu-based): // - mimic user pressing the menu item button @@ -37,24 +50,37 @@ extension NSStatusItem { #endif // read current state and selectively call toggle if state differs - let currentState = button?.state != .off - guard state != currentState else { + guard state != isMenuBarExtraPresented else { updateHighlight() return } // don't allow presenting the menu if the status item is disabled - if state { guard button?.isEnabled == true else { return } } + if state { + guard button?.isEnabled == true else { return } + } togglePresented() } + /// Current presentation state, regardless of macOS version or presentation mechanism. + var isMenuBarExtraPresented: Bool { + if usesExpandedInterfaceSession { + // on macOS 27+ the session is the source of truth; the button state remains `.off` + return expandedInterfaceSession != nil + } + return button?.state != .off + } + @_disfavoredOverload func updateHighlight() { #if MENUBAREXTRAACCESS_DEBUG_LOGGING print("NSStatusItem.\(#function) called") #endif + // the system manages the button highlight for session-based presentation + guard !usesExpandedInterfaceSession else { return } + let s = button?.state != .off #if MENUBAREXTRAACCESS_DEBUG_LOGGING @@ -66,6 +92,10 @@ extension NSStatusItem { /// Only call this when the state of the drop-down window is known. func setKnownPresented(state: Bool) { + // on macOS 27+ the button state does not participate in presentation; + // leave it `.off` so that fallback state reads remain consistent + guard !usesExpandedInterfaceSession else { return } + switch state { case true: button?.state = .on @@ -76,6 +106,15 @@ extension NSStatusItem { func setEnabled(state: Bool) { button?.isEnabled = state + + // on macOS 27+ the button's enabled state never reaches the system menu bar host, + // which begins an expanded interface session on click regardless. detaching the + // status item's delegate is what actually disables click-to-present. + if state { + restoreExpandedInterfaceDelegate() + } else { + suspendExpandedInterfaceDelegate() + } } } diff --git a/Sources/MenuBarExtraAccess/NSStatusItem+ExpandedInterfaceSession.swift b/Sources/MenuBarExtraAccess/NSStatusItem+ExpandedInterfaceSession.swift new file mode 100644 index 0000000..de60cdc --- /dev/null +++ b/Sources/MenuBarExtraAccess/NSStatusItem+ExpandedInterfaceSession.swift @@ -0,0 +1,155 @@ +// +// NSStatusItem+ExpandedInterfaceSession.swift +// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess +// © 2026 Steffan Andrews • Licensed under MIT License +// + +#if canImport(AppKit) + +import AppKit + +// MARK: - Expanded Interface Session (macOS 27+) + +// Starting with macOS 27, SwiftUI no longer wires a window-based MenuBarExtra to its status item +// button's target/action (both are now nil), so simulating a button click no longer toggles the +// window. Presentation is instead driven by private AppKit "expanded interface session" machinery: +// +// 1. A click on the status item is delivered from the system menu bar as a scene action. +// 2. AppKit begins an `NSStatusItemExpandedInterfaceSession` and notifies the status item's +// `expandedInterfaceDelegate`, which SwiftUI sets to its internal `WindowMenuBarExtraBehavior` +// (the same object that was the button's `target` on macOS 26 and earlier). +// 3. The delegate shows or hides the MenuBarExtra window in response to the session +// beginning/ending. +// +// `NSStatusItem.expandedInterfaceSession` is non-nil while the window is presented. The button's +// `state` remains `.off` throughout and can no longer be used to detect or drive presentation. +// +// All symbols involved are SPI, so everything below is invoked dynamically and guarded by runtime +// `responds(to:)` checks. On macOS 26 and earlier — and for menu-based MenuBarExtras, which have +// no delegate — these members are inert and callers fall back to the legacy button target/action +// mechanism. + +@MainActor +extension NSStatusItem { + private enum SPI { + static let beginSession = NSSelectorFromString("_beginExpandedInterfaceSession:") + static let session = NSSelectorFromString("expandedInterfaceSession") + static let delegate = NSSelectorFromString("expandedInterfaceDelegate") + static let setDelegate = NSSelectorFromString("setExpandedInterfaceDelegate:") + static let cancel = NSSelectorFromString("cancel") + } + + /// Returns `true` if this status item presents its content using the expanded interface + /// session mechanism (a window-based `MenuBarExtra` on macOS 27 or later). + var usesExpandedInterfaceSession: Bool { + responds(to: SPI.beginSession) && expandedInterfaceDelegate != nil + } + + /// The status item's `expandedInterfaceDelegate` (macOS 27+): SwiftUI's internal + /// `WindowMenuBarExtraBehavior` for a window-based `MenuBarExtra`, otherwise `nil`. + var expandedInterfaceDelegate: AnyObject? { + guard responds(to: SPI.delegate) else { return nil } + return perform(SPI.delegate)?.takeUnretainedValue() + } + + /// The active `NSStatusItemExpandedInterfaceSession`, or `nil` while not presented + /// (macOS 27+). + var expandedInterfaceSession: NSObject? { + guard responds(to: SPI.session) else { return nil } + return perform(SPI.session)?.takeUnretainedValue() as? NSObject + } + + /// Begins an expanded interface session, presenting the MenuBarExtra window via the status + /// item's delegate (macOS 27+). + /// + /// `-[NSStatusItem _beginExpandedInterfaceSession:]` takes the timestamp of the event that + /// triggered the session and silently drops any begin whose timestamp predates the end of + /// the previous session — a debounce so the click that dismisses the window cannot + /// immediately re-present it. Programmatic presentation has no triggering event, so pass + /// the maximum timestamp to always pass that check. + /// (`-[NSStatusItem _requestExpandedInterfaceSession]`, the round-trip variant that asks + /// the system menu bar host to begin the session, falls afoul of the same debounce and is + /// unreliable for repeated presentation.) + func beginExpandedInterfaceSession() { + #if MENUBAREXTRAACCESS_DEBUG_LOGGING + print("NSStatusItem.\(#function) called") + #endif + + guard expandedInterfaceSession == nil, + responds(to: SPI.beginSession), + let implementation = method(for: SPI.beginSession) + else { return } + + typealias BeginSession = @convention(c) (NSStatusItem, Selector, TimeInterval) -> Void + unsafeBitCast(implementation, to: BeginSession.self)(self, SPI.beginSession, .greatestFiniteMagnitude) + } + + /// Cancels the active expanded interface session, dismissing the MenuBarExtra window + /// (macOS 27+). This mirrors how SwiftUI itself dismisses the window. + func cancelExpandedInterfaceSession() { + #if MENUBAREXTRAACCESS_DEBUG_LOGGING + print("NSStatusItem.\(#function) called") + #endif + + guard let session = expandedInterfaceSession, + session.responds(to: SPI.cancel) + else { return } + + session.perform(SPI.cancel) + } +} + +// MARK: - Suspending Presentation While Disabled + +// On macOS 27+ the button's `isEnabled` state never reaches the system menu bar host, so a click +// on a "disabled" status item still begins a session and momentarily flashes the window before +// any in-process code can react. Removing the status item's delegate is what actually prevents +// this: it clears the scene's supports-expanded-interface setting so a click never begins a +// session in the first place. The delegate is stashed weakly so it can be reinstated when the +// item is re-enabled. + +@MainActor +extension NSStatusItem { + private static let suspendedDelegateKey: UnsafeMutableRawPointer = malloc(1)! + + private final class WeakRef { + private(set) weak var object: AnyObject? + init(_ object: AnyObject) { + self.object = object + } + } + + /// The delegate stashed by ``suspendExpandedInterfaceDelegate()``, if presentation is + /// currently suspended (macOS 27+). + var suspendedExpandedInterfaceDelegate: AnyObject? { + (objc_getAssociatedObject(self, Self.suspendedDelegateKey) as? WeakRef)?.object + } + + /// Detaches the status item's expanded interface delegate so that clicking the item no + /// longer begins a session, stashing it for later restoration (macOS 27+). + /// Harmless no-op if unsupported by the OS or already suspended. + func suspendExpandedInterfaceDelegate() { + guard responds(to: SPI.setDelegate), + let delegate = expandedInterfaceDelegate + else { return } + + objc_setAssociatedObject(self, Self.suspendedDelegateKey, WeakRef(delegate), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + perform(SPI.setDelegate, with: nil) + } + + /// Reinstates a delegate previously detached by ``suspendExpandedInterfaceDelegate()`` + /// (macOS 27+). Harmless no-op if unsupported by the OS or nothing is suspended. + func restoreExpandedInterfaceDelegate() { + guard responds(to: SPI.setDelegate), + let ref = objc_getAssociatedObject(self, Self.suspendedDelegateKey) as? WeakRef + else { return } + + objc_setAssociatedObject(self, Self.suspendedDelegateKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + // don't clobber a delegate SwiftUI may have re-set in the interim + guard expandedInterfaceDelegate == nil, let delegate = ref.object else { return } + perform(SPI.setDelegate, with: delegate) + } +} + +#endif