BrewUI:不想再盯着命令行输出之后,我给自己写了个 Homebrew 可视化客户端
"brew upgrade" 这行命令我敲了快五年,直到某个周五下午升级十几个依赖时猛然意识到:命令行固然高效,但面对一屏密密麻麻的旧版本清单、依赖树和磁盘占用数据时,我需要的不只是执行能力,还有一眼看懂全局的视图。于是有了 BrewUI——一个用 SwiftUI 写的 Homebrew 图形界面客户端。这个项目解决的核心问题很简单:把 brew 的公式、依赖、升级、清理这些高频操作,全都摆到一个可视化的桌面上,让不习惯啃命令行输出的人也能当 Homebrew 的"驾驶员"。
如果你是 macOS 上的开发者,日常依赖 Homebrew 管理软件却又对终端心生抗拒;或者你是 SwiftUI 学习者,想知道怎么用原生框架把外部命令行工具包装成一个完整产品;又或者你只是好奇"给包管理器做 GUI"这件事到底有没有意义——这篇文章都值得你读完。我会从需求盘点讲到技术选型,再到具体模块的落地和一些调试现场的还原,尽量把整个项目的来龙去脉讲透。
1. 为什么用了一年命令行之后,我决定让 brew 长出一张"脸"
1.1 Homebrew 本身并不是为"给人看"设计的
先捋清楚一件事:Homebrew 是一个包管理器,核心工具形态是终端里的命令行程序。它做得非常出色,但它的"出色"体现在命令行交互上:输出短小精悍、参数高度灵活、脚本化能力极强。可如果我们退一步,用普通用户(或者刚刚接触开发工具的用户)的视角去看,brew list、brew outdated、brew deps --tree这一串命令输出的那一大坨文本,信息量很大,可读性和可操作性是另一回事。
举几个实际场景。某个依赖库悄悄升级了大版本,连带拉起了七八个传递依赖,你根本没注意到;升级到一半发现某个 formula 和另一个 formula 冲突,终端里刷过几十行报错,你压根没看清是谁和谁打架;磁盘告急,想清理 Homebrew 缓存,得先回忆命令、再查参数。这些问题归根结底不是 Homebrew 做不到,而是它把所有逻辑都压缩进了"文本+命令"这个界面里。
1.2 真实的痛点:升级风暴、依赖纠缠、磁盘黑洞
我刚产生做 BrewUI 的念头,是因为一次典型的"升级灾难"。某天我执行brew upgrade,升级了四十多个包,过程中有一个 formula 因为新版依赖了一个 macOS 15 才有的系统 API,导致链接失败。终端输出很长,但我真正需要的信息——"到底是哪个包依赖了这个坏包?还有没有其他包和它冲突?"——被淹没在日志里了。我后来花了一个多小时手动梳理,才拼出完整的依赖关系。
还有一个问题:Homebrew 在升级时会默认先更新仓库索引,也就是brew update。如果你很久没手动执行过,一次upgrade可能花上十几分钟。终端里看不到进度条、看不到当前正在做哪一步,只能盯着光标干等。那时候我就想,如果有一个界面,能像 macOS 的软件更新面板一样,提前展示"有哪些包需要升级""这些包分别占多少空间""依赖链是什么样的",那该多好。
1.3 为什么不直接继续装第三方工具
其实当时市面上也不是完全没有替代品。曾经有一些开源 GUI 客户端,但长期维护的很少,功能大多停留在"列出已安装列表 + 一键升级"的层面。我想要的依赖可视化、磁盘占用分析、操作队列和实时日志面板,几乎没有现成的。另一个选择是用 Homebrew 自己的 Web 界面或 API,但官方定位是给 CI 和脚本用的,交互体验不是它的重点。
与其等一个不存在的东西,不如自己动手。反正平时搞开发本来就要消耗工具,做一个符合自己使用习惯的客户端,既能解决实际问题,也能顺带把 SwiftUI 练得更熟。于是 BrewUI 这个项目就这样启动了。
2. 需求盘点:哪些功能最快被看见,哪些功能我故意不做
2.1 核心功能与实现优先级
在画第一版界面之前,我先列了一张需求表,把"日常使用 Homebrew 时最高频、最耗时的动作"拆成四个模块,按"投资收益比"排序。
| 功能模块 | 具体内容 | 优先级 | 实现难度 |
|---|---|---|---|
| 包总览与搜索 | 列出已安装公式/应用,支持关键搜索,展示版本信息 | P0 | 中 |
| 升级操作 | 查看过期包、一键升级指定包或全部升级 | P0 | 低 |
| 信息详情 | 显示依赖、反向依赖、安装路径、体积 | P1 | 中 |
| 清理维护 | 清理缓存、删除旧版本、自动诊断 | P2 | 低 |
这四块的完成顺序也很明确:先做 P0 的"看和动",让 BrewUI 第一天就能替代 80% 我手动敲命令的场景;再把 P1 的依赖可视化补上,这是命令行体验最差的环节;最后用 P2 的清理功能收尾,主要是为了让新用户觉得"界面不只是换个皮肤,而是真的更省事"。
2.2 那些我故意不加的功能
做工具最忌讳的就是功能堆砌。我一开始就给自己定了一条边界:BrewUI 永远不打算做 brew 命令的完整等价物。
原因很实际:Homebrew 的--verbose、--dry-run、环境变量控制、CI 集成等高级能力,本来就是给脚本化场景设计的,硬塞进 GUI 只会让界面变得不可维护。而且一旦用户形成了"GUI 什么都能做"的预期,遇到复杂场景反而会卡在界面上,不如直接回到终端。所以 BrewUI 的定位从第一天就是高频操作的加速器,不是命令行的替代品。
2.3 用户视角的设计原则
在功能之外,我还定了三条交互设计原则,直接影响后面的所有界面:
- 一切操作必须可观测:启动任何安装、卸载、升级任务时,用户要能看到当前进度、当前日志,不能出现"点了按钮,界面却毫无反应"的窗口期。
- 一切状态必须可刷新:列表数据缓存得再好,也得有手动刷新和自动刷新机制,因为 Homebrew 仓库和本地状态随时可能发生变化。
- 一切危险操作必须可撤销或明确警告:卸载和清理不能做成一个扁平的"确认"按钮,要明确展示将影响哪些包、释放多少空间,然后再次确认。
这三条原则在后续开发中帮我挡掉了不少"想当然"的交互设计。
3. 技术选型与架构设计:SwiftUI 当外壳,brew 当引擎
3.1 为什么是 SwiftUI,而不是 Electron、TUI 或者别的
其实立项时我认真比划过几种方案。最省事的是拿 Electron 包一层壳,前端画界面,后端 child_process 调用 brew。优点是 Web 技术生态丰富、界面能做得花哨;缺点是包体积轻轻松松上百 MB,内存占用随随便便几百 MB,对一个只管 brew 的小工具来说太浪费了。而且 Electron 在 macOS 上的原生感始终差一截,圆角、毛玻璃、系统级字体渲染都要额外适配。
还有一条路是 TUI(终端用户界面)。比如用 Python 的 Rich 或 Go 的 Bubble Tea 写一个交互式终端面板,支持方向键选择和回车执行。这种方式对开发者来说确实"够酷",但它的使用门槛要求你先打开终端,这对目标用户(包括不熟悉命令行的设计师、产品经理)来说还是不够友好。
最终我选了 SwiftUI + AppKit 混编的方案。SwiftUI 负责绝大多数界面,Process 调用 brew 命令并读取输出,数据模型用 Codable 解析 brew 的 JSON 输出。整个应用编译完接近原生速度,冷启动不到一秒,内存占用控制在几十 MB 以内,最后的 .app 体积也只有几 MB。这个性价比,Electron 没办法比。
3.2 核心架构:一个永不阻塞主线程的桥接层
brew 命令执行少则几百毫秒,多则十几分钟。如果直接在界面主线程里跑 Process,用户一操作界面就会转菊花、无响应,所以异步处理是整个架构的第一原则。
我把整个数据流设计成三层:
- UI 层:SwiftUI 视图,只负责展示状态和转发用户意图。
- 服务层:
BrewService,封装所有 brew 命令,统一走async/await,返回结构化的模型数据。 - 数据层:
BrewCommandRunner,负责创建Process、解析 stdout/stderr、处理退出码,并把日志流通过闭包或AsyncStream嗒递给上层。
这样的分层带来一个好处:以后如果想支持别的包管理器(比如作为扩展),只需要替换中间层的实现,UI 和数据模型不用大改。下面是服务层一个典型的函数签名:
/// 列出所有已安装的 formula func listFormulae() async throws -> [Formula] { let output = try await runner.run("list", "--formula", "--json=v2") let payload = try JSONDecoder().decode(BrewListPayload.self, from: Data(output.utf8)) return payload.formulae }BrewCommandRunner内部的核心逻辑说白了就是封装Process:设置可执行路径为/opt/homebrew/bin/brew(Apple Silicon 默认路径,Intel 上是/usr/local/bin/brew),把参数拼成字符串数组,用Pipe接管输出和错误流,最后用waitUntilExit等待结果。关键在于所有调用都发生在后台线程,UI 层只接收最终结果。
SwiftUI 侧调用时,我会把任务包进Task或.task修饰符里,用@MainActor保证 UI 更新在主线程进行。这样哪怕brew update跑了五分钟,界面依然可以响应暂停、取消或切换到其他页面。
3.3 数据模型:依赖 JSON 输出的 Codable 方案
Homebrew 自带 JSON 输出能力,brew info --json=v2会把 formula 的版本、依赖、路径、体积等元数据全部输出成结构化数据。这给对外开发提供了很大的便利,BrewUI 的数据模型就建立在它之上。
我用Codable定义了一组和 JSON 字段一一对应的模型,截取关键的几个字段:
struct Formula: Codable, Identifiable { let name: String let versions: Versions let dependencies: [String] // 直接依赖 let installedVersions: [String]? let installedSize: Int? // 安装体积,单位 bytes let desc: String? var id: String { name } struct Versions: Codable { let stable: String? let current: String? } }JSON 里字段名和 Swift 属性名不一致的地方,我直接用CodingKeys映射,比如installedVersions对应 JSON 里的installed_versions。解析流程本身不太复杂,但有几个坑后面再说。
4. 关键模块落地:从包列表到操作队列,再到实时日志
4.1 包列表与状态刷新
界面第一版就是一顿标准的 SwiftUI 三件套:左侧边栏是分类导航,中间是主列表,右侧是详情面板。列表的数据源是已安装的 formula,每次刷新时重新调用brew list --formula --json=v2,解析后存入视图模型的@Published数组。
刷新逻辑上我做了两个策略:手动下拉刷新,以及应用启动时自动刷新。最开始想过用Timer做定时轮询,后来觉得对单机工具来说没有意义——本地包列表不会在没人操作时自己变化,与其浪费资源轮询,不如把刷新动作和用户操作绑定在一起。每次执行完升级或安装操作后,也会自动触发一次列表刷新,保证 UI 状态和实际系统状态一致。
列表项本身信息密度很高,一行里同时显示:包名、当前版本、是否过期、安装体积、是否有更新等待。这样用户在列表页就能做初步判断,不需要点进详情才知道"它是不是最新的"。
4.2 操作队列:为什么我放弃并发执行
用户勾选多个包,点击批量升级。第一反应会想到"并发执行,每个包一个任务,提升效率"。我最早也这么实现,直到第一次测试时它自己把自己的 brew 锁堵死了。
Homebrew 自身是有锁机制的,同一时间只能有一个 brew 实例修改本地状态。多个brew upgrade进程同时跑,其中几个会一直等待锁释放,等待超时后直接报错退出。更麻烦的是,Homebrew 的锁是文件锁,分散在几个路径下,并发冲突时经常出现"一个进程已经拿到锁,另一个进程却不知道锁是谁持有"的诡异状态。
所以 BrewUI 最终把所有写操作收敛到一条串行执行队列里,用Actor来保证互斥:
actor BrewOperationQueue { private var isRunning = false func enqueue(_ operation: @escaping () async throws -> Void) async throws { // 用简单的状态标记保证同一时刻只有一个任务在执行 while isRunning { try await Task.sleep(nanoseconds: 300_000_000) } isRunning = true defer { isRunning = false } try await operation() } }测试下来,串行队列虽然会让批量升级的总耗时变长一点,但稳定性和日志可读性都大幅提高。界面上的"当前任务"区域也会实时显示"正在升级:xxx(2/15)",用户可以看到进展,不会觉得卡死了。
4.3 实时日志流:把 brew 的 stderr 转成界面上的滚动文字
brew 的输出并不规范,平时升级时看到的中途信息大多走 stderr。要在界面上做一个"实时日志面板",就不能等命令跑完再一次性读取输出,必须边执行边把新产生的行推送到 UI。
具体实现是在BrewCommandRunner里用一个AsyncThrowingStream<String, Error>,把从Pipe.fileHandleForReading读到的数据按行拆好,再逐条往外吐。
func runStreaming( _ arguments: [String] ) -> AsyncThrowingStream<String, Error> { AsyncThrowingStream { continuation in let process = Process() process.executableURL = URL(fileURLWithPath: brewPath) process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe // 合并两个流,保证时序正确 pipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { continuation.finish() return } let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? "" continuation.yield(text) } process.terminationHandler = { _ in continuation.finish() } do { try process.run() } catch { continuation.finish(throwing: error) } } }界面上用一个ScrollView加Text渲染日志。这里有个很影响体验的细节:日志滚动必须锚定到底部,否则用户看到的是逐行往上顶出的旧内容,像倒着读日志。实现方式是在新日志到达时判断用户当前是否在底部,如果在底部才自动滚动,否则保持用户当前的阅读位置。
5. 调试与排坑:三个让我差点弃坑的问题
5.1 第一次点击"升级全部"时卡死的教训
第一版 BrewUI 做好后,我自己试用,满怀期待地选中所有过期包,点击"升级全部",然后应用就——不转了。整个窗口变成一个沙滩球,鼠标点击毫无反应。任务管理器里显示 brew 进程还在跑,但 UI 已经死了。
排查链路是这样的:我先怀疑是Process阻塞了主线程。检查代码后发现,问题出在waitUntilExit放在了主线程上直接调用。SwiftUI 的按钮 action 默认跑在主线程,如果我在里面同步等待一个十几分钟的命令完成,主线程就会被彻底卡住。
修复方案是:把所有waitUntilExit操作整体搬到后台任务里。我用Task.detached包住整个命令执行流程,再把最终结果通过@MainActor抛回 UI。这一步做完后,点击升级整个操作期间 UI 依然可以切换页面、点导航键。
5.2 并发任务把 Homebrew 锁死后的复盘
刚才提到并发升级会锁死,但那个问题还包含一个更隐蔽的细节:即使我改成了串行队列,依然会遇到偶尔的锁冲突。后来发现,我虽然串行执行了自己的写操作,但用户可能同时在终端里手动跑brew install,或者某次操作触发了 Homebrew 的自动更新。
Homebrew 有一个HOMEBREW_*环境变量体系可以控制行为,比如HOMEBREW_NO_AUTO_UPDATE=1可以禁止命令启动前自动更新仓库。我在 BrewUI 的Process里主动设置了这一项,把所有命令的启动开销都标准化了。同时,在界面上加了一个"等待锁释放"的状态提示,告诉用户"终端里有另一个 brew 正在运行,请稍候或关闭终端进程",比干等要好得多。
5.3 路径参数里的空格和反斜杠
macOS 上的用户名有时候带空格(比如 "John Doe"),这会导致 brew 的安装路径/Users/John Doe/homebrew/...在拼命令参数时被截断。命令行工具处理空格有一套自己的规则,但用Process执行的是"参数数组"而不是"拼接后的字符串",所以理论上不会出现空格截断的问题。
但保险起见,我还是写了防御性代码:所有从外部传入的路径和包名,在执行前统一做一层校验,拒绝包含换行符、&&、分号等敏感字符的输入。虽然 brew 命令不会直接触发 shell 解释,但养成这个习惯,将来要换成 shell 执行方式时就不会埋雷。
6. 实测体验:BrewUI 进入日常使用后的真实表现
6.1 使用两周后的体感
把 BrewUI 装进开发主力机连续用了两周之后,有一些体感是写代码时没预料到的。首先是"升级前的确认"价值最高。以前brew upgrade是黑盒操作,现在升级前能看到每个包的版本变化、体积大小和依赖链,我可以判断"这次升级值不值得",某些包我会主动排除在批量升级之外,因为它们版本跳动太大或者维护者经常发布破坏性变更。
其次是磁盘占用可视化。BrewUI 在列表页直接显示每个包装了多大,我清理了几个几乎不用又特别占空间的 formula(加起来释放了将近 2GB),这在以前要我逐个brew list --size去查才能做到。
6.2 持续改进的方向:如果接着往下做
目前的 BrewUI 已经覆盖了我最初预设的全部功能,但继续打磨的话有几个方向想尝试:把brew bundle的工作流做成可视化的"配置文件编辑器",让用户可以通过点选来维护多台机器上的依赖清单;增加 graph 视图,用图形化方式展示包与包之间的依赖关系;以及把卸载应用而不是公式的 Cask 管理也做进主面板。
如果你也打算给某个命令行工具做图形界面,我给的建议是:先把高频操作做好,再考虑高级功能;先让用户能看懂,再让他觉得好看。工具类产品的核心价值永远是场景效率,界面是放大效率的载体,不是目的本身。
说实话,做完 BrewUI 之后,我最大的收获不是"以后不用敲终端命令了",而是清楚地体会到:任何命令行工具都有它的默认用户,而这些默认用户之外还站着一大群"想要使用但不想进入命令行世界"的人。一个界面能做的,不只是换一种交互方式,而是把工具的使用门槛降低,让更多人能安全地操作那些"看起来很高级的东西"。如果你也有类似的需求,不妨从今天开始,给你天天用的某个命令行工具画一张脸,说不定会打开一个完全不同的视角。