Files
logos-module-builder/lib/modulePreConfigure.nix
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

267 lines
13 KiB
Nix

# Shell snippets prepended to mkLogosModule / mkLogosModuleTests preConfigure.
{ lib }:
let
# accounts_module -> AccountsModuleImpl
defaultImplClassFromName = moduleName:
let
parts = lib.filter (s: s != "") (lib.splitString "_" moduleName);
cap = s:
if s == "" then ""
else lib.toUpper (lib.substring 0 1 s) + lib.substring 1 (builtins.stringLength s - 1) s;
in
lib.concatStrings (map cap parts) + "Impl";
# Copy resolved external library outputs into ./lib for CMake EXTERNAL_LIBS / includes
copyExternalLibsToLib = externalLibs:
let
names = builtins.attrNames externalLibs;
one = name:
let
v = externalLibs.${name};
in
if v == null then ""
else ''
if [ -d "${v}/lib" ]; then
cp -f "${v}"/lib/* lib/ 2>/dev/null || true
fi
# Windows ships a shared library's runtime half in bin/ (CMake's
# RUNTIME destination). Mirror of the staging in
# logos-plugin-qt/lib/buildPlugin.nix -- see the longer note there
# for why only this library's own files are taken, not all of bin/.
if [ -d "${v}/bin" ]; then
for f in "${v}"/bin/lib${name}.dll "${v}"/bin/${name}.dll; do
[ -f "$f" ] && cp -fL "$f" lib/ 2>/dev/null || true
done
fi
if [ -d "${v}/include" ]; then
cp -f "${v}"/include/*.h lib/ 2>/dev/null || true
fi
'';
in
if names == [] then ""
else ''
mkdir -p lib
${lib.concatMapStringsSep "\n" one names}
'';
# macOS: fix install_name on copied dylibs so tests/runtime resolve via RPATH
fixupDarwinDylibs = ''
if [ "$(uname -s)" = Darwin ]; then
for f in lib/*.dylib; do
[ -f "$f" ] || continue
bn=$(basename "$f")
install_name_tool -id "@rpath/$bn" "$f" 2>/dev/null || true
done
fi
'';
universalCodegen = config:
let
cg = config.codegen or {};
implClass = cg.impl_class or (defaultImplClassFromName config.name);
ihRaw = cg.impl_header or "${config.name}_impl.h";
fromPath =
if lib.hasInfix "/" ihRaw then ihRaw else "src/${ihRaw}";
# Include string embedded in generated glue (basename when path is qualified)
implHeaderInclude =
if lib.hasInfix "/" ihRaw then builtins.baseNameOf ihRaw else ihRaw;
in
''
echo "logos-module-builder: generating universal module glue (${config.name})..."
# Universal modules are header-first cdylibs: same Qt-free mechanism as
# the `cdylib` interface, but the LIDL contract is DERIVED from the impl
# header instead of hand-committed. The author still writes only the
# impl class (deriving LogosModuleContext); the module's own TUs stay
# Qt-free and its outbound modules().<dep> calls go through the lp_* C
# ABI (apiStyle=lp). Qt is confined to the generated uniform glue.
#
# 1. Derive the LIDL contract from the impl header. Doubles as the
# published events sidecar consumed by dependents' typed-event codegen.
logos-cpp-generator --header-to-lidl "${fromPath}" \
--impl-class ${implClass} \
--metadata metadata.json \
-o ./generated_code/${config.name}.lidl
# 2. The uniform Qt-plugin glue over the common module-impl C ABI
# (logos_host loads it unchanged — load ABI preserved).
logos-qt-host-generator --lidl ./generated_code/${config.name}.lidl \
--backend cdylib \
${lib.optionalString ((config.concurrency or "single") == "multi") "--concurrency multi"} \
--output-dir ./generated_code
# 3. The Qt-FREE C-ABI export wrapper (+ typed event emitters) around
# the hand-written impl class.
# No --concurrency here: the C++ cdylib's logos_module_dispatch is
# already safe to call concurrently (no lock across the handler), so the
# multi worker pool lives entirely in the Qt glue above. The author owns
# thread-safety of the impl's methods under concurrency:"multi".
logos-cpp-generator --lidl ./generated_code/${config.name}.lidl \
--backend cdylib \
--impl-class ${implClass} \
--impl-header ${implHeaderInclude} \
--output-dir ./generated_code
'';
# Cdylib authoring: the module is (or wraps) a cdylib exporting the common
# module-impl C ABI (logos_module_impl.h in logos-protocol). The generator
# emits only the uniform Qt-plugin glue from the LIDL contract; the C
# exports come from the module's own language backend — the C++ SDK's
# `--from-header --backend cdylib` wrapper or the Rust SDK's
# `lidl-gen --provider`. The glue is identical either way.
cdylibCodegen = config:
let
cg = config.codegen or {};
# A rust-first module (codegen.rust.trait) derives its .lidl from the trait;
# the builder stages it at generated_code/<name>.lidl (mkLogosModule's
# lidlStaging), so no codegen.lidl is needed. Otherwise it's the committed
# contract named by codegen.lidl.
lidlFile =
if ((cg.rust or {}).trait or null) != null
then "generated_code/${config.name}.lidl"
else (cg.lidl or (throw "cdylib interface requires codegen.lidl in metadata.json"));
# Contract-first C++ flavor: when codegen names an impl_class, the
# generator ALSO emits the C-ABI export wrapper (+ typed events) around
# that hand-written Qt-free class. Without it (e.g. Rust modules whose
# exports come from lidl-gen --provider) only the uniform glue is
# generated.
implClass = cg.impl_class or null;
implHeaderRaw = cg.impl_header or "${config.name}_impl.h";
implHeader =
if lib.hasInfix "/" implHeaderRaw
then builtins.baseNameOf implHeaderRaw
else implHeaderRaw;
implFlags =
if implClass == null then ""
else "--impl-class ${implClass} --impl-header ${implHeader}";
in
''
echo "logos-module-builder: generating cdylib Qt glue (${config.name})..."
logos-qt-host-generator --lidl "${lidlFile}" \
--backend cdylib \
${lib.optionalString ((config.concurrency or "single") == "multi") "--concurrency multi"} \
--output-dir ./generated_code
${lib.optionalString (implClass != null) ''
# Contract-first C++ flavor: the Qt-FREE C-ABI export wrapper
# (+ typed event emitters) around the hand-written impl class.
# No --concurrency: the C++ cdylib dispatch is already concurrency-safe;
# the multi worker pool lives in the Qt glue (logos-qt-generator above).
logos-cpp-generator --lidl "${lidlFile}" \
--backend cdylib \
${implFlags} \
--output-dir ./generated_code
''}
'';
# UI plugin backends (type=ui_qml + interface=universal): the USER
# writes the .rep (the view contract) and the *Backend class (deriving
# <RepClass>SimpleSource + LogosUiPluginContext); the view generator emits
# only the *Interface.h and the *Plugin glue that wires the (Qt-typed)
# LogosModules aggregate into the backend on initLogos.
#
# The binary is logos-VIEW-generator (logos-view-module), not
# logos-qt-generator (logos-qt-sdk). Both shipped the same emitter for a
# while and they rotted apart: logos-qt-sdk#38 added the module teardown
# hook to the copy this line used to call, and the other copy never got it.
#
# That divergence was invisible, and would have become permanent the moment
# this line was repointed without reconciling first: ui-host reaches
# aboutToUnload() BY NAME through the meta-object, so a generated plugin
# class that does not declare it simply has no such meta-method --
# QMetaObject::invokeMethod returns false and the host moves on, which is
# indistinguishable from a view answering "Synchronous, nothing to wait for".
# No build, load or call fails; every view just silently loses its chance to
# finish. logos-view-module's `ui-plugin-metaobject` check now compiles the
# emitted plugin and drives that handshake through QPluginLoader, so the
# regression cannot recur silently in the new home.
#
# The generator lives with the LogosView*.in templates its output is compiled
# against and with logos_ui_plugin_context.h, which its output calls into --
# one authoring surface, one repo, matching how logos-plugin-qt owns the
# cdylib Qt-plugin glue.
#
# `--backend ui` is spelled explicitly even though it is that binary's
# default: it keeps the call site self-describing, and logos-view-generator
# REFUSES an unrecognised --backend rather than silently defaulting.
uiCodegen = config:
let
cg = config.codegen or {};
repFile = cg.rep or "src/${config.name}.rep";
backendFlags =
lib.optionalString (cg ? backend_class) " --backend-class ${cg.backend_class}"
+ lib.optionalString (cg ? backend_header) " --backend-header ${cg.backend_header}";
in
''
echo "logos-module-builder: generating ui plugin glue (${config.name})..."
logos-view-generator --backend ui \
--metadata metadata.json \
--rep "${repFile}"${backendFlags} \
--output-dir ./generated_code
'';
autoCodegen = config:
if config.interface == "universal" && (config.type or "core") == "ui_qml"
then uiCodegen config
else if config.interface == "universal" then universalCodegen config
else if config.interface == "cdylib" then cdylibCodegen config
# `interface: "provider"` (LOGOS_METHOD dispatch via
# `logos-cpp-generator --provider-header`) was removed. Throw rather than
# falling through to the `else ""` no-op below: an unrecognised interface
# silently generates NO glue, so the module would build green and then be
# un-callable from every consumer.
else if config.interface == "provider" then
throw ("logos-module-builder: module '${config.name}' declares the removed "
+ "interface \"provider\". Use interface \"universal\": write a plain "
+ "src/${config.name}_impl.h and the contract is derived from it.")
# `legacy` — the default when metadata.json omits `interface` — generates no
# glue at all. For a CONSUMER that is correct and normal: a ui_qml view
# plugin is not loaded by liblogos, and a fixture that only builds tests has
# nothing to expose. For a module that ships a plugin liblogos loads and
# other modules call, it is the silent form of exactly what the `provider`
# branch above throws for — the module builds green and is un-callable from
# every consumer.
#
# `main` is what separates the two, and it is the only field that does:
# `type` alone cannot, because the core fixtures that legitimately generate
# nothing are core too. A provider ships a plugin, so it names one.
else if (config.type or "core") == "core" && (config.main or null) != null then
throw ("logos-module-builder: module '${config.name}' is a core module "
+ "shipping a plugin (main: ${config.main}) but declares no "
+ "`interface`, so NO glue would be generated and every call into "
+ "it would fail at runtime rather than at build time. Use "
+ "interface \"universal\" (write a plain src/${config.name}_impl.h "
+ "and the contract is derived from it) or \"cdylib\" (bring your "
+ "own C ABI plus codegen.lidl).")
else "";
# Order: optional ext copy -> optional darwin fixup -> codegen -> user hook
# Note: mkLogosModule main builds already copy externals in logos-plugin-qt buildPlugin
# (externalLibCopies). Use copyExternals=true only for contexts without that (e.g. unit tests).
# Stamp the logos-protocol semver the module is being built against into
# the metadata.json the plugin embeds (Q_PLUGIN_METADATA). One number
# governs Logos load/call compatibility (same MAJOR <=> compatible);
# liblogos reads it pre-load. Runs before cmake/moc so the embedded copy
# carries the field; modules built by older builders simply lack it and
# load permissively ("legacy").
stampProtocolVersion = protocolVersion:
if protocolVersion == null then "" else ''
if [ -f ./metadata.json ]; then
jq '. + {logos_protocol_version: "${protocolVersion}"}' ./metadata.json > ./metadata.json.lp-stamp && mv ./metadata.json.lp-stamp ./metadata.json
echo "Stamped logos_protocol_version=${protocolVersion} into metadata.json"
fi
'';
# preCodegen runs AFTER the protocol-version stamp / external copy / darwin fix
# but BEFORE codegen — used to stage a builder-derived .lidl (rust-first) into
# the tree where cdylibCodegen will read codegen.lidl.
compose = { config, externalLibs, userPre, fixDarwin ? false, copyExternals ? false, protocolVersion ? null, preCodegen ? "" }:
let
stamp = stampProtocolVersion protocolVersion;
copy = if copyExternals then copyExternalLibsToLib externalLibs else "";
codegen = autoCodegen config;
fix = if fixDarwin then fixupDarwinDylibs else "";
in
stamp + copy + fix + preCodegen + codegen + userPre;
in {
inherit defaultImplClassFromName copyExternalLibsToLib fixupDarwinDylibs universalCodegen uiCodegen autoCodegen compose stampProtocolVersion;
}