From b0450bd7063536a3b71c219fc7819f5a8163cd74 Mon Sep 17 00:00:00 2001 From: "Nadir A." Date: Mon, 30 Mar 2026 23:28:07 +0300 Subject: [PATCH] feat: add Notch Overflow to access menu bar icons hidden behind the notch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Macs with a camera notch, menu bar icons that overflow past the notch become inaccessible. This feature uses the Accessibility API to enumerate all menu bar extras and their positions, then presents hidden items in a dropdown menu. Access methods: - Global hotkey: Cmd+Shift+B (shows menu at cursor position) - Right-click the expand/collapse button → "Show Notch Items" - Right-click the separator → "Show Notch Items" Features: - Detects notch presence via NSScreen.safeAreaInsets (macOS 12+) - Enumerates all menu bar extras via AXUIElement API - Classifies items as hidden (behind notch) or visible by position - Hidden items displayed in orange for visual distinction - Clicking an item triggers AXPress on the original menu extra - Info label in Preferences window explains available shortcuts - Only activates on Macs with a notch; no-op on other models New files: - hidden/Features/NotchOverflow/NotchOverflowController.swift Co-Authored-By: Claude Opus 4.6 (1M context) --- Hidden Bar.xcodeproj/project.pbxproj | 12 + hidden/AppDelegate.swift | 23 +- hidden/Common/Preferences.swift | 15 +- .../Notification.Name+Extension.swift | 1 + hidden/Extensions/UserDefault+Extension.swift | 1 + .../NotchOverflowController.swift | 279 ++++++++++++++++++ .../PreferencesViewController.swift | 28 ++ .../StatusBar/StatusBarController.swift | 85 +++++- 8 files changed, 429 insertions(+), 15 deletions(-) create mode 100644 hidden/Features/NotchOverflow/NotchOverflowController.swift diff --git a/Hidden Bar.xcodeproj/project.pbxproj b/Hidden Bar.xcodeproj/project.pbxproj index 6f9aa2f..8b7a339 100644 --- a/Hidden Bar.xcodeproj/project.pbxproj +++ b/Hidden Bar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 4D8C4B09D62D4884B34E1A14 /* NotchOverflowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */; }; 00117C4426600671005E517C /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00117C4326600671005E517C /* Assets.swift */; }; 00137CDF24A63DB1004AC855 /* Notification.Name+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00137CDE24A63DB1004AC855 /* Notification.Name+Extension.swift */; }; 0842CDFB23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0842CDFA23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift */; }; @@ -57,6 +58,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOverflowController.swift; sourceTree = ""; }; 00117C4326600671005E517C /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 00137CDE24A63DB1004AC855 /* Notification.Name+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification.Name+Extension.swift"; sourceTree = ""; }; 0842CDFA23A9FDD000D14BD4 /* GlobalKeybindingPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalKeybindingPreferences.swift; sourceTree = ""; }; @@ -152,12 +154,21 @@ isa = PBXGroup; children = ( 08C20FDC23AABC440035D978 /* About */, + 905962CD99D24D0C893AFFA7 /* NotchOverflow */, 0842CDFE23A9FF7C00D14BD4 /* Preferences */, 0842CDFD23A9FF7500D14BD4 /* StatusBar */, ); path = Features; sourceTree = ""; }; + 905962CD99D24D0C893AFFA7 /* NotchOverflow */ = { + isa = PBXGroup; + children = ( + BE73DB4E0664488997EEB6DE /* NotchOverflowController.swift */, + ); + path = NotchOverflow; + sourceTree = ""; + }; 0842CDFD23A9FF7500D14BD4 /* StatusBar */ = { isa = PBXGroup; children = ( @@ -422,6 +433,7 @@ 08C20FE223AB452C0035D978 /* AboutViewController.swift in Sources */, 00137CDF24A63DB1004AC855 /* Notification.Name+Extension.swift in Sources */, 08A5F86423AA09F300981CA5 /* UserDefault+Extension.swift in Sources */, + 4D8C4B09D62D4884B34E1A14 /* NotchOverflowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/hidden/AppDelegate.swift b/hidden/AppDelegate.swift index 4f5f21b..256f842 100644 --- a/hidden/AppDelegate.swift +++ b/hidden/AppDelegate.swift @@ -7,6 +7,7 @@ // import AppKit +import Carbon import HotKey @NSApplicationMain @@ -14,23 +15,38 @@ import HotKey class AppDelegate: NSObject, NSApplicationDelegate{ var statusBarController = StatusBarController() - + var hotKey: HotKey? { didSet { guard let hotKey = hotKey else { return } - + hotKey.keyDownHandler = { [weak self] in self?.statusBarController.expandCollapseIfNeeded() } } } + // Notch overflow hotkey: Cmd+Shift+B + var notchOverflowHotKey: HotKey? + func applicationDidFinishLaunching(_ aNotification: Notification) { setupAutoStartApp() registerDefaultValues() setupHotKey() openPreferencesIfNeeded() detectLTRLang() + statusBarController.setupNotchOverflow() + setupNotchOverflowHotKey() + } + + func setupNotchOverflowHotKey() { + guard NotchOverflowController.hasNotch else { return } + // Cmd+Shift+B (keyCode 11 = B) + let carbonMods = UInt32(cmdKey | shiftKey) + notchOverflowHotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: 11, carbonModifiers: carbonMods)) + notchOverflowHotKey?.keyDownHandler = { [weak self] in + self?.statusBarController.notchOverflowController.triggerOverflow() + } } func openPreferencesIfNeeded() { @@ -50,7 +66,8 @@ class AppDelegate: NSObject, NSApplicationDelegate{ UserDefaults.Key.isAutoHide: true, UserDefaults.Key.numberOfSecondForAutoHide: 10.0, UserDefaults.Key.areSeparatorsHidden: false, - UserDefaults.Key.alwaysHiddenSectionEnabled: false + UserDefaults.Key.alwaysHiddenSectionEnabled: false, + UserDefaults.Key.notchOverflowEnabled: true ]) } diff --git a/hidden/Common/Preferences.swift b/hidden/Common/Preferences.swift index d69adb1..a38b4bd 100644 --- a/hidden/Common/Preferences.swift +++ b/hidden/Common/Preferences.swift @@ -99,11 +99,20 @@ enum Preferences { get { UserDefaults.standard.bool(forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } - + set { UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.useFullStatusBarOnExpandEnabled) } } - - + + static var notchOverflowEnabled: Bool { + get { + UserDefaults.standard.bool(forKey: UserDefaults.Key.notchOverflowEnabled) + } + + set { + UserDefaults.standard.set(newValue, forKey: UserDefaults.Key.notchOverflowEnabled) + NotificationCenter.default.post(Notification(name: .notchOverflowToggle)) + } + } } diff --git a/hidden/Extensions/Notification.Name+Extension.swift b/hidden/Extensions/Notification.Name+Extension.swift index 90dbcd5..cea0743 100644 --- a/hidden/Extensions/Notification.Name+Extension.swift +++ b/hidden/Extensions/Notification.Name+Extension.swift @@ -12,4 +12,5 @@ extension Notification.Name { static let prefsChanged = Notification.Name("prefsChanged") static let alwayHideToggle = Notification.Name("alwayHideToggle") + static let notchOverflowToggle = Notification.Name("notchOverflowToggle") } diff --git a/hidden/Extensions/UserDefault+Extension.swift b/hidden/Extensions/UserDefault+Extension.swift index a870fab..4729e07 100644 --- a/hidden/Extensions/UserDefault+Extension.swift +++ b/hidden/Extensions/UserDefault+Extension.swift @@ -18,6 +18,7 @@ extension UserDefaults { static let areSeparatorsHidden = "areSeparatorsHidden" static let alwaysHiddenSectionEnabled = "alwaysHiddenSectionEnabled" static let useFullStatusBarOnExpandEnabled = "useFullStatusBarOnExpandEnabled" + static let notchOverflowEnabled = "notchOverflowEnabled" } open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { diff --git a/hidden/Features/NotchOverflow/NotchOverflowController.swift b/hidden/Features/NotchOverflow/NotchOverflowController.swift new file mode 100644 index 0000000..3275cbc --- /dev/null +++ b/hidden/Features/NotchOverflow/NotchOverflowController.swift @@ -0,0 +1,279 @@ +// +// NotchOverflowController.swift +// Hidden Bar +// +// Created by Nadir on 2026/03/30. +// Copyright © 2026 Dwarves Foundation. All rights reserved. +// + +import AppKit +import ApplicationServices + +// MARK: - Menu Bar Extra Info + +struct MenuBarExtraInfo { + let element: AXUIElement + let appName: String + let appIcon: NSImage? + let title: String? + let pid: pid_t + let position: CGPoint + let size: CGSize +} + +// MARK: - NotchOverflowController + +class NotchOverflowController: NSObject { + + // MARK: - Properties + + /// Right edge of the notch in screen X coordinates + private var notchRightEdge: CGFloat { + guard let screen = NSScreen.main else { return 972 } + let notchWidth = screen.frame.width / 8 + return screen.frame.width / 2 + notchWidth / 2 + } + + // MARK: - Notch Detection + + static var hasNotch: Bool { + if #available(macOS 12.0, *) { + guard let screen = NSScreen.main else { return false } + return screen.safeAreaInsets.top > 0 + } + return false + } + + // MARK: - Setup + + func setup() { + // No-op; hotkey is set up in AppDelegate + } + + func teardown() { + // No-op + } + + // MARK: - Public: called from hotkey handler + func triggerOverflow() { + showOverflowMenuAtCursor() + } + + /// Show overflow menu anchored to a status item (called from StatusBarController) + func showOverflowMenuFromSeparator(near item: NSStatusItem) { + guard ensureAccessibility() else { return } + let menu = buildOverflowMenu() + item.menu = menu + item.button?.performClick(nil) + DispatchQueue.main.async { + item.menu = nil + } + } + + // MARK: - Show Menu at Cursor + + private func showOverflowMenuAtCursor() { + guard ensureAccessibility() else { return } + + let menu = buildOverflowMenu() + let mouseLocation = NSEvent.mouseLocation + + // Create a temporary invisible window at mouse location to anchor the menu + let tmpWindow = NSWindow( + contentRect: NSRect(x: mouseLocation.x - 1, y: mouseLocation.y - 1, width: 2, height: 2), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + tmpWindow.level = .popUpMenu + tmpWindow.backgroundColor = .clear + tmpWindow.isOpaque = false + tmpWindow.orderFrontRegardless() + + menu.popUp(positioning: nil, at: NSPoint(x: 1, y: 1), in: tmpWindow.contentView) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + tmpWindow.orderOut(nil) + } + } + + // MARK: - Menu Building + + private func buildOverflowMenu() -> NSMenu { + let allExtras = getAllMenuBarExtras() + + let rightEdge = notchRightEdge + let hiddenExtras = allExtras.filter { + $0.size.width > 0 && $0.size.height > 0 && $0.position.x < rightEdge + } + let visibleExtras = allExtras.filter { + $0.size.width > 0 && $0.size.height > 0 && $0.position.x >= rightEdge + } + + let menu = NSMenu() + menu.autoenablesItems = false + + // Title + let titleItem = NSMenuItem(title: "Notch Overflow \u{2318}\u{21E7}B", action: nil, keyEquivalent: "") + titleItem.isEnabled = false + if #available(macOS 10.14, *) { + titleItem.attributedTitle = NSAttributedString( + string: titleItem.title, + attributes: [ + .font: NSFont.systemFont(ofSize: 12, weight: .bold), + .foregroundColor: NSColor.secondaryLabelColor + ]) + } + menu.addItem(titleItem) + menu.addItem(NSMenuItem.separator()) + + // Hidden section + if !hiddenExtras.isEmpty { + let header = NSMenuItem( + title: "\u{26A0} Hidden Behind Notch (\(hiddenExtras.count))", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(NSMenuItem.separator()) + + for info in hiddenExtras.sorted(by: { $0.position.x > $1.position.x }) { + menu.addItem(makeMenuItem(for: info, hidden: true)) + } + } + + // Visible section + if !visibleExtras.isEmpty { + if !hiddenExtras.isEmpty { menu.addItem(NSMenuItem.separator()) } + let header = NSMenuItem( + title: "Visible (\(visibleExtras.count))", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(NSMenuItem.separator()) + + for info in visibleExtras.sorted(by: { $0.position.x > $1.position.x }) { + menu.addItem(makeMenuItem(for: info, hidden: false)) + } + } + + // Empty state + if hiddenExtras.isEmpty && visibleExtras.isEmpty { + let emptyItem = NSMenuItem( + title: "No items found (grant Accessibility permission)", + action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + menu.addItem(emptyItem) + } + + return menu + } + + private func makeMenuItem(for info: MenuBarExtraInfo, hidden: Bool = false) -> NSMenuItem { + let item = NSMenuItem() + let displayTitle = (info.title?.isEmpty == false) ? info.title! : info.appName + + if hidden { + if #available(macOS 10.14, *) { + item.attributedTitle = NSAttributedString( + string: displayTitle, + attributes: [ + .font: NSFont.systemFont(ofSize: 13), + .foregroundColor: NSColor.systemOrange + ]) + } else { + item.title = displayTitle + } + } else { + item.title = displayTitle + } + + if let icon = info.appIcon { + item.image = hidden ? icon.resizedForMenu(tinted: true) : icon.resizedForMenu() + } + item.representedObject = info + item.target = self + item.action = #selector(activateItem(_:)) + return item + } + + @objc private func activateItem(_ sender: NSMenuItem) { + guard let info = sender.representedObject as? MenuBarExtraInfo else { return } + AXUIElementPerformAction(info.element, kAXPressAction as CFString) + } + + // MARK: - Accessibility + + private func ensureAccessibility() -> Bool { + if AXIsProcessTrusted() { return true } + let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + AXIsProcessTrustedWithOptions(opts) + return false + } + + // MARK: - AX Enumeration + + private func getAllMenuBarExtras() -> [MenuBarExtraInfo] { + var results: [MenuBarExtraInfo] = [] + let myPID = ProcessInfo.processInfo.processIdentifier + + for app in NSWorkspace.shared.runningApplications { + if app.processIdentifier == myPID { continue } + + let axApp = AXUIElementCreateApplication(app.processIdentifier) + var extrasRef: AnyObject? + let err = AXUIElementCopyAttributeValue( + axApp, "AXExtrasMenuBar" as CFString, &extrasRef) + guard err == .success else { continue } + + var childrenRef: AnyObject? + let childErr = AXUIElementCopyAttributeValue( + extrasRef as! AXUIElement, + kAXChildrenAttribute as CFString, &childrenRef) + guard childErr == .success, + let children = childrenRef as? [AXUIElement] + else { continue } + + for child in children { + var titleRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXTitleAttribute as CFString, &titleRef) + + var posRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXPositionAttribute as CFString, &posRef) + var pos = CGPoint.zero + if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) } + + var sizeRef: AnyObject? + AXUIElementCopyAttributeValue(child, kAXSizeAttribute as CFString, &sizeRef) + var size = CGSize.zero + if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &size) } + + results.append(MenuBarExtraInfo( + element: child, + appName: app.localizedName ?? "Unknown", + appIcon: app.icon, + title: titleRef as? String, + pid: app.processIdentifier, + position: pos, + size: size + )) + } + } + return results + } + +} + +// MARK: - NSImage Extension + +private extension NSImage { + func resizedForMenu(tinted: Bool = false) -> NSImage { + let target = NSSize(width: 18, height: 18) + let img = NSImage(size: target) + img.lockFocus() + self.draw(in: NSRect(origin: .zero, size: target), + from: NSRect(origin: .zero, size: self.size), + operation: .sourceOver, fraction: tinted ? 0.5 : 1.0) + img.unlockFocus() + return img + } +} diff --git a/hidden/Features/Preferences/PreferencesViewController.swift b/hidden/Features/Preferences/PreferencesViewController.swift index 1703cd2..5af20eb 100644 --- a/hidden/Features/Preferences/PreferencesViewController.swift +++ b/hidden/Features/Preferences/PreferencesViewController.swift @@ -53,6 +53,7 @@ class PreferencesViewController: NSViewController { updateData() loadHotkey() createTutorialView() + addNotchOverflowSection() NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .prefsChanged, object: nil) } @@ -190,6 +191,33 @@ class PreferencesViewController: NSViewController { } } +//MARK: - Notch Overflow Section +extension PreferencesViewController { + + func addNotchOverflowSection() { + guard NotchOverflowController.hasNotch else { return } + + // Add compact info below the tutorial area, anchored to statusBarStackView + let infoLabel = NSTextField(labelWithString: + "Notch Overflow: \u{2318}\u{21E7}B or right-click \u{2039} to access hidden icons") + infoLabel.font = NSFont.systemFont(ofSize: 10.5) + if #available(macOS 10.14, *) { + infoLabel.textColor = .secondaryLabelColor + } + infoLabel.alignment = .center + infoLabel.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(infoLabel) + + // Position centered, between tutorial text and Settings divider + // statusBarStackView is near the top; we go well below it + NSLayoutConstraint.activate([ + infoLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + infoLabel.topAnchor.constraint(equalTo: statusBarStackView.bottomAnchor, constant: 78) + ]) + } + +} + //MARK: - Show tutorial extension PreferencesViewController { diff --git a/hidden/Features/StatusBar/StatusBarController.swift b/hidden/Features/StatusBar/StatusBarController.swift index 1d17718..79c7abb 100644 --- a/hidden/Features/StatusBar/StatusBarController.swift +++ b/hidden/Features/StatusBar/StatusBarController.swift @@ -9,15 +9,18 @@ import AppKit class StatusBarController { - + //MARK: - Variables private var timer:Timer? = nil - + //MARK: - BarItems - + private let btnExpandCollapse = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let btnSeparate = NSStatusBar.system.statusItem(withLength: 1) private var btnAlwaysHidden:NSStatusItem? = nil + + //MARK: - Notch Overflow + private(set) var notchOverflowController = NotchOverflowController() private var btnHiddenLength: CGFloat = 20 private var btnHiddenCollapseLength: CGFloat = 2000 @@ -73,6 +76,21 @@ class StatusBarController { if Preferences.areSeparatorsHidden {hideSeparators()} autoCollapseIfNeeded() + + NotificationCenter.default.addObserver(self, selector: #selector(handleNotchOverflowToggle), name: .notchOverflowToggle, object: nil) + } + + /// Called from AppDelegate after defaults are registered + func setupNotchOverflow() { + notchOverflowController.setup() + } + + @objc private func handleNotchOverflowToggle() { + if Preferences.notchOverflowEnabled { + notchOverflowController.setup() + } else { + notchOverflowController.teardown() + } } deinit { @@ -115,16 +133,53 @@ class StatusBarController { @objc func btnExpandCollapsePressed(sender: NSStatusBarButton) { if let event = NSApp.currentEvent { - + let isOptionKeyPressed = event.modifierFlags.contains(NSEvent.ModifierFlags.option) - - if event.type == NSEvent.EventType.leftMouseUp && !isOptionKeyPressed{ + + if event.type == NSEvent.EventType.leftMouseUp && !isOptionKeyPressed { self.expandCollapseIfNeeded() } else { - self.showHideSeparatorsAndAlwayHideArea() + // Right-click or Opt+click: show context menu + self.showExpandButtonMenu() } } } + + private func showExpandButtonMenu() { + let menu = NSMenu() + + // Notch overflow (only on notch Macs) + if NotchOverflowController.hasNotch { + let overflowItem = NSMenuItem( + title: "Show Notch Items (\u{2318}\u{21E7}B)", + action: #selector(showNotchOverflow), + keyEquivalent: "") + overflowItem.target = self + menu.addItem(overflowItem) + menu.addItem(NSMenuItem.separator()) + } + + // Toggle separators (existing functionality) + let sepTitle = Preferences.areSeparatorsHidden + ? "Show Separators" + : "Hide Separators" + let toggleSep = NSMenuItem( + title: sepTitle, + action: #selector(toggleSeparatorsAction), + keyEquivalent: "") + toggleSep.target = self + menu.addItem(toggleSep) + + btnExpandCollapse.menu = menu + btnExpandCollapse.button?.performClick(nil) + DispatchQueue.main.async { [weak self] in + self?.btnExpandCollapse.menu = nil + } + } + + @objc private func toggleSeparatorsAction() { + self.showHideSeparatorsAndAlwayHideArea() + } func showHideSeparatorsAndAlwayHideArea() { Preferences.areSeparatorsHidden ? self.showSeparators() : self.hideSeparators() @@ -212,11 +267,19 @@ class StatusBarController { private func getContextMenu() -> NSMenu { let menu = NSMenu() - + + // Notch overflow menu item (only on notch Macs) + if NotchOverflowController.hasNotch { + let overflowItem = NSMenuItem(title: "Show Notch Items (\u{2318}\u{21E7}B)", action: #selector(showNotchOverflow), keyEquivalent: "") + overflowItem.target = self + menu.addItem(overflowItem) + menu.addItem(NSMenuItem.separator()) + } + let prefItem = NSMenuItem(title: "Preferences...".localized, action: #selector(openPreferenceViewControllerIfNeeded), keyEquivalent: "P") prefItem.target = self menu.addItem(prefItem) - + let toggleAutoHideItem = NSMenuItem(title: "Toggle Auto Collapse".localized, action: #selector(toggleAutoHide), keyEquivalent: "t") toggleAutoHideItem.target = self toggleAutoHideItem.tag = 1 @@ -246,6 +309,10 @@ class StatusBarController { @objc func openPreferenceViewControllerIfNeeded() { Util.showPrefWindow() } + + @objc func showNotchOverflow() { + notchOverflowController.showOverflowMenuFromSeparator(near: btnExpandCollapse) + } @objc func toggleAutoHide() { Preferences.isAutoHide.toggle()