软件打包
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 的软件打包,有三个好消息和两个坏消息。好消息是:
Nixpkgs,也就是 NixOS 的软件仓库,提供了大量的打包自动化函数
对于很多使用常见编程语言的开源软件(包括 C/C++,Python,Go,Node.js,Rust 等,但不包括 Java) 只需要调用现成的函数,指定一下源码的下载方式,Nixpkgs 就能自动检测软件的打包系统,自动传入合适的参数并完成软件打包
- 对于以二进制方式分发的软件(常见于闭源软件),Nixpkgs 也提供了现成的自动化解决方案:
- 一种是 Autopatchelf,自动修改二进制文件中的库文件路径,将其指向 /nix/store 中
另一种是 Bubblewrap,或者基于 Bubblewrap 的 steam-run,模拟一个符合 FHS 标准的运行环境
顾名思义,steam-run 主要针对的是 Steam 游戏平台以及它上面的游戏,但它也可以用于其它闭源软件
Nix 包管理器会在一个隔离的环境中进行软件打包,可以粗略地理解成一个断网,限制权限,只允许访问固定路径的 Docker 容器。在编译过程中,访问外部路径或者联网的尝试全部会失败,只能使用 Nix 编译脚本中事先指定的依赖
因此,打包出来的程序将完全不依赖其它文件
坏消息是:
开发者不一定比打包者更懂 Linux
开发者可能会在代码和编译脚本里写死各种路径,做出各种只符合 FHS 标准的假设 此时就需要你手动写补丁,纠正这些路径,让程序可以在 NixOS 上正常编译运行
一旦遇到了不能使用现成函数的情况,包括下列情况,就得做好 “一杯茶,一包烟,一个 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 替换成你的仓库地址
在 inputs 的 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 :(来自 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.derivation,stdenv.mkDerivation 将打包过程分成了 7 个 步骤 Phase :
- 解压 Unpack phase:
在这一步中,stdenv.mkDerivation 会自动解压 src 参数指定的 源码包
例如 如果源码包是 .tar.gz 格式的,就会自动调用 tar xf
但 stdenv.mkDerivation 不能识别所有压缩格式,例如 .zip 就不行,需要手动指定解压命令:
nativeBuildInputs = [ unzip ]; unpackPhase = '' unzip $src '';
- stdenv.mkDerivation 要求 源码包的顶层 是一个 文件夹 ,解压完成后会自动 cd 进去
- 打补丁 patch phase:
在这一步中,stdenv.mkDerivation 会按顺序应用 patches 列表中的 所有补丁
这一步可以用来解决一部分软件和 NixOS 的不兼容问题
- 配置 configure phase:
这一步相当于运行 ./configure 或者 cmake
stdenv.mkDerivation 会自动检测打包方案并调用相应命令,或者当相应配置文件不存在时,自动跳过这一步
- 注意:要调用 cmake,需要额外加一行 nativeBuildInputs = [ cmake ]; 把 CMake 加入打包环境中
- 可以用 configureFlags 或者 cmakeFlags 添加配置参数,例如启用/禁用软件的功能。
- 编译 build phase:
- 这一步相当于运行 make 。可以用 makeFlags 添加传给 make 的参数
- 测试 test phase:
- 这一步会运行源码中自带的测试用例,以保证软件功能正确
- 可以用 doCheck = false; 禁用这一步
- 安装 install phase:
- 这一步相当于运行 make install ,将编译结果复制到 Nix store 的相应文件夹中
- 整个构建过程是在临时文件夹中,而不是 Nix store 中进行的,因此需要这一步将文件复制过去
- 当手动指定安装命令时,*目标路径* 存在变量 $out 中。$out 可以是存放有文件的文件夹,也可以直接是一个文件
- 这一步相当于运行 make install ,将编译结果复制到 Nix store 的相应文件夹中
- 额外修补 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 软件源 https://github.com/xddxdd/nur-packages
实例
开源
简单: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" ''; }
这个包主要用来展示 nativeBuildInputs 和 buildInputs 的区别:
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,让它的CPU占有率飙升到 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-run 。steam-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 文件夹就可以了
{ 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 软件包的打包 这里使用的软件是 Konnect,一个 OpenID 单点登录服务,支持 LDAP 后端
Nixpkgs 提供了 buildGoModule 函数 可以几乎全自动地给 Go 语言软件打包
但是 buildGoModule 存在一个问题:由于 Go 语言程序需要联网下载 vendor 目录下的依赖 因此 buildGoModule 会计算整个 vendor 目录的校验码,这个校验码需要在打包时手动给出
校验码不会算怎么办?老方法,先注释掉(或者随便改几个字),然后构建这个软件包,Nix 会报错,并提示正确的校验码:
{ 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
Next: 无状态 | Previous:基础配置 | Home: 实用教程 |