不再 All in Homebrew,用 nix-darwin 管理我的 Mac

2025年5月10日星期六 / , , , ,
摘要:
用 Homebrew 管了几年所有软件之后,版本和依赖的问题越来越多,于是我引入了 nix-darwin,命令行工具交给 Nix,图形界面应用继续用 Homebrew,语言环境则继续由 mise 管理。

从 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。

Shell
# 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
Mermaid diagram

安装

先把 Nix 装好,并且打开 flakes。然后按照 nix-darwin 的说明,在 /etc/nix-darwin 下面用模板生成一个 flake.nix。

Shell
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 运行一次。

Shell
nix run nix-darwin/master#darwin-rebuild -- switch

之后每次改完配置,只需要运行下面这条命令就能生效。

Shell
darwin-rebuild switch

配置

模板生成的 flake.nix 把所有配置都写在一个叫 configuration 的函数里,我也就直接在里面往下加。下面是我现在配置的大致样子,省略了一部分软件。

NIX
{
  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,就可以这样覆盖它的构建参数。

NIX
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 的用户服务。

NIX
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 管理的那一个。

迁移

整个迁移我是一点点做的。

  1. 先用 brew leaves 列出所有我主动装的 formula,在 nixpkgs 里找到对应的包,写进 environment.systemPackages。
  2. 用 brew list --cask 列出所有 cask,原样写进 homebrew.casks。
  3. 确认无误之后,打开 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 的时候完全不敢想的。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...