周一早上,我像往常一样准备给开发机做一轮软件包更新。终端里敲下brew update && brew upgrade,然后对着滚屏的输出发呆:哪些包升了、哪些依赖被顺带更新、哪些包其实已经没用了,我完全没概念。等升级完,我又挨个敲brew list、brew outdated、brew info xxx,来回折腾了十几分钟。那一刻我意识到,天天用 Homebrew 的人,其实缺的不只是一个命令,而是一个能把这些信息组织起来的界面。于是我打算做一件事:给 Homebrew 写一个图形化管理工具,名字就叫 BrewUI。
这个项目想得很朴素——不是要把命令行替换掉,而是把那些高频操作从"记命令、看文本、猜输出"变成"看列表、点按钮、读状态"。做完之后,我发现这玩意儿对三类人特别有用:一类是刚接触 macOS 开发环境、对终端还不太熟的新手;一类是维护着好几台机器、需要快速摸清软件包状态的人;还有一类就是懒得记brew子命令参数、只想把事办完的实用主义者。
这篇文章我会把 BrewUI 从立项到落地的完整链路拆开来讲,包括技术选型、核心功能怎么实现、界面怎么设计、踩了哪些坑,以及后续还能怎么扩展。如果你正准备做类似的管理工具,或者单纯好奇 Homebrew 的二次开发能玩到什么程度,这篇可以直接当参考资料用。
1. 为什么会冒出来一个 BrewUI:命令行用户的真实痛点
先别急着谈技术,我花了不少时间把 Homebrew 的高频操作列了一个清单,然后老老实实对比了"终端里的体验"和"理想中的体验"。这个对比直接决定了 BrewUI 该做什么、不该做什么。
1.1 日常使用中的高频操作清单
我把 Homebrew 的使用场景拆成下面几类,基本覆盖了绝大多数人一周内的操作:
| 操作 | 终端里的典型命令 | 信息获取难度 |
|---|---|---|
| 查看已安装软件包 | brew list --formula | 只有包名列表,没有版本、安装时间、依赖大小 |
| 检查可更新的包 | brew outdated | 输出格式不固定,包多了以后很难扫一眼看懂 |
| 搜索软件包 | brew search keyword | 结果混合了公式和 cask,需要自己再筛 |
| 查看某个包的详情 | brew info xxx | 信息铺满一屏,依赖关系要自己去理 |
| 安装新软件包 | brew install xxx | 看不到安装队列、进度不直观,日志滚屏容易漏掉报错 |
| 卸载不再需要的包 | brew uninstall xxx | 不显示卸载后哪些依赖变成孤儿 |
| 清理旧版本和缓存 | brew cleanup | 干完活才告诉你释放了多少空间 |
| 检查环境问题 | brew doctor | 输出又长又吓人,新手容易慌 |
这些操作单拎出来都不复杂,但组合在一起就很烦躁。尤其当机器上装了 200 个以上的包时,brew list的输出是几百行纯文本,你想找"上次是什么时候装的、这个包被谁依赖着",几乎没有快速路径。
1.2 每个操作在 CLI 里的真实体验
举一个我最常遇到的例子:升级。brew upgrade的执行结果受网络、依赖顺序、冲突影响很大,而终端只会一行一行地刷。有一次我升级一个 Python 相关的包,输出报了一段编译错误,但前面的日志早就被刷掉了,我只能把输出重定向到文件里再翻。这种事碰过几次后,我开始想要一个"能保留历史、能按包名检索、能显示成功还是失败"的界面。
再比如卸载。brew uninstall会问你"是否同时移除依赖包?"——如果选--ignore-dependencies,可能留下孤儿依赖;如果直接卸载,它又不会主动告诉你哪些包是因为这个包才装进来的。在终端里,回答这种问题全凭脑补依赖关系。
还有brew doctor。第一次跑它的人十有八九会被吓到,因为它会把各种 warning 堆在屏幕上。但其中真正需要处理的可能只有一两条。如果能把这些 warning 分级、给出解释和建议动作,对普通用户来说价值就非常大。
1.3 谁需要图形界面,谁不需要
我不是要否定命令行的效率。事实恰恰相反,我自己八成的操作依然在终端里完成。但"高频但需要扫读"的操作,比如看版本、找依赖、确认哪几个包过期了,图形界面有天然优势。而"高频且参数灵活"的操作,比如搜索、安装、卸载,命令行确实更快。
所以 BrewUI 的目标不是"取代 Homebrew",而是给 Homebrew 提供一个可视化的工作台。它的核心价值是:把状态呈现出来、把操作管理起来、把输出解释清楚。这个定位让我在后面的技术选型上少走了很多弯路。
2. 技术选型与整体架构:为什么用 Electron 而不硬啃原生
工具的作用对象是 Homebrew,那么平台自然锁定 macOS,但"锁定了平台"不意味着只能做原生应用。我把技术方案过了一遍,列了一张对比表。
2.1 候选方案对比
| 方案 | 优点 | 缺点 | 我的判断 |
|---|---|---|---|
| SwiftUI + 原生进程调用 | 性能好、系统集成度高、内存占用低 | 开发周期长,对 JS 生态开发者不友好,界面迭代慢 | 适合有时间、有原生经验的团队 |
| Electron + Node.js | 生态成熟、界面开发效率高、跨平台 | 安装包体积大、内存占用高 | 适合验证想法、快速迭代 |
| Tauri + Rust | 体积小、性能好、安全性强 | Rust 上手成本高、WebView 兼容性需要处理 | 适合后续重写考虑 |
| Python + PySide6 | 开发快、写脚本顺手 | 打包体积也不小,UI 表现力一般 | 适合内部工具,不适合对外发版 |
最终我选了 Electron。理由很直接:我可以用成熟的 Web 前端技术快速做出高质量界面,Node.js 的child_process又能非常方便地调用brew命令;Electron 的生态里能找到现成的状态管理、日志、自动更新方案,对一个人开发的项目来说,省下来的时间都是实打实的。
这一章不涉及复杂图形,适合用表格对比,但有个点必须说透:Electron 慢不慢,取决于你拿它干什么。如果只是显示列表、发个异步命令,Electron 的启动速度和渲染速度完全够用。真正的性能瓶颈在 brew 命令本身,而是 CLI 调用后要解析输出。
2.2 核心架构:UI / 主进程 / Brew 桥接层
BrewUI 的架构分三层:
- 渲染进程:负责 UI 渲染,展示包列表、详情、状态,接收用户点击事件。
- 主进程:负责窗口管理、菜单、系统集成,以及所有和文件系统、进程相关的操作。
- Brew 桥接层:这是整个项目的核心,负责把 UI 的请求翻译成
brew子命令,执行后解析输出,再以结构化数据回传给渲染进程。
为什么要单独拆一个桥接层出来?因为 UI 和 Homebrew 不应该直接对话。Homebrew 的输出格式会随版本变化,如果每次都在渲染进程里写解析逻辑,界面代码会被搅乱。桥接层把"命令执行"和"输出解析"收拢到一处,UI 只拿到干净的 JSON,后续 Homebrew 输出格式变了,只需要改桥接层的解析函数。
2.3 为什么直接解析 JSON 而不是解析文本
Homebrew 提供了 JSON 输出方式,最常用的是这两条:
brew info --json=v2 --formula brew info --json=v2 --cask输出的 JSON 里包含名字、版本、依赖、依赖它的包(reverse dependencies)、安装路径、描述、许可证等几十个字段。这就意味着,BrewUI 不需要去切文本,不需要猜格式,直接结构化消费就行。
我最初试过解析brew list --formula的普通输出,然后用brew info逐个补详情。结果不仅慢,还脆弱——不同版本 Homebrew 的输出排版有细微差异,时不时就崩一个解析函数。换成 JSON 之后,解析逻辑稳定多了,而且--json=v2一次能拿到全部公式的信息,不需要循环调命令。
唯一的代价是首次获取全量 JSON 比较慢,机器上包多了之后可能要等一两秒。这个后面会讲我用缓存怎么解决。
3. 核心功能的实现链路:从包列表到安装队列
架构定下来之后,我按用户路径把功能排了个优先级:先做"看",再做"搜",最后做"改"。List → Search → Detail → Install/Uninstall → Update → Cleanup,这是 BrewUI 的六个核心页面。
3.1 包列表:解析 brew list 的边界与陷阱
包列表是整个应用的入口,它必须同时回答三个问题:装了哪些包、这些包是什么版本、这些包占多大空间。
实现上我选择先用brew list --formula拿包名列表,再用brew info --json=v2 --formula拿全量详情,然后在桥接层做一个合并:
const { execFile } = require('child_process'); const { promisify } = require('util'); const execFileAsync = promisify(execFile); async function getInstalledPackages() { const { stdout } = await execFileAsync('brew', ['list', '--formula']); const formulaNames = stdout.split('\n').filter(Boolean); const { stdout: infoJson } = await execFileAsync('brew', [ 'info', '--json=v2', '--formula', ...formulaNames ]); const data = JSON.parse(infoJson); // data.formulae 就是结构化的包详情数组 return data.formulae; }实际使用中有个边界情况:依赖包数量大的时候,命令行参数会非常长。第一次我直接把所有包名拼在brew info后面,结果 200 多个包时命令直接报E2BIG错误。解决办法是把请求拆成每次 50 个包,并行拉取,最后合并结果。
另一个坑是 Homebrew 的依赖关系是动态的,安装一个包时自动拉上来的依赖也会出现在brew list里。如果列表页只是简单铺开,用户很容易被几十个依赖包淹没。所以我给列表做了"只看顶层级(formulae 中不被其他包依赖的)"和"全部"两个视图,默认显示顶层包,依赖放在详情页里展示。这样才能让用户一眼看出"我自己装了什么"。
3.2 搜索与详情:缓存策略与公式信息获取
搜索功能看起来简单,难点在数据来源。brew search的文本输出包含 formula 和 cask 混合结果,字段无法同时使用。我最终选择了另一种思路:
启动时后台跑一次brew update,然后缓存brew search --formula和brew search --cask的结果到本地。用户输入关键词时,前端直接对缓存数据做模糊匹配,不再调用 brew。
function searchLocal(cachedCatalog, keyword) { const kw = keyword.toLowerCase(); return cachedCatalog.filter( (item) => item.name.includes(kw) || (item.desc && item.desc.toLowerCase().includes(kw)) ); }本地搜索最大的优点是快,毫秒级返回。缺点是数据可能不是最新,所以我加了一个"离线/在线"指示器:如果用户想搜到刚发布的包,可以点击"同步最新索引"按钮主动触发一次更新。
详情页的信息我按区块划分:
- 基本信息:名称、版本、简介、许可证、主页
- 依赖关系:这个包依赖谁、谁依赖它(双向展示)
- 安装信息:安装路径、依赖项安装数
- 操作按钮:安装/升级/卸载/打开主页
双向依赖是 Homebrew JSON 提供的一个重要字段,JSON 的dependencies是正向依赖,reverse_dependencies需要根据全量数据自行反推。我在桥接层写了一个函数,遍历所有公式,把每个包被谁依赖的关系索引出来,这样详情页能立刻回答"我删了这个包,谁会受影响"。
3.3 安装、更新、卸载:必须串行化
这是 BrewUI 里最敏感的部分,也是我踩坑最多的部分。Homebrew 自己并不锁 UI 层面,但它对并发操作是敏感的。如果你同时发两个brew install,第二个大概率会卡住或者报错。所以我在桥接层实现了一个操作队列:
class BrewTaskQueue { constructor() { this.queue = []; this.running = false; } push(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this.pump(); }); } async pump() { if (this.running || this.queue.length === 0) return; this.running = true; const { task, resolve, reject } = this.queue.shift(); try { resolve(await task()); } catch (e) { reject(e); } finally { this.running = false; this.pump(); } } }安装、升级、卸载、清理全部走这个队列。UI 层每次提交操作都会拿到一个任务 ID,前端订阅任务状态来更新进度条和日志面板。这样即使用户连续点了三个安装,系统也不会互相打架。
界面把一次操作拆成几个状态:等待中→执行中→解析输出→完成/失败。这个看似简单的状态机,让我在排查问题的时候省了不少心。
3.4 清理与体检:深度集成 brew doctor 和 autoremove
清理和体检是我刻意放到第二版才做的。原因很简单:它们有"破坏性"和"诊断性"特征,必须谨慎对待。
清理功能其实就两条命令:
brew cleanup --dry-run # 预览可以清理什么 brew autoremove --dry-run # 预览可以移除的孤儿依赖BrewUI 先执行 dry-run 拿到可清理项,展示给用户确认后再执行真正的清理。我把--dry-run的输出解析成一个列表,逐条展示要清理什么、能省多少空间。这一步在终端里不容易看明白,图形界面就友好多了。
brew doctor的输出是一堆文本,我按照 Homebrew 输出的前缀做了简单的分类:错误、警告、提示。然后为每个类型写了一段"这是什么意思"和"要不要处理"的说明。比如"Warning: Unbrewed dylibs were found in /usr/local/lib"这种,很多新手不理解,BrewUI 会解释为"系统的库目录里有个不是 Homebrew 管理的东西,可能是其他安装器留下的,通常不用马上处理,但要记住它的存在"。
4. 界面设计与交互:哪些数据值得上屏,哪些必须藏起来
功能做出来了,界面不好用会前功尽弃。BrewUI 的界面我前后改了三版,核心原则只有一个:用户需要决策时,把信息摆出来;用户不需要决策时,把信息收起来。
4.1 信息架构:一屏看状态,一屏做操作
主界面我用了左侧边栏 + 右侧内容区的结构,左侧是导航,右侧是对应的页面,没有用标签页堆叠。原因很简单:包管理这件事的操作路径很短,点进来要么看状态,要么找包,要么点操作,不需要复杂的上下文切换。
导航项包括:
- 已安装
- 可更新
- 搜索
- 依赖关系
- 清理与体检
- 日志
已安装页面默认显示顶层包,每个包行显示:图标、名称、当前版本、简介。用户可以选择"显示全部包"切换。列表上方有一个过滤框,可以按名称过滤,还支持按分类过滤,比如只看 Formula、只看 Cask、只看有可用更新的包。
有个小细节:版本号不要用红色标红,除非明确知道红色代表什么。第一版我把有可用更新的版本号标成红色,用户反馈"以为系统出错了"。后来改成正常显示当前版本,在后面加一个淡绿色的"有新版本"标签,语义就清楚多了。
4.2 状态反馈:操作必须可见、可回溯
任何 CLI 工具执行命令时,最让用户焦虑的是"它是不是卡住了"。所以 BrewUI 做了三件事:
第一,所有操作都有进度状态。每一个队列任务在日志面板里占据一行,实时显示当前的输出行。第二,操作完成后,结果概览会自动生成一段内容,比如"更新了 3 个包,失败 1 个",并链接到失败包的日志位置。第三,日志全部落盘到本地文件,格式是纯文本,用户随时可以打开~/Library/Logs/BrewUI去翻原始输出。
4.3 表格式页面 vs 卡片式页面:信息的密度和可读性
我纠结了很久要不要在详情列表页使用卡片式布局。第一版确实用了卡片,每个包一张卡,名字很大、图标很显眼,视觉上很好看。但在一屏 13 英寸笔记本屏幕上只能同时看到六七个包,效率很低。第二版改回了密度更高的表格:一行一个包,列宽由内容决定,名称和版本放前面,简介用省略号截断,鼠标悬停时显示完整文本。
详情页则保留了卡片式的分组,这是信息密度和可读性的平衡点。表格适合"扫",卡片适合"读"。用户在一个页面的不同层级有不同需求,界面也要跟着变。
5. 实测中的坑与对策:权限、缓存、并发与命令兼容性
这部分是我最想分享的,因为每个坑对应的都是真实运行中踩到的、一旦遇到就会让工具看起来"秀逗"的棘手问题。我整理了一个大表,然后把重点的展开讲。
| 坑 | 表现 | 根因 | 解决方式 |
|---|---|---|---|
| 权限不足 | 安装包时总是失败,日志里有 Permission denied | Homebrew 目录/opt/homebrew或/usr/local的属主不是当前用户 | 检测目录属主,给出修复授权命令的提示 |
| 全量 JSON 拉取慢 | 启动后列表白屏数秒 | brew info --json=v2对所有包执行耗时 | 本地缓存 + 增量刷新,后台异步更新 |
| 命令并发冲突 | 同时执行两个操作,其中一个长时间无输出 | Homebrew 对仓库锁互斥 | 全局串行队列,不允许并发任务 |
| Homebrew 版本变化 | 解析函数偶发报错 | 文本或参数在不同版本间不稳定 | 尽量用 JSON 输出,并增加降级解析 |
| 系统升级后失效 | 找不到 ruby/interpreter | macOS 升级导致 CommandLineTools 路径变化 | 使用/usr/bin/env brew方式启动命令,提示重装工具链 |
5.1 权限提示的处理
Homebrew 安装位置有两种:Intel Mac 一般在/usr/local,Apple Silicon 一般在/opt/homebrew。如果之前用 sudo 操作过、或者从迁移助理搬过系统,目录属主就很容易不对。BrewUI 第一次检测到权限问题时,我没有直接弹出一个笼统的报错,而是运行一条诊断命令:
ls -ld "$(brew --prefix)"这条命令的权限位和属主一眼就能看到问题。如果确实属主不对,我会提示用户执行:
sudo chown -R "$(whoami)" "$(brew --prefix)"这里需要特别提醒:不要把 sudo 接到 brew install 上,那是 Homebrew 官方明确反对的,会导致整个目录权限混乱,后续所有问题都会变成"莫名其妙"。BrewUI 只负责提示,不代执行 sudo 命令,保持操作边界清晰。
5.2 缓存失效与数据一致性问题
BrewUI 的缓存策略很简单:本地存一份 JSON,每次启动时先读缓存秒开界面,同时后台运行brew update和 JSON 拉取,完成后用新数据替换旧缓存。这个策略在 90% 的场景下没问题,但有个数据一致性坑:
用户通过终端手动装了包,BrewUI 的缓存还没更新,界面显示和真实状态不一致。这种状态没法完全避免,所以我在界面上加了一个明显的"最后同步时间",并且每次窗口获得焦点时主动刷新一次列表数据。虽然不能完全消除窗口期,但能极大缩小不一致的时间。
还有一个细节:brew info --json=v2返回的数据里,installed字段是数组,同一个公式可能装了多个版本,比如 Python 3.10 和 3.11 会同时存在。列表页需要取installed数组最后一项的版本号展示,而不是直接取 JSON 顶层的versions.stable,否则会显示"这个公式支持的最新版本",而不是"你当前实际装的版本"。这个坑我第一版就踩了。
5.3 命令并发导致的锁冲突
Homebrew 在运行时会在仓库目录写一个锁文件,如果同时有两个进程执行写操作的命令,其中一个会卡住或者输出一段"Another active Homebrew process is already in progress"的提示。
我在终端里用两个窗口同时跑brew install时见过这种提示,BrewUI 里如果不做任务队列,几乎百分之百重现。所以队列成为整个工具最核心的组件。它的代价是:用户点了多次操作,后面的任务会显示"等待中",而不是立刻执行。刚开始我怕用户觉得"没反应",后来在 UI 上明确显示队列位置和等待状态,反而没人再说卡了。
5.4 不同 Homebrew 版本兼容性
Homebrew 一直在变,命令参数也会调整。比如brew cask install这个老用法已经废弃,改成统一用brew install --cask。BrewUI 有一个适配层,每个需要调用 brew 的模块都通过适配层来发起命令,而不是直接写命令字符串。
适配层有两个职责:一是根据 Homebrew 版本决定用什么参数;二是在命令执行失败时,检查输出里是否包含unknown command或者Usage:之类的标识,如果包含,就自动切换到替代命令格式并重新执行一次。这样即使 Homebrew 悄悄改了接口,BrewUI 也能自己"绕"过去,而不是直接报错。
这个做法的副作用是调试时容易迷惑,因为你看到的实际执行命令可能跟代码里写的不一样。所以我在日志面板里把每次实际执行的命令完整打印出来,方便排查问题。
6. 打包分发与后续玩法
功能稳定后,下一个问题就是怎么把 BrewUI 发给别人用。这一步里没有太多锦上添花的技巧,更多是实打实的踩坑经验。
6.1 打包与签名
Electron 打包我用的是 electron-builder,配置了 macOS 的 dmg 和 zip 两种产物。在 Apple Silicon 和 Intel 两种架构下需要分别打包,我搭了一个简单的 GitHub Actions 工作流,两个系统架构分别构建,然后合并成一个 universal 包。
签名问题绕不开。如果只是自己用,可以跳过签名,但 macOS 的 Gatekeeper 会拦截未签名的应用,用户需要右键打开或者去系统设置里允许。我给 BrewUI 配置了 Developer ID 签名,并做了公证(notarization)。公证这一步在 CI 里其实很容易出问题,因为需要配置 Apple 开发者证书和钥匙串的环境变量,建议提前把证书导出并上传到 CI 的 secrets 里。
6.2 日志收集与远程排查
分发出去之后,最怕的就是用户报了一个 bug 但你完全不知道发生了什么。所以我在日志面板之外,加了一个"导出诊断信息"按钮。点击后 BrewUI 会打包一份摘要,包括:
- Homebrew 版本和前缀路径
- macOS 版本
- BrewUI 版本
- 最近 20 条操作日志
- 当前的缓存文件是否完整
用户把这份摘要贴到 issue 里,我基本就能定位问题。而且因为所有日志都是纯文本,根本没有必要在终端里让用户手动跑命令收集。有一个坑:诊断信息不能包含完整的环境变量和密钥,所以我导出的时候会过滤掉HOMEBREW_*这类含有敏感信息的变量。
6.3 后续可以继续做的方向
BrewUI 做完了 v1,但我脑子里还有几个方向没有完全落实:
- 批量操作模式:在列表里多选多个包,统一升级或统一卸载。现在是一条条操作,效率还可以更高。
- 依赖环可视化:Homebrew 的依赖关系本质是一张有向图,如果能用图形化的依赖图来展示某个包的上游和下游,对排查环境问题会很有帮助。
- 软更新的判定:结合 GitHub 上各仓库的 Release 状态,在 Homebrew 仓库还没更新前就提示用户"上游有新版本",这个对追新版本的人有用,但实现成本不小。
- 多台机器同步:把自己常用的包清单导出成一个文件,在另一台机器上一键恢复安装。
brew bundle已经能做一部分,但 BrewUI 可以把它做成人人可点的界面。
这些方向我都已经记录在项目的 roadmap 里,下一版会优先把批量操作和依赖图可视化落实。
最后再分享一个小技巧,也是我在使用 BrewUI 的过程中最喜欢的一个体验:把"可清理的空间"直接展示在侧边栏的清理入口上。每次清理页面启动时,后台会执行一次brew cleanup --dry-run,把预计释放的空间数字显示在导航栏的角标上。这个数字就像手机存储空间里的"可清理垃圾",看到它的时候你很难忍住不点进去清一波。一个小细节,却让一个工具的使用频率高了不少。有时候工具好不好用,差的真的就是这种顺手的小反馈。