1. 为什么我需要一个 BrewUI:从一个真实的痛点说起
先说个场景。有天同事小白跑过来问我,说他用brew install装了个软件,装到一半报错,终端里刷屏几百行,他不知道是装好了还是没装好,也不知道接下来该干嘛。我过去看了一眼,其实只是某个依赖下载超时,重试一下就好了。但这件小事让我想明白一个道理:Homebrew 本身很强大,可它把所有信息都塞在终端输出里,对不熟悉命令行的人来说,这堆输出就是噪音,而不是信息。
BrewUI 这个名字,说白了就是给 Homebrew 套一个图形界面。它的定位很清晰:你不需要记住brew list、brew outdated、brew upgrade这些命令,打开一个窗口就能看到“我装了哪些包”“哪些包有更新”“哪些包占了多少磁盘空间”,点一下按钮就能完成安装、升级、卸载。这不是要替代 Homebrew,而是把 Homebrew 的能力翻译成更直观的操作。适合的人群也很明确:想用 Homebrew 但不想深钻命令行的普通用户,以及命令行老手想快速概览自己机器上装了什么东西的情况。
坦白说,Homebrew 官方一直没有 GUI,社区里也出过几款工具,但要么年久失修,要么只覆盖了安装和卸载,缺少对依赖关系和磁盘占用的可视化。这给 BrewUI 留出了空间——它不是为了“做一个 GUI 而做 GUI”,而是要把包管理器这个黑盒子打开,让用户能看到里面发生了什么。这篇文章我会从整体设计、核心实现、实操过程到问题排查,把 BrewUI 完整拆一遍,代码片段都会贴出来,你能直接照着复现。
2. 整体设计与技术选型:为什么我选了这套组合拳
2.1 先搞清楚 Homebrew 的数据从哪来
做图形界面第一步,不是画界面,而是搞清楚数据源。Homebrew 的所有状态其实都散落在几个地方:已经安装的 formula 列表、Cask 列表、依赖关系图、安装路径、版本信息。获取这些信息的正统做法是执行brew命令,然后把输出解析成结构化数据。
Homebrew 的 CLI 本身就提供了适合脚本调用的输出格式。比如brew info --json=v2 --installed会把所有已装包的信息以 JSON 格式打出来,包含依赖关系、安装路径、版本号、简介、许可证等字段。brew outdated --json=v2能拿到所有可升级的包。这几个命令就是 BrewUI 的数据基石。
这里要说明一个设计判断:为什么不直接去读 Homebrew 的数据库文件?Homebrew 在较新版本里确实有一套 SQLite 数据库($(brew --prefix)/var/homebrew下的相关文件),记录 formula、依赖、安装状态。但直接读数据库是脆弱的——Homebrew 的数据库结构在不同版本间可能变化,而且它没有公开承诺这是一个稳定接口。相比之下,brew命令的 JSON 输出反而是官方认可的机器可读接口,断代风险小得多。所以 BrewUI 选择“命令输出 + JSON 解析”的路线,而不是“直连数据库”。
2.2 技术栈选型:Python + PySide6 的理由
整个项目我用了 Python 3.10+ 和 PySide6(Qt for Python)。选 Python 的理由很实际:Homebrew 本身是 Ruby 写的,但它的命令行输出对任何语言都友好,Python 解析 JSON 和处理子进程非常顺手,生态里也有 pytest、pyinstaller 这些配套工具。如果你更熟悉 Node.js 或 Go,也可以做,但 Python 是这条路线上试错成本最低的选项。
PySide6 这边,对比过 Tkinter、Electron 和 PySide6 三者。Tkinter 太简陋,做一张好看的列表都费劲,而且在高分屏上的渲染表现很一般。Electron 那一套,开发体验确实顺畅,UI 也能做得漂亮,但打包体积动辄 100MB 起步,对于一个“轻量工具”来说太重了。PySide6 是 Qt 官方支持的 Python 绑定,控件丰富,原生感强,打包后体积能控制在 30MB 左右,性能还比 Electron 好一截。对 BrewUI 这种“列表 + 详情 + 按钮”为主的界面,Qt 的 QTableView / QTreeView 几乎是量身定做的。
2.3 核心模块划分
整个项目拆了四个模块,各管一摊:
- brew_core.py:负责执行 brew 命令、解析 JSON、把数据整理成结构化的 Python 对象。
- ui_main.py:主窗口,负责界面布局、信号槽绑定、数据刷新。
- models.py:数据模型层,把 brew 的原始 JSON 转成 Qt 的 Model/View 架构可用的数据模型。
- tasks.py:后台任务管理,用 QThread 跑耗时命令,避免界面卡死。
这个拆分很常规,但它的好处是:如果哪一天 Homebrew 的 JSON 格式变了,你只需要改 brew_core.py 的解析部分,界面逻辑完全不用动。如果想把 BrewUI 从桌面搬到 Web 端,也只要把 brew_core.py 和 models.py 换掉,UI 层保留一半以上的逻辑。
3. 核心实现细节:从 JSON 到界面数据的完整链路
3.1 brew_core.py:用 subprocess 可靠地拿数据
子进程执行是 BrewUI 最核心的交互方式。这里有几个关键点必须处理好:
第一,subprocess.run必须带capture_output=True,同时把text=True加上,否则拿回来的是 bytes 对象,处理起来麻烦。第二,一定要设置timeout。某些 brew 命令(比如brew update)会去访问网络,慢起来能拖几分钟,界面端会一直等。我在设计上把网络相关的命令单独设置了一个较长的超时(比如 120 秒),本地查询命令(比如brew list)超时就设短一些(15 秒)。第三,环境变量要手动控制,特别是HOMEBREW_NO_AUTO_UPDATE。如果不设置这个变量,每次执行 brew 命令都可能触发自动更新,一个列表查询操作能卡上几十秒,完全没法接受。
下面是最核心的代码片段,它负责获取所有已安装包的信息:
import json import os import subprocess from dataclasses import dataclass, field @dataclass class InstalledPackage: name: str version: str installed_version: str path: str dependencies: list = field(default_factory=list) desc: str = "" license: str = "" class BrewCore: def __init__(self): self.brew_bin = self._find_brew() self.env = os.environ.copy() self.env["HOMEBREW_NO_AUTO_UPDATE"] = "1" self.env["HOMEBREW_NO_INSTALL_CLEANUP"] = "1" def _find_brew(self): # 在 PATH 中找 brew,找不到就抛异常 from shutil import which path = which("brew") if not path: raise RuntimeError("Homebrew 未安装,请先安装 Homebrew") return path def _run(self, args, timeout=30): proc = subprocess.run( [self.brew_bin] + args, capture_output=True, text=True, timeout=timeout, env=self.env, ) if proc.returncode != 0: raise RuntimeError(proc.stderr.strip()) return proc.stdout def get_installed_packages(self): raw = self._run(["info", "--json=v2", "--installed"], timeout=60) data = json.loads(raw) packages = [] for formula in data.get("formulae", []): pkg = InstalledPackage( name=formula["name"], version=formula["versions"]["stable"], installed_version=formula["installed"][0]["version"], path=formula.get("installed", [{}])[0].get("path", ""), dependencies=formula.get("dependencies", []), desc=formula.get("desc", ""), license=formula.get("license", ""), ) packages.append(pkg) return packages def get_outdated_packages(self): raw = self._run(["outdated", "--json=v2"], timeout=60) data = json.loads(raw) return data.get("formulae", [])这里有个细节,installed字段是一个数组,因为同一个 formula 可能装多个版本。数组里每项有version和path字段,我取了第一项作为当前版本。多版本共存的情况在真实环境里不少见,这个处理方式能保证不报错,后续如果想做“切换到另一个版本”的功能,这里的数据结构也能直接支持。
3.2 数据模型:把 JSON 变成 Qt 能懂的东西
PySide6 的 QTableView 依赖 Model/View 架构,你需要继承QAbstractTableModel来实现自己的数据模型。这一步不能偷懒,因为模型的data()方法直接决定表格里每个格子显示什么内容。
在 models.py 里,我定义了一个PackageTableModel,它接收list[InstalledPackage]作为原始数据,对外暴露三列:包名、当前版本、描述。依赖关系不在这里展示,而是在详情面板里单独呈现,这样表格能保持简洁。
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt class PackageTableModel(QAbstractTableModel): HEADERS = ["包名", "当前版本", "描述"] def __init__(self, packages=None): super().__init__() self._packages = packages or [] def rowCount(self, parent=QModelIndex()): return len(self._packages) def columnCount(self, parent=QModelIndex()): return len(self.HEADERS) def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None pkg = self._packages[index.row()] col = index.column() if role == Qt.DisplayRole: if col == 0: return pkg.name elif col == 1: return pkg.installed_version elif col == 2: return pkg.desc elif role == Qt.UserRole: # 整行数据作为自定义角色存储,方便点击后取整个对象 return pkg return NoneQt.UserRole这个设计很关键。界面上点击某一行的“详情”按钮时,我需要拿到完整的包对象,而不是自己根据行号去另一个列表里查。从data()里拿到pkg对象,后续所有操作都围着它转,逻辑会非常干净。
3.3 任务线程:不卡界面的最小实现
brew 命令是阻塞式的,直接在主线程执行会冻结整个界面。解决方案是 QThread + 信号。PySide6 里信号槽是线程安全的,任务线程执行完毕后发信号通知主线程更新 UI。
这里我写了一个通用的BrewTaskThread(QThread),它接收一个task_callable,在线程里执行,执行完成后发出成功或失败的信号:
from PySide6.QtCore import QThread, Signal class BrewTaskThread(QThread): finished_ok = Signal(object) finished_err = Signal(str) def __init__(self, task_callable, *args, **kwargs): super().__init__() self._task_callable = task_callable self._args = args self._kwargs = kwargs def run(self): try: result = self._task_callable(*self._args, **self._kwargs) self.finished_ok.emit(result) except Exception as e: self.finished_err.emit(str(e))这个设计解决了一个实际问题:用户点击“升级全部”按钮后,升级可能要跑几分钟,期间用户可以继续浏览其他页面。如果不用 QThread,用户只能对着一个“转圈”的窗口干等,体验非常差。用了 QThread 之后,升级过程中的日志也会通过信号实时推送到界面上的日志区,用户能看到“当前正在更新 xxx”这样的文本,心里有底。
4. 实操过程:把 BrewUI 跑起来,再做一次完整的包管理操作
4.1 环境准备与项目骨架
假设你现在从零开始复现 BrewUI,我先列一下最低环境要求,这些我都实测跑过:
- macOS 12 及以上(Linux 也能跑,后面会讲差异点)
- Python 3.10+
- Homebrew 4.x
- PySide6 库
创建项目目录并安装依赖:
mkdir brewui cd brewui python3 -m venv .venv source .venv/bin/activate pip install PySide6接下来把 brew_core.py、models.py、tasks.py 写好,最后写 ui_main.py 把界面拼起来。主界面的布局我有意做得精简,左侧是一个搜索框和包列表,右侧是包详情和操作按钮区,底部是日志区。把工具栏放在窗口顶部,依次是“刷新列表”“检查更新”“升级全部”“清理缓存”四个按钮。这个布局经过几轮调整才定下来,核心逻辑是:用户 80% 的操作集中在“看列表”“查详情”“点升级”这三件事上,界面应该让这三件事在一两步之内触达。
4.2 主窗口代码:Qt 布局和信号槽拼接
下面这段是 ui_main.py 的核心部分,它展示了主窗口的骨架和几个按钮的信号绑定方式:
from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableView, QPushButton, QLineEdit, QTextEdit, QLabel ) from PySide6.QtCore import Qt from models import PackageTableModel from tasks import BrewTaskThread from brew_core import BrewCore class MainWindow(QMainWindow): def __init__(self): super().__init__() self.core = BrewCore() self.setWindowTitle("BrewUI") self.resize(1100, 700) central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) # 工具栏 toolbar = QHBoxLayout() self.btn_refresh = QPushButton("刷新列表") self.btn_outdated = QPushButton("检查更新") self.btn_upgrade_all = QPushButton("升级全部") self.btn_cleanup = QPushButton("清理缓存") toolbar.addWidget(self.btn_refresh) toolbar.addWidget(self.btn_outdated) toolbar.addWidget(self.btn_upgrade_all) toolbar.addWidget(self.btn_cleanup) toolbar.addStretch(1) root.addLayout(toolbar) # 搜索框 self.search_input = QLineEdit() self.search_input.setPlaceholderText("搜索已安装的包...") root.addWidget(self.search_input) # 主内容区 content = QHBoxLayout() self.table = QTableView() # 右侧详情区 detail_panel = QVBoxLayout() self.detail_title = QLabel("选择一个包查看详情") self.detail_info = QLabel("") self.detail_info.setWordWrap(True) self.btn_detail_upgrade = QPushButton("升级该包") self.btn_detail_uninstall = QPushButton("卸载该包") detail_panel.addWidget(self.detail_title) detail_panel.addWidget(self.detail_info) detail_panel.addWidget(self.btn_detail_upgrade) detail_panel.addWidget(self.btn_detail_uninstall) detail_panel.addStretch(1) content.addWidget(self.table, stretch=3) content.addLayout(detail_panel, stretch=2) root.addLayout(content, stretch=1) # 日志区 self.log_view = QTextEdit() self.log_view.setReadOnly(True) self.log_view.setMaximumHeight(150) root.addWidget(self.log_view) # 信号绑定 self.btn_refresh.clicked.connect(self.load_packages) self.btn_outdated.clicked.connect(self.check_outdated) self.btn_upgrade_all.clicked.connect(self.upgrade_all) self.search_input.textChanged.connect(self.filter_table) self.table.clicked.connect(self.show_detail) # 初始加载 self.load_packages() def log(self, msg): self.log_view.append(msg) def load_packages(self): self.log("开始加载已安装的包...") def task(): return self.core.get_installed_packages() self.thread = BrewTaskThread(task) self.thread.finished_ok.connect(self.on_packages_loaded) self.thread.finished_err.connect(lambda e: self.log(f"加载失败: {e}")) self.thread.start() def on_packages_loaded(self, packages): self._all_packages = packages self.model = PackageTableModel(packages) self.table.setModel(self.model) self.log(f"加载完成,共 {len(packages)} 个包") def filter_table(self, keyword): if not hasattr(self, "_all_packages"): return keyword = keyword.lower() filtered = [p for p in self._all_packages if keyword in p.name.lower()] self.model = PackageTableModel(filtered) self.table.setModel(self.model) def show_detail(self, index): pkg = index.data(Qt.UserRole) self.detail_title.setText(pkg.name) self.detail_info.setText( f"版本: {pkg.installed_version}\n\n" f"依赖: {', '.join(pkg.dependencies) or '无'}\n\n" f"许可: {pkg.license or '未知'}\n\n" f"简介: {pkg.desc or '无'}" )运行方式很简单:
python ui_main.py窗口弹出来之后,第一屏就会展示当前机器上所有已安装的 formula,如果你机器上有 cask 应用,这个版本的 BrewUI 暂时只展示 formula,cask 支持放在后面扩展。列表加载是异步的,界面不会白屏,日志区会实时输出进度。
4.3 升级操作的完整链路
升级是 BrewUI 的“高频操作”,它的完整链路值得单独讲一遍。用户点“升级全部”按钮之后,程序做三件事:
第一步,先调get_outdated_packages()拿到可升级包的列表。第二步,对每个包执行brew upgrade <包名>,一次只升级一个,这样能更精确地报告每个包的结果。第三步,全部完成后触发一次列表刷新,让表格显示最新版本。
装完需要看效果。在升级过程中,brew upgrade的输出会通过实时管道流向日志区,用户能看到每个包的下载进度和安装状态。这里我用了一个run_live方法,它的核心是用subprocess.Popen逐行读取输出:
def run_live(self, args, log_callback, timeout=600): proc = subprocess.Popen( [self.brew_bin] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=self.env, ) for line in proc.stdout: log_callback(line.rstrip()) proc.wait() if proc.returncode != 0: raise RuntimeError(f"命令失败: {args[0]} {args[1]}") log_callback("命令执行完成")为什么不用一次性读取?因为brew upgrade是个长时间任务,实时输出能给用户带来“程序在干活”的反馈。等它跑完再一次性显示结果,会让用户觉得程序可能卡死了。这个体验细节很重要。
实际测试中,升级一个带大量依赖的包(比如 ffmpeg),整个流程大概需要几分钟。过程中日志区会持续刷出新行,窗口右上角可以加一个进度指示器,但因为 Qt 的进度条需要知道总任务数,而升级过程中依赖数量是动态的,所以我最终选择用“日志 + 按钮禁用”来表达“正在处理中”,而不是强行造一个不精确的进度条。
5. 常见问题与排查技巧实录
5.1 “brew 命令找不到”的排查
这是运行 BrewUI 最常见的启动报错。报错信息通常是RuntimeError: Homebrew 未安装,请先安装 Homebrew。但很多时候用户其实是装了 Homebrew 的,只是 Python 子进程里的 PATH 环境变量不包含 Homebrew 的安装目录。
解决这个问题,关键是_find_brew()的逻辑。它的实现是shutil.which("brew"),但which查的是当前进程的 PATH,而不是你终端里的 PATH。如果你是通过 Finder 双击启动 BrewUI,PATH 可能只有系统默认的/usr/bin:/bin:/usr/sbin:/sbin,/opt/homebrew/bin不在里面。最稳的解法是在BrewCore.__init__里手动检查几个常见安装路径:
def _find_brew(self): from shutil import which candidates = [ which("brew"), "/opt/homebrew/bin/brew", # Apple Silicon "/usr/local/bin/brew", # Intel Mac "/home/linuxbrew/.linuxbrew/bin/brew", # Linux ] for path in candidates: if path and os.path.exists(path): return path raise RuntimeError("Homebrew 未安装,请先安装 Homebrew")我实测遇到最多的情况是:用户终端里能用 brew,但双击启动 GUI 后报找不到。原因就是 PATH。把候选路径硬编码进去,这个问题一次解决。如果你的 Homebrew 装在其他前缀,可以在配置文件里加一个环境变量覆盖。
5.2 界面卡顿:不是 Qt 的问题,是你在主线程跑了命令
有用户反馈“点刷新后窗口直接变白,转圈十几秒”。早期的代码确实把get_installed_packages()放在主线程里调用了,brew 命令执行期间,Qt 的事件循环被阻塞,界面自然就“死了”。
这类问题排查思路很简单:凡是会执行 brew 命令的按钮响应函数,一律开 QThread。这里有个逃不掉的教训——不要为了省事把耗时调用直接写在按钮的 clicked 槽函数里。最开始我图省事,在refresh里直接调brew_core.get_installed_packages(),窗口确实卡过。后来把所有耗时操作都收归到 BrewTaskThread 里统一处理,这个问题彻底灭绝。
排查方法也分享一下:如果你发现 GUI 程序“呆滞”,先看是事件循环被阻塞还是真死锁。最简单的测试是调整窗口大小——如果窗口可以正常拉伸,说明事件循环还活着,只是某个界面元素卡住了;如果窗口完全无法响应,一把就是事件循环被阻塞。BrewUI 早期的卡死属于后者,改线程模型后解决。
5.3 Homebrew 自动更新引发的一切问题
这是 BUG 重灾区。当你执行任何 brew 命令时,Homebrew 默认会先检查自己是否需要更新。如果brew的仓库存在待拉取的更新,它会自动执行brew update,这个过程少则几秒,多则几分钟。在 GUI 场景下,这个“隐式等待”非常致命。
解法就是我前面提到的两行配置:
self.env["HOMEBREW_NO_AUTO_UPDATE"] = "1" self.env["HOMEBREW_NO_INSTALL_CLEANUP"] = "1"第一行关掉自动更新,第二行关掉每次安装后的自动清理。这两个设置对命令行用户也有参考价值,可以写进终端配置里,让日常的 brew 操作更快。
但关掉自动更新不代表不更新 Homebrew 本身。BrewUI 的“检查更新”按钮,如果检测到 brew 自己有可用更新,会在日志区提示“Homebrew 自身可更新,请前往终端执行 brew update”。这里我没有选择在 GUI 里直接执行更新,因为 Homebrew 自我更新有时候会要求输入 sudo 密码,GUI 程序处理密码输入很别扭。让用户去终端执行反而更安全。
5.4 网络问题导致的升级失败
国内网络环境访问 GitHub 相关资源偶尔会有超时情况,表现是安装某个包时卡在“Downloading...”阶段,然后报curl: (28) Operation timed out。这段时间实测下来,给 Homebrew 配置国内镜像源是最有效的解法,但它在 GUI 程序里没法自动完成,因为要改~/.zshrc或/etc/hosts这类用户级配置。
我的建议是在 BrewUI 的“设置”页里增加一个镜像源说明链接,不强制修改,只是把方法告知用户。同时,brew_core.py里可以给网络类命令设置合理的重试次数。简单做法是捕获subprocess.TimeoutExpired后自动重试一次,因为很多超时是瞬时网络抖动,重试就能成功:
def _run(self, args, timeout=30, retries=1): for attempt in range(retries + 1): try: return self._run_once(args, timeout) except subprocess.TimeoutExpired: if attempt == retries: raise RuntimeError(f"命令超时: {' '.join(args)}")这个重试逻辑我实测对“下载慢但最终能成功”的情况很有效。但它不是万能药,如果镜像源本身不通,重试多少次也没用。
6. 打包与分发:让不懂技术的人也能用上 BrewUI
6.1 用 PyInstaller 打成 .app
写完之后,如果只在自己机器上跑,Python 脚本就够了。但 BrewUI 这类工具的价值在于让更多不熟悉命令行的人用上,所以打包是刚需。
PyInstaller 是目前最成熟的方案。安装后执行:
pip install pyinstaller pyinstaller --windowed --name BrewUI --icon=brewui.icns ui_main.py--windowed参数很重要,它告诉 PyInstaller 这是一个 GUI 程序,不弹终端窗口。打包完成后,产物在dist/BrewUI.app,把它拖到“应用程序”文件夹就能像普通 Mac 应用一样使用。
有一个打包坑值得说:PySide6 本身比较大,打包后的应用体积在 30~40MB 是正常的,不要慌。另外,如果用户的 Mac 上没有装 Python 3.10+,PyInstaller 会把 Python 解释器一起打进去,所以目标机器不需要预先装 Python 环境,这也是把工具发给非技术同事的前提条件。
6.2 验证签名与打开提示
没有 Apple Developer 证书的情况下,打包出来的 .app 在别人机器上第一次打开会被 Gatekeeper 拦截,提示“无法验证开发者”。这不是你的程序有问题,而是 macOS 的默认安全策略。用户可以右键点应用图标,选择“打开”,在弹窗里再点一次“打开”就能运行。
如果想彻底规避这个问题,有两个路线:一个是付钱买 Developer ID 证书,一个是让用户执行sudo xattr -dr com.apple.quarantine /Applications/BrewUI.app。我个人的建议是:如果只是内部使用,就用第二种方式,成本最低。如果要公开发布,还是得走正规签名路线。
7. 扩展思路:BrewUI 还能往哪里走
做一个 GUI 只是起点,真正的想象力在于把包管理的“数据”盘活。我目前想到的几个值得做的方向,分享出来供你参考:
- 依赖关系可视化:现在详情面板只展示“这个包依赖谁”,但反过来“谁依赖这个包”同样重要。升级一个底层库之前,你应该知道有哪些上层包会受影响。这个用 Qt 的 QGraphicsView 可以画出一个树状或网状依赖图,交互上比文字描述直观得多。
- 磁盘占用排行:brew 社区一直有“怎么清理没用的包”的痛点。如果能调
brew deps --tree和du -sh $(brew --prefix)/Cellar/*综合判断,把“哪些包占用空间大且没有其他包依赖”列出来,用户一眼就能找到“该卸载的大家伙”。 - 安装历史与回滚:brew 本身有
brew --cache里的旧版本缓存,也支持brew switch切换版本。在 GUI 里做成时间线视图,让用户直观地看到“昨天装了什么”“上次更新是什么时候”,对异常排查会很有帮助。 - 多机器同步:把
brew list --formula的导出结果做成一个“环境清单”,在另一台新机器上导入后自动执行安装。对于需要频繁切换开发机的人来说,这个功能能省下大量时间。
这些扩展的核心思路是一致的:不重新发明包管理器,而是把 Homebrew 已有的能力用更好的交互方式呈现出来。BrewUI 的价值不是替代 brew,而是让 brew 变得更透明、更可操作、更适合不熟悉命令行的用户。
我自己在实际开发里的体会是,做这类工具最难的往往不是技术,而是把“用户到底要什么”想清楚。Homebrew 的用户画像很广,有天天在终端里敲命令的老手,也有只是装了个 Homebrew 然后用完就忘的普通用户。BrewUI 显然服务的是后者,但即便你是前者,一个可视化的界面也能让你在排查问题时更容易发现问题。如果你也想动手做一款类似的工具,建议先从“拿数据、列列表”做起,跑通之后你会发现,剩下的事情会一点点自己冒出来。