mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-29 10:22:42 +00:00
fix(menubar): recover parked status item (#1161)
This commit is contained in:
parent
6406296cb1
commit
daafb593ea
3 changed files with 411 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
130
mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift
Normal file
130
mac/Sources/CodeBurnMenubar/StatusItemPlacementPolicy.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue