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
147 changes: 145 additions & 2 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -166,6 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.stopStatusItemPlacementRecovery()
self?.prepareRefreshPipelineForSleep()
}
}
Expand All @@ -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()
}
}

Expand All @@ -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()
}
}

Expand All @@ -206,6 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
) { [weak self] _ in
Task { @MainActor in
self?.displayAsleep = true
self?.stopStatusItemPlacementRecovery()
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading