用SwiftUI为Homebrew打造图形界面:BrewUI开发全记录 📅 发布时间:2026/9/19 14:10:36 👁 浏览次数: 1. BrewUI的定位给Homebrew这辆“命令行跑车”加一块仪表盘BrewUI这个名字最早是我在连续升级了七八个包之后冒出来的想法。在Mac上做开发这些年brew install、brew update、brew upgrade差不多是每天都要敲的命令用得越久越觉得命令行虽然高效但它把所有信息都压成了一行行文字电脑里几百个包哪些有新版本哪些是某个软件的唯一依赖哪些已经成了没人引用的“孤儿包”靠肉眼去翻brew outdated和brew leaves的输出实在是太费神。于是业余时间动手写了一个叫 BrewUI 的小工具它不是要对 Homebrew 动刀而是架在brew命令之上的一层图形化外壳把原本松散的文本输出整理成能看、能点、能批量操作的界面。这篇文章就把整个开发过程、技术路线和踩过的坑完整复盘一遍给同样想做命令行工具 GUI 封装的人一个参考。1.1 图形界面到底解决了什么痛点先说一个很现实的场景我维护的一台开发机上Homebrew 里装了大概三百多个 formula 和 cask。brew outdated会列出一堆名字但你很难一眼看出哪些更新是安全的、哪些来自我不常用的仓库、哪些包一旦升级可能会连带升级一堆依赖。命令行确实可以做比如brew outdated --verbose、brew deps --tree但这些命令的输出对于每周才看一眼的人并不友好。BrewUI 想解决的问题很简单就是让“管理软件包”这件事从“背命令”变成“看界面”左侧一排分类全部公式、已安装、可升级、已固定版本pinned、需要清理的缓存。点进任意一个包右侧直接展示版本号、依赖树、安装路径、相关 cask。勾选多个包后点“升级”界面按顺序执行、实时滚动日志跑完了弹一个结果摘要。这些功能单独拆开看都不复杂但组合在一起就是一套能让人放心把“包管理”交给鼠标的操作台。项目我自己用了很久后来才整理好代码结构加上了安装包分发。1.2 项目边界只做展示和调度不碰包解析开发这种工具最重要的一个决定是绝对不要重造包管理器的轮子。早期我也想过要不要用 Ruby 直接调用 Homebrew 的 API或者自己做 Formula 解析后来冷静评估了一下完全没必要。Homebrew 本身已经提供了大量稳定输出格式尤其是 JSON 接口足够前端展示使用。所以 BrewUI 的定位非常克制界面层SwiftUI 写界面负责展示和交互。调度层通过Process启动brew CLI把参数传过去收集stdout和stderr。数据层解析brew输出的 JSON转换成 Swift 的Codable模型。这意味着无论 Homebrew 后期怎么升级、Formula 怎么增加字段只要 CLI 命令和 JSON 结构还在BrewUI 就能持续工作。我不会因为一次格式变动就去重写数据层直接把那个“解析 JSON 的版本号”锁在代码里就行。2. 技术路线选择为什么是 SwiftUI Process而不是 Electron 或 Python技术选型这一步我犹豫了很久。吃不准的时候我把候选方案全都列了个对比表写清楚各自的成本再决定。下面就是我当时评估的结果方案开发效率安装体积内存占用macOS 原生体验维护成本SwiftUI Process中小几MB低好低Python PyQt高中需打包Python中一般中Electron Node高很大100MB高一般中Tkinter低小低丑中我最后选了 SwiftUI Process核心原因不是开发快而是这个工具的目标用户是 macOS 上的开发者大家对原生应用的流畅度和内存占用是有预期的。Electron 虽热但一个包管理工具常驻后台就吃两三百 MB 内存实在说不过去。SwiftUI 从 macOS 11 开始已经能满足列表、分栏、手势这些常见交互写起来也比 AppKit 舒服所以果断选它。2.1 进程调用的核心设计不要经过 shell很多人用 Swift 调外部命令习惯写/bin/bash -c brew ...这在终端里没问题但在 GUI 应用里会引入一堆不确定因素。BrewUI 的做法是直接指定可执行文件路径不经过 shellimport Foundation enum BrewEnvironment { static var executableURL: URL { // Apple Silicon 的 Homebrew 默认装在 /opt/homebrew // Intel Mac 老路径是 /usr/local let candidates [ URL(fileURLWithPath: /opt/homebrew/bin/brew), URL(fileURLWithPath: /usr/local/bin/brew) ] return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }! } } func runBrewCommand(arguments: [String]) throws - String { let process Process() process.executableURL BrewEnvironment.executableURL process.arguments arguments let pipe Pipe() process.standardOutput pipe process.standardError pipe try process.run() process.waitUntilExit() let data pipe.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) ?? }这里有个关键点不经过 shell 可以避免非常多权限弹窗问题。macOS 对“终端要访问文件夹”或者“某个 App 要控制终端”这类 TCC 隐私弹窗特别敏感。如果你用/bin/bash -c去跑 brew系统会认为你在尝试控制 shell弹窗概率极高直接执行 brew 二进制反而更接近用户在终端里手动运行命令的行为弹窗会少很多。2.2 为什么不把 brew 做成常驻服务还有一种方案是让 BrewUI 启动一个后台 daemon由 daemon 持有brew命令的调用句柄界面通过本地 HTTP 或 Socket 和 daemon 通信。这能解决一部分性能问题但也引入了更大的复杂度daemon 的权限、进程生命周期、崩溃恢复、用户是否允许开机自启每一样都要额外处理。我最终选择每次操作都直接拉起一个新进程。原因是 brew 命令本身已经很快启动开销主要是 Ruby 解析公式库的时间通常在几百毫秒到一两秒之间对于人工点击操作来说完全无感。而且 brew 自己内部有文件锁多进程同时跑反而容易撞锁。干脆用串行队列确保任意时刻只跑一个brew命令简单、稳定、可控。3. 核心功能落地包列表、状态筛选、一键操作BrewUI 的第一个可用版本只做了一件事把brew list的结果用列表展示出来。后来才逐步加上了状态标记、依赖分析、批量升级、日志回显等功能。下面挑几个值得讲的功能拆解实现思路。3.1 数据模型用brew info --jsonv2作为数据源Homebrew 从很早期就开始支持 JSON 输出这真是做工具的人最大的福音。官方推荐的姿势是brew info --jsonv2后面可以跟具体的包名也可以跟--installed列出所有已安装的包。输出结构大致这样{ formulae: [ { name: wget, full_name: wget, versions: { stable: 1.21.3, head: null, bottle: true }, installed: [ { version: 1.21.3, used_options: [], poured_from_bottle: true, runtime_dependencies: [ { full_name: openssl3 } ] } ], dependencies: [openssl3], caveats: null } ], casks: [] }Swift 这边我建了对应的Codable模型只抽取我关心的字段。特别要注意installed是数组因为它可能包含多个版本比如通过brew install wget1.20装过老版本。我的模型大致长这样struct BrewInfo: Codable { let formulae: [Formula] let casks: [Cask] } struct Formula: Codable, Identifiable { var id: String { name } let name: String let fullName: String let versions: Versions let installed: [InstalledInfo]? let dependencies: [String]? let runtimeDependencies: [RuntimeDependency]? } struct InstalledInfo: Codable { let version: String let pouredFromBottle: Bool? }这里有个容易踩的坑installed字段只有在“已安装”的包上才会有未安装的 formula 可能返回null所以 Swift 模型里一定要声明为可选类型否则解析直接失败。我当时第一次跑brew info --jsonv2 --installed没注意decode 崩了无数次。3.2 左侧分类栏的筛选逻辑BrewUI 的界面是经典的三栏结构左侧是分类中间是包列表右侧是详情。分类本质上是对BrewInfo的数据做内存过滤并没有额外调命令。我维护了一个枚举enum PackageFilter: String, CaseIterable, Identifiable { case all 全部 case outdated 可升级 case pinned 已固定 case onRequest 显式安装 case dependencies 纯依赖 }这些筛选逻辑每家做法都不同我采用的是实时过滤。每次拿到原始数据后会根据当前选中的分类执行一个纯函数式过滤再刷新列表。这样最清晰也不会有状态同步的烦恼。all不做任何过滤。outdated调用brew outdated --jsonv2得到可升级包名集合。pinnedbrew list --pinned的结果。onRequestbrew list --formula里不依赖其他包的顶层包。这个可以用brew leaves得到。dependenciesbrew list减去brew leaves结果就是被动安装的依赖包。别看这几个分类很简单实际使用率非常高。我日常就只看“可升级”和“显式安装”其它包基本不需要关心。3.3 一键升级的并发模型严格串行很多同类工具在“一键升级”这个功能上会犯一个错误把所有包的升级命令并发跑。表面上看更快但 Homebrew 自己的upgrade命令是做了全量锁的多进程并发轻则相互等待重则锁死。BrewUI 的做法是private let brewQueue DispatchQueue(label: com.brewui.brew-queue, qos: .userInitiated) func upgradeSelected(_ packages: [Package]) { brewQueue.async { [weak self] in guard let self self else { return } let names packages.map { $0.name } let args [upgrade] names self.executeBrew(arguments: args) DispatchQueue.main.async { self.refreshPackageList() } } }用串行队列就意味着在同一时间只可能有一个brew任务在跑。这样虽然牺牲了一点并发度但换来了可靠的取消操作用户点“停止”时我可以拿到当前正在跑的Process并执行terminate()不会出现“同时杀了好几个进程”的竞态。实测下来升级一百个包的时候界面依然稳定不会因为日志刷屏而卡死。3.4 日志回显与进度刷新brew 命令在跑的时候会实时打印进度但它的输出是写到stderr的而且带了 ANSI 颜色控制符。如果直接拿原始文本来更新 UI你会看到一堆\033[0;32m这样的转义序列。BrewUI 的处理流程是把stdout和stderr合并到一个Pipe。用FileHandle.readabilityHandler增量读取而不是等命令跑完一次性读。对每一行做 ANSI 转义清洗只保留纯文本。根据正则匹配 Downloading、 Upgrading等关键词动态更新当前任务状态。清洗 ANSI 的代码不复杂无非是正则替换func stripANSI(_ raw: String) - String { let pattern #\x1B\[([0-9;?]*[a-zA-Z])# return raw.replacingOccurrences(of: pattern, with: , options: .regularExpression) }这一步很多人会忽略但如果不做日志回显区域基本没法看。我在真实使用中见过各种奇怪的转义序列比如\x1B[2K光标移动、\x1B[1A上移行、还有\x1B[?25l隐藏光标所以最好把\x1B\[[0-9;?]*[a-zA-Z]和\x1B\][^\a](\a|$)都处理掉。4. 开发期和测试期踩过的坑从锁冲突到权限弹窗上个月我把 BrewUI 拿给几个朋友测试本以为功能很稳结果第一天就收到了三个问题一个说升级到一半卡住了一个说列表里全是乱码还有一个说点升级按钮没有任何反应。逐一排查之后发现三个都是很典型的问题。这里详细记录一下完整链路方便后来人参考。4.1 彩色的 JSON 输出让解析器直接投降先说乱码问题。朋友反馈的不是中文乱码而是“列表里出现好多[32m之类的字符”。我第一反应是数据清洗不对但拉日志一看问题出在brew info --jsonv2 --installed的输出里也被加入了 ANSI 颜色码。正常情况下 JSON 输出是不该带颜色的但是 Homebrew 在某些终端环境下会默认开启颜色导致 JSON 字符串里出现类似\e[32m的控制符。Swift 自带的JSONDecoder对这种字符会直接抛错。解决方式很粗暴但很有效在启动时设置环境变量强制关闭所有输出风格process.environment [ NO_COLOR: 1, CLICOLOR: 0, CLICOLOR_FORCE: 0, HOMEBREW_NO_AUTO_UPDATE: 1 ]NO_COLOR是社区推进的通用变量很多命令行工具都会遵守CLICOLOR和CLICOLOR_FORCE是常见颜色控制变量HOMEBREW_NO_AUTO_UPDATE不是颜色相关的但必须加上不然每次跑brew upgrade都要先等半分钟自动更新非常烦人。4.2 权限弹窗直接调二进制比调 shell 省心得多朋友反馈的“点升级没反应”其实是 macOS 的权限弹窗在拦截。我第一版实现里为了省事用Process去跑/bin/bash -c brew upgrade ...结果在别人的机器上会反复弹窗询问“终端想要控制您的桌面”有些用户不知道要点“允许”整个任务就被挂在后台等权限。排查的时候我做了个对比实验同样一个操作用/bin/bash -c触发系统弹窗的概率极高而直接使用/opt/homebrew/bin/brew作为executableURL时几乎没有弹窗。原因在于brew本身是 Homebrew 安装的普通二进制调用它不会触发 TCC 的“受控目录”提醒。所以我把所有调用都改成直接执行 brew 可执行文件并且排除掉~/.bashrc带来的环境干扰。4.3 升级过程中的文件锁冲突第三个“卡住”问题看日志发现是Another active Homebrew process is already in progress。这几乎是所有 GUI 化 brew 工具都会踩的坑。Homebrew 在运行期间会在临时目录放置锁文件防止两个进程同时操作同一包。如果 GUI 认为“我不小心开启了多个任务”就可能撞锁。解决方式我在项目里明确为任何与 brew 相关的操作都必须走同一个串行队列。哪怕是“刷新列表”这种看起来只读的操作也应该排队不能在refresh的同时去跑upgrade。因为像brew update这类只读操作也可能改变仓库状态万一和upgrade撞上了结果不可预期。BrewUI 在项目初始化时就定义了一个全局的OperationQueuemaxConcurrentOperationCount 1所有任务都投递进去彻底规避锁冲突。4.4 代码签名与 TCC 的隐蔽问题还有一个容易被忽略的问题如果你用 Xcode 的 Debug 配置直接跑工程系统会默认给进程附加一些调试权限。而打包成 Release 并分发后这些权限路径发生变化可能会出现“本机能跑别人装了以后崩溃”的情况。为了签名和公证我花了一个晚上踩坑。后面会在第 5 节细说。5. 打包、签名与分发一个个人开源工具要跨过的坎写代码只是项目的一半分发才是另一半。BrewUI 作为一个面向 Mac 用户的工具栏打包和签名如果处理不好用户下载后连打开都难。这块经验网上零零散散我整理一下完整涉及到的环节。5.1 Developer ID、签名、公证一个都不能少从 macOS Catalina 开始所有应用默认都要经过 Gatekeeper 检查。如果你只是个人分发不花 99 美元一年加入 Apple Developer Program用户下载后只能右键打开体验很糟糕。我建议如果打算长期维护这 99 美元别省。具体流程是在 Apple Developer 后台申请一个 Developer ID Application 证书。对.app做 codesign 签名。把.app打包成.dmg。用xcrun notarytool submit做公证notarization。把公证后的 stapler 信息钉到应用上。我最初的错误是只签名没公证。结果用户第一次打开时还是会被提示“无法验证开发者”。后来补上公证提示就消失了。核心命令大致是# 签名 codesign --deep --force --verify --verbose \ --sign Developer ID Application: Your Name (TEAMID) \ BrewUI.app # 打包 dmg hdiutil create -volname BrewUI -srcfolder BrewUI.app \ -ov -format UDZO BrewUI.dmg # 公证 xcrun notarytool submit BrewUI.dmg \ --apple-id youexample.com \ --team-id TEAMID \ --password app-specific-password --wait公证通过后再用xcrun stapler staple BrewUI.app把票据钉进去。不然每次启动都要向 Apple 服务器在线验证断网就尴尬。5.2 自动更新Sparkle 和自制更新二选一对于 GUI 工具自动更新几乎是必备功能。BrewUI 本质上就是管理包的如果自己没法一键更新那太讽刺了。自动更新方案我考虑过两个SparklemacOS 老牌更新框架功能丰富支持签名校验、强制更新、更新日志展示。它的机制是让 App 自己下载新的 dmg 并替换自身。自己写一个更新器请求一个 JSON 文件对比版本号有更新就跳浏览器到 GitHub Releases 页面。最终我选了 Sparkle因为省心。配置好 appcast 之后只需要在 Release 时把 dmg 和 appcast.xml 传上去Sparkle 就会自动询问用户是否更新。它代码签名校验那一套已经很成熟比自己写安全得多。5.3 性能问题不能每次刷新都调用 brewBrewUI 早期版本的性能问题很突出。每点一次“刷新”就要执行brew info --jsonv2 --installed在我的机器上平均耗时 1.2 秒在包更多的机器上可能 3 秒以上界面会卡顿。优化思路是把耗时命令的结果缓存起来。因为brew的安装状态不会每秒钟都变完全可以缓存 30 秒或 60 秒内的结果。BrewUI 里我加了一个简单的TimestampedCachefinal class BrewCache { private var cachedInfo: BrewInfo? private var cachedAt: Date? func fetchInfo(forceRefresh: Bool false) throws - BrewInfo { if !forceRefresh, let cachedInfo, let cachedAt, Date().timeIntervalSince(cachedAt) 60 { return cachedInfo } let newInfo try BrewDataLoader.loadInstalledInfo() self.cachedInfo newInfo self.cachedAt Date() return newInfo } }刷新列表时默认读缓存用户手动点“强制刷新”才重新执行命令。这就把每次后台刷新对 UI 线程的影响降到了零。6. 维护 BrewUI 半年后的几点心得最后说几句维护层面的体会吧算不上总结更像是我在真实使用中沉淀下来的几条原则。第一条不要试图在 UI 里面重新实现 npm 那种“生态管理”。Homebrew 本身很成熟BrewUI 做得越多要维护的匹配规则就越多。比如依赖树可视化我一开始想画完整的 DAG 图后来发现 Xcode 上画复杂图表的成本远超收益最后只是用一个按缩进渲染的树形列表代替用户照样看得明白。第二条尽量少用私有 API 和 shell 封装。brew 命令的文本输出会有变动但 JSON 结构属于稳定接口值得依赖。而brew的某个子命令添加新参数这种事我不追新只在正式版本确认无回归后才跟进。第三条用户反馈比自测重要。BrewUI 早期版本我觉得“可升级”分类特别重要但实际用户用得最多的是“磁盘占用分析”和“一键清理”。这才让我意识到工具类软件的价值不是你设计得多优雅而是能不能解决用户手边最常发生的那件事。如果你也正在做类似的命令行工具 GUI 封装我的建议很简单先做一个最简陋但能用的版本然后用三个月再回头决定要不要继续。BrewUI 就是这样活下来的。