mirror of
https://github.com/logos-co/logos-module-builder.git
synced 2026-08-30 19:51:07 +00:00
Nothing in metadata.json was platform-keyed, so every platform decision
lived in nix or CMake and modules declared cross-platform supersets by
hand: `include` lists the .so, .dylib AND .dll spellings side by side, and
logos-package-downloader-module already forgot its .dll.
A module may now declare an ORDERED list of selector-keyed overlays:
"nix": {
"packages": { "runtime": ["nlohmann_json"] },
"platforms": [
{ "when": { "os": "linux" }, "packages": { "runtime": ["krb5"] } },
{ "when": { "os": "windows", "architecture": "x86_64", "abi": "gnu" },
"cmake": { "extra_link_libraries": ["ws2_32","bcrypt","ntdll"] } }
]
}
Each of os/architecture/abi is independently optional, so one selector
spells a full variant, a whole OS, or an architecture everywhere. Every
matching entry applies in declaration order; lists concatenate and
attrsets recurse. Recursion rather than overwrite is deliberate and is
argued at resolvePlatforms.nix:20 — every overlay-able key under `nix` is
an object, so literal overwrite would drop packages.build in the krb5 case
above and make "lists concatenate" unreachable for the whole block.
Resolution happens over the raw JSON tree BEFORE the existing parse, so
the 300-line body of parseMetadata.nix runs unchanged over the resolved
answer and config.* keeps its exact flat shape. No consumer below the
parse changed.
The design rule throughout is that a selector must never fail SILENTLY,
because a selector that never fires looks exactly like a platform that
needs nothing:
* `parseModuleConfig` now takes `{ json, platform }`, both required, and
validates the platform on EVERY path — including the early return for
a module with no overlays at all. Guarding only the overlay path would
make the guarantee conditional on file content: authored in one repo,
discovered months later in another.
* An unrecognised os/architecture/abi throws with an alias hint, and so
does a component-wise-valid combination that exists on no target
({darwin, aarch64, gnu} — darwin's abi is "unknown").
* A `platforms` key anywhere it is not read throws naming the path, and
the sweep covers near-misses like `platform` (singular), which is one
character from the mistake it exists to prevent.
* Only `include` is overlay-able at the top level for now. `main` and
`dependencies` resolve for the BUILD but the shipped manifest is the
verbatim source file and the LogosModules umbrella is generated from
the raw dependencies array, so a core module keying them would build
one plugin and ship a manifest naming another. The precondition for
re-admitting each is recorded as data, not prose, so the reason
travels with the restriction.
396 assertions (was 369 reported, of which one asserted nothing — an
assertBool missing its third argument made the element a lambda, and
deepSeq does not force lambdas; tests/default.nix now refuses any element
that is not a bool). All seven checks pass.
Proven inert for the existing tree: field-by-field against master's parser
across 24 config keys x 102 modules x 5 targets, 0 differing; and through
the real mkLogosModule, a non-matching overlay yields a byte-identical
derivation to no overlay at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
14 KiB
Nix
281 lines
14 KiB
Nix
# Common utilities shared across all builder functions (backend-agnostic)
|
|
# Qt-specific build deps and cmake flags now live in the plugin backend.
|
|
{ lib, nix-bundle-lgx ? null, nixpkgs ? null, logos-nix ? null }:
|
|
|
|
let
|
|
# Recursively collect all module dependencies (direct + transitive) from flake
|
|
# inputs, using each module's exported config.dependencies to walk the tree.
|
|
# Returns a flat attrset: { moduleName = lgxDerivation; ... }
|
|
# Uses the LGX package output (packages.lgx) which bundles the plugin plus
|
|
# any external libraries it depends on. When a dependency lacks packages.lgx,
|
|
# it is automatically bundled into an LGX package using nix-bundle-lgx.
|
|
#
|
|
# system: target system string (e.g. "x86_64-linux")
|
|
# inputs: flake inputs attrset to search for dependency modules
|
|
# depNames: list of dependency name strings to resolve
|
|
collectAllModuleDeps = system: inputs: depNames:
|
|
let
|
|
depInputs = lib.filterAttrs (n: _: builtins.elem n depNames) inputs;
|
|
|
|
# Bundle a derivation into LGX on the fly using nix-bundle-lgx.
|
|
# Fails fast if nix-bundle-lgx is unavailable — a silent fallback would
|
|
# cause mkStandaloneApp to silently omit the dependency at runtime.
|
|
autoBundleLgx = drv:
|
|
if nix-bundle-lgx == null then
|
|
builtins.throw "collectAllModuleDeps: dependency lacks packages.${system}.lgx and nix-bundle-lgx is not available to auto-bundle it. Either add an lgx output to the dependency or ensure nix-bundle-lgx is passed to common.nix."
|
|
else if !(nix-bundle-lgx ? bundlers.${system}.default) then
|
|
builtins.throw "collectAllModuleDeps: nix-bundle-lgx does not provide a bundler for system ${system}."
|
|
else
|
|
nix-bundle-lgx.bundlers.${system}.default drv;
|
|
|
|
direct = lib.mapAttrs (depName: input:
|
|
if input ? packages.${system}.lgx
|
|
then input.packages.${system}.lgx
|
|
else if input ? packages.${system}.lib
|
|
then autoBundleLgx input.packages.${system}.lib
|
|
else if input ? packages.${system}.default
|
|
then autoBundleLgx input.packages.${system}.default
|
|
# A flake that publishes `packages` but nothing usable for THIS system is
|
|
# an error, not a fallback -- the same hazard the autoBundleLgx throws
|
|
# above already guard against, one level out. Falling through to `input`
|
|
# puts the dependency's SOURCE TREE into the app bundle where an LGX
|
|
# package belongs: mkStandaloneApp then ships a directory of .cpp files
|
|
# in place of a module and the failure only shows up at runtime, as a
|
|
# module that never loads.
|
|
#
|
|
# Only a genuinely bare-derivation input (no `packages` attr at all) may
|
|
# take the fallback below.
|
|
else if input ? packages then
|
|
builtins.throw ''
|
|
collectAllModuleDeps: dependency '${depName}' publishes no usable package for ${system}.
|
|
|
|
It exposes systems: ${lib.concatStringsSep ", " (builtins.attrNames input.packages)}
|
|
${lib.optionalString (input.packages ? ${system})
|
|
"and for ${system}: ${lib.concatStringsSep ", " (builtins.attrNames input.packages.${system})} (none of lgx / lib / default)"}
|
|
|
|
Fix: give '${depName}' a ${system} target and re-pin it, or publish an
|
|
`lgx`/`lib`/`default` output for it. For a cross target that is usually
|
|
a one-line change to the systems list its flake folds `packages` over.
|
|
''
|
|
else input
|
|
) depInputs;
|
|
|
|
transitive = builtins.foldl' (acc: name:
|
|
let
|
|
input = depInputs.${name};
|
|
# `configFor.<system>` is the dependency's PLATFORM-RESOLVED config;
|
|
# `config` is its system-agnostic one, which cannot answer for a
|
|
# platform-keyed `dependencies` and throws when asked. Prefer the
|
|
# resolved output when the dependency publishes one, and fall back to
|
|
# `config` for a dependency pinned to a builder that predates it —
|
|
# that fallback is exactly right, because a module built by an older
|
|
# builder cannot have had platform overlays applied either.
|
|
tdeps =
|
|
if input ? configFor && input.configFor ? ${system}
|
|
then input.configFor.${system}.dependencies or []
|
|
else (input.config or {}).dependencies or [];
|
|
tinputs = input.inputs or {};
|
|
in
|
|
if tdeps == [] then acc
|
|
else acc // (collectAllModuleDeps system tinputs tdeps)
|
|
) {} (builtins.attrNames depInputs);
|
|
in
|
|
# direct overrides transitive so the closest (most specific) dep wins
|
|
transitive // direct;
|
|
|
|
# Supported target systems. "x86_64-windows" is a PSEUDO-system: a cross
|
|
# derivation's `system` attribute is its BUILD platform, so this evaluates
|
|
# anywhere but only realises on x86_64-linux.
|
|
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]
|
|
++ lib.optional (logos-nix != null) "x86_64-windows";
|
|
|
|
# THE package-set constructor. Every module's pkgs comes from here, which is
|
|
# what lets ~40 modules target Windows without each re-deriving the cross
|
|
# plumbing.
|
|
#
|
|
# x86_64-windows cannot be produced by `import nixpkgs { system = ...; }` --
|
|
# it needs localSystem/crossSystem plus logos-nix's mingw overlays, which is
|
|
# exactly what logos-nix.lib.mkWindowsPkgs wraps.
|
|
mkPkgsWith = extraOverlays: system:
|
|
if system != "x86_64-windows" then
|
|
import nixpkgs { inherit system; overlays = extraOverlays; }
|
|
else if logos-nix == null then
|
|
throw ("logos-module-builder: targeting x86_64-windows requires the "
|
|
+ "logos-nix input to be threaded into the builder lib.")
|
|
else if extraOverlays != [ ] then
|
|
# Rather than silently drop them and hand back a package set that is not
|
|
# what the caller asked for. mkWindowsPkgs owns its overlay list (the
|
|
# mingw cross fixes); teach it to merge before removing this.
|
|
throw ("logos-module-builder: overlays are not yet supported for the "
|
|
+ "x86_64-windows target (requested "
|
|
+ toString (builtins.length extraOverlays) + ").")
|
|
else
|
|
logos-nix.lib.mkWindowsPkgs { buildSystem = windowsBuildSystem; };
|
|
|
|
mkPkgs = mkPkgsWith [ ];
|
|
|
|
# The build platform Windows artifacts are produced FROM.
|
|
#
|
|
# Single-sourced from logos-nix, which owns the decision and the reasoning
|
|
# for it (`windowsBuildSystems`: wine does not exist for aarch64-darwin, and
|
|
# upstream only exercises mingw cross from x86_64-linux). It was previously
|
|
# a bare "x86_64-linux" literal repeated here in two places -- a second copy
|
|
# of someone else's constant, free to drift from it.
|
|
#
|
|
# Deliberately NOT the evaluating system. Pinning it keeps
|
|
# `packages.x86_64-windows.*` one well-defined derivation no matter who
|
|
# evaluates it, so a Darwin and a Linux checkout agree and share a cache; a
|
|
# Darwin dev realises it through a Linux remote builder. Widening this is a
|
|
# change to `windowsBuildSystems` in logos-nix, not an edit here.
|
|
windowsBuildSystem =
|
|
if logos-nix == null then "x86_64-linux"
|
|
else lib.head logos-nix.lib.windowsBuildSystems;
|
|
|
|
# The system a build for `target` actually RUNS on. Host TOOLS -- the code
|
|
# generators, moc, repc -- must come from here, never from the target set:
|
|
# under cross, `packages.x86_64-windows.logos-qt-generator` is a PE that the
|
|
# Linux builder cannot execute ("logos-cpp-generator: command not found").
|
|
# Identity for every native system, so callers need no isWindows test.
|
|
buildSystemFor = target:
|
|
if target == "x86_64-windows" then windowsBuildSystem else target;
|
|
|
|
# Resolve the TRANSITIONAL header-copy dependencies (deps publishing no `lidl`
|
|
# contract) from flake inputs, as a struct exposing the dep's plugin (.lib)
|
|
# plus the header variant matching the consumer's --api-style.
|
|
#
|
|
# ONE implementation on purpose. This logic used to be copy-pasted into
|
|
# mkLogosModule.nix (core modules) and buildCppPlugin.nix (ui_qml view
|
|
# modules), and a fix applied to one silently missed the other -- which is
|
|
# exactly how the missing-system case below went unnoticed for view modules.
|
|
# Remove the whole thing once every module publishes a `lidl` output.
|
|
resolveLegacyHeaderDeps = { system, flakeInputs, depNames }:
|
|
lib.mapAttrs (depName: input:
|
|
let
|
|
# An lp (Qt-free) consumer must NOT silently fall back to a Qt-typed
|
|
# header set. The wrappers would declare QString/QVariantMap while the
|
|
# consumer's own codegen ran with `--api-style lp`, so the build dies
|
|
# deep inside a generated TU with a wall of unrelated-looking Qt type
|
|
# errors. Lazy: only fires if an lp consumer really reads `headers-lp`.
|
|
staleLpDep = reason: throw ''
|
|
logos-module-builder: dependency '${depName}' cannot be consumed by an lp (Qt-free) module.
|
|
|
|
'${depName}' is taking the transitional header-copy path (it publishes
|
|
no `lidl` output), and
|
|
${reason}.
|
|
So the only headers it offers are Qt-typed. Copying those into a
|
|
Qt-free translation unit fails deep inside a generated source file
|
|
with a wall of unrelated-looking Qt type errors, so this build stops
|
|
here instead.
|
|
|
|
Fix: rebuild / re-pin '${depName}' against a current logos-module-builder.
|
|
Any module built by one publishes a `lidl` contract (preferred — it
|
|
skips the header copy entirely) as well as a `headers-lp` output.
|
|
'';
|
|
|
|
# A dep flake that publishes `packages` but nothing for THIS system is an
|
|
# error, not a fallback. Degrading to `input` hands the plugin build the
|
|
# dependency's SOURCE TREE as its header root, and the failure surfaces
|
|
# far away as `fatal error: <dep>_api.h: No such file or directory` in a
|
|
# generated TU -- or, worse, silently succeeds against whatever stale
|
|
# headers happen to be checked in.
|
|
#
|
|
# This is how chat_ui's Windows build failed: chat_module v0.2.2
|
|
# publishes only the four native systems, so an x86_64-windows consumer
|
|
# resolved its headers to the chat_module checkout.
|
|
#
|
|
# Only a genuinely bare-derivation input (no `packages` attr at all) may
|
|
# take the fallback path -- the pre-refactor shape it exists for.
|
|
ps =
|
|
if input ? packages && !(input.packages ? ${system}) then
|
|
throw ''
|
|
logos-module-builder: dependency '${depName}' publishes no packages for ${system}.
|
|
|
|
It exposes: ${lib.concatStringsSep ", " (builtins.attrNames input.packages)}
|
|
|
|
'${depName}' is taking the transitional header-copy path (it
|
|
publishes no `lidl` output), so this build needs its compiled
|
|
headers for ${system} and there are none.
|
|
|
|
Fix: give '${depName}' a ${system} target and re-pin it. For a
|
|
cross target that means adding ${system} to the systems list its
|
|
flake folds `packages` over -- mkLogosModule already understands
|
|
the target, so it is usually a one-line change in that flake.
|
|
''
|
|
else input.packages.${system} or null;
|
|
|
|
# Pre-version of this refactor: input was the raw flake-output derivation
|
|
# (not a packages set). Preserve that path so an external flake-input dep
|
|
# still works.
|
|
fallback = if input ? packages.${system}.default
|
|
then input.packages.${system}.default else input;
|
|
in
|
|
if ps != null then {
|
|
default = ps.default;
|
|
lib = ps.lib or ps.default;
|
|
headers-qt = ps.headers-qt or ps.include or ps.default;
|
|
headers-lp = ps.headers-lp or (staleLpDep "its packages.${system} exposes no `headers-lp`");
|
|
} else {
|
|
default = fallback;
|
|
lib = fallback;
|
|
headers-qt = fallback;
|
|
headers-lp = staleLpDep "the flake input is a bare derivation with no packages.${system} attrset";
|
|
}
|
|
) (lib.filterAttrs (n: _: builtins.elem n depNames) flakeInputs);
|
|
|
|
# Helper to run a function for all systems
|
|
forAllSystems = _nixpkgs: f:
|
|
lib.genAttrs systems (system: f {
|
|
inherit system;
|
|
pkgs = mkPkgs system;
|
|
});
|
|
|
|
in {
|
|
inherit systems mkPkgs mkPkgsWith forAllSystems buildSystemFor resolveLegacyHeaderDeps;
|
|
|
|
inherit collectAllModuleDeps;
|
|
|
|
|
|
|
|
# Determine library extension based on platform
|
|
getLibExtension = pkgs:
|
|
if pkgs.stdenv.hostPlatform.isDarwin then "dylib"
|
|
else if pkgs.stdenv.hostPlatform.isWindows then "dll"
|
|
else "so";
|
|
|
|
# Get the library filename for a module
|
|
getPluginFilename = pkgs: name:
|
|
"${name}_plugin.${if pkgs.stdenv.hostPlatform.isDarwin then "dylib" else "so"}";
|
|
|
|
# Convert module name to various formats
|
|
nameFormats = name: {
|
|
# my_module -> my_module
|
|
snake = name;
|
|
# my_module -> MyModule
|
|
pascal = lib.concatMapStrings (s: lib.toUpper (lib.substring 0 1 s) + lib.substring 1 (-1) s)
|
|
(lib.splitString "_" name);
|
|
# my_module -> myModule
|
|
camel = let
|
|
parts = lib.splitString "_" name;
|
|
first = lib.head parts;
|
|
rest = lib.tail parts;
|
|
in first + lib.concatMapStrings (s: lib.toUpper (lib.substring 0 1 s) + lib.substring 1 (-1) s) rest;
|
|
# my_module -> MY_MODULE
|
|
upper = lib.toUpper (lib.replaceStrings ["-"] ["_"] name);
|
|
};
|
|
|
|
# Merge two attribute sets recursively
|
|
recursiveMerge = attrList:
|
|
let
|
|
f = attrPath:
|
|
lib.zipAttrsWith (n: values:
|
|
if lib.tail values == []
|
|
then lib.head values
|
|
else if lib.all lib.isList values
|
|
then lib.unique (lib.concatLists values)
|
|
else if lib.all lib.isAttrs values
|
|
then f (attrPath ++ [n]) values
|
|
else lib.last values
|
|
);
|
|
in f [] attrList;
|
|
}
|