# The whole toolchain, as one closure. # # This replaces buck2 and DotSlash together, and for the same reason each of # them was here: a checkout should build on a machine with nothing installed. # DotSlash pinned five tools by digest and fetched them on first use; buck2 knew # how to run them. Nix pins the compiler, the C toolchain, the system libraries # *and* the crate graph in one lockfile, so the pinning and the running stop # being two problems. # # nix develop # the edit loop: cargo, just, everything below # nix build # all three desktop objects into result/lib # nix build .#android # the two device objects, cross-compiled # nix flake check # fmt, clippy, tests # # Every push to main publishes the same two outputs to the GitLab package # registry as tarballs rooted at lib/ and include/, so another flake can link # against them without building this one: # # inputs.jolt-native-libs = { # url = "https://gitlab.example/api/v4/projects//packages/generic/jolt-native//android-arm64-v8a.tar.gz"; # flake = false; # a plain tarball, not a flake — nix unpacks it as-is # }; # # then: ${jolt-native-libs}/lib/arm64-v8a/libjoltmoq.so # # The URL carries a commit sha and flake.lock pins the unpacked tree's narHash, # so the input is immutable from both ends; moving it is an edit plus a lock # update. There is deliberately no `latest` URL — a moving target under a # pinned hash is a lockfile that lies. The desktop tarballs are published the # same way, and there are two of them because there are two kinds of consumer: # # x86_64-linux.tar.gz the store objects, RUNPATH into the # *builder's* /nix/store. They resolve only on # a machine holding those paths, so a nix # consumer should take this repo as a flake # input and build .#libs rather than unpack it. # x86_64-linux-portable.tar.gz .#libsPortable — the same objects with their # NEEDED closure beside them and RUNPATH # $ORIGIN. Unpack it anywhere, point # LD_LIBRARY_PATH at its lib/, and dlopen # works with no nix on the machine. The GL # driver is deliberately NOT in it: that one # has to be the host's. # # What went away with buck2: third-party/rust's reindeer-generated BUCK graph # (Cargo.lock is the one dependency graph now), the RBE container and its GitLab # job, and scripts/ entire — zcc, zxx, zig-include, the .dotslash manifests and # the generator that restated them for buck. The zig sysroot in particular was # only ever standing in for a system C toolchain; nix supplies a real one, which # is also why cpal's `pipewire` feature no longer has to be off. { description = "jolt's native backends — glimmer/egui, a terminal painter, and a MoQ media plane, each behind a C ABI"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay = { url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; crane.url = "github:ipetkov/crane"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, rust-overlay, crane, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; overlays = [ (import rust-overlay) ]; # The NDK is unfree, and only the android outputs pull it in. config.allowUnfree = true; config.android_sdk.accept_license = true; }; inherit (pkgs) lib; # One toolchain for both directions. The Android std comes from the same # rustc rather than a second pinned tarball, which is what # scripts/rust-std-android.dotslash and the sysroot symlink farm in # scripts/rustc existed to stitch together by hand. rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ "rust-src" "clippy" "rustfmt" ]; targets = [ "aarch64-linux-android" ]; }; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; # crane's own cleanCargoSource keeps .rs and the manifests and drops # everything else — which here would drop the C headers each crate # publishes and vidya-core's font and emoji assets, all of which are # `include_bytes!`d or shipped beside the object. Keep them. src = lib.cleanSourceWith { src = ./.; name = "jolt-native-source"; filter = path: type: let rel = lib.removePrefix (toString ./. + "/") (toString path); in (craneLib.filterCargoSources path type) || lib.hasInfix "/include/" rel || lib.hasInfix "/assets/" rel; }; # Built by a build script, linked into the objects, or opened by them at # run time. Split out because the Android graph wants almost none of it. desktopBuildInputs = with pkgs; [ alsa-lib # cpal's ALSA host pipewire # cpal's `pipewire` feature, via libspa-sys libGL # glow/glutin libxkbcommon # winit wayland libx11 libxcursor libxi libxrandr ]; nativeBuildInputs = with pkgs; [ pkg-config # aws-lc-sys (under rustls and moq-native) configures with cmake and # generates its assembly with perl and go. cmake ninja perl go # Sets LIBCLANG_PATH and the clang resource-dir include for every # bindgen build script in the graph — v4l2r, libspa-sys, aws-lc-sys. rustPlatform.bindgenHook ]; # v4l2r's build script wants linux/videodev2.h. On a machine with kernel # headers installed that is /usr/include; on one without it is nowhere, # which is the whole reason the justfile used to reach into zig's # bundled copies. nixpkgs has them as a package. v4l2Env = { V4L2R_VIDEODEV2_H_PATH = "${pkgs.linuxHeaders}/include"; BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linuxHeaders}/include"; }; commonArgs = { inherit src; strictDeps = true; inherit nativeBuildInputs; buildInputs = desktopBuildInputs; } // v4l2Env; # Every external crate, compiled once and shared by the objects that # actually want it. # # This used to be ONE derivation for the whole workspace, and the # sharing was the point: three objects, one dependency build. What it # cost was invisible until a consumer wanted only some of them. # jolt-moq brings moq-net, iroh, quinn, rustls and aws-lc-sys behind # it -- 440 crates that nothing else here touches -- so a build of # libvidya alone still paid for the media plane. A client that has # stopped loading libjoltmoq paid for it too, which is the case that # made this worth splitting. # # Split by CONSUMER rather than per package: vidya and tui share # nearly everything, and giving them an artifact each would trade one # kind of waste for another. depsFor = { pname, packages }: craneLib.buildDepsOnly (commonArgs // { inherit pname; version = "0.1.0"; cargoExtraArgs = "--locked " + lib.concatMapStringsSep " " (p: "-p " + p) packages; }); uiArtifacts = depsFor { pname = "jolt-native-ui-deps"; packages = [ "vidya-ffi" "jolt-tui" ]; }; moqArtifacts = depsFor { pname = "jolt-native-moq-deps"; packages = [ "jolt-moq" ]; }; # Its own artifact rather than a share of the UI one, for the reason # crates/jolt-cosmic/Cargo.toml gives: libcosmic drags iced, wgpu, # cosmic-text and zbus in behind it, and none of that belongs in the # dependency build a terminal backend waits on. cosmicArtifacts = depsFor { pname = "jolt-native-cosmic-deps"; packages = [ "jolt-cosmic" ]; }; # The whole workspace, for the clippy and test runs — those do build # everything, and want the sharing this split gives up. cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { pname = "jolt-native-deps"; version = "0.1.0"; }); # cargo does not install a cdylib, so crane's default install phase — # `cargo install`, which only knows about binaries — has nothing to do. # Take the objects out of the target directory instead. # `dir` is named separately because a crate's directory and its cargo # package name are not the same thing here: vidya-ffi lives in # crates/jolt-vidya. Nothing is silenced — a header that stops being # there should fail the build rather than ship an object with no ABI # beside it. # `headers` is false for a backend that exports no C API of its own. # jolt-cosmic is one: the jolt side reaches it through jolt.ffi by # symbol name, so there is no header for an embedder to include and no # crates/jolt-cosmic/include to copy. The others keep the check that # a vanished header fails the build. soPackage = { pname, package, dir, soname, artifacts, headers ? true }: craneLib.buildPackage (commonArgs // { inherit pname; cargoArtifacts = artifacts; version = "0.1.0"; cargoExtraArgs = "--locked -p ${package}"; doCheck = false; installPhaseCommand = '' mkdir -p $out/lib cp target/release/${soname} $out/lib/ '' + lib.optionalString headers '' mkdir -p $out/include cp -r crates/${dir}/include/. $out/include/ ''; }); libvidya = soPackage { artifacts = uiArtifacts; pname = "libvidya"; package = "vidya-ffi"; dir = "jolt-vidya"; soname = "libvidya.so"; }; libjolttui = soPackage { artifacts = uiArtifacts; pname = "libjolttui"; package = "jolt-tui"; dir = "jolt-tui"; soname = "libjolttui.so"; }; libjoltmoq = soPackage { artifacts = moqArtifacts; pname = "libjoltmoq"; package = "jolt-moq"; dir = "jolt-moq"; soname = "libjoltmoq.so"; }; libjoltcosmic = soPackage { artifacts = cosmicArtifacts; pname = "libjoltcosmic"; package = "jolt-cosmic"; dir = "jolt-cosmic"; soname = "libjoltcosmic.so"; headers = false; }; # The phone. Same sources, same Cargo.lock, only the target # configuration moves — which is what //:libs-android and its pair of # configured_alias targets used to say. Build scripts and proc macros # still compile for the host; cargo arranges that on its own. androidApi = 28; androidTarget = "aarch64-linux-android"; androidComposition = pkgs.androidenv.composeAndroidPackages { includeNDK = true; ndkVersions = [ "27.2.12479018" ]; platformVersions = [ "${toString androidApi}" ]; abiVersions = [ "arm64-v8a" ]; }; ndkRoot = "${androidComposition.androidsdk}/libexec/android-sdk/ndk-bundle"; ndkBin = "${ndkRoot}/toolchains/llvm/prebuilt/linux-x86_64/bin"; # The NDK's clang is the linker rustc runs and the compiler cc-rs runs. # scripts/android-cc existed only because -Clinker takes a plain string # and so needed something with a stable name; a nix store path is one. androidCC = "${ndkBin}/clang"; androidEnv = { CARGO_BUILD_TARGET = androidTarget; "CC_${builtins.replaceStrings ["-"] ["_"] androidTarget}" = androidCC; "CXX_${builtins.replaceStrings ["-"] ["_"] androidTarget}" = "${ndkBin}/clang++"; "AR_${builtins.replaceStrings ["-"] ["_"] androidTarget}" = "${ndkBin}/llvm-ar"; CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER = androidCC; # cc-rs and clang both need the API level; rustc's target triple does # not carry one, so it is passed as a flag on every C compile and on # the link. "CFLAGS_${builtins.replaceStrings ["-"] ["_"] androidTarget}" = "--target=${androidTarget}${toString androidApi}"; "CXXFLAGS_${builtins.replaceStrings ["-"] ["_"] androidTarget}" = "--target=${androidTarget}${toString androidApi}"; CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS = "-Clink-arg=--target=${androidTarget}${toString androidApi}"; ANDROID_NDK_HOME = ndkRoot; ANDROID_NDK_ROOT = ndkRoot; }; # None of the desktop libraries cross: the camera is Camera2 over JNI # rather than V4L2, and the audio is cpal's AAudio host rather than # PipeWire or ALSA. The bindgen hook still comes along for aws-lc-sys. androidArgs = { inherit src; strictDeps = true; nativeBuildInputs = nativeBuildInputs ++ [ androidComposition.androidsdk ]; buildInputs = [ ]; doCheck = false; cargoExtraArgs = "--locked -p vidya-ffi -p jolt-moq"; } // androidEnv; androidArtifacts = craneLib.buildDepsOnly (androidArgs // { pname = "jolt-native-android-deps"; version = "0.1.0"; }); libsAndroid = craneLib.buildPackage (androidArgs // { pname = "jolt-native-android"; version = "0.1.0"; cargoArtifacts = androidArtifacts; installPhaseCommand = '' mkdir -p $out/lib/arm64-v8a cp target/${androidTarget}/release/libvidya.so $out/lib/arm64-v8a/ cp target/${androidTarget}/release/libjoltmoq.so $out/lib/arm64-v8a/ # Both objects NEEDED it — aws-lc-sys and libspa-sys pull in the # C++ runtime — and the NDK ships it as a shared library that # nothing else provides on the device. It has to ride along in the # same directory the APK packages, or dlopen fails at run time. cp ${ndkBin}/../sysroot/usr/lib/${androidTarget}/libc++_shared.so \ $out/lib/arm64-v8a/ ''; }); # The directory a consumer points LD_LIBRARY_PATH at — the same shape # `just build` leaves in target/release, and the same one //:libs used # to stage into build/lib. # # libjoltcosmic is in here now. It was left out while it was the new # backend and nothing took this attribute for it, and what that cost # was not local: `.#libs` is what CI stages and tars, so the window # every desktop consumer actually paints with was the one object never # published. frq takes libjolttui and libjoltcosmic by name out of # this flake's packages; the tarball is what a consumer WITHOUT nix # gets, and it had a retired backend in it instead. libs = pkgs.symlinkJoin { name = "jolt-native-libs"; paths = [ libvidya libjolttui libjoltmoq libjoltcosmic ]; }; # The same objects, for a machine with no /nix/store. # # `libs` is only usable under nix, and not by accident: crane links # against the closure, so every object here NEEDs sonames that resolve # through a RUNPATH naming store paths the consumer does not have. # That is the right answer for a nix consumer and the whole problem # for anyone else — a tarball of `libs` unpacked on Debian is a set of # objects dlopen fails on, which is what the x86_64-linux tarball has # quietly been. # # So: copy each object, walk its NEEDED closure, bring along every # library that closure names, and set RUNPATH to $ORIGIN so the copies # find each other wherever the tarball lands. # # Two kinds of library are deliberately NOT brought along, and the # distinction is what keeps this honest rather than a second AppImage: # # the driver stack — libGL, libEGL, libvulkan, libdrm, libgbm — which # must be the HOST's or the window does not open. This is the thing # nixGL exists to paper over, and the reason it exists is that a nix # closure carries its own Mesa. Not carrying one means not needing a # nixGL. Nothing here NEEDs them anyway: wgpu and glutin open the # driver with dlopen at run time, which is exactly the seam that lets # the host's win. # # glibc and the loader, which are the host's for the usual reason: # a newer ld.so can load an older program's libraries and not the # reverse, so bundling ours would set the floor at nixpkgs' glibc # rather than at the oldest thing we actually build against. # # libstdc++ IS bundled: it is the other way round — the C++ runtime # nixpkgs links against is routinely newer than a stable distro's, and # a missing GLIBCXX_3.4.3x is the failure this avoids. portableDenyList = [ "libGL" "libEGL" "libGLX" "libGLdispatch" "libOpenGL" "libvulkan" "libdrm" "libgbm" "libc" "libm" "libdl" "libpthread" "librt" "libresolv" "libutil" "ld-linux-x86-64" "ld-linux-aarch64" ]; libsPortable = pkgs.runCommand "jolt-native-libs-portable" { nativeBuildInputs = [ pkgs.patchelf ]; # The fallback search path. The real one is each file's OWN # RUNPATH, read below: crane records the closure the linker used, # which is complete, where a path built from the flake's inputs is # only the direct ones — it has libxcb because libxcb is a # buildInput, and not libXau, which is libxcb's. searchPath = lib.makeLibraryPath ([ libvidya libjolttui libjoltmoq libjoltcosmic ] ++ desktopBuildInputs ++ [ pkgs.stdenv.cc.cc.lib ]); deny = lib.concatStringsSep " " portableDenyList; } '' set -eu mkdir -p "$out/lib" "$out/include" cp -r ${libs}/include/. "$out/include/" for so in ${libs}/lib/*.so; do install -m 0755 "$so" "$out/lib/" done denied() { # Match on the soname up to the first ".so", so libGL.so.1 and # libGL.so are the same answer. stem=''${1%%.so*} for d in $deny; do [ "$stem" = "$d" ] && return 0; done return 1 } # Breadth-first over NEEDED, because a bundled library has NEEDED # entries of its own — libspa pulls the C++ runtime in, and a pass # that only looked at our four objects would leave it out. pending=$(ls "$out/lib") while [ -n "$pending" ]; do next="" for f in $pending; do for need in $(patchelf --print-needed "$out/lib/$f"); do [ -e "$out/lib/$need" ] && continue denied "$need" && continue found="" # The file's own RUNPATH first: it is the closure this # object was linked against, so the answer is in it unless # the library is dlopened rather than NEEDED. IFS=: read -ra dirs <<< "$(patchelf --print-rpath "$out/lib/$f"):$searchPath" for d in "''${dirs[@]}"; do if [ -e "$d/$need" ]; then found="$d/$need"; break; fi done if [ -z "$found" ]; then echo "no $need on the search path" >&2 exit 1 fi install -m 0755 -T "$found" "$out/lib/$need" next="$next $need" done done pending=$next done # One RUNPATH for the lot, and $ORIGIN rather than a directory: # the consumer decides where this unpacks, and only the loader # knows where that turned out to be. for f in "$out/lib"/*; do patchelf --set-rpath '$ORIGIN' "$f" done echo "bundled:" ls -la "$out/lib" ''; in { packages = { default = libs; android = libsAndroid; inherit libs libsPortable libvidya libjolttui libjoltmoq libjoltcosmic; }; # Just the X11 client library on LD_LIBRARY_PATH, for `jolt gfx-demo`. # The default shell would do too, but it evaluates the rust toolchain # and the crane graph to hand a Clojure program one dlopen target. devShells.gfx = pkgs.mkShell { LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.libx11 ]; }; devShells.default = pkgs.mkShell ({ packages = [ rustToolchain pkgs.just pkgs.sccache pkgs.cargo-nextest ] ++ nativeBuildInputs ++ desktopBuildInputs; # egui opens libGL and the Wayland/X11 client libraries with dlopen, # so they have to be findable at run time and not only at link time. LD_LIBRARY_PATH = lib.makeLibraryPath desktopBuildInputs; } // v4l2Env); checks = { inherit libvidya libjolttui libjoltmoq libjoltcosmic; clippy = craneLib.cargoClippy (commonArgs // { inherit cargoArtifacts; pname = "jolt-native-clippy"; cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings"; }); fmt = craneLib.cargoFmt { inherit src; pname = "jolt-native-fmt"; }; test = craneLib.cargoTest (commonArgs // { inherit cargoArtifacts; pname = "jolt-native-test"; cargoTestExtraArgs = "--workspace"; }); }; formatter = pkgs.nixpkgs-fmt; }); }