fix(menubar): recover parked status item (#1161)

This commit is contained in:
Aditya Vikram Singh 2026-08-29 01:50:48 +05:30 committed by GitHub
parent 6406296cb1
commit daafb593ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 411 additions and 2 deletions

View file

@ -53,6 +53,7 @@ struct CodeBurnApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private var statusItemPlacementRecoveryTask: Task<Void, Never>?
private var popover: NSPopover!
private var rightClickMonitor: Any?
private var lastContextMenuPresentedAt: Date = .distantPast
@ -90,6 +91,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// is the orphan in #1117. shutdown() still runs for the tidy case.
ServeChildRegistry.shared.reapAll()
Task { await ServeConnection.shared.shutdown() }
stopStatusItemPlacementRecovery()
if let monitor = rightClickMonitor {
NSEvent.removeMonitor(monitor)
rightClickMonitor = nil
@ -166,6 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.stopStatusItemPlacementRecovery()
self?.prepareRefreshPipelineForSleep()
}
}
@ -182,6 +185,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
Task { @MainActor in
self?.displayAsleep = false
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
self?.startStatusItemPlacementRecovery()
}
}
@ -193,6 +197,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
Task { @MainActor in
self?.displayAsleep = false
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
self?.startStatusItemPlacementRecovery()
}
}
@ -206,6 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
) { [weak self] _ in
Task { @MainActor in
self?.displayAsleep = true
self?.stopStatusItemPlacementRecovery()
}
}
}
@ -959,8 +965,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth)
guard let button = statusItem.button else { return }
let item = NSStatusBar.system.statusItem(withLength: statusItemWidth)
item.autosaveName = StatusItemPlacementPolicy.autosaveName
// `autosaveName` makes AppKit restore status-item state across launches.
// CodeBurn has no user-facing hide toggle, so explicitly restore the
// supported visible state in case Tahoe persisted a hidden/parked item.
item.isVisible = true
statusItem = item
guard let button = statusItem.button else {
startStatusItemPlacementRecovery()
return
}
// Set the bundled flame image immediately to ensure the status item renders.
// On macOS Tahoe, status items may fail to appear if only an attributed title
@ -1002,9 +1017,137 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// Defer the full attributed title setup to ensure initial render completes
DispatchQueue.main.async { [weak self] in
self?.refreshStatusButton()
self?.startStatusItemPlacementRecovery()
}
}
/// Tahoe can park an accessory app's status item at the screen's top-right
/// corner when the auto-hidden menu bar is hidden during launch (#1148).
/// Wait for the user's pointer to reveal the bar, then perform up to three
/// supported visibility pulses. Never remove/recreate the item: repeated
/// creation churn is implicated in poisoning the bundle-id state this
/// recovery protects.
private func startStatusItemPlacementRecovery() {
stopStatusItemPlacementRecovery()
statusItemPlacementRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return }
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(120))
var recovery = StatusItemPlacementRecoveryCoordinator()
while !Task.isCancelled && clock.now < deadline {
let placement = self.statusItemPlacementState
let revealed = placement.screen.map { screen in
StatusItemPlacementPolicy.isMenuBarRevealed(
pointer: NSEvent.mouseLocation,
screenFrame: screen.frame,
screenVisibleFrame: screen.visibleFrame
)
} ?? false
switch recovery.action(
for: placement.geometry,
isMenuBarRevealed: revealed,
revealHasSettled: false
) {
case .stopHealthy:
return
case .poll, .waitForReveal:
try? await Task.sleep(for: .milliseconds(250))
continue
case .stopExhausted:
NSLog("CodeBurn: status item remains parked after bounded retries")
return
case .settleBeforePulse:
// Let the auto-hide animation finish before asking AppKit
// to place the existing item again.
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
let settledPlacement = self.statusItemPlacementState
let settledReveal = settledPlacement.screen.map { screen in
StatusItemPlacementPolicy.isMenuBarRevealed(
pointer: NSEvent.mouseLocation,
screenFrame: screen.frame,
screenVisibleFrame: screen.visibleFrame
)
} ?? false
switch recovery.action(
for: settledPlacement.geometry,
isMenuBarRevealed: settledReveal,
revealHasSettled: true
) {
case .stopHealthy:
return
case .poll, .waitForReveal, .settleBeforePulse:
continue
case .stopExhausted:
NSLog("CodeBurn: status item remains parked after bounded retries")
return
case .pulse(let attempt):
NSLog("CodeBurn: retrying parked status-item placement after menu bar reveal (\(attempt)/\(recovery.maximumPulseCount))")
await StatusItemVisibilityPulse.run { self.statusItem.isVisible = $0 }
guard !Task.isCancelled else { return }
try? await Task.sleep(for: .milliseconds(250))
continue
}
case .pulse:
// A pulse is only emitted after the settle phase above.
assertionFailure("status item pulse emitted before reveal settled")
return
}
}
guard !Task.isCancelled else { return }
switch self.statusItemPlacementState {
case .healthy:
return
case .unrealized:
NSLog("CodeBurn: status item did not realize before placement recovery timed out")
case .parked:
NSLog("CodeBurn: status item stayed parked without a menu-bar reveal")
}
}
}
private func stopStatusItemPlacementRecovery() {
statusItemPlacementRecoveryTask?.cancel()
statusItemPlacementRecoveryTask = nil
}
private enum StatusItemPlacementState {
case unrealized
case healthy
case parked(NSScreen)
var geometry: StatusItemPlacementRecoveryGeometry {
switch self {
case .unrealized: return .unrealized
case .healthy: return .healthy
case .parked: return .parked
}
}
var screen: NSScreen? {
if case .parked(let screen) = self { return screen }
return nil
}
}
private var statusItemPlacementState: StatusItemPlacementState {
guard let window = statusItem?.button?.window else { return .unrealized }
let frame = window.frame
guard !frame.isEmpty else { return .unrealized }
guard let screen = window.screen
?? NSScreen.screens.first(where: { $0.frame.intersects(frame) })
?? NSScreen.main else { return .unrealized }
let parked = StatusItemPlacementPolicy.isParked(
itemFrame: frame,
screenFrame: screen.frame,
statusBarThickness: NSStatusBar.system.thickness
)
return parked ? .parked(screen) : .healthy
}
/// Composes the menubar title as a single attributed string with the flame as an inline
/// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a
/// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge

