Files
Dario Gabriel LipicarandClaude Opus 5 fe4f26cecf feat: build ui_qml glue with logos-view-generator, not logos-qt-generator
modulePreConfigure.nix switches the `--backend ui` invocation to
logos-view-generator (logos-view-module), which now owns that emitter and
the logos_ui_plugin_context.h it pairs with.

THE REASON THIS IS NOT COSMETIC. This builder pins logos-qt-sdk at 4a1104c,
which predates b7b82e5 — the commit that added the module teardown hook to
the ui emitter. At 4a1104c BOTH halves lack it, so every build has been
green and self-consistent, and the hook has NEVER REACHED A SINGLE SHIPPED
ui_qml MODULE. Measured on the same module, before and after, by loading the
built plugin with QPluginLoader and dumping its QMetaObject:

    BASELINE                      MIGRATED
      initLogos(LogosAPI*)          unloadFinished()      [signal]
                                    initLogos(LogosAPI*)
                                    int aboutToUnload()   [invokable]

So this delivers the hook for the first time rather than preserving it.

LOGOS_VIEW_INCLUDE_DIR is added BEFORE the qt-sdk root in
LogosModule.cmake, so the emitter and its header resolve from ONE pin. That
ordering is load-bearing until logos-qt-sdk stops installing its copy of
logos_ui_plugin_context.h — anything going through logos_module() is safe;
a hand-run cmake putting LOGOS_QT_SDK_ROOT first would silently get the
wrong header. Called out in the code rather than left implicit.

Blast radius is 7 modules, not one: package_manager_ui, chat_ui, wallet_ui,
test_uiqml_probe, test_fullapi_ui, calc_ui_cpp and templates/ui-qml-backend
all match ui_qml + interface:universal. None override aboutToUnload(), and
the base default is Synchronous, so every one answers 0 and no host waits —
the change is additive. Only package_manager_ui was built end to end.

Three assertions in tests/test-module-pre-configure.nix pin the binary
choice; repointing at logos-qt-generator fails them. Also corrects a comment
there that claimed "the ui backend still legitimately uses qt-sdk's".

7/7 checks green, 399 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:59:50 -03:00

