Skip to main content

Wrap a C library as a Logos module

Expose functions from a C shared library through a Logos core module.​

Version

This document is accurate for Testnet v0.3.

This procedure walks you through wrapping a C shared library (.so on Linux, .dylib on macOS) as a Logos module. You write one plain C++ class, with no Qt code and no plugin boilerplate, and logos-module-builder generates the Qt plugin around it. By the end, you have a calc_module that builds, loads into logosctl and answers method calls, and unit tests that run it against a mock of the C library.

For a production example, see logos-libp2p-module, which wraps the nim-libp2p library compiled to a C shared library.

Prerequisites
  • A supported OS:

    • Linux x86_64 or aarch64
    • macOS arm64 (Apple Silicon)
  • Git

  • A C compiler (gcc or clang) to build the C library.

  • logosctl installed.

    • Install it by running curl -fsSL https://raw.githubusercontent.com/logos-co/logos-docs/main/resources/scripts/install-logosctl.sh | sudo sh
  • Nix with flakes enabled.

    • Install from nixos.org, then enable flakes:
    mkdir -p ~/.config/nix
    echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
  • Basic familiarity with C and C++.

What to expect​

  • You can wrap a C library in a Logos module by writing one plain C++ class, and let the build generate the Qt plugin around it.
  • You can inspect the built module with lm and call its methods from the command line with logosctl.
  • You can unit-test the module against a link-time mock of the C library, without starting a daemon.

Step 1: Scaffold the module project​

The with-external-lib template of logos-module-builder provides the flake.nix, metadata.json, CMakeLists.txt and directory layout that a module wrapping a library needs.

  1. Create the project directory and initialise it from the template:

    mkdir logos-calc-module && cd logos-calc-module
    nix flake init -t github:logos-co/logos-module-builder/0.3.2#with-external-lib
    • The template already follows the pure C++ pattern that this procedure uses: one plain class in src/external_lib_impl.h and src/external_lib_impl.cpp, and "interface": "universal" in metadata.json.
    • For a module that wraps no external library, use the default template instead: nix flake init -t github:logos-co/logos-module-builder/0.3.2.
  2. Remove the template's example class. The following steps replace it with the sources of calc_module:

    rm -f src/external_lib_impl.h src/external_lib_impl.cpp

Step 2: Write the C library​

Create the C library that the module wraps, in the lib/ directory. To wrap an existing library instead, place its pre-built .so or .dylib and its header file in lib/ and continue at Step 3.

  1. Create the lib directory:

    mkdir -p lib
  2. Create lib/libcalc.h:

    #ifndef LIBCALC_H
    #define LIBCALC_H

    #ifdef __cplusplus
    extern "C" {
    #endif

    /** Add two integers. */
    int calc_add(int a, int b);

    /** Multiply two integers. */
    int calc_multiply(int a, int b);

    /** Compute factorial of n (n must be >= 0). Returns -1 on error. */
    int calc_factorial(int n);

    /** Compute the nth Fibonacci number (n must be >= 0). Returns -1 on error. */
    int calc_fibonacci(int n);

    /** Return the library version string. Caller must NOT free. */
    const char* calc_version(void);

    #ifdef __cplusplus
    }
    #endif

    #endif /* LIBCALC_H */

    The extern "C" block prevents C++ name mangling, so the module can find the symbols.

  3. Create lib/libcalc.c:

    #include "libcalc.h"

    int calc_add(int a, int b)
    {
    return a + b;
    }

    int calc_multiply(int a, int b)
    {
    return a * b;
    }

    int calc_factorial(int n)
    {
    if (n < 0) return -1;
    if (n <= 1) return 1;
    int result = 1;
    for (int i = 2; i <= n; i++) {
    result *= i;
    }
    return result;
    }

    int calc_fibonacci(int n)
    {
    if (n < 0) return -1;
    if (n == 0) return 0;
    if (n == 1) return 1;
    int a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
    int tmp = a + b;
    a = b;
    b = tmp;
    }
    return b;
    }

    const char* calc_version(void)
    {
    return "1.0.0";
    }
  4. Build the shared library:

    cd lib

    # Linux
    gcc -shared -fPIC -o libcalc.so libcalc.c

    # macOS
    # gcc -shared -fPIC -o libcalc.dylib libcalc.c

    cd ..
  5. Confirm that the symbols are exported:

    # Linux
    nm -D lib/libcalc.so | grep calc

    # macOS
    # nm -gU lib/libcalc.dylib | grep calc

    Each function is listed with T (the text section). Addresses vary, and macOS prefixes each name with _:

    0000000000001139 T calc_add
    0000000000001179 T calc_factorial
    00000000000011f5 T calc_fibonacci
    0000000000001159 T calc_multiply
    0000000000001299 T calc_version

