
说实话最开始想做 BrewUI是被终端里那棵永远捋不清的依赖树逼的。那时候我的 Mac 上装了上百个 Homebrew 包光 PHP、Node、Redis 这些没有版本概念的旧包就占了大半屏。每次想查某个包到底被谁依赖、哪个包已经成了无人引用的孤儿就得brew list、brew deps、brew info来回串串到最后往往连最初想看什么都忘了。BrewUI 这个名字听起来像是 Homebrew 的官方图形界面其实真不是。它就是给我自己这种天天跟 brew 打交道、又想要更直观信息的人写的一个可视化管理工具。核心思路特别简单不重写 Homebrew 的逻辑只给 brew 命令套一层壳把装包、卸包、升级、看依赖、看日志这些操作变成鼠标点击和窗口切换。后来用下来发现它不仅对像我这样的老用户有帮忙对刚上手 Mac 开发、还不太敢碰命令行的新手更是友好得多。这篇文章我会把 BrewUI 从产品定位、功能拆解、核心代码到实际踩坑完整记录一遍。如果你也打算给自己的命令行工具做个 GUI或者正被一团乱麻的依赖关系折磨这篇应该能给你省下不少几个小时。1. 为什么我要写 BrewUI产品定位与设计取舍1.1 终端里那点小痛点Homebrew 本身已经足够好用命令行效率也高但我在实际使用中攒了一堆“看着难受”的场景包多了以后搜索和归类非常痛苦。brew list输出就是竖着一列名字哪些是主包、哪些是依赖包、哪些已经过时一眼根本看不出来。brew outdated能告诉你有更新可更新之后会牵连哪些包它不管。依赖关系是另一个重灾区。brew deps --tree openssl能打出一棵缩进树但包一多那棵树的宽度能溢出终端而且谁是谁的下游全靠数缩进数到后来脑壳疼。更麻烦的是反向依赖我想卸掉某个包但不确定会不会把别的包带崩终端里得先brew uses --installed去查查出来的结果又要再去brew info一个个翻详情。还有一个很实际的问题新手。团队里新来的同事用 brew 安装环境最怕的就是“不知道装到哪里了”和“不知道装了什么”。你让他敲brew list他看着一堆看不懂的包名会更慌。有一个可视面板至少心理上踏实很多。BrewUI 就是冲着这四个痛点去的列表可视化、依赖关系可视化、操作可视化、日志可视化。它不是要替代终端而是想让你大多数时候不用开终端就能把 brew 伺候好。1.2 边界感BrewUI 不该做什么很多工具做着做着就膨胀了我不希望 BrewUI 走这条路所以一开始就给自己画了几条边界第一不绕开 brew 命令。所有操作最终还是调brew install、brew uninstall、brew upgrade只是帮你拼参数、解析输出、展示状态。好处是风险极低即使 UI 自己崩了brew 本身的状态不会坏。第二不做包管理引擎。有些 GUI 为了“更高效”会绕过 Homebrew 直接改符号链接、改文件路径这非常危险。Homebrew 有自己的锁、目录结构和升级机制一旦绕过一个brew doctor能吐出一车错误。第三不搞万能配置中心。Homebrew 的 tap、HOMEBREW_* 环境变量非常多BrewUI 只暴露最常见的几个开关比如是否显示 Cask、是否启用自动清理。高级参数留着让用户自己在终端里设置不在 UI 里硬塞。边界划清楚之后开发量一下子小了很多。BrewUI 本质上是一个“可视化的命令拼接器 输出解析器”只要把命令调用和解析做好其他东西都没必要重造轮子。1.3 技术方案对比SwiftUI、Electron 和本地 Web做一个面向 macOS 的 Homebrew GUI主流方案就那么几条路方案跨平台内存占用开发速度与系统集成与 brew 交互SwiftUI 原生应用仅 Apple 生态低中等最好用 Process 直接调Electron 应用可跨平台高快一般用 child_process 调本地 Web 服务浏览器访问中快一般后端进程调TUI 终端界面可跨平台低中等差一点直接包装我最后选了 SwiftUI原因有三条。一是内存占用。我本来就希望 BrewUI 常驻后台做包更新监测Electron 那几百 MB 内存我不想背。SwiftUI 一个原生应用空闲时内存能压在几十 MB 以内对开发机来说非常友好。二是开发体验。Swift 的Process类可以直接调用 brew不需要自己起一个 HTTP 服务也不需要管跨域数据流转短调起来很顺。三是界面风格。macOS 上原生 SwiftUI 应用的列表、按钮、通知中心、菜单栏都天然跟系统一致不需要自己费劲去适配暗色模式。当然如果你的团队主栈是前端选 Electron 也不丢人。ts 写界面确实更快生态里也有现成的图表库可以直接画依赖树。但对一个“包管理工具”来说我始终觉得原生是更匹配的方向。2. BrewUI 的功能拆解与数据结构2.1 第一版做了哪 6 个核心模块BrewUI 的第一版我把功能收敛成 6 个模块不追求大而全模块主要功能解决什么问题包列表与搜索展示已安装的 formula 和 cask支持模糊搜索一屏看完所有包包详情页显示版本、依赖、被谁依赖、安装时间、大小快速判断能不能卸操作面板安装、卸载、升级、清理常用操作可视化管理依赖关系图可视化展示 upstream/downstream告别brew deps缩进灾难服务管理启动/停止/重启brew services托管的服务管理 Redis、MySQL 等后台服务日志与控制台展示每次 brew 执行的原始输出出问题时能自己判断这几个模块的优先级是经过考虑的。第一版最核心的功能是“看”也就是列表和依赖图第二优先才是“操作”也就是安装卸载升级。原因很简单看懂了才敢动手动手之前心里有底操作失误才少。2.2 Package 数据模型怎么设计Homebrew 官方提供了一套非常完整的 JSON 输出brew info --jsonv2这是 BrewUI 最稳定的数据来源。运行一次会返回一个包含formulae和casks数组的大 JSON里面字段很多但核心就几个。我简化后的 Swift 模型长这样struct BrewPackage: Identifiable, Decodable { let name: String let fullName: String let desc: String? let versions: Versions let dependencies: [String] let buildDependencies: [String] let requiredBy: [String] let installed: [InstalledVersion]? let cask: CaskInfo? let tapped: String? var id: String { name } enum CodingKeys: String, CodingKey { case name case fullName full_name case desc case versions case dependencies case buildDependencies build_dependencies case requiredBy required_by case installed case cask case tapped } } struct Versions: Decodable { let stable: String? let current: String? } struct InstalledVersion: Decodable { let version: String let installedAsDependency: Bool? let installedOnRequest: Bool? }这份 JSON 里最重要的两个字段是dependencies和required_by它们正好对得上依赖图和反向依赖。installed_as_dependency这个布尔值更有用它直接告诉我某个包是不是被其他包带进来的“附属品”如果是UI 上我会给它打个标签“作为依赖安装”并提示用户卸掉它之前先确认主包还在不在。2.3 界面交互上最容易被忽略的细节做界面不是说把命令行的每个参数搬成输入框就完了有几个交互细节特别值得注意。第一个是“操作状态”。brew 安装是个耗时操作如果用户在安装一个 1GB 的 CaskUI 里那个按钮不能只是转个圈最好能显示当前下载进度或者至少显示一个“正在执行 brew install xxx”的实时日志区域。BrewUI 里我单独开了一个底部日志面板所有命令的原始输出都会实时滚动到这里。第二个是“反向确认”。卸载一个包之前必须弹窗展示required_by告诉用户“这个包被 Git、curl、nginx 依赖确认卸载可能会影响它们”。如果required_by是空才允许直接卸载。这个机制能挡住绝大多数手滑操作。第三个是“空状态处理”。没有安装任何包、搜索不到结果、依赖图没有数据这些情况都要有明确的空状态文案不能白屏。别小看这个第一版我偷懒搜索无结果直接显示空白表格后来被一个测试用户吐槽“以为程序崩了”才补上空态提示。3. BrewUI 核心实现解析 JSON、执行命令、画依赖树3.1 读懂 brew 的 JSON v2并映射成 Swift 模型BrewUI 最主要的数据源是这个命令brew info --jsonv2 --installed--jsonv2意味着 Homebrew 会输出两个顶层数组formulae 和 casks。所有已通过 brew 安装的命令行工具和图形应用都能拿到。Cask 的字段和公式略有些不同比如 cask 有appcast、artifacts等字段但核心的name、version、installed结构是类似的映射的时候我用CaskInfo单独存多出来的部分。解析 JSON 的时候有个坑installed字段在几种情况下内容是不同结构的。正常是数组某一段数据里会有version和installed_as_dependency但如果你本机只装了旧版本versions.current可能不是一个字符串而是一个小字符串有点反直觉。所以我的模型里current用了String?并且用decodeIfPresent处理避免版本格式差异直接让整个解码抛错。实际开发时我并不是一次性解析整个 JSON。BrewUI 启动时会把原始 JSON 缓存一份到本地结果用 Swift 原生JSONSerialization先过一遍把 formula 和 cask 分开再逐条解码。这样做的好处是哪怕某一条数据格式出了问题也能跳过它不至于整个列表加载失败。3.2 封装一套可以边跑边看日志的 Brew 执行器Swift 调外部命令用的是Process但直接用它有一些痛点。最典型的是如果不及时读取 stdout 管道缓冲区满了子进程会被阻塞然后你的 UI 就“卡住”了。所以必须异步读输出。我封装了一个BrewRunner核心逻辑是这样final class BrewRunner { static let shared BrewRunner() private let executionQueue DispatchQueue(label: brew.execution) func run(_ args: [String], outputHandler: escaping (String) - Void) async throws - String { try await withCheckedThrowingContinuation { continuation in executionQueue.async { let process Process() let homebrewPrefix Self.brewPrefix() process.executableURL URL(fileURLWithPath: \(homebrewPrefix)/bin/brew) process.arguments args let stdout Pipe() let stderr Pipe() process.standardOutput stdout process.standardError stderr stdout.fileHandleForReading.readabilityHandler { handler in let data handler.availableData if let str String(data: data, encoding: .utf8), !str.isEmpty { outputHandler(str) } } stderr.fileHandleForReading.readabilityHandler { handler in let data handler.availableData if let str String(data: data, encoding: .utf8), !str.isEmpty { outputHandler([stderr] \(str)) } } process.terminationHandler { proc in // 清理 handler stdout.fileHandleForReading.readabilityHandler nil stderr.fileHandleForReading.readabilityHandler nil if proc.terminationStatus 0 { continuation.resume(returning: ) } else { continuation.resume(throwing: BrewRunnerError.exit(code: proc.terminationStatus)) } } do { try process.run() } catch { continuation.resume(throwing: error) } } } } private static func brewPrefix() - String { // 先探测 /opt/homebrew再探测 /usr/local也可以执行 brew --prefix if FileManager.default.fileExists(atPath: /opt/homebrew/bin/brew) { return /opt/homebrew } return /usr/local } }这段代码看上去简单实际使用中为我节省了大量时间。UI 层只需要调用BrewRunner.shared.run([list, --versions])就能拿到完整输出同时日志面板还能实时刷新。所有 brew 命令都走同一个串行队列这又避开了 brew 并发执行时的各种锁问题。3.3 安装、卸载、升级、清理四条链路怎么做操作功能的实现本质就是在界面按钮和 brew 命令之间做映射。安装一个包需要先区分是 formula 还是 cask。用户如果输入了“google-chrome”应该走brew install --cask google-chrome而输入“nginx”就走brew install nginx。BrewUI 的搜索框会同时搜索 formula 和 cask所以操作面板上我放了一个类型切换开关用--cask或者普通参数来控制。卸载时要注意一个点brew 默认会干掉那些“只被你卸载的主包依赖”吗不会。它只会解除目标包其他依赖关系交给brew autoremove处理。所以我在 UI 上做了两层判断如果这个包是被依赖的弹窗提示如果用户确认卸载卸载完成后我会追加调用brew autoremove把变成孤儿的那批依赖一起清掉省得越堆越多。升级链路有两个粒度只升级某个包以及全量升级。单个升级就是brew upgrade formula名全量升级是brew upgrade。全量升级耗时很长控制台必须能实时滚动并且要告诉用户当前更新到哪个包了。我解析输出里类似 Upgrading xxx的行把它单独显示在进度区的标题栏比单纯输出一大坨日志要清楚得多。清理链路是很多人容易忽略的。brew 的旧版本并不会自动删brew cleanup才能删掉那些旧版本和缓存。BrewUI 的操作面板里我把“清理”按钮做成了两步先执行brew cleanup -n做预览把“将要清理哪些文件、释放多少空间”显示成一个列表用户确认后再执行brew cleanup。有了 preview 这一步就不用担心手滑把不该删的缓存清掉。3.4 依赖关系树从文本缩进到可视化图形依赖图是 BrewUI 最花心思的功能。终端里brew deps --tree nginx输出大概是这样的nginx ├── openssl3 │ ├── ca-certificates │ └── pcre2 ├── pcre2 └── zlib这个树可以看但一长就很难交互浏览器里也看不到。BrewUI 的做法是直接用 JSON 的dependencies字段重建图然后用 SwiftUI 的Canvas绘制节点和连线。第一步是拿数据。我从brew info --jsonv2里读取每个包的 dependencies 和 required_by构建一张邻接表结构。比如全局搜索按钮触发时先从用户选中的包出发沿着 dependencies 做一次深度优先遍历收集所有上游节点再反过来收集下游节点。第二步是布局。SwiftUI 的 Canvas 布局我采用了最简单的分层布局根节点放最左边第一层依赖放中间列第二层依赖放最右边。节点位置是一个二维坐标计算好之后画圆角矩形和贝塞尔曲线线用中间层曲线的样式避免太多交叉。第三步是交互。节点点击之后右侧详情面板会同步切换。为了不把画布搞得太大我加了折叠功能每个节点右上角有一个加号/减号按钮展开下一层依赖收起就把下游隐藏起来。这是最重要的优化否则画布会变成一坨蜘蛛网。依赖图上我还会用颜色区分节点状态绿色表示已安装橙色表示可更新灰色表示未安装只作为依赖被需要。颜色规则和列表页保持完全一致视觉记忆上更统一。4. 实机使用中踩过的坑与排查方法4.1 brew 全局锁导致 UI 卡死BrewUI 第一版上线之后我第一个遇到的大问题是“安装按钮点完整个应用像死机了一样”。查了半天发现不是 UI 卡死而是 brew 自己上了全局锁。Homebrew 在设计上不允许同时跑两个进程当一个 brew 进程正在执行另一个进程就会等待日志里会输出一行Waiting for another brew process...我的 UI 因为多个按钮各自触发了 brew 命令比如列表刷新和安装操作几乎同时发起它们挤在同一个 brew 锁上后面的命令全部阻塞。解决办法有两个。所有 brew 命令走BrewRunner的同一个串行队列从机制上保证同一时刻只有一个 brew 进程在跑。另一个是 UI 层加状态锁有 brew 操作正在执行时操作区域的按钮全部置灰并显示当前正在执行的内容。这里给后来者一个忠告不要试图用并发来提高 brew GUI 的响应速度brew 这个工具本身是单飞架构brew install期间刷新列表是典型的“看似优雅、实则堵死”的做法。4.2 Intel Mac 和 Apple Silicon 的路径差异Homebrew 的安装路径非常讲究Intel Mac 上默认装到/usr/localApple Silicon 上默认装到/opt/homebrewLinux 上又可能是/home/linuxbrew。我之前在代码里硬编码了/opt/homebrew/bin/brew结果在老的 Intel Mac 上一跑就找不到命令。之后我改成了探测策略先看/opt/homebrew/bin/brew是否存在如果不存在就看/usr/local/bin/brew再不行就执行which brew和brew --prefix拿到前缀。对用户的提示也有讲究如果两个路径都没检测到说明本机根本没装 HomebrewUI 要给一个引导按钮而不是直接报错“无法启动”。另外一个坑是 PATH 环境变量。GUI 应用通过 Process 启动外部命令时有时候环境变量和终端里不一样。如果用户是通过某种方式自定义了 Homebrew 前缀比如装在非标位置光靠标准路径探测不到。所以我提供了“手动指定 brew 路径”的设置项并且把它放到了首个配置页这个选项对高级用户非常关键。4.3 JSON 版本差异导致解析崩溃brew 官方 JSON 输出格式并不是完全稳定。比如早期版本的brew info --jsonv2返回的字段叫installed更整齐某些版本里会有installed_on_request某些插件版可能没有。如果 Swift 的Decodable模型把字段写死成必选遇到缺失就直接 decode 失败整个列表就刷不出来。我改成两个策略。所有可扩展的字段尽量都用decodeIfPresent能缺失就缺失。在解析前先用JSONSerialization例行检查顶层结构如果公式和 cask 数组都在再走强类型解码如果某个单独包的格式不达标捕获错误并跳过它绝不能因为一条坏数据影响全部列表。这里分享一个调试技巧为了方便分析我在 BrewUI 的“数据来源”页放了一个“导出原始 JSON”的按钮。每次解析出了问题我能拿到用户导出回来的 JSON直接对比字段差异不用靠猜。这个按钮帮我在处理用户反馈时省了八成的沟通成本。4.4 Cask 下载失败与日志导出Cask 类型安装的失败率比 Formula 高不少。因为 cask 的安装本质是下载一个图形应用程序的安装包网络波动、CDN 访问慢、sha256 校验不通过都可能失败。最常见的现象是BrewUI 显示Error: Checksum mismatch安装流程退到起始状态。终端里可以直接看到日志GUI 里如果没有日志面板用户就很容易觉得是程序坏了。所以我给日志面板做了两件事持续将 brew 的输出追加到界面上并且按时间戳保存到本地文件。面板右上角有一个“导出日志”按钮点一下就能生成一个带全部上下文的.log文件用户可以直接发给别人排查。另外Cask 下载失败后我默认会再执行一次brew cleanup --cask把残留的下载缓存清掉。如果某个 cask 反复下载失败我就提示用户可以切换到镜像源或者手动下载安装包。在 BrewUI 上我不做镜像源的全局默认调整因为那会影响网络环境差异很大的不同用户只提供“手动操作指引”入口。还有一个 Cask 特有的小坑有些 GUI 应用卸载时并不会自动删除它写入到~/Library/Application Support下的配置。BrewUI 会在卸载提示框里额外提醒用户并在卸载完成后给出需要清理的文件路径列表。这个功能不接管执行只提供信息避免误删数据。4.5 启动速度和内存占用优化BrewUI 刚做完第一版时启动速度很感人——每次启动都要跑一次brew info --jsonv2 --installed这个命令要扫描所有已安装包的元数据冷启动耗时最短也要几秒机器慢一点甚至要十几秒。用户每次打开应用都得对着一个空白列表干等。我的优化方案是增加本地缓存。应用启动后先在主线程瞬间加载上一次缓存好的 JSON 文件渲染出界面然后再到后台线程重新执行brew info --jsonv2 --installed拿到新数据后比对差异增量刷新列表。这样用户几乎感觉不到加载过程看到的只是数据在某个时刻自动更新了。缓存文件的位置我放在了~/Library/Application Support/BrewUI/cache.json每次刷新成功就把完整 JSON 写入。为了不让缓存文件越滚越大我限制最多保留 20MB超过就只保留 formula 的基础信息不保留完整的 description 长文本。内存占用这一块列表页用了LazyVStack按需加载。正常情况下 BrewUI 的内存占用稳定在 60MB 左右比起动辄几百 MB 的 Electron 版本要舒服太多。如果你用 SwiftUI 做长列表记住别用ForEach包一个数据量巨大的VStack不然滚动起来会非常卡。做这个小工具半年后我最大的体会BrewUI 做到现在我发现自己最初对“依赖树可视化”的执念是对的但更值钱的反而是那些看似不起眼的功能实时日志、缓存刷新、卸载前的反向依赖提示。如果一个包管理 UI 只给你看一张漂亮的树图却不告诉你操作之后会产生什么后果那它本质上还是个“骗点击”的玩具。真正能在工作里帮上忙的工具都是把“知道我在干什么”和“知道我做了什么”这两件事做透的工具。终端里跑brew的人人人都知道自己的行为是什么但 GUI 用户不一定。所以后来每次给 BrewUI 加新功能我都先问一句这个功能能不能让用户更清楚自己在干什么如果只是多一个动画、多一个图表那我宁可继续打磨日志输出和搜索过滤。如果你也想做一个类似的 Homebrew 图形界面或者别的命令行工具封装层我建议你按这个顺序来先把列表和数据模型做稳再做依赖关系可视化最后才考虑安装卸载升级。操作功能最要有但千万别放最前面因为在你把信息展示清楚之前所有操作按钮都是危险按钮。这个道理不光适用于 brew也适用于几乎所有“给高手用的效率工具”。