不再 All in Homebrew,用 nix-darwin 管理我的 Mac
从 2021 年开始,我 Mac 上几乎所有的软件都是用 Homebrew 装的,命令行工具用 formula,图形界面的应用用 cask,最后再用一个 Brewfile 把它们全部记下来。这套办法确实好用,换电脑的时候一条 brew bundle 就能装回来,删软件也能删得比较干净。
但是用了几年下来,问题也越来越多。
Homebrew 的问题
首先是版本。Homebrew 的 formula 基本上只提供最新版,除了少数像 openjdk@17、python@3.12 这种带版本号的 formula,想装一个指定的旧版本是很麻烦的。而且 brew upgrade 一跑,所有东西都会跟着升级,偶尔某个工具升级之后出了问题,想退回去也不太容易。
然后是依赖。Homebrew 装 formula 的时候,会把它的依赖也一起装上,而这些依赖是和我自己装的东西混在一起的。最让我头疼的是 Rust。我的 Rust 是用 mise 装的,可是只要某个 formula 依赖了 Rust,Homebrew 就会再装一个它自己的 Rust 进来,于是 /opt/homebrew/bin 里也多出了一套 cargo 和 rustc。两套 Rust 谁先被找到,取决于 PATH 的顺序,结果就是有时候 cargo build 用的根本不是我以为的那个版本。
为此我甚至专门写过一个函数,用来检查当前的 cargo 是不是来自 Homebrew。
# check if using correct cargo
function check_cargo() {
cargo_path=$(which cargo 2>/dev/null)
if [[ -z "$cargo_path" ]]; then
echo "Cargo is not installed or not in PATH."
return 1
fi
if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
echo "$cargo_path cargo path error"
fi
}
Python 也有类似的情况。我的 Brewfile 里有好几个 Python 的包,比如 numpy、cffi、cryptography,它们都是被别的 formula 当作依赖带进来的,我自己从来没装过,顺便还带进来了一个 python@3.12,又和 mise 装的 Python 混在了一起。
最后就是 Brewfile 本身。虽然我一直在用它,但它其实是事后记录的,我装完东西再 brew bundle dump 一下,把当前的状态导出来。而且 dump 出来的东西里,混着大量我并没有主动装、只是作为依赖进来的 formula,Brewfile 越来越长,到后来 formula 已经有将近两百个了,其中我主动装的可能只有一半。
为什么是 Nix
我之前用过一段时间的 NixOS,一直很喜欢它声明式管理系统的方式。所有东西都写在配置文件里,想要什么就写上,不想要了就删掉,然后 rebuild 一下,系统就会变成配置里描述的样子。我当初坚持维护 Brewfile 其实就是为了这种声明式的感觉,只是 Brewfile 做得并不彻底。
Nix 的包都装在 /nix/store 里,每个包有自己独立的路径,依赖也都是各自隔离的。所以就算某个工具在构建时依赖了 Rust,这个 Rust 也只存在于它自己的依赖里,不会出现在我的 PATH 中,更不会和 mise 装的 Rust 打架。而且 nixpkgs 里有预先编译好的二进制缓存,大部分包装起来和 Homebrew 一样快。
那么在 Mac 上,想要用 Nix 来管理系统,就要用到 nix-darwin。它相当于 macOS 上的 NixOS 配置模块,除了装软件,还能管理系统设置、launchd 服务、环境变量,甚至还有一个 Homebrew 模块,可以用 Nix 的配置去声明 Homebrew 要装什么。
怎么分工
不过 Nix 也不是什么都适合。图形界面的应用在 nixpkgs 里并不全,就算有,很多也不如官方的 dmg 好用,而 Homebrew 的 cask 在这方面已经做得很好了。另外编程语言的运行时,我希望每个项目可以用不同的版本,这件事交给 Nix 来做又太重了。
所以最后我是这么分的。
| 负责的东西 | 交给谁 |
|---|---|
| 系统设置、命令行工具、launchd 服务 | nix-darwin |
| 图形界面应用(cask) | Homebrew,由 nix-darwin 来声明 |
| 编程语言和它们的工具链 | mise |
安装
先把 Nix 装好,并且打开 flakes。然后按照 nix-darwin 的说明,在 /etc/nix-darwin 下面用模板生成一个 flake.nix。
sudo mkdir -p /etc/nix-darwin
sudo chown $(id -nu):$(id -ng) /etc/nix-darwin
cd /etc/nix-darwin
nix flake init -t nix-darwin/master
sed -i '' "s/simple/$(scutil --get LocalHostName)/" flake.nix
第一次安装的时候,系统里还没有 darwin-rebuild 这个命令,所以要用 nix run 运行一次。
nix run nix-darwin/master#darwin-rebuild -- switch
之后每次改完配置,只需要运行下面这条命令就能生效。
darwin-rebuild switch
配置
模板生成的 flake.nix 把所有配置都写在一个叫 configuration 的函数里,我也就直接在里面往下加。下面是我现在配置的大致样子,省略了一部分软件。
{
description = "Brian's nix-darwin system";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin.url = "github:nix-darwin/nix-darwin/master";
nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs@{ self, nix-darwin, nixpkgs }:
let
configuration = { pkgs, ... }: {
nixpkgs.hostPlatform = "aarch64-darwin";
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = "nix-command flakes";
environment.systemPackages = [
# 常用命令行工具
pkgs.bat
pkgs.eza
pkgs.fd
pkgs.fzf
pkgs.ripgrep
pkgs.jq
pkgs.btop
pkgs.yazi
pkgs.zellij
pkgs.zoxide
pkgs.starship
pkgs.gh
pkgs.helix
pkgs.neovim
# 开发工具
pkgs.cmake
pkgs.ninja
pkgs.just
pkgs.lazygit
pkgs.hyperfine
pkgs.tokei
# 网络工具
pkgs.mtr
pkgs.nmap
pkgs.iperf3
pkgs.doggo
pkgs.nexttrace
# 语言环境交给 mise
pkgs.mise
];
homebrew = {
enable = true;
taps = [ "xxxbrian/tap" ];
brews = [
"openssl@3"
"sqlite"
"readline"
"libyaml"
"pkgconf"
"icu4c"
"zlib"
];
casks = [
"1password"
"ghostty"
"google-chrome"
"orbstack"
"raycast"
"surge"
"telegram"
"visual-studio-code"
"zed"
];
onActivation.cleanup = "zap";
};
security.pam.services.sudo_local.touchIdAuth = true;
system.defaults = {
NSGlobalDomain = {
ApplePressAndHoldEnabled = false;
KeyRepeat = 2;
InitialKeyRepeat = 15;
};
dock = {
autohide = false;
show-recents = false;
tilesize = 58;
};
};
system.configurationRevision = self.rev or self.dirtyRev or null;
system.stateVersion = 6;
};
in
{
darwinConfigurations."brian-mbp-m1max" = nix-darwin.lib.darwinSystem {
modules = [ configuration ];
};
};
}
命令行工具
environment.systemPackages 里列的就是要装的命令行工具,名字基本和 Homebrew 里的一样,不一样的话可以去 search.nixos.org 搜一下。装好之后,它们都会出现在 /run/current-system/sw/bin 里。
有时候 nixpkgs 里的包和我想要的不太一样,也可以用 overlay 改一改。比如 nexttrace 在我这里需要额外链接 libpcap,就可以这样覆盖它的构建参数。
nixpkgs.overlays = [
(final: prev: {
nexttrace = prev.nexttrace.overrideAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [ prev.libpcap ];
});
})
];
Homebrew
nix-darwin 并不会帮你安装 Homebrew,所以 Homebrew 本身还是要按原来的方式装好。之后 nix-darwin 会根据 homebrew 这一段配置生成一个 Brewfile,然后在每次 switch 的时候调用 brew bundle 去执行它。
也就是说,Brewfile 从此变成了从配置生成出来的东西,而不是我从电脑上 dump 出来的。这样它里面就只有我主动写上的 cask,不会再混进一堆依赖。
onActivation.cleanup = "zap" 是我觉得最关键的一项。设置了它之后,每次 switch,凡是不在配置里的 formula 和 cask,都会被卸载掉,而且是带 --zap 的那种卸载,会把应用留在 ~/Library 里的东西一起清理掉。所以想删一个软件,只需要把它从配置里删掉,然后 switch 一下。
不过也正因为这样,第一次 switch 之前一定要把 cask 列全,不然没写进去的应用会连同它的数据一起被删掉。我当时是先用 brew list --cask 把所有 cask 列出来,一个个对过之后才敢运行的。
brews 里我只留下了几个库,比如 openssl@3、readline、sqlite、zlib。因为有些东西需要在本地编译,比如用 mise 编译某些语言版本,或者构建带 C 扩展的包,这些构建脚本在 Mac 上默认都会去 Homebrew 的路径里找这几个库,所以把它们留在 Homebrew 里反而最省事。
系统设置
system.defaults 可以设置很多原本要去系统设置里点、或者用 defaults write 改的东西,比如按键重复的速度、Dock 的大小和行为。我把自己习惯的那几项都写了进去,这样换一台电脑,这些设置也能跟着一起过去。
security.pam.services.sudo_local.touchIdAuth 则是让 sudo 可以用 Touch ID 验证,打开之后终端里输 sudo 就不用再敲密码了,按一下指纹就行。
launchd 服务
以前用 brew services 跑的后台服务,比如 aria2,现在也可以直接在 nix-darwin 里声明成一个 launchd 的用户服务。
environment.userLaunchAgents.aria2 = {
enable = true;
target = "org.nix-darwin.aria2.plist";
text = ''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.nix-darwin.aria2</string>
<key>ProgramArguments</key>
<array>
<string>${pkgs.aria2}/bin/aria2c</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
'';
};
${pkgs.aria2} 会被替换成 aria2 在 /nix/store 里的实际路径,所以这个服务永远指向配置里的那个版本。
mise
各种语言的版本,去年我就已经全部交给 mise 了,这次也没有变。唯一的区别是 mise 本身以前是用 Homebrew 装的,现在改成了由 Nix 来装,就是上面 systemPackages 里的那一行 pkgs.mise。~/.config/mise/config.toml 还是原来那份,Nix 不去碰它。
所以现在三者的边界很清楚。Nix 管系统和命令行工具,Homebrew 管图形界面的应用,mise 管语言。因为命令行工具都换成了 Nix 装,Homebrew 也不会再因为依赖偷偷装一个 Rust 进来,which cargo 永远指向 mise 管理的那一个。
迁移
整个迁移我是一点点做的。
- 先用
brew leaves列出所有我主动装的 formula,在 nixpkgs 里找到对应的包,写进environment.systemPackages。 - 用
brew list --cask列出所有 cask,原样写进homebrew.casks。 - 确认无误之后,打开
onActivation.cleanup = "zap",运行darwin-rebuild switch,让它把不在配置里的 formula 全部清理掉。
最后一步跑完,brew leaves 里就只剩下那几个库了,而命令行工具全部换成了 /run/current-system/sw/bin 下面的 Nix 版本。
现在
现在想装一个命令行工具,就在 systemPackages 里加一行,想装一个应用,就在 casks 里加一行,然后 darwin-rebuild switch。想删也是一样,删掉那一行再 switch 就行。整台电脑装了什么、系统是怎么设置的,全都写在 /etc/nix-darwin/flake.nix 这一个文件里。
而且每次 switch 都会生成一个新的系统版本,如果改坏了,还可以用 darwin-rebuild switch --rollback 回到上一个版本,这一点是用 Homebrew 的时候完全不敢想的。