Step 3: Configure the Logos module​

With the pure C++ (universal) pattern you write a single C++ class. metadata.json, CMakeLists.txt and flake.nix tell the build system the rest, and logos-cpp-generator generates the Qt plugin around the class. After this step, the project looks like this:

logos-calc-module/
├── flake.nix # Nix build configuration (~10 lines)
├── metadata.json # Module metadata, build settings, and runtime config
├── CMakeLists.txt # CMake build file
├── lib/
│ ├── libcalc.h # C library header
│ └── libcalc.c # C library source (compiled by CMake)
└── src/
├── calc_module_impl.h # Plain C++ class (no Qt, no plugin macros)
└── calc_module_impl.cpp # Implementation (wrapping logic)
  1. Replace metadata.json with the following. Set "interface": "universal" and declare the library under nix.external_libraries:

    {
    "name": "calc_module",
    "version": "1.0.0",
    "type": "core",
    "category": "general",
    "description": "Calculator module wrapping libcalc C library",
    "main": "calc_module_plugin",
    "interface": "universal",
    "dependencies": [],

    "nix": {
    "packages": {
    "build": [],
    "runtime": []
    },
    "external_libraries": [
    {
    "name": "calc",
    "vendor_path": "lib"
    }
    ],
    "cmake": {
    "find_packages": [],
    "extra_sources": [],
    "extra_include_dirs": ["lib"],
    "extra_link_libraries": []
    }
    }
    }

    This file is the single source of truth for the module. It is embedded in the plugin, read by logos-module-builder to configure the Nix build, used by CMake to link the external library, and used to write the LGX package manifest.

    FieldWhat it does
    nameModule name. It must be a valid C identifier, because it is used in file names and method calls.
    mainThe name of the generated plugin, <name>_plugin. You don't write this file: the build produces calc_module_plugin.so or calc_module_plugin.dylib.
    interface"universal" selects the pure C++ pattern. The build parses src/calc_module_impl.h into the module's interface contract and generates the Qt plugin from it.
    nix.external_librariesThe C or C++ libraries the module wraps. name is the CMake target and vendor_path is the directory that holds the source or binary. The build links each library into the plugin.
    nix.cmake.extra_include_dirsDirectories added to the include path, so your C++ code can #include "lib/libcalc.h".
  2. Replace CMakeLists.txt with the following:

    cmake_minimum_required(VERSION 3.14)
    project(CalcModulePlugin LANGUAGES CXX)

    # Include the Logos Module CMake helper (provided by logos-module-builder)
    if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
    include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
    elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
    include(cmake/LogosModule.cmake)
    else()
    message(FATAL_ERROR "LogosModule.cmake not found")
    endif()

    # Define the module with its external library dependency.
    # Because metadata.json sets `interface: universal`, the builder runs
    # logos-cpp-generator over src/calc_module_impl.h before configuring,
    # and LogosModule.cmake compiles the generated glue automatically.
    logos_module(
    NAME calc_module
    SOURCES
    src/calc_module_impl.h
    src/calc_module_impl.cpp
    EXTERNAL_LIBS
    calc
    )
    • List only your own source files under SOURCES. LogosModule.cmake compiles the generated plugin code automatically.
    • NAME must match name in metadata.json, and EXTERNAL_LIBS must match nix.external_libraries[].name. If NAME differs, the build can succeed and the install phase still fail, because it looks for the plugin file named after metadata.json.
    • logos_module() finds libcalc.so or libcalc.dylib in lib/, links it to the plugin and sets the RPATH so the plugin finds it at runtime.
    • The if/elseif/else block is boilerplate. Don't change it.
  3. Replace flake.nix with the following. It pins logos-module-builder to the release this procedure was tested with:

    {
    description = "Calculator module - wraps libcalc C library for Logos";

    inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/0.3.2";
    };

    outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosModule {
    src = ./.;
    configFile = ./metadata.json;
    flakeInputs = inputs;
    };
    }
    • mkLogosModule fetches Qt, the Logos SDK and the code generators, and derives the build from metadata.json.
    • To depend on another module, add it as a flake input named after the name in that module's metadata.json. For example, a dependency on waku_module is the input waku_module.url = "github:logos-co/logos-waku-module".
    • To fetch and build the library from source instead of placing it in lib/, see Wrapping a library from a flake input in the Logos tutorials.
  4. Create src/calc_module_impl.h. This is the only interface you write, and it is plain C++:

    #pragma once

    #include <cstdint>
    #include <string>

    #include <logos_module_context.h> // LogosModuleContext base + `logos_events:`

    // Include the C library header (extern "C" already in the header).
    extern "C" {
    #include "lib/libcalc.h"
    }

    class CalcModuleImpl : public LogosModuleContext {
    public:
    CalcModuleImpl() = default;
    ~CalcModuleImpl() = default;

    // ── Public API — every method here is callable over IPC ──────────
    // The generator maps C++ types onto the contract automatically:
    // int64_t ↔ int std::string ↔ tstr bool ↔ bool
    //
    // A doc comment directly above a method becomes that method's
    // `description` in the module's method introspection — surfaced
    // by `lm`, `logosctl module show`, and Basecamp's Methods list.
    // Use `///` (one or more lines) or a `/** ... */` block; the
    // comment's line breaks are preserved. (Plain `//` comments like
    // this block are ignored, so they never leak into the API.)

    /// Adds two integers and returns the sum.
    int64_t add(int64_t a, int64_t b);

    /// Multiplies two integers and returns the product.
    int64_t multiply(int64_t a, int64_t b);

    // A multi-line description: consecutive `///` lines keep their breaks.
    /// Computes the factorial n! of a non-negative integer.
    /// Defined as n * (n-1) * ... * 1, with 0! = 1.
    int64_t factorial(int64_t n);

    /// Returns the nth Fibonacci number (0-indexed).
    int64_t fibonacci(int64_t n);

    // A `/** ... */` block comment works too (line breaks preserved).
    /**
    * Returns the version string of the wrapped libcalc C library.
    * Read straight from the linked native library, not metadata.json.
    */
    std::string libVersion();

    /// Looks up the library version and emits it as a `versionReady`
    /// event instead of returning it. Used by the QML tutorial (Part 2).
    void libVersionNotify();

    // ── Events ───────────────────────────────────────────────────────
    // Declared like Qt signals. The generator emits the body (in
    // calc_module_events.cpp) that routes the typed args to subscribers
    // via the host's `eventResponse` mechanism. QML subscribes with
    // logos.onModuleEvent("calc_module", "versionReady").
    //
    // A `///` doc comment documents the event too — it surfaces as the
    // event's `description` alongside methods (`lm events`, `logosctl
    // module show`, and Basecamp's Interface screen).
    logos_events:
    /// Emitted by libVersionNotify() once the library version is known.
    /// Carries the version string read from libcalc.
    void versionReady(const std::string& version);
    };
    • Every public method is callable by other modules and by logosctl. private members are not exposed.
    • A doc comment directly above a method or event (///, or a /** ... */ block) becomes its description, which lm, logosctl module show and Basecamp display. Plain // comments are ignored.
    • Events are declared in a logos_events: section. Inheriting LogosModuleContext lets the class emit them and call other modules.
    • Use int64_t for integers, not int. The code generator reads the header as text and recognises only the types in this table:
    C++ typeContract typeA Qt caller sees
    voidvoidvoid
    boolboolbool
    int64_tintqlonglong
    uint64_tuintqulonglong
    doublefloat64double
    std::stringtstrQString
    std::vector<std::string>[tstr]QStringList
    std::vector<uint8_t>bstrQByteArray
    LogosMap / LogosList (from <logos_json.h>){tstr: any} / [any]QVariantMap / QVariantList
    StdLogosResultresultLogosResult (from <logos_result.h>)

    The contract type is what the module publishes about itself, in LIDL, its interface definition language. It is what lm prints in Step 5, and what a caller in any language binds to. The last column is what a C++ caller built on Qt compiles against.

  5. Create src/calc_module_impl.cpp. Each method calls the C function, converts the result to a C++ type if needed, and returns it:

    #include "calc_module_impl.h"

    int64_t CalcModuleImpl::add(int64_t a, int64_t b)
    {
    return calc_add(static_cast<int>(a), static_cast<int>(b));
    }

    int64_t CalcModuleImpl::multiply(int64_t a, int64_t b)
    {
    return calc_multiply(static_cast<int>(a), static_cast<int>(b));
    }

    int64_t CalcModuleImpl::factorial(int64_t n)
    {
    return calc_factorial(static_cast<int>(n));
    }

    int64_t CalcModuleImpl::fibonacci(int64_t n)
    {
    return calc_fibonacci(static_cast<int>(n));
    }

    std::string CalcModuleImpl::libVersion()
    {
    return std::string(calc_version());
    }

    void CalcModuleImpl::libVersionNotify()
    {
    // Emit the event declared in `logos_events:`. When the module is
    // loaded by a host, this reaches every subscriber. When the class
    // is constructed outside a host (e.g. in unit tests), it is a
    // safe no-op.
    versionReady(std::string(calc_version()));
    }

    You don't write initLogos, Q_INVOKABLE, name(), version() or lidl(). The code generator produces them from the header and metadata.json.

Step 4: Build the module​

  1. Create .gitignore, so build output stays out of the repository:

    # Nix build output
    result
    result-*

    # CMake build directory
    build/
  2. Initialise the Git repository, stage the files and lock the flake inputs:

    git init
    git add -A
    nix flake update
    git add flake.lock
    • Nix only sees files that Git tracks.
  3. Build the plugin library:

    nix build '.#lib'
    • The first build takes 5 to 15 minutes while Nix downloads Qt, the Logos SDK and their dependencies. Later builds use the Nix cache.
    • Keep the quotes around '.#lib'. Some shells, notably zsh, interpret # otherwise.
  4. Build the full package:

    nix build
    • This build derives the module's LIDL contract from src/calc_module_impl.h and generates the Qt plugin from it before CMake compiles it.
  5. Inspect the output:

    ls -la result/lib/

    The plugin and the C library sit side by side, so the plugin finds the library at runtime through its RPATH:

    # Linux
    calc_module_plugin.so # Your Logos module plugin
    libcalc.so # The C library (copied alongside)

    # macOS
    calc_module_plugin.dylib
    libcalc.dylib

Step 5: Inspect the module​

Use the lm tool from logos-module to inspect the compiled plugin.

  1. Build lm:

    nix build 'github:logos-co/logos-module/0.3.0#lm' --out-link ./lm
  2. View the module's metadata:

    # Linux
    ./lm/bin/lm metadata result/lib/calc_module_plugin.so

    # macOS
    ./lm/bin/lm metadata result/lib/calc_module_plugin.dylib
    Plugin Metadata:
    ================
    Name: calc_module
    Display name: (unset — falls back to name)
    Version: 1.0.0
    Description: Calculator module wrapping libcalc C library
    Author:
    Type: core
    Protocol: 0.9.0
    Dependencies: (none)
  3. List the module's methods:

    # Linux
    ./lm/bin/lm methods result/lib/calc_module_plugin.so

    # macOS
    ./lm/bin/lm methods result/lib/calc_module_plugin.dylib
    Plugin Methods:
    ===============

    int add(int a, int b)
    Signature: add(int,int)
    Invokable: yes
    Description: Adds two integers and returns the sum.

    ...

    int factorial(int n)
    Signature: factorial(int)
    Invokable: yes
    Description:
    Computes the factorial n! of a non-negative integer.
    Defined as n * (n-1) * ... * 1, with 0! = 1.

    ...

    tstr lidl()
    Signature: lidl()
    Invokable: yes
    Description: The module's canonical LIDL interface document.
    • Signatures use the contract types (int, tstr), not the C++ types you wrote. In LIDL, int is 64-bit, so a value that fits your int64_t is never truncated on the way across.
    • Each Description is the method's doc comment, with its line breaks kept.
    • name(), version() and lidl() are generated for every module. The last one returns the module's LIDL contract.
    • Add --json for output that scripts and CI can parse.
  4. List the module's events:

    # Linux
    ./lm/bin/lm events result/lib/calc_module_plugin.so

    # macOS
    ./lm/bin/lm events result/lib/calc_module_plugin.dylib
    Plugin Events:
    ==============

    void versionReady(tstr version)
    Signature: versionReady(tstr)
    Description:
    Emitted by libVersionNotify() once the library version is known.
    Carries the version string read from libcalc.

    Running lm with no subcommand prints the metadata, methods and events together.

Step 6: Install the module with logosctl​

logosctl installs modules from LGX packages, which carry the plugin, its libraries and a manifest.json. Build a portable package, start a daemon in a session of its own and install the package into it.

  1. Build the portable LGX package:

    nix build '.#lgx-portable' --out-link result-lgx-portable
    • Build lgx-portable, not lgx. The lgx output is a development build that keeps its libraries in the Nix store (variant darwin-arm64-dev on Apple Silicon, for example), and logosctl refuses to install it. See Troubleshooting.
  2. Point logosctl at a session for this project. Run this in every terminal you use for the rest of this procedure:

    export LOGOSCTL_CONFIG_DIR="$PWD/session"
    • A session holds its own modules, logs and data, so packages from other projects stay out of it. Without LOGOSCTL_CONFIG_DIR, logosctl uses ~/.logosctl.
  3. Start the daemon, detached so this terminal stays free:

    logosctl daemon start --detach
    • The command returns once the daemon accepts commands.
  4. Install the package:

    logosctl package install --file ./result-lgx-portable/*.lgx -y
    • -y applies the install without asking for confirmation.

    The plugin, the C library and the manifest unpack into the session's modules/ directory:

    session/modules/calc_module/
    ├── calc_module_plugin.dylib # (or .so on Linux)
    ├── libcalc.dylib # (or .so on Linux)
    ├── assets/lidl/ # The module's interface, in LIDL
    ├── manifest.json # Package manifest
    ├── variant # Platform variant identifier
    └── ... # Runtime libraries bundled by the portable build
  5. Confirm that the package is installed:

    logosctl package ls

    calc_module is listed with the source user, next to the packages embedded in logosctl.

Step 7: Call the module from logosctl​

  1. Load the module:

    logosctl module load calc_module
  2. Inspect the module's methods and events:

    logosctl module show calc_module
    Name: calc_module
    Version: v1.0.0
    Status: loaded
    ...

    Methods:
    add(a: int, b: int) -> int
    Adds two integers and returns the sum.
    multiply(a: int, b: int) -> int
    Multiplies two integers and returns the product.
    factorial(n: int) -> int
    Computes the factorial n! of a non-negative integer.
    Defined as n * (n-1) * ... * 1, with 0! = 1.
    ...

    Events:
    versionReady(version: tstr)
    Emitted by libVersionNotify() once the library version is known.
    Carries the version string read from libcalc.
    • The descriptions are the doc comments from src/calc_module_impl.h, the same ones lm showed.
  3. Call its methods:

    logosctl call calc_module add 3 5
    logosctl call calc_module factorial 5
    logosctl call calc_module fibonacci 10
    logosctl call calc_module libVersion

    Each call prints its result:

    8
    120
    55
    1.0.0
    • The daemon writes its log, including the output of every module, to session/logs/daemon.log.
  4. Stop the daemon:

    logosctl daemon stop

Step 8: Unit-test the module​

Because the module is a plain C++ class, you can unit-test it directly, with no Qt and no daemon. The Logos Test Framework provides a test runner (LOGOS_TEST and LOGOS_ASSERT_*) and link-time mocks of your C library, so each test decides what the C functions return and checks how the wrapper behaves.

  1. Enable tests in flake.nix by adding a tests block to the mkLogosModule call. mockCLibs lists the external libraries to replace with link-time mocks, so the tests don't link the real libcalc:

    {
    description = "Calculator module - wraps libcalc C library for Logos";

    inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/0.3.2";
    };

    outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosModule {
    src = ./.;
    configFile = ./metadata.json;
    flakeInputs = inputs;
    tests = {
    dir = ./tests;
    mockCLibs = [ "calc" ];
    };
    };
    }
  2. Create tests/CMakeLists.txt. The test build configures tests/ as its own CMake project, which calls logos_test() from the framework:

    cmake_minimum_required(VERSION 3.14)
    project(CalcModuleTests LANGUAGES CXX)

    include(LogosTest)

    logos_test(
    NAME calc_module_tests
    MODULE_SOURCES
    ../src/calc_module_impl.cpp
    mocks/calc_module_events_stub.cpp
    TEST_SOURCES
    main.cpp
    test_calc.cpp
    MOCK_C_SOURCES
    mocks/mock_libcalc.cpp
    )
    • MODULE_SOURCES: your implementation, compiled into the test binary, and the events stub from the next action.
    • TEST_SOURCES: the runner entry point and your test files.
    • MOCK_C_SOURCES: the link-time replacement for libcalc.
    • logos_test() puts the repository root and ../src on the include path, so #include "calc_module_impl.h" and #include "lib/libcalc.h" both resolve.
  3. Create tests/mocks/calc_module_events_stub.cpp. In a normal build, the code generator writes the body of every method in logos_events:. The test build skips that step, so libVersionNotify() needs a no-op body for versionReady to link:

    // Stub bodies for the impl's `logos_events:` methods.
    // In the real build the codegen generates calc_module_events.cpp with
    // bodies that route through LogosModuleContext. The test build skips
    // that codegen, so we provide no-op stubs to satisfy the linker.
    #include "calc_module_impl.h"

    void CalcModuleImpl::versionReady(const std::string&) {}
    • Add a matching no-op line for each event you add to logos_events:. A module without events doesn't need this file.
  4. Create tests/main.cpp, which pulls in the framework's main():

    #include <logos_test.h>

    LOGOS_TEST_MAIN()
  5. Create tests/mocks/mock_libcalc.cpp. Each function has the signature of a libcalc function, records the call and returns the value the test set:

    // Link-time replacement for libcalc. Each function records the call
    // and returns whatever the active test configured via mockCFunction().
    #include <logos_clib_mock.h>

    extern "C" {
    #include "lib/libcalc.h"
    }

    extern "C" int calc_add(int a, int b) {
    LOGOS_CMOCK_RECORD("calc_add");
    return LOGOS_CMOCK_RETURN(int, "calc_add");
    }

    extern "C" int calc_multiply(int a, int b) {
    LOGOS_CMOCK_RECORD("calc_multiply");
    return LOGOS_CMOCK_RETURN(int, "calc_multiply");
    }

    extern "C" int calc_factorial(int n) {
    LOGOS_CMOCK_RECORD("calc_factorial");
    return LOGOS_CMOCK_RETURN(int, "calc_factorial");
    }

    extern "C" int calc_fibonacci(int n) {
    LOGOS_CMOCK_RECORD("calc_fibonacci");
    return LOGOS_CMOCK_RETURN(int, "calc_fibonacci");
    }

    extern "C" const char* calc_version(void) {
    LOGOS_CMOCK_RECORD("calc_version");
    return LOGOS_CMOCK_RETURN_STRING("calc_version");
    }
  6. Create tests/test_calc.cpp. Each test constructs CalcModuleImpl directly, sets what the C functions return, calls a method and asserts on the result. LogosTestContext resets the mocks between tests:

    #include <logos_test.h>
    #include "calc_module_impl.h"

    LOGOS_TEST(add_forwards_to_calc_add) {
    auto t = LogosTestContext("calc_module");
    t.mockCFunction("calc_add").returns(8);

    CalcModuleImpl calc;
    LOGOS_ASSERT_EQ(calc.add(3, 5), 8);
    LOGOS_ASSERT(t.cFunctionCalled("calc_add"));
    }

    LOGOS_TEST(multiply_forwards_to_calc_multiply) {
    auto t = LogosTestContext("calc_module");
    t.mockCFunction("calc_multiply").returns(42);

    CalcModuleImpl calc;
    LOGOS_ASSERT_EQ(calc.multiply(6, 7), 42);
    LOGOS_ASSERT(t.cFunctionCalled("calc_multiply"));
    }

    LOGOS_TEST(factorial_returns_mocked_value) {
    auto t = LogosTestContext("calc_module");
    t.mockCFunction("calc_factorial").returns(120);

    CalcModuleImpl calc;
    LOGOS_ASSERT_EQ(calc.factorial(5), 120);
    }

    LOGOS_TEST(libVersion_converts_cstring_to_string) {
    auto t = LogosTestContext("calc_module");
    t.mockCFunction("calc_version").returns("1.0.0");

    CalcModuleImpl calc;
    LOGOS_ASSERT_EQ(calc.libVersion(), std::string("1.0.0"));
    }
    • <logos_test.h> also provides LOGOS_ASSERT_TRUE, LOGOS_ASSERT_FALSE, LOGOS_ASSERT_NE, LOGOS_ASSERT_GT, LOGOS_ASSERT_GE and LOGOS_ASSERT_LT.
  7. Track the new files, then build and run the tests:

    git add tests/ flake.nix
    nix build '.#unit-tests' -L

    The build compiles the implementation against the mocks and runs every test. A passing run ends with a summary:

    logos-calc_module-tests> PASS add_forwards_to_calc_add 0ms
    logos-calc_module-tests> PASS multiply_forwards_to_calc_multiply 0ms
    logos-calc_module-tests> PASS factorial_returns_mocked_value 0ms
    logos-calc_module-tests> PASS libVersion_converts_cstring_to_string 0ms
    logos-calc_module-tests>
    logos-calc_module-tests> ── Results: 4 passed (0ms) ──────
    • A failed assertion prints its file and line and fails the build.

Troubleshooting Logos module wrapping​

A method doesn't appear in lm or can't be called​

The code generator exposes only public methods whose parameter and return types it recognises. Check that the method is in the public: section, that it uses only the types in the table in Step 3 (notably int64_t, not int, and std::string, not char* or QString), and that each method is declared on its own.

The build fails with an unknown type or a method the generator can't parse​

The generator reads src/calc_module_impl.h as text to derive the module's contract. Qt types or unusual templates in a public method signature confuse it. Keep Qt out of the header entirely, and move helpers that need other types into the private: section or the .cpp file.

The plugin fails with Cannot load library​

Cannot load library calc_module_plugin.so: libcalc.so: cannot open shared object file

The C library must sit in the same directory as the plugin. The build sets the plugin's RPATH to $ORIGIN on Linux and @loader_path on macOS, so it looks for libraries in its own directory.

Events never reach subscribers​

Check that the event is declared in a logos_events: section and that the class inherits LogosModuleContext. Events fire only when a host such as logosctl or Basecamp loads the module; constructed directly, as in unit tests, emitting is a no-op. Subscribers must use the exact event name, for example logos.onModuleEvent("calc_module", "versionReady").

logosctl cannot find the module​

Check that:

  • The module is installed in the current session: logosctl package ls lists it with the source user. If you opened a new terminal, export LOGOSCTL_CONFIG_DIR again.
  • The install did not stop at the variant check. logosctl installs only the portable variant for its own platform. A package from .#lgx fails with Package does not contain variant for platform: … (package provides: …-dev), so build .#lgx-portable.
  • You load the module by the name in metadata.json (calc_module), not by the package file name.

nix build .#lib does nothing or fails silently​

Some shells, notably zsh, treat # specially. Put the flake reference in quotes: nix build '.#lib'.

The first build is slow​

The first nix build downloads Qt 6, the Logos C++ SDK, the code generator and their dependencies. This is a one-time cost; later builds use the Nix cache.

The build fails with undefined symbols from the C library​

Check that the .so or .dylib is in lib/ before you build, that the header has extern "C" guards, and that the symbols are exported: nm -D lib/libcalc.so | grep calc on Linux, or nm -gU lib/libcalc.dylib | grep calc on macOS.