macOS · Rust · VibeTrail

在别人的终端里开一扇门:VibeTrail resume 的四次降级

AppleScript 崩溃、TCC 静默拒绝、死点击探案——macOS 自动化的权限暗礁,和最后那条一直摆在眼前的路。

VibeTrail 的核心承诺是「浏览 → 搜索 → 恢复」:搜到三周前那次 Claude Code 会话,点一下,在你惯用的终端里开新窗口、cd 到项目目录、执行 resume 命令。Terminal.app 和 iTerm2 有成熟的 AppleScript 接口,一路顺利。Ghostty 让我重写了四次——每一次都撞上 macOS 自动化的一块不同暗礁,最后的答案却一直摆在 Finder 的右键菜单里。

第一版:open -n,用 Dock 图标付账

Ghostty 的命令行参数只在进程启动时生效,想「带着目录和命令开个新窗口」,最直接的是 open -n 强制起新实例。功能正确,副作用是每次 resume 都在 Dock 里多一个 Ghostty 图标。对一天 resume 十几次的工具,这不是瑕疵,是垃圾制造机。

第二版:官方 scripting API,把宿主搞崩了

Ghostty 1.3 开始带 AppleScript 字典,能让运行中的实例开新窗口——正是我要的。切换过去,干净利落。直到连续 resume 几个会话之后,Ghostty 本体崩溃

查证的结果让这条路彻底封死:这个字典官方标注 preview 状态,1.4 已计划 breaking change,窗口/标签创建路径上有已知崩溃 issue。用别人的 preview 接口承载自己的核心功能,等于把两个产品的稳定性焊在一起,还把遥控器交给对方。撤回:冷启动走 open -a(单实例,无新 Dock 图标),运行中降级为「resume 命令进剪贴板,提示用户粘贴」。

第三版:死点击探案——TCC 的静默拒绝

降级版发出去,反馈回来一个最难缠的症状:点 Resume,什么都没发生。不报错、不弹窗、无日志。

先解决「看不见」的问题:给前端挂上 window.onerrorunhandledrejection 的 toast 上报——「死点击」这种症状,一半是没执行,一半是执行炸了但异常被吞。顺带自曝一个排查插曲:最初用合成指针事件复现「死点击」,探针本身没校准,差点把自己引向完全错误的方向;换了个临时的 click probe 才确认事件链路是通的,问题在更深处。

真凶在权限层。降级路径的「写剪贴板 + 激活 Ghostty」走的是 osascript,即 AppleEvents——这受 macOS 的 TCC(Automation 权限)管辖。而 TCC 的授权绑定应用签名身份,未签名的开发构建每次重新编译 cdhash 都变,授权随之作废;作废后的调用,系统的处理是静默拒绝:不弹授权框、不返回错误、就当无事发生。这就是「什么都没发生」的全部机制。

这一版立下的规矩比修复本身重要:降级路径绝不允许依赖会静默失败的权限。降级的意义是兜底,一条自己会无声失败的兜底路径,比没有更糟——它让你误以为有保险。改法是把权限面清零:剪贴板改 pbcopy(进程管道,无权限),激活改 open -a(LaunchServices,无权限)。Terminal 和 iTerm2 保留 AppleScript——它们要执行命令,AppleEvents 不可绕,但那是主路径,失败会报错,不是降级。

第四版:答案在 Finder 右键菜单里

换位思考破的局:Ghostty 给 Finder 提供「New Ghostty Tab Here」右键菜单,靠的是什么?翻它的 Info.plist——NSServices。这是 macOS 最古老的进程间集成机制之一:应用声明服务,系统路由调用,Finder 天天在用,厂商自己长期维护。

VibeTrail 最终方案:构造一个装着目标路径的 filenames pasteboard,NSPerformService("New Ghostty Tab Here", pboard),经一层 JXA 桥调用。纯 AppKit、零 AppleEvents、零权限请求、行为和用户在 Finder 里右键逐字节一致——厂商已经为这条路径的稳定性背了书。四个版本的折腾,落在一个第一天就存在的接口上。

沉淀:ADR 里的禁止清单

这段历史最后固化成架构决策记录(ADR-4)里的三条禁令,每条都是尸体换来的:

  • open -n——多实例、多 Dock 图标;
  • 禁 System Events 模拟键击——需要辅助功能权限(又一个会静默失败的 TCC 类目),且键击注入走的是键码层,中文输入法激活时注入的命令会变成乱码
  • 禁 preview 状态的 scripting 字典——宿主崩溃不可接受,preview 的语义就是「我们随时会改」。

同类问题在其他终端的答案,顺手记档:Warp 没有任何可脚本化的「执行命令」面,走 warp://action/new_window?path= URL scheme 开到目录 + 命令进剪贴板,诚实地告诉用户「粘贴回车」;Cursor 是 GUI 客户端语义,官方连「跳到某个历史会话」的 deeplink 都还没有(论坛里还是 feature request),于是给 ResumeSpec 加了 LaunchMode::GuiApp,直接 open -a Cursor <项目路径>,UI 上显示会话标题辅助人肉定位。

两条通用结论:

  • macOS 自动化选路时,优先找厂商已经为系统集成铺好的路——NSServices、URL scheme、文档类型关联。它们服务于厂商自己的用户体验,稳定性承诺天然高于「顺手加的」scripting 接口;
  • 适配了三个终端一个 IDE 之后的诚实感想:resume 的最后一米没有统一抽象。每家的能力面、权限面、稳定性承诺都不同,唯一可复用的是方法论——先摸清对方为谁维护了哪些接口,再决定自己站在哪条上。