先说明一下,BrewUI 这个名字在开源社区里撞过几次车:有做咖啡冲煮记录的,有做啤酒发酵罐控制界面的,我这次要聊的是开发者在日常工作中更常遇到的“Homebrew 图形化管理工具”。起因很简单——我每天要处理大量依赖安装,终端里brew upgrade一跑就是几百行输出,信息全都有,但看起来特别累;装一个图形应用还得记--cask,清理旧版本又怕误删。于是干脆做了个桌面端,把 brew 的关键操作变成可点击的界面。BrewUI 的定位就是:不替代命令行,而是给每天都要碰 brew 的人一个更直观的驾驶舱。
它能做什么?一句话概括:把 brew 的搜索、安装、卸载、更新、清理、服务管理,全部变成图标、表格和按钮,同时保留日志输出。适合谁用?第一类是刚接触 macOS 开发环境的新人,不需要背brew search、brew info这些命令;第二类是自己维护多台机器、希望一眼看清哪些包版本落后的人;第三类是想在团队内降低工具使用门槛的运维或基础设施同学。
1. BrewUI 是什么:一个可点击的 Homebrew 驾驶舱
1.1 终端里信息太密,缺的不是功能而是呈现
Homebrew 本身的功能一点都不弱,真正麻烦的是输出结果对人不友好。brew list只会吐出一串包名,brew outdated也只是一行行“包名 旧版本 -> 新版本”,看几行还好,几十上百个包同时更新的时候,人眼很难快速判断哪些是主版本升级、哪些只是补丁。尤其是 mac 上新旧架构切换,Intel 和 Apple Silicon 用的 Homebrew 路径不同,命令行处理不好还会装错架构的包。
BrewUI 把这些问题收敛到界面里:包名列表、当前版本、最新版本、安装时间、是否被依赖,每一项都对应一列,可以排序、筛选、搜索。版本升级这类操作也不再是无差别的全量执行,而是逐行展示差异,用户勾选后再批量处理。说白了,它不是把命令行藏起来,而是把命令行输出重新做了一遍信息架构。
1.2 功能边界:不碰 brew 做不到的事
工具类项目最容易犯的毛病是“什么都想做”。BrewUI 在最初设计时就划了一条边界:凡是 brew 本身不支持的逻辑,绝不用 hack 方式硬塞进 UI。比如 brew 没有官方 API 去判断某个包是不是某服务的依赖,那界面就不做这种推测性的关系图;某个brew services命令需要手动确认,UI 就弹出确认框而不是偷偷模拟回车。
这个边界帮了大忙。它避免了很多因为 Homebrew 小版本更新导致的兼容性崩溃——底层命令变了,UI 只需要跟着适配输出格式,而不是重写业务判断。对技术方案来说,这是一条很重要的稳定性原则。
2. 技术选型与架构思路
2.1 外壳用 Electron 还是 Tauri
BrewUI 第一版用的 Electron + React + TypeScript。理由很直接:团队对前端技术栈最熟,Electron 的生态最成熟,任何想参与贡献的人上手成本低。Electron 确实被吐槽安装包体积大、内存占用高,但放在一个管理类工具上,体验问题没那么致命。
Tauri 的优势也很明显,二进制体积小、内存占用低,但它需要引入 Rust 壳层,一些系统命令的执行、进程管理要写 Rust 代码,这对纯前端背景的维护者是个门槛。我的建议是:如果你只是自己用,Tauri 值得折腾;如果你想做成一个社区项目、希望更多人能提交代码,Electron 是更稳的起点。BrewUI 先跑通完整流程,后续再评估是否用 Tauri 重写外壳。
2.2 核心架构:命令行适配器
BrewUI 最核心的设计不是界面,而是隐藏在界面之下的“命令行适配器”。所谓适配器,就是所有 brew 操作都收口到一个模块里,前端不允许直接拼接命令字符串,而是统一调用适配器提供的方法。
这样做的好处很直接:
- 安全:用户输入的关键字会被当成参数传入,而不是直接拼进 shell,避免注入问题。
- 可测试:适配器可以 mock brew 命令输出,前端开发不必依赖真实环境。
- 好维护:Homebrew 输出格式变化时,只改适配器一个地方。
数据流是单向的:UI 操作 -> IPC 调用 -> 适配器执行 brew 命令 -> 解析 stdout/stderr -> 返回结构化数据 -> 前端渲染。这个思路和很多 CLI 工具做 GUI 的实践一致,本质是把“人眼阅读命令输出”这件事交给程序去做。
2.3 项目目录与数据流
BrewUI 的目录结构大概长这样:
brewui/ src/ main/ # Electron 主进程 brew.ts # brew 命令适配器 ipc.ts # IPC 事件注册 path.ts # brew 路径探测 renderer/ # React 界面 components/ pages/ stores/ shared/ types.ts # 共享类型定义 resources/ icons/ package.json主进程负责和 brew CLI 通信,渲染进程只负责展示和交互。两者通过ipcMain.handle/ipcRenderer.invoke通信。这样设计有一个额外好处:以后想加命令行模式或者 Web 模式,核心适配器可以原样复用。
3. 核心功能拆解:列表、搜索、安装、升级、清理
3.1 安装包列表的 JSON 化读取
早期版本直接去解析brew list的文本输出,结果就是不同版本的 Homebrew 会微调对齐方式,新手装出来就容易出 bug。后来我老老实实用 Homebrew 自带的 JSON 输出:brew list --formula --json=v2和brew list --cask --json=v2。
JSON 输出里的字段很多,真正用得上的是这些:
interface BrewFormula { name: string; full_name: string; versions: { stable: string; }; installed: Array<{ version: string; }>; dependencies: string[]; build_dependencies: string[]; desc?: string; }拿到 JSON 后,前端就能直接渲染出表格。需要注意一个小坑:不同 Homebrew 版本字段名偶尔会变,适配器里解析 JSON 时要做好默认值。我在代码里写了一个safeParse函数,解析失败会返回空数组而不是直接抛异常,UI 再给用户显示一条“读不到数据,请看日志”的提示。真实环境里,一个工具遇到异常还能保持界面可用,比功能本身更重要。
3.2 搜索与安装的状态机
搜索功能最简单的方式是直接调brew search keyword,但文本输出混着公式和 cask,不好区分。建议用两段式逻辑:先跑brew search --formula,再跑brew search --cask,分别组装结果。不过brew search的 JSON 支持在不同版本里不太稳定,BrewUI 做了兼容,优先尝试--json,失败就退回文本解析。
安装操作最忌“无状态”。用户点一次安装,按钮必须变成 loading,禁用重复点击,同时把这个包的安装状态记录在全局 store 里。因为 brew 本身有一个锁文件,多个安装任务并发跑会互相等待甚至报错,所以 BrewUI 在适配器里加了一个任务队列:所有 install / upgrade / uninstall 操作都排队执行,避免同时触发两个 brew 进程。
卸载也有讲究。brew uninstall formula默认不卸载依赖,界面里我会给用户展示“这个包里有哪些依赖项”,但不会自动勾选。原因是依赖可能被其他包共享,自动处理风险很大,交给用户判断更安全。
3.3 升级与清理的策略
升级是所有操作里最容易“翻车”的。brew upgrade一行命令就能把几百个包全升了,但是升级后项目跑不起来也是常有的事。BrewUI 的策略是三步走:
- 先
brew outdated --json=v2拿到所有可更新包。 - 在界面列出差异,按“主版本升级”和“补丁升级”分组。
- 用户批量勾选后,逐个执行
brew upgrade <pkg>,而不是一次性全量升级。
分组逻辑来自一个小经验:补丁升级通常风险低,主版本升级需要重点确认。BrewUI 里把这两类用不同颜色标出来,还加了一个“只升级补丁版本”的快捷按钮,日常维护时间能省下一大截。
清理操作同样不能手滑。brew cleanup会清理旧版本安装包和缓存,虽然理论上安全,但删之前最好先预览。适配器里我做了两种模式:默认先跑brew cleanup -n,把“将要删除的内容”展示给用户;用户确认后,再执行真正的brew cleanup。另外,自动清理的开关放在设置页,默认关闭,因为有些人会故意保留旧版本来应对版本回滚。
3.4 brew services 的界面化处理
服务管理是 Homebrew 里最容易被新手忽略、但实际很常用的功能。brew services start/stop/restart能管理后台服务,比如你装了 nginx 或 mysql,直接用 brew services 起停比手动撸配置简单得多。
麻烦在于brew services list的输出是文本表格,没有稳定的 JSON 字段。BrewUI 解析它用了一个简单正则:按行读取,跳过表头,再用“第一列服务名、第二列状态、第三列用户”的方式切分。好在服务名一般没有空格,这种解析方式目前还算稳定。
服务操作还有一个特殊点:部分服务启动需要管理员权限。适配器里我留了一个sudo参数,UI 端调用时会先弹系统确认框。千万不要强行把密码硬编码在配置里,也不要前端传明文密码,让系统原生的权限确认去处理更安全。
4. 实操记录:从空目录到一个可用版本
4.1 初始化工程
初始化用的是 Vite 的 React + TypeScript 模板,再手动装 Electron。命令大概是:
npm create vite@latest brewui -- --template react-ts npm install electron electron-builder concurrently开发阶段主进程启动 Electron,渲染进程跑 Vite dev server,两者通过环境变量VITE_DEV_SERVER_URL连接。这个方案比一开始就接 Electron Forge 更轻,插入自定义逻辑也方便。打包用的 electron-builder,目标是生成 macOS 的 dmg,以及一个免安装的 Linux AppImage。
4.2 命令执行层的最终代码
适配器里最核心的是一个runBrew函数,所有 brew 命令都走它:
import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); export async function runBrew( args: string[], options: { timeout?: number; sudo?: boolean } = {} ) { const brewPath = await resolveBrewPath(); const cmd = options.sudo ? `sudo ${brewPath} ${args.join(' ')}` : `${brewPath} ${args.join(' ')}`; const { stdout } = await execAsync(cmd, { maxBuffer: 20 * 1024 * 1024, timeout: options.timeout ?? 60000, }); return stdout; }resolveBrewPath会按照 Apple Silicon/opt/homebrew/bin/brew、Intel/usr/local/bin/brew、以及which brew三种路径依次探测。maxBuffer一定要给大一点,brew outdated --json=v2在包多的时候输出很大,默认 1MB 经常爆。
4.3 长任务的进度与反馈
brew 的 install/upgrade 可能持续几分钟,如果界面一直转圈,用户根本不知道卡在哪个阶段。BrewUI 用一种粗糙但有效的方式解决:把命令的执行过程实时读出来,然后按关键字拆成阶段,更新到 UI。
实现上用spawn而不是exec,监听 stdout 和 stderr:
import { spawn } from 'child_process'; export function runBrewStream(args: string[], onChunk: (text: string) => void) { const child = spawn(resolveBrewPathSync(), args, { stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk) => onChunk(chunk.toString())); child.stderr.on('data', (chunk) => onChunk(chunk.toString())); return child; }然后把日志按行喂给前端,前端做一个简易日志面板,用户能看到 brew 的实时输出。这个功能看起来简单,但对使用体验的提升极大,没人喜欢对着一个无信息的白屏等待。
4.4 界面状态管理
状态管理用了 Zustand,轻量、没有多余的样板代码。全局状态里放三样东西:包列表、正在执行的任务列表、日志缓冲区。核心原则是“所有操作都是异步任务”,每个任务有 id、类型、目标包名、状态、日志。
UI 层按照任务状态渲染按钮:
- 未安装:显示“安装”按钮。
- 安装中:按钮变成 disabled,图标转圈。
- 已安装但有新版本:显示“升级”按钮。
- 已安装且最新:显示“已是最新”。
这样用户不会在一个包上重复点击,也不会误以为“装完就结束了”,其实还有升级空间。包的数量多了以后,状态机比任何花哨的 UI 效果都重要。
5. 常见问题与排查技巧实录
5.1 GUI 里找不到 brew 命令
这是桌面 GUI 工具最常见的坑。Electron 应用启动时,PATH 环境变量不一定会继承用户 shell 里的配置,有些人用 oh-my-zsh、fish 等写的 alias 也不生效。结果就是应用里点击安装,报“command not found: brew”。
解决办法是在适配器里写死 brew 的常见路径,并在启动时做一次探测。如果两个默认路径都不存在,再尝试which brew。最后还不行,设置页里允许用户手动指定 brew 路径,并保存到配置文件。别嘲笑这个功能,真的有人装 Homebrew 到自定义目录。
5.2 JSON 解析碰到杂音
Homebrew 在跑更新的时候,可能会往 stderr 输出一些下载进度或者警告,如果适配器直接JSON.parse(exec 的 stdout),偶尔会成功,偶尔失败,很烦。后来改成只解析 stdout,并且把stderr原样转发到日志面板。
另一个杂音来源是用户 shell 的配置文件。如果 brew 前面挂了些 shell 钩子或环境提示,输出会被污染。所以适配器执行命令时,环境变量里不要继承太多自定义配置,尽量用干净路径和标准PATH去调用 brew。
5.3 brew 任务卡住
brew install卡住的典型原因有几个:网络慢、依赖编译时间过长、等待锁。适配器这边能做的有限,但可以改进两点:
- 命令超时时间要区分场景。
brew update和源码安装类任务给 10 分钟,普通查询给 30 秒。 - 给每个任务加“取消”按钮。取消时直接 kill 掉子进程,并让 UI 状态回到初始状态。
取消后的脏数据也是个问题。比如安装到一半被杀掉,brew 的包目录里可能残留半成品,下次安装会报错。BrewUI 遇到这种情况,会提示用户执行brew cleanup,而不是替用户擅自清理。
5.4 锁冲突与并发控制
Homebrew 在运行关键操作时会产生一个锁文件。如果用户手滑开了多个 BrewUI 实例,或者终端里又同时在跑 brew,就会看到 “Another active Homebrew process” 的报错。解决思路是在应用里做全局单实例锁,然后再加一层内部任务队列。
下面是一个常见问题速查表:
| 现象 | 原因 | 处理建议 |
|---|---|---|
| command not found: brew | 默认 PATH 没包含 Homebrew | 探测 /opt/homebrew 和 /usr/local 路径 |
| JSON 解析失败 | stderr 混入输出 | 只解析 stdout,stderr 单独展示 |
| 安装长时间不动 | 网络慢或正在编译 | 提供取消按钮并合理设置超时 |
| “Another active brew” | 有并发任务 | 任务队列串行执行 |
| “Operation not permitted” | 系统权限不足 | 提示用户手动授权或使用 sudo 执行 |
6. 后续可以继续扩展的方向
6.1 接入 brew bundle
brew 有一个很好用的命令brew bundle,可以把当前环境导出成一个Brewfile,以后在新机器上一条命令恢复环境。BrewUI 可以在界面里做“导出环境”和“导入环境”两个入口,本质是调用brew bundle dump和brew bundle install。这个功能一旦做好,整个团队的开发环境初始化就变成“选几个文件,点一下按钮”。
6.2 依赖关系可视化
Homebrew 本身能输出依赖树,BrewUI 可以把它做成可展开的树形结构。但要注意区分“直接依赖”和“传递依赖”,避免界面变成一张蜘蛛网。我更建议只显示两层:当前包直接依赖谁,以及谁直接依赖当前包。再深层的关系交给专业工具处理,界面里做太多反而失去重点。
6.3 系统通知与定时检查
很多服务类包需要定期检查更新,但用户不会每天手动打开工具。BrewUI 可以做一个后台定时器,固定时间跑一次brew outdated --json=v2,有更新时通过系统通知提醒。提醒文案最好带上数量,比如“有 8 个包可升级,其中 2 个主版本升级”,用户看到通知就能判断是否需要处理。
最后,说一个我在这个项目里踩过最深的坑:不要直接用文本输出的brew list去渲染界面,哪怕它看起来很快。一定要切换到 JSON 模式,前期多花半天适配字段,后面能少维护一年。真正的稳定不是靠小心处理每一行字符串,而是从一开始就选择机器可读的数据格式。BrewUI 这个项目做下来,最大的收获不是 UI 多好看,而是让我重新理解了“给命令行工具做封装”的一条原则:界面是壳,稳定解析才是核心。