NixOS 系列(三):软件打包,从入门到放弃 的插图

NixOS 系列(三):软件打包,从入门到放弃

NixOS 系列文章目录:

NixOS 的一大特点是,系统所有的二进制程序和库文件都在 /nix/store 目录中,由 Nix 包管理器管理。这也意味着,NixOS 不符合 Linux 的 FHS 标准,它的 /lib/lib64 目录下不存在类似 ld-linux-x86-64.so.2 之类的库文件动态加载器,更不存在 libc.so 之类的库文件。因此,除非静态链接,否则为其它 Linux 下编译的二进制文件将完全无法在 NixOS 下运行。

所以,要在 NixOS 上使用尚不存在于 Nixpkgs 仓库中的软件,最佳方案是自己用 Nix 语言写一份打包脚本,给这个软件打一个包,然后把打包定义加入 configuration.nix 中,从而安装到系统上。

关于 NixOS 的软件打包,有三个好消息和两个坏消息。好消息是:

  1. Nixpkgs,也就是 NixOS 的软件仓库,提供了大量的打包自动化函数,对于很多使用常见编程语言的开源软件(包括 C/C++,Python,Go,Node.js,Rust 等,但不包括 Java),你只需要调用现成的函数,指定一下源码的下载方式,Nixpkgs 就能自动检测软件的打包系统,自动传入合适的参数并完成软件打包。
  2. 对于以二进制方式分发的软件(常见于闭源软件),Nixpkgs 也提供了现成的自动化解决方案:
    • 一种是 Autopatchelf,自动修改二进制文件中的库文件路径,将其指向 /nix/store 中。
    • 另一种是 Bubblewrap,或者基于 Bubblewrap 的 steam-run,模拟一个符合 FHS 标准的运行环境。顾名思义,steam-run 主要针对的是 Steam 游戏平台以及它上面的游戏,但它也可以用于其它闭源软件。
  3. Nix 包管理器会在一个隔离的环境中进行软件打包,你可以粗略地理解成一个断网,限制权限,只允许访问固定路径的 Docker 容器。在编译过程中,访问外部路径或者联网的尝试全部会失败,只能使用 Nix 编译脚本中事先指定的依赖。因此,打包出来的程序将完全不依赖其它文件。

坏消息是:

  1. 开发者不一定比打包者更懂 Linux。开发者可能会在代码和编译脚本里写死各种路径,做出各种只符合 FHS 标准的假设。此时就需要你手动写补丁,纠正这些路径,让程序可以在 NixOS 上正常编译运行。
  2. 一旦你遇到了不能使用现成函数的情况,包括下列情况,你就得做好「一杯茶,一包烟,一个 Bug 调一天」的准备:
    • 开发者使用了一些奇怪的源码目录结构(例如 osdlyrics),或者非标准的编译方式
    • 程序主动检测运行环境(例如 UOS 版微信客户端)
    • 程序主动检测对它本身的修改(例如 SVP 视频补帧软件)

我在几个月前将日常使用的发行版从 Arch Linux 换成了 NixOS,在使用过程中打了很多 NixOS 软件包。本文将从简单的打包开始一步步推进,介绍 NixOS 打包的方法,遇到的常见问题以及应对策略。

准备工作

首先,强烈建议你安装好 NixOS 操作系统,并在 NixOS 上进行打包。

  • 虽然在非 NixOS 的操作系统上也可以用 Nix 包管理器打包软件,但打包出的软件在运行过程中可能还会残留有对 FHS 标准目录的依赖,从而导致它们无法正常在 NixOS 上使用。当然,如果你打包只是自用,只考虑自己的运行环境,那可以忽略这条。
  • 此外,要在非 NixOS 的操作系统上安装 Nix 打包的软件,你需要使用 Home Manager,一个通过 Nix 语言的配置文件来管理你的 Home 目录下的软件配置文件的工具。你需要自行研究,或者查阅其它人的相关文章。

使用 NUR 的打包模版

NUR 是 Nix 的由用户自行管理的软件仓库,类似于 Arch Linux 的 AUR。NUR 提供了一份现成的 Nix 仓库模版,你可以方便地统一添加、管理自己的软件包。

在 GitHub 上,访问 nur-packages-template,点击「Use this template」用这个模版建立一个仓库。之后,你可以将所有软件包统一保存在你新建的仓库。

如果要将自己的软件包发布到 NUR,你需要向 NUR 的主仓库发起 Pull Request,将你自己的仓库地址加进去。但即使你不发 Pull Request,也完全可以直接使用自己的仓库。

然后,把你的仓库 Clone 下来。

  • 对于不使用 Nix Flake 的用户,运行以下命令可以对 example-package 这个模版自带的示例软件包进行打包:

    nix-build -A example-package
  • 对于使用 Flake 的用户,运行以下命令:

    nix flake update # 可选,将 flake.lock 中的 Nixpkgs 等仓库更新到最新版
    nix build ".#example-package"

然后,在你的 NixOS 配置中添加自己的仓库。

  • 对于不使用 Nix Flake 的用户,在 configuration.nix 中添加如下定义:

    nixpkgs.config.packageOverrides = pkgs: {
      myRepo = import (builtins.fetchTarball "https://github.com/nix-community/nur-packages-template/archive/master.tar.gz") {
        inherit pkgs;
      };
    };

    https://github.com/nix-community/nur-packages-template 替换成你的仓库地址。

    这样操作后,你就能用类似于 pkgs.myRepo.example-package 的方式使用你打的包了。

  • 对于使用 Nix Flake 的用户,在 flake.nix 中的 inputs 一节中添加如下定义:

    inputs = {
      # ...
      myRepo = {
        url = "github:nix-community/nur-packages-template";
        inputs.nixpkgs.follows = "nixpkgs";
      };
      # ...
    };

    nix-community/nur-packages-template 替换成你的仓库地址。

    然后,在 flake.nix 中的 output 一节,你的 nixosConfigurations 定义中,为每个系统添加一个 module:

    outputs = { self, nixpkgs, ... }@inputs: {
      nixosConfigurations."nixos" = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          # 在 modules 的开头添加下面这几行
          ({
            nixpkgs.overlays = [
              (final: prev: {
                myRepo = inputs.myRepo.packages."${prev.system}";
              })
            ];
          })
          # 在 modules 的开头添加上面这几行
    
          ./configuration.nix
        ];
      };
    };

    这样操作后,你就能用类似于 pkgs.myRepo.example-package 的方式使用你打的包了。

直接在 NixOS 配置文件中添加软件包

当然,你也可以不使用 NUR 的模版,而是直接把打包定义和 NixOS 的配置文件放在一起。

