十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

为 Homebrew 打造图形界面:BrewUI 的设计与实现全解析

为 Homebrew 打造图形界面:BrewUI 的设计与实现全解析 写这个项目的时候我其实是有点赌气的。Homebrew 用了快十年命令行敲得飞起brew update brew upgrade这种肌肉记忆比刷牙还稳。但每次看到身边同事打开终端面对一堆提示符发呆或者在 GitHub Issue 里问我到底该不该跑 brew cleanup的时候我都在想这玩意儿明明是个好东西为什么入口非得是黑底白字的终端BrewUI 要解决的就是这件事——给 Homebrew 套一层不那么丢人的图形界面让管理 Mac 上的开发环境这件事从一个需要背命令的活变成打开窗口点两下就能搞定的操作。先说清楚这不是要取代命令行。命令行永远是最高效的这个立场我到现在也没变。但命令行的高效是给已经会的人准备的对于刚接触 macOS 开发环境、或者只是偶尔装个工具的非主力开发者来说一个能看见当前装了哪些包、哪些该升级了、哪些占了多少磁盘空间的图形界面学习成本低到几乎可以忽略。这篇文章会把 BrewUI 从需求分析、技术选型、核心模块实现到实际踩坑的完整过程拆开讲。重点说说 UI 层怎么和 Homebrew 的命令行交互进程管理怎么做才不会卡界面以及为什么最终选择了 Electron 而不是 SwiftUI。如果你也想给自己的 CLI 工具包一层界面或者单纯想看看一个业余项目是怎么从想法变成能用的工具的可以继续往下看——里面有不少是文档里不会写的经验。1. 整体设计与思路拆解1.1 为什么是 Homebrew在动手之前我其实犹豫过要不要选 Homebrew 作为对象。macOS 上的软件包管理方案其实不算少MacPorts 还在Nix 也越来越流行但综合考虑下来Homebrew 依然是最值得去做图形界面的那个。原因有三。第一用户基数足够大。几乎每个用 Mac 做开发的人都会装 Homebrew哪怕只是用来装个 wget 或者 ffmpeg。这样的人群里有很大一部分对命令行的熟悉程度仅限于复制粘贴——他们不排斥图形界面恰恰相反他们需要图形界面。第二Homebrew 的命令行接口非常规整。brew list、brew info、brew outdated、brew upgrade这些命令的输出格式足够稳定解析起来不太会让人想砸电脑。第三Homebrew 本身就是一个管软件的工具它的数据模型很干净Formula 和 Cask 分得清清楚楚依赖关系明确状态信息完整——这正好是一个 GUI 应用最喜欢的输入。想清楚了做什么接下来就是怎么做。1.2 UI 层与 CLI 的边界划分项目叫 BrewUI核心自然是 UI但准确说应该是UI 驱动的包管理操作。我给自己定了三条设计原则一是只做 Homebrew 能做的事。UI 层不自己发明任何安装逻辑所有对软件包的操作最终都翻译成对应的brew命令子进程去执行。这一点极其重要它保证了我的界面不管怎么改底层行为永远和 Homebrew 一致不会出现界面上显示装好了实际 brew list 里没有这种两套逻辑打架的场面。二是状态显示务必真实。界面上的任何状态比如已安装可升级未安装都必须来自实时读取 Homebrew 的数据而不是自己维护一份本地数据库。Homebrew 的状态可能在外部被改变比如你自己在终端里敲了 brew install 某个包如果界面维护的是一份定时同步的缓存就会出现显示过期的问题。后面我会讲具体怎么做实时但不卡顿的刷新策略。三是让操作可追溯。GUI 做的每一个安装、升级、卸载操作都会记录对应的命令和输出。一旦出了什么问题用户可以直接看到底层发生了什么。按照我的经验这种透明度能省掉至少一半的UI 把我机器搞坏了的误解。1.3 为什么不用 SwiftUI这个问题我被问过挺多次。macOS 原生应用用 SwiftUI 不是更合适吗我最初确实认真考虑过 SwiftUI。作为一个 Mac 上的原生应用它在内存占用、启动速度、系统集成度上都有天然优势。但最终放弃的原因很现实项目范围会失控。SwiftUI 开发一个完整的应用意味着我需要同时处理语言绑定怎么在 Swift 里安全地调用 brew 命令、数据模型设计、依赖注入、并发处理……这已经不是一个周末项目的体量了。Electron 就不一样。前端生态里现成的东西太多了React 管视图、状态管理用 Zustand、UI 组件库直接抄 shadcn 的风格、图表用轻量级的 SVG 手画。大部分时间我都在处理业务逻辑而不是在跟编译器和类型系统搏斗。虽然 Electron 的内存占用被吐槽很多但对 BrewUI 这种应用来说——一个以查看列表和点击按钮为主的应用——不是瓶颈。注意Electron 的内存占用在空闲时大概 150-200MB这在开发类工具里并不是很夸张。真正要注意的是别在渲染进程里做重活否则卡顿是必然的。后面我会讲怎么通过重活全走主进程来规避这个问题。1.4 功能范围与取舍第一版 BrewUI 我做了一个明确的 MVP 功能清单然后在迭代中逐步扩展模块功能优先级仪表盘系统信息、已安装包数量、磁盘占用概览P0包列表已安装 Formula/Cask 列表、搜索、状态过滤P0详情面板单个包的版本、依赖、依赖它的包、安装信息P1操作安装、卸载、升级、清理缓存P0更新检查brew update 手动触发、后台定期检查P1安装、卸载、升级和列表展示是核心中的核心先做。详情面板和更新检查属于有它更好的范畴放到第二个迭代再说。一个常见的错误是贪多——想把 brew 的所有子命令全部做进界面结果每个功能都浅尝辄止到处踩坑。我宁愿把一个安装列表做到极致也不想要 20 个半残废的功能按钮。2. 核心细节解析与实操要点2.1 架构分层主进程、渲染进程、工具层Electron 应用的分层不只是一个架构概念直接决定了应用会不会卡、会不会莫名其妙崩。我把整个应用分成了三层。主进程Main Process承担所有需要权限的操作读取系统信息、执行 npm 包里的原生模块、做网络请求如果以后要做远程源管理的话。最重要的是主进程是唯一被授权执行brew命令的地方。渲染进程Renderer Process只做一件事显示 UI把用户的点击事件通过 IPC 发给主进程然后接收主进程传回来的数据并渲染。中间加一个工具层Services作为 IPC 的门面把主进程里的拆分成一个个纯函数例如listInstalled()、installPackage(name)。这样做的收益非常直接所有的进程管理和资源消耗都在主进程里可控地处理渲染进程只管界面内存和 CPU 的占用都是低的界面不会因为安装一个包而卡死。实操经验渲染进程永远不要直接child_process.exec(brew ...)。我在开发中期试过在渲染进程直接调界面确实没崩但会出现一个很恶心的问题——某些 brew 操作会 fork 出很多子进程如果直接由渲染进程发起退出应用时这些子进程可能会变成孤儿进程继续在后台跑非常难杀。2.2 用 JSON 格式解析 Homebrew 数据Homebrew 命令的输出格式分很多种默认是给人看的、格式化的文本但很多命令都支持--json参数。用 JSON 输出是唯一的正确选择。brew info --jsonv2 --installed返回的是一个大 JSON 对象包含了已安装的 Formula 数组和 Cask 数组。每个 Formula 里有name、full_name、desc、versions包含stable和installed数组、dependencies、build_dependencies、installed数组包含每个已安装版本的version和installed_as_dependency标志等。这些字段几乎覆盖了 BrewUI 需要的全部信息。比如是否作为依赖被安装这个标志在 UI 上可以做得很贴心如果用户试图卸载一个被其他包依赖的包界面直接弹警告。在代码里解析时要注意一个坑installed数组可能为空如果包已安装但信息未同步的话也可能包含多个元素如果你装过多个版本。所以我在封装数据层时写了一个统一的方法来拿当前实际生效的版本export function getActiveVersion(formula: Formula): string | null { if (!formula.installed || formula.installed.length 0) { return null; } // 取最后一个安装的版本 const latest formula.installed[formula.installed.length - 1]; return latest.version; }2.3 命令执行与进度反馈机制执行brew install xxx可能耗时几秒到几分钟尤其遇到需要编译的 Formula能等到人发困。界面不能这么干等着必须有一个反馈机制。BrewUI 的做法是在主进程维护一个任务队列每个任务有独立的 ID状态为 pending / running / success / failed 之一。当用户点击安装时渲染进程发起ipcRenderer.invoke(brew:install, name)主进程创建一个任务并执行import { execFile } from child_process; import { promisify } from util; const execFileAsync promisify(execFile); export async function installFormula(name: string): Promise{ code: number; output: string } { try { const { stdout, stderr } await execFileAsync(brew, [install, name], { maxBuffer: 10 * 1024 * 1024, // 10MB }); return { code: 0, output: stdout stderr }; } catch (error: any) { return { code: error.code || 1, output: error.stdout error.stderr }; } }这个函数本身是异步的但有一个问题在 UI 层我只能知道它最后成没成看不到过程中的实时输出。要是装一个包装到一半卡住了用户只能看到一个转圈完全不知道发生了什么。为了解决这个问题我把execFileAsync换成了实时流式的spawn把stdout和stderr的行数据通过 IPC 事件推送到渲染进程import { spawn } from child_process; import { BrowserWindow } from electron; export function runBrewCommand( win: BrowserWindow, taskId: string, args: string[] ): Promisenumber { return new Promise((resolve) { const child spawn(brew, args, { stdio: [ignore, pipe, pipe] }); child.stdout.on(data, (chunk) { const text chunk.toString(); win.webContents.send(brew:output, { taskId, text }); }); child.stderr.on(data, (chunk) { const text chunk.toString(); win.webContents.send(brew:output, { taskId, text }); }); child.on(close, (code) { resolve(code || 0); }); }); }然后渲染进程在任务进行中时把收到的output累积到任务详情里显示在一个可展开的日志区域。这样用户既能知道正在下载包也能在失败时直接从界面复制报错信息去搜索引擎。2.4 刷新策略不要频繁扫全量如果每次切换页面都重新跑一遍brew list --jsonv2这个命令大概要 1-2 秒用户会明显感受到卡顿。更重要的是brew操作本身很消耗资源频繁跑会拖慢整个系统。BrewUI 的刷新策略分两级全局状态变更时比如安装/卸载成功全量重新读取一次数据。这里的全量是brew list --jsonv2和brew outdated --jsonv2并行跑结果合并后一次性推送。非变更操作比如只是打开详情面板从已经缓存的内存数据里直接取绝不重新执行命令。brew outdated这个命令在数据量大时其实挺慢的因为它会访问远程仓库源。所以我还做了一个优化后台每 30 分钟自动跑一次结果缓存在内存里界面上哪个地方需要用 可升级 状态时直接从缓存读。避坑提示brew outdated --jsonv2的 JSON 输出格式在较新的 Homebrew 版本上不一定稳定建议加上--formula和--cask参数分别获取合并处理。如果某个版本解析报错界面宁可显示未知状态也不要让整个列表崩溃。3. 实操过程与核心环节实现3.1 初始化项目骨架我用了 electron-vite 这个脚手架因为它在构建、热更新和 TypeScript 支持上都做得很顺手比手撸 webpack 配置省了至少两天时间。npm create quick-start/electronlatest brewui -- --template react-ts这个命令会生成一个带 React TypeScript 的 Electron 项目模板。目录结构大概是src/ main/ # 主进程代码 preload/ # 预加载脚本 renderer/ # React 应用模板自带 IPC 的基本示例先不用管后面会改成我自己需要的接口。这个阶段我要做的是把能跑的模板变成知道要干什么的模板——把无关的示例代码清掉建好目录结构。3.2 主进程命令与服务化主进程的核心是runBrewCommand这个函数。但它有个隐患如果同时启动多个 brew 进程比如用户快速点了两次安装不同的包它们会并行执行而 Homebrew 自己是有锁的PREFIX/var/homebrew/lock。等锁的时间不定有时候甚至会因为锁冲突直接失败。我在设计任务队列时就把串行化内置进去了同一时刻最多只有一个 brew 命令在执行其余任务排队等待。let taskQueue: string[] []; let running false; export async function enqueueBrewCommand( win: BrowserWindow, args: string[] ): Promisenumber { return new Promise((resolve) { taskQueue.push(args.join( )); processQueue(win, resolve); }); } async function processQueue(win: BrowserWindow, done: (code: number) void) { if (running) { return; // 已经有任务在跑等队列流转 } running true; while (taskQueue.length 0) { const argsStr taskQueue.shift(); const args argsStr!.split( ); try { const code await runBrewCommand(win, args); if (code ! 0) { // 失败了也要继续不能把后续任务堵死 } } catch (err) { // 记录错误 } } running false; done(0); }上代码有个问题done只会在最后一个任务完成后被调用而每次调enqueueBrewCommand传入的done都不太一样。为了让每个排队任务都能拿到自己的结果我把 Promise 的 resolve 和任务参数一起存进队列而不是上面那样粗暴拼接。这是我在实际开发中调整过的一个细节也踩了一下回调混乱的坑。修正后的核心逻辑是type QueueItem { args: string[]; resolve: (code: number) void; }; let queue: QueueItem[] []; let running false; export function enqueueBrew(win: BrowserWindow, args: string[]): Promisenumber { return new Promise((resolve) { queue.push({ args, resolve }); if (!running) processNext(win); }); } async function processNext(win: BrowserWindow) { running true; const item queue.shift(); if (!item) { running false; return; } const code await runBrewCommand(win, item.args); item.resolve(code); processNext(win); }这里的processNext是递归的。每完成一个任务就从队列头取下一个队列空了就重置 running 状态。这样每个enqueueBrew调用者的 Promise 都能在自己对应的任务完成时被 resolve。3.3 渲染进程状态管理与列表展示前端部分用 React Zustand。Zustand 比 Redux 轻很多写起来也顺手。核心状态是一个 objecttype BrewState { formulas: FormulaInstance[]; casks: CaskInstance[]; outdated: string[]; tasks: Recordstring, BrewTask; loading: boolean; lastUpdated: Date | null; refresh: () Promisevoid; install: (name: string, type: formula | cask) Promisevoid; uninstall: (name: string, type: formula | cask) Promisevoid; upgrade: (name: string, type: formula | cask) Promisevoid; };界面的核心是两个列表Formula 列表和 Cask 列表。这是我见过最常用的两个视图。列表项呈现的关键信息包括名称、描述、已安装版本、是否有新版本、是否作为依赖安装。状态用不同颜色的徽标区分点击行可以展开详情面板。列表展示上有一个最容易被忽视的小问题——搜索。brew 包名称通常是python3.13、libpng这样的短名称用户习惯直接输入名称搜索。我的实现里搜索过滤不只在当前列表内做字符串匹配还会根据desc字段做模糊匹配。这样用户搜 json 就能看到jq、jsoncpp这些而不是只能搜到名称里带 json 的包。搜索部分的核心代码如下function filterPackages(packages: Formula[], query: string): Formula[] { if (!query) return packages; const q query.toLowerCase(); return packages.filter((p) { const nameMatch p.name.toLowerCase().includes(q); const descMatch p.desc?.toLowerCase().includes(q) || false; return nameMatch || descMatch; }); }这看似很简单但结合实时输入防抖才有实际价值。我加了一个 300ms 的防抖窗口避免每次按键都触发列表过滤导致输入卡顿。3.4 依赖关系可视化怎么做的BrewUI 详情面板里有一个依赖关系模块用简单的树形结构展示这个包依赖谁和谁依赖这个包。实现这个的关键突破在于JSON output 里已经包含了dependencies和runtime_dependencies字段不需要自己去解析brew deps。我第一次做这个功能时天真地以为dependencies就够了但在实际测试中发现有些包比如openssl被上百个包依赖如果要把谁依赖它完整展示出来需要自己遍历所有包的 dependencies 字段去反查。这在大仓库上是很耗时的。后来我折中了一下只对用户当前选中的包做反查不做全量反查先保证详情面板打开时速度够快。对于全量依赖图这种扩展功能我标记为 low priority避免过度设计。3.5 安装 / 卸载 / 升级操作的完整流程以安装为例实际操作中的完整链路如下用户在列表页面点击某一行右侧的安装按钮。渲染进程调用 Zustand store 里的install(name, type)。install函数通过 preload 暴露的 APIwindow.api.installPackage(name, type)向主进程发 IPC。主进程的enqueueBrew把参数组装成brew install [name]或者brew install --cask [name]压入任务队列。命令开始执行后主进程用spawn的实时输出通过webContents.send(brew:output, ...)推送进度。渲染进程订阅window.api.onBrewOutput事件把收到的每一行追加到当前任务的日志区域。命令结束后主进程返回退出码。如果是 0安装成功触发全量刷新如果非 0把错误日志直接展示给用户并标记任务失败。这里有个值得注意的细节升级操作的行为在不同 Homebrew 版本上有细微差别。早期版本brew upgrade [formula]只升级指定包但现在有些版本会连带升级它的依赖。如果想要只升级这一个包需要在安装时额外传--ignore-dependencies。但我不建议默认这么做依赖的升级通常是安全的连带的升级并不会额外引入风险。在 UI 上我没有加这个选项因为绝大多数用户并不知道也不关心这个细节。实操心得在 UI 上安装和升级按钮我做了明显的状态区分。如果当前版本和最新版本一致按钮置灰显示已是最新。如果可升级按钮文案是升级。永不出现已是最新 可升级同时成立的错误状态——这种错误的根源通常是刷新时机没做对。4. 常见问题与排查技巧实录实际开发过程中遇到的坑比预期的要多。挑几个比较典型的记录下来给后面做类似项目的人排排雷。4.1 GUI 应用里 PATH 不完整Electron 应用启动时它的 PATH 环境变量和你在终端里看到的并不一样。在终端中通常通过 shell 配置比如.zshrc添加了/opt/homebrew/binApple Silicon 上 Homebrew 的默认路径但在 GUI 应用的环境里这个路径可能会丢失。结果就是brew命令直接找不到spawn brew ENOENT。第一次跑测试时我盯着这个报错想了半天——明明终端里 brew 好好地。解决方案是硬编码 brew 的绝对路径或者在主进程启动时手动把路径补上import { app } from electron; const BREW_PATHS [ /opt/homebrew/bin, // Apple Silicon /usr/local/bin, // Intel ]; app.whenReady().then(() { const currentPath process.env.PATH || ; const missingPaths BREW_PATHS.filter((p) !currentPath.includes(p)); if (missingPaths.length 0) { process.env.PATH [...missingPaths, currentPath].join(:); } });这是一个不起眼但极其致命的环境坑不处理的话应用在非开发者环境上根本无法工作。4.2 Homebrew 命令锁冲突导致假死Homebrew 自己有一个锁机制同一时间只能跑一个写操作。如果在终端开了一个brew upgrade然后 BrewUI 又发起一个brew install后发起的命令会一直等待没有任何输出在界面上看起来就是卡住了。排查到原因之后我在 UI 上做了一个当前有写操作在进行的全局进度条并且把任务队列的状态透明呈现出来。如果某个命令在排队界面上会显示等待其他任务完成。另外当 brew 命令持续无输出超过 30 秒时我会在输出区打一行提示告诉用户可能正在等待其他 brew 进程释放锁这样用户不至于以为应用死掉了。4.3 版本号解析的隐藏错误brew list --jsonv2返回的versions字段是一个对象看起来长这样versions: { stable: 1.0.2, head: null, bottle: true }这个stable是最新可用版本。而实际安装的版本在installed[0].version里。要判断这个包是否可升级理论上只要比较两个字段是否一致。但现实远比这复杂有些包装在本地的是2.0.0_1而 stable 是2.0.0——这是 revision 的差异本质上是同一个版本。有些包的版本号带后缀来自自定义 formula 或 tap和官方版本表示不一致。有些包用日期格式比如 nightly 类比较时不能用语义化版本直接比。我最终决定不用自己写的比较逻辑直接信任brew outdated的结果。它管哪些包可升级是官方钦定的事实来源。UI 上显示的可升级状态完全来自brew outdated --jsonv2解析出的包名列表而不是自研的版本比较。这是一个重要的经验工具能提供的官方判断不要自己做。自己做的判断越多边界情况就越多维护成本就越高。4.4 升级后列表没刷新这是我早期遇到的一个低情商 bug。安装成功后界面上的列表有个很短暂的时间段显示旧数据然后才跳变到新数据。用户感知上会认为应用没反应。解决办法不是在安装成功后立即刷新而是延迟 500ms 再触发刷新——给 brew 文件系统层一点喘口气的时间。实测在大多数机器上500ms 足够让文件系统索引生效保证刷新出来的数据准确。这个延迟时间其实很重要。太短了文件系统还没落盘完毕太长了用户觉得响应慢。我试过 200ms、500ms、1s 三档500ms 在机械硬盘 macOS 上偶尔还是会刷出旧数据但在 NVMe SSD 上是稳定的。如果你复现了刷出旧数据的问题可以尝试把延迟调到 1s。4.5 常见问题速查表问题现象可能原因解决方案启动后报 ENOENT找不到 brewGUI 环境 PATH 不完整主进程手动合并 /opt/homebrew/bin 和 /usr/local/bin安装进度长时间无状态Homebrew 锁等待UI 显示排队状态输出区提示等待锁释放已安装但界面仍显示未安装刷新时机太早延迟 500ms 再触发全量刷新列表加载慢切换卡顿每次都全量执行 brew list内存缓存 定期后台刷新安装失败但日志是空的spawn stdout 没有正确转发检查主进程是否用 webContents.send 推送输出界面显示可升级但 brew outdated 没有版本号比较逻辑有误改用 brew outdated JSON 作为官方事实4.6 性能优化实测记录做性能优化时我用一个小规模测试跑了一下安装 180 个 Formula 20 个 Cask 的环境下首次打开应用加载列表耗时大约 2.1 秒主要是 brew list 和 brew outdated 的冷启动时间。加了缓存后后面每次切换页面或打开详情都稳定在 50ms 内。内存方面Electron 基础占用在 180-250MB 之间。这个数字对于开发工具类 App 来说处于可以接受的范围。没有做极端优化也不打算做。如果未来用户反馈比较多再考虑加惰性渲染只在滚动到可视区域时才渲染列表项可以进一步降低 DOM 节点数和滚动卡顿。4.7 一些避坑心得整个开发过程中我最大的体会是给 CLI 工具做 GUI功夫在边界而不在颜值。界面好不好看只是第一眼印象。真正决定体验的是界面背后那条CLI 通信的链路稳不稳定、数据准不准确、任务执不执行的可靠。另外Homebrew 本身的升级频率不低不同版本间的 JSON 结构也在缓慢演比如 Cask 的一些字段在不同版本上有差异。开发时一定要把解析失败作为一个正常分支来对待而不是让整个应用崩溃。我的做法是所有解析 JSON 的地方都包一层 try/catch解析失败时吞掉这个包给它标记一个数据异常的高亮样式。这样至少不会因为一个格式变化而让整个列表白屏。最后再分享一个小技巧在渲染进程里把 brew 的输出日志提供复制按钮这比什么都管用。用户遇到问题来找你时一句日志复制一份给我比让他在终端里手动贴要省太多沟通成本了。这个按钮只花了我两分钟时间但它为后续的远程问题排查省了至少一周的拉扯。
返回列表