screenpipe 是个本地录屏录音的开源工具,把所有屏幕和音频活动变成可查询的数据流,还能往上面挂叫 pipe 的插件——pipe 就是跑在数据流上的小程序,做摘要、做索引、做你自定义的任何后 post-processing。我想自托管一个不限 pipe 数量的开发版自己用:不给 Apple 交 $99 年费,也不想为纯自用实例付订阅。这两个诉求都占理——自己机器上跑开源代码,天经地义;不麻烦 Apple 的公证体系,因为东西根本不出这台机器。

听起来就是“clone、build、跑起来”三件事,结果 macOS 给我上了两课:一课叫“前端说的不算”,一课叫“系统记的不是你的 app”。两课共同的扎心之处在于:每一步操作都“成功”了,每一步的“成功”又都不算数。学费加起来大概一周,学到的东西比这值钱——尤其是“系统的判定依据”和“你以为的判定依据”之间的那道缝,值得反复回味。

自签名身份让 TCC 授权不再随重编译漂移

第一课:billing bypass 是前台化妆,不是后台身份

我先在前端配了 billing bypass,UI 上确实“解锁”了——会员标识、功能入口,该亮的都亮了。然后本地 pipe 数量限制照样拦我:free plan 就两条 pipe,第三条死活建不起来。

这时候一般的排查直觉是“是不是还有第二个开关”,于是我又把环境变量和配置项翻了一遍——没有。转折点是我放弃了“找开关”的思路,改成“找决策点”:pipe 数量到底是谁在查?顺着前端 gate 的调用栈一路摸到 Rust 后端,才看明白发生了什么:前端 gate 和 Rust 后端的 local_plan_policy()两套独立状态。前端那个 bypass 变量改的是 UI 的展示和登录行为——菜单亮不亮、登录页拦不拦;native 的 pipe policy 一个字没动。UI 跟你说“你是会员”,Rust 在后台说“他没付款”——两边各查各的户口本,而我看的那本恰好是假的。

“bypass”这个名字也是帮凶。它听起来像“绕过计费”,实际上只绕过了“计费相关的前端展示”——名字承诺了一个它根本不管的世界。这种 bug 最耗人的地方在于它一半是对的:界面确实变了,所以你的第一反应永远是“是不是还有别的开关”,而不是“我改的那层根本不是决策层”。一个诚实的开关应该叫 ui_billing_gate_bypass,把作用域写进名字里,而不是让用户自己脑补它管到哪一层。

怎么修,选项摆在那,每个都认真想过:

最简单的是让 native 无条件返回 paid——一行代码的事。但这等于在官方发行版里埋一颗“人人都是付费用户”的雷:这个改动一旦混进正式构建,商业边界直接蒸发,而且谁也不知道它是什么时候混进去的。也可以塞个运行时环境变量解锁——同样简单,但隐蔽、不可审计,二进制摆在面前你都不知道它是不是“特别版”,哪天运维照着文档设了个同名变量,恭喜,又一次商业边界蒸发。还可以复用现有的 official/enterprise feature——语义直接混乱,自托管和官方发行是两种完全不同的承诺,不该共用一把钥匙。

最后我选了最笨也最稳的:新增一个显式 Cargo feature self-hosted-unlimited。默认关闭、写在构建产物里可审计——拿到二进制一查 feature 集就知道它是不是解锁版;更关键的是它和 official/enterprise 在编译期互斥,compile_error! 直接拒建。自托管和官方发行这两个承诺在类型系统层面就不能同时为真,错误的发布组合 fail closed,不靠运气、不靠检查清单、不靠 reviewer 那天没喝多。

三组路径验证下来:默认构建还是两条 pipe(没污染),feature 下无账号本地实例拿到 unlimited(解锁了),冲突组合连编译都过不去(互斥生效)。边界也得说清楚:这只解了本地 native policy,云端 API 的配额、认证和服务端 entitlement 该受限还是受限——我没伪造账户,也不想伪造。自托管的合理诉求是“我机器上的代码听我的”,不是“你服务器上的资源归我用”,这两条线的分界必须干净,不然以后每次云端报错都得先排查“是不是我的解锁 feature 越界了”。

还留了一个对上游的善意约束:未来官方如果新增别的 entitlement 入口——比如某个新功能单独查了 plan——它必须也走 local_plan_policy() 这条统一路径,不能绕开 policy 层自己判。统一入口的意义不只是“好改”,是“改一处就全部生效,漏一处就全部露馅”,两种状态都好过“一半生效一半没生效”的薛定谔解锁。

ad-hoc 每次重编译都是新陌生人,稳定自签名身份才是「同一个人」

嵌套签名顺序与两道构建门禁

第二课:TCC 记的是签名身份,不是文件路径

功能跑通后,定时采集任务读 Voice Memos 这类受保护目录被拒。我第一反应是脚本调度有问题——定时任务嘛,launchd 的环境变量和 TTY 不一样,权限上下文丢了很正常,这类锅我见多了。结果查下来调度完全正常:该跑的时候跑了,该读的路径也对,错误信息清清楚楚写着“操作不被允许”。调度是冤枉的,真凶在更底下。

真正的问题是开发版 .app 一直用 ad-hoc 签名。ad-hoc 的意思是“这玩意没有稳定身份”——codesign -dv 打出来 Signature=adhoc,本质上等于系统眼里的匿名人士,每次链接器重新产出二进制,它的“指纹”就变一次。我之前在系统设置里给过完全磁盘访问授权,但 TCC(Transparency, Consent and Control)绑定的是 code identity:签名身份一漂移,授权记录就对不上号。app 还是原来那个路径、那个 icon、那个 bundle id,系统却当你是“另一个 app”——你亲手授权的坐在家里,跑进来的这个只是长得一模一样。

