BrewUI 新手教程

BrewUI 新手教程

Homebrew 官方出品的 macOS 图形界面。不碰终端也能发现、安装、升级、卸载 Homebrew 包——而且它从不隐藏 Homebrew 到底在做什么。

macOS 26+ Swift 6 + SwiftUI AGPL-3.0 v0.4.2 GitHub 仓库

01这是什么

一句话:BrewUI 是 Homebrew 官方出的 macOS GUI——用原生 SwiftUI 写的一个 App,把「发现 → 安装 → 升级 → 卸载」这套包管理动作,从终端搬进窗口里,同时坚持一条原则:不隐藏任何东西。

1.4k+
GitHub Stars
v0.4.2
最新 Release
20
SwiftPM 模块
~2.0 万
生产 Swift 行数
1,148
自动化测试
核心数字

仓库 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 SwiftUI · 只绑定与转发 ViewModel @Observable 呈现状态 Repository CRUD 形状的数据访问 或 Interactor(一个用例) Services 基础设施 · 按集成边界分组 brew CLI(子进程) /bin/zsh --no-rcs --no-global-rcs formulae.brew.sh JSON API Discover / 目录数据 · 宽容解码 依赖永远向内指:UI 不认识 brew,brew 也不认识 UI View(SwiftUI) 只绑定状态与转发动作 ViewModel @Observable 呈现状态 Repository / Interactor 数据访问 / 单个用例 Services 基础设施分组 brew CLI 子进程 · 可取消 · 流式输出 JSON API formulae.brew.sh

三步理解这个流程

① 读 · View
→
② 决定 · ViewModel
→
③ 执行 · Services
阶段做什么为什么这样切
① 读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安装

这里有个新手最容易踩的坑:存在两条完全不同的路,走错一条会浪费半小时。

路线 A · 只想用

你只想有个图形界面管包。一行命令,或者直接下官方安装包。

brew install --cask homebrew-app

也可以去 GitHub Releases 下 Homebrew-0.4.2.pkg 或 .zip 手动装。不需要 Xcode,不需要读源码。

路线 B · 想读 / 改代码

你想看懂它怎么写的,或者想提交 PR。克隆仓库然后跑 bootstrap。

git clone https://github.com/Homebrew/BrewUI.git
cd BrewUI
./scripts/bootstrap

需要完整 Xcode(不只是命令行工具),装完打开 Homebrew.xcodeproj。

路线 A 细节:cask 装出来的是什么

项目值
cask tokenhomebrew-app
cask 显示名Homebrew
cask 描述Homebrew's official GUI
安装位置/Applications/Homebrew.app
bundle identifiersh.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
为什么 .pkg 版本会顺手把 Homebrew 也装了?因为安装包里带了一个 postinstall 脚本:先检查 /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 是幂等的,随时可以重跑。它按顺序做五件事:

  1. 检查 Xcode Command Line Tools(xcode-select -p),缺了会提示你先跑 xcode-select --install
  2. 检查 Homebrew,没有就调用官方 install 脚本
  3. brew bundle --file Brewfile —— 装 Mint 等工具链
  4. mint bootstrap —— 按 Mintfile 里钉死的版本编译出 SwiftFormat 与 SwiftLint
  5. 安装 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.2SwiftPM 无法解析这个包
Mint + SwiftFormat + SwiftLint提交前自动格式化与 lintbootstrap 会装;缺了 pre-commit hook 会报错
网络Discover 要访问 formulae.brew.sh;装包要访问 ghcr.io列表加载失败,界面出现统一错误态 + 重试按钮

关键二选一:你的 brew 在哪个 prefix

你的机器brew 路径说明
Apple Silicon(M 系列)/opt/homebrew/bin/brewApp 的探测顺序里排在第一
Intel Mac/usr/local/bin/brew排在第二

这个顺序是写死的常量(BrewExecutableLocator 负责探测),没有配置项。因为 Homebrew 自己也不支持「自定义 prefix 的跨机器通用配置」,BrewUI 的选择是:先只支持默认情况,把选择权留到以后。

