说起来有点不好意思。我用Homebrew已经五年多,日常装包、更新、清理全在终端里完成,一直觉得这很高效。直到有一次帮一位刚转行的朋友搭建开发环境,我让他执行一条brew install命令,他看着终端愣了很久,然后问我要在哪个窗口输入。那一刻我意识到,命令行对熟悉它的人是效率,对不熟悉它的人就是一堵墙。BrewUI这个项目就是从那次对话开始的:给Homebrew做一层图形界面,让搜索、安装、卸载、查看依赖像用应用商店一样直观,底层仍然调用brew命令,不改变包管理器自身的任何行为。它面向的读者有两类:一类是想给身边“终端恐惧症”同事提供趁手工具的开发者,另一类是正在找真实项目练手的桌面端开发者。我在开发过程中,把命令桥接、数据建模、长任务进度、依赖可视化这几个关键环节都完整踩了一遍,这篇文章会把整个过程如实写下来。
1. 痛点定位:Homebrew能力很强,但它的信息都是“管中窥豹”
做管理工具的第一步不是写代码,而是把“为什么要做”想清楚。BrewUI的目标不是取代终端,而是解决终端里信息整理的问题。Homebrew本身很强大,可是它的输出是面向“执行”而不是面向“理解”的,当包多到一定程度,这个矛盾会非常明显。
1.1 高频命令人人都懂,包多了之后却没人能看清全局
Homebrew的核心命令其实就那么几条:brew install装包、brew uninstall卸载、brew search搜索、brew list查看已装、brew update更新源、brew upgrade升级、brew outdated查看可升级包、brew cleanup清理。单看任何一条命令都很直观,可一旦把时间拉长到三个月、半年,你机器上累积了一百多个包之后,情况就不一样了。
我自己的开发机就是典型。第一次认真跑brew list时,输出超过了三屏,里面有一堆我自己都忘了为什么装的东西,比如某个项目装完就删掉但依赖还残留的库、某个临时调试用的工具、某个早就不在用的语言运行时。更麻烦的是,想搞清楚“这个包被我哪个项目依赖”得额外敲brew deps --tree,输出还是一棵极其庞大的字符树,几百个节点堆在终端里,滚动都困难。
这种状态下,你其实失去了对这台机器软件的“全局视图”。你只知道一个个孤立的包存在,却不知道它们之间的依赖关系、不知道哪些可以安全清理、不知道上次统一更新是什么时候。这不是命令不够用,而是命令行天然不擅长呈现这种多对多的关系信息。
1.2 可视化不是替代CLI,而是补一个“管理视图”
基于上面的观察,我在设计BrewUI时给自己定了一条死边界:所有安装、卸载、升级操作,最终都执行真实有效的brew命令;但展示层全部用表格、列表、图表替代文本流。
这条边界非常重要。因为一旦想做“替代终端里的brew”,就会陷入一个巨大的坑:终端能做的事情太多了,光参数组合就有上百种,你永远做不完,最后只会得到一个功能残缺的“伪终端”。而BrewUI真正要解决的事情是“审视”而不是“执行”:你今天装了什么、什么该升级、什么该清理、依赖长什么样,这些用界面看远比用文本看直观。
于是产品形态就清晰了:日常快速安装一个新包,我用终端一条命令搞定,根本不会打开GUI;但每周做一次“包管家”式的检查时,打开BrewUI,看过期列表、看缓存占用、看依赖树,体验比terminal好了不止一个量级。这个边界确定得越早,后面开发的返工越少。
2. 桥接层设计:所有功能都从一条带参数的brew子进程开始
BrewUI本质上是一个“遥控器”:前端发指令,后端翻译成brew命令并执行,再把结果包装成结构化数据返回。这个桥接层是整个项目的地基,它设计得好不好,直接影响后面所有功能的开发成本。
2.1 优先使用brew提供的原生JSON输出
Homebrew从很早的版本开始就支持结构化输出,核心参数是--json=v2。我最初差点忽略这个能力,因为brew help里不会明显提示,直到看别人脚本里用过。
实用价值非常大,比如查看某个包的详情,终端里是一大段文本,但换成JSON结构,所有字段都能被程序直接使用。
brew info --json=v2 fzf返回的JSON里会包含formulae数组,每个元素有name、full_name、versions、installed、dependencies、build_dependencies这些字段。被安装多次时,installed本身也是个数组,会记录每个安装版本、安装时间、是否按用户请求安装。这个设计解决了我最头疼的问题:“这个包是被当作依赖装进来的,还是用户主动装的”。在JSON里看installed[].installed_on_request字段就够了,终端输出里反而没这么明显。
为什么用v2而不用v1?v1是早期版本,结构比较乱,字段命名也不统一,尤其cask和formula混在一起时很难区分。v2在顶层就分成了formulae和casks两个数组,结构清晰得多,所有新代码都应该基于v2写。
2.2 Rust侧封装统一命令执行器,而不是让前端直接调用子进程
我最终用的是Tauri框架,后端是Rust。最初我有点天真,想过是不是可以让前端JS直接通过Node的child_process调用brew,后来发现自己绕了远路。WebView环境里没有完整的Node运行时,系统命令调用必须交给后端处理。
于是我在Rust侧写了一个统一执行器,所有brew操作都走同一个入口:
use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; const BREW_PATH: &str = "/opt/homebrew/bin/brew"; #[tauri::command] async fn brew_exec_raw(args: Vec<String>) -> Result<String, String> { let output = Command::new(BREW_PATH) .args(&args) .output() .await .map_err(|e| format!("执行失败: {}", e))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if !output.status.success() { return Err(format!("命令退出码: {:?}, stderr: {}", output.status.code(), stderr)); } Ok(stdout) }BREW_PATH写死是/opt/homebrew/bin/brew,因为Apple Silicon机器的Homebrew安装路径固定在这里。Intel Mac则可能是/usr/local/bin/brew,这个细节在适配阶段最容易踩到,我后来处理方案是启动时自动探测两个路径,哪个存在用哪个。
后端统一执行还有一个好处:安全上能控制命令白名单。前端拿到的命令参数都经过一层校验,比如只允许以install或uninstall开头的参数组合,避免将来界面出现注入问题。
2.3 数据模型:组合调用是核心,一条命令拿不到全貌
brew虽然提供了JSON输出,但没有任何一条单独的命令能把“已安装、可升级、依赖关系、磁盘占用”一次全给你。实际操作上要组合三条命令:
- brew list --formula --json=v2:拿到所有已安装formula的基本信息
- brew outdated --json=v2:拿到所有可升级包
- brew info --json=v2 :按需补详情
组合之后,我在前端定义了一套统一的数据模型,避免界面层到处散落字符串拼接。
export interface FormulaInfo { name: string; versions: { stable: string; }; installed: Array<{ version: string; installed_on_request: boolean; installed_as_dependency: boolean; }>; dependencies: string[]; build_dependencies: string[]; outdated: boolean; pinned: boolean; }界面层只需要关心这个模型,完全不用管数据是从哪条命令来的。聚合逻辑放在Rust侧,前端一个invoke调用的返回就是干净的结构化数据。这个模式在后面做依赖可视化时帮了大忙,因为图数据需要把每个包的dependencies递归展开,有统一模型做地基就简单很多。
3. 技术栈选择:我在Electron、SwiftUI、Tauri之间的取舍过程
说实话,这个项目刚开始的时候,我并没有立刻选Tauri。市面上做桌面UI的方案太多,我花了大约一周时间做调研和Demo验证,才确定方向。这个过程值得讲一下,因为技术选型直接影响后续开发效率。
3.1 三套方案横向对比
| 维度 | Electron | SwiftUI | Tauri |
|---|---|---|---|
| 安装包体积 | 普遍80MB以上 | 随系统,很小 | 配置良好时10MB左右 |
| 前端技术 | HTML/CSS/JS | Swift原生 | HTML/CSS/JS |
| 后端能力 | Node.js | Swift | Rust |
| 跨平台 | Windows/macOS/Linux | 仅Apple平台 | Windows/macOS/Linux |
| 系统命令调用便利度 | 中等,Node即可 | 高,可直接用Process | 高,Rust生态成熟 |
| 上手成本 | 低 | 高(需要Swift) | 中(需要Rust基础) |
| 资源占用 | 内存消耗大 | 很低 | 低 |
从这个表能看出来,Electron最大的优势是生态成熟、社区资料多、前端开发者上手快;SwiftUI在Apple生态内体验最好,但无法覆盖我偶尔要在Linux上测试的场景;Tauri在体积和内存占用上优势明显,但需要会一点Rust。
3.2 为什么最终选了Tauri,而不是更加“成熟”的Electron
我的判断依据很主观,但也很真实。第一,BrewUI作为开发者工具,我自己就是用户,天天要对内存敏感。Electron做个笔记应用还能忍,但开发者工具本来运行的时候,旁边可能还开着IDE、好几个终端、浏览器和Docker,再加一个占用800MB内存的Electron应用就真的吃不消。Tauri用系统WebView渲染,内存占用通常只有Electron的三分之一左右。
第二,这个项目需要大量调用系统子进程,Rust侧的tokio::process生态非常顺手,异步读取输出、处理流日志都很直接。Electron虽然也能做,但Node的child_process在处理长时间运行的子进程时,代码写起来没有Rust这么清爽,尤其涉及到按行流式读取时。
第三,跨平台收益。我自己主力是macOS,但偶尔会在Linux容器里做同样的事情,Tauri可以让我同一套代码在两个平台跑,这在Electron和SwiftUI之间是一个不错的平衡点。Caveat是Rust的学习曲线,但认真说,这个项目用到的Rust特性非常有限,基本就是把Command包一包、处理一下JSON,有基础的类型概念就够上手了。
4. 四个核心面板的落地过程
技术选型确定之后,剩下的就是功能实现。BrewUI的第一个可用版本包含四个核心面板,分别对应我平时在终端里最常用的四类操作:搜索安装、查看已装、升级、清理。每个面板都有各自的难点,我按实现顺序讲。
4.1 搜索与安装面板:防抖、结果列表与日志流
搜索面板的交互逻辑看起来简单,实际写起来有不少细节。用户输入关键字,前端要实时请求后端,但不能每次按键都打一次brew search,否则建议防抖处理,等用户停止输入300毫秒后再发起搜索。
let timer: ReturnType<typeof setTimeout> | undefined; export function onSearchInput(keyword: string) { clearTimeout(timer); timer = setTimeout(() => { invoke("brew_search", { keyword }) .then((results) => renderResults(results as FormulaInfo[])) .catch((err) => showError(err)); }, 300); }Rust侧的brew_search实现是两条命令的组合:先执行brew search --formula拿到包名列表,再对每个包名批量执行brew info --json=v2拿详情。注意这里不能对每个结果都单独拉一次详情,那样在搜索结果几十个的时候会非常慢;我采取的做法是拿到名称列表后,先在本地的已安装列表里做一次快速匹配,只有用户“真的点开某个包的卡片”时才去拉完整详情,这样搜索响应速度能控制在几百毫秒内。
安装功能的关键点是日志流。安装一个包少则一分钟,多则十几分钟,前端必须实时展示进度输出。调用brew install时,如果直接用Command::output去拿最终的stdout,就会面临“界面卡着不动直到命令跑完”的问题。要解决这个,需要用Stdio::piped()把stdout重定向出来,逐行读取:
let mut child = Command::new(BREW_PATH) .args(["install", formula]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| e.to_string())?; let stdout = child.stdout.take().unwrap(); let mut lines = BufReader::new(stdout).lines(); while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? { app_handle.emit("install-logs", line).map_err(|e| e.to_string())?; }前端监听install-logs事件,把每一行追加到日志区。体验已经接近在终端里看到的效果。
4.2 已安装包列表与卸载保护
已安装列表的数据来源是brew list --formula --json=v2,这个命令返回的是所有已安装的formula,但里面并不包含依赖关系,所以我还需要额外的信息判断“这个包能不能被卸载”。要判断一个包是否能被安全卸载,正确的做法是找出哪些包依赖它。若还有包在依赖它,卸载后会导致那些包运行异常。
我在后端封装了一个“可卸载检查”接口,逻辑是:先构建当前环境里所有formula的依赖倒排索引,然后对每一个包算出“逆向依赖数量”。界面上,逆向依赖数量大于0的包在卸载按钮旁边会显示一个黄色警告,并明确列出依赖它的包名,而不是直接阻止操作。
卸载本身执行brew uninstall ,但有两个注意点。第一,要带上--ignore-dependencies做了双重确认的用户才执行;第二,支持的包一般通过brew uninstall --cask 执行,formula和cask在底层数据结构里就是两个入口,界面上要区分开。
4.3 更新与升级:outdated列表与任务队列
升级面板的数据来源是brew outdated --json=v2,它会返回所有“当前已安装但不是最新版”的包。实现上要注意:不要做一个“全部升级”大按钮就完事,因为一次性升级几十个包的风险非常大。某次升级可能因为某个包编译失败导致链路中断,前端也没有办法做到“取消已经开始的升级”。
我提供的交互是:默认列出所有outdated包,用户逐个点击升级,也可以勾选多个后批量进入“升级队列”。后端维护一个异步队列,一次只跑一个升级任务,跑完自动执行下一个。前端用事件把每个任务的进度展示在队列卡片上,这样用户既能看清整体进度,也能随时暂停队列。
升级之前一定要先执行一遍brew update,否则outdated结果可能是基于过期索引算出来的。这个逻辑不能省,我实现的是在用户打开升级面板时自动触发一次后台update,同时显示“索引更新于xx分钟前”的时间戳。
4.4 缓存清理与磁盘空间展示
缓存清理面板做的是两件事:展示Homebrew各种缓存目录占用的磁盘空间,以及一键执行清理。
#[tauri::command] async fn get_cache_size() -> Result<u64, String> { let cache_dir = dirs::cache_dir().map_err(|e| e.to_string())?; let brew_cache = cache_dir.join("Homebrew"); let downloads = brew_cache.join("downloads"); let api_cache = brew_cache.join("api"); let download_size = dir_size(&downloads).await; let api_size = dir_size(&api_cache).await; Ok(download_size + api_size) }dir_size是一个递归统计目录大小的函数,用tokio::fs读取目录,碰到文件就累加文件大小。这个计算在缓存目录很大的时候可能耗时几秒,所以要放在异步线程里执行,不能让UI卡住。
真正执行清理时,我先用brew cleanup -n做一次“dry run”,让用户看一眼“将被清理的项目有哪些”,确认后再执行brew cleanup。这个设计很重要,因为用户对“一键清理”天然有恐惧,一旦界面能提前展示即将释放多少磁盘空间,体验完全不同。清理完成后立刻重新刷新占用数据。
5. 走查三个真实坑位:排查链路与最终解法
任何桌面工具项目,Demo跑通只是开头,真正折磨人的是那些“终端里一切正常,但界面里表现诡异”的问题。我在这里把踩过的三个坑完整复盘,也是BrewUI开发过程中最重要的经验沉淀。
5.1 安装进度条卡在0%:行缓冲与ANSI转义
第一个版本里,安装日志区采用的是逐行追加方式。我测试时发现一个诡异现象:装一个包,终端里明明很顺利,但BrewUI的日志区一直显示“==> Downloading …”,进度条也卡在0%,过很长时间突然一次性跳出一大段文本。
排查过程是这样的。我首先怀疑的是异步读取有缓冲问题,于是把stdout改成直接输出到文件再读取,结果文件内容也是等到最后才出现。我突然意识到,问题不在读取端,而在brew命令本身:当brew认为它是在“非交互式环境”运行时,会启用进度条动画,用\r回车符反复刷新同一行,并且输出大量ANSI颜色转义序列。这些内容在传统终端里看着没问题,但按行读取时就会被拆成几百个只有一两个字符的“行”,前端每收到一行就追加一次DOM,不仅展示错乱,性能也扛不住。
最终解法有两个层面。首先是环境变量,在调用brew命令时显式设置:
Command::new(BREW_PATH) .env("HOMEBREW_NO_INSTALL_CLEANUP", "1") .env("HOMEBREW_NO_AUTO_UPDATE", "1")HOMEBREW_NO_INSTALL_CLEANUP是为了减少安装过程中的职责外操作,日志更干净;HOMEBREW_NO_AUTO_UPDATE是为了避免每次安装都先去更新索引,大幅缩短安装等待时间。第二个层面是前端做ANSI转义清理,把所有\r、颜色码、光标移动码都剥掉,只保留可读文本:
const CLEAN_ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; function sanitizeLine(raw: string): string { return raw.replace(/\r/g, "\n").replace(CLEAN_ANSI, ""); }这两个改动之后,安装日志终于跟终端一致了,进度条也正常推进。
5.2 权限边界:sudo不是代码里能“顺手解决”的问题
BrewUI在测试到某个cask包时遇到了权限报错:安装过程中brew尝试往/Applications目录写入文件,而那台测试机上当前用户没有写权限,于是命令失败,报错信息里出现了Permission denied。
我一开始的想法是在UI里增加一个“使用管理员权限重试”的按钮,计划通过osascript的do shell script with administrator privileges来触发。后来仔细想清楚,放弃了这个方案。原因很简单:一个桌面应用在运行过程中随时弹窗索取管理员权限,是非常危险的信号,也违背最小权限原则。更合理的做法是提醒用户幂等在终端单独执行,并提供一条复制即用的命令。
#[tauri::command] async fn format_sudo_instruction(args: Vec<String>) -> String { format!("sudo /opt/homebrew/bin/brew {}", args.join(" ")) }实际交互变成了:当检测到stderr里有权限错误时,界面出现一个提示条,告诉用户“这个操作需要管理员权限,BrewUI不会尝试获取你的系统密码”,然后展示需要复制的命令,同时附上“复制命令”按钮。大部分情况其实根本用不到这一步,因为Apple Silicon机器上/opt/homebrew目录默认归当前用户所有,绝大多数安装都不需要sudo。把这个边界守住,应用的安全模型就清晰了。
5.3 brew update 之后数据过期:缓存策略的教训
我最早为了优化启动速度,给BrewUI做了一个本地缓存:每次启动时读取本地缓存的已安装包列表,而不是实时执行brew list。结果碰到过一个问题:用户通过BrewUI执行了升级之后,回到已安装面板,发现列表还是旧的版本号,明明升级已经成功但界面没变。
排查链路是这样的:我第一反应是升级命令本身失败了,于是手动在终端跑了brew list,发现版本号确实已经更新。那问题一定出在UI读取了缓存数据。再往下查,我发现缓存文件并没有在升级完成后被失效,因为升级操作在任务队列里跑完之后,没有主动触发“刷新缓存”的事件。
修正策略很简单,但也很有代表性:凡是对系统环境有改变的操作(安装、卸载、升级、清理)完成后,强制重新拉取全量数据,而不是依赖缓存;缓存只用于“启动后第一次展示”的冷启动加速,并且在界面上显示“数据更新于xx秒前”。另外加了一个手动刷新按钮,让用户可以随时重新拉取。这个过程让我真的理解了一条经验:管理类工具的UI状态,必须与其背后的系统状态保持同步,而“事件驱动刷新”比“定时刷新”可靠得多。
6. 依赖关系可视化:把安装树变成可以交互的图
BrewUI最让我自己满意的功能,不是表格,而是依赖关系可视化。Homebrew的依赖关系非常复杂,只看文本很难察觉到“哦,原来这个包靠着一百多个依赖撑着”。界面把它变成图之后,很多东西一下子清晰了。
6.1 从JSON里抽取关系数据
要画图,首先要把每个包的dependencies字段收集起来,形成一个图数据结构。我的做法是在Rust侧递归遍历当前所有已安装formula,抽出每条依赖边,然后返回给前端:
#[tauri::command] async fn build_dependency_graph() -> Result<GraphData, String> { let installed = get_installed_formulae().await?; let mut nodes = vec![]; let mut edges = vec![]; let mut seen = HashSet::new(); for formula in &installed { let all_deps = collect_dependencies(formula).await?; for dep in all_deps { let (from, to) = (formula.name.clone(), dep); if from != to && seen.insert((from.clone(), to.clone())) { edges.push(GraphEdge { source: from, target: to }); } } } Ok(GraphData { nodes, edges }) }collect_dependencies是一个递归函数,沿着dependencies字段一直往下找,直到没有新依赖为止。为了避免递归层数过深导致栈溢出,我用的是显式循环加栈,而不是直接递归。
6.2 用D3-force渲染力导向图
渲染部分我用了D3的force模拟。选择D3的核心理由是:它对图布局的控制力强,虽然是老库,但文档成熟,踩坑案例多。力导向图特别适合这种“关系比层级重要”的展示场景——它会把被依赖得最多的包推到中心位置,把孤立的包推到边缘。
核心代码大致是这样的:
const simulation = d3.forceSimulation(nodes) .force("link", d3.forceLink(edges).id((d) => d.id)) .force("charge", d3.forceManyBody().strength(-240)) .force("center", d3.forceCenter(width / 2, height / 2)); simulation.on("tick", () => { // 根据 nodes 和 edges 的最新坐标更新 SVG 元素 });真正渲染时我不会直接用D3直接操作DOM,而是把D3当作纯布局引擎,拿到每个节点计算出的坐标后,再用常规的DOM操作更新SVG。这样做的原因是:混在一起写容易造成后续维护困难,一旦以后想把渲染层换成Canvas或WebGL,纯数据驱动的方式会让你省很多事。
6.3 性能与展示边界:两层裁剪和按需展开
依赖图最容易犯的错是“全量展开”。我一开始就是这样干的,结果好家伙,画面上同时出现上千个节点,力导向模拟跑起来卡到几乎没法交互,拖动一次要卡三秒。
解决办法是裁剪。默认只显示“离当前选中包两层以内的依赖”,也就是直接依赖和间接依赖各一层。想看更深的关系,点击某个节点再展开。还有一个技巧:页面只渲染视口范围内的节点坐标,缩放时动态增删SVG里的节点元素,而不是把所有节点一次画出来。经过这两个优化,哪怕总节点数上千也能保持基本流畅。
这个功能做好之后,我发现自己对“这台机器上装了什么”的理解完全是另一个层次。比如我注意到很多工具都依赖了同一套C库,这套库其实是整个环境的基础设施;也看到有些包没有任何依赖也不被其他包依赖,属于“孤儿包”,基本可以放心清理。
7. 做完BrewUI之后,我的使用习惯发生了什么变化
项目做到这里,基本已经进入“自己天天用”的阶段,使用习惯也随之变化。以前我每周大概一两次进终端跑outdated和cleanup,现在基本每天都会打开BrewUI扫一眼有没有可升级的包。清缓存也从“想起才弄”变成了“看到数字大就点一下”,因为磁盘占用一眼就能看到,清理后的释放效果立刻反馈在界面上。
不过我也要诚实说:并不是所有事情都适合在UI里做。临时快速安装一个包,我依然会打开终端敲brew install xxx,因为那是最快的方式,完全不需要打开应用再走一遍搜索流程。批量安装一整套开发环境时,我也更倾向于写一个bash脚本一次性执行,比在UI里手动勾选几十个包高效得多。所以BrewUI对我不是“终端的替代品”,而是“终端之外的第二个入口”:终端负责快速执行,BrewUI负责审视和管理。
如果后面继续迭代,我大概会加三个方向的东西。一个是菜单栏常驻模式,让BrewUI缩在系统菜单栏里,后台定期检查outdated列表,有更新时弹系统通知;另一个是导入导出功能,把当前环境的包清单导成一个文件,换新机器时一键复现,这个思路和brew bundle差不多,但用UI来管理会直观很多;再有就是对孤儿包的“建议清理”提示,在依赖图里把所有不被任何其他包依赖且长时间未使用过的包单独列出来,给出清理建议。
最后分享一个很微小的细节。BrewUI第一次成功把依赖图画出来的时候,我盯着屏幕看了很久。那些平时藏在brew deps --tree里、千丝万缕的关系突然变成了一张可以缩放、拖拽、点击的网。那一刻我特别确信,所谓好工具,未必是功能更多,而是把一个已经存在但很难看清的事实,用最合适的方式讲明白。如果你也在做类似的管理类桌面工具,希望这些经验能帮你少踩几个坑,至少不会纠结安装进度条为什么不动了。