Skip to content
Open
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
9 changes: 8 additions & 1 deletion Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,14 @@ struct MenuBarExtraAccess<Content: Scene>: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
45 changes: 42 additions & 3 deletions Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
}
}
}

Expand Down
155 changes: 155 additions & 0 deletions Sources/MenuBarExtraAccess/NSStatusItem+ExpandedInterfaceSession.swift
Original file line number Diff line number Diff line change
@@ -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