把 Claude Code 塞进 MacBook 的刘海里:灵动岛 Claude v5.0 开发手记

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

收起态:贴着 notch 显示当前状态

展开态:一排自选组件,背后是原生玻璃质感

任务完成:整条绿光脉冲 + 对勾

它最初只是个「看状态」的玩意儿。但 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
2
3
4
5
6
{
"PermissionRequest": [{
"matcher": "",
"hooks": [{ "type": "http", "url": "http://127.0.0.1:23889/permission", "timeout": 600 }]
}]
}

注意那个 timeout: 600——Claude Code 愿意为我的回答等十分钟。

app 这头我用 NWListener(Network.framework)起了个只绑回环地址的本地服务。请求进来时,我不立刻回复——把这条 NWConnection 攥在一个字典里,按到达顺序排队,然后在刘海弹卡:

1
2
3
4
5
6
7
8
private var connections: [UUID: NWConnection] = [:]

private func handle(_ request: HTTPRequest, conn: NWConnection, connID: UUID) {
// ... 解析 tool_name / tool_input ...
connections[connID] = conn // 攥住连接,先不回
pending.append(item) // 推给 UI 显示
watchForClose(on: conn, connID: connID)
}

直到你在刘海上点了允许/拒绝,我才往那条一直开着的连接里写回决定:

1
2
3
4
5
6
7
8
func respond(_ id: UUID, decision: PermissionDecision) {
guard let conn = connections.removeValue(forKey: id) else { return }
let body = ["hookSpecificOutput": [
"hookEventName": "PermissionRequest",
"decision": ["behavior": decision == .deny ? "deny" : "allow"]
]]
Self.send(conn, status: "200 OK", body: jsonData(body))
}

收起态权限审批

