Files
ggbond268--mactools/Sources/App/MenuBarStatusItemExpandedInterface.swift
T
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

130 lines
5.5 KiB
Swift

import AppKit
import OSLog
// MARK: - MenuBarStatusItemExpandedInterface
//
// Runtime-only bridge to the `NSStatusItem.expandedInterfaceDelegate` channel.
// The app builds with older SDKs and a macOS 14.0 target, so every touchpoint
// goes through selector-based dispatch. When the API is absent, the status item
// stays on the normal button action route.
@MainActor
final class MenuBarStatusItemExpandedInterfaceAdapter: NSObject {
static let delegateSetterSelectorName = "setExpandedInterfaceDelegate:"
static let delegateGetterKey = "expandedInterfaceDelegate"
static let sessionDidBeginSelectorName = "statusItem:didBeginExpandedInterfaceSession:"
static let sessionDidEndSelectorName = "statusItemDidEndExpandedInterfaceSession:animated:"
static let sessionCancelSelectorName = "cancel"
var onSessionBegin: ((_ session: NSObject) -> Void)?
var onSessionEnd: ((_ animated: Bool) -> Void)?
static func isSupported(by item: NSStatusItem) -> Bool {
item.responds(to: NSSelectorFromString(delegateSetterSelectorName))
}
/// Sets this adapter as the item's expanded-interface delegate and
/// verifies the write by reading the property back. Returns true only
/// when the read-back is this exact adapter. The caller must retain the
/// adapter for as long as it is attached (the item's reference is weak).
func attach(to item: NSStatusItem) -> Bool {
guard Self.isSupported(by: item) else { return false }
// perform(_:with:), not KVC: setValue(forKey:) with a wrong key
// raises an ObjC exception Swift cannot catch.
_ = item.perform(NSSelectorFromString(Self.delegateSetterSelectorName), with: self)
// The read-back KVC key resolves through the property getter; a
// responds check must guard it for the same uncatchable-exception
// reason as above.
guard item.responds(to: NSSelectorFromString(Self.delegateGetterKey)) else {
rollBackAttach(on: item)
AppLog.pluginHost.error(
"Status item responds to the expanded-interface delegate setter but not the getter; attach aborted"
)
return false
}
guard (item.value(forKey: Self.delegateGetterKey) as? NSObject) === self else {
rollBackAttach(on: item)
AppLog.pluginHost.error(
"Expanded-interface delegate read-back mismatch after attach; falling back to the action route"
)
return false
}
return true
}
/// Every failed attach must end with the delegate cleared: an attached
/// delegate replaces the button action channel, so a false return that
/// leaves it set would strand the status item with neither route.
private func rollBackAttach(on item: NSStatusItem) {
_ = item.perform(NSSelectorFromString(Self.delegateSetterSelectorName), with: nil)
}
/// Cancels an expanded-interface session. `cancel` triggers the didEnd
/// callback synchronously on this same call stack; callers must have
/// their state ready for that re-entry BEFORE calling this.
static func cancel(session: NSObject) {
let selector = NSSelectorFromString(sessionCancelSelectorName)
guard session.responds(to: selector) else {
// A session that cannot be cancelled stays active host-side and
// leaves the status item inert; this must never be silent.
AppLog.pluginHost.error(
"Expanded-interface session \(String(describing: type(of: session)), privacy: .public) does not respond to cancel; session left active"
)
return
}
_ = session.perform(selector)
}
// MARK: ObjC delegate callbacks (host-invoked; selectors must match the
// probed strings exactly)
@objc(statusItem:didBeginExpandedInterfaceSession:)
func statusItemDidBeginExpandedInterfaceSession(_ statusItem: NSStatusItem, session: NSObject) {
onSessionBegin?(session)
}
@objc(statusItemDidEndExpandedInterfaceSession:animated:)
func statusItemDidEndExpandedInterfaceSession(_ statusItem: NSStatusItem, animated: Bool) {
onSessionEnd?(animated)
}
}
// MARK: - MenuBarExpandedSessionCoordinator
/// Pure expanded-session bookkeeping, free of AppKit so it is testable
/// headless. The close path clears state before calling `cancel` because AppKit
/// may synchronously call the did-end delegate callback.
@MainActor
final class MenuBarExpandedSessionCoordinator {
private(set) var activeSession: NSObject?
private var isHandlingSessionEnd = false
func sessionDidBegin(_ session: NSObject) {
activeSession = session
}
/// Single close gate. With an active session the close must go through
/// `cancel(session)`: `activeSession` is cleared first because cancel
/// may synchronously re-enter via didEnd. Without a session the request
/// falls through to `directDismiss()`.
func requestClose(cancel: (NSObject) -> Void, directDismiss: () -> Void) {
guard let session = activeSession else {
directDismiss()
return
}
activeSession = nil
cancel(session)
}
/// didEnd handler. The re-entry guard prevents a dismiss implementation
/// from recursively routing back into this path.
func sessionDidEnd(dismiss: () -> Void) {
if isHandlingSessionEnd { return }
isHandlingSessionEnd = true
defer { isHandlingSessionEnd = false }
activeSession = nil
dismiss()
}
}