尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BrewUI项目复盘:给Homebrew配上图形化驾驶舱

BrewUI项目复盘:给Homebrew配上图形化驾驶舱 写个BrewUI的项目复盘说点你们在GitHub README里绝对看不到的东西。先交代一下背景。熟悉macOS开发环境的人基本都绕不开Homebrew用命令行装个nginx、node、postgresql一句brew install就搞定。但Homebrew有个天然的门槛——它建立在命令行之上一切操作都需要记忆和输入指令新人不习惯老人也偶尔要有一次brew services list才能想起来某个服务到底有没有在跑。BrewUI就是冲着这个问题来的给Homebrew这辆老牌赛车装一个驾驶舱用可视化的方式管理包、服务、依赖关系和升级流程。这个项目的定位很直接不是替代Homebrew而是当一个称职的“图形化控制面板”。它把终端里那些频繁使用的命令比如brew list、brew update、brew upgrade、brew services start/stop、brew info、brew deps --tree变成可以点击、搜索、一眼扫过就能获取信息的界面。核心价值不是炫技而是降低使用门槛同时把被命令行掩盖的信息结构呈现出来。先说我做这个项目时定下的几件事第一必须支持brew services的完整管理这是很多人装完Homebrew之后最常用的功能之一第二依赖关系必须可视化brew deps --tree的输出在终端里看起来还行但对于依赖一多就乱的包图形化树状图会直观太多第三所有操作必须有日志回显毕竟底层还是命令行事你得让用户知道点击“升级”之后到底发生了什么第四绝不能锁死用户界面保留终端入口。1. 项目整体设计与拆解思路这个项目叫BrewUI听起来像是一个Homebrew的“皮肤”但做起来远不止套壳这么简单。第一步要解决的是Homebrew命令行的输出如何进行结构化解析否则界面拿到一堆终端文本也白搭。1.1 核心需求拆解不只是换个窗口我把用户场景拆成四个模块包管理、服务管理、依赖分析与升级控制。包管理查看已安装包列表、搜索可用包、查看某个包的详细信息版本、安装路径、依赖了什么;服务管理查看通过brew services管理的后台服务支持启动、停止、重启查看运行状态;依赖分析以树状图展示某个包的依赖关系反向查一下谁依赖了它这个在命令行里比较麻烦但图形界面做起来很顺手;升级控制区分brew update和brew upgrade可以批量升级也可以单包升级升级前展示依赖变更预览。这四个模块覆盖了90%以上用户日常对Homebrew的使用频率。其余的比如创建自己的tap、编辑formula、处理冲突属于低频深度操作我决定在第一版不做保持产品廉价快捷。1.2 方案选型为什么不用纯前端方案BrewUI的整体架构我必须坦白说最初考虑过用纯Web实现做个本地Web服务浏览器访问前端像Dashboard一样展示数据。这个方案上手快UI也漂亮。但后来放弃了。原因有三个。第一Homebrew本身依赖/opt/homebrew目录Apple Silicon或/usr/localIntelGUI工具需要和系统路径、环境变量、shell配置文件打交道用Web服务绕一圈再调命令行权限和路径处理会多余很多层。第二Web页面每次都要启动一个本地服务关闭、重启、端口占用这些在原生应用里面根本不存在。第三Surface层要做菜单栏快捷入口和系统通知升级完成后弹个通知原生代码反而更顺手。最终选型是Electron。我知道很多人对Electron有成见说它体积大、内存高但在这里它有不可替代的优势Node.js的子进程调用非常成熟可以直接spawn(brew, [list])拿输出前端也能用现代前端框架快速迭代UI。而且Homebrew用户群体的设备性能普遍不差几百兆内存的代价换来快速开发和多平台适配这笔账划算。1.3 信息架构与数据流设计数据流是这个项目的心脏。我在设计时遵循了这样一套原则所有界面上的数据都来自本地Homebrew命令的真实输出做一层解析转换成结构化JSON再由前端渲染。绝不凭空捏造包的状态也不允许UI和实际命令行状态不一致。每个页面的数据获取都做了缓存策略比如列表页每30秒刷新一次详情页在打开时拉取升级按钮点击后强制刷新依赖树。这避免了一个常见的问题用户明明升级了某个包UI却还显示旧版本。所有写操作安装、卸载、升级、服务启停都是一个长时间运行的子进程前端用轮询或IPC事件获取进度完成后统一刷新。这个过程有点像在写一个“胶水层”——把散落的命令行工具拼接成一个有逻辑的整体。难点不在于单个命令的调用而在于状态的同步、错误处理的覆盖、各种异常输出的兼容。2. 核心功能模块与关键实现2.1 包列表与搜索如何把brew list变成仪表盘包列表是用户进入BrewUI后看到的第一个页面。命令行里执行brew list只能看到一个包名列表没头没尾BrewUI要呈现的信息远多于此包名、当前版本、安装时间、占用的磁盘空间、是否有新版本可升级、被哪些包依赖。这些信息分散在不同的命令里需要拼合。brew list --versions拿到包名和版本号brew info --jsonv2 --installed拿到详细的JSON结构包含安装时间、依赖关系、下载统计等brew outdated拿到可升级的包列表du -sh或者读取brew --prefix下对应目录大小估算每个包的磁盘占用。拼合逻辑上最麻烦的是中文包名和特殊字符包名。Homebrew允许包名包含版本号比如python3.12解析的时候如果把当作分隔符切错会导致后续依赖分析和升级全部错位。我踩过这个坑后来统一用JSON输出字段做主键而不是自己解析文本。搜索功能的实现也值得说一下。Homebrew的brew search支持模糊匹配但那个命令在交互模式下会有输出动画不适合程序化调用。我改用brew search --formula和brew search --cask分开搜每次只处理纯文本输出再在前端本地做一次过滤这样既保证了搜索速度也方便同时匹配已安装和未安装的包。2.2 依赖图与反向依赖一眼看穿包之间的关系依赖可视化是BrewUI最受好评的功能也是技术上最容易翻车的地方。Homebrew提供brew deps --tree package命令能画出依赖树。但终端输出的树是文本符号拼的格式不完全稳定节点缩进在不同终端宽度下会变化直接解析文本树非常脆弱。我后来改用brew info --jsonv2 package这个命令返回完整的依赖数组包括dependencies、build_dependencies、recommended_dependencies、optional_dependencies。拿到数组之后在前端用递归方式构建树结构交给图可视化库渲染。反向依赖更麻烦因为Homebrew没有直接的“谁依赖了这个包”命令。我的做法是维护一个本地已安装包的依赖索引每次进入反向依赖页面时遍历所有已安装包的JSON数据建立一张反向映射表从包A出发找到所有直接或间接依赖了A的包。这个计算量在已安装包超过500个时会有感所以我加了异步计算和缓存页面先渲染出已缓存的结果后台跑完再更新。2.3 服务管理把brew services做成开关brew services start|stop|restart service是Homebrew管理后台服务的最常用命令。它管理的服务来自/Library/LaunchDaemons系统级和~/Library/LaunchAgents用户级BrewUI把这两类分开展示避免用户误操作系统级服务。状态的判断不能只看进程是否存在。brew services list输出的状态列有三种started、stopped、error。其中error状态很坑可能是配置错误、端口冲突、权限问题也可能是服务自己崩了。BrewUI在处理时会把error状态的服务的最近日志片段一并展示在界面上省得用户再到/opt/homebrew/var/log/里翻文件。服务启停的操作我封装成了一个队列所有动作串行执行避免用户连续点击“启动A”和“启动B”导致两个brew services进程并发打架。Homebrew并发执行自身命令时经常出现数据库锁冲突这一点大家使用命令行时感受不深但GUI叠加用户快速点击场景就暴露了。实际操作中这个问题非常关键不加锁用户连续操作三次就会出现“Another active Homebrew process is already in progress”报错。2.4 升级流程与日志回显透明操作才能让人放心升级是BrewUI操作频率最高的功能也是最需要有“掌控感”的功能。界面拆成两段brew update更新Homebrew自身brew upgrade升级所有可升级的包。这里有个容易被忽略的细节brew upgrade默认会升级所有已过期的包用户有时候只希望升级某一个BrewUI在界面上做了单选升级的入口实际执行的是brew upgrade 特定包名。升级过程不是一个瞬间动作尤其在大版本升级时可能耗时几分钟。我选择把子进程的stdout和stderr实时通过IPC推送到前端在界面上模拟一个终端面板逐行滚动显示输出。用户能看到从Downloading到Pouring再到Caveats的完整过程每一步都有实感。一旦某个公式编译失败比如本地缺少依赖错误栈完整呈现在面板里用户可以一键复制所有日志这个功能比命令行还方便。升级前还有一个依赖变更预览。brew outdated可以列出将要升级的包但升级后会影响哪些依赖默认命令不告诉你。我的做法是升级前遍历这些包的rev_dependencies反向依赖生成一张“影响范围”的表格提示用户“升级node后会影响这些已安装包”。虽然实际影响和命令行的brew upgrade逻辑一致但把这个信息前置展示UI的安心感提升极大。3. 实操过程与核心环节实现3.1 环境准备与项目搭建开发BrewUI的机器环境是macOS 14.x Homebrew 4.x Node.js 20。Electron版本选了27前端框架用React Vite图表库用vis-networkUI组件库用Ant Design。这个组合不算新但胜在稳定社区问题多遇到坑容易搜到答案。项目初始化用现成的electron-vite模板一次性搞定主进程、渲染进程和预加载脚本的目录结构。关键依赖如下{ dependencies: { electron: ^27.0.0, react: ^18.2.0, antd: ^5.12.0, vis-network: ^9.1.9, dayjs: ^1.11.10 }, devDependencies: { vite: ^5.0.0, electron-builder: ^24.9.1, concurrently: ^8.2.2 } }用concurrently启动开发模式一条命令同时拉起Vite和Electron主进程代码热更新。这个体验太重要了改一行React代码不需要重启整个应用。3.2 子进程调用与数据解析核心的包管理器调用代码我封装了一个BrewService模块统一负责所有和Homebrew的交互。核心思路是每次调用都返回Promise内部分配一个唯一的任务ID主进程通过任务ID向前端推送状态更新。const { spawn } require(child_process); function runBrewCommand(args, options {}) { return new Promise((resolve, reject) { const brewPath process.env.BREW_PATH || /opt/homebrew/bin/brew; const child spawn(brewPath, args, { env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: 1 }, shell: false }); let stdout ; let stderr ; child.stdout.on(data, (data) { stdout data.toString(); if (options.onStdout) options.onStdout(data.toString()); }); child.stderr.on(data, (data) { stderr data.toString(); if (options.onStderr) options.onStderr(data.toString()); }); child.on(close, (code) { if (code 0) { resolve({ stdout, stderr }); } else { reject(new Error(Command failed with code ${code}: ${stderr})); } }); }); }这里有一个关键的环境变量HOMEBREW_NO_AUTO_UPDATE: 1。如果不设置这个每次执行brew install或brew upgradeHomebrew都会先自动执行一次brew update对于UI场景来说这会导致操作响应极慢升级耗时翻倍。我把自动更新关掉升级时机完全由用户决定这也更符合GUI交互的逻辑。JSON解析层我写了一个parseBrewJson函数统一处理brew info返回的数据。因为Homebrew的JSON结构在不同的Homebrew版本间偶尔会有微调解析函数必须做容错处理所有字段都有默认值避免界面因为某个字段不存在直接白屏。function parseInstalledPackages(jsonOutput) { const data JSON.parse(jsonOutput); const packages []; const formulae data.formulae || []; const casks data.casks || []; formulae.forEach((f) { packages.push({ name: f.name, version: f.versions?.stable || unknown, installed: f.installed?.[0]?.version || unknown, dependencies: f.dependencies || [], buildDependencies: f.build_dependencies || [], recommendedDependencies: f.recommended_dependencies || [], optionalDependencies: f.optional_dependencies || [], installedOn: f.installed?.[0]?.installed_on || null, runtimeDependencies: f.installed?.[0]?.runtime_dependencies || [], size: f.installed?.[0]?.size?.poured?.out_of_bottle || null, desc: f.desc || , homepage: f.homepage || , outdated: f.outdated || false, caveats: f.caveats || }); }); casks.forEach((c) { // cask 的处理逻辑略 }); return packages; }这些数据的字段命名我参考了brew info --jsonv2 --installed的真实输出。installed_on不是所有Homebrew版本都返回所以必须加|| null兜底否则前端渲染时间轴会报错。3.3 依赖树构建与渲染依赖树构建是在渲染进程完成的。拿到某个包的依赖数组后我需要递归查询每个依赖包自身的信息拼接成一棵完整的树。这个过程很容易形成性能瓶颈尤其像python这种大包依赖可能有几十个节点。我做了两层优化第一所有已经查询过的包信息缓存24小时不重复向Homebrew发请求第二构建树时做成延迟加载默认只展示两层用户点击展开某节点时才查询更深层依赖。界面渲染用vis-network的快照模式先计算好所有节点和边一次性渲染。依赖树的数据结构大概是这样的const treeData { id: react, label: react, version: 18.2.0, children: [ { id: caco, label: caco, version: 1.0.1, children: [] }, { id: loose-envify, label: loose-envify, version: 1.0.0, children: [...] } ] };反向依赖图用有向图展现节点颜色区分依赖类型直接依赖绿色构建依赖橙色间接依赖灰色。这个功能在排查“为什么升级这个包会影响那么多东西”时特别好用。3.4 服务管理面板的实现服务管理面板核心还是调用brew services命令但我在前端做了状态轮询和时间线记录。启动面板后每5秒请求一次brew services list刷新服务状态表。服务状态的数据结构{ name: nginx, status: started, // started / stopped / error / unknown user: admin, file: /opt/homebrew/opt/nginx/homebrew.mxcl.nginx.plist, exitCode: null, lastLog: Worker process exited with code 0, time: 2024-11-08 14:30:22 }brew services list的默认输出是文本表格不好解析我又是用--json参数拿结构化数据。如果某个服务状态是error界面会高亮显示并提供“查看日志”按钮点击后读取该服务的日志文件最后200行展示出来。这比用户自己去翻日志目录省事一万倍。服务启停按钮做成了反人类的双重确认模式尤其是对stop操作。“停止服务”按钮点击后弹出确认框“重启服务”则直接执行。这个设计是从用户反馈中总结出来的——很多人习惯性点了停止事后完全想不起来是自己点的。3.5 界面交互与终端联动BrewUI左侧边栏是四个导航概览Dashboard、包管理Packages、依赖分析Dependencies、服务管理Services。顶部是全局搜索框可以直接输入任意包名搜索结果会同时展示formula和cask并标明来源。Dashboard是用户打开应用后看到的第一个页面信息密度很高Homebrew版本、当前已安装包数量、可升级包数量、运行中的服务数量、磁盘占用Top10包、最近更新的包列表。这个页面是纯只读的不给操作按钮保持信息面板的清爽感。终端联动功能我设了一个隐藏入口——在包详情页按一下键盘上的“”键会打开一个内嵌模拟终端直接在当前包的上下文环境中执行命令。很多人问为什么要保留这个入口其实道理很简单GUI再全面也有覆盖不到的操作留一条直通命令行的逃生通道既满足了高级用户又逼着自己在GUI覆盖面上做到尽可能广否则用户就会觉得“开头那个终端才是真家伙UI是摆设”。核心操作流程上的快捷键我也做了CommandK聚焦全局搜索Command1/2/3/4切换四个主页面CommandR刷新当前页数据。这些快捷键在发布后意外受欢迎很多用户反馈说从命令行切换过来毫无违和感。4. 常见问题与踩坑记录做BrewUI这段时间踩了不少坑有些是Homebrew本身的怪癖有些是我自己设计上的漏洞。下面把这些记录下来给想给Homebrew写GUI的同学一些参考。4.1 权限问题的处理Homebrew在Apple Silicon上默认安装在/opt/homebrew目录这个目录对普通用户有写权限但服务和某些编译缓存会写到系统目录。遇到过最典型的问题brew services start的时候如果服务名称对应用户级LaunchAgent需要在~/Library/LaunchAgents下有对应的plist文件如果这个文件不存在服务启动会失败但错误提示极不明确。BrewUI在服务管理页面的错误展示做了优化检测到Operation not permitted时会额外提示用户检查系统设置里的“完全磁盘访问权限”这个权限在辅助功能配置里非常容易忽略。还有一类问题是安装包时需要sudo权限比如brew install某些需要写入/usr/local的旧式包。GUI应用弹sudo密码框很别扭我最终选择不在BrewUI里内置sudo而是检测到需要提权时弹出一条提示让用户为当前终端进程提前授权或者直接在系统设置中给应用分配权限。避免把提权逻辑写死在应用里这是安全底线。4.2brew命令并发冲突GUI应用和命令行不同用户可能一边开着终端一边用BrewUI两边同时执行brew install就会触发Homebrew的并发锁机制。报错信息是“Another active Homebrew process is already in progress”在终端里几乎遇不到因为没人会同时开两个终端跑brew但GUI场景下很容易。解决方案是BrewUI在启动时检查/opt/homebrew/var/homebrew/locks下是否有锁文件有的话弹窗提示而不是硬等。同时在应用内部维护一个全局的任务队列所有brew命令串行执行队列状态在界面底部显示用户能看到“第2/3个任务正在执行中”。4.3 JSON字段变化导致的兼容性问题Homebrew版本更新频率高brew info --json的输出结构也不是一成不变。我在开发过程中经历过至少两次字段调整一次是runtime_dependencies从数组变成小写开头的对象一次是某些包从formulae里挪到了casks里。对付这种问题没有一劳永逸的办法我只能写一个“数据适配层”定期跑一遍自己的测试用例sample JSON和真实brew输出对比一旦发现字段缺失就走兜底逻辑。好在这类变更不频繁而且Homebrew官方对JSON schema有文档背书一般变动会在changelog里提前看到。4.4 磁盘占用计算的坑显示包大小是很多人喜欢的功能但brew info --json里并不总是包含每个包的安装大小。即便有也只是安装时的快照升级后未必会更新。我尝试过直接扫目录算大小但/opt/homebrew/Cellar下每个包都是实际安装内容有些包含符号链接直接递归统计会重复计数。最后采用的方法是du -sk单目录计算并忽略符号链接同时缓存结果72小时。对于超过1GB的大包比如node、pyenv界面会显示“估算大小”避免误导。4.5 服务状态变化不实时brew services list并不是常驻进程你每次调用它它都要去读plist文件、检查进程状态所以5秒轮询已经是比较合理的频率。用户如果期望像活动监视器那样的实时数据那就不现实了Homebrew服务本身也没有推送机制。这个限制在UI上的解决方案是服务状态标签保留上一次轮询结果的时间和状态在状态变化时短暂高亮然后才更新。这样用户能感知到“服务刚才是启动的现在是停止的”而不是看到突兀的状态跳变。5. 技术选型与工具对比5.1 为什么不用TauriBrewUI发布后被问最多的问题就是“为什么不用Tauri”。Tauri的Rust后端确实更轻量内存占用和包体都比Electron小很多。但我当时评估Homebrew操作需要频繁的进程调用、环境变量管理、JSON解析这些功能对Rust来说生态不如Node.js顺手。Node的child_process、JSON.parse和npm生态里的解析库都是一等公民能帮我快速迭代不必在系统库上耗费时间。如果以后出2.0版本我可能会考虑用Tauri重写重点目的其实是包体积和启动速度而不是运行内存。说句公道话对于用户设备性能普遍不错的场景Electron的性能短板没有想象中那么致命开发效率才是实打实的生产力指标。5.2 配方和Opt分支的区分Homebrew有formula命令行工具和cask图形化应用两种包类型。brew search的结果会同时混入两者但在UI里如果不加区分地展示用户很容易混淆。BrewUI在搜索结果中默认用两个Tab区分formula和cask并且在包详情页用不同图标和配色标记类型。升版本检查时formula用brew outdatedcask用brew outdated --cask两个命令分开跑避免互相干扰。5.3 内置浏览器和外部链接每当展示某个包的github仓库或官网时用shell.openExternal在默认浏览器打开。这个细节很不起眼但体验差别巨大。Electron内置的webview渲染外部页面会有兼容性问题内置BrowserWindow打开外部链接又占资源直接用系统浏览器是最稳的方案。6. 关键决策背后的成本与收益很多人看开源项目只关注代码质量和功能列表却很少去想“为什么这个项目要这么做”。我梳理了几个BrewUI最关键的决策点把背后的成本和收益摊开来说。先拿Electron选型举例。Electron的收益是社区巨大、人力资源好找、调试工具链完善成本是包体积约200MB起步内存常驻约300MB。对BrewUI的目标用户开发人员设备配置普遍不低来说这个成本可以接受收益非常明显。如果目标用户是普通办公人群那这个选型就是灾难。再比如用vis-network而不是自定义SVG绘图。vis-network是一个成熟的网络图库内置缩放、拖拽、节点高亮开箱即用。用它的成本是bundle体积多几百KB收益是省去了大量图交互逻辑的开发时间。我评估过自己手写交互逻辑至少要多花两周时间这还不算调试成本。还有一点比较重要从一开始我就决定BrewUI必须开源。开源带来的收益不仅是用户的信任更重要的是社区反馈能帮你发现设计漏洞。BrewUI的服务管理面板最初的默认排序是按服务名后来有用户提issue说应该按启动时间排序我一看确实更合理改起来也不难这类似的小改进积累了不少。7. 后续规划与扩展方向BrewUI第一版发布之后我收集了不少有价值的反馈。下一步的规划有几个方向。一个是支持多机器管理。因为Homebrew本身是单机的工具BrewUI目前也只在本地跑。但很多开发者拥有两台以上的Mac办公室、家里、服务器如果能支持SSH连接到远程机器在本地UI上管理远程机器的Homebrew包这个场景会非常有价值相当于把BrewUI从一个“本地GUI”变成“包管理控制台”。另一个是升级策略的细化。目前BrewUI只支持升级全部或者升级单个包但有些用户希望对一批特定的包做统一升级比如所有带版本号的运行时python3.11、ruby3.2同时升级这需要引入“升级组”的概念也可以保存升级预设。还有人在issue里提到想加一个brew cleanup的定时清理功能。这个功能很有意思因为很多用户并不了解brew cleanup --pruneall能清理掉多少旧的下载缓存和已卸载包的残留。做成一个默认每周执行的定时任务加上清理前后磁盘空间的对比报告可见性非常强也容易变成留存用户的卖点。8. 写在最后的经验与体会BrewUI做到这个阶段我个人最大的体会是一个工具类项目UI永远不只是把命令翻译成按钮而是要重新思考信息的组织和展示方式。命令行里用户被迫用眼睛扫描文本GUI则可以主动把关键信息前置让用户不用想就知道下一步该点什么。这款工具的适用人群其实比很多人想的宽。不只是开发者那些装了Homebrew但平时只在终端里跑一两个命令的人比如数据分析师、运维刚入门的新手、自己折腾脚本的爱好者他们受益于BrewUI可能比老手更明显。老手可能觉得“不就是个brew Gui吗”但对于害怕黑框的人来说一个可视化的包管理界面本身就是安全感。最后分享一个小技巧如果你也想做这类“给命令行工具做GUI”的项目最忌讳的是把界面做得太花哨。BrewUI从第一版开始UI配色就非常有节制主色调用的是系统蓝灰色系所有的红色只用于错误和危险操作绿色只用于成功状态。任何一个状态通知都做两秒自动消失的处理既不打扰用户又不让人错过关键信息。这种克制的设计风格说实话比功能本身更能积累用户信任。后续如果大家对BrewUI的实现细节感兴趣我可以单独拆几篇博文讲讲依赖图的可视化算法、Electron主进程与子进程的通信机制以及如何对brew的JSON输出做高兼容解析。这几个话题任何一个单独拿出来都能再写几千字。
返回列表