commit e6001f45f7b64607639d592e3e13c555e46a1a89 from: mtmn date: Mon Jul 27 14:40:10 2026 UTC add a void chroot builder for virittaa commit - b2fd8f182d36836b3041cc710a6545e88e0f4bd4 commit + e6001f45f7b64607639d592e3e13c555e46a1a89 blob - 9ea5b7226a083bcda854477e9cd265361ff484fb blob + 65b4df5b4b9c3ca4ad9b45bf7d7fb813e78d5a06 --- .gitignore +++ .gitignore @@ -2,3 +2,5 @@ target result zilinaplayed essentia +void-runbook.org +nix/BUILD_NOTES.md blob - 1bcac2c2dfeb8d9ac17ab25daee37af9d6a4b6fd blob + a93f4fb12587ed5af0fb43b75ba3f92df01cd7ae --- nix/BUILD_NOTES.md +++ nix/BUILD_NOTES.md @@ -101,6 +101,31 @@ was removed. `nix build` must build: - To move the pin: `nix hash path --sri --type sha256 `, or set `hash = lib.fakeHash` and read the correct value out of the build error. +## Building outside Nix (Void Linux) + +`void-build-essentia.rb` (repo root, run as root) builds libessentia in a +throwaway Void chroot and emits an installable tarball. It deliberately mirrors +this derivation: same pinned commit, same two `Network::lastCreated` patches, +same `essentia.pc` Cflags fixup. Keep the two in sync — `src/audio_processing.rs` +runs Essentia's `compute` unlocked, which is only safe against a patched build. + +`void-build-virittaa.rb` drives that script, installs the resulting essentia +into the same chroot, then builds virittaa and emits a relocatable bundle +(binary + private libs, `$ORIGIN/../lib` rpath), optionally unpacking it into +the invoking user's `~/.local` and smoke-testing it. It applies the same +essentia_sys eigen include-path patch the flake does. + +A fully static binary is not on the table: Void ships no static +ffmpeg/taglib/chromaprint/fftw, and a statically linked glibc breaks NSS at +runtime. The `$ORIGIN` bundle is the portable substitute, and unlike the flake's +`wrapProgram` it needs no `LD_LIBRARY_PATH`. + +Void specifics: `ffmpeg6-devel`, not `ffmpeg-devel` (still 4.4.x); `eigen` is +headers-only with no `-devel`; `zlib-devel` is needed because taglib's +pkg-config `Libs` pulls in `-lz`; `rust`/`cargo` are 1.97, new enough for +edition 2024. Whichever ffmpeg virittaa links must be the one essentia was +built against. + ## Current status - [x] Rust integration done (`src/audio_processing.rs` uses blob - 9f3f01db74fa8a2f813650dea7f2067e2c6937a6 (mode 644) blob + 2365721f10755df280269d44b72f4926bf08b124 (mode 755) --- void-build-essentia.rb +++ void-build-essentia.rb @@ -1,32 +1,78 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Build libessentia in a throwaway Void Linux chroot and emit an installable +# tarball. +# +# Essentia is not in the Void repos, and virittaa needs it as a *library* +# (headers + libessentia.so + essentia.pc), not the prebuilt extractor binary. +# This mirrors what nix/essentia.nix does, so a Void build lines up with the +# Nix one: same pinned master commit, same two upstream patches, same +# essentia.pc fixup. + require "fileutils" require "open3" require "etc" class Builder REPO = "https://repo-default.voidlinux.org/current" + + # The last tag, v2.1_beta5, needs libavresample (gone since ffmpeg 5) and + # bundles waf 2.0.10, which cannot run on Python 3.12+. Master resamples with + # libswresample and bundles waf 2.0.25, which runs fine on Void's python3. + REV = "b9fa6cb674ca43dfb94d28d293aeda441c6745db" + + # ffmpeg6-devel rather than ffmpeg-devel: the latter is still 4.4.x. Either + # provides libswresample, but whatever is chosen here must match what virittaa + # itself links against. eigen is headers-only (no -devel package). zlib-devel + # is required because taglib's pkg-config Libs pulls in -lz. DEPS = %w[ - cmake - make - gcc - qt5-devel - qt5-svg-devel - qt5-multimedia-devel - qt5-declarative-devel - qt5-webkit-devel - hunspell-devel - gstreamer1-devel - libxml2-devel - qt5-tools + base-devel + python3 + pkg-config + eigen + fftw-devel + ffmpeg6-devel + libsamplerate-devel + taglib-devel + chromaprint-devel + libyaml-devel + zlib-devel ] .freeze + # `scheduler::Network::lastCreated` is a process-global pointer written by + # every Network constructor/destructor and read on every `Network::run()`. + # MonoLoader, RhythmExtractor2013 and KeyExtractor each build an inner + # Network, so analysing on more than one thread corrupts it and aborts with a + # double free. It is only a debug convenience, so make it per-thread. The + # third hunk fixes the null dereference on the line after the warning that + # says the pointer is null. Both are worth reporting upstream. + PATCHES = [ + [ + "src/essentia/scheduler/network.h", + "static Network* lastCreated;", + "static thread_local Network* lastCreated;" + ], + [ + "src/essentia/scheduler/network.cpp", + "Network* Network::lastCreated = 0;", + "thread_local Network* Network::lastCreated = 0;" + ], + [ + "src/essentia/scheduler/network.cpp", + 'E_WARNING("No network created, or last created network has been deleted...");', + 'E_WARNING("No network created, or last created network has been deleted..."); return;' + ] + ].freeze + def initialize(argv) @outdir = nil - @chroot = "/var/chroot/otter-build" - @keep = false + @chroot = "/var/chroot/essentia-build" + @rev = REV + @src = nil + @prefix = "/usr" + @delete = false @quiet = false parse(argv) validate @@ -37,11 +83,13 @@ class Builder mount_vfs prep_chroot install_deps + stage_source + patch_source build extract ensure unmount_vfs - remove_chroot unless @keep + remove_chroot if @delete msg("done.") end @@ -55,8 +103,14 @@ class Builder @outdir = argv[i += 1] when "-c" @chroot = argv[i += 1] - when "-k" - @keep = true + when "-r" + @rev = argv[i += 1] + when "-s" + @src = argv[i += 1] + when "-p" + @prefix = argv[i += 1] + when "-d" + @delete = true when "-q" @quiet = true when "-h", "--help" @@ -71,11 +125,14 @@ class Builder def usage_lines [ - "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-k] [-q]", - " -o DIR output directory (default: invoking user home)", - " -c DIR chroot directory (default: /var/chroot/otter-build)", - " -k keep chroot after build", - " -q quiet" + "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-r REV] [-s DIR] [-p PREFIX] [-d] [-q]", + " -o DIR output directory (default: invoking user home)", + " -c DIR chroot directory (default: /var/chroot/essentia-build)", + " -r REV essentia git revision (default: #{REV[0, 12]})", + " -s DIR build this source tree instead of fetching REV", + " -p PREFIX install prefix inside the tarball (default: /usr)", + " -d delete chroot after build (default: keep)", + " -q quiet" ] end @@ -90,23 +147,35 @@ class Builder die("must run as root") end - @src = Dir.pwd - die("no CMakeLists.txt in #{@src}") unless File.file?(File.join(@src, "CMakeLists.txt")) + if @src + @src = File.expand_path(@src) + die("no wscript in #{@src}") unless File.file?(File.join(@src, "wscript")) + die("#{@src} does not look like essentia") unless File.directory?(File.join(@src, "src/essentia")) + end inv = ENV["SUDO_USER"] || ENV["DOAS_USER"] if inv - @pw = Etc.getpwnam(inv) rescue nil + @pw = begin + Etc.getpwnam(inv) + rescue + nil + end die("cannot resolve user #{inv}") unless @pw @outdir ||= @pw.dir else @outdir ||= File.expand_path("~") end + + @build_root = File.join(@chroot, "src/essentia") + @stage = File.join(@chroot, "staging") end - def msg(s) = puts(s) unless @quiet + def msg(s) + puts(s) unless @quiet + end def die(s) - $stderr.puts("#{File.basename($0)}: #{s}") + warn("#{File.basename($0)}: #{s}") exit(1) end @@ -117,9 +186,15 @@ class Builder st.success? end - def chroot_cmd(script) - msg("chroot #{@chroot} /bin/bash -c '#{script}'") - die("chroot command failed") unless system("chroot #{@chroot} /bin/bash -c '#{script}'") + # Run a shell snippet inside the chroot by dropping it in a file and invoking + # bash on it, rather than interpolating into a quoted -c string. Keeps the + # build scripts free to use quotes, $(...) and newlines. + def chroot_sh(label, script) + path = File.join(@chroot, "tmp/step.sh") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "set -e\n#{script}") + msg(label) + die("#{label} failed") unless system("chroot", @chroot, "/bin/bash", "/tmp/step.sh") end def bootstrap @@ -127,15 +202,18 @@ class Builder return if File.executable?(File.join(@chroot, "bin/bash")) msg("bootstrapping chroot...") - kdir = File.join(@chroot, "var/db/xbps/keys") - FileUtils.mkdir_p(kdir) - Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } - + copy_keys run_cmd("xbps-install -Syu -R #{REPO} -r #{@chroot} base-voidstrap", fatal: false) die("bootstrap failed") unless File.executable?(File.join(@chroot, "bin/bash")) msg("bootstrap ok") end + def copy_keys + kdir = File.join(@chroot, "var/db/xbps/keys") + FileUtils.mkdir_p(kdir) + Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + end + def mount_vfs mounts = { File.join(@chroot, "proc") => ["-t", "proc", "proc"], @@ -155,43 +233,95 @@ class Builder def prep_chroot FileUtils.cp("/etc/resolv.conf", File.join(@chroot, "etc/resolv.conf")) - kdir = File.join(@chroot, "var/db/xbps/keys") - FileUtils.mkdir_p(kdir) - Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + copy_keys end def install_deps - chroot_cmd("xbps-install -Syu") - chroot_cmd("xbps-install -S #{DEPS.join(" ")}") + chroot_sh("installing deps", <<~SH) + xbps-install -Syu + xbps-install -Sy #{DEPS.join(" ")} + SH end + # Fetched on the host rather than in the chroot, so the chroot needs neither + # curl nor ca-certificates. + def stage_source + FileUtils.rm_rf(@build_root) + FileUtils.mkdir_p(File.dirname(@build_root)) + + if @src + msg("copying #{@src}") + system("cp", "-a", @src, @build_root) || die("copy failed") + return + end + + tarball = File.join(@chroot, "tmp/essentia.tar.gz") + FileUtils.mkdir_p(File.dirname(tarball)) + url = "https://github.com/MTG/essentia/archive/#{@rev}.tar.gz" + run_cmd("curl -fsSL -o #{tarball} #{url}") + + FileUtils.mkdir_p(@build_root) + run_cmd("tar xzf #{tarball} -C #{@build_root} --strip-components=1") + FileUtils.rm_f(tarball) + end + + # Applied on the host, on the copied tree. Idempotent, and fatal if a pattern + # has moved -- silently skipping would produce a library that crashes only + # under concurrency, which is exactly the failure this prevents. + def patch_source + PATCHES.each do |rel, from, to| + path = File.join(@build_root, rel) + die("missing #{rel}") unless File.file?(path) + + txt = File.read(path) + if txt.include?(to) + msg("already patched: #{rel}") + next + end + die("#{rel}: pattern not found: #{from}") unless txt.include?(from) + + File.write(path, txt.sub(from, to)) + msg("patched #{rel}") + end + end + def build - dst = File.join(@chroot, "src/otter-browser") - FileUtils.rm_rf(dst) - FileUtils.mkdir_p(File.dirname(dst)) - system("cp -a #{@src} #{dst}") || die("copy failed") + FileUtils.rm_rf(@stage) + chroot_sh("building essentia", <<~SH) + export HOME=/tmp + cd /src/essentia + python3 waf configure --prefix=#{@prefix} + python3 waf -j$(nproc) + python3 waf install --destdir=/staging + SH - cm = File.join(dst, "CMakeLists.txt") - txt = File.read(cm) - unless txt.include?("FATAL_ON_MISSING_REQUIRED_PACKAGES") - die("CMakeLists.txt: FATAL_ON_MISSING_REQUIRED_PACKAGES not found") - end + lib = File.join(@stage, @prefix.delete_prefix("/"), "lib/libessentia.so") + die("libessentia.so not found at #{lib}") unless File.file?(lib) - txt.gsub!("FATAL_ON_MISSING_REQUIRED_PACKAGES", "") - File.write(cm, txt) + fix_pkgconfig + end - script = "set -e; cd /src/otter-browser; rm -rf build; mkdir build; cd build; cmake -DENABLE_QT5=ON ../; make -j$(nproc)" - chroot_cmd(script) + # essentia.pc lists only the per-subdirectory include paths, so the + # `#include ` that essentia-rs's bridge emits does not resolve. + # Add the parent include dir, exactly as nix/essentia.nix does. + def fix_pkgconfig + pc = File.join(@stage, @prefix.delete_prefix("/"), "lib/pkgconfig/essentia.pc") + die("essentia.pc not found at #{pc}") unless File.file?(pc) + + txt = File.read(pc) + from = "Cflags: -I${includedir}/essentia" + to = "Cflags: -I${includedir} -I${includedir}/essentia" + return if txt.include?(to) + die("essentia.pc: Cflags line not found") unless txt.include?(from) + + File.write(pc, txt.sub(from, to)) + msg("patched essentia.pc") end def extract - bin = File.join(@chroot, "src/otter-browser/build/otter-browser") - die("binary not found") unless File.file?(bin) + out = File.join(@outdir, "essentia-#{@rev[0, 12]}-#{`uname -m`.strip}.tar.gz") + run_cmd("tar czf #{out} -C #{@stage} .") - out = File.join(@outdir, "otter-browser") - system("cp #{bin} #{out}") || die("copy binary failed") - FileUtils.chmod("+x", out) - if @pw begin File.chown(@pw.uid, @pw.gid, out) @@ -200,7 +330,8 @@ class Builder end end - msg("binary: #{out}") + msg("tarball: #{out}") + msg("install with: tar xzf #{out} -C /") end def remove_chroot blob - /dev/null blob + 473cf300aa7e7a51d4a25f264be3ce77c32edfd1 (mode 755) --- /dev/null +++ void-build-virittaa.rb @@ -0,0 +1,437 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Build virittaa in a throwaway Void Linux chroot and install a relocatable +# bundle into the invoking user's ~/.local. +# +# Drives void-build-essentia.rb first (libessentia is not in the Void repos), +# installs the result into the chroot, then builds virittaa against it and +# bundles the binary with its non-glibc shared libraries. +# +# On "static": a fully static binary is not achievable here. Void ships no +# static ffmpeg/taglib/chromaprint/fftw, and statically linking glibc breaks +# NSS at runtime. What this produces instead is a *relocatable* bundle -- the +# binary plus its private libs, wired up with an $ORIGIN rpath -- which runs +# from any directory without LD_LIBRARY_PATH and without installing essentia +# system-wide. + +require "fileutils" +require "open3" +require "etc" + +class Builder + ESSENTIA_SCRIPT = "void-build-essentia.rb" + + DEPS = %w[ + rust + cargo + git + clang + sqlite-devel + patchelf + ] + .freeze + + # Only what cargo needs. The virittaa working directory may also hold a music + # library, which must not be copied into the chroot. + SRC_ITEMS = %w[Cargo.toml Cargo.lock src .cargo].freeze + + # Provided by the host's glibc and loader; bundling these is what breaks a + # relocatable tree rather than what saves it. + SYSTEM_LIBS = %w[ + linux-vdso.so.1 + ld-linux-x86-64.so.2 + libc.so.6 + libm.so.6 + libpthread.so.0 + libdl.so.2 + librt.so.1 + libresolv.so.2 + ].freeze + + def initialize(argv) + @outdir = nil + @chroot = "/var/chroot/virittaa-build" + @src = Dir.pwd + @essentia_rs = nil + @rev = nil + @install = true + @delete = false + @quiet = false + parse(argv) + validate + end + + def run + build_essentia + # The essentia builder unmounts the vfs on its way out; cargo and rustc + # need /dev/null and /proc, so put them back. + mount_vfs + install_essentia + install_deps + stage_source + build + bundle + package + install_local if @install + ensure + unmount_vfs + remove_chroot if @delete + msg("done.") + end + + private + + def parse(argv) + i = 0 + while i < argv.length + case argv[i] + when "-o" + @outdir = argv[i += 1] + when "-c" + @chroot = argv[i += 1] + when "-s" + @src = argv[i += 1] + when "-e" + @essentia_rs = argv[i += 1] + when "-r" + @rev = argv[i += 1] + when "-n" + @install = false + when "-d" + @delete = true + when "-q" + @quiet = true + when "-h", "--help" + usage(code: 0) + else + usage + end + + i += 1 + end + end + + def usage_lines + [ + "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-s DIR] [-e DIR] [-r REV] [-n] [-d] [-q]", + " -o DIR output directory for the tarball (default: invoking user home)", + " -c DIR chroot directory (default: /var/chroot/virittaa-build)", + " -s DIR virittaa source (default: current directory)", + " -e DIR essentia-rs source (default: SRC/../essentia-rs)", + " -r REV essentia git revision (passed to #{ESSENTIA_SCRIPT})", + " -n build only, do not install into ~/.local", + " -d delete chroot after build (default: keep)", + " -q quiet" + ] + end + + def usage(code: 1) + usage_lines.each { |l| puts(l) } + exit(code) + end + + def validate + unless Process.uid.zero? + usage_lines.each { |l| puts(l) } + die("must run as root") + end + + @src = File.expand_path(@src) + die("no Cargo.toml in #{@src}") unless File.file?(File.join(@src, "Cargo.toml")) + + @essentia_script = File.join(@src, ESSENTIA_SCRIPT) + die("#{ESSENTIA_SCRIPT} not found next to the source") unless File.file?(@essentia_script) + + @essentia_rs = File.expand_path(@essentia_rs || File.join(@src, "..", "essentia-rs")) + unless File.file?(File.join(@essentia_rs, "essentia_sys", "build.rs")) + die("no essentia-rs checkout at #{@essentia_rs} (use -e DIR)") + end + + inv = ENV["SUDO_USER"] || ENV["DOAS_USER"] + if inv + @pw = begin + Etc.getpwnam(inv) + rescue + nil + end + die("cannot resolve user #{inv}") unless @pw + @outdir ||= @pw.dir + else + @outdir ||= File.expand_path("~") + @install = false if @install && !@pw + end + + @build_root = File.join(@chroot, "src/virittaa") + @dist = File.join(@chroot, "dist/virittaa") + @binary = File.join(@build_root, "target/release/virittaa") + end + + def msg(s) + puts(s) unless @quiet + end + + def die(s) + warn("#{File.basename($0)}: #{s}") + exit(1) + end + + def run_cmd(cmd, fatal: true) + msg(cmd) + _o, e, st = Open3.capture3(cmd) + die("#{cmd}: #{e.strip}") if !st.success? && fatal + st.success? + end + + def chroot_sh(label, script) + path = File.join(@chroot, "tmp/step.sh") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "set -e\n#{script}") + msg(label) + die("#{label} failed") unless system("chroot", @chroot, "/bin/bash", "/tmp/step.sh") + end + + def chroot_capture(script) + path = File.join(@chroot, "tmp/query.sh") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "set -e\n#{script}") + out, _e, st = Open3.capture3("chroot", @chroot, "/bin/bash", "/tmp/query.sh") + die("chroot query failed") unless st.success? + out + end + + # Copy a tree without dragging in build output (or, for the virittaa source, + # anything not on the allowlist). + def copy_tree(src, dst, items: nil, excludes: %w[./target]) + FileUtils.mkdir_p(dst) + if items + items.each do |item| + path = File.join(src, item) + next unless File.exist?(path) + system("cp", "-a", path, dst) || die("copy #{item} failed") + end + else + args = excludes.map { |e| "--exclude=#{e}" }.join(" ") + run_cmd("tar -C #{src} -cf - #{args} . | tar -C #{dst} -xf -") + end + end + + # The essentia builder owns bootstrapping the chroot; it keeps it by default, + # and its tarball lands in the chroot's own tmp. -d is deliberately never + # passed down: this script shares that chroot and tears it down itself. + def build_essentia + FileUtils.mkdir_p(File.join(@chroot, "tmp")) + cmd = ["ruby", @essentia_script, "-c", @chroot, "-o", File.join(@chroot, "tmp")] + cmd += ["-r", @rev] if @rev + cmd << "-q" if @quiet + + msg(cmd.join(" ")) + die("essentia build failed") unless system(*cmd) + end + + def install_essentia + tarballs = Dir.glob(File.join(@chroot, "tmp/essentia-*.tar.gz")) + die("no essentia tarball produced") if tarballs.empty? + inner = "/tmp/#{File.basename(tarballs.max_by { |f| File.mtime(f) })}" + + chroot_sh("installing essentia into chroot", <<~SH) + tar xzf #{inner} -C / + ldconfig + pkg-config --exists essentia + echo "essentia $(pkg-config --modversion essentia) ok" + SH + end + + def install_deps + chroot_sh("installing rust toolchain and build deps", <<~SH) + xbps-install -Sy #{DEPS.join(" ")} + SH + end + + # The source is re-staged from scratch every run, but the chroot now persists + # by default, so carry target/ across so a rebuild is incremental rather than + # recompiling the whole dependency tree. + def stage_source + target = File.join(@build_root, "target") + cache = File.join(@chroot, "src/target-cache") + if File.directory?(target) + FileUtils.rm_rf(cache) + FileUtils.mv(target, cache) + msg("preserving cargo target/ for incremental rebuild") + end + + FileUtils.rm_rf(@build_root) + FileUtils.mkdir_p(@build_root) + FileUtils.mv(cache, target) if File.directory?(cache) + + msg("staging virittaa source") + copy_tree(@src, @build_root, items: SRC_ITEMS) + + vendor = File.join(@build_root, "vendor-essentia-rs") + msg("staging essentia-rs from #{@essentia_rs}") + copy_tree(@essentia_rs, vendor) + + rewrite_path_dep + patch_eigen_include + end + + # The sibling path dependency does not exist inside the chroot. + def rewrite_path_dep + manifest = File.join(@build_root, "Cargo.toml") + txt = File.read(manifest) + from = "../essentia-rs/essentia" + to = "vendor-essentia-rs/essentia" + return if txt.include?(to) + die("Cargo.toml: path dependency #{from} not found") unless txt.include?(from) + + File.write(manifest, txt.sub(from, to)) + msg("rewrote essentia path dependency") + end + + # essentia_sys's build.rs appends "eigen3" to every include path eigen3.pc + # reports. Void's eigen3.pc already points at .../include/eigen3, so the + # bridge would look in include/eigen3/eigen3 and fail on + # . Same fix the flake applies; drop this + # once essentia-rs handles both layouts. + def patch_eigen_include + path = File.join(@build_root, "vendor-essentia-rs/essentia_sys/build.rs") + die("missing vendored essentia_sys/build.rs") unless File.file?(path) + + txt = File.read(path) + from = 'if library.name == "eigen3" {' + to = 'if library.name == "eigen3" && !include_path.join("Eigen").is_dir() {' + return if txt.include?(to) + die("essentia_sys/build.rs: eigen guard not found") unless txt.include?(from) + + File.write(path, txt.sub(from, to)) + msg("patched essentia_sys eigen include path") + end + + def build + chroot_sh("building virittaa (this takes a while)", <<~SH) + export HOME=/tmp + export CARGO_HOME=/tmp/cargo + export USE_TENSORFLOW=0 + export LIBCLANG_PATH=/usr/lib + cd /src/virittaa + cargo build --release --locked + SH + + die("virittaa binary not found") unless File.file?(@binary) + end + + # Copy the binary plus every non-glibc shared library it resolves, then point + # its rpath at the private lib directory. $ORIGIN is resolved by the loader + # relative to the real path of the binary, so the tree can be moved or + # symlinked into ~/.local/bin without breaking. + def bundle + FileUtils.rm_rf(@dist) + FileUtils.mkdir_p(File.join(@dist, "bin")) + FileUtils.mkdir_p(File.join(@dist, "lib")) + + FileUtils.cp(@binary, File.join(@dist, "bin/virittaa")) + + ldd = chroot_capture("ldd /src/virittaa/target/release/virittaa") + + missing = ldd.lines.filter_map { |l| l[/^\s*(\S+)\s+=>\s+not found/, 1] } + die("unresolved libraries: #{missing.join(", ")}") unless missing.empty? + + libs = ldd + .lines + .filter_map { |l| l[%r{=>\s+(/\S+)}, 1] } + .uniq + .reject { |p| SYSTEM_LIBS.include?(File.basename(p)) } + + die("ldd resolved no libraries") if libs.empty? + + libs.each do |lib| + host_path = File.join(@chroot, lib.delete_prefix("/")) + next unless File.file?(host_path) + FileUtils.cp(host_path, File.join(@dist, "lib", File.basename(lib))) + end + msg("bundled #{libs.length} libraries") + + chroot_sh("setting rpaths", <<~SH) + patchelf --set-rpath '$ORIGIN/../lib' /dist/virittaa/bin/virittaa + for so in /dist/virittaa/lib/*.so*; do + patchelf --set-rpath '$ORIGIN' "$so" 2>/dev/null || true + done + /dist/virittaa/bin/virittaa --help > /dev/null + echo "in-chroot smoke test ok" + SH + end + + def package + @tarball = File.join(@outdir, "virittaa-#{version}-#{`uname -m`.strip}.tar.gz") + run_cmd("tar czf #{@tarball} -C #{File.dirname(@dist)} virittaa") + chown_to_user(@tarball) + msg("tarball: #{@tarball}") + end + + def version + File.read(File.join(@src, "Cargo.toml"))[/^version\s*=\s*"([^"]+)"/, 1] || "0" + end + + # Unpack into the invoking user's ~/.local and prove it runs outside the + # chroot, which is the whole point of the $ORIGIN rpath. + def install_local + target = File.join(@pw.dir, ".local/lib/virittaa") + bindir = File.join(@pw.dir, ".local/bin") + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.mkdir_p(bindir) + FileUtils.rm_rf(target) + + run_cmd("tar xzf #{@tarball} -C #{File.dirname(target)}") + + link = File.join(bindir, "virittaa") + FileUtils.rm_f(link) + FileUtils.ln_s(File.join(target, "bin/virittaa"), link) + + chown_recursive(target) + chown_to_user(link) + + out, _e, st = Open3.capture3(link, "--help") + die("installed binary does not run") unless st.success? + msg(out.lines.first.to_s.strip) + msg("installed: #{link}") + msg("add ~/.local/bin to PATH if it is not there already") + end + + def chown_to_user(path) + return unless @pw + File.lchown(@pw.uid, @pw.gid, path) + rescue => e + msg("chown failed: #{e}") + end + + def chown_recursive(dir) + return unless @pw + FileUtils.chown_R(@pw.uid, @pw.gid, dir) + rescue => e + msg("chown failed: #{e}") + end + + def mount_vfs + mounts = { + File.join(@chroot, "proc") => ["-t", "proc", "proc"], + File.join(@chroot, "sys") => ["-t", "sysfs", "sys"], + File.join(@chroot, "dev") => ["--bind", "/dev"] + } + mounts.each do |dst, opts| + FileUtils.mkdir_p(dst) + next if system("mountpoint -q #{dst} 2>/dev/null") + system("mount #{opts.join(" ")} #{dst}") + end + end + + def unmount_vfs + %w[dev sys proc].each { |n| system("umount #{File.join(@chroot, n)} 2>/dev/null") } + end + + def remove_chroot + msg("removing #{@chroot}...") + FileUtils.rm_rf(@chroot) + end +end + +Builder.new(ARGV).run