2026-01-23 08:50:17 -05:00
# Core module builder function
2026-03-28 18:01:28 -04:00
# 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.
2026-08-20 00:31:52 -03:00
{ 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 }:
2026-01-23 08:50:17 -05:00
{
# Required: Path to the module source
src ,
2026-03-28 18:01:28 -04:00
2026-03-24 17:21:59 +01:00
# Required: Path to the metadata.json configuration file
2026-01-23 08:50:17 -05:00
configFile ,
2026-03-24 17:21:59 +01:00
# Optional: all flake inputs — dependencies in metadata.json are resolved automatically
flakeInputs ? {} ,
2026-01-23 08:50:17 -05:00
# Optional: Additional flake inputs for external libraries
externalLibInputs ? {} ,
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
# Optional: Extra build inputs to add
extraBuildInputs ? [] ,
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
# Optional: Extra native build inputs to add
extraNativeBuildInputs ? [] ,
2026-03-28 18:01:28 -04:00
2026-06-16 19:46:06 -03:00
# 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 ? {} ,
2026-01-23 08:50:17 -05:00
# Optional: Override any config values
configOverrides ? {} ,
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
# Optional: Custom preConfigure hook
preConfigure ? "" ,
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
# Optional: Custom postInstall hook
postInstall ? "" ,
2026-03-23 23:12:39 +01:00
2026-03-27 14:09:42 +01:00
# 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.
2026-03-23 23:12:39 +01:00
logosStandalone ? null ,
2026-04-02 17:06:21 -04:00
# 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 ,
2026-01-23 08:50:17 -05:00
}:
let
2026-08-21 16:46:22 -03:00
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 ; };
2026-01-23 08:50:17 -05:00
config = common . recursiveMerge [ rawConfig configOverrides ];
2026-03-28 18:01:28 -04:00
2026-08-21 16:46:22 -03:00
configFor = system : common . recursiveMerge [
( parseMetadata . parseModuleConfig {
json = metadataJson ;
platform = parseMetadata . platformForSystem system ;
})
configOverrides
];
2026-03-28 18:01:28 -04:00
# 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)
2026-01-23 08:50:17 -05:00
mkExternalLib = import ./mkExternalLib.nix { inherit lib common ; };
2026-03-23 23:12:39 +01:00
mkStandaloneApp = import ./mkStandaloneApp.nix ;
2026-04-07 10:39:04 -04:00
modulePreConfigure = import ./modulePreConfigure.nix { inherit lib ; };
2026-08-20 00:31:52 -03:00
# 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." );
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
# Helper to get a package from nixpkgs by name
getPkg = pkgs : name :
2026-03-24 17:21:59 +01:00
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 } " ;
2026-01-23 08:50:17 -05:00
forAllSystems = f : lib . genAttrs common . systems ( system : f system );
2026-03-23 23:12:39 +01:00
2026-01-23 08:50:17 -05:00
# Package outputs
packages = forAllSystems ( system :
let
2026-08-11 18:07:50 -03:00
pkgs = common . mkPkgs system ;
2026-08-21 16:46:22 -03:00
config = configFor system ;
2026-03-28 18:01:28 -04:00
2026-08-14 14:45:47 -03:00
# 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 ;
2026-06-21 09:40:23 -03:00
# 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
2026-08-14 14:45:47 -03:00
# 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 ; }
2026-06-21 09:40:23 -03:00
else pkgs . rustPlatform ;
2026-08-14 14:45:47 -03:00
# 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" ;
2026-08-14 18:50:34 -03:00
# 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" ;
2026-08-14 14:45:47 -03:00
};
2026-06-08 11:54:32 -03:00
# ── 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
2026-05-19 15:42:24 -03:00
# 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.
2026-08-14 18:50:19 -03:00
# Shared with buildCppPlugin (view modules) — see common.nix.
resolvedModuleDeps = common . resolveLegacyHeaderDeps {
inherit system flakeInputs ;
depNames = legacyHeaderDepNames ;
};
2026-03-28 18:01:28 -04:00
2026-06-05 20:51:23 -03:00
# 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 ;
2026-04-01 10:21:14 -03:00
# 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 );
2026-03-24 17:21:59 +01:00
buildPkgs = map ( getPkg pkgs ) ( lib . filter builtins . isString config . nix_packages . build );
runtimePkgs = map ( getPkg pkgs ) ( lib . filter builtins . isString config . nix_packages . runtime );
2026-06-16 19:46:06 -03:00
# 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.
2026-08-14 14:45:47 -03:00
# 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 );
2026-06-16 19:46:06 -03:00
rustBuildPkgs = map ( getPkg pkgs ) ( lib . filter builtins . isString config . nix_rust . packages . runtime );
2026-04-01 10:21:14 -03:00
# 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 {
2026-06-09 14:18:03 -03:00
inherit pkgs config src ;
2026-04-01 10:21:14 -03:00
externalInputs = defaultResolvedExternalLibs ;
2026-01-23 08:50:17 -05:00
};
2026-03-28 18:01:28 -04:00
2026-08-14 16:17:11 -03:00
# 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.
#
2026-08-21 16:46:22 -03:00
# `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.
#
2026-08-14 16:17:11 -03:00
# 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
'' ;
2026-04-01 18:09:23 -04:00
# Resolve SDK deps for this system — injected into the backend
logosSdk = logos-cpp-sdk . packages . ${ system } . default ;
2026-08-11 18:07:50 -03:00
# 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 ;
2026-06-12 20:45:37 -03:00
logosQtSdk = logos-qt-sdk . packages . ${ system } . default ;
2026-08-20 00:31:52 -03:00
# 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 ;
2026-06-12 22:32:07 -03:00
# The Qt glue generator (universal/cdylib/ui backends) — Qt code is
# the Qt layer's product; logos-cpp-generator keeps Qt-free outputs.
2026-08-11 18:07:50 -03:00
logosQtGenerator = logos-qt-sdk . packages . ${ common . buildSystemFor system } . logos-qt-generator ;
2026-08-20 00:31:52 -03:00
# 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 ;
2026-08-22 08:57:15 -03:00
# 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 ;
2026-06-12 20:45:37 -03:00
logosProtocolPkg = logos-protocol . packages . ${ system } . default ;
2026-04-01 18:09:23 -04:00
logosModule = logos-module . packages . ${ system } . default ;
2026-06-12 20:45:37 -03:00
# 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 );
2026-06-15 19:59:35 -03:00
# ── 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 ;
2026-08-14 14:45:47 -03:00
# 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 ;
2026-06-15 19:59:35 -03:00
# 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 } " ;
2026-08-21 16:27:11 -03:00
# 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." );
2026-06-15 19:59:35 -03:00
rustScaffold =
if ! isRustModule then null
else pkgs . runCommand "logos- ${ config . name } -rust-scaffold" {
nativeBuildInputs = [ rustGen ];
} ''
mkdir -p $out
2026-06-19 16:57:36 -03:00
logos-lidl-gen " ${ rustLidlPath } " --provider ${ lib . optionalString rustDeriveMode "--no-trait" } \
${ lib . optionalString (( config . concurrency or "single" ) == "multi" ) "--concurrency multi" } ${ rustDepFlags } \
2026-08-21 16:27:11 -03:00
--protocol-version ${ rustProtocolVersion } \
2026-06-15 19:59:35 -03:00
-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
2026-08-14 14:45:47 -03:00
else rustPlatform . buildRustPackage ({
2026-06-15 19:59:35 -03:00
pname = rustStaticName ;
version = config . version ;
src = rustCrateSrc ;
sourceRoot = "logos- ${ config . name } -rust-src/rust-lib" ;
cargoLock = {
lockFile = " ${ rustCrateDir } /Cargo.lock" ;
allowBuiltinFetchGit = true ;
};
2026-06-16 19:46:06 -03:00
# 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.
2026-08-14 14:45:47 -03:00
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 ;
2026-06-16 19:46:06 -03:00
buildInputs = rustBuildPkgs ++ rustExtraBuildInputs ;
2026-08-14 14:45:47 -03:00
env = config . nix_rust . env // rustEnv // rustCrossEnv ;
2026-06-15 19:59:35 -03:00
doCheck = false ;
2026-08-14 14:45:47 -03:00
}
# 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
'' ;
});
2026-06-15 19:59:35 -03:00
# 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/
'' ;
2026-06-12 20:45:37 -03:00
2026-06-14 14:28:08 -03:00
# 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 :
2026-04-01 10:21:14 -03:00
let
externalLibs =
if variant == "default" then defaultExternalLibs
else mkExternalLib . buildExternalLibs {
2026-06-09 14:18:03 -03:00
inherit pkgs config src ;
2026-04-01 10:21:14 -03:00
externalInputs = lib . mapAttrs ( resolveExtInput variant ) externalLibInputs ;
};
2026-06-15 19:59:35 -03:00
# 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.
2026-04-07 10:39:04 -04:00
userPreConfigure =
2026-06-15 19:59:35 -03:00
rustStaging + (
if builtins . isFunction preConfigure
then preConfigure { inherit externalLibs ; }
else preConfigure );
2026-04-07 10:39:04 -04:00
preConfigureStr = modulePreConfigure . compose {
2026-06-12 20:45:37 -03:00
inherit config externalLibs protocolVersion ;
2026-04-07 10:39:04 -04:00
userPre = userPreConfigure ;
2026-06-15 19:59:35 -03:00
# Stage the rust-first derived .lidl before the glue codegen reads it.
preCodegen = lidlStaging ;
2026-04-07 10:39:04 -04:00
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 } "
];
2026-05-19 15:42:24 -03:00
# LOGOS_API_STYLE forwards through to logos-cpp-generator and
2026-06-14 09:25:22 -03:00
# 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
2026-06-15 11:22:57 -03:00
# 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.
2026-06-14 09:25:22 -03:00
# (Only consulted in the source layout; nix builds get apiStyle from
# the backend's --general-only call.)
2026-08-20 00:31:52 -03:00
#
# `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.
2026-06-14 09:25:22 -03:00
apiStyleCmakeFlags =
if config . interface == "universal" && ( config . type or "core" ) != "ui_qml"
2026-08-20 00:31:52 -03:00
&& config . consumer_api_style == "lp"
2026-06-14 09:25:22 -03:00
then [ "-DLOGOS_API_STYLE=lp" ]
2026-06-15 11:22:57 -03:00
else [];
2026-04-01 18:09:23 -04:00
# The backend only knows about Qt + logosModule (interface.h).
# SDK (generator, lib, headers) is injected via extra* args.
2026-06-14 14:28:08 -03:00
in ({
2026-08-14 16:17:11 -03:00
inherit pkgs src config logosModule ;
postInstall = stageIncludedRuntimeFiles + postInstall ;
2026-04-01 10:21:14 -03:00
preConfigure = preConfigureStr ;
moduleDeps = resolvedModuleDeps ;
inherit externalLibs ;
2026-08-11 18:07:50 -03:00
# pkgs.jq is target-typed too and jq runs in preConfigure
# (modulePreConfigure.nix:203). buildPackages == pkgs natively.
2026-08-22 08:57:15 -03:00
extraNativeBuildInputs = extraNativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild logosQtGenerator logosQtHostGenerator logosViewGenerator pkgs . buildPackages . jq ];
2026-08-20 00:31:52 -03:00
extraBuildInputs = extraBuildInputs ++ runtimePkgs ++ [ logosQtSdk logosQtHost logosProtocolPkg ]
2026-08-14 18:50:34 -03:00
# 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 ;
2026-08-11 18:07:50 -03:00
# 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 [ ]) ++ [
2026-06-12 20:45:37 -03:00
"-DLOGOS_CPP_SDK_ROOT= ${ logosSdk } "
"-DLOGOS_QT_SDK_ROOT= ${ logosQtSdk } "
2026-08-20 00:31:52 -03:00
"-DLOGOS_QT_HOST_ROOT= ${ logosQtHost } "
2026-06-12 20:45:37 -03:00
"-DLOGOS_PROTOCOL_ROOT= ${ logosProtocolPkg } "
2026-08-20 00:31:52 -03:00
"-DLOGOS_VIEW_TEMPLATE_DIR= ${ viewTemplates } "
2026-08-22 08:57:15 -03:00
"-DLOGOS_VIEW_INCLUDE_DIR= ${ logosViewInclude } "
2026-06-15 19:59:35 -03:00
] ++ goCmakeFlags ++ apiStyleCmakeFlags
++ lib . optionals isRustModule [ "-DLOGOS_MODULE_RUST_STATIC_LIBS= ${ rustStaticName } " ];
2026-04-07 10:39:04 -04:00
extraEnv = {
LOGOS_CPP_SDK_ROOT = " ${ logosSdk } " ;
2026-06-12 20:45:37 -03:00
LOGOS_QT_SDK_ROOT = " ${ logosQtSdk } " ;
2026-08-20 00:31:52 -03:00
LOGOS_QT_HOST_ROOT = " ${ logosQtHost } " ;
2026-06-12 20:45:37 -03:00
LOGOS_PROTOCOL_ROOT = " ${ logosProtocolPkg } " ;
2026-08-20 00:31:52 -03:00
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 } " ;
2026-08-22 08:57:15 -03:00
LOGOS_VIEW_INCLUDE_DIR = " ${ logosViewInclude } " ;
2026-04-07 10:39:04 -04:00
};
2026-06-05 20:51:23 -03:00
}
# Only pass interfaceDeps when the module declares any — keeps existing
# dependency-only modules buildable against a backend that predates the
2026-06-15 19:59:35 -03:00
# 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.
2026-06-05 20:51:23 -03:00
// lib . optionalAttrs ( config . interface_dependencies != []) {
interfaceDeps = resolvedInterfaceDeps ;
2026-06-08 11:54:32 -03:00
}
# 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 ;
2026-06-05 20:51:23 -03:00
});
2026-04-01 10:21:14 -03:00
2026-06-14 14:28:08 -03:00
# Compile the plugin for a variant (delegated to the backend).
buildVariant = variant : selectedBackend . buildPlugin ( mkPluginArgs variant );
2026-04-01 10:21:14 -03:00
moduleLib = buildVariant "default" ;
moduleLibPortable = if hasVariants then buildVariant "portable" else null ;
2026-03-28 18:01:28 -04:00
2026-06-14 14:28:08 -03:00
# 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" );
2026-07-30 21:25:13 -03:00
# Two header variants per module — Qt-typed and lp (Qt-free,
# logos-protocol C ABI). Each is its own Nix derivation, so a
2026-06-14 09:25:22 -03:00
# 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`.
2026-07-30 21:25:13 -03:00
# (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.)
2026-08-11 18:07:50 -03:00
# 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 ;
2026-08-20 00:31:52 -03:00
# `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.
2026-05-19 15:42:24 -03:00
moduleIncludeQt = selectedBackend . buildHeaders {
2026-08-11 18:07:50 -03:00
inherit pkgs src config ;
2026-08-20 00:31:52 -03:00
# buildHeaders uses these ONLY to put a generator on PATH -- a pure
# tool role, hence the BUILD-platform variants under cross.
2026-08-11 18:07:50 -03:00
logosSdk = logosSdkBuild ;
2026-08-20 00:31:52 -03:00
qtGenerator = logosQtGenerator ;
2026-03-28 18:01:28 -04:00
pluginLib = moduleLib ;
2026-05-19 15:42:24 -03:00
apiStyle = "qt" ;
2026-08-11 18:07:50 -03:00
contractLidl = headerContractLidl ;
2026-05-19 15:42:24 -03:00
};
2026-06-14 09:25:22 -03:00
moduleIncludeLp = selectedBackend . buildHeaders {
2026-08-11 18:07:50 -03:00
inherit pkgs src config ;
2026-08-20 00:31:52 -03:00
# 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.
2026-08-11 18:07:50 -03:00
logosSdk = logosSdkBuild ;
2026-06-14 09:25:22 -03:00
pluginLib = moduleLib ;
apiStyle = "lp" ;
2026-08-11 18:07:50 -03:00
contractLidl = headerContractLidl ;
2026-06-14 09:25:22 -03:00
};
2026-03-28 18:01:28 -04:00
2026-06-08 11:54:32 -03:00
# 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" {
2026-08-11 18:07:50 -03:00
nativeBuildInputs = [ logosSdkBuild ];
2026-06-08 11:54:32 -03:00
} ''
mkdir -p $out
logos-cpp-generator --header-to-lidl " ${ src } / ${ lidlImplHeaderRel } " \
--impl-class " ${ lidlImplClass } " \
--metadata " ${ configFile } " \
-o "$out/ ${ config . name } .lidl"
''
2026-06-15 19:59:35 -03:00
# 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"
''
2026-06-12 22:32:07 -03:00
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"
''
2026-06-08 11:54:32 -03:00
else null ;
2026-05-19 15:42:24 -03:00
# 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.
2026-04-01 10:21:14 -03:00
combined = ( pkgs . runCommand "logos- ${ config . name } -module" {} ''
2026-01-23 09:08:16 -05:00
mkdir -p $out/lib $out/include
2026-03-28 18:01:28 -04:00
2026-01-23 09:08:16 -05:00
# Copy library files (not symlinks)
if [ -d " ${ moduleLib } /lib" ]; then
cp -rL ${ moduleLib } /lib/* $out/lib/
fi
2026-03-28 18:01:28 -04:00
2026-03-12 16:01:05 -04:00
# Copy include files (not symlinks) — use find to avoid nullglob issues
2026-05-19 15:42:24 -03:00
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/
2026-01-23 09:08:16 -05:00
fi
2026-04-01 10:21:14 -03:00
'' ) // { inherit src ; version = config . version ; };
2026-03-28 18:01:28 -04:00
2026-01-23 08:50:17 -05:00
in {
2026-01-23 09:08:16 -05:00
# Individual outputs (e.g., nix build .#chat-lib)
2026-01-23 08:50:17 -05:00
" ${ config . name } -lib" = moduleLib ;
2026-05-19 15:42:24 -03:00
" ${ config . name } -include" = moduleIncludeQt ;
" ${ config . name } -headers-qt" = moduleIncludeQt ;
2026-06-14 09:25:22 -03:00
" ${ config . name } -headers-lp" = moduleIncludeLp ;
2026-03-28 18:01:28 -04:00
2026-01-23 09:08:16 -05:00
# Short aliases (e.g., nix build .#lib)
2026-01-23 08:50:17 -05:00
lib = moduleLib ;
2026-05-19 15:42:24 -03:00
include = moduleIncludeQt ;
headers-qt = moduleIncludeQt ;
2026-06-14 09:25:22 -03:00
headers-lp = moduleIncludeLp ;
2026-03-28 18:01:28 -04:00
2026-01-23 09:08:16 -05:00
# Default package - combined lib + include (nix build)
2026-01-23 08:50:17 -05:00
default = combined ;
2026-06-14 14:28:08 -03:00
# 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 ;
2026-04-01 10:21:14 -03:00
} // lib . optionalAttrs ( moduleLibPortable != null ) {
" ${ config . name } -lib-portable" = moduleLibPortable ;
lib-portable = moduleLibPortable ;
2026-06-08 11:54:32 -03:00
} // 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 ;
2026-01-23 08:50:17 -05:00
}
);
2026-03-28 18:01:28 -04:00
# Development shell (delegates to backend for deps)
2026-01-23 08:50:17 -05:00
devShells = forAllSystems ( system :
let
2026-08-11 18:07:50 -03:00
pkgs = common . mkPkgs system ;
2026-08-21 16:46:22 -03:00
config = configFor system ;
2026-04-01 18:09:23 -04:00
logosSdk = logos-cpp-sdk . packages . ${ system } . default ;
2026-08-11 18:07:50 -03:00
# 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 ;
2026-06-12 20:45:37 -03:00
logosQtSdk = logos-qt-sdk . packages . ${ system } . default ;
2026-08-20 00:31:52 -03:00
# Same repoint in the dev shell: LOGOS_QT_HOST_ROOT below.
logosQtHost = logos-plugin-qt . packages . ${ system } . logos-qt-host ;
2026-06-12 22:32:07 -03:00
# The Qt glue generator (universal/cdylib/ui backends) — Qt code is
# the Qt layer's product; logos-cpp-generator keeps Qt-free outputs.
2026-08-11 18:07:50 -03:00
logosQtGenerator = logos-qt-sdk . packages . ${ common . buildSystemFor system } . logos-qt-generator ;
2026-08-20 00:31:52 -03:00
# 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 ;
2026-08-22 08:57:15 -03:00
# 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 ;
2026-06-12 20:45:37 -03:00
logosProtocolPkg = logos-protocol . packages . ${ system } . default ;
2026-04-01 18:09:23 -04:00
logosModule = logos-module . packages . ${ system } . default ;
2026-06-12 20:45:37 -03:00
2026-04-01 18:09:23 -04:00
backendShell = selectedBackend . devShellInputs pkgs { inherit logosModule ; };
2026-01-23 08:50:17 -05:00
buildPkgs = map ( getPkg pkgs ) config . nix_packages . build ;
runtimePkgs = map ( getPkg pkgs ) config . nix_packages . runtime ;
2026-06-17 15:40:04 +02:00
# Resolve external lib inputs for this system so we can point cmake directly
2026-06-17 16:28:01 +02:00
# at their Nix store paths via LOGOS_EXT_ROOT_<NAME>, skipping the ./lib/ staging copy.
2026-06-17 15:40:04 +02:00
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 );
2026-01-23 08:50:17 -05:00
in {
default = pkgs . mkShell {
2026-08-22 08:57:15 -03:00
nativeBuildInputs = backendShell . nativeBuildInputs ++ buildPkgs ++ [ logosSdkBuild logosViewGenerator ];
2026-06-17 15:40:04 +02:00
buildInputs = backendShell . buildInputs ++ runtimePkgs ++ lib . attrValues devExternalLibs ;
2026-01-23 08:50:17 -05:00
shellHook = ''
2026-03-28 18:01:28 -04:00
${ backendShell . shellHook }
2026-04-01 18:09:23 -04:00
export LOGOS_CPP_SDK_ROOT=" ${ logosSdk } "
2026-06-12 20:45:37 -03:00
export LOGOS_QT_SDK_ROOT=" ${ logos-qt-sdk . packages . ${ system } . default } "
2026-08-20 00:31:52 -03:00
export LOGOS_QT_HOST_ROOT=" ${ logosQtHost } "
2026-06-12 20:45:37 -03:00
export LOGOS_PROTOCOL_ROOT=" ${ logos-protocol . packages . ${ system } . default } "
2026-08-20 00:31:52 -03:00
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 } "
2026-08-22 08:57:15 -03:00
export LOGOS_VIEW_INCLUDE_DIR=" ${ logosViewInclude } "
2026-06-17 15:40:04 +02:00
${ lib . concatStringsSep " \n " ( lib . mapAttrsToList ( name : drv : ''
2026-06-17 16:28:01 +02:00
export LOGOS_EXT_ROOT_ ${ lib . toUpper name } =" ${ drv } "
2026-06-17 15:40:04 +02:00
'' ) devExternalLibs ) }
2026-01-23 08:50:17 -05:00
echo "Logos ${ config . name } module development environment"
echo "LOGOS_CPP_SDK_ROOT: $LOGOS_CPP_SDK_ROOT"
2026-03-20 08:49:31 -04:00
echo "LOGOS_MODULE_ROOT: $LOGOS_MODULE_ROOT"
2026-03-25 20:56:13 +01:00
echo "LOGOS_MODULE_BUILDER_ROOT: $LOGOS_MODULE_BUILDER_ROOT"
2026-01-23 08:50:17 -05:00
'' ;
};
}
);
2026-03-23 23:12:39 +01:00
2026-03-27 11:02:41 +01:00
# LGX package outputs (nix-bundle-lgx provided by the builder)
nixBundleLgx = nix-bundle-lgx ;
2026-03-26 11:27:34 +01:00
optionalLgx =
2026-03-27 11:02:41 +01:00
{
2026-03-26 11:27:34 +01:00
packages = forAllSystems ( system :
let
bundleLgx = nixBundleLgx . bundlers . ${ system } . default ;
bundleLgxPortable = nixBundleLgx . bundlers . ${ system } . portable ;
2026-03-31 17:31:25 -03:00
installDev = nix-bundle-logos-module-install . bundlers . ${ system } . dev ;
installPortable = nix-bundle-logos-module-install . bundlers . ${ system } . portable ;
2026-03-26 11:27:34 +01:00
moduleLib = packages . ${ system } . lib ;
2026-04-01 10:21:14 -03:00
# Use the portable-linked plugin for lgx-portable when available
moduleLibForPortable =
packages . ${ system } . lib-portable or moduleLib ;
2026-03-26 11:27:34 +01:00
in {
lgx = bundleLgx moduleLib ;
2026-03-31 17:31:25 -03:00
install = installDev moduleLib ;
2026-04-01 10:21:14 -03:00
lgx-portable = bundleLgxPortable moduleLibForPortable ;
install-portable = installPortable moduleLibForPortable ;
2026-03-26 11:27:34 +01:00
}
);
};
2026-03-27 14:09:42 +01:00
# 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 ;
2026-03-23 23:12:39 +01:00
optionalApps =
2026-03-27 14:09:42 +01:00
if resolvedStandalone == null then {}
2026-03-23 23:12:39 +01:00
else {
apps = forAllSystems ( system :
2026-03-27 14:09:42 +01:00
let
2026-08-11 18:07:50 -03:00
pkgs = common . mkPkgs system ;
2026-08-21 16:46:22 -03:00
config = configFor system ;
2026-03-27 14:09:42 +01:00
# Collect all module dependencies (direct + transitive) for bundling
allDeps = common . collectAllModuleDeps system flakeInputs config . dependencies ;
2026-03-23 23:12:39 +01:00
in {
default = mkStandaloneApp {
2026-03-24 17:21:59 +01:00
inherit pkgs ;
2026-03-27 14:09:42 +01:00
standalone = resolvedStandalone . packages . ${ system } . default ;
2026-03-24 17:21:59 +01:00
plugin = packages . ${ system } . default ;
metadataFile = configFile ;
dirName = "logos- ${ config . name } -plugin-dir" ;
format = "qt-plugin" ;
2026-03-27 14:09:42 +01:00
moduleDeps = allDeps ;
2026-03-23 23:12:39 +01:00
};
}
);
};
2026-03-27 11:02:41 +01:00
# Merge LGX outputs into packages
mergedPackages = lib . mapAttrs ( system : sysPkgs :
sysPkgs // ( optionalLgx . packages . ${ system } or {})
) packages ;
2026-03-26 11:27:34 +01:00
2026-04-02 17:06:21 -04:00
# Build unit tests — explicit config wins, otherwise auto-detect tests/CMakeLists.txt
mkTests = import ./mkLogosModuleTests.nix {
inherit nixpkgs lib common parseMetadata ;
2026-08-20 00:31:52 -03:00
inherit logos-cpp-sdk logos-protocol logos-qt-sdk logos-plugin-qt ;
2026-08-22 08:57:15 -03:00
# Source of logos-view-generator: a ui_qml module's unit tests run the same
# autoCodegen the plugin build does.
inherit logos-view-module ;
2026-04-02 17:06:21 -04:00
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 {
2026-04-06 10:59:03 +04:00
inherit src flakeInputs externalLibInputs ;
2026-04-02 17:06:21 -04:00
configFile = configFile ;
testDir = resolvedTests . dir ;
mockCLibs = resolvedTests . mockCLibs or [];
2026-04-06 10:59:03 +04:00
preConfigure = resolvedTests . preConfigure or preConfigure ;
2026-04-02 17:06:21 -04:00
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 ;
2026-03-23 23:12:39 +01:00
in {
2026-04-02 17:06:21 -04:00
packages = finalPackages ;
2026-03-26 11:27:34 +01:00
inherit devShells config ;
2026-08-21 16:46:22 -03:00
# 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 ;
2026-04-02 17:06:21 -04:00
} // optionalApps // optionalTests