ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

BrewUI:用SwiftUI为Homebrew打造原生macOS图形包管理器

BrewUI:用SwiftUI为Homebrew打造原生macOS图形包管理器 1. 从命令行焦虑到做一款 GUIBrewUI 的出发点如果你长期用 macOS 做开发大概率已经习惯了这样一套流程装 Python 用brew install python装 Redis 用brew install redis电脑用久了想看看到底装了哪些包就敲一句brew list。这本身没什么问题命令行熟练以后效率确实高。但当你的机器上有四五百个包、十几个服务、一堆不再需要的老版本时命令行就开始暴露短板了搜索输出太长、依赖关系不直观、升级时不知道该不该带--force、卸载某个包时总是担心牵连其他东西。我做 BrewUI 的动机非常简单就是不想每次清理环境都打开终端翻那些密密麻麻的文字输出。我想在 macOS 上有一个图形界面把 Homebrew 的包管理能力全部搬到桌面上像操作 App Store 一样操作 Homebrew。BrewUI 这个名字也直白就是 Brew UI一个 Homebrew 的图形化管理工具。这个项目最初只是我自己的内部工具因为当时市面上确实有一些类似的开源项目但要么很久没维护要么只覆盖了install/search几个命令没有把brew services、brew autoremove这些高阶能力做进去。所以我自己用 SwiftUI 写了一版后来觉得有价值就整理开源了。如果你也有类似的需求——经常管理 Homebrew 包、想看清晰的信息展示、不想记一堆命令参数或者你单纯想学 SwiftUI 怎么和命令行工具交互这篇内容应该能给你一些参考。2. 技术选型背后的几条硬约束2.1 为什么用 SwiftUI 而不是 Electron很多人一听说做 GUI 客户端第一反应是 Electron因为前端生态好、开发快。但对我来说BrewUI 的目标是 macOS 原生体验需要占用小、启动快、和系统风格一致Electron 就不太合适了。SwiftUI 在 macOS 11 上已经很成熟尤其做这种工具类应用List、NavigationSplitView、Searchable 这些组件几乎是量身定做的。另外SwiftUI 的 State 管理机制非常适合异步任务场景。包管理器本质上就是一个长时间运行的命令行交互过程SwiftUI 的Task、Published、ObservableObject组合起来可以非常干净地处理异步输出不需要像前端那样搞一堆状态管理库。2.2 核心交互方式Shell 命令还是 Homebrew APIHomebrew 官方没有提供稳定的 REST API但有命令行 JSON 输出能力这确实是设计上的关键选择。BrewUI 的做法是通过Process调用本机安装的brew可执行文件然后用--jsonv2参数解析包信息。为什么不直接读取 Homebrew 的数据库文件因为目录结构、内部格式可能随版本变化而且直接解析本地文件很容易在 Homebrew 更新后挂掉。走命令行输出才是最稳定的方式。举个例子获取包列表可以用brew info --jsonv2 --installed--jsonv2返回的数据结构非常完整包含 formula 的 name、full_name、desc、versions、dependencies、installed 数组等信息。这个输出就是 BrewUI 的数据源。每次点刷新按钮其实就是重新执行一次这个命令然后把 JSON 解析成 Swift 模型。同样的搜索包用brew search 关键词 --formula安装包用brew install 包名更新包用brew upgrade 包名卸载包用brew uninstall 包名。这套思路绕开了任何私有 API本质上是把 Homebrew 的命令行包装成 GUI稳定性和 Homebrew 本身完全一致。2.3 模块划分整体架构一目了然BrewUI 的代码分为三层Model 层定义 Formula、Service、OutdatedFormula 等数据模型直接映射brew info --jsonv2的返回结构。Service 层封装BrewService负责执行 brew 命令、解析输出、发布状态变更。View 层SwiftUI 视图绑定 Service 层的数据。我刻意没有引入 UIKit 或 AppKit 混合整个项目纯 SwiftUI。这样做的最大好处是代码简洁预览也好用窗口大小自适应、深浅色模式自动适配基本不需要额外处理。3. 核心功能拆解与实现细节3.1 包列表一眼看出哪些包可以升级主界面是一个分组列表把已安装的 formula 分成几类需要升级的、已安装的、需要清理的。关键的数据来自两个命令brew outdated --jsonv2列出所有可以升级的旧版本包。brew info --jsonv2 --installed列出所有已安装包。outdated的 JSON 里包含 current_version、upgrade_version非常容易生成可升级数量角标。列表行上我同时显示包名、当前版本、最新版本和简短描述。点击一行会展开详情面板展示完整的依赖关系、依赖它哪些包、安装路径、许可证等信息。这里有一个有意思的细节Homebrew 包名和 cask 包名并不在同一个命名空间BrewUI 只展示 formula不展示 cask。因为 cask 是图形应用安装器交互逻辑完全不一样把两者混在一起会导致界面混乱。所以在过滤数据时我会把caveats包含但不限于直接按 kind 字段过滤。3.2 搜索交互支持模糊匹配和分类过滤搜索功能用 SwiftUI 的.searchable修饰符就可以实现。用户输入关键词后在brew search的结果中再次本地过滤避免每输入一个字符就执行一次命令。这个设计很重要brew search 的响应速度在几十毫秒到几百毫秒不等如果做成同步执行界面会明显卡顿。我在本地维护了一个全量公式缓存启动时执行一次brew search是没有意义的因为 brew search 只输出公式名列表但不返回描述。真正的全量搜索应该用brew info --jsonv2 --all不过这个命令输出非常巨大几 MB只适合做一次性缓存。BrewUI 默认不拉取全量数据而是通过brew search获取名称候选然后用brew info 名称补全描述。用户输入关键词时优先在本地已安装列表里匹配其次再触发 remote search。3.3 安装、升级、卸载的完整流程安装一个新包的操作流程是搜索列表里找到包点击安装按钮此时弹出确认框显示这个包的基本信息、依赖数量、预计安装大小确认后进入任务队列。任务队列是整个工具最核心的部分。因为 Homebrew 命令可能会要求用户输入密码比如安装依赖需要 sudo所以任务必须串行执行不能并发多个 brew 进程。我实现了一个CommandExecutionQueue所有任务按顺序入队每次只执行一个任务并把执行日志实时输出到界面上的一个 Terminal 风格的滚动视图里。实现要点是Process的terminationHandler回调在命令执行完毕后通知队列执行下一个任务。同时要捕捉标准输出和标准错误将它们合并为一条时间线这样用户能看到完整的安装过程而不是只等一个 spinner。func runCommand(_ executable: String, arguments: [String]) async throws - Int32 { let process Process() process.executableURL URL(fileURLWithPath: executable) process.arguments arguments let pipe Pipe() process.standardOutput pipe process.standardError pipe return try await withCheckedThrowingContinuation { continuation in process.terminationHandler { process in continuation.resume(returning: process.terminationStatus) } do { try process.run() } catch { continuation.resume(throwing: error) } } }注意pipe要持续读取并转发我用了FileHandle.availableData循环读取把读到的 Data 转成字符串后追加到日志数组。如果你直接用pipe.fileHandleForReading.readToEnd()在命令输出量大的时候会阻塞导致界面卡死所以必须用异步循环或者借助DispatchSource监听文件句柄。3.4 服务的图形化管理brew services 的包装brew services管理后台服务是 Homebrew 的特色能力。BrewUI 把services list的结果渲染成一张服务状态表每行显示服务名、状态started / stopped / error、启动的 plist 文件路径。启动、停止、重启服务操作本质上就是在runCommand里追加参数。实际操作中发现brew services有些操作需要 root 权限不是每个命令都需要。比如brew services start mysql在当前用户有权限访问 /Library/LaunchDaemons 时可能可以执行但有些服务会提示需要 sudo。这时候 GUI 必须提供一个输入密码的窗口。我用了一个非常朴素的方案在窗口上弹出一个 SecureField用户输入密码后用sudo -S方式调用命令密码通过 standardInput 写入同时开头加-S参数和sudo提示。这里有个安全教训一定不要在日志里打印密码也不要把密码保存在内存中。BrewUI 的方案是在每一条命令执行前先检测是否需要权限需要权限才弹密码框密码用完后立即从字符串变量中置空。而且在终端输出日志里如果命令包含-S我会直接把命令显示为sudo -S brew services start [服务名]密码部分绝不输出。3.5 公式的依赖关系可视化首页的依赖树是 BrewUI 最受好评的功能。点击某个包后界面会展示一个可展开的树形视图展示它依赖的所有包和所有依赖它的包。数据来源是brew info --jsonv2的 dependencies 和 reverse_dependencies 字段。构建树需要小心循环依赖。虽然 Homebrew 自身会避免循环依赖但历史版本可能残留奇怪的关系。我在树构建时用一个 visited 集合记录已经展开过的节点遇到重复就标记为循环引用而不是无限递归。界面展示效果是包 A 的右侧是它依赖的包包 A 的左侧是所有依赖 A 的包。正常尺寸窗口下用三个 NSStackView 组合展示。点击任意节点可以跳转到那个包的信息页面。这样做的好处是让你一眼看出想卸载某个包之前先看看哪些包在依赖它避免卸载完导致一堆链上的包挂掉。4. 与 Homebrew 交互中的坑和应对策略4.1 环境变量问题PATH 不正确导致 brew 找不到这是从我第一次运行brew --version就意识到的问题。macOS 的 GUI App 默认从 Finder 启动环境变量 PATH 被精简成/usr/bin:/bin:/usr/sbin:/sbin完全没有/opt/homebrew/bin。如果你的 Mac 是 Apple SiliconHomebrew 默认安装到/opt/homebrew/bin/brew那么直接调用brew会报command not found。解决方案有两种一是用绝对路径/opt/homebrew/bin/brew但这样兼容性差Intel Mac 上路径是/usr/local/bin/brew。二是查出 brew 的真实位置并写入 PATH。更好的方案是执行usr/bin/env bash -l -c which brew让 shell 以登录方式读取环境变量。我最终在BrewService初始化时做了路径探测let candidatePaths [ /opt/homebrew/bin/brew, /usr/local/bin/brew, /usr/bin/brew ] self.brewPath candidatePaths.first { FileManager.default.isExecutableFile(atPath: $0) }如果没有找到则通过Process执行/bin/zsh -ic which brew获取。zsh 的交互模式会加载用户的.zshrc通常能拿到正确的 brew 路径。4.2 输出乱序和合并问题brew 命令的标准输出和标准错误是两个独立的管道如果分别读取日志会乱序。比如brew install的下载进度信息走 stderr正常日志走 stdout乱序可能导致界面上提示信息前后颠倒。我修正的办法是把两个 pipe 都设置成同一个Pipe()对象也就是上面代码里的pipe。这样一个管道同时接收 stdout 和 stderr系统会按写入顺序合并。实测下来虽然偶尔还有一点点交错但整体可接受。4.3 带特殊字符的包名的处理Homebrew 的 formula 名一般只包含小写字母、数字和连字符但 cask 的 name 可能包含大写、点号、空格。BrewUI 只处理 formula但仍然会遇到类似openssl1.1这种带版本号后缀的包名。执行命令时参数数组里直接传openssl1.1是安全的Process 会自动处理引号。如果你图省事拼接成字符串再用bash -c执行就会遇到转义问题。所以 BrewUI 里所有命令都使用参数数组方式不用 shell 字符串拼接。4.4 权限对话框的时机brew 有些命令在执行过程中才会请求 sudo 权限比如安装一个需要运行 post-install 脚本的包可能中途提示输入密码。如果你在 GUI 里直接调用Process终端不会自动弹出密码框命令会挂起在waiting for password状态此时界面表现是安装卡住了。我后来增加了一个检测机制如果命令在 10 秒内没有产生新的输出并且进程仍然在运行就弹出命令可能需要权限是否用 sudo 重试的选项。用户点击后用 sudo 重新执行该命令但把步骤记录在队列里避免重复执行已经成功的步骤。这个方案不算完美但对于大多数 brew 操作来说已经够用。如果你的使用场景经常涉及有 post-install 的 formula建议在提示 UI 里明确标注让用户提前勾选使用 sudo 执行本命令。4.5 并发锁与多窗口误操作Homebrew 自己会用锁文件来防止多个 brew 进程同时操作同一个 formula但如果你的 GUI 开了多个窗口就可能出现两个窗口同时对同一包执行 install。BrewUI 在 Service 层增加了一个简单的互斥锁对每个包名在执行命令前先请求对该包名的独占锁如果已被占用则等待。同时全局只允许一个 brew 进程运行因为 brew 的 update 是全局操作不能和其他 install 并发。5. 界面设计细节从工具到产品的距离5.1 主窗口布局逻辑窗口左侧是 Sidebar分成仪表盘包管理服务管理清理空间四个入口。仪表盘就是启动后的首页显示一些统计卡片已安装 formula 数量、可升级数量、服务的运行数、磁盘占用估算值。统计卡片用的是 LazyVGrid固定两列布局。中间是主内容区包管理列表使用 Table 控件列分别为名称、当前版本、目标版本、简介和操作按钮。操作按钮放在行尾用弹出式菜单Menu提供安装、升级、卸载、打开依赖图等操作减少误触。右上角有一个刷新按钮点击后重新拉取所有数据。启动时自动刷新且刷新使用异步任务不阻塞界面。5.2 深浅色模式和辅助功能BrewUI 从第一天就全面支持深色模式因为很多开发者喜欢深色终端。颜色上采用了系统色Color.accentColor没有自定义品牌色这样能自动适配系统的强调色设置。辅助功能上所有按钮都有 accessibilityLabel表格行可以用键盘上下左右键选择空格键触发操作。这些细节看起来不起眼但对一个开发者常用工具来说非常加分。5.3 日志视图尽量还原终端体验在安装过程中界面下方会展开一个日志面板用等宽字体显示实时输出支持滚动到底部。我做了颜色高亮错误行显示红色、警告行显示黄色、普通输出显示默认色、命令执行前显示一行$ /opt/homebrew/bin/brew install ...这种黄色提示。这基本复刻了终端里敲命令的体感。日志是存储在LogStore里的Published数组每次 append 时截断到最多 2000 行防止内存无限增长。如果你也做类似工具这个截断很有必要因为brew install pkg的输出可能非常长尤其是编译安装。6. 调试与分发自己用的工具也要有产品化的样子6.1 调试技巧Print和os_log的取舍SwiftUI 开发中你可能会遇到明明数据已经更新但视图没刷新的情况。这个多半是Published在非主线程更新造成的。BrewUI 的所有命令执行都在后台线程完成完成后再切到主线程更新Published。我把这个封装成MainActor的方法避免每次手写DispatchQueue.main.async。调试期间我用了很多print但 App 在 Release 下会忽略这些输出不容易定位问题。后来改用os_log通过 Console.app 可以实时看到分层日志效率和可控性都高得多。建议你在正式项目里从一开始就用os_log这个习惯能少踩很多坑。6.2 签名与公证绕过 Gatekeeper 的麻烦开发版可以直接本地运行但如果你想分发给其他开发者就必须处理签名、公证和 stapler。macOS 的 Gatekeeper 会拦截未公证的应用。我申请的是个人开发者证书然后用codesign --deep --sign Developer ID签名再通过notarytool提交公证。整个过程每一步都可能踩坑。比如签名时如果项目里有多个二进制文件必须按正确的顺序签名公证前要确保所有动态库也都签了名提交后的等待时间可能长达 5-10 分钟。我的经验是先本地命令行把签名逻辑跑通再集成到 Xcode 的 scheme 里。如果你不想花 99 美元买开发者账号也可以用xcodebuild -exportArchive生成 ad-hoc 签名的 App这样在本地运行没有限制但分发给别人时麻烦一点。BrewUI 我最终以开源形式分发不追求签名用户在 GitHub Releases 下载 zip 解压后右键打开一次即可运行省事很多。6.3 自动更新自建 Sparkle 还是简单提示BrewUI 自身是一个 App也需要更新机制。我没有用 Sparkle因为 Sparkle 主要适合 Objective-C/Cocoa 绑定的架构虽然 Swift 也可以用但集成成本不低。我实现了一个简单的自动更新逻辑启动时请求 GitHub Releases 的最新 release 版本和当前本地版本比较如果有新版本就弹窗引导用户去下载 zip。这个方案不自动安装但胜在代码量少、可控。如果你是第一次做发布流程建议也用这种简单方式先把工具用起来后续再考虑自动化。7. 边界情况与压力测试7.1 网络异常和 brew 源问题国内网络访问 GitHub 仓库经常超时brew update经常失败。我做了失败重试机制命令失败时检查退出码如果非 0 就在日志里展示完整输出并把按钮恢复为可操作状态不阻塞用户继续做其他操作。同时提供了一个切换源的隐藏功能菜单可以手动设置HOMEBREW_BREW_GIT_REMOTE、HOMEBREW_CORE_GIT_REMOTE环境变量。这只是把命令行里的环境变量配置搬到了 GUI 里没有做任何代理相关的事。7.2 卸载系统关键包的保护brew uninstall项目会把一些系统依赖也列进去比如 glibc、openssl 这种被很多包依赖的底层库。BrewUI 在点击卸载时会先检查reverse_dependencies如果数量大于 0就弹出一个红色警告框列出所有依赖它的包并且要求用户输入confirm 才能继续。这个刻意设计的阻力是为了防止手滑造成的环境崩坏。7.3 长时间任务的取消和恢复brew install 一个大包可能耗时几分钟甚至几十分钟。BrewUI 在队列执行时没有提供取消当前任务的按钮因为中断一个安装过程可能导致 brew 处于不一致状态。但我提供了一个跳过当前任务的选项它会把当前 Process 终止掉但不会自动回滚用户后续可以在日志中看到当前位置。如果进程终止子进程可能残留。我在 terminationHandler 里额外用pgrep -P pid查找子进程并 kill 掉避免残余的编译进程继续跑。7.4 空状态和错误状态的用户提示搜索结果为空、服务全部停止、磁盘空间清理完成时都应该展示一个友好的空状态。我写了一个EmptyStateView包含一个大图标、一句说明文字和一个引导按钮。这个对于工具类 App 来说非常重要避免用户以为程序卡死了。8. 开源之后的反馈与迭代方向BrewUI 开源之后收到了不少 issue。主要有三类一是有人想要 cask 管理二是有人想支持 Linux因为 Homebrew 也支持 Linux三是有人希望在窗口里直接编辑 formula 的配置项。这三类需求我都认真考虑过。cask 管理其实可以单独做一套 UI因为 cask 的安装行为是下载 .dmg/.pkg需要挂载磁盘镜像交互上有很大不同。Linux 支持理论上可行只要把 brew 路径探测和权限逻辑抽象成平台无关层。编辑 formula 配置项则需要读文件并 diff难度不大但风险高改错了会导致整个依赖树出错。从我的规划来看下一步优先做的是 cask 的轻度支持只显示已安装的 cask 列表、支持卸载不支持安装。因为安装 cask 的选项太复杂而卸载相对简单。另外计划做一个依赖图导出功能把当前包的依赖树导出为 DOT 格式用于生成图片这比界面内嵌的树形展示更灵活。如果你在考虑自己写类似的工具我的建议是先从一个最小的可运行循环开始列出所有包、点击某个包查看详情、执行安装。这三个流程跑通之后再逐步添加别的功能。不要一开始就把架构设计成什么都支持因为 Homebrew 自己也在持续演进接口一变你的抽象层就不得不跟着改。9. 实测中特别值得重复提醒的三点经验第一一定要用参数数组而不是 shell 字符串来执行进程。这个我在实际开发中吃了不少苦头包名里带、、空格是家常便饭一旦字符串拼接没处理好轻则命令错乱重则误删包。第二日志系统从一开始就设计成可以持久化到文件。BrewUI 把每次命令的日志都追加到~/Library/Logs/BrewUI/command.log在用户反馈问题的时候这个日志文件能节约大量沟通时间。你也可以用一个简单的按钮复制日志到剪贴板。第三不要回避权限问题。Homebrew 在 macOS 上权限问题很普遍与其想办法蒙混过去不如把 sudo 流程做清楚包括密码输入、验证结果、失败原因。我见过其他工具为了避免弹密码框而故意禁用某些功能用户体验反而更差。BrewUI 的做法是把执行命令所需的真实权限需求列在 UI 上让用户自己决定是否授权。10. 最后分享一点关于工具类 App 的看法做 BrewUI 这一年多我最大的感受是工具类应用不需要炫酷的动画和复杂的视觉层次稳定、可预期、响应快才是核心。包管理器本身就背负着搞坏环境的心理压力所以 GUI 的职责不是显得聪明而是让人安心。每次点击更新按钮之前把将要执行的命令和可能的结果讲清楚比什么都重要。我在界面里特意保留了命令行预览功能点任何操作按钮之前都会展示将要执行的完整命令并且附带一条官方文档请参考的链接。这个设计被很多用户称赞过我也觉得这是 BrewUI 区别于其他包管理器的关键。当前版本仍然有些粗糙比如日志面板的字体大小不能调整、快捷键支持不完整、窗口布局不能自定义。但这些都是可以慢慢打磨的细节。如果你有兴趣可以拉到仓库自己改一版或者提 issue 告诉我你想加什么功能。工具软件最大的价值就是真的能替人省时间BrewUI 帮我省了希望也能帮到你。
返回列表