1197 lines
63 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
metadataJson = builtins.readFile configFile;
# ── Two configs, and why ──────────────────────────────────────────────────
#
# `config` is parsed with NO platform. It is the answer for the handful of
# fields no `platforms` overlay may vary — name, version, type, interface —
# and those are exactly the fields read here, above forAllSystems, where no
# target exists yet: `selectedBackend` below, and the system-agnostic `config`
# flake output that collectAllModuleDeps reads from a FOREIGN flake.
#
# It is NOT a fallback. A field some overlay declares comes back as a throw
# (resolvePlatforms.poisonField), so reading one of those up here is a loud
# error rather than the base value — which is the whole point: a superset
# nobody meant to use on its own is how the .so/.dylib/.dll spelling lists
# drifted in the first place.
#
# `configFor system` is the resolved answer, and every per-system closure
# below rebinds `config` to it as its first `let` binding. That rebinding is
# deliberate rather than a rename: it keeps ~40 existing `config.*` reads
# correct by construction, and there is no reading inside a per-system
# closure that should see the unresolved tree.
rawConfig = parseMetadata.parseModuleConfig { json = metadataJson; platform = null; };
config = common.recursiveMerge [ rawConfig configOverrides ];
configFor = system: common.recursiveMerge [
(parseMetadata.parseModuleConfig {
json = metadataJson;
platform = parseMetadata.platformForSystem system;
})
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;
config = configFor 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.
#
# `config.include` here is the PLATFORM-RESOLVED list (this closure
# rebinds `config` to `configFor system`), so a module can now name one
# spelling per target with a `platforms` overlay instead of a superset.
# The tolerance above stays for the modules that still write the superset
# — and because the superset is unvalidated, which is how
# logos-package-downloader-module ended up naming .so and .dylib but not
# .dll. An overlay whose selector is misspelled throws at parse time
# instead.
#
# 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;
# The VIEW plugin glue generator (`--backend ui`). It lives in
# logos-view-module, beside the LogosView*.in templates the glue it emits
# is compiled against and beside logos_ui_plugin_context.h, which that
# glue calls into -- the three are one authoring surface and used to be
# split across two repos. logos-qt-sdk shipped the same emitter and
# rotted: it gained the teardown hook, the copy here did not, and nothing
# detected it because a missing hook is silent at every layer.
#
# buildSystemFor: a code generator RUNS on the build machine, and
# logos-view-module publishes only the four NATIVE systems, so plain
# ${system} would EVAL-fail on the Windows leg.
logosViewGenerator =
logos-view-module.packages.${common.buildSystemFor system}.logos-view-generator;
# logos_ui_plugin_context.h -- the context a view's *Backend derives, and
# the header the emitted glue calls maybeUiPluginAboutToUnload() in.
#
# It comes from logos-view-module, the SAME pin as the generator above,
# and that is the whole point. The emitter and this header are one
# MATCHED PAIR: the emitter writes a call, the header declares what it
# calls. While both lived in logos-qt-sdk they moved together under one
# pin and could not disagree. Sourcing the generator from one repo and
# this header from another would make every ui_qml build depend on two
# pins agreeing, with nothing enforcing it -- and the failure is a
# compile error deep inside GENERATED code, far from the pin that caused
# it. One pin, one pair.
logosViewInclude =
logos-view-module.packages.${common.buildSystemFor system}.include;
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 logosViewGenerator 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}"
"-DLOGOS_VIEW_INCLUDE_DIR=${logosViewInclude}"
] ++ 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}";
LOGOS_VIEW_INCLUDE_DIR = "${logosViewInclude}";
};
}
# 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;
config = configFor 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;
# The VIEW plugin glue generator (`--backend ui`). It lives in
# logos-view-module, beside the LogosView*.in templates the glue it emits
# is compiled against and beside logos_ui_plugin_context.h, which that
# glue calls into -- the three are one authoring surface and used to be
# split across two repos. logos-qt-sdk shipped the same emitter and
# rotted: it gained the teardown hook, the copy here did not, and nothing
# detected it because a missing hook is silent at every layer.
#
# buildSystemFor: a code generator RUNS on the build machine, and
# logos-view-module publishes only the four NATIVE systems, so plain
# ${system} would EVAL-fail on the Windows leg.
logosViewGenerator =
logos-view-module.packages.${common.buildSystemFor system}.logos-view-generator;
# logos_ui_plugin_context.h -- the context a view's *Backend derives, and
# the header the emitted glue calls maybeUiPluginAboutToUnload() in.
#
# It comes from logos-view-module, the SAME pin as the generator above,
# and that is the whole point. The emitter and this header are one
# MATCHED PAIR: the emitter writes a call, the header declares what it
# calls. While both lived in logos-qt-sdk they moved together under one
# pin and could not disagree. Sourcing the generator from one repo and
# this header from another would make every ui_qml build depend on two
# pins agreeing, with nothing enforcing it -- and the failure is a
# compile error deep inside GENERATED code, far from the pin that caused
# it. One pin, one pair.
logosViewInclude =
logos-view-module.packages.${common.buildSystemFor system}.include;
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 logosViewGenerator ];
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}"
export LOGOS_VIEW_INCLUDE_DIR="${logosViewInclude}"
${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;
config = configFor 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;
# Source of logos-view-generator: a ui_qml module's unit tests run the same
# autoCodegen the plugin build does.
inherit logos-view-module;
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;
# The RESOLVED config, per target. `config` above cannot answer for a
# platform-keyed field and says so when asked; a consumer that needs
# `dependencies` / `include` / `main` for a specific system reads this.
# collectAllModuleDeps already prefers it when a dependency publishes one.
configFor = forAllSystems configFor;
inherit metadataJson;
} // optionalApps // optionalTests