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

资讯详情

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

BrewUI:用SwiftUI为Homebrew构建原生图形化包管理客户端

BrewUI:用SwiftUI为Homebrew构建原生图形化包管理客户端 1. 为什么我想给 Homebrew 做一套图形界面说实话我做这个叫 BrewUI 的小项目起因特别朴素Mac 上的 Homebrew 用多了之后每次brew update、brew upgrade、brew cleanup敲得我手指头都累而且终端滚屏一多到底哪个包更新了、哪个包依赖有问题、磁盘又被哪几个大文件占着扫一眼根本看不清。BrewUI 说白了就是给 Homebrew 套一层图形化客户端让你用鼠标点击就能完成软件包的管理——安装、卸载、升级、查看依赖关系、清理旧版本不用再背一堆命令。它跟 Homebrew 本身没有关系也不是要替代它而是把 brew 强大的能力包装成更直观的界面底层仍然是调用系统里的 brew 命令行工具把命令输出解析出来再渲染成 UI。这个项目适合谁呢第一类是刚接触 Homebrew 的新用户对着终端怕得不行又不想放弃 Homebrew 这棵大树第二类是像我这样命令行用得很熟、但就是想偷懒的老人批量升级之前想快速看一眼哪些包要动避免每次都是brew upgrade一把梭第三类是给别人装环境的人图形界面演示起来比甩一串命令友好得多。做之前我给自己定了几条硬性要求界面必须快不能因为一个包卡住整个应用底层依赖必须是标准的 brew 命令不做黑魔法解析逻辑必须稳定brew 输出的格式一变宁可报错也不能把错误数据渲染给用户。后面所有架构和编码的取舍都是围绕这三条来的。2. BrewUI 的整体设计思路和技术选型2.1 为什么用 SwiftUI 而不是 Electron 或 Web 技术我第一版其实用的 Electron毕竟前端写起来效率确实高一套代码还能同时出 Win 和 Mac 两个版本。但等我接上 brew 之后很快发现问题Electron 打包出来体积轻松超过 100MB针对一个系统工具来说实在太重了。更关键的是brew list、brew info 这类命令每次输出的数据量不小如果你频繁点按钮每次都要在 Node 和原生进程之间搬数据内存波动非常明显。后来我整个推翻重写改用 SwiftUI。原因很简单这是 macOS 上性能最可控的原生方案渲染列表用的是 LazyVStack数据多了也不会一次性卡死内存占用我实测下来只有 Electron 版本的六分之一而且 Swift 直接调用 Process 来跑命令拿到的输出直接就是 Data不用做字符串跨进程传输。注意如果你以后想复用这套代码到 iOS 或者 iPadOSSwiftUI 也有优势。brew 本身跑不了但你可以把 UI 层留着底层换成远程执行命令的 API这是我后来才意识到的扩展空间。2.2 数据流设计双向的“命令-解析-渲染”管道BrewUI 的核心数据流是一条单向管道用户点击按钮 - 组装并执行 brew 命令 - 读取标准输出和标准错误 - 解析结构化的 JSON - 转成 UI 模型 - 驱动界面刷新。所有操作都走这条管道不搞特殊路径。比如“获取全部包列表”和“搜索一个包”最后拿到的都是统一的 PackageInfo 模型只有命令参数不同。这样做的好处是界面层根本不关心数据是怎么来的只管渲染模型后面想换成 apt 或者 winget只需要替换底层的 executor 和 parser。管道上我加了一个操作串行队列所有 brew 命令都排在一个后台队列里挨个执行而不是开多个并发进程。原因我后面在“常见问题”里会细说——brew 自己有全局锁并发执行基本必挂。2.3 线程模型和界面稳定性SwiftUI 有一条铁的规矩UI 只能在主线程更新。但 brew 命令动不动要跑几秒钟如果你在主线程里等它界面就会转菊花甚至出现“无响应”。我第二版因为偷懒在主线程执行过一次brew upgrade --dry-run结果整个 App 卡了快十秒用户点击任何地方都没反应。所以 BrewUI 的整体线程模型固定成三层主线程只管渲染和接收用户事件一个后台串行队列负责执行 brew 命令命令执行完后回到主线程更新模型。中间靠MainActor和Task来做隔离Swift 5.5 之后的 async/await 让这种切换写起来非常干净。记住一个原则任何可能阻塞超过 50 毫秒的操作都不要出现在主线程里。3. 核心数据层命令执行、输出解析与依赖关系3.1 命令执行器Process 封装与安全参数传递执行器是 BrewUI 地基中的地基。我封装了一个CommandExecutor核心能力只有一个给定可执行文件路径和参数数组返回标准输出、标准和退出码。写这个类的时候我特别注意一点所有命令参数必须是数组绝不能先把参数拼成一个字符串再丢给 shell 执行。原因很好理解brew list --formula 和 brew list --cask 的区别如果拼成字符串再传给/bin/zsh -c brew list --formula一旦参数里有需要转义的字符就可能注入额外命令。BrewUI 里搜索框是用户直接输入的文本这个风险必须堵死。MainActor final class CommandExecutor { enum CommandError: Error { case nonZeroExit(Int32, String, String) } static func run( _ executable: String, arguments: [String], environment: [String: String]? nil ) async throws - (stdout: String, stderr: String) { let process Process() process.executableURL URL(fileURLWithPath: executable) process.arguments arguments let stdoutPipe Pipe() let stderrPipe Pipe() process.standardOutput stdoutPipe process.standardError stderrPipe if let environment { process.environment environment } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in process.terminationHandler { proc in let stdoutData stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData stderrPipe.fileHandleForReading.readDataToEndOfFile() let stdout String(data: stdoutData, encoding: .utf8) ?? let stderr String(data: stderrData, encoding: .utf8) ?? if proc.terminationStatus 0 { continuation.resume(returning: (stdout: stdout, stderr: stderr)) } else { continuation.resume(throwing: CommandError.nonZeroExit( proc.terminationStatus, stdout, stderr )) } } do { try process.run() } catch { continuation.resume(throwing: error) } } } onCancel: { process.terminate() } } }退出码为 0 就返回输出非 0 就抛错误这个行为一定要严格不能让调用方拿到半截输出当完整数据用。3.2 输出解析器JSON 优先文本兜底Homebrew 从 1.7 开始支持brew info --jsonv2这是 BrewUI 解析层最大的福音。之前解析brew list的纯文本要靠一个空格拆分遇到带空格的包名直接裂开换成 JSON 之后包名、版本、依赖、描述、安装路径、caveat 这些字段全部是结构化的解析稳如老狗。但有一类命令拿不到 JSONbrew update的输出是给人看的文本brew doctor的警告也是文本。所以我的解析器分了两层凡是能用--jsonv2的命令一律走 JSON 解析只能输出文本的命令则通过关键字匹配拆分成状态条目比如brew update里的Already up-to-date.和Updated 3 tap渲染成对应状态卡片。依赖关系的解析我也走 JSON。brew info --jsonv2返回的每个 formula 对象里有dependencies、build_dependencies、recommended_dependencies等数组我要做的就是把它们拼成一个有向关系集formula 是节点依赖是边然后在 UI 里用自定义的 GraphView 画成树形图。第一版我用的是嵌套列表但你会发现查不清“这个包被谁依赖”换成图结构之后一眼就看到完整的上下游。3.3 依赖关系图的数据结构依赖图我用的是邻接表每个节点保存出边和入边direct_dependencies是出边reverse_dependencies是入边。JSON 里没有直接的“反向依赖”字段所以我是把所有 formula 遍历一遍把每个 dependency 都反向登记到它的依赖者列表里。这个操作在命令行下是brew uses --installed formula才能看到的但 I 在数据层直接算好渲染的时候零成本。算法也很简单struct DependencyGraph { private var outgoing: [String: SetString] [:] private var incoming: [String: SetString] [:] mutating func addDependency(from formula: String, to dependency: String) { outgoing[formula, default: []].insert(dependency) incoming[dependency, default: []].insert(formula) } func reverseDependencies(of formula: String) - SetString { incoming[formula] ?? [] } }这个图在最坏情况下会有几千个节点但对 SwiftUI 来说毫无压力因为我是按需查询的——用户展开某一个包的依赖详情时才去查邻接表而不是一开始就把整个图铺满屏幕。4. UI 层的实操实现4.1 主界面布局三栏式结构BrewUI 主界面我用了典型的 macOS 三栏结构跟 Xcode 左边导航栏类似最左侧是来源分类中间是包列表右侧是详情面板。分类栏包含“全部 Formula”“全部 Cask”“已安装”“可升级”“无依赖”“孤儿包”这六个智能列表每个智能列表本质上是一个过滤谓词而不是一份独立数据副本。这种设计有个大好处数据源只有一个[PackageInfo]数组界面上任何筛选都只是对同一个数组做惰性过滤不会出现多个列表之间数据不同步的问题。我第一次做的时候给每个分类单独维护了一个数组结果升级一个包之后所有列表乱七八糟。后来统一成单一数据源 谓词过滤整个世界清净了。中间列表我用LazyVStack渲染每行展示包名、版本号和一个小的状态圆点绿色表示已安装最新版黄色表示有升级可用灰色表示未安装。点击某一行后右侧详情面板会异步拉取brew info --jsonv2的详细数据包括依赖、反向依赖、安装体积、描述和 web 页面链接。整个主界面代码量不算大难点全在数据的加载和状态管理上。4.2 安装与升级如何优雅地处理“大批量操作”安装和升级是最核心、对用户体验影响最大的操作。安装单个包的流程是点击 Install 按钮 - 禁用该行的所有按钮防止重复操作 - 后台串行队列执行brew install formula- 输出流不断追加到右侧日志面板 - 完成后刷新整个数据源。升级我特意做了“差分升级”而不是无脑brew upgrade。因为直接升级所有包经常有不愿看到的意外比如某个依赖版本被强制改动。差分升级的思路是先跑一次brew outdated --jsonv2拿到所有可升级的包和最新版本在界面上用复选框让你逐个勾选确认之后只对勾选的包执行brew upgrade formula。这样每次发布新版本之后我先升自己最关心的几个剩下的观察两天再说风险可控。大批量操作的时候进度反馈最重要。我用了一个OperationProgress模型当前操作的包名、总包数、已完成数、当前命令输出片段。日志面板就渲染这个模型。别小看这个 UI 细节用户看到“12/35 正在升级 xx”比看一个转圈菊花安心得多。4.3 卸载、清理和孤儿包处理为什么要做“二次确认”卸载比安装更危险因为你装的时候是明确的卸载时往往忘了还有别的包依赖它。BrewUI 里卸载按钮点击后我会先展示“反向依赖列表”如果这个包被其他已安装的包依赖警告条就会亮红字“以下已安装包依赖它强制卸载可能导致它们失效”。清理操作brew cleanup也一样我先给你列出将要被清理的旧版本文件以及能释放多少磁盘空间确认后才执行。磁盘空间我是在命令执行前用brew cleanup -n取一次预演输出解析出“would clean”那几行再展示给用户。宁可在确认弹窗里多等一秒也比手滑清掉了不该清的东西强。提示卸载逻辑里有一个特殊分支。如果用户想卸载的包是 Cask绝不能直接用brew uninstall --force会把该 Cask 的配置文件一并删掉应该用brew uninstall --zap或者先问用户是否需要保留配置。这个分支我踩过坑后面有一个用户数据差点丢了的录像教训很深。4.4 实时日志与批量操作的进度管理brew 命令执行过程中会持续输出大量文本brew install在编译源码包的时候尤其夸张。所以执行器不能等命令完全结束才把输出搬到 UI而是用FileHandle.readabilityHandler持续读取管道内容追加到日志缓冲区。日志缓冲区只保留最近 400 行防止内存无限膨胀。为什么是 400因为一个复杂的编译日志上万行很正常但用户真正关心的最后几百行就够了要往前翻你直接去终端跑命令更靠谱。批量操作和我单个操作共用同一个后台串行队列所有brew进程一视同仁排着执行。界面上的“取消全部”按钮会给当前进程发终止信号并且放弃队列里剩余任务但正在执行的命令会等它自己退出绝不强制 kill防止 brew 写一半留下损坏的安装状态。5. 常见问题与排查技巧实录5.1 brew 命令并发执行导致的锁冲突这个问题我强调过很多次但还是要再讲一遍Homebrew 自身有一个进程级的全局锁如果你同时跑两个brew install后启动的命令大概率会报Another active Homebrew process is already in progress。我在开发阶段踩的第一个大坑就是无脑并发列表刷新和后台升级同时进行结果升级命令直接失败报了锁冲突。解决方案就是前面说的全局串行队列所有命令按提交顺序排成一队同一时间绝对只有一个 brew 进程在跑。注意即便你用了串行队列也拦不住外部终端的操作。如果用户在终端里同时跑了一个brew update你的 App 发起的命令依然可能撞锁。所以我捕获到锁冲突的错误时会弹一个友好提示“Homebrew 正被其他进程占用请稍后再试”而不是直接崩溃。5.2 命令输出解析的意外情况输出不是完整 JSONbrew info --jsonv2正常应返回一个 JSON 数组但某些 tap 源或网络异常时命令输出可能会混入非 JSON 的警告文本直接 JSONDecoder 解码就会失败整个列表加载失败。我的经验是解析前先用正则把输出里从第一个[到最后一个]之间的部分截取出来再丢给 JSONDecoder。虽然听起来有点土但在 brew 输出格式“半文本半 JSON”的极端情况下真的救命。func extractJSONArray(from rawOutput: String) - String { guard let firstBracket rawOutput.firstIndex(of: [), let lastBracket rawOutput.lastIndex(of: ]) else { return rawOutput } return String(rawOutput[firstBracket...lastBracket]) }另外一个坑是 brew 的--jsonv2输出体积非常大。装了 500 个 formula 的时候完整 JSON 能有十几 MB。如果你在主线程直接 JSONDecoder几秒的白屏妥妥跑不掉。所以解析一定放在后台 Task 里做解析完成再切主线程刷新。5.3 sudo 权限问题的处理方式brew 的大多数操作都不需要管理员权限用户目录下就能完成。但某些 Cask 安装器在安装过程最后会弹系统级授权框比如安装某些带有 pkg 的 App。这个授权弹窗是 App 自己弹出来的并不是我要处理的问题。真正需要我处理的是另一种情况用户将 Homebrew 安装到了/opt/homebrew系统目录而该目录的权限被改动过。此时brew install可能因为权限不足失败错误信息里带着Permission denied。我的解决方案是不在 App 层尝试任何sudo操作而是检测到权限错误后提示用户到终端执行sudo chown -R $(whoami) /opt/homebrew修复目录归属。坚决不在 App 里内置 sudo 逻辑的原因有两个一是 sudo 需要密码交互Process 里处理密码输入本身就是安全黑洞二是给一个包管理工具提权等于把整个系统的钥匙交出去了这个口子绝对不能开。5.4 UI 状态与真实 brew 状态不一致的处理BrewUI 有一个很棘手的场景用户既用了 App又在终端跑命令两边操作互相看不到UI 里显示“已装”的包在真实环境中可能已经被手动卸了。我刚做完的时候没管这种情况结果用户反馈“点了升级按钮没反应”。因为 UI 数据源是内存缓存缓存里显示可升级但真实环境已经是最新版本执行brew upgrade formula时 brew 直接返回“已经是最新”我的解析器把退出码 0 当作成功于是按钮看起来“没反应”。之后我加了一个“手动刷新”快捷键同时在每次执行安装/升级/卸载操作成功后自动触发一次深度的brew list --jsonv2数据重新拉取让 UI 跟随真实状态。虽然多花一点时间但一致性对这类工具来说永远是第一位的。5.5 常见问题速查表现象原因解决办法点击操作后一直转圈命令执行中或 brew 被外部进程锁住等待或关闭外部终端里的 brew 进程列表加载失败提示 JSON 解析错误输出混入非 JSON 文本或数据过大后台解析 截取 JSON 区间见 5.2升级按钮点击“没反应”UI 缓存与真实状态不一致手动刷新数据源或执行时传递--forcePermission denied报错Homebrew 安装目录权限异常在终端执行sudo chown -R $(whoami)修复Cask 配置被误删使用了--force卸载避免--force优先brew uninstall --zap界面卡顿严重主线程执行了解析或 IO 操作检查是否所有 IO 都放到了后台任务5.6 几个独家的稳定性细节再分享几个一般文档里不会写的细节。第一brew 命令执行前先跑brew config的缓存拿到HOMEBREW_PREFIX、HOMEBREW_CELLAR这些路径。很多操作如果路径写死换台机器就废了但通过brew config动态读取后可移植性大大增强。第二执行brew install/upgrade时给子进程设置HOME环境变量非常重要。因为 brew 的用户级配置在~/.homebrew下如果你用Process运行时不显式传递HOME环境异常时 brew 的行为会变得很诡异建议显式传递当前用户的完整环境。第三日志面板的字体我用的是系统monospaced字体字号 12。如果字太小编译输出挤成一团用户根本找不到错误在哪一行。界面看起来美观性差点但实用性拉满。最后分享一点实操体会做 BrewUI 这段时间我最大的感受是一个工具型应用的难点从来不是界面好不好看而是边界条件的处理。brew 本身是一个非常成熟、行为稳定的命令行工具我的工作本质上是替用户把这些稳定但繁杂的能力包装成不容易出错的操作这就要求每一个按钮背后都要想清楚用户如果点错了怎么办、命令执行到一半取消怎么办、真实状态和预期不符怎么办。把这些情况都兜住了应用才会真正贴心。另外一个很有用的经验是解析层一定要先做好 mock 测试再对接真实 brew。我在开发早期抓到各个版本的 brew 输出样本存成 JSON 测试用例每改一次解析逻辑就跑一遍全部测试这样 brew 升级导致输出格式微调时你能在几分钟内定位到是哪条解析规则挂了而不是被用户吐槽之后才被动排查。这个习惯帮我省了太多时间。BrewUI 目前还在持续迭代下一步我计划加入 tap 管理、service 管理和更直观的依赖图交互。如果你也在做类似的工具型 App希望这份总结能给你一些参考尤其是数据层和线程模型的部分。有任何更好的方案欢迎一起讨论。
返回列表