nandi/jolt-nativepublic Fork 0
65c27be020b52eb87d0c0718c8cfff1869e8d2f7
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

The window nobody could unpack 121e5f1 · on 65c27be020b52eb87d0c0718c8cfff1869e8d2f7 · nandi · 21h ago
flake.nix · 498 lines · 22.1 KBNix Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# 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/<id>/packages/generic/jolt-native/<sha>/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;
      });
}