Files
logos-module-builder/lib/mkLogosModule.nix
Dario Gabriel LipicarandClaude Opus 5 9259fe86a1 fix: a protocol version that will not parse must not silently vanish
A Rust module's EXPORT SET is decided by the --protocol-version string:
lidl-gen gates logos_module_grant_host_services on >= 0.3 and the teardown
pair on >= 0.5. It was passed as

    ${lib.optionalString (protocolVersion != null) "--protocol-version ..."}

and that does not make a bad version WRONG — it makes the flag VANISH.
lidl-gen then falls back to "0.1.0", emits the seven founding exports, and
exits 0. Every Rust module in the workspace would quietly regenerate
incomplete, link cleanly, and fail at dlopen() on Linux with an undefined
symbol — invisible on macOS, three repos away from the cause, and reported
by the runtime as a module that LOADED.

protocolVersion goes null in two different situations and they are two
different bugs, so they now throw with two different messages: no
logos-protocol input at all (a caller error), versus a header that did not
parse (a bug in the regex right above it). Neither is a thing to fall back
from.

checks.module-impl-abi-nm (#207) already DETECTS this by reading the built
plugin's symbol table. This is the other half — refuse at the point of the
mistake, so it never reaches a build. Metadata stamping is untouched: null
there legitimately means "pre-protocol, load as legacy".

Proven: renaming the macro in the split pattern used to yield a
silently-broken plugin. It now yields

    error: logos-module-builder: could not read LOGOS_PROTOCOL_VERSION_STRING
    from /nix/store/...-source/cpp/logos_protocol.h, needed to generate the
    Rust cdylib scaffold for 'rust_native_dep_module'. The header moved or
    changed shape — fix the parse above; do NOT let it fall back, because
    the fallback silently emits an incomplete module-impl C ABI.

Also deletes a dead duplicate of the same parse. It sat in the devShells
block with no consumer anywhere in it — an exhaustive grep finds exactly
four mentions of protocolVersion: this definition, the two uses in
`packages`, and that orphan. A duplicated, half-dead parse of the value
this whole failure mode hinges on is not something to leave lying around.

All eight checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:27:11 -03:00

1092 lines
57 KiB
Nix

# Core module builder function
# This is the main entry point for building Logos modules.
# Plugin compilation and header generation are delegated to a backend selected
# by metadata.json "type": core modules use coreBackend, UI modules use uiBackend.
{ nixpkgs, lib, common, parseMetadata, builderRoot, uiBackend, coreBackend, logos-cpp-sdk, logos-protocol ? null, logos-qt-sdk ? null, logos-plugin-qt ? null, logos-view-module, logos-module, logos-test-framework, logos-rust-sdk ? null, nix-bundle-lgx, nix-bundle-logos-module-install, logos-standalone-app, rust-overlay ? null }:
{
# Required: Path to the module source
src,
# Required: Path to the metadata.json configuration file
configFile,
# Optional: all flake inputs — dependencies in metadata.json are resolved automatically
flakeInputs ? {},
# Optional: Additional flake inputs for external libraries
externalLibInputs ? {},
# Optional: Extra build inputs to add
extraBuildInputs ? [],
# Optional: Extra native build inputs to add
extraNativeBuildInputs ? [],
# Optional: Extra inputs/env for the Rust crate compile (cdylib modules).
# Programmatic escape hatch complementing metadata `nix.rust` — for arbitrary
# derivations or store-path env that can't be named by a nixpkgs attr path.
# Merged on top of the metadata-declared inputs (rustEnv wins on key conflict).
rustExtraNativeBuildInputs ? [],
rustExtraBuildInputs ? [],
rustEnv ? {},
# Optional: Override any config values
configOverrides ? {},
# Optional: Custom preConfigure hook
preConfigure ? "",
# Optional: Custom postInstall hook
postInstall ? "",
# Optional: override the logos-standalone-app used for `nix run`.
# By default, UI modules (type = "ui") automatically get apps.default wired up
# using the standalone app bundled with logos-module-builder.
logosStandalone ? null,
# Optional: Unit test configuration. When provided, a checks.<system>.unit-tests
# output is automatically generated using logos-test-framework.
# tests = {
# dir = ./tests; # Required: directory containing test sources + CMakeLists.txt
# mockCLibs = []; # Optional: C libraries to mock at link time
# preConfigure = ""; # Optional: custom preConfigure hook
# extraBuildInputs = [];
# extraCmakeFlags = [];
# };
tests ? null,
}:
let
# Parse the module configuration
rawConfig = parseMetadata.parseModuleConfig (builtins.readFile configFile);
config = common.recursiveMerge [ rawConfig configOverrides ];
# Select backend based on module type: core modules are swappable, UI stays Qt
selectedBackend =
if config.type == "core" then coreBackend
else uiBackend;
# Import sub-builders (backend-agnostic)
mkExternalLib = import ./mkExternalLib.nix { inherit lib common; };
mkStandaloneApp = import ./mkStandaloneApp.nix;
modulePreConfigure = import ./modulePreConfigure.nix { inherit lib; };
# cmake/LogosModule.cmake lives HERE and nowhere else — logos-plugin-qt used
# to ship a second copy, and this was a `pathExists` probe whose miss handed
# the build to that copy instead. There is nothing to fall back to now, so a
# miss throws rather than silently configuring against another file.
builderCmakeRoot =
if builtins.pathExists (builderRoot + "/cmake/LogosModule.cmake")
then "${builderRoot}"
else throw ("logos-module-builder: cmake/LogosModule.cmake is missing from "
+ "${toString builderRoot}. It is the only copy; no backend ships one.");
# Helper to get a package from nixpkgs by name
getPkg = pkgs: name:
let evaluatedName = builtins.seq name name;
in if builtins.isString evaluatedName
then lib.getAttrFromPath (lib.splitString "." evaluatedName) pkgs
else builtins.throw "getPkg expected string but got ${builtins.typeOf evaluatedName}";
forAllSystems = f: lib.genAttrs common.systems (system: f system);
# Package outputs
packages = forAllSystems (system:
let
pkgs = common.mkPkgs system;
# Rust target triple when `system` is a cross pseudo-system; null natively.
# Every cross branch below keys off this being non-null, so a native build
# takes exactly the code path it did before.
rustCrossTarget =
if system == "x86_64-windows" then "x86_64-pc-windows-gnu" else null;
# Rust toolchain for the crate compile. Default = the pinned nixpkgs rustc,
# so non-Rust modules and Rust modules without a `nix.rust.toolchain` are
# unchanged. When a module sets `nix.rust.toolchain` (e.g. "1.96.0") and the
# builder has a rust-overlay input, use a rust-overlay stable toolchain at
# that version — for crates whose deps need a newer rustc than nixpkgs ships
# (the railgun engine's alloy 1.8 / ruint need >= 1.91).
rustPlatform =
if config.nix_rust.toolchain != null && rust-overlay != null
then
let
# The toolchain must RUN on the builder and merely TARGET `system`.
# Asking the CROSS set for rust-bin evaluates
# `targetPackages.threads.package` (nixpkgs all-packages.nix) --
# an attribute only the MinGW branch touches and that the cross set
# does not define -- and mkPkgsWith refuses overlays for
# x86_64-windows for the same "that is not the set you asked for"
# reason. Taking it from the BUILD system sidesteps both, and is
# what a cross toolchain should be regardless.
# buildSystemFor is the identity on every native system, so this is
# a no-op there.
bpkgs = common.mkPkgsWith [ (import rust-overlay) ] (common.buildSystemFor system);
base = bpkgs.rust-bin.stable.${config.nix_rust.toolchain}.default;
toolchain =
if rustCrossTarget == null
then base
else base.override { targets = [ rustCrossTarget ]; };
in bpkgs.makeRustPlatform { cargo = toolchain; rustc = toolchain; }
else pkgs.rustPlatform;
# Cross wiring for the crate compile. The derivation runs in the BUILD
# platform's stdenv (see rustPlatform above), so nothing sets these for us.
rustCrossEnv =
if rustCrossTarget == null then { }
else
let
cc = pkgs.stdenv.cc; # `pkgs` is the TARGET set: the mingw wrapper
u = builtins.replaceStrings [ "-" ] [ "_" ] rustCrossTarget;
U = lib.toUpper u;
in {
CARGO_BUILD_TARGET = rustCrossTarget;
"CARGO_TARGET_${U}_LINKER" = "${cc}/bin/${cc.targetPrefix}cc";
# windows-gnu std links `-l:libpthread.a`, but nixpkgs builds
# mingw-w64 against mcfgthread, which ships no pthreads at all.
"CARGO_TARGET_${U}_RUSTFLAGS" = "-L native=${pkgs.windows.pthreads}/lib";
# cc-rs keys its toolchain off CC_<triple>/CXX_/AR_ with dashes
# replaced by underscores. Without these a build script compiles its
# bundled C for the BUILDER and the link then fails on undefined
# symbols -- silently, because the archive is still produced.
"CC_${u}" = "${cc}/bin/${cc.targetPrefix}cc";
"CXX_${u}" = "${cc}/bin/${cc.targetPrefix}c++";
"AR_${u}" = "${cc.bintools}/bin/${cc.targetPrefix}ar";
# The header half of the same pthreads story as RUSTFLAGS above.
# mingw-w64 DOES ship <sched.h>, <pthread.h> and <semaphore.h> --
# but in the winpthreads package, which is not on the default
# sysroot include path because nixpkgs builds mingw against
# mcfgthread. A crate's vendored C that reaches for them therefore
# fails with a bare "fatal error: sched.h: No such file or
# directory" that reads like the platform is unsupported when it is
# only unwired. aws-lc-sys hits exactly this, compiling
# jitterentropy for the Windows target.
#
# cc-rs appends CFLAGS_<triple>/CXXFLAGS_<triple> to the compiler
# invocations it drives, so this reaches build-script C without
# touching the Rust compile.
"CFLAGS_${u}" = "-I${pkgs.windows.pthreads}/include";
"CXXFLAGS_${u}" = "-I${pkgs.windows.pthreads}/include";
};
# ── Concrete dependency classification ─────────────────────────────────
# A dependency's typed `modules().<dep>` wrapper is generated from its
# published LIDL contract (`packages.<sys>.lidl`) WITHOUT building the
# dep's plugin. Deps that don't expose a `lidl` output yet take the
# TRANSITIONAL header-copy fallback (`legacyHeaderDepNames`), which DOES
# build them — identical to today's behavior.
# Returns the dep's published LIDL output, or null if the input isn't a
# flake exposing packages.<system>.lidl (e.g. a raw-derivation dep, or a
# module built by a builder that predates this feature) — those fall
# through to the TRANSITIONAL header-copy path. Guard every level so a
# non-flake input never throws.
depLidlOf = name:
let i = flakeInputs.${name} or null;
in if i != null && i ? packages && i.packages ? ${system}
then (i.packages.${system}.lidl or null)
else null;
depIsLidl = name: (config.dependency_overrides ? ${name}) || (depLidlOf name != null);
# LIDL-based deps → `--dep <name>=<lidl>` for the generator. An override
# forces a specific definition (.lidl, or .h + impl_class); otherwise we
# use the dep's published `lidl` output.
staticDeps = map (name:
let ov = config.dependency_overrides.${name} or null;
in if ov != null then {
inherit name;
impl_class = ov.impl_class;
path = if ov.input != null
then (if flakeInputs ? ${ov.input}
then "${flakeInputs.${ov.input}}/${ov.file}"
else throw "dependency_overrides.${name}: flake input '${ov.input}' was not passed to mkLogosModule.")
else "${src}/${ov.file}";
} else {
inherit name;
impl_class = null;
path = "${depLidlOf name}/${name}.lidl";
}
) (lib.filter depIsLidl config.dependencies);
# TRANSITIONAL: header-copy fallback for deps that predate the `lidl`
# output. These deps ARE built (their headers come from introspecting the
# compiled plugin). Remove this block — and the `moduleDepIncludes` use in
# the plugin backends — once every module exposes packages.<sys>.lidl.
legacyHeaderDepNames = lib.filter (name: !(depIsLidl name)) config.dependencies;
# Resolve the fallback deps from inputs. Each entry is exposed
# as a struct so the plugin builder can pick BOTH the dep's
# plugin .dylib AND the right header variant for its own
# --api-style without re-running the codegen at consume time.
# Shared with buildCppPlugin (view modules) — see common.nix.
resolvedModuleDeps = common.resolveLegacyHeaderDeps {
inherit system flakeInputs;
depNames = legacyHeaderDepNames;
};
# Resolve interface dependencies (method/event contracts) to concrete
# definition-file paths. A LOCAL interface lives in this repo's `src`;
# a REMOTE one comes from a flake input named by `input` — mirroring
# how `dependencies` resolve to flake inputs. We resolve the path here
# so the generator never touches flake inputs: it just receives
# `--interface <name>=<path>[=<impl_class>]`. (System-independent, but
# kept in this scope alongside resolvedModuleDeps for locality.)
resolvedInterfaceDeps = map (e: {
inherit (e) name impl_class;
path = if e.input != null
then (if flakeInputs ? ${e.input}
then "${flakeInputs.${e.input}}/${e.file}"
else throw "interface_dependencies: interface '${e.name}' references flake input '${e.input}', but no such input was passed to mkLogosModule (declare it in flake.nix and pass it via flakeInputs).")
else "${src}/${e.file}";
}) config.interface_dependencies;
# Resolve a single externalLibInputs entry for a given variant.
# Supports both simple (bare flake input) and structured ({ input, packages }) formats.
resolveExtInput = variant: name: value:
if builtins.isAttrs value && value ? input then
let
flakeInput = value.input;
packages = value.packages or {};
pkgName = packages.${variant} or packages.default or "default";
in
if flakeInput ? packages.${system}.${pkgName}
then flakeInput.packages.${system}.${pkgName}
else builtins.throw ''
External lib "${name}": flake input does not provide packages.${system}.${pkgName}.
Check the "externalLibInputs" structured entry and ensure the flake input exposes the expected package.
''
else
if value ? packages.${system}.default then value.packages.${system}.default else value;
# Whether any external lib input declares per-variant packages
hasVariants = lib.any (v: builtins.isAttrs v && v ? input && v ? packages)
(lib.attrValues externalLibInputs);
buildPkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.build);
runtimePkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_packages.runtime);
# Rust crate compile inputs (metadata nix.rust). build -> nativeBuildInputs
# (host tools), runtime -> buildInputs (link libs). Resolved with the same
# dotted-path getPkg as buildPkgs/runtimePkgs. Fed only to rustStaticLib,
# not the C++ plugin link.
# buildPackages, not pkgs: these are TOOLS that run on the builder
# (pkg-config, perl, protobuf, cmake). Under cross, resolving them from
# the target set would try to build each one FOR Windows. Identity on
# every native system, so no native derivation changes.
rustNativeBuildPkgs = map (getPkg pkgs.buildPackages) (lib.filter builtins.isString config.nix_rust.packages.build);
rustBuildPkgs = map (getPkg pkgs) (lib.filter builtins.isString config.nix_rust.packages.runtime);
# Pre-resolve default variant external libs (always needed, avoids
# duplicate evaluation when hasVariants triggers a second buildVariant).
defaultResolvedExternalLibs = lib.mapAttrs (resolveExtInput "default") externalLibInputs;
defaultExternalLibs = mkExternalLib.buildExternalLibs {
inherit pkgs config src;
externalInputs = defaultResolvedExternalLibs;
};
# metadata `include`: runtime files a module needs BESIDE its plugin but
# never links against -- in practice, dlopen'd libraries.
#
# Nothing else can stage these. The Windows DLL walk
# (logos-plugin-qt postFixup -> linkDLLsInfolder) is IMPORT-TABLE driven,
# so a library reached only through dlopen appears in no table and is
# invisible to it; on Unix there is equally no DT_NEEDED entry to follow.
# delivery_module hit exactly this with libpq: declared, needed at
# runtime, and silently absent from the module output.
#
# Sources are the module's own runtime nix packages and its resolved
# external libs; both `lib/` and `bin/` are searched, because a Windows
# shared library's runtime half lives in bin/ by convention.
#
# A name that matches nothing is NORMAL, not an error: the list is a
# deliberate cross-platform superset (modules name the .so, .dylib and
# .dll spellings side by side), so at most one spelling can ever match.
#
# Runs BEFORE the module's own postInstall, so author hooks can react to
# what was staged, and before the Windows postFixup, so linkDLLsInfolder
# then also walks the staged library's OWN imports (libpq pulls in
# libssl/libcrypto that way).
stageIncludedRuntimeFiles =
let
sources = runtimePkgs ++ lib.attrValues defaultResolvedExternalLibs;
in
lib.optionalString (config.include != [ ] && sources != [ ]) ''
echo "Staging declared runtime files (metadata 'include')..."
mkdir -p $out/lib
for _inc_name in ${lib.escapeShellArgs config.include}; do
for _inc_root in ${lib.escapeShellArgs (map toString sources)}; do
for _inc_sub in lib bin; do
if [ -e "$_inc_root/$_inc_sub/$_inc_name" ]; then
cp -Lf "$_inc_root/$_inc_sub/$_inc_name" "$out/lib/" 2>/dev/null \
&& echo " staged $_inc_name" && break 2
fi
done
done
done
'';
# Resolve SDK deps for this system — injected into the backend
logosSdk = logos-cpp-sdk.packages.${system}.default;
# Build-platform half of the SDK. logos-cpp-generator is invoked by BARE
# NAME from a build phase (logos-plugin-qt/lib/buildPlugin.nix:145), so it
# must run on the builder. Under cross, packages.x86_64-windows.default
# carries no runnable generator at all -- logos-cpp-sdk/nix/bin.nix:39
# silently skips the mingw .exe -- hence "command not found".
#
# `logosSdk` deliberately stays TARGET-typed: it is ALSO the header and
# CMake-package root passed to LOGOS_CPP_SDK_ROOT, and those must keep
# coming from the Windows set. Splitting the two roles is the whole point;
# pointing the headers at the build system would produce a build that
# SUCCEEDS while linking the wrong architecture.
#
# buildSystemFor is the identity on every native system, so this is a
# no-op off the Windows target.
logosSdkBuild = logos-cpp-sdk.packages.${common.buildSystemFor system}.default;
logosQtSdk = logos-qt-sdk.packages.${system}.default;
# The Qt HOST RUNTIME (LogosAPI, LogosAPIProvider, LogosProviderBase, the
# legacy PluginInterface) a plugin links. It moved out of logos-qt-sdk
# into logos-plugin-qt and ships as `logos-qt-host`; logos-qt-sdk still
# forwards it, so this is the repoint, not a new dependency. TARGET-typed
# like logosQtSdk — it is a library that gets linked into the plugin.
# logos-qt-sdk stays for what the host runtime never carried: the
# Qt-typed logos_qt_lp_bridge.h / logos_qt_wire.h / logos_ui_plugin_context.h
# and the logos-qt-generator that emits #includes of them.
logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host;
# The Qt glue generator (universal/cdylib/ui backends) — Qt code is
# the Qt layer's product; logos-cpp-generator keeps Qt-free outputs.
logosQtGenerator = logos-qt-sdk.packages.${common.buildSystemFor system}.logos-qt-generator;
# The cdylib Qt-plugin glue generator lives in logos-plugin-qt (the Qt
# plugin BACKEND owns the glue; the SDK does not). logos-qt-sdk still
# ships an older copy of the SAME emitter, and calling that one is not a
# compile error — it silently emits STALE glue. That is how a
# host-services grant went undelivered while every build stayed green.
logosQtHostGenerator =
logos-plugin-qt.packages.${common.buildSystemFor system}.logos-qt-host-generator;
# The four LogosView*.in templates logos_module(REP_FILE ...) instantiates.
# They live in logos-view-module (the ui_qml authoring flavour), NOT in
# the plugin backend any more, and cmake/LogosModule.cmake here refuses to
# guess — it hard-errors unless handed LOGOS_VIEW_TEMPLATE_DIR.
#
# buildSystemFor, not plain ${system}: these are text files with no
# platform dimension, and logos-view-module publishes only the four
# NATIVE systems, so `packages.x86_64-windows` would EVAL-fail on the
# Windows leg — a failure that is invisible until someone crosses.
viewTemplates =
logos-view-module.packages.${common.buildSystemFor system}.logos-view-templates;
logosProtocolPkg = logos-protocol.packages.${system}.default;
logosModule = logos-module.packages.${system}.default;
# The logos-protocol semver — parsed from the protocol header the
# whole stack links. Stamped into every module's embedded metadata
# (see modulePreConfigure.stampProtocolVersion). null (no stamp) only
# if the input is somehow absent — modules then load as "legacy".
protocolVersion =
if logos-protocol == null then null
else
let
header = builtins.readFile "${logos-protocol}/cpp/logos_protocol.h";
parts = builtins.split "LOGOS_PROTOCOL_VERSION_STRING \"([^\"]*)\"" header;
in if builtins.length parts < 2 then null
else builtins.head (builtins.elemAt parts 1);
# ── Rust cdylib authoring (codegen.rust) ───────────────────────────────
# A Rust module's module-impl C ABI scaffold (logos_module_* exports +
# typed trait + RustModuleContext + dep clients) is generated from the SAME
# .lidl contract that drives the Qt glue, and the crate is compiled to a
# staticlib — both done HERE by the builder, exactly as it runs the C++
# generator. The author writes no build.rs and the module's flake stays
# trivial (no buildRustPackage / preConfigure staging).
#
# logos-lidl-gen AND the SDK source the crate links both come from this
# builder's own logos-rust-sdk input — so a Rust module's flake.nix is
# identical to a C++ one (just logos-module-builder), and the generator and
# the runtime SDK are the SAME pinned rev (no skew). logos-rust-sdk depends
# back on this builder for its tests, so its module-builder input is cut
# with `follows` in flake.nix to break the cycle (see there).
isRustModule = (config.codegen or {}) ? rust;
rustCfg = (config.codegen or {}).rust or {};
rustCrateDir =
"${src}/${rustCfg.crate or (throw "codegen.rust must set 'crate' (the crate directory, e.g. \"rust-lib\") in ${config.name}")}";
# The staticlib basename (produces lib<name>.a) — read from the crate's
# Cargo.toml ([lib].name, else [package].name with - -> _) so the author
# needn't repeat it. codegen.rust.staticlib still overrides if set.
rustCargoToml =
if !isRustModule then {}
else builtins.fromTOML (builtins.readFile "${rustCrateDir}/Cargo.toml");
rustStaticName =
rustCfg.staticlib
or (rustCargoToml.lib.name
or (lib.replaceStrings ["-"] ["_"] rustCargoToml.package.name));
# Rust-FIRST authoring: when codegen.rust names the contract `trait`, that
# trait is declared in the crate and the .lidl is DERIVED from it at build
# time (logos-lidl-gen --from-rust over the crate source) — exactly as a
# universal C++ module derives its .lidl from the impl header. The .rs file
# is the single source of truth: no committed .lidl, no manual derive step.
# The scaffold is then generated with --no-trait (the trait is the
# author's). Without `trait`, the module is contract-first: codegen.lidl is
# a committed file and the trait is generated.
rustTrait = rustCfg.trait or null;
rustDeriveMode = rustTrait != null;
# The .rs file holding the trait (+ optional <Trait>Events companion),
# relative to the crate dir.
rustSource = rustCfg.source or "src/lib.rs";
rustSdk =
if !isRustModule then null
else if logos-rust-sdk == null
then throw "codegen.rust module '${config.name}' requires logos-module-builder to be built with a logos-rust-sdk input (it provides the lidl-gen generator + the SDK source). Update the builder."
else logos-rust-sdk;
# lidl-gen is a build-time TOOL: it runs on the builder to emit the Rust
# scaffold. Resolving it from the TARGET set asks logos-rust-sdk for an
# x86_64-windows attribute it does not publish -- and which would be an
# unrunnable PE if it did. buildSystemFor is the identity natively.
rustGen = if !isRustModule then null
else rustSdk.packages.${common.buildSystemFor system}.lidl-gen;
# The dep contracts that feed the Rust generator: the same resolved
# concrete + interface deps the C++ generator gets. Concrete deps →
# `modules().<dep>`; interface deps → a bound client (`<Iface>Client::bind`).
# Both arrive as `--dep name=<lidl>` (the Rust CLI has no separate
# interface flag — every generated client carries new() AND bind()).
rustDepFlags = lib.concatStringsSep " " (
(map (d: "--dep ${d.name}=${d.path}") staticDeps)
++ (map (e: "--dep ${e.name}=${e.path}") resolvedInterfaceDeps)
);
# The contract .lidl, derived from the crate's trait in rust-first mode.
# Reused by the scaffold gen, the Qt glue (staged into the build below), and
# the published `packages.<sys>.lidl`.
derivedLidl =
if !rustDeriveMode then null
else pkgs.runCommand "logos-${config.name}-derived-lidl" {
nativeBuildInputs = [ rustGen ];
} ''
mkdir -p $out
logos-lidl-gen --from-rust "${rustCrateDir}/${rustSource}" \
--trait ${rustTrait} --module-name ${config.name} --module-version ${config.version} \
-o "$out/${config.name}.lidl"
'';
# The .lidl the generators consume: the derived one (rust-first) or the
# committed codegen.lidl (contract-first).
rustLidlPath =
if rustDeriveMode then "${derivedLidl}/${config.name}.lidl"
else "${src}/${config.codegen.lidl}";
# A Rust module's EXPORT SET is decided by this string: lidl-gen gates
# logos_module_grant_host_services on >= 0.3 and the teardown pair on
# >= 0.5. So it cannot be optional here the way it is for metadata
# stamping below, where null legitimately means "pre-protocol, load as
# legacy".
#
# It used to be passed as
# ${lib.optionalString (protocolVersion != null) "--protocol-version ..."}
# which does not make a bad version WRONG — it makes the flag VANISH.
# lidl-gen then falls back to "0.1.0", emits the seven founding exports,
# and exits 0. Every Rust module in the workspace would quietly regenerate
# incomplete, link cleanly, and fail at dlopen() on Linux with an
# undefined symbol — invisible on macOS, and three repos away from here.
#
# checks.module-impl-abi-nm DETECTS that by reading the built plugin's
# symbol table. This is the other half: refuse at the point of the
# mistake, so it never reaches a build. The two causes need different
# messages because they are different bugs.
rustProtocolVersion =
if protocolVersion != null then protocolVersion
else if logos-protocol == null then
throw ("logos-module-builder: module '" + config.name + "' is a Rust "
+ "cdylib (codegen.rust), but this builder has no logos-protocol "
+ "input, so the module-impl C ABI version it must generate against "
+ "is unknown. Generating anyway would emit the pre-0.3 export set "
+ "and produce a module that fails to dlopen.")
else
throw ("logos-module-builder: could not read "
+ "LOGOS_PROTOCOL_VERSION_STRING from ${logos-protocol}/cpp/"
+ "logos_protocol.h, needed to generate the Rust cdylib scaffold "
+ "for '" + config.name + "'. The header moved or changed shape — "
+ "fix the parse above; do NOT let it fall back, because the "
+ "fallback silently emits an incomplete module-impl C ABI.");
rustScaffold =
if !isRustModule then null
else pkgs.runCommand "logos-${config.name}-rust-scaffold" {
nativeBuildInputs = [ rustGen ];
} ''
mkdir -p $out
logos-lidl-gen "${rustLidlPath}" --provider ${lib.optionalString rustDeriveMode "--no-trait"} \
${lib.optionalString ((config.concurrency or "single") == "multi") "--concurrency multi"} ${rustDepFlags} \
--protocol-version ${rustProtocolVersion} \
-o "$out/provider_gen.rs"
'';
# Rust-first only: stage the derived .lidl into generated_code/ BEFORE the
# Qt-glue codegen runs — that's where cdylibCodegen reads it for a rust-first
# module (so no codegen.lidl is needed in metadata; the builder owns the
# path). Empty for contract-first, where the .lidl is committed.
lidlStaging = lib.optionalString rustDeriveMode ''
mkdir -p generated_code
cp ${derivedLidl}/${config.name}.lidl generated_code/${config.name}.lidl
'';
# The crate source laid out for the build: the crate under rust-lib/ (with
# the generated scaffold injected at generated/provider_gen.rs) and the
# builder's logos-rust-sdk source alongside it, so the crate's
# `logos-rust-sdk = { path = "../logos-rust-sdk-src" }` dep resolves against
# the SAME rev the generator came from. The author crate carries only the
# trait impl + hook — no build.rs, no OUT_DIR.
rustCrateSrc =
if !isRustModule then null
else pkgs.runCommand "logos-${config.name}-rust-src" {} ''
mkdir -p $out
cp -r ${rustCrateDir} $out/rust-lib
chmod -R u+w $out/rust-lib
mkdir -p $out/rust-lib/generated
cp ${rustScaffold}/provider_gen.rs $out/rust-lib/generated/provider_gen.rs
cp -r ${rustSdk} $out/logos-rust-sdk-src
'';
rustStaticLib =
if !isRustModule then null
else rustPlatform.buildRustPackage ({
pname = rustStaticName;
version = config.version;
src = rustCrateSrc;
sourceRoot = "logos-${config.name}-rust-src/rust-lib";
cargoLock = {
lockFile = "${rustCrateDir}/Cargo.lock";
allowBuiltinFetchGit = true;
};
# External system build deps for the crate compile — from metadata
# `nix.rust` plus the programmatic escape-hatch args. Empty by default,
# so modules with no native deps build exactly as before.
nativeBuildInputs = rustNativeBuildPkgs ++ rustExtraNativeBuildInputs
# The cc-rs / linker wiring above names the cross compiler by store
# path, but build scripts also expect it on PATH.
++ lib.optional (rustCrossTarget != null) pkgs.stdenv.cc;
buildInputs = rustBuildPkgs ++ rustExtraBuildInputs;
env = config.nix_rust.env // rustEnv // rustCrossEnv;
doCheck = false;
}
# nixpkgs' cargoBuildHook derives `--target` from the stdenv's HOST
# platform, and this derivation deliberately runs in the BUILD
# platform's stdenv (see rustPlatform above) so that the toolchain is
# runnable. Left alone it therefore builds for the BUILDER -- silently,
# producing a perfectly good Linux archive that then fails to link into
# a PE. Drive cargo directly for the cross case instead.
// lib.optionalAttrs (rustCrossTarget != null) {
buildPhase = ''
runHook preBuild
export CARGO_HOME=$TMPDIR/cargo
cargo build --release --offline --target ${rustCrossTarget}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib
cp target/${rustCrossTarget}/release/lib${rustStaticName}.a $out/lib/
runHook postInstall
'';
});
# Stage the compiled staticlib where LogosModule.cmake's
# LOGOS_MODULE_RUST_STATIC_LIBS block finds it (the plugin build's lib/).
rustStaging = lib.optionalString isRustModule ''
mkdir -p lib
cp ${rustStaticLib}/lib/lib${rustStaticName}.a lib/
'';
# Backend arguments for a given external-lib variant ("default" or
# "portable"). Shared by buildVariant (compiles the plugin) and the
# generate output (snapshots the post-codegen source tree) so both use the
# identical preConfigure / deps / env.
mkPluginArgs = variant:
let
externalLibs =
if variant == "default" then defaultExternalLibs
else mkExternalLib.buildExternalLibs {
inherit pkgs config src;
externalInputs = lib.mapAttrs (resolveExtInput variant) externalLibInputs;
};
# rustStaging (empty for non-Rust modules) drops the compiled Rust
# staticlib into lib/ before cmake, where the LOGOS_MODULE_RUST_STATIC_LIBS
# block links it — the builder-driven replacement for the per-flake
# buildRustPackage + cp the author used to write by hand.
userPreConfigure =
rustStaging + (
if builtins.isFunction preConfigure
then preConfigure { inherit externalLibs; }
else preConfigure);
preConfigureStr = modulePreConfigure.compose {
inherit config externalLibs protocolVersion;
userPre = userPreConfigure;
# Stage the rust-first derived .lidl before the glue codegen reads it.
preCodegen = lidlStaging;
fixDarwin = false;
# logos-plugin-qt buildPlugin already stages external libs into lib/
copyExternals = false;
};
goCmakeFlags = lib.optionals (config.go_static_lib_names != []) [
"-DLOGOS_MODULE_GO_STATIC_LIBS=${lib.concatStringsSep ";" config.go_static_lib_names}"
];
# LOGOS_API_STYLE forwards through to logos-cpp-generator and
# picks which type surface the generated <Module> client wrappers
# (and the umbrella LogosModules struct) expose. Mirrors the
# backend's apiStyle (logos-plugin-qt buildPlugin.nix): core
# universal modules are header-first cdylibs → Qt-free lp_* wrappers.
# UI universal backends (type: ui_qml) are NOT modules — they derive a
# Qt SimpleSource whose .rep slots are Qt-typed, so their
# LogosUiPluginContext.modules() dep wrappers are Qt-typed too (the
# generator default — no flag). Every other interface keeps qt.
# (Only consulted in the source layout; nix builds get apiStyle from
# the backend's --general-only call.)
#
# `config.consumer_api_style` (parseMetadata.nix — the resolved
# `codegen.consumer_api_style`) is what makes this an override rather
# than a pure derivation. It only ever REMOVES the flag: a
# cdylib-packaged module that asks for the Qt consumer surface must
# not have `lp` forced on it here. It is deliberately NOT allowed to
# ADD one — the trigger condition below is character-for-character
# today's, so no module that passes no flag today starts passing one
# (a `cdylib` module never got this flag even though the nix backend
# types it `lp`; unifying that would change every cdylib module's
# derivation for a flag only the legacy source layout reads).
#
# There is no `--binding` counterpart here on purpose: this branch of
# LogosModule.cmake never invokes logos-qt-generator at all, so it
# cannot emit the origin-bound wrapper SET that the origin-bound
# umbrella needs. The Qt consumer surface for a cdylib module is a
# nix-build capability; the source layout keeps the one shape it can
# actually produce.
apiStyleCmakeFlags =
if config.interface == "universal" && (config.type or "core") != "ui_qml"
&& config.consumer_api_style == "lp"
then [ "-DLOGOS_API_STYLE=lp" ]
else [];
# The backend only knows about Qt + logosModule (interface.h).
# SDK (generator, lib, headers) is injected via extra* args.
in ({
inherit pkgs src config logosModule;
postInstall = stageIncludedRuntimeFiles + postInstall;
preConfigure = preConfigureStr;
moduleDeps = resolvedModuleDeps;
inherit externalLibs;
# pkgs.jq is target-typed too and jq runs in preConfigure
# (modulePreConfigure.nix:203). buildPackages == pkgs natively.
extraNativeBuildInputs = extraNativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild logosQtGenerator logosQtHostGenerator pkgs.buildPackages.jq ];
extraBuildInputs = extraBuildInputs ++ runtimePkgs ++ [ logosQtSdk logosQtHost logosProtocolPkg ]
# A Rust staticlib's vendored C may want winpthreads: with <sched.h>
# reachable, aws-lc-sys compiles aws-lc's thread_pthread.c and the
# plugin link then needs pthread_rwlock_*, pthread_once, sched_yield.
# aws-lc assumes the standard mingw environment, where winpthreads is
# simply present; nixpkgs builds mingw against mcfgthread, so it is a
# separate package on no default path. As a buildInput its lib/ lands
# on NIX_LDFLAGS, which is what lets the `pthread` named by
# LogosModule.cmake's WIN32 branch resolve.
#
# Cross Rust modules only, and free for the ones that do not need it:
# ld pulls archive members on demand, so a module referencing no
# pthread symbol links exactly as before.
++ lib.optional (isRustModule && rustCrossTarget != null) pkgs.windows.pthreads;
# Qt splits each module's TOOLS (repc, moc, qmltyperegistrar) into a
# SEPARATE package that must run on the BUILD machine. Without these
# flags find_package(Qt6 COMPONENTS RemoteObjects) fails on a
# thoroughly misleading message -- it names Qt6RemoteObjects, but the
# TARGET config is found fine; it is Qt6RemoteObjectsTools that is
# missing. logos-nix's Windows overlay exposes the flags; the
# attribute is absent (and so `or []`) on a native build, which is why
# this needs no isWindows guard.
extraCmakeFlags = (pkgs.logosQtCrossCmakeFlags or [ ]) ++ [
"-DLOGOS_CPP_SDK_ROOT=${logosSdk}"
"-DLOGOS_QT_SDK_ROOT=${logosQtSdk}"
"-DLOGOS_QT_HOST_ROOT=${logosQtHost}"
"-DLOGOS_PROTOCOL_ROOT=${logosProtocolPkg}"
"-DLOGOS_VIEW_TEMPLATE_DIR=${viewTemplates}"
] ++ goCmakeFlags ++ apiStyleCmakeFlags
++ lib.optionals isRustModule [ "-DLOGOS_MODULE_RUST_STATIC_LIBS=${rustStaticName}" ];
extraEnv = {
LOGOS_CPP_SDK_ROOT = "${logosSdk}";
LOGOS_QT_SDK_ROOT = "${logosQtSdk}";
LOGOS_QT_HOST_ROOT = "${logosQtHost}";
LOGOS_PROTOCOL_ROOT = "${logosProtocolPkg}";
LOGOS_MODULE_BUILDER_ROOT = builderCmakeRoot;
# Both channels on purpose, not belt-and-braces: LogosModule.cmake
# prefers the cache variable above and falls back to this env var,
# and the two reach different consumers. The flag is what a nix
# buildPlugin's cmakeConfigurePhase sees; the env var is what a
# hand-run `cmake` in a dev shell sees, where no cmakeFlags exist.
LOGOS_VIEW_TEMPLATE_DIR = "${viewTemplates}";
};
}
# Only pass interfaceDeps when the module declares any — keeps existing
# dependency-only modules buildable against a backend that predates the
# interface-dependencies feature (graceful degradation). A Rust module's
# deps ALSO feed the Rust generator (rustDepFlags) for the typed
# modules()/bind() it actually calls; they still go to the C++ backend
# too so the generated umbrella (logos_sdk.h, emitted from
# metadata.dependencies) finds each dep's api header and compiles.
// lib.optionalAttrs (config.interface_dependencies != []) {
interfaceDeps = resolvedInterfaceDeps;
}
# LIDL-based concrete deps → `--dep` flags (generate from the dep's
# published LIDL, no dep plugin build). Gated so a backend that predates
# this feature still builds (such deps then fall through unresolved).
// lib.optionalAttrs (staticDeps != []) {
inherit staticDeps;
});
# Compile the plugin for a variant (delegated to the backend).
buildVariant = variant: selectedBackend.buildPlugin (mkPluginArgs variant);
moduleLib = buildVariant "default";
moduleLibPortable = if hasVariants then buildVariant "portable" else null;
# Ready-to-build source tree: the backend runs every generator the build
# runs, then snapshots the result (module source + generated_code/) instead
# of compiling. Same args as the default plugin build, so the emitted tree
# is exactly what a real build generates. Built from the module's
# `nix develop` shell (which exports LOGOS_*_ROOT) without re-running codegen.
moduleGenerate = selectedBackend.generate (mkPluginArgs "default");
# Two header variants per module — Qt-typed and lp (Qt-free,
# logos-protocol C ABI). Each is its own Nix derivation, so a
# downstream module only realises the one its `--api-style` actually
# consumes. The lp variant lets a core universal (header-first cdylib)
# module copy a Qt-free typed wrapper for a LEGACY dependency that
# publishes no `.lidl` (the wrapper is generated by introspecting the
# dep's built plugin, so it works regardless of how the dep was
# authored). Default output (`include`) stays the Qt variant for
# backward compatibility with consumers that read `${dep}/include`.
# (A third `std` variant — std-typed signatures but still marshalling
# through QVariant, so never actually Qt-free — used to be built here.
# `buildPlugin.nix` only ever selects "qt" or "lp", so it had no
# consumer; it was retired rather than rebuilt for every module.)
# The contract buildHeaders falls back to when it cannot introspect the
# built plugin (cross-compilation — a Linux builder cannot load a PE).
# Preference order:
# 1. this module's published `lidl` output (universal + cdylib), then
# 2. a contract committed at src/<name>.lidl.
# (2) is the escape hatch for handcrafted Qt / `interface: "legacy"`
# modules, which derive no contract from their sources. It is deliberately
# NOT folded into `moduleLidl` below: publishing a `lidl` output flips
# every downstream consumer of this module from the transitional
# header-copy path onto `--dep` (see depIsLidl above), which would change
# native builds across the tree. This binding is consumed by buildHeaders
# ALONE, and buildHeaders only reads it when cross-compiling.
committedLidl = src + "/src/${config.name}.lidl";
headerContractLidl =
if moduleLidl != null then "${moduleLidl}/${config.name}.lidl"
else if builtins.pathExists committedLidl then "${committedLidl}"
else null;
# `qtGenerator` is what lets the QT variant come from the module's
# CONTRACT (logos-qt-generator --backend consumer) instead of from
# introspecting the compiled plugin. Both tools are passed for a pure
# tool role -- the backend picks the one its selected emitter needs and
# puts only that one on PATH. Omitting qtGenerator does not break the
# build; it silently demotes every contract-bearing module back to the
# legacy Qt emitter, which is why buildHeaders shouts about that case
# rather than just falling back.
moduleIncludeQt = selectedBackend.buildHeaders {
inherit pkgs src config;
# buildHeaders uses these ONLY to put a generator on PATH -- a pure
# tool role, hence the BUILD-platform variants under cross.
logosSdk = logosSdkBuild;
qtGenerator = logosQtGenerator;
pluginLib = moduleLib;
apiStyle = "qt";
contractLidl = headerContractLidl;
};
moduleIncludeLp = selectedBackend.buildHeaders {
inherit pkgs src config;
# No qtGenerator: logos-qt-generator has no lp backend, so the lp
# wrapper still comes from logos-cpp-generator's (non-legacy-Qt) lp
# emitter, byte-for-byte as before.
logosSdk = logosSdkBuild;
pluginLib = moduleLib;
apiStyle = "lp";
contractLidl = headerContractLidl;
};
# Publish this module's interface as LIDL — the language-neutral contract
# a consumer turns into typed `modules().<name>` bindings WITHOUT building
# this module's plugin (source → LIDL → C++). Cheap: runs only the C++
# frontend (`--header-to-lidl`) over the impl header; no Qt/plugin compile.
# Produced for universal modules; the impl header + class come from the
# same convention `universalCodegen` uses (`codegen.impl_*` or defaults).
lidlImplClass = config.codegen.impl_class or (modulePreConfigure.defaultImplClassFromName config.name);
lidlIhRaw = config.codegen.impl_header or "${config.name}_impl.h";
lidlImplHeaderRel = if lib.hasInfix "/" lidlIhRaw then lidlIhRaw else "src/${lidlIhRaw}";
moduleLidl =
if config.interface == "universal"
then pkgs.runCommand "logos-${config.name}-lidl" {
nativeBuildInputs = [ logosSdkBuild ];
} ''
mkdir -p $out
logos-cpp-generator --header-to-lidl "${src}/${lidlImplHeaderRel}" \
--impl-class "${lidlImplClass}" \
--metadata "${configFile}" \
-o "$out/${config.name}.lidl"
''
# Cdylib modules publish their .lidl as the interface (whether the impl
# is Rust or C++), so consumers generate typed bindings from it like for
# any other dep. Contract-first modules copy the committed file; a
# rust-first module publishes the .lidl DERIVED from its trait.
else if rustDeriveMode
then pkgs.runCommand "logos-${config.name}-lidl" {} ''
mkdir -p $out
cp "${derivedLidl}/${config.name}.lidl" "$out/${config.name}.lidl"
''
else if config.interface == "cdylib" && config.codegen ? lidl
then pkgs.runCommand "logos-${config.name}-lidl" {} ''
mkdir -p $out
cp "${src}/${config.codegen.lidl}" "$out/${config.name}.lidl"
''
else null;
# Combined package — copies the Qt-typed headers (backward
# compat). The `//` merge exposes src + version on the derivation
# so downstream bundlers (nix-bundle-lgx) can locate metadata.json.
combined = (pkgs.runCommand "logos-${config.name}-module" {} ''
mkdir -p $out/lib $out/include
# Copy library files (not symlinks)
if [ -d "${moduleLib}/lib" ]; then
cp -rL ${moduleLib}/lib/* $out/lib/
fi
# Copy include files (not symlinks) — use find to avoid nullglob issues
if [ -d "${moduleIncludeQt}/include" ] && [ -n "$(find ${moduleIncludeQt}/include -maxdepth 1 -not -name '.*' -not -path ${moduleIncludeQt}/include -print -quit)" ]; then
cp -rL ${moduleIncludeQt}/include/* $out/include/
fi
'') // { inherit src; version = config.version; };
in {
# Individual outputs (e.g., nix build .#chat-lib)
"${config.name}-lib" = moduleLib;
"${config.name}-include" = moduleIncludeQt;
"${config.name}-headers-qt" = moduleIncludeQt;
"${config.name}-headers-lp" = moduleIncludeLp;
# Short aliases (e.g., nix build .#lib)
lib = moduleLib;
include = moduleIncludeQt;
headers-qt = moduleIncludeQt;
headers-lp = moduleIncludeLp;
# Default package - combined lib + include (nix build)
default = combined;
# Ready-to-build codebase: all code generators run, emitted as a source
# tree (nix build .#generate). Build it from `nix develop` — no generator
# re-runs (LogosModule.cmake consumes the pre-populated generated_code/).
generate = moduleGenerate;
"${config.name}-generate" = moduleGenerate;
} // lib.optionalAttrs (moduleLibPortable != null) {
"${config.name}-lib-portable" = moduleLibPortable;
lib-portable = moduleLibPortable;
} // lib.optionalAttrs (moduleLidl != null) {
# Published LIDL contract — consumers generate bindings from this without
# building the plugin. Cheap (frontend only). Absent for non-universal
# modules, so consumers fall back to the header-copy path for those.
"${config.name}-lidl" = moduleLidl;
lidl = moduleLidl;
}
);
# Development shell (delegates to backend for deps)
devShells = forAllSystems (system:
let
pkgs = common.mkPkgs system;
logosSdk = logos-cpp-sdk.packages.${system}.default;
# Build-platform half of the SDK. logos-cpp-generator is invoked by BARE
# NAME from a build phase (logos-plugin-qt/lib/buildPlugin.nix:145), so it
# must run on the builder. Under cross, packages.x86_64-windows.default
# carries no runnable generator at all -- logos-cpp-sdk/nix/bin.nix:39
# silently skips the mingw .exe -- hence "command not found".
#
# `logosSdk` deliberately stays TARGET-typed: it is ALSO the header and
# CMake-package root passed to LOGOS_CPP_SDK_ROOT, and those must keep
# coming from the Windows set. Splitting the two roles is the whole point;
# pointing the headers at the build system would produce a build that
# SUCCEEDS while linking the wrong architecture.
#
# buildSystemFor is the identity on every native system, so this is a
# no-op off the Windows target.
logosSdkBuild = logos-cpp-sdk.packages.${common.buildSystemFor system}.default;
logosQtSdk = logos-qt-sdk.packages.${system}.default;
# Same repoint in the dev shell: LOGOS_QT_HOST_ROOT below.
logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host;
# The Qt glue generator (universal/cdylib/ui backends) — Qt code is
# the Qt layer's product; logos-cpp-generator keeps Qt-free outputs.
logosQtGenerator = logos-qt-sdk.packages.${common.buildSystemFor system}.logos-qt-generator;
# The cdylib Qt-plugin glue generator lives in logos-plugin-qt (the Qt
# plugin BACKEND owns the glue; the SDK does not). logos-qt-sdk still
# ships an older copy of the SAME emitter, and calling that one is not a
# compile error — it silently emits STALE glue. That is how a
# host-services grant went undelivered while every build stayed green.
logosQtHostGenerator =
logos-plugin-qt.packages.${common.buildSystemFor system}.logos-qt-host-generator;
# The four LogosView*.in templates logos_module(REP_FILE ...) instantiates.
# They live in logos-view-module (the ui_qml authoring flavour), NOT in
# the plugin backend any more, and cmake/LogosModule.cmake here refuses to
# guess — it hard-errors unless handed LOGOS_VIEW_TEMPLATE_DIR.
#
# buildSystemFor, not plain ${system}: these are text files with no
# platform dimension, and logos-view-module publishes only the four
# NATIVE systems, so `packages.x86_64-windows` would EVAL-fail on the
# Windows leg — a failure that is invisible until someone crosses.
viewTemplates =
logos-view-module.packages.${common.buildSystemFor system}.logos-view-templates;
logosProtocolPkg = logos-protocol.packages.${system}.default;
logosModule = logos-module.packages.${system}.default;
backendShell = selectedBackend.devShellInputs pkgs { inherit logosModule; };
buildPkgs = map (getPkg pkgs) config.nix_packages.build;
runtimePkgs = map (getPkg pkgs) config.nix_packages.runtime;
# Resolve external lib inputs for this system so we can point cmake directly
# at their Nix store paths via LOGOS_EXT_ROOT_<NAME>, skipping the ./lib/ staging copy.
resolveExtInputDev = name: value:
if builtins.isAttrs value && value ? input then
let pkgName = (value.packages or {}).default or "default";
in value.input.packages.${system}.${pkgName} or null
else
value.packages.${system}.default or value;
devExternalLibs = lib.filterAttrs (_: v: v != null && lib.isDerivation v)
(lib.mapAttrs resolveExtInputDev externalLibInputs);
in {
default = pkgs.mkShell {
nativeBuildInputs = backendShell.nativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild ];
buildInputs = backendShell.buildInputs ++ runtimePkgs ++ lib.attrValues devExternalLibs;
shellHook = ''
${backendShell.shellHook}
export LOGOS_CPP_SDK_ROOT="${logosSdk}"
export LOGOS_QT_SDK_ROOT="${logos-qt-sdk.packages.${system}.default}"
export LOGOS_QT_HOST_ROOT="${logosQtHost}"
export LOGOS_PROTOCOL_ROOT="${logos-protocol.packages.${system}.default}"
export LOGOS_MODULE_BUILDER_ROOT="${builderCmakeRoot}"
# The plugin backend used to export this from its own devShellInputs
# shellHook (spliced in above). It stopped when the templates left it,
# and nothing in that repo can catch the regression — a missing value
# here surfaces only when someone hand-runs cmake on a REP_FILE module.
export LOGOS_VIEW_TEMPLATE_DIR="${viewTemplates}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: drv: ''
export LOGOS_EXT_ROOT_${lib.toUpper name}="${drv}"
'') devExternalLibs)}
echo "Logos ${config.name} module development environment"
echo "LOGOS_CPP_SDK_ROOT: $LOGOS_CPP_SDK_ROOT"
echo "LOGOS_MODULE_ROOT: $LOGOS_MODULE_ROOT"
echo "LOGOS_MODULE_BUILDER_ROOT: $LOGOS_MODULE_BUILDER_ROOT"
'';
};
}
);
# LGX package outputs (nix-bundle-lgx provided by the builder)
nixBundleLgx = nix-bundle-lgx;
optionalLgx =
{
packages = forAllSystems (system:
let
bundleLgx = nixBundleLgx.bundlers.${system}.default;
bundleLgxPortable = nixBundleLgx.bundlers.${system}.portable;
installDev = nix-bundle-logos-module-install.bundlers.${system}.dev;
installPortable = nix-bundle-logos-module-install.bundlers.${system}.portable;
moduleLib = packages.${system}.lib;
# Use the portable-linked plugin for lgx-portable when available
moduleLibForPortable =
packages.${system}.lib-portable or moduleLib;
in {
lgx = bundleLgx moduleLib;
install = installDev moduleLib;
lgx-portable = bundleLgxPortable moduleLibForPortable;
install-portable = installPortable moduleLibForPortable;
}
);
};
# Resolve the standalone app: explicit override > built-in from module-builder
resolvedStandalone =
if logosStandalone != null then logosStandalone
else if config.type == "ui" then logos-standalone-app
else null;
optionalApps =
if resolvedStandalone == null then {}
else {
apps = forAllSystems (system:
let
pkgs = common.mkPkgs system;
# Collect all module dependencies (direct + transitive) for bundling
allDeps = common.collectAllModuleDeps system flakeInputs config.dependencies;
in {
default = mkStandaloneApp {
inherit pkgs;
standalone = resolvedStandalone.packages.${system}.default;
plugin = packages.${system}.default;
metadataFile = configFile;
dirName = "logos-${config.name}-plugin-dir";
format = "qt-plugin";
moduleDeps = allDeps;
};
}
);
};
# Merge LGX outputs into packages
mergedPackages = lib.mapAttrs (system: sysPkgs:
sysPkgs // (optionalLgx.packages.${system} or {})
) packages;
# Build unit tests — explicit config wins, otherwise auto-detect tests/CMakeLists.txt
mkTests = import ./mkLogosModuleTests.nix {
inherit nixpkgs lib common parseMetadata;
inherit logos-cpp-sdk logos-protocol logos-qt-sdk logos-plugin-qt;
logos-test-framework = logos-test-framework;
};
resolvedTests =
if tests != null then tests
else if builtins.pathExists (src + "/tests/CMakeLists.txt") then {
dir = src + "/tests";
}
else null;
testChecks =
if resolvedTests == null then {}
else mkTests {
inherit src flakeInputs externalLibInputs;
configFile = configFile;
testDir = resolvedTests.dir;
mockCLibs = resolvedTests.mockCLibs or [];
preConfigure = resolvedTests.preConfigure or preConfigure;
extraBuildInputs = resolvedTests.extraBuildInputs or [];
extraCmakeFlags = resolvedTests.extraCmakeFlags or [];
};
optionalTests =
if testChecks == {} then {}
else { checks = testChecks; };
# Also expose unit-tests as a package so `nix build .#unit-tests` works
testPackages =
if testChecks == {} then {}
else lib.mapAttrs (_system: sysChecks:
{ unit-tests = sysChecks.unit-tests; }
) testChecks;
finalPackages = lib.mapAttrs (system: sysPkgs:
sysPkgs // (testPackages.${system} or {})
) mergedPackages;
in {
packages = finalPackages;
inherit devShells config;
metadataJson = builtins.readFile configFile;
} // optionalApps // optionalTests