39 KiB
Tutorial: Wrapping a C Library as a Logos Module
This tutorial walks you through wrapping a C shared library (.so on Linux, .dylib on macOS) as a Logos module. By the end, you will have a module that compiles, loads, and responds to method calls via logoscore.
What you'll build: A calc_module that wraps a tiny C calculator library (libcalc), exposing arithmetic functions to the Logos platform.
What you'll learn:
- How a Logos module wraps a C library
- The role of each file in the module project
- How to build, inspect, and test your module
- How
logoscorediscovers, loads, and calls your module
Prerequisites
- Nix with flakes enabled. Install from nixos.org, then enable flakes globally:
Verify it works:
# Add to ~/.config/nix/nix.conf (create the file if it doesn't exist): mkdir -p ~/.config/nix echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.confnix flake --help >/dev/null 2>&1 && echo "Flakes enabled" || echo "Flakes NOT enabled — check nix.conf" - A C compiler (gcc or clang) — only needed for the optional local sanity check in Step 1.5. The Nix build compiles the C library itself, so you don't need a compiler on hand for the actual module build.
- Basic familiarity with C and C++.
Step 1: Scaffold the Module Project
Before writing any C code, scaffold the Logos module project using the official template. This gives you the correct flake.nix, metadata.json, directory structure, and build configuration out of the box.
1.1 Create the project using the module builder template
# For a module that wraps an external C library:
mkdir logos-calc-module && cd logos-calc-module
nix flake init -t github:logos-co/logos-module-builder/tutorial-v2#with-external-lib
# Or for a plain module (no external library):
# nix flake init -t github:logos-co/logos-module-builder/tutorial-v2
Note: The generated
flake.nixuses an unpinnedlogos-module-builderURL. Replace it with the pinned version shown in Step 2.3 to ensure reproducible builds.
This generates the skeleton files (flake.nix, metadata.json, CMakeLists.txt, etc.) pre-configured for the logos-module-builder. You then customize them for your specific library.
Alternative approach: You can also create the C library as a separate project, build it there, then copy the resulting
.so/.dyliband header files into the module'slib/directory. This can be cleaner for larger libraries with their own build systems.
1.2 Create the lib directory
mkdir -p lib
1.3 Write the C header
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 is essential — it prevents C++ name mangling so the Logos module can find the symbols.
1.4 Write the C implementation
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";
}
1.5 Add a Makefile to build the library
The Nix build (Step 3) compiles libcalc from source for you — you don't ship a
pre-built binary. To do that it needs a build command, so add a small
lib/Makefile that produces the shared library with the platform-correct
extension:
# Pick the platform-correct shared-library extension.
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
EXT := dylib
else
EXT := so
endif
# Extra compile flags — overridable from the environment (the Nix stdenv
# exports CC, and may set CFLAGS/LDFLAGS). Note that -shared/-fPIC are passed
# literally on the recipe line, NOT via CFLAGS: if they lived in `CFLAGS ?=`,
# an environment-provided CFLAGS would drop them and you'd build a plain
# executable named libcalc.so/.dylib instead of a shared library.
CFLAGS ?= -O2
shared:
mkdir -p build
$(CC) -shared -fPIC $(CFLAGS) $(LDFLAGS) -o build/libcalc.$(EXT) libcalc.c
clean:
rm -rf build
.PHONY: shared clean
Recipe lines in a
Makefilemust be indented with a tab, not spaces.
Optional sanity check. Compile it once locally to confirm your C code builds and exports the expected symbols (the Nix build does this for real in Step 3):
cd lib
make shared # → build/libcalc.so on Linux, build/libcalc.dylib on macOS
cd ..
# Linux
nm -D lib/build/libcalc.so | grep calc
# macOS
# nm -gU lib/build/libcalc.dylib | grep calc
You should see each symbol marked with T (text/code section). Addresses will vary:
0000000000001139 T calc_add
0000000000001179 T calc_factorial
00000000000011f5 T calc_fibonacci
0000000000001159 T calc_multiply
0000000000001299 T calc_version
Wrapping a third-party library you already have as a binary? You can skip building from source and ship the pre-built
.so/.dylibinstead — see the staging note in Step 2.1. Just remember Nix only sees git-tracked files, so the binary must be committed.
Step 2: Configure the Logos Module
The template from Step 1.1 generated skeleton files with placeholder names (external_lib, example_lib). Now rename and customize them for your library. You need to edit every generated file:
| File | What to change |
|---|---|
metadata.json |
Module name, description, library name, include dirs |
CMakeLists.txt |
Project name, module name, source filenames, library name |
flake.nix |
Description (and dependency inputs if needed) |
src/*.h, src/*.cpp |
Rename files, replace class/method names, add your wrapping logic |
After renaming and editing, your project should look like this:
logos-calc-module/
├── flake.nix # Nix build configuration (~10 lines)
├── metadata.json # Module metadata, build settings, and runtime config (~25 lines)
├── CMakeLists.txt # CMake build file (~20 lines)
├── lib/
│ ├── libcalc.h # C library header
│ ├── libcalc.c # C library source
│ └── Makefile # Builds libcalc from source (run by the Nix build)
└── src/
├── calc_module_interface.h # Interface declaration
├── calc_module_plugin.h # Plugin header
└── calc_module_plugin.cpp # Plugin implementation (wrapping logic)
2.1 metadata.json — Module Configuration
Edit: Change
name,description,main,nix.external_libraries[].name, andnix.cmake.extra_include_dirsto match your module and library.
This is the single source of truth for your module. It is embedded into the plugin binary by Qt's Q_PLUGIN_METADATA macro (for runtime metadata), read by logos-module-builder to configure the Nix build, used by CMake to resolve external dependencies and link libraries (via the nix section), and used by nix-bundle-lgx to generate the LGX manifest.
{
"name": "calc_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "Calculator module wrapping libcalc C library",
"main": "calc_module_plugin",
"dependencies": [],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [
{
"name": "calc",
"build_command": "make shared",
"output_pattern": "build/libcalc.*"
}
],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": ["lib"],
"extra_link_libraries": []
}
}
}
Key fields explained:
| Field | What it does |
|---|---|
name |
Module name — must be a valid C identifier (used in filenames, method calls) |
nix.external_libraries[].name |
Library name without the lib prefix. So name: calc corresponds to libcalc.so / libcalc.dylib, following the standard Unix convention where -lcalc links against libcalc. The builder uses it to name the built artifact and to match EXTERNAL_LIBS calc in CMakeLists.txt. |
nix.external_libraries[].build_command |
Shell command that builds the library from source. It runs in the source tree passed via externalLibInputs in flake.nix (see Step 2.3). Here it invokes the lib/Makefile's make shared target. The Nix stdenv exports $CC/$CXX, and make/pkg-config are on PATH. |
nix.external_libraries[].output_pattern |
Glob (relative to the build dir) the builder uses to locate the compiled library. "build/libcalc.*" matches the build/libcalc.so / build/libcalc.dylib that make shared produces. |
nix.cmake.extra_include_dirs |
Added to the CMake include path so your C++ code can #include "lib/libcalc.h" |
Build from source vs. ship a binary — and why you must
git adda binary. This example buildslibcalcfrom source on every Nix build, so there's nothing to commit but the.c/.h/Makefilesources. If instead you receive a library pre-built (a third-party.so/.dylib), dropbuild_commandandoutput_pattern, set"vendor_path": "lib", and place the binary inlib/— but you mustgit addit. Nix flakes only see git-tracked files, so an un-staged binary is invisible to the build, and the failure is silent:find_librarymisses it (a CMake warning, not an error), the plugin still links because the C symbols resolve lazily, and it only crashes when something tries to load it. Always commit a vendored binary, or build it from source as shown here.
2.2 CMakeLists.txt — Build File
Edit: Change
project()name,NAME,SOURCESfilenames, andEXTERNAL_LIBSto match your module and library.
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
logos_module(
NAME calc_module
SOURCES
src/calc_module_interface.h
src/calc_module_plugin.h
src/calc_module_plugin.cpp
EXTERNAL_LIBS
calc
)
The template generates this with default names (e.g., external_lib). You must update:
project()— rename to match your module (e.g.,CalcModulePlugin)NAME— your module name (must matchnameinmetadata.json, e.g.,calc_module)SOURCES— your renamed source filesEXTERNAL_LIBS— names of external libraries to link (must matchnix.external_libraries[].nameinmetadata.json)
The if/elseif/else block above it is boilerplate — don't change it.
Common mistake: If
NAMEdoesn't matchnameinmetadata.json, the build will succeed but the install phase will fail because it looks for<name>_plugin.dylibbased onmetadata.json.
How EXTERNAL_LIBS calc works: Before CMake runs, the builder has compiled libcalc from source (Step 2.1) and staged the result into lib/. The logos_module() CMake function then finds libcalc.so (Linux) or libcalc.dylib (macOS) there, links it to your plugin, and sets up RPATH so the library is found at runtime.
2.3 flake.nix — Nix Build Config
Edit: Change
description, and pointexternalLibInputsat your library's source. Add other flake inputs here too if your module depends on other modules (see Advanced: Wrapping a Library from a Flake Input).
{
description = "Calculator module - wraps libcalc C library for Logos";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder/tutorial-v2";
# The C library source, built from source by Nix (see metadata.json's
# build_command). flake = false means "just give me the source tree".
calc-src = {
url = "path:./lib";
flake = false;
};
};
outputs = inputs@{ logos-module-builder, calc-src, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
# Hand the C source to the builder. The attribute name (calc) must match
# the external_libraries[].name in metadata.json.
externalLibInputs = {
calc = calc-src;
};
};
}
That's it — mkLogosModule handles all the Nix complexity (fetching Qt, the SDK, the code generator, setting up include paths, etc.). configFile points to metadata.json (the single source of truth), flakeInputs = inputs passes all flake inputs to the builder so module dependencies resolve automatically, and externalLibInputs hands the builder the C source it compiles via build_command.
Bundled source via
path:./lib. Thecalc-srcinput is a relative path input pointing at thelib/directory in this same repo, so the C source stays bundled with the module — no separate repository needed. To pull the source from a subfolder of a remote repo instead, use the?dir=locator, e.g.url = "github:you/your-repo?dir=lib";.
Naming flake inputs: When adding module dependencies, the flake input attribute name must match the
namefield in that dependency'smetadata.json. For example, if you depend on a module whosemetadata.jsonhas"name": "waku_module", your flake input must bewaku_module.url = "github:logos-co/logos-waku-module". The URL can point to any repo, but the attribute name is how the builder resolves dependencies.
2.4 src/calc_module_interface.h — Interface Declaration
Edit: Rename from
external_lib_interface.h. Replace the class name, interface ID, include guard, and declare your module's methods asQ_INVOKABLE virtualpure-virtual functions.
This declares the methods your module exposes. It inherits from PluginInterface (provided by the Logos C++ SDK).
#ifndef CALC_MODULE_INTERFACE_H
#define CALC_MODULE_INTERFACE_H
#include <QObject>
#include <QString>
#include "interface.h"
class CalcModuleInterface : public PluginInterface
{
public:
virtual ~CalcModuleInterface() = default;
Q_INVOKABLE virtual int add(int a, int b) = 0;
Q_INVOKABLE virtual int multiply(int a, int b) = 0;
Q_INVOKABLE virtual int factorial(int n) = 0;
Q_INVOKABLE virtual int fibonacci(int n) = 0;
Q_INVOKABLE virtual QString libVersion() = 0;
};
#define CalcModuleInterface_iid "org.logos.CalcModuleInterface"
Q_DECLARE_INTERFACE(CalcModuleInterface, CalcModuleInterface_iid)
#endif // CALC_MODULE_INTERFACE_H
Rules for the interface:
- Every method you want callable by other modules must be
Q_INVOKABLEandvirtual - Supported parameter/return types:
int,bool,QString,QByteArray,QVariant,QJsonArray,QStringList,LogosResult - The interface ID string (e.g.,
"org.logos.CalcModuleInterface") must be unique across all modules
2.5 src/calc_module_plugin.h — Plugin Header
Edit: Rename from
external_lib_plugin.h. Replace class name, interface references,name()/version()return values, and declare yourQ_INVOKABLEwrapper methods. Add#includefor your C library header.
This is the actual plugin class. It inherits from both QObject (for Qt's meta-object system) and your interface.
#ifndef CALC_MODULE_PLUGIN_H
#define CALC_MODULE_PLUGIN_H
#include <QObject>
#include <QString>
#include "calc_module_interface.h"
// Include the C library header
#include "lib/libcalc.h"
class LogosAPI;
class CalcModulePlugin : public QObject, public CalcModuleInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID CalcModuleInterface_iid FILE "metadata.json")
Q_INTERFACES(CalcModuleInterface PluginInterface)
public:
explicit CalcModulePlugin(QObject* parent = nullptr);
~CalcModulePlugin() override;
// PluginInterface — required by every module
QString name() const override { return "calc_module"; }
QString version() const override { return "1.0.0"; }
// Called by the Logos host when the module is loaded.
// NOT marked override — it is invoked reflectively via QMetaObject.
Q_INVOKABLE void initLogos(LogosAPI* api);
// CalcModuleInterface — each wraps a libcalc C function
Q_INVOKABLE int add(int a, int b) override;
Q_INVOKABLE int multiply(int a, int b) override;
Q_INVOKABLE int factorial(int n) override;
Q_INVOKABLE int fibonacci(int n) override;
Q_INVOKABLE QString libVersion() override;
Q_INVOKABLE void libVersionNotify();
signals:
void eventResponse(const QString& eventName, const QVariantList& args);
};
#endif // CALC_MODULE_PLUGIN_H
Critical details:
Q_PLUGIN_METADATA(IID ... FILE "metadata.json")— embeds the metadata into the binaryQ_INTERFACES(CalcModuleInterface PluginInterface)— registers both interfaces with Qt's plugin systeminitLogosmust beQ_INVOKABLEbut notoverride— the base classPluginInterfacedoes not declare it as virtual; the Logos host calls it reflectively viaQMetaObject::invokeMethodeventResponsesignal is required for event forwarding between modules. Emit it to push data to subscribers (e.g., QML UIs listening vialogos.onModuleEvent())name()must return the same string as thenamefield inmetadata.json- No
m_logosAPImember variable — theLogosAPI* pointer is stored in the globallogosAPIvariable defined inliblogos, not in a class member. See theinitLogosimplementation below.
2.6 src/calc_module_plugin.cpp — Plugin Implementation
Edit: Rename from
external_lib_plugin.cpp. Replace the placeholder implementations with actual calls to your C library functions.
This is where the wrapping happens. Each method calls the corresponding C function.
#include "calc_module_plugin.h"
#include "logos_api.h"
#include <QDebug>
CalcModulePlugin::CalcModulePlugin(QObject* parent)
: QObject(parent)
{
qDebug() << "CalcModulePlugin: created";
}
CalcModulePlugin::~CalcModulePlugin()
{
qDebug() << "CalcModulePlugin: destroyed";
}
void CalcModulePlugin::initLogos(LogosAPI* api)
{
// IMPORTANT: Use the global `logosAPI` variable from liblogos, NOT a class member.
// `logosAPI` is defined in the Logos SDK headers and is used by the API
// internally. Storing the pointer in a local `m_logosAPI` member will NOT work.
logosAPI = api;
qDebug() << "CalcModulePlugin: LogosAPI initialized";
}
int CalcModulePlugin::add(int a, int b)
{
// Call the C library function
int result = calc_add(a, b);
qDebug() << "CalcModulePlugin::add" << a << "+" << b << "=" << result;
return result;
}
int CalcModulePlugin::multiply(int a, int b)
{
int result = calc_multiply(a, b);
qDebug() << "CalcModulePlugin::multiply" << a << "*" << b << "=" << result;
return result;
}
int CalcModulePlugin::factorial(int n)
{
int result = calc_factorial(n);
qDebug() << "CalcModulePlugin::factorial" << n << "! =" << result;
return result;
}
int CalcModulePlugin::fibonacci(int n)
{
int result = calc_fibonacci(n);
qDebug() << "CalcModulePlugin::fibonacci fib(" << n << ") =" << result;
return result;
}
QString CalcModulePlugin::libVersion()
{
const char* ver = calc_version();
QString result = QString::fromUtf8(ver);
qDebug() << "CalcModulePlugin::libVersion" << result;
return result;
}
void CalcModulePlugin::libVersionNotify()
{
const char* ver = calc_version();
QString result = QString::fromUtf8(ver);
qDebug() << "CalcModulePlugin::libVersionNotify" << result;
emit eventResponse("versionReady", {result});
}
The wrapping pattern is always the same:
- Call the C function with the arguments
- Convert the C result to a Qt type if needed (e.g.,
const char* →QString) - Return the Qt type
Step 3: Build the Module
3.1 Initialize the Git repo
Nix flakes require a git repository.
Before staging files, create a .gitignore to exclude build artifacts:
# Nix build output
result
result-*
# CMake build directory
build/
# Compiled C library (built from source by the Nix external-lib derivation,
# or by `make shared` for a local sanity check)
lib/build/
Then initialise the repo:
cd logos-calc-module
git init
git add -A
nix flake update
git add flake.lock
3.2 Build with Nix
# Build just the plugin library (.so / .dylib)
nix build '.#lib'
# Build everything (library + generated SDK headers)
nix build
Quoting matters: Use
'.#lib'(with quotes) rather than barenix build .#lib. Some shells (especially zsh) may interpret the#as a comment character, causing the command to silently build the wrong thing or fail.
The first build takes a while (5-15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching.
3.3 Inspect the output
ls -la result/lib/
You should see two files (extensions depend on your platform):
# Linux
calc_module_plugin.so # Your Logos module plugin
libcalc.so # The C library (built from source, staged alongside)
# macOS
calc_module_plugin.dylib
libcalc.dylib
Both library files are placed together so the plugin can find the C library at runtime via RPATH.
Step 4: Inspect the Module
4.1 Build the lm tool
The lm CLI tool (from logos-module) inspects compiled module binaries:
nix build 'github:logos-co/logos-module/tutorial-v2#lm' --out-link ./lm
4.2 View metadata
# Linux
./lm/bin/lm metadata result/lib/calc_module_plugin.so
# macOS
./lm/bin/lm metadata result/lib/calc_module_plugin.dylib
Output:
Plugin Metadata:
================
Name: calc_module
Version: 1.0.0
Description: Calculator module wrapping libcalc C library
Author:
Type: core
Dependencies: (none)
4.3 List methods
# Linux
./lm/bin/lm methods result/lib/calc_module_plugin.so
# macOS
./lm/bin/lm methods result/lib/calc_module_plugin.dylib
Output:
Plugin Methods:
===============
void eventResponse(QString eventName, QVariantList args)
Signature: eventResponse(QString,QVariantList)
Invokable: no
void initLogos(LogosAPI* api)
Signature: initLogos(LogosAPI*)
Invokable: yes
int add(int a, int b)
Signature: add(int,int)
Invokable: yes
int multiply(int a, int b)
Signature: multiply(int,int)
Invokable: yes
int factorial(int n)
Signature: factorial(int)
Invokable: yes
int fibonacci(int n)
Signature: fibonacci(int)
Invokable: yes
QString libVersion()
Signature: libVersion()
Invokable: yes
All five wrapping methods are visible and invokable. The initLogos method is automatically called by the Logos host when loading the module.
4.4 JSON output
For scripting and CI, use --json:
# Linux
./lm/bin/lm methods result/lib/calc_module_plugin.so --json
# macOS
./lm/bin/lm methods result/lib/calc_module_plugin.dylib --json
[
{
"isInvokable": true,
"name": "add",
"parameters": [
{ "name": "a", "type": "int" },
{ "name": "b", "type": "int" }
],
"returnType": "int",
"signature": "add(int,int)"
},
...
]
Step 5: Test with logoscore
5.1 Build logoscore
nix build 'github:logos-co/logos-logoscore-cli/tutorial-v2' --out-link ./logos
5.2 Set up the modules directory
logoscore expects modules in subdirectories, each with a manifest.json. Rather than copying files and writing the manifest manually, use the Nix derivation to create an LGX package and install it with the package manager:
# Bundle the module into an LGX package
nix build '.#lgx'
# Install it into a modules directory using the Logos Package Manager
nix build 'github:logos-co/logos-package-manager/tutorial-v2#cli' --out-link ./pm
mkdir -p modules
./pm/bin/lgpm --modules-dir ./modules install --file result/*.lgx
This extracts the plugin, external libraries, and manifest into the correct directory structure:
modules/calc_module/
├── calc_module_plugin.dylib # (or .so on Linux)
├── libcalc.dylib # (or .so on Linux)
├── manifest.json # Auto-generated by lgx
└── variant # Platform variant identifier
5.3 Call methods
Start the daemon and call methods:
# Start logoscore daemon with modules directory
./logos/bin/logoscore -D -m ./modules &
# Load the module
./logos/bin/logoscore load-module calc_module
# Call methods
./logos/bin/logoscore call calc_module add 3 5
./logos/bin/logoscore call calc_module factorial 5
./logos/bin/logoscore call calc_module fibonacci 10
./logos/bin/logoscore call calc_module libVersion
# Stop the daemon when done
./logos/bin/logoscore stop
For inline (legacy) mode and other logoscore options, see the Developer Guide -- Running with logoscore.
What happens under the hood:
logoscorescans./modules/for subdirectories containingmanifest.json- It finds
calc_moduleand extracts metadata from the plugin binary - It spawns a
logos_hostprocess that loadscalc_module_plugin.so logos_hostcallsinitLogos()on the plugin, providing aLogosAPI*for inter-module communication- The call command is parsed: module name
calc_module, methodadd, args[3, 5] logoscoresends the call tologos_hostvia Qt Remote Objects (IPC)logos_hostinvokesCalcModulePlugin::add(3, 5)which callscalc_add(3, 5)from libcalc- The result is returned via IPC to
logoscore
You'll see debug output like:
Debug: Found plugin: "./modules/calc_module/calc_module_plugin.so"
Debug: Plugin Metadata:
Debug: - Name: "calc_module"
Debug: - Version: "1.0.0"
Debug: - Description: "Calculator module wrapping libcalc C library"
Debug: Loading plugin: "calc_module" in separate process
Debug: Executing call: "calc_module" . "add" with 2 params
Method call successful. Result: ...
Step 6: Package for Distribution (Optional)
The LGX package created in Step 5.2 is a local package — its libraries still reference /nix/store paths, so it only works on the machine that built it. To create a portable package that can be distributed to other machines:
nix build '.#lgx-portable'
Portable LGX packages are fully self-contained with no /nix/store references at runtime. These are the packages used by the Logos App Package Manager UI and published to logos-modules releases.
To create both dev and portable variants (the dev variant works with local nix build of basecamp; the portable variant works with standalone basecamp builds), use --out-link to avoid overwriting the result symlink:
nix build '.#lgx' --out-link result-lgx
nix build '.#lgx-portable' --out-link result-lgx-portable
For more bundling options (standalone bundler syntax, cross-platform packaging), see the Developer Guide — Building LGX Packages.
To install a portable package on another machine:
nix build 'github:logos-co/logos-package-manager/tutorial-v2#cli' --out-link ./pm
./pm/bin/lgpm --modules-dir ./modules install --file result-lgx-portable/*.lgx
Note: Local builds of
logoscore/logos-basecamp(vianix build) expect local.lgxpackages. Portable builds (vianix build '.#bin-bundle-dir',.#bin-appimage, or.#bin-macos-app) expect portable.lgxpackages. See the logos-basecamp README for details.
Common Wrapping Patterns
Wrapping C functions with opaque pointers
Many C libraries use opaque pointers (handles) for state management:
// C API
typedef struct db_ctx db_ctx_t;
db_ctx_t* db_open(const char* path);
int db_get(db_ctx_t* ctx, const char* key, char* buf, int buf_len);
void db_close(db_ctx_t* ctx);
Store the handle in your plugin class:
class DbModulePlugin : public QObject, public DbModuleInterface
{
// ...
private:
db_ctx_t* m_ctx = nullptr;
public:
Q_INVOKABLE bool open(const QString& path) {
m_ctx = db_open(path.toUtf8().constData());
return m_ctx != nullptr;
}
Q_INVOKABLE QString get(const QString& key) {
if (!m_ctx) return QString();
char buf[4096];
int len = db_get(m_ctx, key.toUtf8().constData(), buf, sizeof(buf));
if (len < 0) return QString();
return QString::fromUtf8(buf, len);
}
~DbModulePlugin() {
if (m_ctx) db_close(m_ctx);
}
};
Wrapping C callbacks
C libraries often use callbacks for async operations:
typedef void (*event_cb)(int code, const char* msg, void* user_data);
void lib_set_callback(void* ctx, event_cb cb, void* user_data);
Use a static method as the callback, passing this as user_data:
class MyPlugin : public QObject, public MyInterface
{
// ...
static void c_callback(int code, const char* msg, void* user_data) {
auto* self = static_cast<MyPlugin*>(user_data);
// Forward to Qt signal (thread-safe)
emit self->eventResponse("lib_event",
QVariantList() << code << QString::fromUtf8(msg));
}
Q_INVOKABLE void startListening() {
lib_set_callback(m_ctx, c_callback, this);
}
};
Wrapping C libraries that allocate strings
If the C library returns allocated strings that must be freed:
Q_INVOKABLE QString getData() {
char* c_str = lib_get_data(m_ctx); // Library allocates
QString result = QString::fromUtf8(c_str);
lib_free_string(c_str); // Library deallocates
return result;
}
String conversion reference
| C type | Qt type | C → Qt | Qt → C |
|---|---|---|---|
const char* |
QString |
QString::fromUtf8(c_str) |
str.toUtf8().constData() |
const char* (binary) |
QByteArray |
QByteArray(data, len) |
ba.data(), ba.size() |
int |
int |
direct | direct |
bool / int |
bool |
result != 0 |
direct |
void* |
(store in member) | — | — |
Advanced: Wrapping a Library from a Flake Input
The main example already builds its library from source via externalLibInputs, using a relative path:./lib input for the bundled lib/ directory. The same mechanism works when the library source lives in its own GitHub repository — you just point the input at the remote instead of a local path. This is the common case when wrapping a third-party C/C++ library.
flake.nix with external library input
{
description = "Module wrapping libfoo from GitHub";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder/tutorial-v2";
# Fetch the library source (non-flake)
libfoo-src = {
url = "github:example/libfoo";
flake = false;
};
};
outputs = inputs@{ logos-module-builder, libfoo-src, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
# Pass the fetched source to the builder
externalLibInputs = {
foo = libfoo-src;
};
};
}
metadata.json for flake input
{
"name": "foo_module",
"version": "1.0.0",
"type": "core",
"description": "Module wrapping libfoo",
"main": "foo_module_plugin",
"dependencies": [],
"nix": {
"packages": { "build": [], "runtime": [] },
"external_libraries": [
{
"name": "foo",
"flake_input": "github:example/libfoo",
"build_command": "make shared",
"output_pattern": "build/libfoo.*"
}
],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": ["lib"],
"extra_link_libraries": []
}
}
}
Key difference: The externalLibInputs key in flake.nix (foo) must match the name field in nix.external_libraries (foo). The builder will:
- Clone the source from the flake input
- Run
build_command(make shared) - Search for output files matching
output_pattern - Copy the resulting
.so/.dyliband headers tolib/ - Proceed with the normal module build
For Go libraries
If the external library is written in Go with C bindings (cgo), set go_build: true in the nix.external_libraries entry within metadata.json:
{
"nix": {
"external_libraries": [
{
"name": "mygolib",
"flake_input": "github:example/mygolib",
"go_build": true,
"output_pattern": "libmygolib.*"
}
]
}
}
Setting go_build: true enables the Go toolchain and sets CGO_ENABLED=1.
Real-World Example: logos-libp2p-module
The logos-libp2p-module is a production module that wraps the nim-libp2p library (compiled to a C shared library). Key files:
**flake.nix**— UsesexternalLibInputsto fetch the nim-libp2p C bindings from a GitHub flake**metadata.json**— Declaresnim_libp2pas an external library withgo_build: falsein thenixsection**src/plugin.cpp**— Wraps ~40 C functions (libp2p_new,libp2p_start,libp2p_connect,libp2p_dial,libp2p_gossipsub_subscribe, etc.) asQ_INVOKABLEmethods**tests/**— Qt test suite that exercises every wrapped function
It follows the exact same pattern as this tutorial, just at a larger scale.
Troubleshooting
initLogos marked 'override', but does not override
error: 'void MyPlugin::initLogos(LogosAPI*)' marked 'override', but does not override
Fix: Remove the override keyword from initLogos. The base PluginInterface class does not declare it as virtual. The Logos host calls it reflectively via QMetaObject::invokeMethod. Declare it as:
Q_INVOKABLE void initLogos(LogosAPI* api); // No override!
Library not found at runtime
Cannot load library calc_module_plugin.so: libcalc.so: cannot open shared object file
Fix: Ensure libcalc.so / libcalc.dylib is in the same directory as the plugin. The build system sets RPATH to $ORIGIN (Linux) / @loader_path (macOS) so the plugin looks for libraries in its own directory.
initLogos stores API pointer in wrong variable
If inter-module calls or API features silently fail, check that initLogos assigns to the global logosAPI variable (defined in the Logos SDK / liblogos), not to a class member like m_logosAPI:
// CORRECT — uses the global variable from liblogos
void MyPlugin::initLogos(LogosAPI* api)
{
logosAPI = api;
}
// WRONG — stores in a local member, API calls won't work
void MyPlugin::initLogos(LogosAPI* api)
{
m_logosAPI = api;
}
Plugin not discovered by logoscore
Check:
- The module is in a subdirectory of the modules dir (e.g.,
modules/calc_module/) - The subdirectory contains a
manifest.jsonwith a validmainobject - The platform key in
mainmatches your OS/arch (e.g.,linux-aarch64,darwin-arm64)
nix build .#lib does nothing or fails silently
Some shells (notably zsh) treat # as a comment character. Always quote the flake reference:
# Correct
nix build '.#lib'
# May fail in zsh
nix build .#lib
First build is slow
The first nix build downloads Qt 6, the Logos C++ SDK, the code generator, and other dependencies. This is a one-time cost — subsequent builds use the Nix cache and are fast (usually under 30 seconds).
Symbol not found errors
If the plugin fails to load with "undefined symbol" errors for your C library functions, the library wasn't linked. The build does not fail in this case — find_library only prints a warning and the plugin links anyway, so the breakage surfaces at load time. Check:
- The library actually built — the build log should show a
logos-external-<name>derivation running yourbuild_commandand aFound: build/lib<name>...line. output_patternmatches what the build produces (build/libcalc.*↔build/libcalc.{so,dylib}).- The header has
extern "C"guards. - The symbols are exported:
nm -gU lib/build/libcalc.dylib | grep calc(macOS) /nm -D lib/build/libcalc.so | grep calc(Linux). - If you vendored a prebuilt binary (
vendor_path) instead of building from source, confirm it is committed — Nix can't see an un-staged file, andfind_librarymisses it silently. Verify the plugin actually links the library:otool -L result/lib/calc_module_plugin.dylib | grep calc(macOS) /ldd result/lib/calc_module_plugin.so | grep calc(Linux) should listlibcalc. If it doesn't, the library wasn't found at build time.