Files
logos-module-builder/docs/external-libraries.md
T
Dario LipicarandClaude Opus 4.8 aa5b5d354d External-library doc-tests + per-platform vendored binaries (#112)
* 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>
2026-06-09 14:18:03 -03:00

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 in logoscore, 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:

  1. Vendor/Pre-built — Library already compiled, in the lib/ directory (simplest)
  2. Flake Input (build from source) — Library source as a flake input, built by mkExternalLib during nix build
  3. 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

  1. 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
  1. Configure metadata.json:
{
  "nix": {
    "external_libraries": [
      { "name": "mylib", "vendor_path": "lib" }
    ],
    "cmake": {
      "extra_include_dirs": ["lib"]
    }
  }
}
  1. flake.nix stays 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 .lgx packaging 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 with make / 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

  1. Add library as git submodule:
git submodule add https://github.com/org/my-lib vendor/my-lib
  1. 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:

  1. Build the library
  2. 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/
  1. 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:

  1. Search for library in lib/
  2. Add lib/ to include directories
  3. Link the library
  4. 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;
}