把 Claude Code 塞进 MacBook 的刘海里:灵动岛 Claude v5.0 开发手记
在用 Claude Code 的时候,经常遇到需要权限批准、或者跑长任务等结果的时候。于是我开发了一个常驻在 MacBook 刘海上的小面板,实时显示 Claude Code 在干什么:在思考、在跑命令、还是在等我回话。鼠标悬停展开,是一排我自己拼的组件;任务跑完,刘海会闪一下绿光告诉我「交回来了」。



它最初只是个「看状态」的玩意儿。但 v5.0 我想把它从「看」推到「托管」——人离开电脑,多个 Claude Code 会话并行跑着,哪个要权限我在刘海上点一下就行,真正完成了才提醒我回来。
这篇不讲功能清单(那是 README 的事),讲讲这一版我踩进去又爬出来的几个坑,以及每个坑底下那点没人会写进 release note 的机制细节。
一、在刘海上批准权限:让一个 hook「挂起」
灵感来自一个叫 clawd-on-desk 的桌宠项目。我扒它的实现时发现 Claude Code 有个我没用过的能力:PermissionRequest 可以配成 HTTP hook,而不是普通的 command hook。
这个区别是整件事的关键。普通 command hook 是「触发即返回」——Claude Code 调一下你的脚本,拿到 stdout,继续。但权限审批的语义是阻塞的:Claude Code 问「能跑 git push 吗?」,然后停在那里等答复。要在刘海上接住它,我需要一个能「先收下请求、过几秒再回复」的通道。HTTP hook 给的正是这个——它会 POST 请求体过来,然后同步等待我的 HTTP 响应。在 ~/.claude/settings.json 里长这样:
1 | { |
注意那个 timeout: 600——Claude Code 愿意为我的回答等十分钟。
app 这头我用 NWListener(Network.framework)起了个只绑回环地址的本地服务。请求进来时,我不立刻回复——把这条 NWConnection 攥在一个字典里,按到达顺序排队,然后在刘海弹卡:
1 | private var connections: [UUID: NWConnection] = [:] |
直到你在刘海上点了允许/拒绝,我才往那条一直开着的连接里写回决定:
1 | func respond(_ id: UUID, decision: PermissionDecision) { |

「始终允许」是我最喜欢的一处。Claude Code 在请求体里会附带一个 permission_suggestions 数组,里面是它建议的规则(比如 addRules: Bash(git push:*))。我原样把它塞回响应的 updatedPermissions 字段——它就被写进项目的 settings.local.json,和你在终端里按 Always allow 一模一样。我不用自己拼规则,Claude Code 把规则递到我手上了,我只是个转发的。
有两个边界,处理好了它才敢做成默认行为:
你在终端先答了。 那条挂起的连接会被对端关闭。所以我攥住连接后还在上面挂了个
receive,纯粹为了侦测 EOF——一旦读到连接关闭,就把刘海上的卡片撤掉:1
2
3
4
5
6
7
8
9private func watchForClose(on conn: NWConnection, connID: UUID) {
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { _, _, isComplete, error in
Task { in
guard self.connections[connID] != nil else { return }
if isComplete || error != nil { self.drop(connID) } // 终端先答了 → 撤卡
else { self.watchForClose(on: conn, connID: connID) }
}
}
}app 没开。 连接直接被拒(connection refused),Claude Code 视作非阻塞错误,自动回退到它自己的终端确认。这是零风险的——我装这个 hook 不会把任何人的权限流程搞坏。
还有个后来补的细节:AskUserQuestion 这类「选择题」工具也会触发 PermissionRequest,但它要的是让你选一个选项,不是同意/拒绝。刘海的二元按钮表达不了。于是我维护了一个工具名单,命中就直接回 204 No Content,把它原封不动交还给终端的选项菜单。不是所有请求都该被同一个 UI 接住。
顺带说多会话。以前所有状态写在一个 status.json 里,两个终端同时跑就会互相覆盖。v5.0 改成每会话一个文件 ~/.claude-code-notch/sessions/<session_id>.json,hook 从 stdin 拿 session_id 区分;app 端扫描整个目录,按「需授权 > 出错 > 运行中 > 思考」的优先级聚合出最该被关注的那个会话显示在收起态,展开则列出全部。崩溃退出的会话靠 kill(pid, 0) 探活清理。

二、能自由拼的组件:整个面板的底子(v3.0)
往回退一步说点架构。这个面板展开后是一排组件——日历、系统监控、Git、音乐、番茄钟……你可以挑 2 到 6 个、拖着排序、拖出去删。这套模块化组件系统是 v3.0 打的底,也是整个项目我最满意的一个决定:它让后来每一版加东西都几乎不用碰主面板。
核心是把「一个组件」抽象成一个轻量的描述符——它不持有任何状态,只知道自己的 id、名字、图标,和一个怎么把自己画出来的闭包:
1 | struct WidgetDescriptor: Identifiable { |
所有组件登记在一张注册表里。新增一个组件,就是往这个数组里追加一条描述符——主面板、组件管理、拖拽排序的代码一行都不用动:
1 | enum WidgetRegistry { |
那个 WidgetEnvironment 是所有组件共享的「数据源仓库」,每个组件要的 provider(天气、电池、音乐、Git 状态……)都挂在上面。这里有个性能细节:这些 provider 全是 lazy 的——天气组件没被启用,就不会去发网络请求;音乐没显示,就不去碰 MediaRemote。面板展开后才 warmUp() 一把,把当前在用的几个唤起来。用户拼了什么,才付出什么代价。
这套底子的回报,在 v5.0 体现得最明显:这一版的多会话列表、带上下文百分比的 Claude 卡片、权限审批的展开态视图——对系统来说,它们只是「Claude 状态」这一个组件内部换了张脸。我加这些功能时,完全没去动组件框架本身。好的模块化,是让新需求变成「往清单里加一条」,而不是「在主干上开一刀」。
三、玻璃质感的弯路:原生 ≠ 适合
macOS 26 出了 Liquid Glass、原生 .glassEffect() API。我想当然地用上,结果做出来两版都被自己否掉。
.glassEffect() 的工作方式,是采样它背后的内容、做模糊、再叠一层偏亮的磨砂。这对「深色文字浮在浅色玻璃上」的场景(系统大部分控件就是这样)很完美。但我的面板整屏都是白字:
- 第一版「亮玻璃」:纯
.glassEffect(.regular)。玻璃够透,但白字配亮磨砂,直接糊没了。 - 第二版「暗玻璃」:在亮玻璃上叠黑色蒙版压暗找回对比。结果糊成一块均匀死灰,毫无通透感,像灰塑料板。
两头不讨好,卡了大半天,直到我承认方向从一开始就错了:
Liquid Glass 天生偏亮,它是为浅色玻璃 + 深色内容设计的。我要的是深色玻璃 + 白色内容,正好相反。原生不等于适合。
正解反而是个「老」东西——NSVisualEffectView,聚焦搜索和深色菜单用的就是它。关键是三个参数的组合:
1 | let view = NSVisualEffectView() |
behindWindow 让它真实地把桌面虚化透上来,而不是平涂一块半透明黑;vibrantDark 是「振动」的精髓——内容(我的白字)会自动从背景里「提」出对比,材质本身也带层次,不会糊成死灰。一个字的颜色都没改,白字就全清晰了。

还有个细节坑:组件卡片我一开始也给每张叠了独立的玻璃。玻璃叠玻璃 = 两层模糊叠加,糊成一团。 控制中心的那些圆角片其实不是玻璃叠玻璃——它们是浮在同一层壁纸模糊上的半透明亮片。照这个改:外壳是唯一的真实模糊层,卡片只是一层 .white.opacity(0.12) 的浮片加描边。层次一下就出来了。
最后保留「厚玻璃 / 薄玻璃」两档在右键菜单里切(材质分别是 .hudWindow 和 .fullScreenUI)。有些审美决策,与其替用户定死,不如给个开关。
四、灵动岛动画:让同一条弹簧驱动一切
要让展开/收起像原生灵动岛那样丝滑,我做的第一个、也是最关键的决定是:让窗口根本不动。
原生那种丝滑的本质,是一条弹簧曲线驱动所有维度。如果窗口用 AppKit 动画、内容用 SwiftUI 弹簧,两条曲线不同步,边界和内容在不同时刻到位,就有种果冻般的错位感。所以我把窗口固定成最大尺寸、永不改变——收起态的窗口其实和展开态一样大,只是绝大部分透明。那块会变形的「岛体」是窗口里一个顶部居中的 SwiftUI 容器,用 GeometryReader 拿到窗口尺寸,自己在 260×42 和「铺满窗口」之间做弹簧动画:
1 | GeometryReader { geo in |
窗口不动,尺寸、圆角、内容透明度就全挂在同一条 SwiftUI 弹簧上了。再补几笔模仿原生的细节:
不对称弹簧。 展开和收起用不同参数——这是原生岛的标志性手感:
1 | private var expandAnimation: Animation { |
dampingFraction 0.72 那一点点「弹过头再弹回来」,正是橡皮感的来源;收起时不需要这个,调高阻尼让它利落收掉。
圆角连续过渡。 形状默认不参与动画,圆角 21→34 会瞬间跳变,破坏轮廓的连续感。给自定义 Shape 实现 animatableData,SwiftUI 就会逐帧插值它:
1 | struct PanelShape: Shape { |
内容模糊交叉淡化。 展开/收起时新旧内容不直接硬切,而是旧内容虚化退场、新内容稍晚一点从 88% 缩放 + 模糊里浮入。我写了个自定义 transition,用 ViewModifier 的 active/identity 两个状态描述「模糊+透明 ↔ 清晰」:
1 | extension AnyTransition { |
回弹余量。 还有个有意思的小 bug:展开弹簧那 ~4% 的过冲,会让岛体瞬间超出窗口边界被切平。解法是让窗口比岛体的展开目标再大一圈(那个 overshootMargin),过冲全程待在窗口内,鼠标交互矩形再相应内缩。
「窗口不动」还带来一个要收拾的副作用:窗口现在常驻一千多像素宽、大部分透明,但 macOS 不会让点击自动穿过透明像素——你点刘海正下方那片「空气」,事件还是被这个隐形大窗口吃掉了。解法是挂全局鼠标监视器,实时算「光标在不在那块可见的岛体矩形里」,不在就把整个窗口 ignoresMouseEvents = true,让点击落到底下的应用。这套判定,下一节还会再立一次功。
五、误展开 bug,和「精确的事交给精确的人」
发布前用户报了个偶发问题:收起态时,鼠标移到「展开后才会覆盖到的区域」,面板就自己展开了。
根因藏在 SwiftUI 的修饰符顺序里。.onHover 的鼠标追踪区域,是按它所附加的那个视图的渲染尺寸来定的。我的 .onHover 加在岛体视图上,但限制宽度的 .frame(width: 260) 是在更外层、.onHover 之后才套上去的。于是 onHover 看到的是它内部那个被 maxWidth: .infinity 撑满的尺寸——追踪区被拉成了整条顶部宽带。鼠标进到展开区,照样命中。
修法不是去调 frame 的顺序(那很脆,视图层级一变又会漂),而是把展开判定整个从 onHover 挪走,交给一套我本来就有的、坐标精确的机制——就是上一节为了点击穿透写的那个鼠标监视器。它每次鼠标移动都在算「光标在不在可见岛体的精确屏幕矩形里」。我让它顺手把这个布尔值用 Combine 发出来:
1 | // 控制器:用精确矩形判定,发布 hoverInside |
1 | // 视图:订阅它来驱动展开/收起,不再用 onHover |
这里还藏着一个我之前不知道的细节:为什么要同时挂全局和本地两个鼠标监视器?因为 ignoresMouseEvents 会把事件投递一分为二——当窗口忽略鼠标时,移动事件穿过去投给别的 app,这时只有全局监视器收得到;当窗口接收鼠标时,事件投给我自己,只有本地监视器收得到。两个都挂上,才能覆盖光标在「死区 ↔ 岛体」之间来回穿越的全过程。
于是展开只由那个 260 宽的收起条矩形触发,死区永远判 false。当你已经有一套精确的几何判定时,别再让一个模糊的、依赖布局层级的 API 去做同一件事。让精确的事交给精确的人。
写在最后
整个 v5.0,是我用 Claude Code 开发一个「给 Claude Code 用」的工具——一边写,它一边就在我刘海上显示着自己在写什么。这种递归挺有意思:上面那些玻璃的弯路、那些鼠标事件的暗礁,很多是我和它一来一回试出来的,它甚至在我让它跑长任务、自己起身倒水时,用我做的这个面板提醒我回来。
做小工具的乐趣,大概就在这些没人会写进功能列表、但你自己心里门儿清的细节里:一套加东西不用动主干的组件框架、一块终于「对」了的玻璃、一次鼠标移过去而面板纹丝不动的安静。
v5.0 已经发布。如果你也常让 Claude Code 跑长任务然后起身去倒杯水,也许它能帮上忙。
项目地址:github.com/CuO-kokomi/notch-claude-app