不再 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...