ARTICLE DETAIL

资讯详情

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

用SwiftUI为Homebrew打造图形界面:BrewUI的设计与实现

用SwiftUI为Homebrew打造图形界面:BrewUI的设计与实现 消息列表里最近老看到有人在刷 brewui 这个热词点进去一看不少人都在问Homebrew 能不能有个像样的图形界面说实话这个问题我也纠结了很久。倒不是命令行用不了而是当机器上装了上百个包、时不时还要帮同事处理环境时一个可靠的可视化入口能省掉太多解释成本。BrewUI 就是在这种背景下折腾出来的一个 macOS 小项目它不打算替代 Homebrew只是把brew最常用的安装、卸载、升级、搜索、服务管理、诊断检查这些操作变成了一个看得见、点得动、状态可追踪的桌面应用。如果你属于下面这几类人这篇文章应该对你有用刚接触 Homebrew、不习惯背命令参数的性价比用户被“包依赖冲突”“升级一半卡住”折磨过的日常使用者或者本身是开发者想在图形壳的基础上做二次封装。接下来我会把 BrewUI 的设计思路、关键技术点、落地实现和踩过的坑完整写出来包含可直接抄走的 Swift 代码和打包脚本希望能帮你少走点弯路。1. 为什么会有 BrewUI先把问题定义清楚1.1 命令行很好但不是所有人的菜Homebrew 本身是个优秀的命令行工具但它的心智负担被很多人低估了。brew install一敲确实流畅可一旦涉及以下场景劣势就暴露出来装过的包没有直观、可排序的清单只能靠brew list输出文本硬看。某个包被哪些包依赖、又被谁依赖想搞清楚得敲好几条命令。brew update和brew upgrade跑起来之后界面上只有光秃秃的日志没法一眼看出“这次升级会不会动到我常用的包”。对不熟悉终端的人来说面对一屏滚动日志很容易产生误操作比如升级到一半 CtrlC留下几个安装到一半的临时文件。BrewUI 最初就是奔着这些场景去的。它把 Homebrew 背后比较隐蔽的状态信息捞出来用列表、详情页和状态标签呈现。你不用再反复输入命令去核对界面上直接告诉你“哪些包可以升级”“哪个包依赖了 OpenSSL”“cask 安装的 App 装在哪”。这些信息本来 brew 都能给缺的只是一个人性化的呈现层。1.2 BrewUI 到底想解决什么问题BrewUI 不是一个“用鼠标代替键盘”的玩具它的核心价值是降低风险、提高效率。具体拆成三件事可视化包的版本、来源、依赖、安装状态一目了然。可控性每一步操作都有明确入口还能看到实时输出避免在黑暗终端里瞎敲。可交流非技术用户拿到 BrewUI也能自己完成“升级某几个包”“启动某个服务”这种操作不需要随时来问你怎么写命令。除了这三个目标我还有一个隐藏需求把 Homebrew 的 JSON 数据源摸透。Homebrew 现在几乎所有信息都能通过结构化 JSON 暴露出来可以据此做二次开发。BrewUI 本质上就是把这些 JSON 数据变成界面再通过Process调起 brew 执行变更操作。理解了这一点整个项目的技术路线就清晰了。2. 整体设计与选型先用最稳的方案跑通2.1 技术路线SwiftUI Process而不是跨平台框架项目一开始也考虑过 Electron 和 Tauri。Electron 的优势是前端生态成熟画面表现力强Tauri 胜在体积小、内存占用低。但 BrewUI 是纯 macOS 场景用户基本都装着 Xcode Command Line ToolsSwiftUI 是最贴合的方案。我自己的选型考量是这样维度SwiftUI ProcessElectronTauri开发成本需要熟悉 Swift但项目不大前端能力强上手快需要 Rust 知识包体积几 MB100MB 起步较低Homebrew 调用Process直接调二进制child_process调用需要 command 插件系统整合原生体验权限路径自然需要额外处理环境变量适配层要自己写适合场景macOS 专属工具跨平台应用跨平台但偏好 RustBrewUI 是明显的“macOS 专属工具”没必要背一个跨平台框架。SwiftUI 的状态绑定能力也适合解决 brew 命令异步执行这个核心难点界面观察命令状态命令结束自动刷新列表不会出现 UI 和数据不同步的问题。2.2 架构分层BrewService、Parser、ViewModel、ViewBrewUI 的代码没有搞太复杂的分层只拆了四块每一块只干自己那件事BrewService负责拼命令、启动Process、读取输出。这是整个应用的“引擎”所有 brew 操作都从这里过。BrewParser负责把 brew 返回的 JSON 解析成 Swift 模型。它不关心数据从哪来只负责“翻译”。BrewViewModel把BrewService的结果整理成视图能直接使用的状态比如installedPackages、upgradablePackages、isRunning。BrewViewSwiftUI 视图层只管展示和用户交互。这样拆的好处是以后如果想把 UI 换成命令行交互或者加一个 web 远程管理界面只需要复用BrewService和BrewParser不需要重写核心逻辑。2.3 数据源Homebrew 自带 JSON 接口很多开发者不知道Homebrew 从很早开始就提供了 JSON 输出。BrewUI 主要用这几个brew info --jsonv2 --formula输出已安装 formula 的完整 JSON。brew info --jsonv2 --cask输出已安装 cask 的完整 JSON。brew search --formula --json keyword搜索 formula。brew search --cask --json keyword搜索 cask。brew list --versions快速拿到版本列表。brew outdated --json获取可升级包列表。选择 JSON 接口而不是去brew list之后用字符串硬解析原因很简单brew list的文本输出在不同版本可能微调而 JSON 字段相对稳定解析代码也不容易断。实际开发中我踩过文本解析的坑换到 JSON 后基本一劳永逸。3. 核心功能实现与踩坑3.1 让 GUI 能调用 brew动态定位二进制Mac 上的 brew 有两个常见位置Apple Silicon 机器上默认是/opt/homebrew/bin/brewIntel 机器上默认是/usr/local/bin/brew。直接用固定路径会碰到“用户机器上没这个路径”的问题。我写了一个定位方法启动时先看PATH环境变量再从两个已知路径里选一个存在的最后才返回 nilimport Foundation enum BrewLocator { static func locateBrewPath() - URL? { let candidatePaths [ /opt/homebrew/bin/brew, // Apple Silicon /usr/local/bin/brew // Intel ] let envPaths ProcessInfo.processInfo.environment[PATH]? .split(separator: :) .map(String.init) ?? [] for path in envPaths { let url URL(fileURLWithPath: path).appendingPathComponent(brew) if FileManager.default.isExecutableFile(atPath: url.path) { return url } } for path in candidatePaths { if FileManager.default.isExecutableFile(atPath: path) { return URL(fileURLWithPath: path) } } return nil } }这个顺序有讲究优先相信用户PATH里的 brew因为可能是自己编译或者用其他包管理器装的找不到再退回两个默认路径。实测下来绝大多数机器都能命中少数用户把 Homebrew 装在别的目录时才会需要手动配置。提示如果你的应用要跑 sudo 类操作注意Process调 brew 时不会经过用户 shellPATH经常是空的。定位 brew 二进制必须传完整路径不能依赖 shell 的which逻辑。3.2 跑命令不卡界面异步执行与输出分流Process本身是同步等待的如果直接在主线程调用waitUntilExit()界面必然卡死。BrewUI 的做法是把真正跑命令的过程放到全局并发队列同时把 stdout 和 stderr 用两个Pipe分开读避免输出量大时阻塞管道。这里是一个我自己用着很舒服的模板discardableResult func runBrew(arguments: [String], environment: [String: String]? nil, completion: escaping (Int32, String, String) - Void) - Process { let process Process() process.executableURL BrewLocator.locateBrewPath() var env ProcessInfo.processInfo.environment env[HOMEBREW_NO_AUTO_UPDATE] 1 env[HOMEBREW_NO_ANALYTICS] 1 environment?.forEach { env[$0.key] $0.value } process.environment env process.arguments arguments let stdoutPipe Pipe() let stderrPipe Pipe() process.standardOutput stdoutPipe process.standardError stderrPipe let queue DispatchQueue(label: com.brewui.process) queue.async { process.waitUntilExit() let stdout stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderr stderrPipe.fileHandleForReading.readDataToEndOfFile() let status process.terminationStatus let out String(data: stdout, encoding: .utf8) ?? let err String(data: stderr, encoding: .utf8) ?? DispatchQueue.main.async { completion(status, out, err) } } process.launch() return process }HOMEBREW_NO_AUTO_UPDATE是一个必须加的环境变量。不加的话每次执行brew install或brew upgrade都可能触发自动更新界面会长时间停在“正在检查更新”的假死状态用户体验极差。单独留一个“检查更新”按钮效果更好。3.3 解析 JSON建出包列表Homebrew 的--jsonv2输出格式很完整。以 formula 为例大致结构是这样{ formulae: [ { name: wget, full_name: wget, versions: { stable: 1.24.5 }, installed: [ { version: 1.24.5, installed_on_request: true, runtime_dependencies: [...] } ], dependencies: [openssl3, pcre2], caveats: ... } ], casks: [] }Swift 端可以用Codable定义对应的模型但不用定义全部字段只需要把 UI 关心的字段解析出来struct BrewFormula: Codable, Identifiable { var id: String { name } let name: String let fullName: String let versions: BrewVersions let installed: [BrewInstalledInfo]? let dependencies: [String]? let caveats: String? enum CodingKeys: String, CodingKey { case name, versions, installed, dependencies, caveats case fullName full_name } } struct BrewVersions: Codable { let stable: String? } struct BrewInstalledInfo: Codable { let version: String let installedOnRequest: Bool? enum CodingKeys: String, CodingKey { case version case installedOnRequest installed_on_request } }解析的时候注意brew info --jsonv2 --formula会输出一个formulae数组而brew info --jsonv2 formulaName的返回结构在早期版本是数组在 v2 里也是包在外层。最稳妥的办法是统一解析 v2 这个外壳struct BrewInfoPayload: Codable { let formulae: [BrewFormula] let casks: [BrewCask] }这里有个细节installed字段在“没有安装”时返回空数组在“已安装”时里面有元素。UI 判断“是否已安装”不能只判断字段是否存在要判断数组是否为空。这个坑让我在列表页浪费了一个下午的调试时间。3.4 安装、卸载、升级的状态机BrewUI 的每个操作按钮都要区分几个状态空闲、运行中、成功、失败。比如“安装”按钮点击之后要立即变成“安装中并禁用点击”命令结束后根据退出码切换状态。我一开始直接用一个Bool表示“正在执行”后来发现多个操作并发时状态会乱。改成一个小型状态机enum BrewOperationState { case idle case running case succeeded(String) case failed(String) }BrewViewModel里维护一个[String: BrewOperationState]字典key 是包名。按钮的禁用逻辑只认running显示逻辑分别处理四种情况。这样即便后面想支持“同时操作多个包”状态也不会互相覆盖。在实际操作过程中brew install的输出是流式的你可以在stdoutPipe.fileHandleForReading.readabilityHandler里实时刷新一段Text模拟终端效果。有经验的开发者应该懂长命令最怕“看起来没反应”实时输出是最好的安慰剂。4. 分发、签名与机器环境适配4.1 签名与公证macOS 从 Catalina 开始强制要求公证否则用户在别的机器上下载后无法直接打开。BrewUI 这部分我吃过亏本地跑得好好的发给朋友打开就是“已损坏无法打开”后来才想起没做 notarize。公证流程现在用notarytool比较快。基本流程整理成了脚本# 1. Archive xcodebuild archive \ -scheme BrewUI \ -archivePath ./build/BrewUI.xcarchive \ -destination platformmacOS \ -configuration Release # 2. Export xcodebuild -exportArchive \ -archivePath ./build/BrewUI.xcarchive \ -exportPath ./build/BrewUIExport \ -exportOptionsPlist ./ExportOptions.plist # 3. 压缩并提交公证 ditto -c -k --sequesterRsrc --keepParent \ ./build/BrewUIExport/BrewUI.app \ ./build/BrewUI.zip xcrun notarytool submit \ ./build/BrewUI.zip \ --apple-id your-apple-id \ --team-id your-team-id \ --password your-app-specific-password \ --wait # 4. 装订 xcrun stapler staple ./build/BrewUIExport/BrewUI.app这一步踩坑的地方在于应用内所有可执行文件、动态库都要正确签名包括 SwiftPM 拉下来的本地依赖。用codesign --verify --deep --strict可以提前检查别等公证返回失败再回头找。4.2 两个芯片架构的路径差异BrewUI 必须处理 Intel 和 Apple Silicon 两种机器。除了 brew 路径不同还有两个隐藏问题/opt/homebrew和/usr/local下的 Cellar、Caskroom 目录不同如果直接读文件系统路径要区分。如果你的 BrewUI 想做“查看安装目录”的功能必须在脚本或代码里同时兼容/opt/homebrew/opt/formula和/usr/local/opt/formula。更省事的方案是所有信息都通过 brew 命令本身获取不要直觉去拼接路径。比如查看包安装位置直接用brew --prefix拿到当前机器的前缀再拼包名BREW_PREFIX$(brew --prefix) echo $BREW_PREFIX/opt/wget写完代码后再跑一遍arch命令确认 CPU 架构给不同架构的测试机各发一版比在单一架构上写完就打包靠谱得多。4.3 更新提示与自定义 TapBrewUI 目前是菜单栏加主窗口的应用我觉得后续还可以继续做两类能力一类是定期读取brew outdated --json在菜单栏显示“有 N 个更新”的角标另一类是接入自定义 Tap让企业用户能统一维护内部工具直接展示在同一个列表里。这两块不复杂但很影响实际体验。菜单栏角标可以让用户不必隔三差五打开主界面自定义 Tap 则能覆盖公司内网场景。目前 BrewUI 先把核心链路跑稳这些能力在迭代中逐步加。5. 常见问题排查与实战笔记5.1 子进程环境变量丢失BrewUI 里执行 brew 命令时如果发现“命令执行失败但终端里跑同样的命令却能成功”大概率是环境变量问题。GUI 应用从 LaunchServices 启动时不会加载用户 shell 里的~/.zshrcPATH通常是/usr/bin:/bin:/usr/sbin:/sbin。处理方法前文已经提到定位 brew 时用完整路径拼接 brew 需要额外依赖的可执行文件时再补上PATH。比如调用brew service start时如果内部依赖了launchctl而launchctl在/bin/launchctl直接用完整路径就行。5.2 JSON 偶发解析失败刚开始用JSONDecoder解析 brew 输出时偶发遇到过decodeError。后来打日志才发现brew 偶尔会在 JSON 前面输出一行“Warning: 某个旧目录残留”之类的警告。这些 Warning 会混进 stdout导致 JSON 整体无法解析。解决办法有两个层面执行命令时给 brew 加-q或--quiet减少警告输出。解析前先把输出字符串规整一下找到第一个{的位置前面全部丢弃再用Data解析。func extractJSON(from raw: String) - Data? { guard let start raw.range(of: {) else { return nil } let trimmed String(raw[start.lowerBound...]) return trimmed.data(using: .utf8) }这个技巧处理 brew 的潜在警告非常管用尤其在老版本 macOS 上兼容性一下子好了不少。5.3 权限与系统扩展BrewUI 目前不依赖 sudo日常的安装卸载升级都不需要密码。但brew services的分层操作有点特殊用户级服务直接用brew services run系统级服务在某些机器上要手动配合sudo。我的建议是BrewUI 界面默认只展示和管理用户级服务系统级服务通过日志输出提示用户需要在终端人工处理。这个边界要划清楚。一个 GUI 应用如果频繁弹授权框用户会觉得很不安。尽量把权限要求控制在最低范围只在确有必要时提示。5.4 调试技巧BrewUI 最有用的调试方式不是看界面而是把所有Process的 stdout 和 stderr 同时写到本地日志文件。我写了个很简单的 log 工具每次执行命令都会在~/Library/Logs/BrewUI/brew.log追加func logCommand(_ arguments: [String], stdout: String, stderr: String) { let logLine $ brew \(arguments.joined(separator: )) --- stdout --- \(stdout) --- stderr --- \(stderr) let logger Logger(subsystem: com.brewui.log, category: Command) logger.info(\(logLine, privacy: .public)) }配合 Console.app 的实时过滤很多“莫名失败”一眼就能看出原因。特别是brew upgrade升级依赖后 stdout 里那一大堆日志按关键词过滤后往往能快速定位到某个包编译错误。6. 接下来我还会怎么迭代 BrewUI从第一个可运行的版本到现在BrewUI 的核心逻辑已经稳定。说实话这个项目给我最大的感受是Homebrew 本身代码质量高JSON 数据很全做 GUI 壳子并不难难的是把边界想清楚把状态管好。我在实际开发中养成的一个习惯是每个新功能先问自己“这个功能底层数据从哪来能不能通过已有的 brew 命令拿到”拿不到就不做免得动不动就要解析随版本变动的文本格式。这个习惯可以直接迁移到任何工具类应用开发里。如果你也想自己动手写类似的项目我建议从“看日志”功能做起先做一个能显示brew install xxx输出的页面再把列表页加上。一步一步来你会比我更快摸清整个 Homebrew 的数据模型。最后再分享一个小技巧开发阶段给自己准备一台“随便折腾”的 Intel 旧 MacApple Silicon 和 Intel 两条路径都要实测很多环境和路径问题只有换架构才会暴露。
返回列表