* feat: per-platform vendored binaries in mkExternalLib A vendored external library could only ship one platform's binary: two platforms sharing an extension (linux x86_64 vs aarch64, both libfoo.so) collide in lib/. Commit each platform's binary under lib/<nix-system>/ (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and the build selects the subdir matching pkgs.stdenv.hostPlatform.system. Contained to mkExternalLib.nix (new src arg + a selection branch before the null fallback); src threaded through mkLogosModule.nix and buildCppPlugin.nix. The selected binary flows through the existing flake-input staging path, so no CMake or logos-plugin-qt change. Flat single-platform vendoring is unchanged. Documented in docs/external-libraries.md (Nix system strings, distinct from .lgx variant labels; raw nix-develop+cmake does not descend into the subdirs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(doctests): executable external-library doc-tests + Pages publish Add four YAML doc-tests under doctests/ — the logos-tutorial / logos-doctest approach — each scaffolding a real universal module that wraps the same tiny libgreet a different way, building it against the commit under test, loading it in a logoscore daemon, and asserting `call greet_module hello` returns "hello from libgreet": 1. wrap-external-lib-1-source source compiled into the plugin 2. wrap-external-lib-2-prebuilt-binaries prebuilt binary vendored per-platform 3. wrap-external-lib-3-external-source external source built with `make` 4. wrap-external-lib-4-nix-flake library from an external Nix flake .github/workflows/doctests.yml runs them via `nix run github:logos-co/logos-doctest` with `--release-for logos-module-builder=<sha>` (matrix ubuntu/macos), and publishes the two-column HTML report per-ref/per-os to gh-pages with a PR comment — mirroring logos-cpp-sdk's doctests.yml. Skipped on forks. All four pass locally on aarch64-darwin against the PR commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: fail loudly when a configured external library is missing logos_module() used to emit message(WARNING ...) and keep going when a configured external library could not be found at build time — on macOS -undefined dynamic_lookup then let the plugin link anyway, producing a silently broken module. Turn the three not-found paths (EXTERNAL_LIBS, go_build static archives, LINK_TARGETS) into message(FATAL_ERROR ...) so a missing/failed external dependency aborts the build with an actionable message instead of a silent warning. Guarded by test-static-extlib.nix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(doctests): use portable builds (lgx-portable, cli-portable, bundle-dir) Switch the run/package steps in all four external-library doc-tests to the portable distribution path — the form real users ship: portable LGX (`.#lgx-portable`), portable package manager (`#cli-portable`), and the self-contained logoscore bundle (`#cli-bundle-dir`, binary at bin/logoscore). Validated locally on aarch64-darwin: case 1 21/21 and case 2 (per-platform vendored binary) 19/19, each ending in `call greet_module hello -> "result":"hello from libgreet"`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
External Libraries Guide
How to wrap external C/C++ libraries in Logos modules.
Step-by-step tutorials. Complete, runnable walkthroughs of each approach below live as executable doc-tests in
doctests/(wrap-external-lib-1-source,-2-prebuilt-binaries,-3-external-source,-4-nix-flake). Each scaffolds a real module, builds it, loads it inlogoscore, and calls it — run/published in CI via logos-doctest.
Overview
Logos modules can wrap external C/C++ libraries to expose their functionality to the Logos ecosystem. There are three approaches:
- Vendor/Pre-built — Library already compiled, in the
lib/directory (simplest) - Flake Input (build from source) — Library source as a flake input, built by
mkExternalLibduring nix build - Flake Input (Nix package) — Library provided by a flake that has its own Nix build (
built_nix: true)
Approach 1: Vendor / Pre-built Library
Best for: Pre-built proprietary libraries or binaries you already have compiled.
Setup
- Place the pre-built library in
lib/and git-track it (Nix only sees tracked files):
cp /path/to/libmylib.dylib lib/
git add lib/libmylib.dylib lib/libmylib.h
- Configure
metadata.json:
{
"nix": {
"external_libraries": [
{ "name": "mylib", "vendor_path": "lib" }
],
"cmake": {
"extra_include_dirs": ["lib"]
}
}
}
flake.nixstays simple — no extra inputs needed:
{
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
Multiple platforms (per-platform binaries)
A flat lib/libmylib.so only works for the platform it was built for, and you
cannot keep both a linux x86_64 and a linux aarch64 build in lib/ — they
share the name libmylib.so. To ship several platforms, put each binary in a
subdirectory named by its Nix system string; the build selects the one
matching the platform it is building for:
lib/
├── mylib.h # shared header, stays flat
├── x86_64-linux/libmylib.so
├── aarch64-linux/libmylib.so
├── x86_64-darwin/libmylib.dylib
└── aarch64-darwin/libmylib.dylib
The valid subdirectory names are x86_64-linux, aarch64-linux,
x86_64-darwin, aarch64-darwin. vendor_path and metadata.json are
unchanged ({ "name": "mylib", "vendor_path": "lib" }); commit only the
platforms you support. Don't mix layouts — use per-platform subdirs or a flat
binary for a given library, not both.
Notes:
- These are Nix system strings, distinct from the
.lgxpackaging variant labels (linux-amd64,darwin-arm64). - Give shared libraries a SONAME (
libmylib.so) /install_name(@rpath/libmylib.dylib) so the plugin records a relocatable dependency. - Selection happens during the Nix build's staging step; raw
nix develop+ cmake does not descend into the subdirs.
Full walkthrough: the wrap-external-lib-2-prebuilt-binaries doc-test.
Approach 2: Flake Input (Build from Source)
Best for: Libraries with clean build systems (make, cmake, etc.) whose source you want pinned as a flake input.
Configuration
flake.nix:
{
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
my-lib-src = {
url = "github:org/my-lib/v1.0.0";
flake = false;
};
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
externalLibInputs = {
mylib = inputs.my-lib-src;
};
};
}
metadata.json:
{
"nix": {
"external_libraries": [
{
"name": "mylib",
"build_command": "make shared",
"output_pattern": "build/libmylib.*"
}
],
"cmake": {
"extra_include_dirs": ["lib"]
}
}
}
Build Command Options
{ "build_command": "make" }
{ "build_command": "make shared-library" }
{ "build_command": "mkdir build && cd build && cmake .. && make" }
{ "build_command": "./build.sh" }
Go Libraries
For Go libraries that produce C shared libraries:
{
"nix": {
"external_libraries": [
{
"name": "gowalletsdk",
"build_command": "make shared-library",
"go_build": true
}
]
}
}
The go_build: true flag sets up GOCACHE, GOPATH, CGO_ENABLED=1, and the Go toolchain in the build environment.
Approach 3: Flake Input (Nix Package)
Best for: Libraries that already have their own flake.nix producing a Nix derivation with lib/ and include/ outputs. The module builder detects this automatically — if the resolved input is a Nix derivation, it's used directly; no extra flags needed in metadata.json.
How detection works
The module builder calls lib.isDerivation on the resolved input:
- Derivation (a specific package output) → used directly, no build step
- Raw source (non-flake input,
flake = false) → built withmake/ custom command (Approach 2)
When you point externalLibInputs at a specific package output (or use the structured format with packages), the resolved value is always a derivation, so it's used as-is.
Configuration
flake.nix:
{
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
my-lib.url = "github:org/my-lib";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
externalLibInputs = {
mylib = inputs.my-lib;
};
};
}
metadata.json — only the name is needed:
{
"nix": {
"external_libraries": [
{ "name": "mylib" }
],
"cmake": {
"extra_include_dirs": ["lib"]
}
}
}
If my-lib has packages.${system}.default, the module builder resolves to that derivation and uses it directly. If it's a non-flake source repo, it falls back to building with make.
Per-variant packages
If the flake input provides multiple package outputs (e.g. a dev build and a portable build), use the structured externalLibInputs format:
externalLibInputs = {
mylib = {
input = inputs.my-lib;
packages = {
default = "lib"; # used for nix build .#lib
portable = "lib-portable"; # used for nix build .#lib-portable
};
};
};
Approach 4: Vendor Submodule (Build from Source in Repo)
Best for: Libraries requiring custom build scripts, where source lives in a git submodule.
Setup
- Add library as git submodule:
git submodule add https://github.com/org/my-lib vendor/my-lib
- Create build script:
# scripts/build-mylib.sh
#!/bin/bash
cd vendor/my-lib
make clean
make shared
cp build/libmylib.* ../../lib/
Custom Build Scripts
Build scripts receive no arguments and should:
- Build the library
- Copy outputs to
lib/directory
Example for nwaku/libwaku:
#!/bin/bash
set -e
cd vendor/nwaku
# Build libwaku
make libwaku
# Copy to lib/
mkdir -p ../../lib
cp build/libwaku.* ../../lib/
cp library/libwaku.h ../../lib/
- Configure
metadata.json:
{
"nix": {
"external_libraries": [
{
"name": "mylib",
"vendor_path": "vendor/my-lib",
"build_script": "scripts/build-mylib.sh"
}
]
}
}
CMake Integration
Basic Linking
In CMakeLists.txt:
logos_module(
NAME my_module
SOURCES ...
EXTERNAL_LIBS
mylib
)
This will:
- Search for library in
lib/ - Add
lib/to include directories - Link the library
- Copy library to output directory
Manual Linking
For more control:
# After logos_module()
find_library(EXTRA_LIB extralib PATHS ${CMAKE_CURRENT_SOURCE_DIR}/lib)
target_link_libraries(my_module_module_plugin PRIVATE ${EXTRA_LIB})
Plugin Implementation
Including Headers
// In my_module_plugin.h
#include "lib/libmylib.h" // Include the C header
Using the Library
// In my_module_plugin.cpp
#include "my_module_plugin.h"
#include "lib/libmylib.h"
void MyModulePlugin::init() {
mylib_handle* handle = mylib_init();
if (!handle) {
qWarning() << "Failed to initialize mylib";
return;
}
m_handle = handle;
}
void MyModulePlugin::cleanup() {
if (m_handle) {
mylib_cleanup(m_handle);
m_handle = nullptr;
}
}
Memory Management
C libraries often return allocated memory. Always free it:
QString MyModulePlugin::getData() {
char* result = mylib_get_data(m_handle);
QString output = QString::fromUtf8(result);
mylib_free_string(result); // Don't forget!
return output;
}
Callbacks
For C callbacks, use static methods:
// Header
class MyModulePlugin {
private:
static void callback(int code, const char* msg, void* user_data);
};
// Implementation
void MyModulePlugin::callback(int code, const char* msg, void* user_data) {
auto* plugin = static_cast<MyModulePlugin*>(user_data);
emit plugin->eventResponse("callback", QVariantList() << code << QString::fromUtf8(msg));
}
void MyModulePlugin::subscribe() {
mylib_subscribe(m_handle, callback, this); // Pass 'this' as user_data
}
Platform Considerations
macOS
Libraries need correct install names. The builder automatically runs:
install_name_tool -id "@rpath/libmylib.dylib" libmylib.dylib
For the plugin:
install_name_tool -change "/old/path/libmylib.dylib" "@rpath/libmylib.dylib" my_module_plugin.dylib
Linux
Libraries are found via $ORIGIN RPATH:
patchelf --set-rpath '$ORIGIN' my_module_plugin.so
Troubleshooting
Library not found at runtime:
# Check RPATH on macOS
otool -L my_module_plugin.dylib
# Check RPATH on Linux
readelf -d my_module_plugin.so | grep RPATH
ldd my_module_plugin.so
Symbol not found:
# List symbols in library
nm -gU libmylib.dylib
# Check if symbol is referenced
nm -u my_module_plugin.dylib | grep mylib
Library not copied to result/lib:
For vendor libraries: ensure the .dylib/.so is git-tracked:
git add lib/libmylib.dylib
Complete Example: Wallet Module
Here's how the wallet module wraps go-wallet-sdk:
flake.nix:
{
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
go-wallet-sdk = {
url = "github:status-im/go-wallet-sdk/v1.0.0";
flake = false;
};
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
externalLibInputs = {
gowalletsdk = inputs.go-wallet-sdk;
};
};
}
metadata.json:
{
"name": "wallet_module",
"version": "1.0.0",
"type": "core",
"category": "wallet",
"main": "wallet_module_plugin",
"dependencies": [],
"nix": {
"packages": { "build": ["gnumake", "go"], "runtime": [] },
"external_libraries": [
{
"name": "gowalletsdk",
"build_command": "make shared-library",
"go_build": true
}
],
"cmake": { "extra_include_dirs": ["lib"] }
}
}
wallet_module_plugin.cpp:
#include "lib/libgowalletsdk.h"
bool WalletModulePlugin::initWallet(const QString& rpcUrl) {
char* err = nullptr;
m_handle = GoWSK_ethclient_NewClient(rpcUrl.toUtf8().constData(), &err);
if (err) {
QString error = QString::fromUtf8(err);
GoWSK_FreeCString(err);
qWarning() << "Wallet init failed:" << error;
return false;
}
return true;
}