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

资讯详情

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

用SwiftUI为Homebrew打造图形化工具:BrewUI开发实战

用SwiftUI为Homebrew打造图形化工具:BrewUI开发实战 作为一个天天和 Homebrew 打交道的人命令行一时爽但真要给同事解释“你先 brew search 一下、再 brew info 看看依赖、然后 brew install 带上参数”这套流程对方基本就劝退了。BrewUI 就是为解决这个问题折腾出来的小项目一个面向 macOS 的 Homebrew 图形化管理工具。它的核心思路很简单——不重新造轮子只封装 brew 命令把软件包列表、安装、卸载、升级、清理这些高频操作变成点击就能完成的事。这篇博文我会把 BrewUI 从想法到落地的完整过程拆开讲包括技术选型、工程结构、核心功能实现、还有几个我磕了很久的坑。不管你是重度依赖 Homebrew 的 mac 用户还是正打算给命令行工具做 GUI 的开发者都应该能从中找到直接可用的思路。提前说一句这里面没有用任何花哨的黑科技全是最朴素的 SwiftUI Process JSON但恰恰是这种朴素路线让项目能稳定跑在每天的工作流里。1. 整体设计与技术选型1.1 先想明白 BrewUI 到底要解决什么问题Homebrew 本身已经很强大了缺的从来不是功能而是“给不熟悉命令行的人一个入口”。很多日常操作看着简单实际用起来有门槛记不住包名。brew search出来一堆结果哪个是官方推荐、哪个是新出的替代品光靠命令行很难快速判断。不敢动升级。brew upgrade一下会连带升级很多依赖新手怕出问题老手也怕升级后某个服务起不来。卸载不干净。brew uninstall并不会自动清理没被依赖的旧版本更别说brew cleanup到底能清掉多少缓存心里没数。排查困难。软件包装了哪几个版本、被哪些包依赖、是否还在被服务引用这些信息在命令行里散落在不同命令的输出里。所以 BrewUI 的设计目标不是做一个“包管理器替代品”而是做一个“Homebrew 的可视化操作台”。它要能回答三个问题我的电脑上装了些什么、它们处于什么状态、我想要怎么办。围绕这三个问题功能边界一下子清晰了。1.2 技术选型SwiftUI 原生应用而不是 Electron我见过不少类似的工具选择 Electron Node.js 来写跨平台、前端生态丰富、上手快。但 BrewUI 从一开始就决定用 SwiftUI原因很实际它本身就是给 macOS 用户用的工具不需要考虑 Windows/Linux跨平台是伪需求。Homebrew 的命令执行和输出解析本质上是进程管理 文本处理Swift 的Process和JSONDecoder用起来非常顺手不需要额外运行时。Electron 应用体积动辄上百兆而一个 SwiftUI 应用压缩完不到 5MB对开发者工具来说轻量本身就是一种尊重。系统原生能力好接比如菜单栏、通知推送、NSWorkspace唤醒这些在 SwiftUI 里都是原生体验而 Electron 要绕一层。当然 SwiftUI 也有它的学习曲线特别是状态管理那套State、ObservableObject、EnvironmentObject初次接触会有点绕。但如果你只是做一个工具型应用SwiftUI 的声明式写法反而比 AppKit 省心得多。还有个关键问题BrewUI 该怎么驱动 Homebrew方案有三个直接调用brew二进制。调用 Homebrew API。读取 Homebrew 的数据库文件比如/opt/homebrew/Caskroom或者公式目录。方案 3 绝对不要碰那等于和 Homebrew 的实现细节耦合了。方案 2 在获取仓库信息时很有用但本机安装状态、升级依赖关系这类数据还是得靠 brew 命令本身。所以最终采用方案 1 为主、方案 2 为辅本地操作走 brew 命令搜索和展示远程仓库信息时走formulae.brew.sh的 JSON API。这样既保证了数据足够新鲜也避免短时间大量调用 brew 命令带来的延迟。1.3 设计原则永不过度解析 brew 输出Homebrew 的命令行输出设计得其实挺人性的彩色度高、提示多。但给 GUI 用解析彩色文本就是灾难。好在 brew 从很早开始就支持以 JSON 格式输出结构化数据brew info --jsonv2 --installed brew list --formula --jsonv2 brew outdated --jsonv2这是整个 BrewUI 的地基。我要求自己做到一条铁律能拿 JSON 的坚决不解析文本文案。比如判断一个包是不是已安装直接看brew list --jsonv2返回的数组长度就完了而不是去grep那行带版本号的输出。这样代码更稳也不会因为 Homebrew 改了提示文案就崩。2. 环境准备与工程骨架搭建2.1 创建 SwiftUI 项目并规划模块BrewUI 的 Xcode 工程结构很简单我把它按职责拆成了几个文件BrewUI/ ├── App/ │ └── BrewUIApp.swift ├── Models/ │ ├── BrewFormula.swift │ └── BrewCask.swift ├── Services/ │ ├── BrewService.swift │ └── BrewTaskRunner.swift ├── ViewModels/ │ ├── PackageListViewModel.swift │ └── UpdateViewModel.swift ├── Views/ │ ├── ContentView.swift │ ├── PackageListView.swift │ ├── PackageDetailView.swift │ ├── UpdateView.swift │ └── SettingsView.swift └── Resources/ └── Assets.xcassets核心是BrewTaskRunner它负责执行 brew 命令并返回结果。BrewService在它之上把命令语义化成安装、卸载、升级、清理这些操作。View 层不直接碰Process只和BrewService打交道。之所以要隔离一层是因为调试时我可以很方便地 mock 掉整个 brew 层不用每次跑真命令。Xcode 创建项目时选 macOS App界面框架选 SwiftUI语言选 Swift。这里需要注意部署目标不要定太高我建议定在你的系统版本往前一两个版本比如 macOS 13这样用户不需要为了用 BrewUI 单独升级系统。2.2 封装 brew 命令执行的细节Swift 里跑外部命令最朴素的方式是Process但直接在主线程跑会卡住 UI必须配合异步。我封装了一个最小可用的执行器import Foundation struct BrewCommandResult { let stdout: Data let stderr: Data let exitCode: Int32 } final class BrewTaskRunner { static let shared BrewTaskRunner() private let brewPath: String { // Apple Silicon 和 Intel Mac 的 Homebrew 路径不一样 let candidatePaths [ /opt/homebrew/bin/brew, // Apple Silicon /usr/local/bin/brew // Intel ] for path in candidatePaths where FileManager.default.isExecutableFile(atPath: path) { return path } return /opt/homebrew/bin/brew }() func run(arguments: [String]) async throws - BrewCommandResult { try await withCheckedThrowingContinuation { continuation in let process Process() let stdoutPipe Pipe() let stderrPipe Pipe() process.executableURL URL(fileURLWithPath: brewPath) process.arguments arguments process.standardOutput stdoutPipe process.standardError stderrPipe let stdoutHandle stdoutPipe.fileHandleForReading let stderrHandle stderrPipe.fileHandleForReading var stdoutData Data() var stderrData Data() // 用 readabilityHandler 持续读取避免管道堵塞 stdoutHandle.readabilityHandler { handle in let data handle.availableData if data.isEmpty { stdoutHandle.readabilityHandler nil } else { stdoutData.append(data) } } stderrHandle.readabilityHandler { handle in let data handle.availableData if data.isEmpty { stderrHandle.readabilityHandler nil } else { stderrData.append(data) } } process.terminationHandler { _ in stdoutHandle.readabilityHandler nil stderrHandle.readabilityHandler nil let result BrewCommandResult( stdout: stdoutData, stderr: stderrData, exitCode: process.terminationStatus ) continuation.resume(returning: result) } do { try process.run() } catch { continuation.resume(throwing: error) } } } }这里的几个细节值得注意readabilityHandler是关键。如果不异步读取管道brew 输出一多比如brew upgrade实时输出上百兆日志管道缓冲区一满子进程就会卡死这在 GUI 应用里会表现为“转圈转不完”。brew可执行文件的路径必须做适配。Apple Silicon 上是/opt/homebrew/bin/brewIntel 上是/usr/local/bin/brew。还有那种用自定义HOMEBREW_PREFIX装在别处的最好在这个基础上留一个配置入口。我没有在应用内用sudo做敏感操作。Homebrew 设计得比较好的地方就是它能避免频繁要求 root 权限凡是需要提权的场景比如装到系统目录直接在终端里让用户自己处理更安全。2.3 解析 brew 的 JSON 输出brew info --jsonv2的返回结构比较稳定顶层有两个数组formulae和casks。我针对自己的需求只保留了字段的子集。struct BrewFormula: Codable, Identifiable { let name: String let fullName: String let desc: String? let homepage: String? let versions: BrewFormulaVersions let installed: [BrewInstalledVersion]? let dependencies: [String]? let buildDependencies: [String]? let dependenciesInfo193: [DependencyInfo]? let outdated: Bool? var id: String { fullName } var installedVersion: String? { installed?.first?.version } var isInstalled: Bool { let list installed ?? [] return !list.isEmpty } } struct BrewFormulaVersions: Codable { let stable: String? let head: String? let bottle: Bool? } struct BrewInstalledVersion: Codable { let version: String let installedAsDependency: Bool? let installedOnRequest: Bool? } struct DependencyInfo: Codable { let name: String let declaredDirectly: Bool? let declaredIndirectly: Bool? }一个比较重要的经验是解析时尽量用decodeIfPresent和可选值而不是强转非可选。因为某个公式的dependencies可能为空某些老包可能没有versions.stable一旦解码失败整个 JSON 就废了。要让JSONDecoder的设置宽容一点let decoder JSONDecoder() decoder.keyDecodingStrategy .convertFromSnakeCaseconvertFromSnakeCase会把full_name自动映射成fullName省掉不少手写CodingKeys的功夫。但如果个别字段名映射不如意单独给结构体写CodingKeys就行。3. 核心功能实现从列表到操作闭环3.1 已安装软件包列表与状态标识BrewUI 第一个要做的自然是已安装包列表。拉数据我用的是brew list --formula --jsonv2相比brew info --installed --jsonv2这个命令更轻量输出就是纯数组。拿到数据后用MainActor修饰的 ViewModel 推送到界面。MainActor final class PackageListViewModel: ObservableObject { enum LoadingState { case idle case loading case loaded case failed(String) } Published var state: LoadingState .idle Published var packages: [BrewFormula] [] Published var searchText: String var filteredPackages: [BrewFormula] { guard !searchText.isEmpty else { return packages } return packages.filter { $0.name.localizedCaseInsensitiveContains(searchText) || ($0.desc ?? ).localizedCaseInsensitiveContains(searchText) } } func loadInstalledPackages() async { state .loading do { let result try await BrewTaskRunner.shared.run(arguments: [list, --formula, --jsonv2]) let formulas try JSONDecoder().decode([BrewFormula].self, from: result.stdout) packages formulas.sorted { $0.name $1.name } state .loaded } catch { state .failed(error.localizedDescription) } } }界面上列表左侧是包名和简短描述右侧做几个小状态标签是否陈旧outdated、是否作为依赖被自动安装installedAsDependency、有没有新版本可用。这些数据在BrewFormula里都有直接用标签渲染就行。这里有个体验优化的细节首次启动时brew list可能要跑一两秒数据量大了之后会明显感受到卡顿。我在 ViewModel 里加了一个简单的缓存把上次解析好的包列表存到 UserDefaults 或本地文件启动时先展示缓存后台再刷新。虽然只是“秒开 vs 等一秒”的差别但在日常使用里主观体验提升很大。3.2 搜索远程软件包并安装搜索功能走的是 Homebrew 官方提供的 JSON APIhttps://formulae.brew.sh/api/formula.json而不是让用户敲brew search。区别在于HTTP 请求可以复用URLSession的缓存策略同一关键词反复搜索不会每次都去执行 brew 命令。返回的数据比brew search的终端输出更容易过滤和排序。可以顺带展示星标数、最近提交时间等额外信息。搜索页的交互设计是这样的输入关键词debounce 300ms 后请求 API结果列表展示包名、描述、依赖数量和最新版本。点击某个包进入详情页详情页里有两个核心按钮安装、卸载。安装按钮点击后调用brew install formula安装过程需要实时输出日志。问题在于 brew 安装时的输出是持续性的而且一般会有换行动画尤其是下载进度条。我在BrewTaskRunner之外单独写了一个流式输出版本通过AsyncThrowingStream把单行输出推给 UIfunc streamOutput(for arguments: [String]) - AsyncThrowingStreamString, Error { AsyncThrowingStream { continuation in let process Process() let pipe Pipe() process.executableURL URL(fileURLWithPath: brewPath) process.arguments arguments process.standardOutput pipe process.standardError pipe pipe.fileHandleForReading.readabilityHandler { handle in let data handle.availableData if data.isEmpty { continuation.finish() } else if let line String(data: data, encoding: .utf8) { continuation.yield(line) } } process.terminationHandler { process in pipe.fileHandleForReading.readabilityHandler nil if process.terminationStatus 0 { continuation.finish() } else { continuation.finish(throwing: BrewError.exitCode(process.terminationStatus)) } } try? process.run() } }这个流式输出的好处是用户能在界面上实时看到 “ Downloading ...”、“ Pouring ...”、“ /opt/homebrew/Cellar/xxx/1.0.0” 这些熟悉的信息安装过程不是黑盒出了问题也能直接在界面里定位。3.3 卸载软件包与依赖分析卸载功能表面上就是brew uninstall formula一条命令其实没有这么简单。真实使用中经常遇到这几个问题要卸载的包被其他包依赖直接卸载会连带影响别人。包有多个安装版本默认只卸载当前激活的版本可能留下旧版本。brew uninstall默认不删除依赖的自动安装包时间长了系统里会堆满“孤儿依赖”。所以我在 BrewUI 里做了两步。第一步展示依赖关系点击包详情时显示“谁依赖它”和“它依赖谁”。这个数据来自brew info --jsonv2里的dependenciesInfo193字段这个字段把直接依赖、间接依赖、是否被用户显式请求等信息都带上了非常适合画依赖视图。实现上我没有引入巨复杂的 GraphView只是用了一个嵌套列表展示够用而且好维护。第二步卸载前提醒。如果某个包被其他包依赖在确认卸载之前弹一个警告列出受影响的上游包。如果只是想清理孤儿依赖单独提供一个“清理”按钮执行brew autoremove把installedAsDependencytrue且已经没有包依赖它的那些自动安装清理掉。这一步对长期使用的 mac 特别有用能腾出不少磁盘空间。3.4 批量升级与旧包检测升级是“最容易翻车”的操作所以 BrewUI 把它做成了单独的一个页面不是藏在包详情里的一个小按钮。页面打开时执行brew outdated --jsonv2返回当前所有有更新版本的包以及它们的旧版本号、新版本号、是否有更新注意事项。默认是全选状态用户自己想继续升哪几个就勾选哪几个点击“升级所选”后我按顺序逐个执行brew upgrade formula而不是一次性执行brew upgrade。原因很简单逐包升级可以清晰地看到每个包的升级结果哪个成功、哪个失败、失败原因是什么而一把梭全量升级中间任何一步失败日志就会乱成一锅粥。为了实现“按顺序逐个执行”我写了一个简单的队列func upgrade(packages: [BrewFormula]) async throws { for package in packages { let result try await BrewTaskRunner.shared.run(arguments: [upgrade, package.name]) // 解析 result写入升级历史 } }升级结果我用一个本地 JSON 文件记录下来包含时间、包名、旧版本、新版本、执行状态。这个历史记录在“最近操作”页面里能看到。虽然不是新功能但对排查“我什么时候升过这个包”特别有用。3.5 磁盘缓存清理清理缓存这个功能本身不难但容易判断错对象。Homebrew 的缓存目录分几块~/Library/Caches/Homebrew/downloads下载的安装包压缩包。~/Library/Caches/Homebrew/下各种临时文件。已经装了新版之后Cellar里残留的旧版本目录。brew 自带的清理命令是brew cleanup默认只清最近 30 天内没被用到的旧版本和缓存。在 BrewUI 里我加了两种模式标准清理和深度清理。标准清理执行brew cleanup深度清理额外加上brew cleanup --pruneall把超过保留期的缓存全部删掉。做这个功能期间踩过一个印象很深的坑我以为brew cleanup -s会把所有旧版本和缓存全部扫光实测下来才知道它同样受自动清理策略影响只是对“旧版本”处理得更激进一点。所以最终界面上一律提示用户先执行标准清理深度清理单独放一个按钮避免用户误操作把还在滚动的 bottle 缓存放掉了。4. 实战坑点与排查技巧实录4.1 坑点一多个 brew 进程同时跑会互相打架这是整个 BrewUI 开发过程中遇到的最隐蔽的问题。有用户在快速点击“升级全部”的同时又去搜索包结果升级任务执行到一半搜索请求也触发了 brew 命令然后就出现了Error: Another active Homebrew process is already in progress.这个错误本质上是 Homebrew 自己用锁文件做了并发保护但 GUI 应用恰恰容易在用户快速操作时触发这种并发。解决办法是在BrewTaskRunner里加一个全局串行队列保证同一时间只有一个 brew 进程在执行。actor BrewTaskRunner { private var activeTask: TaskVoid, Never? func run(arguments: [String]) async throws - BrewCommandResult { // 在 actor 内部串行执行 } }从 Swift 5.5 开始用actor天然保证了方法体内部不会并发执行。我把所有 brew 命令的入口都收敛到这一个 actor 里问题迎刃而解。如果你还在用并发队列或者信号量建议直接改成 actor代码会干净很多。4.2 坑点二列表刷新太频繁会变成“按钮连点器”之前做刷新按钮时用户双击刷新或者快速切换 Tab每次操作都会触发一次完整的数据拉取。一次brew list --jsonv2加上 UI 解析需要几百毫秒到一秒不等高频触发会让界面出现明显的抖动和卡顿。解决思路是两层一层是“请求去重”同一个 Tab 里如果上一次刷新还没结束就把新的刷新请求合并成一次另一层是“手动刷新冷却”在 UI 上做了一个 2 秒的冷却时间按钮状态变成 disabled禁止连点。这两种手段的组合比单纯写防抖好用因为防抖是 300ms 合并一次冷却时间是明确让用户感知到“操作已被受理”。4.3 坑点三JSON 解析失败往往不是数据结构问题有段时间 BrewUI 启动后频繁崩溃日志显示dataCorrupted。后来定位到问题出在某个第三方公式的versions字段里它额外带了一个version_scheme字段我没有声明按道理不会影响解码。但真正的原因是JSON 里的installed数组内有一条数据installed_as_dependency字段由于我误用了convertFromSnakeCase把installedAsDependency映射成了installedasdependency导致字段丢失后面用installedAsDependency判断来展示状态时全部成了 false。这个经历后来让我养成了一个习惯JSON 结构体写完后先写一个单元测试用真实的brew list --jsonv2输出跑一遍确认所有字段都解析正确再往下写界面。只要数据层稳了UI 层随便改都不慌。4.4 坑点四Homebrew 路径在不同系统上的差异BrewUI 早期只在 Apple Silicon 的 Mac 上测过默认路径写死/opt/homebrew/bin/brew。第一次在 Intel Mac 上跑直接白屏日志里是No such file or directory。加上路径检测后问题解决但我还发现更极端的场景用户在/opt/homebrew下装了 Intel 版本用 Rosetta 终端装的或者用HOMEBREW_PREFIX环境变量把 Homebrew 装到了自定义目录光靠两条固定路径根本覆盖不到。最终我保留了“自动检测 手动设置”双保险。自动检测逻辑是先读几个常见路径再尝试通过Process执行which brew获取当前 shell 环境下的路径如果都没有就在设置页提供路径选择器。这个兜底策略很重要尤其是面向非技术用户做分发时不能假设所有人都用标准目录。4.5 一个关于 UI 的细节经验SwiftUI 里列表刷新时有个经典问题更新Published数组给List之后行会闪一下。原因是ForEach的id发生了变化。brew 的 JSON 输出中同一个包在升级前后版本号变了如果id用的是var id: String { name }版本变化不会引起id变化列表不会闪。但如果你用fullName version拼成 id刷新后每行 id 都变了整个列表就全量重建视觉上就是明显闪屏。所以这里我的选择是id用稳定的name版本变化通过Published的installedVersion属性去驱动对应行的内容更新。这个经验对做任何带状态列表的 SwiftUI 应用都通用。5. 打包分发与后续扩展方向5.1 让用户能装得上签名与公证BrewUI 的包发布走上的是标准 macOS 开发者流程。没有开发者账号时本地直接xcodebuild archive能出 app但系统会提示“无法验证开发者身份”用户想要运行还得右键打开再二次确认。对目标用户来说这一步就足以劝退很多人。所以如果真要发布给公开用户用一定要申请 Developer ID 证书并做 notarization公证。公证的流程现在基本都是命令行一站式xcodebuild -scheme BrewUI -configuration Release archive xcodebuild -exportArchive -archivePath ./build/BrewUI.xcarchive -exportPath ./build/export -exportOptionsPlist exportOptions.plist xcrun notarytool submit ./build/export/BrewUI.app --keychain-profile brewui-notary --wait公证通过后系统在用户首次打开时就不会再弹红色危险提示。这一步虽然要花点时间但对开发者工具类应用的口碑影响非常大。我个人会把公证脚本放进 CI每次打 tag 自动出包。5.2 可以继续做下去的功能方向BrewUI 目前覆盖了包管理的“看、装、卸、升、清”五件事但 Homebrew 还有不少可挖掘的能力服务管理Homebrew 自带brew services可以管理后台服务目前是纯命令行。BrewUI 加一个服务管理页把服务列表、启动/停止/重启做成开关是非常自然的扩展。定时检查更新利用系统的LaunchAgent后台跑brew update有更新了就推通知省得用户每次都手动刷新。Cask 支持GUI 应用类的管理需求其实比公式更强brew install --cask装的是 .dmg/.pkg 应用卸载时是否能删干净是用户更关心的点。把 Cask 纳入 BrewUI 会让覆盖度一下子提高不少。菜单栏模式很多同类工具都有菜单栏常驻、点击图标快速查看版本的功能BrewUI 后续如果做只用套一层MenuBarExtra就能实现SwiftUI 原生支持得很好。5.3 版本迭代中的心得体会BrewUI 从第一个能跑的版本到现在我最大的体会是工具类应用最重要的不是炫酷而是不添乱。GUI 把好多事情变简单了但如果数据展示错乱、操作按钮状态不可靠给用户的信任感损失比命令行还严重因为命令行至少能看清每一步输出。所以在后续迭代里我会更注意“状态一致性”。包列表展示的是本地文件系统信息更新页反映的是远端仓库信息两者不能混着用。所有按钮的可用状态必须在数据刷新完成后再计算不能凭上一次的缓存猜。这不是某个具体函数能保证的而是整个架构层面的纪律。BrewUI 的名字听着像是一个“Homebrew 的 UI 壳子”但做下来你会意识到它其实是在替用户缩短“意图到结果”的距离。命令行本身没有错它依然是最高效的接口但图形界面让那些本来被命令行吓住的人也能安全地使用这个优秀的包管理系统。最后分享一个我在开发过程中形成的小习惯每次改动 brew 调用相关的代码我都会先在终端里手动跑一遍对应的命令确认输出格式再回来改代码。原因很简单Homebrew 文档更新很快有时候--jsonv2的字段在某个版本里就悄悄变了。把这个“先在终端验证再写解析代码”的流程固定下来BrewUI 维护成本能低一个量级。
返回列表