BrewUI 新手教程
Homebrew 官方出品的 macOS 图形界面。不碰终端也能发现、安装、升级、卸载 Homebrew 包——而且它从不隐藏 Homebrew 到底在做什么。
01这是什么
一句话:BrewUI 是 Homebrew 官方出的 macOS GUI——用原生 SwiftUI 写的一个 App,把「发现 → 安装 → 升级 → 卸载」这套包管理动作,从终端搬进窗口里,同时坚持一条原则:不隐藏任何东西。
仓库 2026 年 3 月创建,2026 年 9 月 15 日刚发 v0.4.2。协议 AGPL-3.0,最低系统 macOS Tahoe 26,语言模式 Swift 6.0(严格并发)。它不是玩具项目——生产代码里大约每 1 行功能代码就配了 1 行测试。
它不做什么(反向界定,最能消除误解)
新手最容易把 BrewUI 想成「Homebrew 的重写版」或者「Homebrew 的替代品」。都不是。把边界划清楚,后面看什么都顺:
| 常见误解 | 实际情况 |
|---|---|
| 它自己实现了包管理逻辑 | 没有一行 Ruby。所有真实动作都是起一个 brew 子进程去执行。App 只负责「决定跑什么」和「把输出显示给你看」 |
| 它捆绑了 Homebrew | 源码运行时不捆绑。只有官方的 .pkg 安装包会带一个 postinstall 脚本:如果系统上找不到 brew,才用官方签名过的安装器装一遍 |
| 它也能装到 iPhone / iPad 上 | 纯 macOS App。项目里明确写着「No iOS / iPadOS / cross-platform for now」 |
| 它能管自定义 prefix 的 Homebrew | 只认默认两个位置:Apple Silicon 的 /opt/homebrew 和 Intel 的 /usr/local,且顺序写死 |
| 它能装第三方 tap | 初期范围只覆盖核心 tap(core tap scope),自定义 tap 暂不支持 |
BrewUI,cask 叫 homebrew-app,装出来的 App 叫 Homebrew.app,bundle identifier 是 sh.brew.app。这四个名字指向同一个东西——教程里会交替出现,看到别慌。
那么,为什么需要它?终端到底哪里让人难受?
02为什么需要它
先别急着看界面。先问一个更根本的问题:一个不想用终端的人,现在想装个命令行工具,他有什么办法?摆出三条现实中的路,看看代价分别是什么。
| Workaround | 具体做法 | 代价 |
|---|---|---|
| 抄进备忘录 | 从别人的博客复制 brew install xxx,存到备忘录,用的时候再粘一遍 |
不知道有哪些包可装;不知道机器上装过什么;有包可升级了也不会知道,全靠自己想起来敲 brew upgrade |
| 第三方 GUI | 装个开源图形界面(比如 Cakebrew 这类),点点点 | 界面常常替你做了决定:它说「已是最新」你无法核实,出错只给一句「操作失败」,真实命令和原始输出都被吞掉了 |
| 只敢问 AI | 问「怎么装 ripgrep」,拿到命令,自己回终端执行 | 本质还是得手动操作终端;而且信息是二手的,出了问题还是得自己看报错 |
三条路有一个共同缺陷:界面与真相之间隔了一层。你能看到「结果」,看不到「过程」。而包管理恰恰是一个过程比结果更需要被看见的场景——它要下载、编译、链接、改 PATH,任何一步都可能出问题。
它的核心洞察:透明优先
README 里那句话是整份代码的宪法:「never hides what Homebrew is doing」——永远不隐藏 Homebrew 在做什么。
这句话不是口号,它直接决定了三个看得见的实现选择:
- 命令可复制。界面每次要跑 Homebrew,都渲染一条人能直接粘进终端运行的命令(比如
brew info ripgrep)。只用于 App 内部解析的 flag(比如--json=v2)不会出现在给你看的文案里。 - 输出是实时的。App 底部有一条常驻的 Console,流式显示 subprocess 的 stdout / stderr,连 ANSI 颜色码都解析出来还原——你看到的和终端里看到的差不多。
- 错误不吞。项目规范里写明「Do not hide errors from the user — surface them appropriately」。技术细节会被保留进日志,而不是翻译成一句「出错了」。
原则清楚了。接下来看它内部是怎么把「点一下按钮」变成「跑对一条命令」的。
03核心流程
BrewUI 的分层很简单,一句话能背下来:View → ViewModel → Repository / Interactor → Services → brew CLI 或 JSON API。数据从上往下走,依赖只向内指向抽象。
三步理解这个流程
| 阶段 | 做什么 | 为什么这样切 |
|---|---|---|
| ① 读 | View 只做两件事:绑定 ViewModel 的状态、把用户动作转发出去 | 规范里叫「passive view」——视图里禁止写跨状态的决策树 |
| ② 决定 | ViewModel 把领域模型映射成「可以直接画」的呈现状态 | 用「加载中 / 已加载 / 出错」一个 enum 表达,而不是三个布尔字段拼组合:状态互斥,非法组合直接消失 |
| ③ 执行 | Repository / Interactor 定义「要什么」,Services 负责「怎么拿到」 | 起 brew 子进程还是打 HTTP 请求,是可替换的实现细节,不该泄漏到上层 |
每一层具体管什么
| 层 | 职责 | 典型类型 |
|---|---|---|
| Views | 声明式渲染 + 动作转发,保持「薄」 | InstalledView、CommandBlockView |
| *Root 视图 | 依赖组装边界:读环境、构造并注入内容视图的依赖、管 ViewModel 生命周期 | …Root 系列 |
| ViewModels | 呈现状态与映射;领域规则不许写在这里 | InstalledViewModel、ConsoleViewModel |
| Repositories | 面向某个数据源的读写适配(CLI 输出 / API / 存储) | BrewInstalledPackagesRepository |
| Interactors | 一个类型 = 一个用例(不是通用 CRUD),例如「跑一次体检」 | …Interactor / Mock…Interactor |
| Services | 按集成边界分组的基础设施 | BrewCommandService、BrewAPIClient |
| Models | 只放领域值类型与关系,UI / presentation helper 一律不许进 | InstalledBrewPackage、DoctorReport |
BrewCommandCenter 是这套流程里最值得记住的一个设计。它是一个 actor 协议,默认实现 SerialBrewCommandCenter,干三件事:把会改状态的 brew 命令串行化(避免两个 install 打架);用 BrewOperationID + BrewOperationPhase 追踪「正在跑 / 失败了」的操作状态给各个界面用;执行一个个小的 BrewMutatingCommand。它不负责解析 brew list / brew info 的输出——那是 Repository 的事。
流程清楚了,来看怎么把它装到自己机器上。
04安装
这里有个新手最容易踩的坑:存在两条完全不同的路,走错一条会浪费半小时。
你只想有个图形界面管包。一行命令,或者直接下官方安装包。
brew install --cask homebrew-app
也可以去 GitHub Releases 下 Homebrew-0.4.2.pkg 或 .zip 手动装。不需要 Xcode,不需要读源码。
你想看懂它怎么写的,或者想提交 PR。克隆仓库然后跑 bootstrap。
git clone https://github.com/Homebrew/BrewUI.git
cd BrewUI
./scripts/bootstrap
需要完整 Xcode(不只是命令行工具),装完打开 Homebrew.xcodeproj。
路线 A 细节:cask 装出来的是什么
| 项目 | 值 |
|---|---|
| cask token | homebrew-app |
| cask 显示名 | Homebrew |
| cask 描述 | Homebrew's official GUI |
| 安装位置 | /Applications/Homebrew.app |
| bundle identifier | sh.brew.app |
| 系统要求 | macos >= 26(写死在 cask 里) |
| 卸载清理 | brew uninstall --zap --cask homebrew-app 会一并清掉 ~/Library/Application Support/sh.brew.app、~/Library/Caches/sh.brew.app、~/Library/Preferences/sh.brew.app.plist |
/opt/homebrew/bin/brew 和 /usr/local/bin/brew,两个都不存在时才去装它捆绑的官方 Homebrew.pkg。已经在用 Homebrew 的人装 App,不会动你现有的 Homebrew——脚本第一句就是「已存在则跳过」。
顺带一个工程质量细节:打包时脚本会校验下载到的
Homebrew.pkg 的签名,并且钉死签名 Team ID(当前是 Homebrew 维护者 Patrick Linnane 的 927JGANW46)。这样即使有人把资产换成另一个「签名合法但不是这一方」的安装器,构建也会直接失败。Team ID 历史上有过轮换(上一任是 6248TWFRH6),所以这个值被写成了带维护注释的常量。
路线 B 细节:bootstrap 会做什么
./scripts/bootstrap 是幂等的,随时可以重跑。它按顺序做五件事:
- 检查 Xcode Command Line Tools(
xcode-select -p),缺了会提示你先跑xcode-select --install - 检查 Homebrew,没有就调用官方 install 脚本
brew bundle --file Brewfile—— 装 Mint 等工具链mint bootstrap—— 按Mintfile里钉死的版本编译出 SwiftFormat 与 SwiftLint- 安装 git hooks,并
xcodebuild -resolvePackageDependencies解析 SPM 依赖;最后从示例文件生成Configurations/Signing.local.xcconfig
bootstrap 生成的 Signing.local.xcconfig 里有一个占位符 YOUR_TEAM_ID_HERE,必须换成你自己的 10 位 Apple Team ID(Xcode → Settings → Accounts 里能查到),签名的其他部分就会自动解析。这个文件是 gitignored 的,不要提交。
装好了。下一步:怎么确认环境真的对?
05环境自检
在开着 App 折腾之前,先花 30 秒在终端把下面这一串跑一遍。任何一项不符合预期,App 里的怪现象都能对应到这一串里的某一行输出。
# 1. 系统版本 —— 必须 26.x 及以上(Tahoe)
sw_vers -productVersion
# 2. Homebrew 装在哪 —— 只会是 /opt/homebrew(Apple Silicon)
# 或 /usr/local(Intel)其中之一
brew --prefix
# 3. Homebrew 自己的完整环境报告 —— 出错时第一个要看的东西
brew config
# 4. (仅路线 B)Xcode 与 Swift 工具链
xcodebuild -version
swift --version
# 5. 确认 App 是否已装上
ls -d /Applications/Homebrew.app 2>/dev/null && echo "已安装"
依赖清单
| 依赖 | 为什么需要 | 缺失时的表现 |
|---|---|---|
| macOS Tahoe 26+ | cask 声明 macos >= 26;Package.swift 里 platforms: [.macOS("26.0")] | cask 直接拒绝安装 |
| Homebrew | 所有真实动作都要它 | App 显示「brew 未找到」状态(AXID.brewNotFoundState),不会崩 |
| Xcode(完整版) | 路线 B 编译 App 与跑测试 | 只有命令行工具时 xcodebuild 缺失,无法构建 |
| Swift 6.2 工具链 | Package.swift 首行是 swift-tools-version: 6.2 | SwiftPM 无法解析这个包 |
| Mint + SwiftFormat + SwiftLint | 提交前自动格式化与 lint | bootstrap 会装;缺了 pre-commit hook 会报错 |
| 网络 | Discover 要访问 formulae.brew.sh;装包要访问 ghcr.io | 列表加载失败,界面出现统一错误态 + 重试按钮 |
关键二选一:你的 brew 在哪个 prefix
| 你的机器 | brew 路径 | 说明 |
|---|---|---|
| Apple Silicon(M 系列) | /opt/homebrew/bin/brew | App 的探测顺序里排在第一 |
| Intel Mac | /usr/local/bin/brew | 排在第二 |
这个顺序是写死的常量(BrewExecutableLocator 负责探测),没有配置项。因为 Homebrew 自己也不支持「自定义 prefix 的跨机器通用配置」,BrewUI 的选择是:先只支持默认情况,把选择权留到以后。
brew config 的环境报告。它和你在终端里跑 brew config 得到的结果可能不一样——这不是 bug。App 启动 brew 的方式和你终端不一样(见第 9 节),差异本身往往就是问题所在。
环境没问题了,可以开始真的用了。
06第一次实战
先认路,再动手。BrewUI 的主界面是标准的三栏结构:左边侧栏、中间主区、底部命令控制台。
| 位置 | 它管什么 |
|---|---|
| Installed(已安装) | 你机器上所有 formula 与 cask;支持搜索;每行能进详情页做升级 / 卸载 |
| Upgrades(可升级) | 所有 outdated 的包,可单个升级或批量升级;有刷新按钮 |
| Discover(发现) | 从 Homebrew JSON API 拉的目录,用来解决「我该装什么」;带搜索与趋势数据 |
| Doctor(体检) | 跑 brew doctor,把输出解析成带严重级别的条目,并提供一键修复动作 |
| Configuration(配置) | 展示 brew config 报告与 Homebrew 环境说明 |
| Console(控制台) | 贯穿全 App 的命令输出面板;平时折叠成一条状态条,可展开成全屏日志;支持选中复制 |
| 自升级横幅 | 发现 BrewUI 自己(cask homebrew-app)有新版本时出现,两个按钮:升级 / 稍后 |
照抄可跑的五步
| 步骤 | 怎么做 | 怎么算成功 |
|---|---|---|
| ① 确认 Homebrew | 打开 App,进「配置」标签 | 能看到 HOMEBREW_VERSION / HOMEBREW_PREFIX 这两行 |
| ② 搜索 | 进「发现」,搜 ripgrep | 列表出现结果 = 目录拉取成功、JSON 解码成功 |
| ③ 看详情 | 点进详情页 | 版本、描述、依赖、license 都渲染正常 |
| ④ 安装 | 点 Install,然后低头看底部 Console | 出现 brew install --formula ripgrep,并跟着真实的下载与安装输出 |
| ⑤ 验收 | 回「已安装」搜索 ripgrep | 能看到它并显示已装版本;终端里 which rg 也能验证 |
第 ④ 步不是「点按钮等结果」,而是「点按钮 → 核对命令 → 看输出」。这就是 BrewUI 和普通 GUI 的区别所在。养成这个习惯,任何异常你都能立刻知道是哪一步出的问题。
用起来了。现在逆向拆开,看它代码是怎么组织的。
07项目结构
这个仓库有一套很值得学的组织方式:App target 很薄,能力全在 SwiftPM 包里。打开 Xcode 你会看到一个 app target Homebrew,加上一个名为 BrewKit 的本地 SwiftPM 包;包里是 20 个模块。
顶层目录
| 路径 | 用途 | 规模 |
|---|---|---|
Homebrew/ | App target:BrewApp.swift 入口、Assets、Debug 菜单、自升级交接、UI 测试夹具安装器 | 15 个 Swift 文件 · ~1,300 行 |
Sources/ | SwiftPM 包的全部模块(真正的功能都在这) | 208 个文件 · ~19,100 行 |
Tests/ | 单元 / 集成测试(Swift Testing 为主) | 130 个文件 · ~20,400 行 |
BrewUITests/ | UI 测试:页面对象、元素封装、夹具、确定性套件与真实端到端 canary | 40 个文件 · ~3,000 行 |
Tools/BrewUILint/ | 自研 Swift 静态检查器(基于 swift-syntax),CI 与 pre-commit 都会跑 | 独立 SwiftPM 包 |
HomebrewUpgradeHelper/ | 自升级用的独立小可执行文件(bundle id sh.brew.app.upgradehelper) | 2 个文件 |
scripts/ | bootstrap / test / test-ui / test-e2e / pre-commit / 版本号推导等 | 11 个脚本 |
docs/ | 设计系统文档(BrewUI-DesignSystem.md)与文档 lint 配置 | Jekyll + Vale |
.github/workflows/ | CI:构建、发布、Swift 质量、文档、e2e、issue / PR 检查等 | 11 个 workflow |
.ai/ | memory.md —— 长期决策日志(把「为什么这么做」逐条记下来的地方) | 约 110 KB |
20 个模块都在干什么
| 模块 | 角色 |
|---|---|
BrewCore | 领域层。HomebrewPackageID、InstalledBrewPackage、DoctorReport、ANSIParser、SelfUpgradeIdentity 等。零依赖 |
BrewCLI | brew 子进程执行:BrewCommandService、BrewExecutableLocator、ZshBrewCommandRunner、SerialBrewCommandCenter、伪终端 PseudoTerminal |
BrewNetworking | BrewAPIClient + 目录缓存,对接 formulae.brew.sh |
BrewRepositoryInterfaces | 仓库协议的抽象层,配套 Fakes 与预览用的假实现 |
BrewRepositories | 协议的真实实现:已安装清单、目录、配置、Doctor、自升级状态等 |
BrewAppEnvironment | 依赖注入组装点(Repository / Navigation / Banner 环境) |
BrewUIComponents | 设计系统:BrewColors / BrewSpacing / BrewFonts / BrewSymbols + 通用视图(CommandBlockView、AsyncContentView、AnimatedSplit…) |
BrewFeatureInstalled | 已安装 / 可升级 / 详情 —— 最大的功能模块(约 3,500 行) |
BrewFeatureDiscover | 发现页:目录列表、详情、趋势数据 |
BrewFeatureConsole | 命令控制台:命令任务仓库 + 控制台视图模型 |
BrewFeatureDoctor | 体检:解析 brew doctor 输出为分级条目 + 修复动作 |
BrewFeatureConfig | 配置标签页 |
BrewFeatureSelfUpgrade | App 自升级横幅与协调器 |
BrewAccessibilityID | 只有 102 行,却是个漂亮设计:一个 enum AXID,App 和 UI 测试共同引用,标识符不可能漂移 |
BrewUITestContract | UI 测试启动契约:环境变量键名 + 夹具 payload,两边只写一次 |
BrewCrashReporting | 崩溃捕获与报告(本地写盘 + 一键预填 GitHub issue) |
BrewSelfUpgradeContract / BrewSelfUpgradeHelperCore | 自升级交接协议与 helper 的可复用核心逻辑 |
BrewCoreTestSupport / BrewServicesTestSupport | 测试支撑:夹具、Stub、可控的命令中心替身 |
统一是 Views/ + ViewModels/ 两个子目录。功能专属的 UI 呈现类型就放在这里,不许塞进 BrewCore/Models/。这条边界在 CONVENTIONS.md 里被反复强调,理由是:Models 一旦被 UI 需求污染,就再也拆不出来了。
一条值得抄走的单向依赖链
BrewCore ← 零依赖,谁都可以依赖它
↑
BrewRepositoryInterfaces ← 只依赖 BrewCore
↑
BrewRepositories ← 依赖 Interfaces + BrewCLI + BrewNetworking
↑
BrewAppEnvironment ← 组装点
↑
BrewFeature* ← 功能模块,依赖 UIComponents + Interfaces + 环境
↑
Homebrew(app target) ← 只做接线与生命周期
好处很直接:想给某个功能写测试,只需要构造它声明的协议,不用拉起整个 App。测试里永远不会真的调 brew——规范写死了这条:只在 BrewCommandRunning(子进程边界)和 BrewExecutableLocating(brew 定位边界)两个地方打桩。
结构清楚了,回到使用者视角:这些代码最终给你的是哪些能力?
08怎么用
这一节回答一个具体问题:我在界面上点的每一下,背后到底是什么?把这张映射表记住,你就能在 GUI 和命令行之间自由切换,而不是被 GUI 困住。
| 界面动作 | 实际执行的命令 / 请求 |
|---|---|
| 浏览「发现」、看目录 | HTTP 请求 formulae.brew.sh JSON API(不跑 brew) |
| 装载「已安装」列表 | 内部:brew info --installed --json=v2;界面上显示给你的等价命令是 brew info <名字> |
| 安装一个 formula | brew install --formula 名字 |
| 安装一个 cask | brew install --cask token |
| 升级单个包 | brew upgrade --formula 名字 或 --cask token |
| 批量升级 | brew upgrade 加上所选项的参数(全部 outdated / 只 formula / 只 cask / 显式列表) |
| 卸载 | brew uninstall --formula 名字 或 --cask token |
| 体检 Doctor | brew doctor(只读,捕获输出后解析) |
| Doctor 的一键修复 | 修复动作本身就是一个 argv,例如 brew link openssl@3 或 brew cleanup |
| 刷新 tap(特定模式) | brew update --auto-update --quiet(和 brew upgrade 内部用的是同一条) |
| App 自我升级 | brew upgrade --cask homebrew-app |
命令组装是纯数据,这一点很值得看
「到底跑什么」在代码里集中在 Sources/BrewCore/Operations/BrewCommands.swift——一个没有任何副作用的枚举,一行一个 argv 数组。所以「跑什么」和「界面上显示成哪个阶段」是写在同一个地方的数据,而不是散落在各个命令类型里:
public enum BrewCommands {
public static func install(_ name: String, kind: HomebrewPackageKind) -> BrewCommand {
BrewCommand(
operationKind: kind == .formula ? .installFormula : .installCask,
arguments: ["install", flag(for: kind), name],
)
}
public static func uninstall(_ name: String, kind: HomebrewPackageKind) -> BrewCommand {
BrewCommand(
operationKind: kind == .formula ? .uninstallFormula : .uninstallCask,
arguments: ["uninstall", flag(for: kind), name],
)
}
/// `brew doctor` —— 只读;用捕获模式跑,好让仓库拿到输出再剥离 + 解析。
public static func doctorRead() -> BrewCommand {
BrewCommand(operationKind: .doctorRead, arguments: ["doctor"])
}
private static func flag(for kind: HomebrewPackageKind) -> String {
kind == .formula ? "--formula" : "--cask"
}
}
那一个包名会被怎么用?答案是它必须先是 HomebrewPackageID——一个把「formula 还是 cask」编码进类型的枚举。类型即身份,裸 String 不允许出现在包标识的位置上:
public enum HomebrewPackageID: Hashable, Identifiable, Sendable {
case formula(name: String)
case cask(token: String)
public var id: Self { self } // 类型自己就是自己的 ID
public var kind: HomebrewPackageKind { /* … */ }
public var name: String { /* … */ } // formula 和 cask 统一成一个 name
}
为什么这么做?因为 git(formula)和 git(同名 cask)是两件东西,用字符串当 ID 迟早会出现「同名不同类」的混淆。把类别编进类型,编译器帮你挡。
✅ 会发生 vs ❌ 不会发生
- 每个动作都给出可直接粘贴进终端的命令
- 实时看到 brew 的 stdout / stderr,ANSI 颜色被解析还原
- 加载中的界面有明确状态;失败时给原始报错和重试按钮
- 批量操作有进度与「正在跑什么」的显式提示
- 不会把
--json=v2这类内部解析 flag 展示给你看 - 不会静默吞掉错误,也不会把技术细节翻译成「出错了」
- 不会在你不知道的情况下跑 brew
- 不会把命令文本当稳定 API —— 解析是宽容的,CLI 文本会变
基本操作到这。下面是一些「用了才知道」的进阶前提。
09进阶:配置与自升级
这一节有一个最容易被误解的点,值得单独拿出来讲:你在终端里写的 shell 配置,对 BrewUI 里的 Homebrew 不起作用。先搞清楚为什么,再给正确做法。
9.1 为什么我的 shell 配置不管用
BrewUI 启动 Homebrew 的方式是刻意的:它总是用 /bin/zsh --no-rcs --no-global-rcs 起 brew,并且带一个干净环境。--no-rcs 的意思是不加载你的 ~/.zshrc 等启动文件。PATH 里只有 brew 所在目录加 /usr/bin:/bin。
结果就是:你的 login shell、shell alias、export 出来的变量、自定义 PATH,都不会影响 App 里的 Homebrew。连 shell 导出的 XDG_CONFIG_HOME 也被忽略。
把 Homebrew 配置变量写进 brew.env 文件——Homebrew 自己会读这些文件,与 shell 无关。
| 作用域 | 文件路径 |
|---|---|
| 用户级 | ~/.homebrew/brew.env |
| 安装级 | <Homebrew prefix>/etc/homebrew/brew.env |
| 系统级 | /etc/homebrew/brew.env |
写法很朴素——一行一个 NAME=value,不带 export,不做 shell 展开,也不用命令替换:
# 写入用户级配置(把 Homebrew 的环境提示关掉)
mkdir -p ~/.homebrew
echo 'HOMEBREW_NO_ENV_HINTS=1' >> ~/.homebrew/brew.env
优先级是:用户级 > 安装级 > 系统级。如果想让系统级文件优先,在系统级文件里写 HOMEBREW_SYSTEM_ENV_TAKES_PRIORITY=1。改完配置必须重启 BrewUI——环境探测结果在进程内是有缓存的。
① 系统 zsh 一定会读
/etc/zshenv,这个行为无法用参数关掉。如果那里面打印了 banner,会污染 Homebrew 的 JSON 输出。所以 App 在启动后再用一次干净环境把变量清掉,并且丢弃启动阶段的输出,避免 banner 跑到报告或控制台里。
② App 自己读不到
HOMEBREW_* 环境变量——它是被 Finder 启动的,不是从 shell 启动的。所以它不是去读环境,而是反过来问 brew:跑 brew config,看输出里有没有 HOMEBREW_NO_INSTALL_FROM_API 这一行(brew 只在它被设置时才打印这一行)。这个探测每进程只做一次。
9.2 它是怎么升级自己的
App 不能一边运行一边替换自己的 bundle。所以自升级走的是退出 → 替换 → 重新启动的交接流程:
几个设计细节值得注意:
- 检测复用数据,不发明第二条路。App 自己就是一个 cask,所以它本来就出现在
brew info --installed --json=v2的结果里,带着outdated标记。状态直接取这个标记,代码里根本没有语义化版本比较器。 - 用词统一成 upgrade。项目里专门有一次全量改名,把
SelfUpdate*改成SelfUpgrade*。原因是brew update刷新的是 tap,brew upgrade才是装新版本——两个词不能混用。 - 忽略提示是按版本记忆的。你点「稍后」,记的是「这一版我不升」;下一版出来横幅会重新出现。如果 brew 报 outdated 但拿不到版本号,忽略只对本会话有效(因为没有版本号可以记)。
- 没有自动升级,也没有偏好设置界面。作者试过,然后删掉了——理由是没有必要替用户做决定。
9.3 文档即流程:四个文件的分工
| 文件 | 管什么 | 什么时候动它 |
|---|---|---|
ARCHITECTURE.md | 分层、数据流、集成方式、产品约束 | 结构或大前提变了 |
CONVENTIONS.md | 命名、代码风格、模式(怎么写) | 某个写法在代码里稳定下来之后 |
AGENTS.md | 给 AI Agent 的规则、工作流、质量门禁 | 流程变化时 |
.ai/memory.md | 长期决策日志:为什么这么做(只追加 / 更新,永不覆盖) | 学到任何持久性结论时 |
这套分工有个明确的好处:「怎么做」和「为什么这么做」分开存放。新人改代码看 CONVENTIONS;想推翻某个设计之前,先去 memory.md 查它是不是已经争论过了。搜索一下这个文件里「supersedes」(取代)出现的次数,你会看到好几个功能被反复重构、每次都留了记录——这是很健康的工程习惯。
看到这里你已经会用了。最后一层:这个项目还能用来学什么?
10不止于此
BrewUI 的「用法」很浅——装包、卸包。但它的代码是一份很完整的现代 macOS 工程样本。如果你正在学下面任何一项,这个仓库都值得当作对照材料读。
Swift 6 严格并发
每个 target 都显式声明 .swiftLanguageMode(.v6),且按模块决定默认隔离:UI 模块 .defaultIsolation(MainActor.self),纯逻辑模块 .defaultIsolation(nil)。共享可变状态用 actor 保护。
MVVM + Clean Architecture 的边界
什么能进 Models/、什么必须留在功能层、*Root 视图为什么是依赖组装边界——这些平时很虚的原则,这里有可执行的判断标准。
三层 UI 测试体系
确定性的 fixture 套件(46 个用例)· 页面对象模型(9 个 Screen + 6 个 Element 封装)· 真实环境的端到端 canary(5 个)。三层职责不同,失败信号不互相污染。
设计系统 token 化
颜色、间距、字号、圆角全是语义 token,视图里禁止写死数值。docs/BrewUI-DesignSystem.md 是一份可以直接当范本读的设计规范。
无障碍是被自动审计的
有一个测试会直接解析 Media.xcassets、计算 WCAG 2.1 相对亮度与对比度,断言高对比模式下全部达到 AA。对比度成了跑测试就会挂掉的约束,而不是靠人肉检查。
把 AI Agent 当同事
AGENTS.md 开头就写「改版本号这类会变的数值,先去搜网,不要相信训练数据」,还有指令优先级、越界清单、必须跑的质量门禁。可以直接照抄这套骨架。
建议的学习顺序
| 遍次 | 读什么 | 目的 |
|---|---|---|
| 第一遍 | README → ARCHITECTURE.md → CONVENTIONS.md → docs/BrewUI-DesignSystem.md | 先把「他们想要什么」搞清楚,别急着看代码 |
| 第二遍 | HomebrewPackageID.swift、BrewCommands.swift、ZshBrewCommandRunner.swift、SerialBrewCommandCenter.swift | 这四份合起来就是整个 App 的骨架 |
| 第三遍 | 挑一个功能模块(建议 BrewFeatureInstalled 或 BrewFeatureConsole),顺着 View → ViewModel → Repository 读一遍 | 看边界是不是真的守住了;最后跳去读 .ai/memory.md 里对应的日期条目,对照「当时为什么这么做」 |
那么它的规模到底有多大?值不值得花时间读?
11规模与成本
先给结论,再给数字。数字是 2026-09-16 对 v0.4.2 源码做的本地快照统计(浅克隆后统计 .swift 文件的行数,含空行与注释)。
| 指标 | 数量 | 说明 |
|---|---|---|
生产代码(Sources/ + Homebrew/) | 223 个文件 · 约 20,400 行 | App target 只占约 1,300 行,其余全在包里 |
单元 / 集成测试(Tests/) | 130 个文件 · 约 20,400 行 | 以 Swift Testing 为主 |
UI 测试(BrewUITests/) | 40 个文件 · 约 3,000 行 | 含页面对象与夹具 |
| 测试用例总数 | 1,148 | @Test 宏 1,097 个 + XCTest 风格 51 个 |
| UI 测试用例 | 46(13 个文件) | 搜索焦点、列表焦点、错误态、自升级、安装 / 卸载各成一组 |
| 真实环境端到端 canary | 5 | 要连真 Homebrew 和真网络,会装上再卸掉 formula hello |
| SwiftPM 模块 | 20 | 外加 Tools/BrewUILint 独立包 |
| CI workflow | 11 | 构建 / 发布 / Swift 质量 / e2e / 文档规范 / issue 与 PR 检查 |
测试代码比生产代码还多。加上 40 个文件的 UI 测试,这个比例还要再往上走。README 里那句「Stable and under active development」不是客套——它是靠这个比例撑起来的。如果你只想学「怎么把 SwiftUI App 写得可测」,那测试部分才是这个仓库最值钱的地方。
时间成本:哪些操作是慢的
| 操作 | 耗时特征 | 原因 / 备注 |
|---|---|---|
./scripts/bootstrap | 慢(首次数分钟) | 要编译 SwiftFormat / SwiftLint(Mint 按 Mintfile 钉版本),还要解析 SPM 依赖 |
BrewUILint 首次构建 | 约 2 分钟以上 | 要编译 swift-syntax。官方明确建议保留 Tools/BrewUILint/.build,删掉就再来一次 |
scripts/test | 中等 | 跑两个包(BrewKit + BrewUILint)的全部 swift test |
scripts/test-ui | 慢且要占屏 | 会启动真实 App 窗口;跑的时候屏幕不能锁、不要同时操作鼠标键盘 |
scripts/test-e2e | 最慢,且会改机器状态 | 连真网络、真 brew,会装 / 卸 hello。官方规矩:不要「顺手跑一下」 |
upgrade(没法保证机器上有个刚好过时的包)、doctor(输出完全取决于机器状态,没有稳定 happy path)、错误路径(那些属于第二层,让失败长得像夹具问题而不是线上故障)。
还有个细节很讲究:套件会重试失败的用例两次,但重试成功了也会报告——因为它认为「重试把红变绿」正是真实故障被误当成网络抖动的路径。
最后是一些会让人卡住的具体问题。
12FAQ / 踩坑
每条都给可执行的解法。前四条是新手最可能碰到的。
Q1 · 打开提示「已损坏,无法打开,请移到废纸篓」怎么办?
先把情况分清:
- 用 cask 或官方
.pkg装的 → 走的是 Developer ID 签名 + 公证的流程,正常不该出现这个提示。先确认你下的是 GitHub Releases 里的官方资产,别下第三方转载。 - 自己从 Xcode 跑 UI 测试时出现的 → 这是已知的坑,有明确根因:
BrewUITests-Runner.app是 XcodeXCTRunner.app模板的副本,本身已由 Apple 签名。构建把.xctest塞进去之后,如果禁用了签名(CODE_SIGNING_ALLOWED=NO),就没重新签,那个旧签名与内容不匹配——macOS 判定为「已损坏」。随后通常还会跟着The test runner hung before establishing connection(大约 5 分钟后),因为 xcodebuild 在等一个系统拒绝启动的 runner。
解法:放开签名而不是禁用它。用 CODE_SIGN_IDENTITY=- 做 ad-hoc 签名——不需要 identity、team 或 profile,CI 裸机上也能用。注意 scripts/test-ui 目前仍传着 CODE_SIGNING_ALLOWED=NO,项目自己也把它标为同类潜藏缺陷。
Q2 · 我 shell 里明明 export 了 HOMEBREW_*,App 为什么不认?
因为它故意不继承你的 shell 环境。BrewUI 总是用 /bin/zsh --no-rcs --no-global-rcs 加干净环境启动 brew,PATH 只给 brew 目录 + /usr/bin:/bin。目的是:让 App 里跑的命令是可复现的,不受每个用户不同 shell 配置的影响。
解法:写 brew.env(见第 9.1 节,三个作用域那张表)。写完后重启 App,因为环境探测有进程内缓存。
另外还有一个独立的坑:~/.homebrew/brew.env 这类文件里不要用 export、不要用 shell 变量展开、不要用命令替换。只要朴素的 NAME=value 一行一条。
Q3 · App 说「全部都是最新」,但我 brew update 过啊?
关键事实:brew info 不是 auto-update 命令。Homebrew 的 auto-update.sh 里列出的会触发自动更新的命令是 install、outdated、upgrade、bundle、release(以及带参数的 tap)——而 App 判断「有没有过时」用的 brew info --installed --json=v2 不在名单里。
在默认的 API 模式下这无所谓:brew 会按自己的 TTL 重新拉 api/formula.jws.json。但如果你设了 HOMEBREW_NO_INSTALL_FROM_API,公式和 cask 数据就来自本地 tap 的 git 克隆——不会自动刷新,App 会一直报告你上次手动 brew update 时的快照。
App 已内置解法:BrewInstalledPackagesRepository.updateTapsIfNeeded 会在这条模式下、拉清单之前先跑 brew update --auto-update --quiet(和 brew upgrade 内部用的是同一条命令),并且用 Homebrew 自己的 300 秒间隔做节流。而且 tap 更新失败不会导致整个检查失败——失败也会被记时间戳,避免卡住后续所有请求。
还有一个细节特别值得学:当刷新失败时,仓库故意让缓存里的包保持「已加载」状态。因为如果只看状态字段,界面就会把「陈旧的数据」当成事实,宣称「全部最新」——而它其实根本没查成。所以有一个单独的 refreshFailure 字段承载「上次失败」,并且只有一次真正完成的拉取才能清掉它。如果在应用缓存时清掉,一次「只用缓存重绘、什么都没拉」的操作就会把「没查成」悄悄变回「没得升」。
Q4 · 为什么到处都写 upgrade,不写 update?
因为它们是两件事:brew update 刷新的是 tap 索引,brew upgrade 才是安装新版本。App 的自升级跑的是 brew upgrade --cask homebrew-app,所以「update」在它出现的每个名字里都是错的。
项目为此做过一次全量改名:target BrewFeatureSelfUpgrade、BrewSelfUpgradeContract、BrewSelfUpgradeHelperCore,Xcode target HomebrewUpgradeHelper(bundle id sh.brew.app.upgradehelper),以及 performUpgrade()、beginUpgrade()、isUpgradeAvailable 这些成员。
刻意保留「update」的地方:updateNSView、observeRowUpdates、缓存类的 update… 方法、以及 brew update --auto-update。这些确实是「刷新」,不是「装新版本」。
顺带一个迁移后果:改名动了持久化 key(现在是 selfUpgrade.dismissedVersion / selfUpgrade.lastUpgradeOutcome),日志在 ~/Library/Logs/sh.brew.app/self-upgrade.log。旧版本记下的「忽略」会忘一次,横幅会重新出现——项目明确写了「不为一个正式版之前的偏好做迁移」。
Q5 · 为什么要关掉 App Sandbox?
因为沙盒会挡住它需要做的两件事:执行 /opt/homebrew/bin/brew、以及往仓库的日志路径写会话日志。这是被记录下来的「漂移」事件——Xcode 里一度是 ENABLE_APP_SANDBOX = YES,而 ARCHITECTURE.md 规定的是 unsandboxed,两者冲突后对齐到文档:Debug 与 Release 都设为 NO。
代价也写得清楚:如果想上 Mac App Store,就得重新考虑沙盒与 entitlements 的问题。
Q6 · 从命令行跑 UI 测试,为什么卡五分钟才失败?
两个独立原因,都能复现:
- 给 scheme 传了项目相对的
-derivedDataPath。复现过两次、不同-only-testing:范围,都在大约 330 秒处超时;换成默认的~/Library/Developer/Xcode/DerivedData/…就几秒通过。根因没有深挖,官方结论是「别用那个路径」——scripts/test-ui和 CI 都不传它。 - 屏幕锁了。macOS 不会给锁屏状态下的任何东西真正的窗口焦点,所以「锁屏」和「harness 有 bug」的表现完全一样。如果失败突然集中在每一个用例的前台 / 窗口报错上,先看这条。
还有一个环境级的操作误区:测试正在轮询元素可点击性时,你去跑任何诊断命令(哪怕只是一句 osascript 查询)都可能把终端顶到最前,遮住被测 App,制造出与代码完全无关的假失败。要复核结果,就让测试不被打扰地跑完。
Q7 · 没装 Homebrew 就打开 App,会是什么样?
不会崩,会降级。产品约束里写得很明确:「Homebrew is separate — detect and degrade if missing; do not bundle Homebrew」。代码里对应一个专门的界面状态 AXID.brewNotFoundState(标识符字符串是 brew.not.found)。
定位 brew 的探测顺序是固定的:/opt/homebrew/bin/brew → /usr/local/bin/brew,两个都没有就是未找到。
解法:装官方 Homebrew 就行。或者直接装 .pkg 版本的 BrewUI——它的 postinstall 脚本在检测不到 brew 时会用捆绑的官方安装器装一个。
Q8 · 我改了 brew.env,App 里没变化?
两个原因,依次排查:
- 没重启 App。Homebrew 环境(尤其是「是否处于
HOMEBREW_NO_INSTALL_FROM_API模式」这个探测)在进程内是缓存一次的——理由是:要改它就得编辑 shell 配置文件,而那对已经在运行的 App 本来也不会生效。所以记住这条因果:改配置 → 重启 App → 再看配置标签页。 - 语法不对。必须是字面量
NAME=value,不写export、不做展开、不用命令替换。
还有一条心理准备:App 里 Configuration 标签页显示的环境报告,和你在终端跑 brew config 的结果可能不一样,因为两者的启动方式不同(第 9.1 节)。差异是信息,不是 bug。
最后两节:授权边界和去哪找一手资料。
13合规与授权
这一节短但重要,尤其是打算把它用在工作项目里的人。
BrewUI 采用 GNU Affero General Public License v3.0。README 里专门提了一句容易被忽略的话:「If you reuse or adapt the source the AGPL terms apply, including the network-use clause.」
对你意味着什么
| 你的场景 | 要不要开源你的代码 |
|---|---|
| 只是自己用这个 App 管包 | 不需要。使用软件不触发开源义务 |
| 把它的一部分代码抄进你的闭源 App 分发出去 | 需要。AGPL 的传染性强于 GPL,分发即触发 |
| 改了它,然后通过网络提供服务(SaaS、内部平台) | 需要。这就是 network-use clause(第 13 条)的作用:即使不「分发」二进制,只要通过网络给用户用,也要把修改后的源码提供给这些用户 |
| 在内部读它的代码学架构,自己重写一遍 | 学思路没问题,但要注意别把它的表达方式逐句搬过去 |
几条明确的边界
LICENSE文件被列为 Agent / 贡献者的禁改区(「Do not modify LICENSE」)。- 不要提交密钥。
Configurations/Signing.local.xcconfig(含你的 Team ID)是 gitignored 的,别提交;Configurations/Signing.local.xcconfig.example才是可以提交的模板。 - 仓库里有一份
.github/denied-licenses.txt,还有一个licenses.ymlworkflow——依赖许可是有 CI 门禁的。
隐私:它把什么数据送到了哪里
| 数据 | 去向 |
|---|---|
| 目录 / 趋势数据请求 | 发给 formulae.brew.sh(Homebrew 自己的公开 JSON API),不带账号信息 |
| 崩溃报告 | 先只写本地磁盘:~/Library/Application Support/sh.brew.app/CrashReports/crash-<毫秒时间戳>.log。只有你在下次启动的对话框里点「上报」时,才会打开一个预填好的 GitHub issue 新建页面——在你在 GitHub 上提交之前,什么都还没发出去 |
| 没有的东西 | 没有账号体系、没有遥测服务、没有第三方埋点 |
崩溃捕获的实现也值得一提:它装的是 uncaught exception handler 加 POSIX 信号处理(SIGABRT / SIGBUS / SIGFPE / SIGILL / SIGSEGV / SIGTRAP),信号路径是 async-signal-safe 的——所需的一切(文件路径、表头)都在安装时就准备好,崩溃时只做 open / write / backtrace_symbols_fd / close,绝不构造 Swift 字符串,然后重新抛出信号,让系统自己的崩溃报告也能生成。单个报告还会在建好时截断到 16 KB(因为对话框里是一个 Text 整体布局,128 KB 会让启动多花六秒)。
最后一节:一手资料去哪找。
14参考链接
教程里的每个数字和结论都来自下面这些位置。想核对或想读得更深,直接去原始文件。
官方资源
| 资源 | 链接 |
|---|---|
| 仓库主页 | github.com/Homebrew/BrewUI |
Releases(下载 .pkg / .zip) | github.com/Homebrew/BrewUI/releases |
| Issue 模板(报 Bug / 提需求) | github.com/Homebrew/BrewUI/issues/new/choose |
| Homebrew JSON API 文档 | formulae.brew.sh/docs/api/ |
| Homebrew 环境变量手册 | docs.brew.sh/Manpage#environment |
| Swift Concurrency | Swift 官方书 · Concurrency |
| SwiftUI | Apple Developer Documentation |
仓库内值得直接读的文件
| 文件 | 读它的理由 |
|---|---|
README.md | 定位、技术栈、安装、Homebrew 配置说明 |
ARCHITECTURE.md | 分层、数据流、产品约束 —— 全项目最短的一份「总纲」 |
CONVENTIONS.md | 命名、边界、测试写法的可执行规则 |
AGENTS.md | 给 AI Agent 的规则、工作流与质量门禁 |
docs/BrewUI-DesignSystem.md | 完整设计系统:品牌色、语义 token、间距、圆角、动效、无障碍目标 |
.ai/memory.md | 最推荐的一份。100 多 KB 的决策日志,逐条记录「为什么这么做」和「什么被取代了」 |
BrewUITests/TROUBLESHOOTING.md | 把 UI 测试从「从没跑通过」推到全绿的全部根因,实战价值很高 |
BrewUITests/E2E/README.md | 端到端 canary 的设计哲学:为什么断言「形状」而不是「数值」 |
Sources/BrewCore/Operations/BrewCommands.swift | 全部 brew 命令的唯一来源,一眼看完 App 能做什么 |
Sources/BrewCore/Models/HomebrewPackageID.swift | 「类型即身份」的教科书式实现 |
Sources/BrewAccessibilityID/AXID.swift | 102 行解决 App 与测试之间标识符漂移问题 |
本文所有代码规模数字,均为 2026-09-16 对 v0.4.2(最新提交 feb9a3a7)做 git clone --depth 1 后在本地统计 .swift 文件行数得到(含空行与注释)。版本号、测试数量、文件数量会随仓库演进变化——引用前请以仓库当前状态为准。
.ai/memory.md——那份文档比这个教程更有价值。