说实话,最开始想做 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 --json=v2,这是 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 的 Cask,UI 里那个按钮不能只是转个圈,最好能显示当前下载进度或者至少显示一个“正在执行 brew install xxx”的实时日志区域。BrewUI 里我单独开了一个底部日志面板,所有命令的原始输出都会实时滚动到这里。
第二个是“反向确认”。卸载一个包之前,必须弹窗展示required_by,告诉用户“这个包被 Git、curl、nginx 依赖,确认卸载可能会影响它们”。如果required_by是空,才允许直接卸载。这个机制能挡住绝大多数手滑操作。
第三个是“空状态处理”。没有安装任何包、搜索不到结果、依赖图没有数据,这些情况都要有明确的空状态文案,不能白屏。别小看这个,第一版我偷懒,搜索无结果直接显示空白表格,后来被一个测试用户吐槽“以为程序崩了”,才补上空态提示。
3. BrewUI 核心实现:解析 JSON、执行命令、画依赖树
3.1 读懂 brew 的 JSON v2,并映射成 Swift 模型
BrewUI 最主要的数据源是这个命令:
brew info --json=v2 --installed--json=v2意味着 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 ├── openssl@3 │ ├── ca-certificates │ └── pcre2 ├── pcre2 └── zlib这个树可以看,但一长就很难交互,浏览器里也看不到。BrewUI 的做法是:直接用 JSON 的dependencies字段重建图,然后用 SwiftUI 的Canvas绘制节点和连线。
第一步是拿数据。我从brew info --json=v2里读取每个包的 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/local,Apple Silicon 上默认装到/opt/homebrew,Linux 上又可能是/home/linuxbrew。我之前在代码里硬编码了/opt/homebrew/bin/brew,结果在老的 Intel Mac 上一跑就找不到命令。
之后我改成了探测策略:先看/opt/homebrew/bin/brew是否存在,如果不存在就看/usr/local/bin/brew,再不行就执行which brew和brew --prefix拿到前缀。对用户的提示也有讲究:如果两个路径都没检测到,说明本机根本没装 Homebrew,UI 要给一个引导按钮,而不是直接报错“无法启动”。
另外一个坑是 PATH 环境变量。GUI 应用通过 Process 启动外部命令时,有时候环境变量和终端里不一样。如果用户是通过某种方式自定义了 Homebrew 前缀,比如装在非标位置,光靠标准路径探测不到。所以我提供了“手动指定 brew 路径”的设置项,并且把它放到了首个配置页,这个选项对高级用户非常关键。
4.3 JSON 版本差异导致解析崩溃
brew 官方 JSON 输出格式并不是完全稳定。比如早期版本的brew info --json=v2返回的字段叫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 --json=v2 --installed,这个命令要扫描所有已安装包的元数据,冷启动耗时最短也要几秒,机器慢一点甚至要十几秒。用户每次打开应用都得对着一个空白列表干等。
我的优化方案是增加本地缓存。应用启动后,先在主线程瞬间加载上一次缓存好的 JSON 文件,渲染出界面,然后再到后台线程重新执行brew info --json=v2 --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,也适用于几乎所有“给高手用的效率工具”。