From de0592a5561174fb3effc7075d4a7e6ccb358807 Mon Sep 17 00:00:00 2001 From: Arseniy Kamyshev Date: Fri, 26 Jun 2026 13:13:37 +0700 Subject: [PATCH] fix(statusbar): detect macOS 27 hide failure and degrade gracefully (#360) macOS 27 ("Golden Gate") re-architected the menu bar so inflating the separator NSStatusItem's length no longer pushes neighboring icons off-screen: the separator's own backing window grows wider than the screen while neighbors stay put, so collapsing silently hides nothing. Detect this on the first collapse (separator backing window wider than the screen) and degrade gracefully instead of pretending to hide: - stop inflating the separator and restore the bar to its expanded state - restore the app activation policy under "use full menu bar on expanding" - reveal a one-time context-menu notice + alert linking #360 - on later launches, skip the inflation up front so the bar never flashes The entire degrade is gated behind ProcessInfo.isMacOS27OrLater, so macOS <= 26 behavior is byte-identical (only the existing HideMechanism diagnostic log gains screenWidth/os fields). Implements the maintainer-documented "Option B (detect-and-degrade)" and the three required review fixes from docs/BACKLOG.md: move the one-shot latch after the measurement, never coerce an unmeasurable outcome to "honored", and restore the activation policy in the degrade path. Validated on real macOS 27 (build 26A5368g): HideMechanism: requested=6016 windowWidth=5016 buttonWidth=5000 screenWidth=3008 -> degraded No new entitlements or private APIs; sandbox posture unchanged. --- CHANGELOG.md | 3 +- docs/ARCHITECTURE.md | 13 +- hidden/Common/Constant.swift | 11 ++ hidden/Common/Preferences.swift | 18 +- hidden/Extensions/UserDefault+Extension.swift | 1 + .../StatusBar/StatusBarController.swift | 164 ++++++++++++++++-- hidden/en.lproj/Localizable.strings | 7 + 7 files changed, 191 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb45f3..65d74ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,12 @@ Requires macOS 13 Ventura or later. (Pre-Ventura users: stay on - Status items that were dragged off the bar are restored at launch instead of leaving the app unreachable. - Fixed constraint and observer leaks in the tutorial view rebuild. - Tutorial strings and F-key shortcut labels now render correctly (no more private-use glyphs). +- macOS 27: Hidden Bar now detects that collapsing no longer hides icons on the re-architected menu bar and degrades gracefully — it stops inflating the separator, keeps the menu bar usable, restores the app's activation policy, and shows a one-time notice linking #360 — instead of silently doing nothing. ### Changed - Start-at-login now uses `SMAppService` (macOS 13+); the legacy launcher helper was removed and any leftover login item is deauthorized automatically on first launch. - Pinned the HotKey dependency to an exact version and removed an unused file-access entitlement and dead code (no behavior change). ### Known / in progress -- macOS 27: the hide mechanism (separator-length inflation) can stop working on the re-architected menu bar (#360). This build adds diagnostics to characterize the failure; the graceful-degrade behavior and the longer-term managed-overflow redesign are tracked separately. +- macOS 27 (#360): restoring actual icon hiding on the re-architected menu bar needs the longer-term managed-overflow redesign (#366). This build detects the failure and degrades gracefully in the meantime (see Fixed). - New menu-bar icons can appear in the hidden zone because macOS inserts them at the far left; ⌘-drag them to the right of the separator (see the manual). A built-in pin is part of the planned redesign. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36be2dd..59dfa32 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,9 +97,16 @@ A full-tree audit (2026-06) scored 9/10 with hygiene-level findings only. - **The notch**: hidden icons sit "under" the notch area on notched Macs; the trick cannot reveal them there. The real fix is a spillover/second-bar design (tracked in issues #357/#341/#148; candidate implementations in PRs #350/#358). -- **macOS 27**: the menu bar re-architecture in macOS 27 betas - (`NSMenuBarNavigationSceneExtension`) breaks length-inflation hiding entirely - (issue #360). A different mechanism may be required. +- **macOS 27**: on macOS 27 ("Golden Gate") inflating the separator's length no + longer pushes neighboring icons off-screen — the separator's own backing window + simply grows wider than the screen while its neighbors stay put, so hiding + silently does nothing (issue #360). On macOS <= 26 a status item's backing window + is the shared, screen-wide menu-bar window, so lengthening the item reflows the + bar and pushes neighbors off-screen. The app detects the macOS 27 behavior on the + first collapse (the separator's backing window ends up wider than the screen) and + degrades gracefully: it stops inflating, restores the bar, and shows a one-time + notice. Restoring real hiding needs a different mechanism (the managed-overflow + redesign, #366). - **Other apps' open menus**: interaction-awareness is pointer-position-based; a pointer deep inside another app's open dropdown is below the menubar band, so the collapse can still fire there. diff --git a/hidden/Common/Constant.swift b/hidden/Common/Constant.swift index 3bbf388..aa1b26a 100644 --- a/hidden/Common/Constant.swift +++ b/hidden/Common/Constant.swift @@ -13,3 +13,14 @@ enum Constant { static var isUsingLTRLanguage = false } + +extension ProcessInfo { + // True on macOS 27 ("Golden Gate") and later, where inflating an NSStatusItem's + // length no longer pushes neighboring menu-bar icons off-screen — the separator's + // own backing window just grows wider than the screen instead (#360). Used to + // gate the detect-and-degrade path so macOS <= 26 stays byte-identical. A runtime + // check (not `#available`) so it compiles against pre-27 SDKs. + var isMacOS27OrLater: Bool { + isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 27, minorVersion: 0, patchVersion: 0)) + } +} diff --git a/hidden/Common/Preferences.swift b/hidden/Common/Preferences.swift index 679eacc..f57103b 100644 --- a/hidden/Common/Preferences.swift +++ b/hidden/Common/Preferences.swift @@ -109,11 +109,23 @@ enum Preferences { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } } - - + + // Latches the one-time "hiding is unavailable on macOS 27" notice (#360) so it + // is shown at most once per user. No notification post: nothing observes it. + static var didShowMacOS27HideUnavailableNotice: Bool { + get { + UserDefaults.standard.bool(forKey: UserDefaults.Key.didShowMacOS27HideUnavailableNotice) + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.didShowMacOS27HideUnavailableNotice) + } + } + + } diff --git a/hidden/Extensions/UserDefault+Extension.swift b/hidden/Extensions/UserDefault+Extension.swift index 28e2668..f312262 100644 --- a/hidden/Extensions/UserDefault+Extension.swift +++ b/hidden/Extensions/UserDefault+Extension.swift @@ -19,5 +19,6 @@ extension UserDefaults { static let alwaysHiddenSectionEnabled = "alwaysHiddenSectionEnabled" static let useFullStatusBarOnExpandEnabled = "useFullStatusBarOnExpandEnabled" static let hoverToExpand = "hoverToExpand" + static let didShowMacOS27HideUnavailableNotice = "didShowMacOS27HideUnavailableNotice" } } diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 0340181..75591bc 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -63,15 +63,20 @@ class StatusBarController { private var isToggle = false - // SPEC-003 (macOS 27 hide-mechanism). macOS 27 re-architected the menu bar so - // inflating the separator length may no longer push items off-screen (#360). - // This is DIAGNOSTIC ONLY: on the first collapse with the menu-bar window - // ready, log the separator geometry so a macOS 27 run reveals which signal - // (if any) distinguishes "length honored" from "ignored". No behavior change. - // The degrade ACTION is deliberately NOT shipped: review found the trigger - // unverifiable without 27 hardware, and a false positive would disable hiding - // for a working user. The action lands once this log calibrates the signal. + // macOS 27 hide-mechanism (#360). On macOS 27 ("Golden Gate") inflating the + // separator grows OUR item's own backing window wider than the screen without + // pushing neighboring icons off-screen: hiding silently does nothing. We detect + // that on the first collapse and degrade gracefully (stop inflating, restore the + // bar, notify once). The whole action is gated behind macOS >= 27, so macOS <= 26 + // behavior is byte-identical. private var hideMechanismChecked = false + // Latched once the degrade has run, to stop any further collapse/auto-hide + // attempt from re-inflating the separator in a loop. + private var hideDegraded = false + // Context-menu item revealed only while degraded; links to #360. + private weak var macOS27NoticeMenuItem: NSMenuItem? + + private static let macOS27IssueURL = "https://github.com/dwarvesf/hidden/issues/360" private var hoverMonitor: Any? private var hoverDwellTimer: Timer? @@ -148,6 +153,8 @@ class StatusBarController { } @objc private func handleScreenParametersChanged() { + // Once degraded on macOS 27 there is nothing to re-apply; never re-inflate. + guard !hideDegraded else { return } // Re-apply the recomputed length to the LIVE item when collapsed, or a // display hot-plug leaves the separator at a stale length (PR #354). let wasCollapsed = isCollapsed @@ -227,8 +234,11 @@ class StatusBarController { } func showHideSeparatorsAndAlwayHideArea() { + // After the macOS 27 degrade there is nothing to hide; don't re-inflate + // the always-hidden separator into a dead zone. + guard !hideDegraded else { return } Preferences.areSeparatorsHidden ? self.showSeparators() : self.hideSeparators() - + if self.isCollapsed {self.expandMenubar()} } @@ -263,6 +273,16 @@ class StatusBarController { } private func collapseMenuBar() { + // macOS 27 (#360): hiding has been confirmed unavailable, so do not inflate. + guard !hideDegraded else { return } + // If a previous launch already confirmed hiding is unavailable on macOS 27 + // (the one-time notice was shown), degrade up front instead of inflating the + // separator first — otherwise it visibly stretches then snaps back on every + // launch. The notice flag is only ever set on macOS 27. + if ProcessInfo.processInfo.isMacOS27OrLater, Preferences.didShowMacOS27HideUnavailableNotice { + degradeHideUnavailable() + return + } guard self.isBtnSeparateValidPosition && !self.isCollapsed else { autoCollapseIfNeeded() return @@ -294,37 +314,134 @@ class StatusBarController { } private func autoCollapseIfNeeded() { + guard !hideDegraded else { return } guard Preferences.isAutoHide else {return} guard !isCollapsed else { return } startTimerToAutoHide() } - // After a collapse, confirm on the next runloop tick (so layout settles) that - // the separator actually claimed its inflated width. macOS <= 26 honors it; - // a macOS that ignores NSStatusItem.length leaves the slot narrow, meaning - // hiding did nothing. Checked once: cheap, and the OS behavior won't change - // mid-session. + // After a collapse, confirm on the next runloop tick (so layout settles) + // whether inflating the separator actually hid neighbors. macOS <= 26 shares one + // menu-bar window across all items, so the separator's backing window stays + // screen-wide (honored). macOS 27 gives each item its own window, so inflating + // grows that window wider than the screen while neighbors stay put (ignored): + // hiding is a no-op. Checked once; the OS behavior won't change mid-session. private func verifyHideMechanismIfNeeded() { guard !hideMechanismChecked else { return } DispatchQueue.main.async { [weak self] in guard let self = self, self.isCollapsed else { return } + guard !self.hideMechanismChecked else { return } // Need the separator's backing window to measure. If it is not up yet // (early launch), do NOT burn the one-shot check: return and let a // later collapse retry once the window exists. guard let separatorButton = self.btnSeparate.button, let window = separatorButton.window else { return } + // Latch only AFTER a real measurement is possible, so a transient nil + // window never burns the one-shot check. self.hideMechanismChecked = true - // Log several geometry signals. On macOS <= 26 the inflation is - // honored; on macOS 27 it may be ignored. Which of these tracks the - // requested length is exactly what a 27 capture must reveal before any - // degrade action can trigger on a sound signal. + let requested = self.btnHiddenCollapseLength let windowWidth = window.frame.width let buttonWidth = separatorButton.frame.width - NSLog("HideMechanism: requested=\(requested) windowWidth=\(windowWidth) buttonWidth=\(buttonWidth) length=\(self.btnSeparate.length)") + let screenWidth = NSScreen.screens.map { $0.frame.width }.max() ?? 0 + NSLog("HideMechanism: requested=\(requested) windowWidth=\(windowWidth) buttonWidth=\(buttonWidth) length=\(self.btnSeparate.length) screenWidth=\(screenWidth) os=\(ProcessInfo.processInfo.operatingSystemVersionString)") + + // The degrade action is macOS-27-only: macOS <= 26 keeps the diagnostic + // log above and nothing else, so its behavior is byte-identical. + guard ProcessInfo.processInfo.isMacOS27OrLater else { return } + + // No `?? requested` fallback: an unmeasurable outcome is never coerced + // to "honored". `ignored` and `inconclusive` both degrade (on macOS 27 + // hiding is broken regardless, so a residual false positive is safe). + switch self.evaluateHideOutcome(separatorWindow: window, screenWidth: screenWidth) { + case .honored: + break // a macOS 27.x that restored the trick: keep hiding, do nothing + case .ignored, .inconclusive: + self.degradeHideUnavailable() + } + } + } + + private enum HideOutcome { + case honored // inflation displaced neighbors -> hiding works + case ignored // inflation only grew our own window -> hiding is a no-op (#360) + case inconclusive // could not measure + } + + // Discriminate "hiding worked" from "hiding is a no-op" by whether the + // separator's backing window is wider than the screen. macOS <= 26 packs every + // status item into ONE shared menu-bar window whose width is the screen width, + // no matter how long our item is. macOS 27 gives each item its OWN window, so + // inflating grows that window far wider than the screen. A separator window + // wider than its screen therefore means the inflation stayed inside our own item + // and displaced nothing. (The system caps the inflated window near ~5000pt, so + // comparing against the requested length is unreliable; the screen width is the + // stable reference.) Only consulted under the macOS-27 gate. + private func evaluateHideOutcome(separatorWindow: NSWindow, screenWidth: CGFloat) -> HideOutcome { + guard screenWidth > 0 else { return .inconclusive } + let ownWindowExceedsScreen = separatorWindow.frame.width > screenWidth * 1.1 + return ownWindowExceedsScreen ? .ignored : .honored + } + + // macOS 27 (#360): stop the futile inflation, return the bar to its expanded + // state, and tell the user once. Idempotent. + private func degradeHideUnavailable() { + guard !hideDegraded else { return } + hideDegraded = true + + // Undo the inflation so the bar looks normal instead of leaving a wide + // empty slot. isCollapsed derives from btnSeparate.length, so resetting it + // to btnHiddenLength makes isCollapsed == false: state stays consistent. + btnSeparate.length = btnHiddenLength + if Preferences.areSeparatorsHidden { + btnAlwaysHidden?.length = btnAlwaysHiddenLength + } + if let button = btnExpandCollapse.button { + button.image = Assets.collapseImage + } + + // collapseMenuBar() switched the app to .accessory and deactivated it under + // "use full menu bar on expanding". Mirror expandMenubar()'s restore, or the + // bar shows while the app stays stuck in .accessory. + if Preferences.useFullStatusBarOnExpandEnabled { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + // Kill the auto-hide and hover timers so neither can re-enter collapse logic. + timer?.invalidate() + timer = nil + hoverDwellTimer?.invalidate() + hoverDwellTimer = nil + + macOS27NoticeMenuItem?.isHidden = false + NSLog("HideMechanism: degraded - macOS 27 hide unavailable (#360)") + + if !Preferences.didShowMacOS27HideUnavailableNotice { + Preferences.didShowMacOS27HideUnavailableNotice = true + presentMacOS27DegradeAlert() } } + + private func presentMacOS27DegradeAlert() { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Menu bar hiding isn't available on macOS 27".localized + alert.informativeText = "macOS 27 changed how the menu bar works, so Hidden Bar can no longer hide other apps' icons by collapsing. This is a known limitation tracked on GitHub; Hidden Bar will stay out of the way until a compatible method is available.".localized + alert.addButton(withTitle: "Learn More".localized) + alert.addButton(withTitle: "OK".localized) + // Accessory apps don't own the active state; pull the alert to the front. + NSApp.activate(ignoringOtherApps: true) + if alert.runModal() == .alertFirstButtonReturn { + openMacOS27Notice() + } + } + + @objc private func openMacOS27Notice() { + guard let url = URL(string: StatusBarController.macOS27IssueURL) else { return } + NSWorkspace.shared.open(url) + } private func startTimerToAutoHide() { timer?.invalidate() @@ -345,6 +462,15 @@ class StatusBarController { private func getContextMenu() -> NSMenu { let menu = NSMenu() + // Hidden until the macOS 27 degrade runs; then it links to #360 so users + // understand why hiding stopped. A hidden item renders nothing, so the menu + // is unchanged on macOS <= 26 and before degrade. + let noticeItem = NSMenuItem(title: "Hiding unavailable on macOS 27 - Learn more".localized, action: #selector(openMacOS27Notice), keyEquivalent: "") + noticeItem.target = self + noticeItem.isHidden = true + menu.addItem(noticeItem) + self.macOS27NoticeMenuItem = noticeItem + let prefItem = NSMenuItem(title: "Preferences...".localized, action: #selector(openPreferenceViewControllerIfNeeded), keyEquivalent: "P") prefItem.target = self menu.addItem(prefItem) diff --git a/hidden/en.lproj/Localizable.strings b/hidden/en.lproj/Localizable.strings index cacb1c0..87ff450 100644 --- a/hidden/en.lproj/Localizable.strings +++ b/hidden/en.lproj/Localizable.strings @@ -12,6 +12,13 @@ "Disable Auto Collapse" = "Disable Auto Collapse"; "Quit" = "Quit"; "Set Shortcut" = "Set Shortcut"; + +/* macOS 27 hide-mechanism degrade notice (#360). English-only for now; translations welcome. */ +"Hiding unavailable on macOS 27 - Learn more" = "Hiding unavailable on macOS 27 - Learn more"; +"Menu bar hiding isn't available on macOS 27" = "Menu bar hiding isn't available on macOS 27"; +"macOS 27 changed how the menu bar works, so Hidden Bar can no longer hide other apps' icons by collapsing. This is a known limitation tracked on GitHub; Hidden Bar will stay out of the way until a compatible method is available." = "macOS 27 changed how the menu bar works, so Hidden Bar can no longer hide other apps' icons by collapsing. This is a known limitation tracked on GitHub; Hidden Bar will stay out of the way until a compatible method is available."; +"Learn More" = "Learn More"; +"OK" = "OK"; "Tutorial text" = " Use the always hidden feature to keep your icons tidy. Here's how to set it Steps to enable: