AppWindow 开发踩坑复盘

把 macOS 的 Cmd+Tab / Cmd+` 变成液态玻璃选择面板 —— 一天迭代 20+ 轮换来的教训

AppWindow v0.5.0·macOS 26 / Darwin 27·Accessibility API·NSGlassEffectView

结构性约束 平台不给路,只能选接受或绕行

单 key window 模型 无法绕过

一个 app 同一时刻只有一个 key window,第二个 makeKey 必然抢走第一个的 key。多屏面板想让玻璃同时聚焦?做不到——这是后面一串坑的总根源。

玻璃聚焦渲染是二值瞬变 无公开接口

NSGlassEffectView 的聚焦/失焦样式由系统按窗口 key 状态渲染,SDK 头文件确认没有「强制聚焦样式」的属性。所有「给玻璃切换加过渡」的尝试都在对抗这一点。

activate() 的窗口决策会覆盖预设焦点 竞争中间态

纯 AX(setFocused)对后台 app 无激活效果,必须 app.activate();而 activate 自带窗口决策,激活后还得 refocus 纠正——决策窗口「闪现后消失」由此而来,窗口级路径无法根除。

activate() 的组提升只覆盖单个显示器 macOS 26 实测

系统 cmd+tab 会把应用全部窗口带到前台,但直接调 activate() 在 macOS 26 上只提升一个显示器的窗口,副屏被盖的窗口不会浮上来。

global monitor 只收本 app 外的事件 事件边界

「点击面板外取消」用 addGlobalMonitorForEvents 实现,但面板窗口内的透明区域点击被本 app 窗口吞掉——不取消也不透传,成了点击死区,只能自己垫透明点击层。

makeKey 必然隐式激活 app 已接受

makeKeyAndOrderFront 实测必然隐式激活(yieldActivation 也拦不住),面板生命周期内 app 短暂 active,关闭即失活。

方案死路 多轮试错,方向本身不通

手动组提升:三轮翻车 已整体放弃

① 全量 raise:打乱其他窗口相对层序,每次切换非选中窗口互换位置。② CG z 序保序 raise:被完全盖住的窗口「提到顶再被压回」闪现。③ 遮挡感知 raise(只提被其他 app 盖住的):仍闪屏。根因:AXRaise 没有原子组操作,中间态必然可见。

玻璃过渡动画:四轮翻车 回到直接切换

① 恒定压暗层:难看(用户不要常驻背景)。② 玻璃淡到 0 再切换:玻璃完全消失露出纯模糊底,闪帧。③ 淡到 30%:alpha 稀释了玻璃的黑色 tint,「先亮一下再慢慢变暗」的脉冲。④ 瞬时高光补偿:也没能留住。结论:直接切换是接受态。

跨屏单窗口:修完仍被回退 环境不配合

一个窗口横跨所有屏、一个 key 全屏聚焦——思路正确,但 .screenSaver 级窗口跨屏只在主屏渲染(WindowServer 不合成第二屏),空白区还有点击死区;降级窗口级修复后仍被要求回退双窗口方案。

30 秒硬超时误杀 已修复

兜底「modifier 状态丢失」的超时把长时间按住浏览的面板杀掉了。改成每秒轮询 CGEventSource.flagsState:只有 cmd 物理已松开(且松开事件丢失)才收起,按住多久都不取消。

交互细节 一次性修掉

iPhone 接力条目覆盖 Dock 角标 已修复

iPhone 靠近时 Dock 出现 Handoff 条目(AXHandoffDockItem),title 与真实 app 同名(如「提醒事项」),AXStatusLabel 竟是设备标识 com.apple.iphone-13-pro-1——把真实未读数 4 覆盖成了一行字符串。按 subrole 过滤解决。

列表行两侧点击不到 已修复

行视图被裁剪视口的 edgeInset 裁掉两侧,点击落在容器上无效。改成行全宽接收点击、高亮块用内缩子层绘制——点击区域与视觉宽度解耦。

工程细节 环境与语言的小刀子

Xcode license 拦截命令行编译 绕过

未同意 Xcode license 时 swift build 直接报错。DEVELOPER_DIR=/Library/Developer/CommandLineTools 指向 CLT 工具链即可绕过。

Swift 类型小坑三连 已修

① 参数声明为 NSPanel 时看不到 NonKeyPanel 子类的方法;② 覆写 init(frame:) 忘写 override;③ CGRect.union 是实例方法,不能作为函数值传给 reduce,要写 { $0.union($1) }。

删 tag 被 Draft release「复活」 已处理

发版跳号时删了 tag,稍后 tag 又出现了——CI 在被取消前已创建 Draft release,GitHub 会为 draft release 自动重建关联 tag。必须先删 release 再删 tag;取消 CI 要赶在 release 步骤之前。

最大的教训:macOS 公开 API 对「窗口层级精细控制」几乎没有承诺——单 key、二值玻璃渲染、activate 的黑盒决策都是墙。撞墙时先查 SDK 头文件确认有没有门,没有就尽早接受系统的朴素行为,把精力花在用户能感知的地方。
可复用的结论都已沉淀进项目记忆,下一轮迭代不再重走弯路。