macOS 菜单栏没有一个 API 能藏图标,Hidden Bar 用「撑开」假装藏起来了
- 利用 NSStatusItem.length 无上限校验,撑开分隔符将图标挤出可视区域,模拟隐藏效果
- 撑开宽度经多屏适配、边界处理和系统硬限制计算,确保不同显示器稳定工作
- 采用派生状态判断收起/展开,通过比较分隔符长度避免热插拔状态错乱
- 自动收起检测鼠标是否在菜单栏区域,避免打断用户操作,全屏时自动禁用
- 项目安全姿态极低:沙盒、无网络、无文件读写、无 IPC,代码量仅 1.5k 行
2024 年 6 月,Mac 圈子炸了个不大不小的瓜。老牌菜单栏管理工具 Bartender 被悄悄卖了,买家是个没人认识的开发商,前后两个月没人吭一声。用户是通过签名证书换人才发现的,一款能管你整个菜单栏、深度接触系统 UI 的应用,在你不注意的时候换了个新主人,还通过自动更新装了上来。
那几天 Reddit 的 r/apple 铺天盖地在骂「trust breach」,一堆人连夜找替代品。结果涌出来的三个名字几乎在每个推荐帖里都出现,Hidden Bar、Ice、Dozer,全是开源的。
我今天想拆的就是其中一个,Hidden Bar,Dwarves Foundation 出的。14468 个 Star,MIT 协议,Swift 写的。它解决的这个问题听起来特别小,藏菜单栏图标,但拆开源码你会发现,这玩意儿的技术解法是个彻头彻尾的「假动作」,而正是这个假动作,值得每个做 Mac 工具的人看一眼。
macOS 根本没给藏图标的 API
先说清楚这个项目到底在干嘛。
你的 Mac 屏幕右上角那排小图标,蓝牙、WiFi、电池、输入法,加上一堆 App 自己塞进去的,叫菜单栏(menu bar)。第三方 App 想往里放图标,用 NSStatusBar.system.statusItem(withLength:) 就行。但 macOS 从来没给过一个 API,让一个 App 去控制别的 App 的图标显示。
这个限制是设计层面的。菜单栏每个图标归各自的 App 管,进程之间隔离,你 Hidden Bar 没资格去动 Slack 的图标。所以所有这类工具,都不是真的「藏」,而是在骗。
骗法很朴素。Hidden Bar 自己往菜单栏塞了三个 NSStatusItem,一个箭头(展开/收起的按钮)、一个分隔符、一个可选的「永远隐藏」分隔符。核心全在那个分隔符上。
它的 length 平时是 1pt,几乎是看不见的一条线。你点一下箭头收起,Hidden Bar 就把这个分隔符的 length 一下撑到屏幕宽度的两倍。菜单栏是从右往左排的,分隔符突然变这么宽,就把它左边所有的图标,一股脑推到屏幕可见区域之外去了。
用户看到的效果是「图标藏起来了」。系统视角下的真相是,图标还在那,只是被一个突然变胖的分隔符挤到了你看不见的地方。
这个 trick 在源码里就一行。
btnSeparate.length = self.btnHiddenCollapseLength
StatusBarController.swift 里 collapseMenuBar() 干的全部事情,就是改这一个属性。展开就是把它拨回 20pt。整个产品的「灵魂」,是 macOS 菜单栏对 NSStatusItem.length 没做上限校验,你给多大它就占多大。
撑多宽是个算过的数,不是拍脑袋
读到 updateCollapsedLengths() 你会发现,这个「两倍屏宽」不是随便写的,是踩过坑才定下来的。
let screenWidth = NSScreen.screens.map { $0.frame.width }.max() ?? 1728
let boundedCollapseLength = max(500, min(screenWidth * 2, 10_000))
三个细节,每个背后都有真实的 issue。
第一,取的是所有屏幕里最宽的那个,不是当前焦点屏幕(NSScreen.main)。原因是菜单栏会在每个屏幕上都复制一份,如果你用 13 寸笔记本的屏宽去算,接到 27 寸外接显示器的时候,藏住的图标会在那个宽屏上「漏出来」。这个 bug 在 v1.11 的 CHANGELOG 里专门列了,multi-display 的 leak。
第二,乘以 2。为什么不是 1 倍屏宽就够?因为图标被挤出去的距离得够远,不能卡在边缘来回闪烁。两倍是一个经验值,保证彻底推到可视区外。
第三,封顶 10000pt。注释里写得很直白,「macOS enforces a hard 10,000pt maximum on NSStatusItem.length」。这是系统层的硬限制,你给更大的数系统也只认一万。下限 500 是防止极端情况算出个负数或零把分隔符搞没了。
你看,就这么一个看着像玩具的小工具,光「撑多宽」这一件事,就处理了多屏、边界、系统硬限制三种情况。这不是 README 吹的,是 StatusBarController.swift:175 那几行代码实打实写的。
把整个项目拉通看,它的架构其实非常薄,从上到下就四层。
最上面 AppDelegate 负责接人,注册默认偏好、挂全局热键、把自己登记成登录项。中间那层高亮的 StatusBarController 是整个产品,415 行代码管着三个 NSStatusItem 和所有收起展开的逻辑。再往下 Preferences 是个 enum 门面,没有独立 store,直接读写 UserDefaults,靠 NotificationCenter 通知上层刷新。最底下一层是唯一的 UI 窗口加安全边界。没有 daemon,没有 helper 进程,没有网络。这张图里你能看到的所有东西,加起来 1.5k 行 Swift。
状态不存 flag,存「形状」
继续往里读,有个设计决策我觉得特别聪明,值得单独拿出来说。
收起还是展开,你直觉上会存一个布尔变量 isCollapsed,点一下就翻转。Hidden Bar 没这么干。它把这个状态从分隔符的当前长度反推出来。
private var isCollapsed: Bool {
// Compare with > rather than == so the state survives updateCollapsedLengths
// changing btnHiddenCollapseLength while the bar is collapsed (PR #354).
return self.btnSeparate.length > self.btnHiddenLength
}
注释点破了关键,用 > 而不是 ==。为什么不直接判断「length 等于收起长度」?因为收起长度 btnHiddenCollapseLength 本身是会变的。你插拔显示器的时候,updateCollapsedLengths() 会重算这个值。如果这时候 bar 正好是收起状态,而你用 == 去判断,重算之后 length 不再等于旧的那个收起值,状态就「跳」了,本来收着的突然被判定成展开。
用「比展开状态长」这个不等式去定义收起,状态就和具体的数字解耦了。不管收起长度被重算成 2000 还是 3440,只要它还大于 20,就是收起。这是一种 derived state(派生状态)的思路,不存冗余的真相源,真相只有一个,就是分隔符当下的物理长度。
这个小决策救了一个真实的场景。PR #354 修的就是显示器热插拔时状态错乱的问题。你要是存了个独立的 bool,还得记得每次重算长度时同步它,忘了就是 bug。derived state 直接消灭了「忘记同步」这个可能性。
自动收起不会打断你,靠的是一个 menubar 检测
Hidden Bar 有个自动收起功能,展开一段时间后自己收回去。听起来简单,定个 Timer 到点收起就行。但真这么做会出洋相,你正盯着菜单栏点图标呢,啪一下给你收了。
源码里的处理是,Timer 到点的那一刻,先看鼠标还在不在菜单栏区域,在就重新计时,不在才真收。
if self.isMouseInMenuBar || self.isPreferencesWindowVisible {
self.startTimerToAutoHide()
} else {
self.collapseMenuBar()
}
「在菜单栏区域」怎么判断?这里有个很巧的几何技巧。每个屏幕都有 frame(完整物理范围)和 visibleFrame(扣掉菜单栏和 Dock 后的可用范围),两者的差值 frame.maxY - visibleFrame.maxY,正好就是菜单栏的高度那条带。鼠标坐标落在这条带里,就是在菜单栏。
mouse.y >= screen.visibleFrame.maxY && mouse.y <= screen.frame.maxY
全屏 App 下菜单栏是隐藏的,那条带宽度塌缩到接近零,这个检测自然返回 false,也就不会无限 defer。注释里专门提了这个边界,「intentional, no visible menubar = no deferral」。
一个 0.5 秒的 hover 展开(hover-to-expand)功能也是同一套思路,但更克制。它默认是关的,得用 Terminal 命令 defaults write com.dwarvesv.minimalbar hoverToExpand -bool true 才开。为什么不开?因为开了就要装一个全局 mouseMoved 监听器,哪怕只是观测鼠标位置,对「极简、零后台开销」这个产品人设是有损耗的。源码里写得很明确,「No monitor is installed at all unless the pref is true at launch」,不开就不装,开了才装,关了就 NSEvent.removeMonitor 拆掉。
安全姿态,是这个项目最硬的底牌
回到开头那个 Bartender 的瓜。用户为什么那么慌?因为这类工具常驻后台、能看你的菜单栏、能自动更新装新代码,信任成本极高。Bartender 换了主人还不说,等于把「谁在跑这段代码」这个最基本的问题变成了黑箱。
Hidden Bar 在这件事上几乎是教科书级别的透明,而透明不是靠嘴说的,是 entitlements 和代码层面可以验证的。
它的 ARCHITECTURE.md 有一节叫 Security posture,我逐条对了一下源码。
沙盒启用,com.apple.security.app-sandbox。Hardened runtime 开着。没有网络权限,没有任何文件读写,没有 IPC 接口,没有 shell 调用,没有子进程。 唯一的外部依赖是 HotKey,一个很小的 Carbon RegisterEventHotKey 封装库,版本还用 Package.resolved 锁死了。那个 opt-in 的 hover 监听只观测鼠标位置,事件 payload 直接丢弃。About 窗口里的链接全是硬编码。
这意味着什么?这个 App 在你的机器上,没有一条路径能把你的数据送出去。它连网都上不了。你哪怕是被害妄想症晚期,也能放心装。这跟 Bartender 那种闭源、自动更新、换了老板还不告诉你的黑箱,是两个物种。
PRIVACY_POLICY.md 也只有短短几行,核心一句,「The app does NOT use any third party services that may collect information used to identify you.」沙盒 + 无网络 entitlement 让这句话不是承诺,是物理事实。
这个 trick 正在被 macOS 27 推到墙角
夸了这么多,得说真话了。这个「撑开分隔符」的把戏,正面临一个生死级的问题。
macOS 27(beta)重构了菜单栏,引入了 NSMenuBarNavigationSceneExtension 这套新架构。结果就是,你把 NSStatusItem.length 撑得再大,系统也不认了,图标不再被推出去。issue #360 标题就叫「Osx 27 broken hidden bar」,22 条评论,是目前未关闭 issue 里热度第三的。
这不是能打个补丁修的小 bug。整个产品的技术地基,就是「macOS 会老老实实按你给的 length 撑开 status item」。macOS 27 把这个假设抽掉了。
作者的应对方式我很佩服,诚实到有点轴。v1.11(还没正式发)里加了一段 verifyHideMechanismIfNeeded() 的诊断代码,在第一次收起后的下一个 runloop,log 出 requested / windowWidth / buttonWidth / actual length 四个几何信号,目的就是搞清楚 macOS 27 到底是「完全忽略 length」还是「换了个地方算」。但降级处理(degrade)的动作故意没上。源码注释写得很直白,
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.
翻译一下,降级逻辑没法在没有 macOS 27 真机的情况下验证,贸然上线的话,万一是误判,正常用户的隐藏功能就被你关掉了。所以宁可先只埋诊断,等拿到 27 真机的 log 再决定。BACKLOG.md 里把这个标成「Blocked on macOS 27 hardware (Han UAT)」,卡在等真机。
这是一种很克制工程决策。很多项目碰到核心机制要失效,第一反应是赶紧上 workaround 显示「我们在修」。Hidden Bar 选择的是,我不糊弄你,问题确实没解,等能解了再解。
一个更深的问题,这个 trick 从根上就有几道裂缝
其实就算没有 macOS 27,这套「物理挤开」的机制也有几个修不掉的先天缺陷,全写在 ARCHITECTURE.md 的「Known architectural limits」里,我对着 issue 区验证过,都是真的。
刘海(notch)。 带刘海的 Mac,被挤出去的图标会卡在刘海区域下面,藏了但叫不出来。这个不是 bug,是几何决定的,菜单栏在刘海那里就被切了。issue #330、#269 一堆人反馈。真正的解法不是优化 length,而是做一个「溢出栏 / 第二条菜单栏」,通过 Accessibility API 来管理,那是另一个量级的工程。
新装的 App 图标默认就被藏。 macOS 插入新菜单栏图标永远插在最左边的槽位,而最左边正好在 Hidden Bar 的隐藏区里。所以你刚装个新 App,发现图标「不见了」,以为是 bug,其实是被吞了。MANUAL.md 专门有一节「Why new icons start hidden」解释这个,解法是 ⌘ 拖到分隔符右边。这是 macOS 的行为,Hidden Bar 无能为力,「there is no way for one app to reposition another app’s menu-bar icon」。
别人 App 的展开菜单里也会误收起。 自动收起的检测是基于鼠标在不在菜单栏那条带。但你如果鼠标停在某个 App 展开的下拉菜单深处,那个位置在菜单栏带之下,检测认为你离开了,就可能触发收起。注释里也认了这条。
这三条都不是 bug,是这个 trick 的物理边界。你用「挤开」来模拟「隐藏」,就必然有挤不到的地方。作者很清楚,所以 BACKLOG.md 里那个「Option D 架构史诗」,managed-overflow / second-bar 重设计,一口气挂了 30 个 issue 在它下面,从 icon-drift、always-hidden、notch 到 macOS 27,全指望这个重设计来一次性解决。但那需要重新选型,大概率走 Accessibility API,工作量大到作者标成「Needs a design decision from Han」。
跟同类比一下
既然这类工具有好几个,横向看一眼。
| 维度 | Hidden Bar | Ice | Dozer | Bartender |
|---|---|---|---|---|
| Star | 14468 | 28992 | 8717 | 闭源 |
| 开源 | MIT | MIT | MPL-2.0 | 否 |
| 核心机制 | length 撑开 | length 撑开 | length 撑开 | 闭源未知 |
| 代码量 | ~1.5k 行 Swift | 较大 | 中等 | 未知 |
| 最近更新 | 2026-06 活跃,v1.11 待发 | 2025-09 后偏 dev 预览 | 有更新 | 2024 易主后争议 |
| 信任成本 | 极低(沙盒+无网络) | 低 | 低 | 高 |
Ice 的 Star 更高,功能也更全,但 Hidden Bar 的卖点不是功能多,是代码量小到你能在一杯咖啡的时间里读完。1.5k 行 Swift,一个依赖,没有 daemon 没有网络。这种规模意味着审计成本极低,对「我装的到底是什么」有洁癖的人,这就是最优解。Hidden Bar 和 Ice、Dozer 用的是同一个底层 trick(length 撑开),所以它们在 macOS 27 面前其实是同一条船。谁先想出下一代机制,谁就赢下一轮。
Dozer 值得一提的是协议,MPL-2.0 比 MIT 多一点「文件级 copyleft」,改了它的源码文件再分发得开源,Hidden Bar 的 MIT 则最宽松。对普通用户无所谓,对企业二次开发有区别。
收个尾,谈谈这个项目给的启发
拆完 Hidden Bar,我脑子里留下的不是某个功能,而是一个判断。
很多看起来「需要一个 API」的问题,其实没有 API 也能解,只要你找到一个现有系统的可利用的物理性质。macOS 没给藏图标的 API,但给了「status item 长度无校验」这个性质,Hidden Bar 用撑开宽度模拟了隐藏。整个产品没有一行代码碰了别的 App 的图标,它只动了自己那个分隔符的大小,效果却达到了。
这种思路可以给它起个名,「性质即接口」(property as API)。你不依赖系统给你显式的功能,你依赖系统某个边界行为的副作用。类似的还有,早期很多「窗口置顶」工具不是靠 API,是靠把自己声明成一个特殊 window level 把别的窗口挤下去,用刮水器贴膜模拟除雾。代价是,你绑死在这个性质上了,系统哪天把这个边界行为收紧(比如 macOS 27 给 length 加了上限校验),你的整个产品就失效。
所以从 Hidden Bar 身上,你能学到两条,互相对立但都成立。第一,当官方没给你路,别等,去找系统的物理性质,副作用往往能帮你把活干了,而且代码量惊人地小。第二,这种建立在「副作用」上的方案天生脆弱,一定要在第一天就诚实记录它的边界(刘海、新图标、macOS 27),并且把「下一代机制」放进 backlog,别等到系统把路堵死才慌。
Hidden Bar 最打动我的,恰恰是它把这两条都做到了。代码小到极致,边界也诚实到极致。issue 区那些「坏了」的吐槽它没删,ARCHITECTURE.md 把「这个 trick 撑不了多久」白纸黑字写在那。
在一个 Bartender 式「装完就跑、换了主人也不说」的生态里,这种「我就 1.5k 行代码,你随时可以来查我」的姿态,本身就是最强的功能。
大家好,我是若风。Hidden Bar 用 brew install --cask hiddenbar 就能装,想读源码的,核心就一个文件 StatusBarController.swift,415 行,一杯咖啡的时间。
评论互动