假设你有这样一个打包定义,保存成 example-package.nix:(来自 https://github.com/nix-community/nur-packages-template/blob/master/pkgs/example-package/default.nix

{ stdenv }:

stdenv.mkDerivation rec {
  name = "example-package-${version}";
  version = "1.0";
  src = ./.;
  buildPhase = "echo echo Hello World > example";
  installPhase = "install -Dm755 example $out";
}

你可以在 configuration.nix 中使用 pkgs.callPackage 函数来调用它:

{ config, pkgs, ... }:

{
  # 直接使用这个包
  environment.systemPackages = [
    (pkgs.callPackage ./example-package.nix { })
  ];

  # 或者将这个包先定义成一个常量
  environment.systemPackages = let
    examplePackage = pkgs.callPackage ./example-package.nix { };
  in [
    examplePackage
  ];
}

如果你要单独尝试构建这个软件包,你可以使用以下命令:

nix-build -E 'with import <nixpkgs> {}; callPackage ./example-package.nix {}'

打包流程

虽然你可以直接调用 Nix 包管理器内置的 builtins.derivation 函数进行打包,但我们一般用更为方便的 stdenv.mkDerivation 函数来生成一个 Nix 包管理器的打包定义。相比于 builtins.derivationstdenv.mkDerivation 将打包过程分成了 7 个步骤(Phase):

  1. 解压(Unpack phase)

    • 在这一步中,stdenv.mkDerivation 会自动解压 src 参数指定的源码包。例如如果你的源码包是 .tar.gz 格式的,就会自动调用 tar xf

    • stdenv.mkDerivation 不能识别所有压缩格式,例如 .zip 就不行,需要手动指定解压命令:

      nativeBuildInputs = [ unzip ];
      unpackPhase = ''
        unzip $src
      '';
    • stdenv.mkDerivation 要求源码包的顶层是一个文件夹,解压完成后会自动 cd 进去。

  2. 打补丁(Patch phase)

    • 在这一步中,stdenv.mkDerivation 会按顺序应用 patches 列表中的所有补丁。这一步可以用来解决一部分软件和 NixOS 的不兼容问题。
  3. 配置(Configure phase)

    • 这一步相当于运行 ./configure 或者 cmakestdenv.mkDerivation 会自动检测打包方案并调用相应命令,或者当相应配置文件不存在时,自动跳过这一步。
    • 需要注意的是,要调用 cmake,你需要额外加一行 nativeBuildInputs = [ cmake ]; 把 CMake 加入打包环境中。
    • 你可以用 configureFlags 或者 cmakeFlags 添加配置参数,例如启用/禁用软件的功能。
  4. 编译(Build phase)

    • 这一步相当于运行 make。你可以用 makeFlags 添加传给 make 的参数。
  5. 测试(Check phase)

    • 这一步会运行源码中自带的测试用例,以保证软件功能正确。
    • 你可以用 doCheck = false; 禁用这一步。
  6. 安装(Install phase)

    • 这一步相当于运行 make install,将编译结果复制到 Nix store 的相应文件夹中。
    • 整个构建过程是在临时文件夹中,而不是 Nix store 中进行的,因此需要这一步将文件复制过去。
    • 当你手动指定安装命令时,目标路径存在变量 $out 中。$out 可以是存放有文件的文件夹,也可以直接是一个文件。
  7. 额外修补(Fixup phase)

    • 这一步会对 Nix store 中的结果做一些清理,例如去除调试符号等。
    • Autopatchelf Hook,一个自动替换闭源软件 .so 的路径的 Hook,就是在这一步运行的。
    • 你可以用 dontFixup = true; 禁用这一步。

每一个步骤都可以手动指定对应的命令,或者在原有命令之前或之后额外增加命令。以安装这一步为例:

preInstall = ''
  echo 这里指定在安装步骤之前运行的命令
'';
installPhase = ''
  # 运行 preInstall 的命令。默认的 installPhase 自带了下面这一行,但当你指定整个步骤的命令时,就需要自己加上,否则 preInstall 不会运行
  runHook preInstall

  echo 这里指定安装步骤的所有命令

  # 运行 postInstall 的命令,同理
  runHook postInstall
'';
postInstall = ''
  echo 这里指定在安装步骤之后运行的命令
'';

只看这些步骤的解释可能有些抽象,因此接下来我会给出一些实例,并给出详细解释。此外,我的实例中还会涉及 Nixpkgs 提供的对于几种常用编程语言的专用打包函数,例如 Python 的 buildPythonPackage,Go 的 buildGoModule 等等。这些实例都来自我的 NUR 软件源

实例:开源软件

开源软件的打包往往都比较容易,因为在打包过程中,Nix 包管理器会调整好环境变量,让编译器找到存放在 Nix store 中其它路径的库文件,所以生成的二进制文件都会链接到 Nix store 的库文件中,不依赖 /usr 等路径下的其它文件,可以直接在 NixOS 上使用,此外,即使开源软件中出现路径写死等情况,你在打包过程中也可以写一个补丁,把路径修改掉,从而让它能在 NixOS 下正常工作。

简单:LibOQS(C++,CMake,自动化构建)

首先我们来看一个最简单的例子:LibOQS。LibOQS 提供了多种后量子加密算法的实现,可以用来给 OpenSSL 或 BoringSSL 提供后量子加密支持

LibOQS 使用 CMake 构建,并且本身没有任何依赖,因此基本上所有工作都可以由 stdenv.mkDerivations 自动完成,我们只需要为 CMake 指定几个额外的参数:

# 当你使用 pkgs.callPackage 函数时,这里的参数会用 Nixpkgs 的软件包和函数自动填充(如果有对应的话)
{ lib
, stdenv
, fetchFromGitHub
, cmake
, ...
} @ args:

stdenv.mkDerivation rec {
  # 指定包名和版本
  pname = "liboqs";
  version = "0.7.1";

  # 从 GitHub 下载源代码
  src = fetchFromGitHub ({
    owner = "open-quantum-safe";
    repo = "liboqs";
    # 对应的 commit 或者 tag,注意 fetchFromGitHub 不能跟随 branch!
    rev = "0.7.1";
    # 下载 git submodules,绝大部分软件包没有这个
    fetchSubmodules = false;
    # 这里的 SHA256 校验码不会算怎么办?先注释掉,然后构建这个软件包,Nix 会报错,并提示你正确的校验码
    sha256 = "sha256-m20M4+3zsH40hTpMJG9cyIjXp0xcCUBS+cCiRVLXFqM=";
  });

  # 并行编译,大幅加快打包速度,默认是启用的。对于极少数并行编译会失败的软件包,才需要禁用。
  enableParallelBuilding = true;
  # 如果基于 CMake 的软件包在打包时出现了奇怪的错误,可以尝试启用此选项
  # 此选项禁用了对 CMake 软件包的一些自动修正
  dontFixCmake = true;

  # 将 CMake 加入编译环境,用来生成 Makefile
  nativeBuildInputs = [ cmake ];

  # 传给 CMake 的配置参数,控制 liboqs 的功能
  cmakeFlags = [
    "-DBUILD_SHARED_LIBS=ON"
    "-DOQS_BUILD_ONLY_LIB=1"
    "-DOQS_USE_OPENSSL=OFF"
    "-DOQS_DIST_BUILD=ON"
  ];

  # stdenv.mkDerivation 自动帮你完成其余的步骤
}

然后运行下面这行命令,Nix 包管理器就会自动构建这个软件包,并把输出链接到当前目录的 results

nix-build -E 'with import <nixpkgs> {}; callPackage ./liboqs.nix {}'

中等:openssl-oqs-provider(C,增加依赖)

有了 LibOQS,我们可以再打包一个 OpenSSL OQS Provider,一个 OpenSSL 3.0 的加解密引擎,可以把后量子加密算法加入 OpenSSL 3.0 中

{ lib
, stdenv
, fetchFromGitHub
, cmake
, liboqs
, openssl_3_0
, python3
, ...
} @ args:

stdenv.mkDerivation rec {
  pname = "openssl-oqs-provider";
  version = "ec60cde5cc894814016f821a1162fe1a4b888a75";
  src = fetchFromGitHub ({
    owner = "open-quantum-safe";
    repo = "oqs-provider";
    rev = "ec60cde5cc894814016f821a1162fe1a4b888a75";
    fetchSubmodules = false;
    sha256 = "sha256-NyT5CpQeclSJ0b4Qr4McAJXwKgy6SWiUijkAgu6TTNM=";
  });

  enableParallelBuilding = true;
  dontFixCmake = true;

  # nativeBuildInputs 指定的是只有在构建时用到,运行时不会用到的软件包
  # 例如这里的用来生成 Makefile 的 CMake,和用来生成配置文件的 Python
  nativeBuildInputs = [
    cmake
    # 向打包环境加入 Python 和这几个包,preConfigure 中的命令需要用到
    (python3.withPackages (p: with p; [ jinja2 pyyaml tabulate ]))
  ];

  # buildInputs 指定的是运行时也会用到的软件包
  buildInputs = [
    liboqs
    openssl_3_0
  ];

  # 在配置步骤(Configure phase)之前运行的命令,用来启用所有的后量子加密算法
  preConfigure = ''
    cp ${sources.openssl-oqs.src}/oqs-template/generate.yml oqs-template/generate.yml
    sed -i "s/enable: false/enable: true/g" oqs-template/generate.yml
    LIBOQS_SRC_DIR=${sources.liboqs.src} python oqs-template/generate.py
  '';

  cmakeFlags = [ "-DCMAKE_BUILD_TYPE=Release" ];

  # 手动指定安装命令,把 oqsprovider.so 复制到 $out/lib 文件夹下
  # 一般来说可执行文件放在 $out/bin,库文件放在 $out/lib,菜单图标等放在 $out/share
  # 但并非强制,你在 $out 下随便放都可以,只不过在其它地方调用会麻烦一些
  installPhase = ''
    mkdir -p $out/lib
    install -m755 oqsprov/oqsprovider.so "$out/lib"
  '';
}

这个包主要用来展示 nativeBuildInputsbuildInputs 的区别:

  • nativeBuildInputs 只有在构建时用到,一般用来生成一些配置文件或者编译脚本。在交叉编译(给其它架构的设备编译软件)时,nativeBuildInputs 的架构会和运行编译的设备相同,而不是和目标设备相同。例如用 x86 电脑给 ARM 树莓派编译时,nativeBuildInputs 的架构会是 x86。
  • buildInputs 在构建和最终运行软件时都会用到。所有的依赖库都会放到这里。这些依赖的架构和目标设备相同,例如 openssl-oqs-provider 依赖的 liboqs 必然和它是同一架构的(都是 x86 或者都是 ARM)。

困难:OSDLyrics(Python 和 C++,两轮构建)

接下来我们来看 OSDLyrics,一个桌面歌词软件。这个包表面上看起来很好打,官方给出的编译命令就是下面几行:

./autogen.sh
./configure --prefix=/usr PYTHON=/usr/bin/python3
make
sudo make install

但是编译命令里出现了 Python,这就比较麻烦了。OSDLyrics 由 Python 和 C++ 两部分组成,其中 C++ 部分会调用 Python 的库。因此,官方的编译脚本会把 OSDLyrics 的 Python 模块安装到 Python 的 site-packages 文件夹中。但是在 Nix 中,对于 OSDLyrics 这个软件包来说,Python 的安装目录是只读的,自然无法安装这个模块。

因此我们需要先给 Python 模块部分单独打个包:

{ python3Packages
, fetchFromGitHub
, writeText
, ...
}:

python3Packages.buildPythonPackage rec {
  pname = "osdlyrics";
  version = "0.5.10";
  src = fetchFromGitHub ({
    owner = "osdlyrics";
    repo = "osdlyrics";
    rev = "0.5.10";
    fetchSubmodules = false;
    sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU=";
  });

  configurePhase =
    let
      # 原软件包的 Python 模块部分不符合 PIP 的打包格式,需要手动加入这两个配置文件
      setupPy = writeText "setup.py" ''
        from setuptools import setup, find_packages
        setup(
          name='${pname}',
          version='${version}',
          packages=['osdlyrics', 'osdlyrics/dbusext'],
        )
      '';
      initPy = writeText "__init__.py" ''
        PROGRAM_NAME = 'OSD Lyrics'
        PACKAGE_NAME = '${pname}'
        PACKAGE_VERSION = '${version}'
      '';
    in
    # 把 Python 模块的文件夹改名并加入配置文件,以符合 PIP 规范
    ''
      ln -s ${setupPy} setup.py
      mv python osdlyrics
      ln -s ${initPy} osdlyrics/__init__.py
    '';

  # 禁用测试,原软件包中没有单元测试
  doCheck = false;
}

然后把这个模块加入 OSDLyrics 最终使用的 Python 环境:

{ python3Packages
, fetchFromGitHub
, writeText
, python3
, ...
}:

let
  osdlyricsPython = python3Packages.buildPythonPackage rec {
    # ...
  };

  # 下面列出的包都是 OSDLyrics 要用到的
  python = python3.withPackages (p: with p; [
    chardet
    dbus-python
    future
    mpd2
    osdlyricsPython
    pycurl
    pygobject3
  ]);
in
# ...

最终才能打包它的 C++ 部分:

{ ... }:

let
# ...
in
stdenv.mkDerivation rec {
  pname = "osdlyrics";
  version = "0.5.10";
  src = fetchFromGitHub ({
    owner = "osdlyrics";
    repo = "osdlyrics";
    rev = "0.5.10";
    fetchSubmodules = false;
    sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU=";
  });

  nativeBuildInputs = [
    # 自动运行 autoconf,也就是 autogen.sh 做的事
    autoreconfHook
    # 生成语言文件的工具
    intltool
    # pkgconfig 被 autoconf 系列配置脚本用来查找依赖
    pkg-config
  ];
  # C++ 部分用到的依赖
  buildInputs = [
    dbus-glib
    gtk2
    libnotify
    # 注意这个 Python 是我们上面定义的,加了几个模块的版本
    python
  ];

  # 解决一些编译错误
  postPatch = ''
    sed -i 's/-Werror//g' configure.ac
  '';

  # autoreconfHook 会在构建步骤中加入一个 autoreconf phase,也有对应的前置/后置命令 Hook
  preAutoreconf = ''
    export AUTOPOINT=intltoolize
  '';

  # 指定用我们的加了模块的 Python
  makeFlags = [ "PYTHON=${python}/bin/python" ];

  # 删除结果中的 Python 模块部分(因为已经打包过了)
  postInstall = ''
    rm -rf $out/lib/python*
  '';
}

最终完整的定义如下:

{ stdenv
, lib
, fetchFromGitHub
, writeText
, python3Packages
  # nativeBuildInputs
, autoreconfHook
, intltool
, pkg-config
  # buildInputs
, dbus-glib
, gtk2
, libnotify
, python3
, ...
} @ args:

let
  pname = "osdlyrics";
  version = "0.5.10";
  src = fetchFromGitHub ({
    owner = "osdlyrics";
    repo = "osdlyrics";
    rev = "0.5.10";
    fetchSubmodules = false;
    sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU=";
  });

  osdlyricsPython = python3Packages.buildPythonPackage rec {
    inherit pname version src;

    configurePhase =
      let
        setupPy = writeText "setup.py" ''
          from setuptools import setup, find_packages
          setup(
            name='${pname}',
            version='${version}',
            packages=['osdlyrics', 'osdlyrics/dbusext'],
          )
        '';
        initPy = writeText "__init__.py" ''
          PROGRAM_NAME = 'OSD Lyrics'
          PACKAGE_NAME = '${pname}'
          PACKAGE_VERSION = '${version}'
        '';
      in
      ''
        ln -s ${setupPy} setup.py
        mv python osdlyrics
        ln -s ${initPy} osdlyrics/__init__.py
      '';

    doCheck = false;
  };

  python = python3.withPackages (p: with p; [
    chardet
    dbus-python
    future
    mpd2
    osdlyricsPython
    pycurl
    pygobject3
  ]);
in
stdenv.mkDerivation rec {
  inherit pname version src;
  nativeBuildInputs = [
    autoreconfHook
    intltool
    pkg-config
  ];
  buildInputs = [
    dbus-glib
    gtk2
    libnotify
    python
  ];
  postPatch = ''
    sed -i 's/-Werror//g' configure.ac
  '';
  preAutoreconf = ''
    export AUTOPOINT=intltoolize
  '';
  makeFlags = [ "PYTHON=${python}/bin/python" ];
  postInstall = ''
    rm -rf $out/lib/python*
  '';
}

实例:闭源软件(以及以二进制形式分发的软件)

比起开源软件,给闭源软件打包就比较困难了。这些闭源软件往往只提供二进制文件,而这些二进制文件往往是提供给传统的、使用 FHS 标准目录结构的 Linux 发行版的,例如 CentOS、Debian、Ubuntu 等。由于我们没有源代码,我们只能想办法在二进制文件上动手术,在二进制文件中查找 FHS 标准路径,并把它们全部替换成 Nix store 的路径。

幸运的是,针对不同的情况,Nixpkgs 提供了好几种方案,让多数的闭源软件都能打包成功。

简单:Bilibili-linux(解压 DEB 包,Electron)

首先我们看一个简单的情况:基于 Electron 的软件。这里以 Bilibili-linux 为例,它是基于哔哩哔哩官方的桌面客户端移植到 Linux 系统的版本

虽然 Electron 软件相比传统的基于 GTK 或 Qt 的桌面软件耗电大,占用空间多,而且会让每台电脑中都装上十来个 Chromium,让它的市场占有率飙升到 1000% 以上,但它的移植便捷性不容忽视。Bilibili-linux 这个客户端是使用纯 Javascript 实现的,软件包里除了 Electron 之外,没有任何其它的二进制文件。因此我们可以取出它的 Javascript 代码,然后直接用系统的 Electron 运行。

{ stdenv
, fetchurl
, electron
, lib
, makeWrapper
, ...
} @ args:

################################################################################
# Mostly based on bilibili-bin package from AUR:
# https://aur.archlinux.org/packages/bilibili-bin
################################################################################

stdenv.mkDerivation rec {
  pname = "bilibili";
  version = "1.2.1-1";
  src = fetchurl {
    url = "https://github.com/msojocs/bilibili-linux/releases/download/v1.2.1-1/io.github.msojocs.bilibili_1.2.1-1_amd64.deb";
    sha256 = "sha256-t/igezm0ipkOkKION8qTYGK9f6qI3c4iPuS/wWrMywQ=";
  };

  # 解压 DEB 包
  unpackPhase = ''
    ar x ${src}
    tar xf data.tar.xz
  '';

  # makeWrapper 可以自动生成一个调用其它命令的命令(也就是 wrapper),并且可以在原命令上修改参数、环境变量等
  buildInputs = [ makeWrapper ];

  installPhase = ''
    mkdir -p $out/bin

    # 替换菜单项目(desktop 文件)中的路径
    cp -r usr/share $out/share
    sed -i "s|Exec=.*|Exec=$out/bin/bilibili|" $out/share/applications/*.desktop

    # 复制出客户端的 Javascript 部分,其它的不要了
    cp -r opt/apps/io.github.msojocs.bilibili/files/bin/app $out/opt

    # 生成 bilibili 命令,运行这个命令时会调用 electron 加载客户端的 Javascript 包($out/opt/app.asar)
    makeWrapper ${electron}/bin/electron $out/bin/bilibili \
      --argv0 "bilibili" \
      --add-flags "$out/opt/app.asar"
  '';
}

中等:DingTalk(自动 Patch 二进制,查找依赖)

当然,不是所有闭源软件都用的是 Electron 方案。对于有二进制文件的闭源软件,我们就需要在二进制文件上动刀了,把它的依赖库文件全部改成 Nix store 里的库。Nixpkgs 提供了一个方便的工具 autoPatchelfHook,它会搜索软件包里的所有二进制,并修改所有的依赖路径,当有依赖路径没被满足时会自动报错,方便调试。

我们这次用的例子是 DingTalk,钉钉的 Linux 客户端,它使用 GTK 作为界面框架。由于我们一开始不知道钉钉有什么依赖,我们先编写一个大致的打包模版:

{ stdenv
, fetchurl
, autoPatchelfHook
, makeWrapper
, lib
, callPackage
, ...
} @ args:

################################################################################
# Mostly based on dingtalk-bin package from AUR:
# https://aur.archlinux.org/packages/dingtalk-bin
################################################################################

stdenv.mkDerivation rec {
  pname = "dingtalk";
  version = "1.4.0.20425";
  src = fetchurl {
    url = "https://dtapp-pub.dingtalk.com/dingtalk-desktop/xc_dingtalk_update/linux_deb/Release/com.alibabainc.dingtalk_${version}_amd64.deb";
    sha256 = "sha256-UKkFuuFK/Ae+XIWbPYYsqwS/FOJfOqm9e1i18JB8UfA=";
  };

  # autoPatchelfHook 可以自动修改二进制文件
  nativeBuildInputs = [ autoPatchelfHook makeWrapper ];

  unpackPhase = ''
    ar x ${src}
    tar xf data.tar.xz

    mv opt/apps/com.alibabainc.dingtalk/files/version version
    mv opt/apps/com.alibabainc.dingtalk/files/*-Release.* release

    # 删除一些可以用系统库替代的库文件,和没用的 exe 等文件
    rm -rf release/Resources/{i18n/tool/*.exe,qss/mac}
    rm -f release/{*.a,*.la,*.prl}
    rm -f release/dingtalk_updater
    rm -f release/libgtk-x11-2.0.so.*
    rm -f release/libm.so.*
  '';

  installPhase = ''
    mkdir -p $out
    mv version $out/

    # 有些库文件必须使用钉钉自带的版本
    mv release $out/lib

    # 这里的 desktop 文件和图标是从 AUR 拿的
    mkdir -p $out/share/applications $out/share/pixmaps
    ln -s ${./dingtalk.desktop} $out/share/applications/dingtalk.desktop
    ln -s ${./dingtalk.png} $out/share/pixmaps/dingtalk.png
  '';
}

然后尝试打包。不出所料,报错了:

# ...
> auto-patchelf failed to find all the required dependencies.
> Add the missing dependencies to --libs or use `--ignore-missing="foo.so.1 bar.so etc.so"`.
For full logs, run 'nix log /nix/store/gm3d0jm6l19ypcz6vfmv5hmx8d9iygr1-dingtalk-1.4.0.20425.drv'.

我们运行上面这行命令查看完整的日志:

# ...
error: auto-patchelf could not satisfy dependency libX11.so.6 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclient
error: auto-patchelf could not satisfy dependency libgtk-x11-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclie
nt
error: auto-patchelf could not satisfy dependency libgdk_pixbuf-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefc
lient
error: auto-patchelf could not satisfy dependency libgobject-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclie
nt
error: auto-patchelf could not satisfy dependency libglib-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclient
# ...

autoPatchelfHook 已经列出了所有缺失的库文件,接下来,我们需要一个一个查找这些库文件对应的软件包,并把它们加入软件包的依赖 buildInputs 中。你可以根据自己的经验在 NixOS Search 上查找软件包,也可以使用 nix-index,一个根据文件名搜包的工具,来加快查找速度。

最后添加完后,DingTalk 包的定义是这样的:

{ stdenv
, fetchurl
, autoPatchelfHook
, makeWrapper
, lib
, callPackage
  # DingTalk dependencies
, alsa-lib
, at-spi2-atk
, at-spi2-core
, cairo
, cups
, dbus
, e2fsprogs
, gdk-pixbuf
, glib
, gnutls
, graphite2
, gtk2
, krb5
, libdrm
, libgcrypt
, libGLU
, libpulseaudio
, libthai
, libxkbcommon
, mesa_drivers
, nspr
, nss
, rtmpdump
, udev
, util-linux
, xorg
, ...
} @ args:

################################################################################
# Mostly based on dingtalk-bin package from AUR:
# https://aur.archlinux.org/packages/dingtalk-bin
################################################################################

let
  version = "1.4.0.20425";

  # 钉钉依赖旧版本的 OpenLDAP,openldap-2_4.nix 这个定义可以在我的 NUR 找到
  openldap = callPackage ./openldap-2_4.nix { };

  libraries = [
    alsa-lib
    at-spi2-atk
    at-spi2-core
    cairo
    cups
    dbus
    e2fsprogs
    gdk-pixbuf
    glib
    gnutls
    graphite2
    gtk2
    krb5
    libdrm
    libgcrypt
    libGLU
    libpulseaudio
    libthai
    libxkbcommon
    mesa_drivers
    nspr
    nss
    openldap
    rtmpdump
    udev
    util-linux
    xorg.libICE
    xorg.libSM
    xorg.libX11
    xorg.libxcb
    xorg.libXcomposite
    xorg.libXcursor
    xorg.libXdamage
    xorg.libXext
    xorg.libXfixes
    xorg.libXi
    xorg.libXinerama
    xorg.libXmu
    xorg.libXrandr
    xorg.libXrender
    xorg.libXScrnSaver
    xorg.libXt
    xorg.libXtst
  ];
in
stdenv.mkDerivation rec {
  pname = "dingtalk";
  inherit version;
  src = fetchurl {
    url = "https://dtapp-pub.dingtalk.com/dingtalk-desktop/xc_dingtalk_update/linux_deb/Release/com.alibabainc.dingtalk_${version}_amd64.deb";
    sha256 = "sha256-UKkFuuFK/Ae+XIWbPYYsqwS/FOJfOqm9e1i18JB8UfA=";
  };

  nativeBuildInputs = [ autoPatchelfHook makeWrapper ];
  buildInputs = libraries;

  unpackPhase = ''
    ar x ${src}
    tar xf data.tar.xz

    mv opt/apps/com.alibabainc.dingtalk/files/version version
    mv opt/apps/com.alibabainc.dingtalk/files/*-Release.* release

    # Cleanup
    rm -rf release/Resources/{i18n/tool/*.exe,qss/mac}
    rm -f release/{*.a,*.la,*.prl}
    rm -f release/dingtalk_updater
    rm -f release/libgtk-x11-2.0.so.*
    rm -f release/libm.so.*
  '';

  installPhase = ''
    mkdir -p $out
    mv version $out/

    # Move libraries
    # DingTalk relies on (some of) the exact libraries it ships with
    mv release $out/lib

    # Entrypoint
    mkdir -p $out/bin
    # 因为钉钉客户端会在运行过程中动态加载库文件,所以要把所有的依赖项加入 LD_LIBRARY_PATH,让钉钉客户端能找到
    makeWrapper $out/lib/com.alibabainc.dingtalk $out/bin/dingtalk \
      --argv0 "com.alibabainc.dingtalk" \
      --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libraries}"

    # App Menu
    mkdir -p $out/share/applications $out/share/pixmaps
    ln -s ${./dingtalk.desktop} $out/share/applications/dingtalk.desktop
    ln -s ${./dingtalk.png} $out/share/pixmaps/dingtalk.png
  '';
}

困难:SVP(程序检测自身完整性,Bubblewrap)

以钉钉客户端为例的闭源软件虽然打包麻烦,需要手动查找所有的依赖库,反复测试,但至少软件本身不会给你下绊子。有些闭源软件为了防止破解,会检测自身的完整性,只要自己的二进制文件被修改就拒绝启动,例如 SVP 视频补帧软件

对于这些软件,autoPatchelfHook 自然用不了了。因此我们只能换成另一种办法:生成一个符合 FHS 标准的虚拟环境,把所有的库文件放在虚拟环境中对应的路径,然后在虚拟环境中启动软件。最常用的创建虚拟环境的软件是 Bubblewrap,它原本的用途是把软件放在沙盒中,阻止它读取敏感数据,但这个沙盒正好也可以是我们要用的虚拟环境。

我们直接来看 SVP 的打包定义:

{ stdenv
, bubblewrap
, fetchurl
  # SVP 的所有依赖
, ffmpeg
, glibc
, gnome
, lib
, libmediainfo
, libsForQt5
, libusb1
, lsof
, makeWrapper
, mpv-unwrapped
  # NVIDIA 驱动,SVP 需要调用其中的一个库来支持 N 卡光流加速
  # 在 N 卡系统上需要用户手动 override 成自己的驱动版本
  # 在非 N 卡系统上可以设置成 null
, nvidia_x11 ? null
, ocl-icd
, p7zip
, patchelf
, vapoursynth
, wrapMpv
, writeShellScript
, writeText
, xdg-utils
, xorg
, ...
}:

################################################################################
# Based on svp package from AUR:
# https://aur.archlinux.org/packages/svp
################################################################################

let
  # 打包一个加了 N 卡光流库,并开启 Vapoursynth 视频处理引擎的 MPV
  mpvForSVP = wrapMpv
    (mpv-unwrapped.override {
      vapoursynthSupport = true;
    })
    {
      extraMakeWrapperArgs = lib.optionals (nvidia_x11 != null) [
        "--prefix"
        "LD_LIBRARY_PATH"
        ":"
        "${lib.makeLibraryPath [ nvidia_x11 ]}"
      ];
    };

  # SVP 主程序的依赖
  libPath = lib.makeLibraryPath [
    libsForQt5.qtbase
    libsForQt5.qtdeclarative
    libsForQt5.qtscript
    libsForQt5.qtsvg
    libmediainfo
    libusb1
    xorg.libX11
    stdenv.cc.cc.lib
    ocl-icd
    vapoursynth
  ];

  # SVP 查找二进制程序的路径(即 PATH 环境变量)
  execPath = lib.makeBinPath [
    ffmpeg.bin
    gnome.zenity
    lsof
    xdg-utils
  ];

  svp-dist = stdenv.mkDerivation rec {
    pname = "svp-dist";
    version = "4.5.210";
    src = fetchurl {
      url = "https://www.svp-team.com/files/svp4-linux.${version}-1.tar.bz2";
      sha256 = "10q8r401wg81vanwxd7v07qrh3w70gdhgv5vmvymai0flndm63cl";
    };

    nativeBuildInputs = [ p7zip patchelf ];

    # 禁用修补步骤(Fixup phase),它会修改 SVP 二进制文件,导致完整性校验报错
    dontFixup = true;

    # 解压、安装步骤来自 AUR:https://aur.archlinux.org/packages/svp-bin
    unpackPhase = ''
      tar xf ${src}
    '';

    buildPhase = ''
      mkdir installer
      LANG=C grep --only-matching --byte-offset --binary --text  $'7z\xBC\xAF\x27\x1C' "svp4-linux-64.run" |
        cut -f1 -d: |
        while read ofs; do dd if="svp4-linux-64.run" bs=1M iflag=skip_bytes status=none skip=$ofs of="installer/bin-$ofs.7z"; done
    '';

    installPhase = ''
      mkdir -p $out/opt
      for f in "installer/"*.7z; do
        7z -bd -bb0 -y x -o"$out/opt/" "$f" || true
      done

      for SIZE in 32 48 64 128; do
        mkdir -p "$out/share/icons/hicolor/''${SIZE}x''${SIZE}/apps"
        mv "$out/opt/svp-manager4-''${SIZE}.png" "$out/share/icons/hicolor/''${SIZE}x''${SIZE}/apps/svp-manager4.png"
      done
      rm -f $out/opt/{add,remove}-menuitem.sh
    '';
  };

  # 创建一个使用 Bubblewrap 的启动脚本
  startScript = writeShellScript "SVPManager" ''
    # 除了这些路径以外,其它的根目录下的路径都映射进虚拟环境
    # 这里的有些路径不是完全不映射,而是在下面有更细粒度的映射配置
    blacklist=(/nix /dev /usr /lib /lib64 /proc)

    declare -a auto_mounts
    # loop through all directories in the root
    for dir in /*; do
      # if it is a directory and it is not in the blacklist
      if [[ -d "$dir" ]] && [[ ! "''${blacklist[@]}" =~ "$dir" ]]; then
        # add it to the mount list
        auto_mounts+=(--bind "$dir" "$dir")
      fi
    done

    # Bubblewrap 启动脚本
    cmd=(
      ${bubblewrap}/bin/bwrap
      # /dev 需要特殊的映射方式
      --dev-bind /dev /dev
      # 在虚拟环境中也切换到当前文件夹
      --chdir "$(pwd)"
      # Bubblewrap 退出时杀掉虚拟环境里的所有进程
      --die-with-parent
      # /nix 目录只读
      --ro-bind /nix /nix
      # /proc 需要特殊的映射方式
      --proc /proc
      # 把 Glibc 放到 /lib 和 /lib64,让 SVP 加载
      --bind ${glibc}/lib /lib
      --bind ${glibc}/lib /lib64
      # 一些 SVP 需要用到的命令,SVP 固定去 /usr/bin 查找这些命令
      --bind /usr/bin/env /usr/bin/env
      --bind ${ffmpeg.bin}/bin/ffmpeg /usr/bin/ffmpeg
      --bind ${lsof}/bin/lsof /usr/bin/lsof
      # 配置环境变量,包括查找命令和库的路径
      --setenv PATH "${execPath}:''${PATH}"
      --setenv LD_LIBRARY_PATH "${libPath}:''${LD_LIBRARY_PATH}"
      # 把 SVP 专用的 MPV 播放器映射过来
      --symlink ${mpvForSVP}/bin/mpv /usr/bin/mpv
      # 映射其它根目录下的路径
      "''${auto_mounts[@]}"
      # 虚拟环境启动后运行 SVP 主程序
      ${svp-dist}/opt/SVPManager "$@"
    )
    exec "''${cmd[@]}"
  '';

  # SVP 菜单项
  desktopFile = writeText "svp-manager4.desktop" ''
    [Desktop Entry]
    Version=1.0
    Encoding=UTF-8
    Name=SVP 4 Linux
    GenericName=Real time frame interpolation
    Type=Application
    Categories=Multimedia;AudioVideo;Player;Video;
    MimeType=video/x-msvideo;video/x-matroska;video/webm;video/mpeg;video/mp4;
    Terminal=false
    StartupNotify=true
    Exec=${startScript} %f
    Icon=svp-manager4.png
  '';
in
# 创建一个简单的包,只包含启动脚本和菜单项
stdenv.mkDerivation {
  pname = "svp";
  inherit (svp-dist) version;
  phases = [ "installPhase" ];
  installPhase = ''
    mkdir -p $out/bin $out/share/applications
    ln -s ${startScript} $out/bin/SVPManager
    ln -s ${desktopFile} $out/share/applications/svp-manager4.desktop
    ln -s ${svp-dist}/share/icons $out/share/icons
  '';
}

困难:WeChat-UOS(程序检测运行环境,Steam-run)

另一个会检测运行环境的是 UOS 版微信客户端。虽然它本身是一个 Electron 应用,打包应该很简单,但是它自带了一个库文件,包含有检测 UOS 系统授权文件的逻辑,检测失败就拒绝你登录。因此,我们依然需要构造一个虚拟环境,把 UOS 的授权文件放到对应的位置,才能正常使用微信。

这里展示 Nixpkgs 中的一个便捷打包工具:steam-runsteam-run 本身就是调用的 Bubblewrap,但是顾名思义,steam-run 原本是用来运行 Steam 客户端和 Steam 上的游戏的,因此它的默认环境包含了大量常用的库文件,很多闭源软件都能用它跑起来。

{ stdenv
, fetchurl
, writeShellScript
, electron
, steam
, lib
, scrot
, ...
} @ args:

################################################################################
# Mostly based on wechat-uos package from AUR:
# https://aur.archlinux.org/packages/wechat-uos
################################################################################

let
  version = "2.1.4";

  # UOS 授权文件,从 AUR 下载:https://aur.archlinux.org/packages/wechat-uos
  license = stdenv.mkDerivation rec {
    pname = "wechat-uos-license";
    version = "0.0.1";
    src = ./license.tar.gz;

    installPhase = ''
      mkdir -p $out
      cp -r etc var $out/
    '';
  };

  # 微信软件包,和 B 站客户端一样只保留 Javascript 部分和几个需要的库
  resource = stdenv.mkDerivation rec {
    pname = "wechat-uos-resource";
    inherit version;
    src = fetchurl {
      url = "https://home-store-packages.uniontech.com/appstore/pool/appstore/c/com.tencent.weixin/com.tencent.weixin_${version}_amd64.deb";
      sha256 = "sha256-V74m+dFK9/f0QoHfvIjk7hyIil6FpV9HGkPqwJLvQhM=";
    };

    unpackPhase = ''
      ar x ${src}
    '';

    installPhase = ''
      mkdir -p $out
      tar xf data.tar.xz -C $out
      mv $out/usr/* $out/
      mv $out/opt/apps/com.tencent.weixin/files/weixin/resources/app $out/lib/wechat-uos
      chmod 0644 $out/lib/license/libuosdevicea.so
      rm -rf $out/opt $out/usr

      # use system scrot
      pushd $out/lib/wechat-uos/packages/main/dist/
      sed -i 's|__dirname,"bin","scrot"|"${scrot}/bin/"|g' index.js
      popd
    '';
  };

  # 生成一个 Steam-run 虚拟环境,这里包含了 UOS 授权文件和微信软件包
  steam-run = (steam.override {
    extraPkgs = p: [ license resource ];
    runtimeOnly = true;
  }).run;

  # 微信启动脚本
  startScript = writeShellScript "wechat-uos" ''
    # 目前版本的微信在 NixOS 上无法显示托盘图标,如果关掉窗口有可能就再也找不到了
    # 因此如果微信运行着,就直接把它杀掉,这样就可以重新启动微信了
    wechat_pid=`pidof wechat-uos`
    if test $wechat_pid; then
        kill -9 $wechat_pid
    fi

    # 用 Steam-run 在虚拟环境中启动微信
    ${steam-run}/bin/steam-run \
      ${electron}/bin/electron \
      ${resource}/lib/wechat-uos
  '';
in
# 创建一个简单的包,只包含启动脚本和菜单项
stdenv.mkDerivation {
  pname = "wechat-uos";
  inherit version;
  phases = [ "installPhase" ];
  installPhase = ''
    mkdir -p $out/bin $out/share/applications
    ln -s ${startScript} $out/bin/wechat-uos
    ln -s ${./wechat-uos.desktop} $out/share/applications/wechat-uos.desktop
    ln -s ${resource}/share/icons $out/share/icons
  '';
}

steam-run 虽然好用,但因为它为了支持大量的 Steam 游戏默认引入了大量的库文件,如果只为了运行一些简单的程序,不免有些大材小用。因此我建议,对于简单的软件尽量用 Bubblewrap 手动打包,对于复杂的软件再用 steam-run

实例:特殊软件包

最后,我演示几种特殊软件的打包。

字体:Hoyo-Glyphs

NixOS 中的字体也是一个个软件包,只要把 TTF 文件放进软件包的 $out/share/fonts/opentype 文件夹就可以了。

这里我用 Hoyo-Glyphs 演示,它是一个由米哈游游戏爱好者创建的字体项目,模仿了米哈游的原神、星穹铁道、绝区零等游戏内的架空文字

{ stdenvNoCC
, lib
, fetchFromGitHub
, ...
} @ args:

# stdenvNoCC 是一个没有编译器的打包环境,毕竟我们打包字体也用不到编译器
stdenvNoCC.mkDerivation rec {
  pname = "hoyo-glyphs";
  version = "b2bf17cd3d9637fbf55c23bf46fe380e4f7e0739";
  src = fetchFromGitHub ({
    owner = "SpeedyOrc-C";
    repo = "Hoyo-Glyphs";
    rev = "b2bf17cd3d9637fbf55c23bf46fe380e4f7e0739";
    fetchSubmodules = false;
    sha256 = "sha256-7Jx/7z3QxAi7lsV3JFwUDWJUpaKOmfZyGKL3MUrUopw=";
  });

  # 查找所有的 otf 字体文件,复制到 $out/share/fonts/opentype 目录下
  # 这样做是因为 hoyo-glyphs 项目中字体散布在多个文件夹下
  installPhase = ''
    mkdir -p $out/share/fonts/opentype/
    cp font/**/*.otf $out/share/fonts/opentype/
  '';
}

最后把这个软件包加入 NixOS 的字体配置(或者 Home-Manager 的字体配置),就可以使用了:

let
  hoyo-glyphs = pkgs.callPackage ./hoyo-glyphs.nix { };
in
{
  fonts.fonts = [
    hoyo-glyphs
  ];
}

Go 软件包:Konnect

接下来我演示一下 Go 软件包的打包。Nixpkgs 提供了 buildGoModule 函数,可以几乎全自动地给 Go 语言软件打包。但是 buildGoModule 存在一个问题:由于 Go 语言程序需要联网下载 vendor 目录下的依赖,因此 buildGoModule 会计算整个 vendor 目录的校验码,这个校验码需要在打包时手动给出。

校验码不会算怎么办?老方法,先注释掉(或者随便改几个字),然后构建这个软件包,Nix 会报错,并提示你正确的校验码。

这里我演示的软件是 Konnect,一个 OpenID 单点登录服务,支持 LDAP 后端

{ fetchFromGitHub
, buildGoModule
}:

buildGoModule rec {
  pname = "konnect";
  version = "v0.34.0";
  src = fetchFromGitHub ({
    owner = "Kopano-dev";
    repo = "konnect";
    rev = "v0.34.0";
    fetchSubmodules = false;
    sha256 = "sha256-y7SD+czD/jK/m0LbFq7qGjwJgBIXfTNrdsA3pzgD2xE=";
  });
  vendorSha256 = "sha256-ZrwFUZDTbJx5qvloVOa5qK1ykKNkUn1hjfz0xf+8sWk=";
}

你不需要指定任何的编译命令,buildGoModule 会自动完成一切。

类似的,Python,NodeJS,Rust 等多种语言的项目都有它们对应的打包函数,具体用法可以在 NixOS Wiki 查找

但是不包括 Java,因为 Java 常用的 Maven 构建系统不支持把依赖固定在某一个版本,因此两次编译的依赖可能会发生变化,违反了 Nix 的初衷。

内核:linux-xanmod-lantian

最后,我演示一下如何自定义 Linux 内核。Nixpkgs 照例提供了方便的 buildLinux 函数:

{ pkgs
, stdenv
, lib
, fetchFromGitHub
, buildLinux
, ...
} @ args:

let
  version = "5.17.14";
  release = "1";
in
buildLinux {
  inherit stdenv lib version;
  src = fetchFromGitHub {
    owner = "xanmod";
    repo = "linux";
    rev = "${version}-xanmod${release}";
    sha256 = "sha256-OutD9Z/4LMT1cNmpq5fHaJZzU6iMDoj2N8GXFvXkECY=";
  };

  # 指定内核模块文件夹的名称,我修改了 CONFIG_LOCALVERSION,因此这里要一同修改
  modDirVersion = "${version}-xanmod${release}-lantian";

  # 从 config.nix 中加载配置
  structuredExtraConfig = import ./config.nix args;

  # 在内核上打的补丁,我这里打了 Nixpkgs 自带的两个补丁,和 patches 目录下的所有补丁(自动检测)
  kernelPatches = [
    pkgs.kernelPatches.bridge_stp_helper
    pkgs.kernelPatches.request_key_helper
  ] ++ (builtins.map
    (name: {
      inherit name;
      patch = ./patches + "/${name}";
    })
    (builtins.attrNames (builtins.readDir ./patches)));

  # 只允许给 x86_64 平台打包
  extraMeta.broken = !stdenv.hostPlatform.isx86_64;
}

config.nix 存放你自定义的配置,Nixpkgs 会在 NixOS 默认内核配置的基础上应用你的修改。

{ lib, ... }:

with lib.kernel;
{
  # 指定一个字符串:使用 freeform
  LOCALVERSION = freeform "-lantian";

  # 指定一个数字:也是使用 freeform,把数字写成字符串
  LOG_BUF_SHIFT = freeform "12";

  # 编译成模块:使用 module
  # 如果和默认配置冲突,可以用 lib.mkForce 强制应用
  TCP_CONG_CUBIC = lib.mkForce module;

  # 编译进内核:使用 yes
  TCP_CONG_BBR = yes;

  # 禁用:使用 no
  CRYPTO_842 = no;
}

总结

软件打包一向是困难的,在打包过程中,你往往需要考虑软件的所有依赖,并且调整参数反复尝试。相比于其它发行版,NixOS(以及 Nixpkgs)的打包看起来复杂,但实际上是比较容易的:

  • 大量的重复工作被以函数的形式自动化;
  • 打包环境与主系统隔离,不用担心系统上的残留库文件产生冲突,也不用担心少指定依赖导致其他人无法使用。

本文中我展示了常见的几种打包情况,包括开源软件和闭源软件。但因为我展示的样本很少,无法覆盖到你会遇到的所有情况,因此更多时候还是需要你去自行查阅资料:

  • NixOS Wiki 上有多种常见编程语言的打包教程,以及一些特殊情况的介绍(例如 Qt)。
  • Nixpkgs 本身就是一个大型的软件包仓库,存放了 8 万多个软件包的定义,也可以作为参考。
  • NUR 是 Nix 用户个人管理的软件包仓库,类似于 AUR。

本文所有的打包样例都来自我的 NUR 仓库