bitspire/nix/mkAtmApp.nix
Padreug 6568618811 fix(nix): keep only the serialport prebuild this system can load
The first ever aarch64 build of the app died here, on a Raspberry Pi 4:

  auto-patchelf could not satisfy dependency liblog.so wanted by
    node_modules/@serialport/bindings-cpp/prebuilds/android-arm64/node.napi.armv8.node
  auto-patchelf could not satisfy dependency libc++_shared.so wanted by
    (the same file)

@serialport/bindings-cpp ships prebuilds for every platform it supports:
android-arm, android-arm64, darwin, linux-arm, linux-arm64, linux-x64 in
both glibc and musl, win32-ia32 and win32-x64. installPhase copied the
whole directory.

On x86_64 that was harmless because autoPatchelf skips ELF files whose
architecture does not match the host, so the Android and ARM prebuilds
were never touched. On aarch64 the android-arm64 prebuild IS the host
architecture, so autoPatchelf picks it up and goes looking for Android's
liblog.so and libc++_shared.so, which NixOS does not have. The failure was
invisible until someone built for a second architecture.

Keep only the prebuild the target can load, selected from
stdenv.hostPlatform: linux-arm64 on aarch64, linux-x64 elsewhere, glibc
rather than musl.

Pruning rather than adding those two libraries to
autoPatchelfIgnoreMissingDeps, which would have been the one-line fix.
Teaching autoPatchelf to tolerate a binary we never load, for a platform
we do not target, leaves the foreign prebuilds in the closure and leaves
the same trap set for the next architecture. The existing
"libc.musl-x86_64.so.1" entry in that list is this same problem solved the
other way; it is now redundant, and is left in place only because this
commit is unblocking a machine mid-build and is not the moment to find out
whether something else depended on it.

Verified on x86_64: the app still builds, ships linux-x64/node.napi.glibc.node
alone where it previously carried nine platforms, and comes to 25M.
2026-09-25 12:30:25 +02:00

221 lines
9.8 KiB
Nix