TCC 这个设计其实很讲理:它要防的恰恰是“恶意程序顶替合法程序的位置”——如果授权跟路径走,病毒只要把自己改名成 Screenpipe.app 放进 /Applications 就继承全部权限。所以它认的是“这个二进制是谁签的、签名链条指向哪个身份”,路径和图标只是展示给你看的皮囊。理解这一层,“重编译后权限失效”就从玄学变成了必然:不是你授权没存住,是你每次都换了个新人去领旧人的通行证。

之前每次失效我都手动重新授权,治标不治本,还顺手掩盖了嵌套签名的问题——你以为是“授权又过期了”,其实是“你又编译出了一个新 app”。手动重授这个动作还有个隐蔽的坏处:它让问题看起来像“周期性的系统小脾气”,而不是“每次构建都必然复发的结构性故障”,于是一次次点系统设置就混成了日常仪式,没人再去问为什么。

更损的是同步脚本把权限失败吞成了 Errors=0:日志一片祥和,权限一地鸡毛。打开一看,受保护目录的读取错误根本没接进错误计数——脚本只统计它“看得懂”的失败,EPERM 这种系统层拒绝在它的世界观里不存在。监控系统的“健康”有时就是这么定义的——没统计到错误,等于没有错误。这条给我留下的反射弧是:看到 Errors=0 先别高兴,先问错误计数器覆盖到了哪一层——它管的是“业务失败”,还是连“系统拒绝”也管?只统计前者的仪表盘,本质上是在汇报“我心情很好”。

没有 $99 的 Apple Developer 账号,Developer ID 和公证都免谈——这两样要 Apple 背书的真实证书,伪造不了也不该伪造。可行的路是造一把只存在本机钥匙串里的自签名 identity:私钥不出 Keychain,不进仓库,生命周期就绑在这台机器上。自签名给不了公证和分发,但它能给 TCC 一样最需要的东西:一个重编译前后不变的 code identity——证书指纹固定,Designated Requirement 稳定,下次编译出来的 app 在系统眼里还是“同一个人”。

这里有个值得单独说的细节:签名方案的所有备选我都过过一遍,淘汰理由各不相同——继续 ad-hoc 等于接受每次重编译重新授权的永动循环;每次手工重授治标还掩盖嵌套签名问题;伪装 Developer ID 没有账号根本做不了;只签外层壳在 strict 校验面前当场露馅。最后剩下自签名,不是因为它最好,是因为在“无账号”这个硬约束下,它是唯一同时满足“身份稳定 + 私钥不出本机”的解。选型有时候不是选最优,是先把约束摆上桌,看最后谁还活着。

然后把签名嵌进 tauri:build:local 的 post-build——这里又有个顺序的讲究:Tauri app 肚子里揣着 Bun、FFmpeg、MetalLib 和一堆 dylib,全是独立的 Mach-O,得从内到外逐个签,先签嵌套组件、再签主程序、最后签 .app 外层。只签外壳?codesign --verify --deep --strict 会把没签的内件一个个揪出来——macOS 对 nested code 的验证模型就是“每个可执行件都要有自己的名分”,外包再厚也没用。还有个维护性细节:嵌套组件列表不能写死,依赖哪天升级多带一个二进制,写死的列表就静默漏签,所以脚本得自动发现所有 Mach-O 再逐个签。

验收标准是 strict 校验加 Designated Requirement 核验,两道都设成 build 门禁——签名命令退出 0 不算验收,verify 通过才算。这个区分很实在:命令成功只代表“签名动作执行了”,verify 才代表“签出来的东西能被系统认”。顺手把本地构建限定成 --bundles app:开发只要 .app,DMG 打包失败纯属无关故障面——本地运行的验收目标里根本没有 DMG,让它挡路等于给自己增加一个天天红着但无所谓的告警。

代价和边界也得认:每台开发机要自己创建并信任这个 identity,团队共享和外部分发都不适用——这是“本机开发方案”的题中之义,别拿它当发布方案。identity 一变,TCC 还是要用户手动重授——系统权限不许脚本代劳,这是特性不是 bug,能脚本代授的权限等于没有权限。依赖哪天新增一个嵌套二进制,签名脚本得能自动发现,不然 strict 又翻脸。另外还有对常见误区分个层:本地 build 成功 ≠ 可分发,自签名 ≠ Developer ID,“我这台机器能跑”和“别人下载能跑”之间隔着公证、entitlement、分发签名三座山。最后一条诚实的边界:新 identity 下“完全磁盘访问”的最终重授,会话结束前没有亲自验证——构建和校验都绿了,但 TCC 授权那一哆嗦还得用户亲手在系统设置里点完才算数。

两课其实是一课:macOS 的权限模型很讲道理,它不认 UI 显示什么,认 native policy;不认文件路径,认签名身份。麻烦的是这些边界从不报错——它只是静默地不给你干活,然后留你在 Errors=0 的日志前面怀疑人生。你改的那一层和系统查的那一层,往往不是同一层;而系统从不负责告诉你这一点。

我现在的经验是:凡是“看着应该行但就是不行”的权限问题,先问系统认的是哪个身份,再问自己伪造的是哪一层。前端 bypass 伪造的是展示层,TCC 授权绑定的是签名层,定时脚本统计的是业务层——三层各自安好,问题永远出在层与层之间。前台化妆和后台身份,是两本不同的户口本;你想办事,得先搞清工作人员看的是哪本。