View file

@ -0,0 +1,130 @@
import AppKit
/// Conservative policy for the Tahoe status-item parking failure in #1148.
///
/// A narrow geometry match matters: menu bar items can legitimately be short
/// or near a display edge. The poisoned state reported in #1148 combines all
/// three signals legacy 22pt height, flush with the display's right edge,
/// and parked in the top menu-bar band.
enum StatusItemPlacementPolicy {
static let autosaveName: NSStatusItem.AutosaveName = "CodeBurnMenubar.MainStatusItem"
static func isParked(
itemFrame: CGRect,
screenFrame: CGRect,
statusBarThickness: CGFloat
) -> Bool {
guard !itemFrame.isEmpty,
!screenFrame.isEmpty,
statusBarThickness > 0 else { return false }
let geometryTolerance: CGFloat = 1
let legacyHeight = itemFrame.height + geometryTolerance < statusBarThickness
let flushWithRightEdge = abs(itemFrame.maxX - screenFrame.maxX) <= geometryTolerance
let inTopBand = itemFrame.maxY >= screenFrame.maxY - max(statusBarThickness, itemFrame.height)
return legacyHeight && flushWithRightEdge && inTopBand
}
static func isMenuBarRevealLocation(
_ location: CGPoint,
screenFrame: CGRect,
activationBand: CGFloat = 4,
edgeOvershoot: CGFloat = 2
) -> Bool {
guard activationBand > 0,
edgeOvershoot >= 0,
location.x >= screenFrame.minX,
location.x <= screenFrame.maxX else { return false }
return location.y >= screenFrame.maxY - activationBand
&& location.y <= screenFrame.maxY + edgeOvershoot
}
static func isMenuBarRevealed(
pointer: CGPoint,
screenFrame: CGRect,
screenVisibleFrame: CGRect
) -> Bool {
let geometryTolerance: CGFloat = 1
let menuBarOccupiesVisibleFrame = screenVisibleFrame.maxY < screenFrame.maxY - geometryTolerance
return menuBarOccupiesVisibleFrame
|| isMenuBarRevealLocation(pointer, screenFrame: screenFrame)
}
}
enum StatusItemPlacementRecoveryGeometry: Equatable {
case unrealized
case healthy
case parked
}
enum StatusItemPlacementRecoveryAction: Equatable {
case stopHealthy
case poll
case waitForReveal
case settleBeforePulse
case pulse(Int)
case stopExhausted
}
/// Pure state machine for the AppKit recovery loop. A reveal is consumed only
/// when a pulse is actually issued; realization lag must not waste the user's
/// one reveal gesture. After a failed pulse, a hide followed by a distinct
/// reveal is required before another attempt.
struct StatusItemPlacementRecoveryCoordinator {
private(set) var pulseCount = 0
private var requiresHideBeforeNextPulse = false
let maximumPulseCount: Int
init(maximumPulseCount: Int = 3) {
self.maximumPulseCount = maximumPulseCount
}
mutating func action(
for geometry: StatusItemPlacementRecoveryGeometry,
isMenuBarRevealed: Bool,
revealHasSettled: Bool
) -> StatusItemPlacementRecoveryAction {
if geometry == .healthy {
return .stopHealthy
}
guard geometry != .unrealized else {
return .poll
}
guard pulseCount < maximumPulseCount else {
return .stopExhausted
}
if requiresHideBeforeNextPulse {
if !isMenuBarRevealed {
requiresHideBeforeNextPulse = false
}
return .waitForReveal
}
guard isMenuBarRevealed else {
return .waitForReveal
}
guard revealHasSettled else {
return .settleBeforePulse
}
pulseCount += 1
requiresHideBeforeNextPulse = true
return .pulse(pulseCount)
}
}
@MainActor
enum StatusItemVisibilityPulse {
static func run(
setVisible: (Bool) -> Void,
sleep: (Duration) async throws -> Void = { duration in
try await Task.sleep(for: duration)
}
) async {
setVisible(false)
// A cancelled sleep throws immediately. Visibility is restored before
// the caller observes cancellation or returns.
try? await sleep(.milliseconds(50))
setVisible(true)
}
}