# Pure Nix derivation for the Lamassu ATM Electron app.
#
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
# eliminating the need for --impure or a local pnpm install.
#
# Usage (from flake.nix):
# mkAtmApp = import ./nix/mkAtmApp.nix { inherit pkgs pkgs-unstable; src = self; };
# atm-app-douro = mkAtmApp { model = "douro"; fiatCode = "GTQ"; };
{ pkgs, pkgs-unstable, src }:
{ model, fiatCode }:
let
nodejs = pkgs-unstable.nodejs_22;
pnpm = pkgs-unstable.pnpm_9;
electron = pkgs-unstable.electron;
# Electron's Node.js headers — needed to compile native addons (better-sqlite3)
# that use V8 C++ API (not N-API). Must match the Electron version exactly.
electronHeaders = pkgs.fetchurl {
url = "https://electronjs.org/headers/v${electron.version}/node-v${electron.version}-headers.tar.gz";
hash = "sha256-xtGHm/2Sb0ILU4yUVAqXTRLwzf8yiXxD0zIIrBk8NDw=";
};
in
pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "bitspire-atm-app";
version = "0.1.0";
inherit src;
# Filter to only the workspace packages needed for @bitSpire/machine
pnpmWorkspaces = [
"@bitSpire/machine..." # "..." suffix = include transitive workspace deps
];
pnpmDeps = pkgs-unstable.fetchPnpmDeps {
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY=";
};
nativeBuildInputs = [
nodejs
pnpm
pkgs-unstable.pnpmConfigHook
# node-gyp@9.4.1 (pulled in by better-sqlite3) imports distutils, which
# Python 3.12 removed. pkgs.python3 became 3.12 in nixpkgs 24.11. Pin
# 3.11 here until pnpm-lock bumps better-sqlite3 to a node-gyp@10+ build.
pkgs.python311
pkgs.pkg-config
pkgs.autoPatchelfHook # patch .node ELF binaries
];
buildInputs = [
pkgs.sqlite.dev # better-sqlite3
pkgs.libudev-zero # serialport
pkgs.stdenv.cc.cc.lib # libstdc++
# @pokusew/pcsclite (nfc-pcsc): the `lib` output carries libpcsclite.so so
# autoPatchelf wires it into the .node RPATH at runtime. Compile/link paths
# are injected via CPATH/LIBRARY_PATH in buildPhase (its binding.gyp
# hardcodes Debian /usr paths instead of using pkg-config).
pkgs.pcsclite.lib
];
env = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
# Vite compile-time variables (baked into the frontend bundle)
VITE_LAMASSU_MACHINE_MODEL = model;
VITE_LAMASSU_FIAT_CODE = fiatCode;
};
buildPhase = ''
runHook preBuild
# Extract Electron's Node.js headers for native addon compilation.
# better-sqlite3 uses the V8 C++ API (not N-API), so it MUST be compiled
# against Electron's headers — not Node.js headers — or you get
# "undefined symbol: _ZN2v811HandleScopeC1EPNS_7IsolateE" at runtime.
electron_nodedir=$(mktemp -d)
tar -xzf ${electronHeaders} -C "$electron_nodedir" --strip-components=1
echo "=== Rebuilding better-sqlite3 against Electron ${electron.version} headers ==="
pushd node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
--nodedir="$electron_nodedir" \
--arch=x64
popd
# @pokusew/pcsclite (nfc-pcsc's native addon) — also V8 C++ API, so it too
# must be rebuilt against Electron's headers. Its binding.gyp hardcodes
# /usr/include/PCSC + /usr/lib, so point the compiler/linker at nixpkgs'
# pcsclite explicitly (winscard.h lives under include/PCSC).
echo "=== Rebuilding @pokusew/pcsclite against Electron ${electron.version} headers ==="
pushd node_modules/.pnpm/@pokusew+pcsclite@*/node_modules/@pokusew/pcsclite
CPATH="${pkgs.pcsclite.dev}/include/PCSC''${CPATH:+:$CPATH}" \
LIBRARY_PATH="${pkgs.pcsclite.lib}/lib''${LIBRARY_PATH:+:$LIBRARY_PATH}" \
HOME=$TMPDIR ${nodejs}/bin/npx --yes node-gyp rebuild \
--nodedir="$electron_nodedir" \
--arch=x64
popd
# Build the Electron app (turbo builds all workspace deps + app)
pnpm --filter="@bitSpire/machine..." build
runHook postBuild
'';
# Cherry-pick only the runtime node_modules needed by the Electron app.
# Vite bundles most JS deps — these are native addons and dynamic imports
# that can't be bundled.
installPhase = ''
runHook preInstall
mkdir -p $out/node_modules/{@lamassu,@serialport,@pokusew}
# Helper: find a package dir inside the pnpm virtual store.
# pnpm store dirs look like: node_modules/.pnpm/<name>@<ver>[_<peer-suffix>]/node_modules/<name>
# The glob must handle version + optional peer suffixes.
copy_pnpm_pkg() {
local pkg="$1" # e.g. "debug" or "@serialport/stream"
local dest="$2" # e.g. "$out/node_modules/debug"
# Convert @scope/name to @scope+name for the .pnpm dir prefix
local store_prefix="''${pkg/\//-}" # debug -> debug, @serialport/stream -> @serialport-stream
store_prefix="''${store_prefix//@/@}"
# Try globbing; sort to pick the first match deterministically
local found=$(find node_modules/.pnpm -maxdepth 1 -name "''${store_prefix}@*" -type d | sort | head -1)
if [ -z "$found" ]; then
# Fallback: scoped packages use + instead of -
store_prefix="''${pkg/\//+}"
found=$(find node_modules/.pnpm -maxdepth 1 -name "''${store_prefix}@*" -type d | sort | head -1)
fi
if [ -z "$found" ]; then
echo "ERROR: could not find pnpm store dir for $pkg" >&2
exit 1
fi
cp -rL "$found/node_modules/$pkg" "$dest"
}
# Electron app build outputs
cp -r apps/machine/dist $out/dist
cp -r apps/machine/dist-electron $out/dist-electron
cp apps/machine/package.json $out/package.json
# --- Native modules (not bundleable by Vite) ---
# better-sqlite3 (compiled native addon)
copy_pnpm_pkg better-sqlite3 $out/node_modules/better-sqlite3
copy_pnpm_pkg bindings $out/node_modules/bindings
copy_pnpm_pkg file-uri-to-path $out/node_modules/file-uri-to-path
# nfc-pcsc + @pokusew/pcsclite (Bolt Card reader). The compiled
# pcsclite.node (from the rebuild above) rides along in the package dir and
# loads via `bindings` (already copied). autoPatchelf wires libpcsclite.
copy_pnpm_pkg nfc-pcsc $out/node_modules/nfc-pcsc
copy_pnpm_pkg @pokusew/pcsclite $out/node_modules/@pokusew/pcsclite
# @bitSpire/hal (workspace package, dynamically imported for hardware access)
mkdir -p $out/node_modules/@bitSpire/hal/dist
cp -rL packages/hal/dist/* $out/node_modules/@bitSpire/hal/dist/
cp packages/hal/package.json $out/node_modules/@bitSpire/hal/package.json
# serialport (native RS232 driver chain)
copy_pnpm_pkg serialport $out/node_modules/serialport
copy_pnpm_pkg @serialport/stream $out/node_modules/@serialport/stream
# bindings-cpp: only dist/ + prebuilds/ + package.json (build/ has broken symlinks)
mkdir -p $out/node_modules/@serialport/bindings-cpp
bcpp_store=$(find node_modules/.pnpm -maxdepth 1 -name "@serialport+bindings-cpp@*" -type d | sort | head -1)
cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/dist" $out/node_modules/@serialport/bindings-cpp/dist
cp -rL "$bcpp_store/node_modules/@serialport/bindings-cpp/prebuilds" $out/node_modules/@serialport/bindings-cpp/prebuilds
cp "$bcpp_store/node_modules/@serialport/bindings-cpp/package.json" $out/node_modules/@serialport/bindings-cpp/package.json
# bindings-cpp ships prebuilds for every platform it supports: android,
# win32, darwin, and linux for several arches in both glibc and musl. Keep
# only the one this system can actually load.
#
# This is load-bearing on aarch64, not just tidiness. On x86_64 autoPatchelf
# skipped the foreign prebuilds because their ELF architecture did not match
# the host. On aarch64 the android-arm64 prebuild IS the host architecture,
# so autoPatchelf tries to patch it and fails hunting for Android's
# liblog.so and libc++_shared.so, which do not exist on NixOS. First Pi
# build died exactly there.
#
# Pruning rather than extending autoPatchelfIgnoreMissingDeps: teaching
# autoPatchelf to tolerate a binary we never load, for a platform we do not
# target, is the wrong shape of fix. The musl entry in that list below is
# the same problem solved the other way, and is now redundant.
keep_prebuild=${if pkgs.stdenv.hostPlatform.isAarch64 then "linux-arm64" else "linux-x64"}
find $out/node_modules/@serialport/bindings-cpp/prebuilds -mindepth 1 -maxdepth 1 \
! -name "$keep_prebuild" -exec rm -rf {} +
rm -f $out/node_modules/@serialport/bindings-cpp/prebuilds/*/*.musl.node
echo "serialport prebuilds kept: $(ls $out/node_modules/@serialport/bindings-cpp/prebuilds)/$(ls $out/node_modules/@serialport/bindings-cpp/prebuilds/"$keep_prebuild")"
copy_pnpm_pkg @serialport/bindings-interface $out/node_modules/@serialport/bindings-interface
copy_pnpm_pkg @serialport/binding-mock $out/node_modules/@serialport/binding-mock
# Transitive deps of serialport
copy_pnpm_pkg debug $out/node_modules/debug
copy_pnpm_pkg ms $out/node_modules/ms
copy_pnpm_pkg node-addon-api $out/node_modules/node-addon-api
copy_pnpm_pkg node-gyp-build $out/node_modules/node-gyp-build
copy_pnpm_pkg lodash-es $out/node_modules/lodash-es
# serialport parser sub-packages (barrel import requires all 10)
for parser in \
parser-byte-length parser-cctalk parser-delimiter \
parser-inter-byte-timeout parser-packet-length parser-readline \
parser-ready parser-regex parser-slip-encoder parser-spacepacket; do
copy_pnpm_pkg "@serialport/$parser" "$out/node_modules/@serialport/$parser"
done
runHook postInstall
'';
# autoPatchelfHook will scan $out for .node ELF binaries and patch their
# RPATH to find libstdc++, libudev, libsqlite3, etc.
# The musl prebuild ships alongside glibc — we only use glibc on NixOS.
autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" ];
})