「始终允许」是我最喜欢的一处。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
    9
    private func watchForClose(on conn: NWConnection, connID: UUID) {
    conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { _, _, isComplete, error in
    Task { @MainActor 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
2
3
4
5
6
struct WidgetDescriptor: Identifiable {
let id: String
let displayName: String
let iconName: String
let viewBuilder: (WidgetEnvironment, Alignment) -> AnyView
}

所有组件登记在一张注册表里。新增一个组件,就是往这个数组里追加一条描述符——主面板、组件管理、拖拽排序的代码一行都不用动:

1
2
3
4
5
6
7
8
9
enum WidgetRegistry {
static let all: [WidgetDescriptor] = [
WidgetDescriptor(id: "claude_status", displayName: "Claude 状态", iconName: "brain.head.profile",
viewBuilder: { env, align in AnyView(ClaudeStatusWidget(claude: env.claudeStatus, ...)) }),
WidgetDescriptor(id: "weather", displayName: "天气", iconName: "cloud.sun",
viewBuilder: { env, align in AnyView(WeatherWidget(weather: env.weather, ...)) }),
// ... 一共十几条,加一个组件 = 加一行
]
}

那个 WidgetEnvironment 是所有组件共享的「数据源仓库」,每个组件要的 provider(天气、电池、音乐、Git 状态……)都挂在上面。这里有个性能细节:这些 provider 全是 lazy 的——天气组件没被启用,就不会去发网络请求;音乐没显示,就不去碰 MediaRemote。面板展开后才 warmUp() 一把,把当前在用的几个唤起来。用户拼了什么,才付出什么代价。

这套底子的回报,在 v5.0 体现得最明显:这一版的多会话列表、带上下文百分比的 Claude 卡片、权限审批的展开态视图——对系统来说,它们只是「Claude 状态」这一个组件内部换了张脸。我加这些功能时,完全没去动组件框架本身。好的模块化,是让新需求变成「往清单里加一条」,而不是「在主干上开一刀」。

三、玻璃质感的弯路:原生 ≠ 适合

macOS 26 出了 Liquid Glass、原生 .glassEffect() API。我想当然地用上,结果做出来两版都被自己否掉。

.glassEffect() 的工作方式,是采样它背后的内容、做模糊、再叠一层偏亮的磨砂。这对「深色文字浮在浅色玻璃上」的场景(系统大部分控件就是这样)很完美。但我的面板整屏都是白字

  • 第一版「亮玻璃」:纯 .glassEffect(.regular)。玻璃够透,但白字配亮磨砂,直接糊没了。
  • 第二版「暗玻璃」:在亮玻璃上叠黑色蒙版压暗找回对比。结果糊成一块均匀死灰,毫无通透感,像灰塑料板。

两头不讨好,卡了大半天,直到我承认方向从一开始就错了:

Liquid Glass 天生偏亮,它是为浅色玻璃 + 深色内容设计的。我要的是深色玻璃 + 白色内容,正好相反。原生不等于适合。

正解反而是个「老」东西——NSVisualEffectView,聚焦搜索和深色菜单用的就是它。关键是三个参数的组合:

1
2
3
4
5
let view = NSVisualEffectView()
view.material = .hudWindow // 暗色、偏实的玻璃
view.blendingMode = .behindWindow // 采样窗口「背后」的桌面来虚化
view.state = .active
view.appearance = NSAppearance(named: .vibrantDark) // 振动暗色:内容自动获得对比

behindWindow 让它真实地把桌面虚化透上来,而不是平涂一块半透明黑;vibrantDark 是「振动」的精髓——内容(我的白字)会自动从背景里「提」出对比,材质本身也带层次,不会糊成死灰。一个字的颜色都没改,白字就全清晰了。

展开态玻璃质感

还有个细节坑:组件卡片我一开始也给每张叠了独立的玻璃。玻璃叠玻璃 = 两层模糊叠加,糊成一团。 控制中心的那些圆角片其实不是玻璃叠玻璃——它们是浮在同一层壁纸模糊上的半透明亮片。照这个改:外壳是唯一的真实模糊层,卡片只是一层 .white.opacity(0.12) 的浮片加描边。层次一下就出来了。

最后保留「厚玻璃 / 薄玻璃」两档在右键菜单里切(材质分别是 .hudWindow.fullScreenUI)。有些审美决策,与其替用户定死,不如给个开关。

四、灵动岛动画:让同一条弹簧驱动一切

要让展开/收起像原生灵动岛那样丝滑,我做的第一个、也是最关键的决定是:让窗口根本不动。

原生那种丝滑的本质,是一条弹簧曲线驱动所有维度。如果窗口用 AppKit 动画、内容用 SwiftUI 弹簧,两条曲线不同步,边界和内容在不同时刻到位,就有种果冻般的错位感。所以我把窗口固定成最大尺寸、永不改变——收起态的窗口其实和展开态一样大,只是绝大部分透明。那块会变形的「岛体」是窗口里一个顶部居中的 SwiftUI 容器,用 GeometryReader 拿到窗口尺寸,自己在 260×42 和「铺满窗口」之间做弹簧动画:

1
2
3
4
5
6
7
GeometryReader { geo in
island.frame(
width: isExpanded ? geo.size.width - overshootMargin*2 : 260,
height: isExpanded ? geo.size.height - overshootMargin : collapsedHeight
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}

窗口不动,尺寸、圆角、内容透明度就全挂在同一条 SwiftUI 弹簧上了。再补几笔模仿原生的细节:

不对称弹簧。 展开和收起用不同参数——这是原生岛的标志性手感:

1
2
3
4
5
private var expandAnimation: Animation {
isExpanded
? .spring(response: 0.42, dampingFraction: 0.72) // 展开:带一点回弹过冲
: .spring(response: 0.32, dampingFraction: 0.85) // 收起:干脆利落、不回弹
}

dampingFraction 0.72 那一点点「弹过头再弹回来」,正是橡皮感的来源;收起时不需要这个,调高阻尼让它利落收掉。

圆角连续过渡。 形状默认不参与动画,圆角 21→34 会瞬间跳变,破坏轮廓的连续感。给自定义 Shape 实现 animatableData,SwiftUI 就会逐帧插值它:

1
2
3
4
5
6
7
8
struct PanelShape: Shape {
var cornerRadius: CGFloat
var animatableData: CGFloat { // 把圆角暴露成可动画的量
get { cornerRadius }
set { cornerRadius = newValue }
}
func path(in rect: CGRect) -> Path { /* 用 cornerRadius 画 */ }
}

内容模糊交叉淡化。 展开/收起时新旧内容不直接硬切,而是旧内容虚化退场、新内容稍晚一点从 88% 缩放 + 模糊里浮入。我写了个自定义 transition,用 ViewModifier 的 active/identity 两个状态描述「模糊+透明 ↔ 清晰」:

1
2
3
4
5
6
extension AnyTransition {
static var blurFade: AnyTransition {
.modifier(active: BlurFadeModifier(radius: 8, opacity: 0),
identity: BlurFadeModifier(radius: 0, opacity: 1))
}
}

回弹余量。 还有个有意思的小 bug:展开弹簧那 ~4% 的过冲,会让岛体瞬间超出窗口边界被切平。解法是让窗口比岛体的展开目标再大一圈(那个 overshootMargin),过冲全程待在窗口内,鼠标交互矩形再相应内缩。

「窗口不动」还带来一个要收拾的副作用:窗口现在常驻一千多像素宽、大部分透明,但 macOS 不会让点击自动穿过透明像素——你点刘海正下方那片「空气」,事件还是被这个隐形大窗口吃掉了。解法是挂全局鼠标监视器,实时算「光标在不在那块可见的岛体矩形里」,不在就把整个窗口 ignoresMouseEvents = true,让点击落到底下的应用。这套判定,下一节还会再立一次功。

五、误展开 bug,和「精确的事交给精确的人」

发布前用户报了个偶发问题:收起态时,鼠标移到「展开后才会覆盖到的区域」,面板就自己展开了。

根因藏在 SwiftUI 的修饰符顺序里。.onHover 的鼠标追踪区域,是按它所附加的那个视图的渲染尺寸来定的。我的 .onHover 加在岛体视图上,但限制宽度的 .frame(width: 260) 是在更外层、.onHover 之后才套上去的。于是 onHover 看到的是它内部那个被 maxWidth: .infinity 撑满的尺寸——追踪区被拉成了整条顶部宽带。鼠标进到展开区,照样命中。

修法不是去调 frame 的顺序(那很脆,视图层级一变又会漂),而是把展开判定整个从 onHover 挪走,交给一套我本来就有的、坐标精确的机制——就是上一节为了点击穿透写的那个鼠标监视器。它每次鼠标移动都在算「光标在不在可见岛体的精确屏幕矩形里」。我让它顺手把这个布尔值用 Combine 发出来:

1
2
3
// 控制器:用精确矩形判定,发布 hoverInside
let inside = islandRect.contains(NSEvent.mouseLocation)
if hoverInside.value != inside { hoverInside.send(inside) }
1
2
3
4
// 视图:订阅它来驱动展开/收起,不再用 onHover
.onReceive(hoverInside) { inside in
if inside { expand() } else { scheduleCollapse() }
}

这里还藏着一个我之前不知道的细节:为什么要同时挂全局和本地两个鼠标监视器?因为 ignoresMouseEvents 会把事件投递一分为二——当窗口忽略鼠标时,移动事件穿过去投给别的 app,这时只有全局监视器收得到;当窗口接收鼠标时,事件投给我自己,只有本地监视器收得到。两个都挂上,才能覆盖光标在「死区 ↔ 岛体」之间来回穿越的全过程。

于是展开只由那个 260 宽的收起条矩形触发,死区永远判 false当你已经有一套精确的几何判定时,别再让一个模糊的、依赖布局层级的 API 去做同一件事。让精确的事交给精确的人。

写在最后

整个 v5.0,是我用 Claude Code 开发一个「给 Claude Code 用」的工具——一边写,它一边就在我刘海上显示着自己在写什么。这种递归挺有意思:上面那些玻璃的弯路、那些鼠标事件的暗礁,很多是我和它一来一回试出来的,它甚至在我让它跑长任务、自己起身倒水时,用我做的这个面板提醒我回来。

做小工具的乐趣,大概就在这些没人会写进功能列表、但你自己心里门儿清的细节里:一套加东西不用动主干的组件框架、一块终于「对」了的玻璃、一次鼠标移过去而面板纹丝不动的安静。

v5.0 已经发布。如果你也常让 Claude Code 跑长任务然后起身去倒杯水,也许它能帮上忙。

项目地址:github.com/CuO-kokomi/notch-claude-app