View file

@ -0,0 +1,136 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Status item placement policy")
struct StatusItemPlacementPolicyTests {
private let screen = CGRect(x: 0, y: 0, width: 1_440, height: 900)
@Test("recognizes the Tahoe parked frame reported in issue 1148")
func recognizesParkedFrame() {
let parked = CGRect(x: 1_418, y: 878, width: 22, height: 22)
#expect(StatusItemPlacementPolicy.isParked(
itemFrame: parked,
screenFrame: screen,
statusBarThickness: 30
))
}
@Test("does not disturb a healthy rightmost item")
func preservesHealthyRightmostItem() {
let healthy = CGRect(x: 1_410, y: 870, width: 30, height: 30)
#expect(!StatusItemPlacementPolicy.isParked(
itemFrame: healthy,
screenFrame: screen,
statusBarThickness: 30
))
}
@Test("does not mistake a short item away from the corner for parked")
func preservesShortPlacedItem() {
let placed = CGRect(x: 900, y: 878, width: 22, height: 22)
#expect(!StatusItemPlacementPolicy.isParked(
itemFrame: placed,
screenFrame: screen,
statusBarThickness: 30
))
}
@Test("waits for the pointer to reveal an auto-hidden menu bar")
func recognizesRevealGesture() {
#expect(StatusItemPlacementPolicy.isMenuBarRevealLocation(
CGPoint(x: 720, y: 899),
screenFrame: screen
))
#expect(StatusItemPlacementPolicy.isMenuBarRevealLocation(
CGPoint(x: 720, y: 900),
screenFrame: screen
))
#expect(StatusItemPlacementPolicy.isMenuBarRevealLocation(
CGPoint(x: 720, y: 902),
screenFrame: screen
))
#expect(!StatusItemPlacementPolicy.isMenuBarRevealLocation(
CGPoint(x: 720, y: 880),
screenFrame: screen
))
#expect(!StatusItemPlacementPolicy.isMenuBarRevealLocation(
CGPoint(x: 1_500, y: 900),
screenFrame: screen
))
}
@Test("uses pointer location for an auto-hidden menu bar")
func autoHiddenRevealSignal() {
#expect(!StatusItemPlacementPolicy.isMenuBarRevealed(
pointer: CGPoint(x: 720, y: 500),
screenFrame: screen,
screenVisibleFrame: screen
))
#expect(StatusItemPlacementPolicy.isMenuBarRevealed(
pointer: CGPoint(x: 720, y: 900),
screenFrame: screen,
screenVisibleFrame: screen
))
}
@Test("recognizes a menu bar that occupies the visible frame")
func alwaysVisibleMenuBarSignal() {
let visibleFrame = CGRect(x: 0, y: 0, width: 1_440, height: 870)
#expect(StatusItemPlacementPolicy.isMenuBarRevealed(
pointer: CGPoint(x: 720, y: 500),
screenFrame: screen,
screenVisibleFrame: visibleFrame
))
}
@Test("realization lag does not consume the reveal")
func realizationLagPreservesReveal() {
var recovery = StatusItemPlacementRecoveryCoordinator()
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse)
#expect(recovery.action(for: .unrealized, isMenuBarRevealed: false, revealHasSettled: true) == .poll)
#expect(recovery.pulseCount == 0)
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse)
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(1))
}
@Test("requires a hide and distinct reveal after an actual pulse")
func retryRequiresDistinctReveal() {
var recovery = StatusItemPlacementRecoveryCoordinator()
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(1))
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .waitForReveal)
#expect(recovery.action(for: .parked, isMenuBarRevealed: false, revealHasSettled: false) == .waitForReveal)
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: false) == .settleBeforePulse)
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(2))
}
@Test("never emits more than three pulses")
func boundsPulseCount() {
var recovery = StatusItemPlacementRecoveryCoordinator(maximumPulseCount: 3)
for attempt in 1...3 {
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .pulse(attempt))
_ = recovery.action(for: .parked, isMenuBarRevealed: false, revealHasSettled: false)
}
#expect(recovery.action(for: .parked, isMenuBarRevealed: true, revealHasSettled: true) == .stopExhausted)
#expect(recovery.pulseCount == 3)
}
@Test("restores visibility when the pulse sleep is cancelled")
@MainActor
func cancellationRestoresVisibility() async {
var visibleStates: [Bool] = []
await StatusItemVisibilityPulse.run(
setVisible: { visibleStates.append($0) },
sleep: { _ in throw CancellationError() }
)
#expect(visibleStates == [false, true])
}
@Test("keeps a stable autosave identity across launches")
func stableAutosaveIdentity() {
#expect(StatusItemPlacementPolicy.autosaveName == "CodeBurnMenubar.MainStatusItem")
}
}