手上有个自己写的 macOS 菜单栏 app,用 NEFilterDataProvider 在内核 flow 级拦截网络流量。最早的动机很具体:用手机热点的时候,Time Machine 会经由 Tailscale 隧道往家里 OpenWRT 的 SMB 共享写入几百 GB 备份,瞬间吃光移动流量,tmutil disable 又得手动切换。于是写了个系统扩展,按当前 SSID 动态决定放行还是拦截特定流量,顺手做成一个可以扩展的规则引擎。
M1 到 M11 的里程碑(工程生成、扩展激活、规则匹配、SSID 感知、菜单栏 UI、日志持久化、kill switch 三层机制……)几个月前就跑通了,后来又加了一批 IDS 式的深度包检测(SNI/HTTP Host/team identifier 匹配、两阶段规则评估、字节阈值触发)。功能是够了,但代码这边攒了一堆债:UI 手搓了三份重复的状态徽章,规则编辑器 622 行里塞了 12 个裸文本框、正则和 CIDR 语法全靠 placeholder 教学,8 个状态对象靠构造器一层层往下传,SWIFT_STRICT_CONCURRENCY 还停在 minimal,全仓库零自动化测试。
这次决定不修修补补,直接做一次 UX + 架构的整体重设计。
怎么组织这次重构
代码量不小,两个 target(app + 系统扩展)加起来几十个文件。没有自己一个个改,而是走了个多 agent 分工的路数:
- 先并行派两个只读探索 agent,一个盘点 UI 层现状,一个盘点架构和数据流现状,各自产出一份报告。
- 把两份报告喂给一个 opus 模型的 planner agent,出一份分阶段实施方案——每个阶段独立可构建、可部署、可验证,非平凡的决策都要写清楚选了什么、否掉了哪些备选、为什么。
- 方案里定了两个关键裁决:一是重设计前一直没接入的某个 UI 框架直接弃用(现有实现已经 100% 用另一套框架跑通了 dashboard 布局,硬接等于同时背两套布局系统);二是日志数据库的存储位置继续留在应用沙盒之外的一个共享目录(后面会讲这个决定为什么关键)。
- 方案分成 7 个阶段,每个阶段派一个独立的 subagent 去实现,完成后我自己重新拉一次代码、重新跑测试、重新跑构建、亲自看一遍 diff——不拿 subagent 自己的完成汇报当验证。测试和构建绿了才提交,下一阶段才开始。
这套流程里最容易偷懒的地方就是”信任 subagent 的自我汇报”。一个 agent 说”测试全绿、构建成功”,你不重新跑一遍,你就不知道它是不是真的跑了、还是模型自己觉得应该会过。这次全程没有一次例外——每个阶段提交前都是我自己重新执行 xcodebuild test 和签名构建,拿退出码说话。
七个阶段做了什么
按提交顺序简单过一遍,两个比较有意思的展开说:
- Phase 0:项目此前零自动化测试,而全部安全语义(放行/拦截判断)压在几个纯函数上。加了一个不挂 host app 的 logic-test target(
bundle.unit-test但不设host application,否则跑测试会触发@main里的系统扩展激活链),覆盖规则引擎的 first-match-wins、monitor/enforce 模式转换、SSID 门控、CIDR 边界匹配。 - Phase 1:过滤扩展此前对每一条放行的流量都无条件开启内联检查(inline inspection,peek 字节数设成
.max),哪怕规则集里根本没有任何需要深度包检测的规则。加了一个门控:只有规则集里确实存在需要 layer-7 数据的规则时才进入检查路径,否则直接放行。同时把日志数据库目录/文件权限从0o777/0o666(任何本机进程可读写)收紧成chown给当前登录用户后的0700/0600。 - Phase 2:
SWIFT_STRICT_CONCURRENCY从minimal直接尝试冲到complete。事先按未知风险单独隔离成一个阶段,结果实际暴露面很小——6 个文件、43 行改动,每个保留的@unchecked Sendable都补了一段注释说明为什么手工加的锁是完备的。 - Phase 3:8 个
ObservableObject全部迁到 Swift 的@Observable+ SwiftUI Environment 注入。过程中发现一个坑:菜单栏用的 UI 框架的主窗口是自己独立建的一个NSHostingView根,不在MenuBarExtra的 Scene 图里——在菜单栏场景注入的 environment 值,主窗口那棵树完全收不到,两处必须分别注入。 - Phase 4:抽了一个共享的状态徽章组件,把三处各自手搓的实现收敛掉;同时把菜单栏从纯文字菜单样式切到能承载真实 SwiftUI 视图的窗口样式,才能在菜单栏也显示彩色状态指示。
- Phase 5:规则编辑器重构——渐进展开(默认只显示常用字段,高级匹配条件收进折叠区)、实时校验、显式排序(first-match-wins 的优先级此前完全没有 UI 可调)。校验逻辑顺手抓到一个此前完全静默的真 bug:
/33、/129这类前缀越界的 CIDR 会被解析函数当作合法值接受,存进配置,到匹配阶段才因为边界检查失败变成一条永远不会命中的规则——用户毫无察觉地配置了一条”看起来生效、实际上是废纸”的规则。 - Phase 6:系统扩展批准状态的引导 UI(需要用户去系统设置手动批准、需要重启等几种状态,此前只有一行文字提示)。
七个阶段全部走完,构建绿、测试绿(从 0 涨到 47 个),部署到本机、扩展显示 activated enabled,进程正常存活,看着像是收工了。
真机验证:一个被测试和构建全部漏掉的 bug
按惯例做最后一步收尾验证——不是”部署完看它没崩就算过”,是真的去构造一条会触发行为差异的场景,看日志和数据库里的真实记录。
配置文件里加一条临时规则,drop 所有目的端口是 80 的 TCP 流量,模式设成 monitor(这样不会真的中断我自己的网络,只记录”本该拦截”),发一个 Darwin 通知触发扩展热重载配置:
notifyutil -p dev.lishuyu.smartfirewall.configChangedcurl -s -m 5 -o /dev/null http://example.com然后查日志数据库最近的记录:
SELECT datetime(timestamp,'unixepoch','localtime'), verdict, engineDecision, ruleId, remotePortFROM drop_events WHERE remotePort=80 ORDER BY timestamp DESC LIMIT 5;2026-07-01 17:45:36|allow|allow||802026-07-01 17:44:50|allow|allow||802026-07-01 17:44:18|allow|allow||80ruleId 是空的,engineDecision 是 allow——那条临时规则根本没有命中。用同一份规则引擎源码单独编了个 CLI 小工具直接解码配置文件、跑一遍匹配,结果却是应该命中、应该产出 monitorOnly。同一份代码,同一份配置文件,两个不同的判断——只可能是扩展进程读到的配置文件根本不是我改的那份。
顺手测了一下另一个独立的开关:往约定路径写一个”熔断标志文件”,扩展检测到就应该无视所有规则直接放行,同时打一条日志。放了标志文件,发流量,等了足够长的时间窗口——扩展进程零日志输出。这条开关名义上存在了很久,从来没有真正触发过。
根因:root 进程和用户进程看到的是两个不同的共享目录
两个独立现象指向同一个原因。配置文件、这个熔断标志文件,此前都存在 App Group 容器里——FileManager.containerURL(forSecurityApplicationGroupIdentifier:) 这套 API,App 和系统扩展各自拿同一个 group identifier 去解析,理论上应该拿到同一个目录,用来跨进程共享数据。
但系统扩展是以 root 身份运行的,不是当前登录用户。查证之后确认:root 进程调用这同一个 API,解析出来的路径落在 /private/var/root/Library/Group Containers/...,而普通用户进程解析到的是 /Users/<user>/Library/Group Containers/...——两个完全不同的目录。App 侧改规则、切模式、放熔断标志,其实全部写进了自己的用户级容器;扩展进程一直读写的是它自己那份、/var/root 下独立的容器副本。两边各自玩各自的,从未真正共享过。
这不是一个只在我这台机器上出现的怪现象——Apple 的 DTS 工程师在开发者论坛上有一条针对同类场景(另一种系统扩展)的明确回复:App Group 共享容器只在两个进程运行在同一个用户身份下才能真正共享;root 权限的系统扩展和普通用户进程即便声明了同一个 App Group,拿到的物理路径也不同,官方给的建议是改用 XPC 而不是依赖跨用户边界的共享容器。
回头翻自己项目几个月前的提交记录,发现日志数据库这一份数据其实早就撞过这个坑——四月份的一次重构里,日志数据库被从 App Group 容器搬到了一个不属于任何沙盒容器的共享目录(/Users/Shared/ 下自建一个子目录,root 能写、当前用户能读),提交信息里写得很清楚:内容过滤扩展以 root 运行,它的 App Group 容器解析到 /var/root,用户态 app 读不到。但那次只搬了日志数据库,配置文件、网络快照、熔断标志这三样东西被漏掉了,一直悬在一个从未真正连通过的通道上——包括本文开头提到的那个熔断标志文件,本质上是个安全兜底机制,从它被写下来的那天起就没有真正生效过。
修复:统一迁到共享目录,以及修复本身带出的第二个坑
修法沿用四月那次已经验证过的架构:把配置文件、配置备份、网络快照、熔断标志全部迁到日志数据库所在的那个共享目录,权限模型保持一致——目录 chown 给当前登录用户、0700,文件 0600。App 首次启动时把旧的用户级容器里可能存在的历史配置做一次单向拷贝迁移(只拷贝,不删除旧文件,方便万一要回滚)。
改完,重新构建、部署、重启 app——菜单栏图标再也没有出现。进程活着,没有崩溃日志,但主界面无从谈起。
用 sample 抓了一下卡住进程的调用栈:
SmartFirewallApp.init() closure #3 ConfigController.load() ConfigStore.loadCurrent() (queue.sync) ConfigStore.loadCurrentLocked() migrateFromLegacyIfNeeded() migrateFileIfAbsent(...) FileManager.copyItem → copyfile → clonefileat (阻塞)主线程卡在一个文件拷贝的系统调用里,纹丝不动。截个屏,屏幕中间果然躺着一个系统弹窗——“SmartFirewall would like to access data from other apps”,是 macOS 新增的一道 TCC 授权提示:跨 App Group 容器读取数据(哪怕是自己应用之前写在别处的旧数据)会触发这个确认框。而这段迁移逻辑是内联写在配置加载的同步路径里的,copyItem 会一直阻塞到用户点了按钮为止;这个阻塞又发生在被主线程同步等待的串行队列上——主线程冻结,MenuBarExtra 这个 Scene 永远建不出来,用户连”点一下 Allow”的入口都看不到。
命令行手动跑一遍同样的拷贝,瞬间成功——证明不是文件系统权限的问题,纯粹是”首次跨容器访问会弹一次系统确认框,而这次确认框恰好卡在了一条被主线程同步等待的路径上”。
修法是把迁移这一步从加载路径里摘出来,读配置这个动作重新变成一个纯粹只读、绝不写盘的操作(缺失就用内存里的默认值兜底);迁移和”首次填充默认配置”这两个动作合并成一个独立的入口,由 app 启动后用后台任务触发,绝不让调用方同步等待它。这一步顺手还堵了一个连带问题:如果”迁移”和”配置缺失时写入默认值”这两个动作顺序反了,先落盘默认值的话,后面”目标文件已存在就跳过”的迁移逻辑会直接放弃,用户改过的旧规则就这么被默认值悄悄覆盖、永久丢失——这两个动作必须在同一个执行上下文里、按固定顺序完成。
修完再部署,sample 确认主线程回到正常的事件循环,菜单栏正常出现,主窗口正常渲染。再跑一遍同样的临时规则测试:
2026-07-01 18:26:54|allow|monitorOnly|DEADBEEF-...|80ruleId 对上了、engineDecision 变成了 monitorOnly。再测熔断标志:
panic.flag present, bypassing all filtering日志出现了,修复前这行字从没打印过。两条独立通道都从”从未生效”变成了”实测生效”。
经验总结
几件事值得记下来:
“构建成功 + 单测全绿 + 进程没崩溃”不等于系统真的在工作。 这次重构的 47 个单测全程保持绿色,两次真正的严重 bug——kill switch 失效、config 热更新失效——单测一个都没抓到,因为它们测的是纯函数层面的匹配逻辑,从来没有跨过进程边界。这类 bug 只存在于”两个不同 UID 的真实进程,通过操作系统的真实 API 交换数据”这个具体场景里,唯一能撞见它的方式是真的把两个进程都跑起来,构造一个会产生行为分叉的输入,去读两边真实产生的输出做对比。
修一个 bug 引入的新 bug,往往和原来那个一样隐蔽。 迁移逻辑本身的设计是对的,架构方向也是对的(沿用了已经验证过的共享目录方案),但把一个可能长时间阻塞、依赖用户交互的操作放进了同步启动路径——这是一个纯粹的执行时机问题,编译期看不出来,代码审查大概率也看不出来,只有在真机上重新走一遍完整的冷启动流程才会现形。
多 agent 分工能大幅加速这类重构,但前提是协调者要独立复核,而不是转发汇报。 七个阶段全部由不同 subagent 实现,每一个我都自己重新跑了一遍测试和构建才提交——这个习惯在最后收尾阶段发挥了作用:如果只满足于”subagent 说测试过了、构建成功了”就直接部署收工,这两个 bug 会一直潜伏到用户真正在家和在外切换网络的那一刻才暴露,那时候排查成本会高得多。