顺手记一个排查技巧:App 里的 Configuration 标签页会展示一份基于 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
→
② 搜索
→
③ 看详情
→
④ 安装
→
⑤ 验收
步骤怎么做怎么算成功
① 确认 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 测试:页面对象、元素封装、夹具、确定性套件与真实端到端 canary40 个文件 · ~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 等。零依赖
BrewCLIbrew 子进程执行:BrewCommandService、BrewExecutableLocator、ZshBrewCommandRunner、SerialBrewCommandCenter、伪终端 PseudoTerminal
BrewNetworkingBrewAPIClient + 目录缓存,对接 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配置标签页
BrewFeatureSelfUpgradeApp 自升级横幅与协调器
BrewAccessibilityID只有 102 行,却是个漂亮设计:一个 enum AXID,App 和 UI 测试共同引用,标识符不可能漂移
BrewUITestContractUI 测试启动契约:环境变量键名 + 夹具 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 <名字>
安装一个 formulabrew install --formula 名字
安装一个 caskbrew install --cask token
升级单个包brew upgrade --formula 名字 或 --cask token
批量升级brew upgrade 加上所选项的参数(全部 outdated / 只 formula / 只 cask / 显式列表)
卸载brew uninstall --formula 名字 或 --cask token
体检 Doctorbrew 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。所以自升级走的是退出 → 替换 → 重新启动的交接流程:

① 检测 已是 cask,复用已安装清单的 outdated 标记(不比重版本号) ② 横幅 升级 / 稍后 · 按版本记忆 tooltip 显示真实命令 ③ 退出 → helper → 重启 独立 helper 执行 brew upgrade --cask homebrew-app,跑完拉起来 升级用的是和 App 其他地方完全一样的 zsh 启动方式,没有第二条特殊通道 下次启动时用一条「上次结果」提示告诉你成没成功 ① 检测 复用已安装清单的 outdated 标记 不重新比较版本号 ② 横幅 升级 / 稍后 忽略状态按版本记忆 ③ 退出 → helper 独立 helper 跑 brew upgrade --cask homebrew-app ④ 重新启动 App

几个设计细节值得注意:

  • 检测复用数据,不发明第二条路。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 个文件)搜索焦点、列表焦点、错误态、自升级、安装 / 卸载各成一组
真实环境端到端 canary5要连真 Homebrew 和真网络,会装上再卸掉 formula hello
SwiftPM 模块20外加 Tools/BrewUILint 独立包
CI workflow11构建 / 发布 / 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。官方规矩:不要「顺手跑一下」
关于 e2e:把它当「合同探针」,不是「覆盖率工具」。这个套件的定位写得很清楚——如果 Homebrew 改了 JSON 结构或 CLI 输出,确定性的那层还在用夹具回答、会是绿的,只有它会红。另外它刻意不覆盖三件事:upgrade(没法保证机器上有个刚好过时的包)、doctor(输出完全取决于机器状态,没有稳定 happy path)、错误路径(那些属于第二层,让失败长得像夹具问题而不是线上故障)。

还有个细节很讲究:套件会重试失败的用例两次,但重试成功了也会报告——因为它认为「重试把红变绿」正是真实故障被误当成网络抖动的路径。

最后是一些会让人卡住的具体问题。

12FAQ / 踩坑

每条都给可执行的解法。前四条是新手最可能碰到的。

Q1 · 打开提示「已损坏,无法打开,请移到废纸篓」怎么办?

先把情况分清:

  • 用 cask 或官方 .pkg 装的 → 走的是 Developer ID 签名 + 公证的流程,正常不该出现这个提示。先确认你下的是 GitHub Releases 里的官方资产,别下第三方转载。
  • 自己从 Xcode 跑 UI 测试时出现的 → 这是已知的坑,有明确根因:BrewUITests-Runner.app 是 Xcode XCTRunner.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合规与授权

这一节短但重要,尤其是打算把它用在工作项目里的人。

AGPL-3.0

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.yml workflow——依赖许可是有 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 ConcurrencySwift 官方书 · Concurrency
SwiftUIApple 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.swift102 行解决 App 与测试之间标识符漂移问题
数据口径

本文所有代码规模数字,均为 2026-09-16 对 v0.4.2(最新提交 feb9a3a7)做 git clone --depth 1 后在本地统计 .swift 文件行数得到(含空行与注释)。版本号、测试数量、文件数量会随仓库演进变化——引用前请以仓库当前状态为准。

这是一份「读得懂、跑得通、能照抄」的教程。如果它帮你省下了半小时,最好的回报是:去仓库点个 Star,或者读一遍 .ai/memory.md——那份文档比这个教程更有价值。

BrewUI 新手教程 · 基于 Homebrew/BrewUI v0.4.2 整理

BrewUI 采用 AGPL-3.0 协议,版权归 Homebrew 项目所有。本教程为第三方整理,非官方文档。