Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 10 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions hidden/Common/Constant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
18 changes: 15 additions & 3 deletions hidden/Common/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}


}
1 change: 1 addition & 0 deletions hidden/Extensions/UserDefault+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ extension UserDefaults {
static let alwaysHiddenSectionEnabled = "alwaysHiddenSectionEnabled"
static let useFullStatusBarOnExpandEnabled = "useFullStatusBarOnExpandEnabled"
static let hoverToExpand = "hoverToExpand"
static let didShowMacOS27HideUnavailableNotice = "didShowMacOS27HideUnavailableNotice"
}
}
164 changes: 145 additions & 19 deletions hidden/Features/StatusBar/StatusBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions hidden/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down