Files
logos-tutorial/logos-developer-guide.md

1828 lines
88 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Logos Module Developer Guide
A comprehensive guide to creating, building, testing, packaging, and distributing modules for the Logos platform.
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Part 1: Creating a Module](#part-1-creating-a-module)
- [1.1 Scaffold with logos-module-builder](#11-scaffold-with-logos-module-builder)
- [1.2 Project Structure](#12-project-structure)
- [1.3 The metadata.json Configuration](#13-the-metadatajson-configuration)
- [1.4 Writing Module Code](#14-writing-module-code)
- [1.5 Building Your Module](#15-building-your-module)
- [Part 2: Inspecting Your Module](#part-2-inspecting-your-module)
- [2.1 The lm CLI Tool](#21-the-lm-cli-tool)
- [2.2 The logos-module-viewer](#22-the-logos-module-viewer)
- [Part 3: Testing UI Modules](#part-3-testing-ui-modules)
- [3.1 How It Works](#31-how-it-works)
- [3.2 Writing Tests](#32-writing-tests)
- [3.3 Running Tests](#33-running-tests)
- [Part 4: Packaging Your Module](#part-4-packaging-your-module)
- [4.1 The LGX Package Format](#41-the-lgx-package-format)
- [4.2 Building LGX Packages](#42-building-lgx-packages)
- [Built-in Nix Derivation (Preferred)](#built-in-nix-derivation-preferred)
- [Using nix bundle (Alternative)](#using-nix-bundle-alternative)
- [Part 5: Installing and Managing Modules](#part-5-installing-and-managing-modules)
- [5.1 The lgpm CLI](#51-the-lgpm-cli)
- [5.2 Installing from Local Files](#52-installing-from-local-files)
- [5.3 Installing from a Registry](#53-installing-from-a-registry)
- [Part 6: Running Your Module](#part-6-running-your-module)
- [6.1 Running with logoscore](#61-running-with-logoscore)
- [Part 7: Running in logos-basecamp](#part-7-running-in-logos-basecamp)
- [7.1 Building logos-basecamp](#71-building-logos-basecamp)
- [7.2 Module Types in logos-basecamp](#72-module-types-in-logos-basecamp)
- [Part 8: Inter-Module Communication](#part-8-inter-module-communication)
- [8.1 The LogosAPI](#81-the-logosapi)
- [8.2 The C++ SDK Code Generator](#82-the-c-sdk-code-generator)
- [8.3 LogosResult](#83-logosresult)
- [8.4 Communication Modes](#84-communication-modes)
- [Part 9: Advanced Topics](#part-9-advanced-topics)
- [9.1 Tutorials](#91-tutorials)
- [9.2 Module Dependencies](#92-module-dependencies)
- [Reference: Repository Map](#reference-repository-map)
- [Reference: CLI Tools Summary](#reference-cli-tools-summary)
- [Troubleshooting](#troubleshooting)
---
## Overview
The **Logos platform** is a modular application framework built in C++ on top of Qt 6. Applications are composed of dynamically loaded **modules** (plugins) that communicate via an IPC layer. The platform provides:
- **Process isolation** -- each module runs in its own host process (on desktop), communicating via Qt Remote Objects
- **Cross-platform support** -- macOS (arm64, x86_64) and Linux (arm64, x86_64)
- **A package format** (`.lgx`) for distributing modules with platform-specific variants
- **A desktop application shell** (`logos-basecamp`) with a sidebar, tabbed workspace, and plugin management UI
- **A CLI runtime** (`logoscore`) for running modules headlessly
## Architecture
```
+---------------------------------------------------------------+
| Application Layer |
| logos-basecamp (Desktop GUI) or logoscore (CLI Runtime) |
+---------------------------------------------------------------+
| | |
v v v
+---------------+ +------------------+ +------------------+
| Module A | | Module B | | Package Manager |
| (logos_host) | | (logos_host) | | Module |
+-------+-------+ +--------+---------+ +--------+---------+
| | |
| Qt Remote Objects (IPC) |
+--------------------------------------------+
|
+--------v---------+
| liblogos | (Core Runtime)
| logos-liblogos |
+--------+---------+
|
+--------v---------+
| logos-cpp-sdk | (SDK: LogosAPI,
| | Code Generator,
| | Types, IPC)
+------------------+
```
**Key components:**
| Component | Repository | Role |
| ---------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| **logos-module-builder** | [logos-co/logos-module-builder](https://github.com/logos-co/logos-module-builder) | Scaffolding and build system for new modules |
| **logos-module** | [logos-co/logos-module](https://github.com/logos-co/logos-module) | Plugin loading/introspection library + `lm` CLI |
| **logos-cpp-sdk** | [logos-co/logos-cpp-sdk](https://github.com/logos-co/logos-cpp-sdk) | C++ SDK, types, IPC layer, code generator |
| **logos-liblogos** | [logos-co/logos-liblogos](https://github.com/logos-co/logos-liblogos) | Core library (`logos_host`, `liblogos_core`) |
| **logos-logoscore-cli** | [logos-co/logos-logoscore-cli](https://github.com/logos-co/logos-logoscore-cli) | Headless CLI runtime (`logoscore`) |
| **logos-package** | [logos-co/logos-package](https://github.com/logos-co/logos-package) | LGX package format library + `lgx` CLI |
| **logos-package-manager** | [logos-co/logos-package-manager](https://github.com/logos-co/logos-package-manager) | Local package manager library + `lgpm` CLI |
| **logos-package-downloader** | [logos-co/logos-package-downloader](https://github.com/logos-co/logos-package-downloader) | Online catalog browser + `lgpd` CLI |
| **logos-standalone-app** | [logos-co/logos-standalone-app](https://github.com/logos-co/logos-standalone-app) | Minimal shell for running/testing UI modules in isolation |
| **logos-basecamp** | [logos-co/logos-basecamp](https://github.com/logos-co/logos-basecamp) | Desktop application shell |
## Prerequisites
### Required
- **Nix** with flakes enabled. This is the primary build tool for the entire ecosystem. Install Nix from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
# If you need experimental features enabled per-command:
nix --extra-experimental-features "nix-command flakes" <command>
# Or enable globally in ~/.config/nix/nix.conf:
experimental-features = nix-command flakes
```
### Recommended Knowledge
- C++ (C++17)
- Qt 6 basics (`QObject`, `Q_INVOKABLE`, `Q_PLUGIN_METADATA`, signals/slots)
- Basic CMake
- Basic Nix concepts (flakes, derivations)
---
## Part 1: Creating a Module
### 1.1 Scaffold with logos-module-builder
The fastest way to create a new module is using the **logos-module-builder** template:
```bash
# Create a new directory for your module
mkdir logos-my-module && cd logos-my-module
# Scaffold a minimal core module (no external dependencies)
nix flake init -t github:logos-co/logos-module-builder
# Or scaffold a module that wraps an external C/C++ library
nix flake init -t github:logos-co/logos-module-builder#with-external-lib
# For ui_qml modules with C++ backend (process-isolated)
nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend
# For ui_qml modules (QML-only, no C++)
nix flake init -t github:logos-co/logos-module-builder#ui-qml
```
> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. For reproducible builds, pin it to a specific commit — see the `flake.nix` examples in [Section 3.2](#32-building-lgx-packages) and the [tutorials](tutorial-wrapping-c-library.md#23-flakenix--nix-build-config).
**Available templates:**
| Template | Use Case |
| ------------------- | ----------------------------------------------------- |
| `default` | Minimal core module (C++ backend, no UI) |
| `with-external-lib` | Core module wrapping an external C/C++ library |
| `ui-qml-backend` | ui_qml with C++ backend + QML view (process-isolated) |
| `ui-qml` | ui_qml QML-only (in-process, no C++) |
The `ui-qml-backend` and `ui-qml` templates automatically enable `nix run` to launch and test your UI plugin in isolation without the full logos-basecamp shell. The standalone app runner is bundled with `logos-module-builder` — no extra flake input is needed. All module dependencies declared in `metadata.json` are auto-bundled from their LGX packages.
This generates a ready-to-build project with all the boilerplate handled for you.
### 1.2 Project Structure
> We will use the recommended **pure-C++ pattern** (`"interface": "universal"`) for a core module. The scaffolding templates currently emit the older Qt-plugin layout; you replace their `src/` files with the two `*_impl` files shown here (see [Section 1.4](#14-understanding-the-module-code)).
A pure-C++ core module looks like this:
```
logos-my-module/
├── flake.nix # Nix flake (build config, ~15 lines)
├── metadata.json # Single source of truth: module metadata + build config (~30 lines)
├── CMakeLists.txt # CMake build file (~25 lines)
└── src/
├── my_module_impl.h # Plain C++ class — no Qt
└── my_module_impl.cpp # Implementation
```
The key insight: **logos-module-builder** reduces ~600 lines of configuration across 5+ files down to ~70 lines across 2-3 files, and the `universal` pattern collapses the three hand-written Qt source files into one plain C++ class. `metadata.json` serves as the single source of truth — it contains both the runtime metadata (embedded into the generated plugin binary) and the build configuration (read by the builder via the `nix` section).
The `CMakeLists.txt` is minimal -- it includes `LogosModule.cmake` (provided by the builder) and calls the `logos_module()` macro, which sets up the plugin target, runs `logos-cpp-generator` for `universal` modules, links the SDK, configures include paths, and compiles the generated glue. You just list your `*_impl` source files. See the [C-library tutorial](tutorial-wrapping-c-library.md#step-3-configure-the-logos-module) for a complete `CMakeLists.txt`.
### 1.3 The metadata.json Configuration
The `metadata.json` file is the single source of truth for your module. It is embedded into the generated plugin binary (for runtime metadata, read by `lm`), 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. See the scaffolded [`metadata.json`](https://github.com/logos-co/logos-module-builder/blob/master/templates/minimal-module/metadata.json) in the template.
The full set of available fields:
```json
{
"name": "my_module",
"display_name": "My Module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "My first Logos module",
"icon": "src/icons/my_module.png",
"main": "my_module_plugin",
"interface": "universal",
"dependencies": [],
"provides": [],
"uses": [],
"include": [],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": [],
"extra_link_libraries": []
}
}
}
```
**Field reference:**
| Field | Required | Default | Description |
| -------------------------------- | -------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | -- | Module name (used for filenames and identifiers) |
| `display_name` | No | `name` | Human-readable label shown in UIs (Package Manager, App Manager, `lm metadata`, `lgx manifest`). Consumers fall back to `name` when unset, so older packages keep working. |
| `version` | No | `1.0.0` | Semantic version |
| `type` | No | `core` | Module type (`core`, `ui`, `ui_qml`) |
| `category` | No | `general` | Category (general, network, chat, wallet, integration) |
| `description` | No | `"A Logos module"` | Human-readable description |
| `icon` | No | `null` | Relative path to the module icon. **PNG, exactly 256x256.** Required for `ui_qml` modules (manifest 0.4.0+), optional for `core`. Bundled once at `assets/icon.png` inside the `.lgx` so hosts can show it before install; also copied into the standalone app plugin directory. Convention: `src/icons/<module_name>.png`. |
| `main` | Yes (`core`/`ui`), optional (`ui_qml`) | -- | Plugin entry point. For `core`/`ui` modules: plugin name without extension (the generated `<name>_plugin`). For `ui_qml`: optional backend plugin name (omit if QML-only). |
| `interface` | No | -- | Set to `"universal"` for the pure-C++ pattern: you write a plain `src/<name>_impl.h`/`.cpp` and the builder runs `logos-cpp-generator --from-header` to synthesize the Qt plugin. Omit for the older hand-written Qt-plugin pattern. |
| `concurrency` | No | `"single"` | Dispatch mode. `"single"` (default): calls to this module are dispatched one at a time (event-loop semantics) — you need no thread-safety. `"multi"`: handlers run **concurrently** on a worker pool, so one blocking handler (a slow download, a slow RPC) no longer stalls other callers — but **you** own thread-safety. See [§1.6 Concurrent dispatch](#16-concurrent-dispatch). |
| `view` | Yes (`ui_qml`) | -- | Relative path to the QML entry file (e.g. `Main.qml`). Required for `ui_qml` modules. |
| `dependencies` | No | `[]` | Other Logos module names this depends on. Each entry must match the `name` field in that dependency's `metadata.json`. |
| `provides` | No | `[]` | Intents this module can service, as an **array of objects**: `[{"intent": "chat.group.open"}]`. Each entry may also carry `params` describing the payload it expects, which the shell enforces before dispatch — see §8.5. Intent **names** are carried into the signed `.lgx` manifest (0.5.0+) so a catalog can answer "which installable package provides X?"; `params` stays here, in `metadata.json`, which is the copy the shell reads. See §8.5. |
| `uses` | No | `[]` | Intents this module may request, as an **array of objects**: `[{"intent": "wallet.sign", "cardinality": "single"}]`. Mandatory to request one — an undeclared request fails `not_declared`. `cardinality` is optional; only `single` is accepted today (`all` is reserved). ⚠ A bare string array is silently ignored — see §8.5. |
| `interface_dependencies` | No | `[]` | Header *interfaces* this module binds at runtime, decoupled from any concrete module. Each entry is `{ name, file, impl_class?, input? }` — see [Dependency interfaces](#dependency-interfaces) and the [tutorial](tutorial-interface-dependencies.md). |
| `dependency_overrides` | No | `{}` | Per-dependency LIDL-contract source overrides, keyed by dependency name → `{ file, input?, impl_class? }`. Forces where a dependency's interface is read from; normally auto-resolved from the dep's `lidl` output. See [§9.2 Module Dependencies](#92-module-dependencies). |
| `include` | No | `[]` | Additional files (e.g. shared libraries like `libwaku.so`, `libwaku.dylib`) to bundle alongside the plugin in the output. |
| `nix.packages.build` | No | `[]` | Nix packages for build time |
| `nix.packages.runtime` | No | `[]` | Nix packages for runtime |
| `nix.external_libraries` | No | `[]` | External C/C++ libraries to wrap. Each entry is an object — see [configuration reference](https://github.com/logos-co/logos-module-builder/blob/master/docs/configuration.md#nixexternal_libraries) for fields (`name`, `vendor_path`, `build_command`, etc.). |
| `nix.cmake.find_packages` | No | `[]` | CMake `find_package()` calls |
| `nix.cmake.extra_sources` | No | `[]` | Additional source files to compile |
| `nix.cmake.extra_include_dirs` | No | `[]` | Additional include directories |
| `nix.cmake.extra_link_libraries` | No | `[]` | Additional libraries to link |
### 1.4 Understanding the Module Code
The recommended way to write a core module is the **pure-C++ pattern** (`"interface": "universal"` in `metadata.json`). You write a single plain C++ class — `src/<name>_impl.h` and `src/<name>_impl.cpp` — with **no Qt, no `Q_OBJECT`, no `Q_PLUGIN_METADATA`, no interface header**. At build time `logos-cpp-generator --from-header` parses your header and generates the Qt plugin wrapper, the interface, and the inter-module glue into `generated_code/`. You never see or edit that generated code.
A minimal impl class looks like this:
```cpp
// src/my_module_impl.h
#pragma once
#include <cstdint>
#include <string>
#include <logos_module_context.h> // optional: events + inter-module calls
class MyModuleImpl : public LogosModuleContext {
public:
// Every public method is exposed: discoverable by `lm`, callable by
// `logoscore call`, and reachable from other modules.
std::string greet(const std::string& name);
int64_t add(int64_t a, int64_t b);
// Events are declared like Qt signals. The generator emits the body;
// calling the method delivers the typed args to subscribers.
logos_events:
void greeted(const std::string& name);
};
```
```cpp
// src/my_module_impl.cpp
#include "my_module_impl.h"
std::string MyModuleImpl::greet(const std::string& name) {
greeted(name); // emit the event
return "Hello, " + name + "!";
}
int64_t MyModuleImpl::add(int64_t a, int64_t b) { return a + b; }
```
**How it works:**
1. **Any `public` method is exposed** — discoverable by `lm`, callable by `logoscore call`, and accessible from other modules. `private` members are not.
2. **Use the supported types** so the generator can translate them onto the wire: `void`, `bool`, `int64_t`, `uint64_t`, `double`, `std::string`, `std::vector<std::string>`, `std::vector<uint8_t>`, `LogosMap`/`LogosList` (from `<logos_json.h>`), and `StdLogosResult` (from `<logos_result.h>`). Use `int64_t` for integers, not `int`.
3. **Events** are declared in a `logos_events:` section (the class must inherit `LogosModuleContext`). Calling the event method routes the typed args to subscribers via the host's `eventResponse` channel — outside a host (unit tests) it's a safe no-op.
4. **Inter-module calls** also go through `LogosModuleContext`: from a method body, `modules().other_module.someMethod(arg)` calls another module using std types, with no raw `LogosAPI` and no Qt. Declare the dependency in `metadata.json`'s `dependencies` and as a flake input.
You do **not** write `initLogos`, `name()`/`version()` (read from `metadata.json`), `Q_INVOKABLE`, or the `eventResponse` signal — all are generated. `name()` is taken from `metadata.json`'s `name`, so they can never drift out of sync.
> **Older Qt-plugin pattern.** As of this writing the scaffolding templates still emit a hand-written Qt plugin (`*_interface.h` + `*_plugin.h` + `*_plugin.cpp` with `QObject`, `Q_PLUGIN_METADATA`, `Q_INVOKABLE`, and an `initLogos(LogosAPI*)` you store). That pattern still builds and is what `ui_qml` C++ backends use (see [Building a C++ UI Module](tutorial-cpp-ui-app.md)). For a new core module, prefer the pure-C++ pattern above — replace the template's `src/` files with your `*_impl.h`/`*_impl.cpp` and add `"interface": "universal"` to `metadata.json`. The [C-library tutorial](tutorial-wrapping-c-library.md) walks through this end to end.
### 1.5 Building Your Module
```bash
# Nix requires all source files to be tracked by git
git init && git add -A
# Build everything (library + generated SDK headers)
nix build
# Build just the plugin shared library (.so / .dylib)
nix build .#lib
# Build just the generated SDK headers (for other modules to use)
nix build .#include
# Emit a ready-to-build codebase: runs every code generator that is part of the
# build and writes the module source + a fully-populated generated_code/ to
# result/. Build it from `nix develop` without re-running any generator.
nix build .#generate
# Enter the dev shell for manual CMake builds (see: https://nix.dev/tutorials/first-steps/dev-environment)
# The shell provides cmake, ninja, Qt, the Logos SDK, and all build dependencies.
nix develop
cmake -B build -GNinja && cmake --build build
```
**Build outputs:**
```
result/
├── lib/
│ └── my_module_plugin.so # (or .dylib on macOS)
└── include/
├── my_module_api.h # Generated type-safe wrapper header
└── my_module_api.cpp # Generated wrapper implementation
```
---
### 1.6 Concurrent dispatch
By default every call to a module is dispatched **one at a time** — the module's
methods run on a single thread (the event loop), so you never have to think about
thread-safety. This is the right default and stays the default. The downside: a
handler that **blocks** — a download that runs for minutes, a slow RPC — stalls
*every other caller* of that module until it returns.
Set **`"concurrency": "multi"`** in `metadata.json` to opt that module into
**concurrent dispatch**: each incoming call runs on its own worker, so a blocking
handler no longer holds up the others (e.g. a downloader can serve two downloads
at once). In exchange, **you own thread-safety** — your handlers run in parallel,
so any state they share must be synchronized.
The generated code enforces the contract differently per language:
- **Rust** (`interface: "cdylib"`, rust-first). In `multi` mode the generated
trait takes **`&self`** (not `&mut self`) and is **`Send + Sync`**, and the
instance is shared behind an `Arc`. The compiler makes the rule unavoidable: a
handler that mutates a plain field won't build — wrap mutated state in interior
mutability (`Mutex`, `RwLock`, `Atomic*`, `DashMap`, …). `on_context_ready`
also becomes `&self`.
```rust
pub trait Downloader: Send + Sync + 'static {
fn fetch(&self, url: String) -> String; // &self — runs concurrently
}
#[derive(Default)]
struct Impl { jobs: std::sync::Mutex<Vec<String>> } // guard shared state
```
- **C++** (`interface: "universal"` / `"cdylib"`). In `multi` mode each call runs
on a worker thread, so your impl's methods may execute concurrently — treat them
as re-entrant and guard any shared members (`std::atomic`, `std::mutex`). The
`LogosModuleContext` accessors and the event-emit path are already thread-safe.
**How it works (and its limits).** `multi` is realized **entirely by the code
generator** — there is no new transport and, crucially, **no change to the
provider/host ABI**. A `multi` module's generated glue does not block in its
dispatch entry point: it hands the handler to a worker and immediately returns a
small *pending* marker, then pushes the real result back as a completion event
once the worker finishes. The consumer side awaits that completion transparently,
so generated clients are unchanged. The decomposition is *serialized dispatch +
concurrent processing + serialized responses*, and it works over the default
transport (QtRO) as well as the plain transport. Because the host merely forwards
the marker and the completion, **an existing (older) daemon or app loads and runs
a `multi` module unmodified** — this is logos-protocol **0.2**, an additive,
backward-compatible minor bump (same MAJOR ⇒ still compatible). Caveats: (1) a
`single` module is unchanged and pays zero overhead; (2) events your handlers emit
are delivered safely but their order **relative to method replies is not
guaranteed** — don't rely on "event X always arrives before method Y returns";
(3) a *caller* built against logos-protocol < 0.2 will see the raw pending marker
instead of the result — rebuild callers against ≥ 0.2 (still compatible with every
existing module) to consume a `multi` module concurrently.
---
## Part 2: Inspecting Your Module
### 2.1 The `lm` CLI Tool
The **`lm`** tool (from `logos-module`) lets you inspect compiled module binaries without loading them into the full runtime. It reads metadata and enumerates methods via Qt's meta-object system.
#### Building lm
```bash
nix build 'github:logos-co/logos-module#lm' --out-link ./lm
```
#### Viewing Metadata
```bash
# Human-readable metadata
./lm/bin/lm metadata ./result/lib/my_module_plugin.so
# JSON output
./lm/bin/lm metadata ./result/lib/my_module_plugin.so --json
```
Example JSON output:
```json
{
"name": "my_module",
"display_name": "My Module",
"version": "1.0.0",
"description": "My first Logos module",
"author": "",
"type": "core",
"dependencies": []
}
```
`display_name` is omitted when unset; consumers fall back to `name`.
#### Viewing Methods
```bash
# Human-readable method list
./lm/bin/lm methods ./result/lib/my_module_plugin.so
# JSON output
./lm/bin/lm methods ./result/lib/my_module_plugin.so --json
```
**The type names depend on which kind of module you are inspecting**, because
two different things publish this JSON:
- A **universal / cdylib module** (`"interface": "universal"`, the style used
throughout this guide and in the tutorials) publishes its **LIDL contract**
types — `tstr`, `int`, `uint`, `bstr`, `[tstr]`, `{tstr: any}`, `? uint`,
`result`, and a record's declared name. The module is Qt-free, so the
contract is the only vocabulary in which the question has one answer, and
a Rust module implementing the same contract answers identically.
- A **handwritten Qt plugin** publishes what its `QMetaObject` says — `QString`,
`QVariantList`, `QVariantMap` — because there the metaobject *is* the
contract.
Example JSON output, for a universal module with
`method doSomething(input: tstr) -> tstr`:
```json
[
{
"name": "doSomething",
"signature": "doSomething(tstr)",
"returnType": "tstr",
"isInvokable": true,
"parameters": [{ "name": "input", "type": "tstr" }]
},
{
"name": "name",
"signature": "name()",
"returnType": "tstr",
"isInvokable": true,
"description": "The module's name, as declared in its metadata."
}
]
```
`name` and `version` are **derived**: the generator emits them from
`metadata.json`, so every module answers them without the author writing them,
and they appear in every listing.
The same listing from a handwritten Qt plugin would instead read:
```json
[
{
"name": "doSomething",
"signature": "doSomething(QString)",
"returnType": "QString",
"isInvokable": true,
"parameters": [{ "name": "input", "type": "QString" }]
}
]
```
### 2.2 The logos-module-viewer
The **logos-module-viewer** is a graphical tool for inspecting loaded modules.
```bash
# Build the viewer
nix build 'github:logos-co/logos-module-viewer#app' --out-link ./logos-viewer
# Run it with your module
./logos-viewer/bin/logos-module-viewer -m ./result/lib/my_module_plugin.so
```
This opens a window showing the module's metadata, methods, and allows interactive method invocation.
---
## Part 3: Testing UI Modules
For `ui_qml` modules (both QML-only and C++ backend), `logos-module-builder` provides automatic integration testing using the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) QML inspector.
### 3.1 How It Works
The test infrastructure has three layers:
1. **QML Inspector** — a TCP server compiled into `logos-standalone-app` that exposes the QML object tree
2. **MCP Server** — a Node.js bridge that translates test commands into inspector calls
3. **Test Framework** — a JavaScript API for writing UI assertions (`expectTexts`, `click`, `waitFor`, etc.)
When you run `nix build .#integration-test`, the builder:
- Launches `logos-standalone-app` with your plugin in headless mode (`QT_QPA_PLATFORM=offscreen`)
- Connects to the QML inspector
- Runs all `.mjs` test files in your `tests/` directory
### 3.2 Writing Tests
Create `.mjs` files in `tests/`. Each file imports the test framework and defines test cases:
```javascript
import { resolve } from "node:path";
// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
resolve(root, "test-framework/framework.mjs")
);
test("my_module: loads UI", async (app) => {
await app.waitFor(
async () => {
await app.expectTexts(["Hello"]);
},
{ timeout: 15000, interval: 500, description: "UI to load" },
);
});
test("my_module: click button", async (app) => {
await app.click("Submit");
await app.expectTexts(["Result:"]);
});
run();
```
Key test APIs:
- `app.expectTexts(["text1", "text2"])` — assert text is visible in the UI
- `app.click("Button Text")` — find an element by text and click it
- `app.waitFor(fn, opts)` — retry an assertion until it passes or times out
- `app.screenshot()` — capture the current UI state (returns a base64 PNG; write it to a file to embed it in docs)
> In the executable-tutorial specs (`tests/*.test.yaml`), you don't call `app.screenshot()` directly — add a `screenshot: "name.png"` field to any UI-test action and the runner captures the headless app to `outputs/images/` and embeds it in the generated tutorial. See [`docs/spec.md`](docs/spec.md).
### 3.3 Running Tests
```bash
# Hermetic CI test (builds everything, no display needed)
nix build .#integration-test -L
# Interactive: build the test framework locally (one-time)
nix build .#test-framework -o result-mcp
# Start the app (inspector listens on localhost:3768)
nix run .
# Run tests against the running app (in another terminal)
node tests/ui-tests.mjs
```
Multiple test files in `tests/` are discovered and run automatically. You can organize tests by concern (e.g., `tests/smoke.mjs`, `tests/interactions.mjs`).
> **Note:** The integration test infrastructure requires `logos-standalone-app` with QML inspector support. This is provided automatically by `logos-module-builder` — no extra flake inputs needed.
---
## Part 4: Packaging Your Module
Before you can run your module with `logoscore` or install it into `logos-basecamp`, you need to package the build output into an `.lgx` package and install it into a `modules/` directory.
### 4.1 The LGX Package Format
Logos modules are distributed as **`.lgx` packages**. An LGX file is a gzip-compressed tar archive with a specific internal structure:
```
mymodule.lgx (tar.gz)
├── manifest.json # Package metadata
├── variants/
│ ├── linux-amd64/
│ │ └── my_module_plugin.so
│ ├── darwin-arm64/
│ │ └── my_module_plugin.dylib
│ └── darwin-arm64-dev/
│ └── my_module_plugin.dylib
├── docs/ # Optional
└── licenses/ # Optional
```
The **manifest.json** is auto-generated from your module's `metadata.json` by the bundler. It maps each variant to its main entry point.
It is not a copy of `metadata.json`. The bundler projects a fixed set of fields
across — including `name`, `version`, `type`, `dependencies`, `view`, `icon` and
`provides` — and anything else stays behind in `metadata.json`. Two consequences
worth knowing:
- **`manifest.json` is signed; `metadata.json` is not.** The signature covers
the manifest bytes, so whatever reaches the manifest is attested by whoever
signed the package.
- **`provides` is carried; `uses` is not.** A catalog needs to know what an
uninstalled package *can do* to suggest it; nobody outside the shell needs to
know what it *wants to call*.
`manifestVersion` tracks the manifest schema, separately from your module's
`version`:
| Schema | Adds |
| --- | --- |
| `0.2.x` | plain-string dependencies |
| `0.3.x` | dependency version ranges + signer DIDs |
| `0.4.x` | root-level `assets/icon.png` (the 256×256 PNG contract) |
| `0.5.x` | `provides` |
Every addition so far has been an **optional** field, so a client reading a newer
manifest ignores what it does not recognise rather than refusing the package.
That is why a package built before 0.5.0 simply has no `provides` — and why the
version was bumped rather than reused, so "declares no intents" stays
distinguishable from "predates the field".
### 4.2 Building LGX Packages
There are two ways to create `.lgx` packages. The preferred approach uses the built-in Nix derivation that comes with `logos-module-builder`. Alternatively, you can use the `nix bundle` command directly.
#### Built-in Nix Derivation (Preferred)
When your module uses `logos-module-builder`, LGX package outputs are automatically available as part of your flake (the builder includes `nix-bundle-lgx` internally):
```bash
# Dev variant (uses /nix/store references, for local development)
nix build .#lgx
# Portable variant (self-contained, all dependencies bundled)
nix build .#lgx-portable
```
This produces a `my_module-<version>.lgx` file in the `result/` directory.
This works because `logos-module-builder` includes `nix-bundle-lgx` as its own dependency and both `mkLogosModule` and `mkLogosQmlModule` automatically create the `lgx` and `lgx-portable` package outputs. No extra configuration is needed — it is part of the standard module template:
```nix
{
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;
};
}
```
#### Using `nix bundle` (Alternative)
You can also create `.lgx` packages using the `nix bundle` command directly. This is useful if your module does not use `logos-module-builder`, or if you need the `dual` bundling mode (both dev and portable in a single `.lgx` file) which is only available via the `nix bundle` command:
```bash
# Dev variant
nix bundle --bundler github:logos-co/nix-bundle-lgx .#lib
# Portable variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#portable .#lib
# Dual variant (both dev and portable in one .lgx file)
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib
```
This produces a `my_module-<version>.lgx` file in the current directory.
**Bundling modes:**
| Mode | Built-in Command | `nix bundle` Command | Variant Created | Use Case |
| ------------ | ----------------------------- | ----------------------------------------- | --------------------- | -------------------------------------------- |
| **Dev** | `nix build .#lgx` | `nix bundle --bundler ...#default .#lib` | `darwin-arm64-dev` | Local development (requires Nix store) |
| **Portable** | `nix build .#lgx-portable` | `nix bundle --bundler ...#portable .#lib` | `darwin-arm64` | Distribution (self-contained, no Nix needed) |
| **Dual** | _(not available as built-in)_ | `nix bundle --bundler ...#dual .#lib` | Both dev and portable | One package for both environments |
**Variant naming:**
| Nix System | Dev Variant | Portable Variant |
| ---------------- | ------------------ | ---------------- |
| `aarch64-darwin` | `darwin-arm64-dev` | `darwin-arm64` |
| `x86_64-darwin` | `darwin-amd64-dev` | `darwin-amd64` |
| `aarch64-linux` | `linux-arm64-dev` | `linux-arm64` |
| `x86_64-linux` | `linux-amd64-dev` | `linux-amd64` |
| `x86_64-windows` | `windows-x86_64-dev` | `windows-x86_64` |
> **Important:** The variant type matters when installing into `logos-basecamp`. A dev build of basecamp expects dev variants, and a portable build expects portable variants. Use the `dual` bundler to produce packages that work with both.
> **Windows is cross-built only.** `x86_64-windows` is a pseudo-system: there is no Nix daemon for Windows, so the package is produced on a Linux (or macOS) machine targeting `x86_64-w64-mingw32` and copied across. Note the variant is spelled `windows-x86_64`, not `windows-amd64` — unlike Linux, it has no alias, so a package labelled `windows-amd64` will not install.
---
## Part 5: Installing and Managing Modules
### 5.1 The `lgpm` CLI
The **`lgpm`** CLI (Logos Package Manager) installs, searches, and manages module packages. Installing a package extracts it into a `modules/` directory that `logoscore` and `logos-basecamp` can load from.
#### Building lgpm
```bash
nix build 'github:logos-co/logos-package-manager#cli' --out-link ./package-manager
```
#### Commands
`lgpm` manages **locally-available** `.lgx` packages. It does not download packages from the network — use `lgpd` (logos-package-downloader) for that.
```bash
# Install from a local .lgx file
./package-manager/bin/lgpm --modules-dir ./modules install --file ./my_module.lgx
# Install all .lgx files in a directory
./package-manager/bin/lgpm --modules-dir ./modules install --dir ./packages/
# List installed packages
./package-manager/bin/lgpm --modules-dir ./modules list
# Show installed package details
./package-manager/bin/lgpm --modules-dir ./modules info my_module
```
#### Global Options
| Option | Description |
| ------------------------- | ------------------------------------------- |
| `--modules-dir <path>` | Target directory for installed core modules |
| `--ui-plugins-dir <path>` | Target directory for UI plugins |
| `--json` | Output in JSON format |
| `--platform <variant>` | Install for a platform other than this machine (see below) |
| `-h, --help` | Show help |
#### Installing for another platform
By default `lgpm` derives the variant from the machine it is running on, and
**refuses** a package that does not provide it:
```
Error: Package does not contain variant for platform: linux-x86_64-dev
(package provides: windows-x86_64-dev)
```
That refusal is the protection against installing a package built for one
platform onto another, so it is deliberately fail-closed. Cross-building needs an
explicit opt-out — the Nix install bundler, for instance, runs `lgpm` on a Linux
builder to lay out a Windows package:
```bash
lgpm --modules-dir ./modules install --platform windows-x86_64 --file ./my_module.lgx
```
`--platform` applies to `install`, `list` and `info` alike, so all three agree on
which platform is being managed, and `lgpm` prints the override to stderr when it
is in effect — a silent platform switch would defeat the very check it bypasses.
> **Do not reach for `--platform` to resolve a dev/portable variant mismatch.**
> If a package provides `darwin-arm64` and your basecamp wants
> `darwin-arm64-dev`, the fix is to build the right variant (or use the `dual`
> bundler), not to override the platform — forcing it installs a package the
> runtime cannot load, turning a clear install-time error into a confusing
> load-time one.
### 5.2 Installing from Local Files
```bash
# Install a locally built .lgx package into a modules/ directory
./package-manager/bin/lgpm --modules-dir ./modules install --file ./my_module-1.0.0.lgx
```
After installation, the `modules/` directory contains your extracted module:
```
modules/
└── my_module/
├── manifest.json
├── my_module_plugin.dylib # (or .so on Linux)
└── variant
```
### 5.3 Downloading and Installing from a Registry
To download packages from the online catalog and then install them locally, use `lgpd` (logos-package-downloader) followed by `lgpm`:
```bash
# Build lgpd
nix build 'github:logos-co/logos-package-downloader#cli' --out-link ./downloader
# Search for packages
./downloader/bin/lgpd search waku
# List all available packages
./downloader/bin/lgpd list
# Download a package
./downloader/bin/lgpd download my_module -o ./packages/
# Download from a specific release
./downloader/bin/lgpd --release v2.0.0 download my_module -o ./packages/
# Install the downloaded package locally
./package-manager/bin/lgpm --modules-dir ./modules install --file ./packages/my_module.lgx
```
`lgpd` handles the network side (browsing, searching, downloading), while `lgpm` handles local installation.
---
## Part 6: Running Your Module
Once your module is packaged and installed into a `modules/` directory (see Parts 3 and 4), you can run it with `logoscore`.
### 6.1 Running with `logoscore`
The **`logoscore`** CLI (from `logos-liblogos`) is a headless runtime that can load modules and invoke their methods from the command line.
#### Building logoscore
```bash
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
```
#### Daemon Mode
`logoscore` runs as a daemon that stays alive to host modules. Start it with `-D`:
```bash
# Start the daemon with a modules directory
./logos/bin/logoscore -D -m ./modules
```
Once the daemon is running, use commands from another terminal:
```bash
# Load a module
./logos/bin/logoscore load-module my_module
# Call a method on a loaded module
./logos/bin/logoscore call my_module doSomething hello
# List loaded modules
./logos/bin/logoscore list-modules --loaded
# Show module details
./logos/bin/logoscore module-info my_module
# Watch events from a module
./logos/bin/logoscore watch my_module
# Show daemon and module health
./logos/bin/logoscore status
# Stop the daemon
./logos/bin/logoscore stop
```
#### One-shot execution
There is no separate single-process mode — start a clean daemon, load the
module(s) with `load-module`, call methods with `call`, then stop the daemon:
```bash
# Start a clean daemon (loads nothing on its own)
./logos/bin/logoscore -D -m ./modules &
# Wait until the daemon is accepting commands
until ./logos/bin/logoscore status >/dev/null 2>&1; do sleep 0.2; done
# Load the module(s) you need (dependencies resolved automatically)
./logos/bin/logoscore load-module my_module
# Call methods (positional args; @file reads a parameter from a file)
./logos/bin/logoscore call my_module doSomething hello
./logos/bin/logoscore call my_module init @config.json
./logos/bin/logoscore call my_module start
# Stop the daemon when done
./logos/bin/logoscore stop
```
> **Note:** The legacy inline mode (`-c "module.method(args)"` / `--quit-on-finish`,
> which ran calls in one short-lived process) has been removed, as has the
> `-l/--load-modules` autoload flag — the daemon starts clean and modules are
> loaded with `load-module`. `-m`/`--persistence-path` configure daemon startup (`-D`).
**Daemon startup flags:**
| Flag | Description |
| ---------------------------------- | ---------------------------------------------------- |
| `-D` | Start the daemon |
| `-m, --modules-dir <dir>` | Directory containing module libraries (repeatable) |
| `--persistence-path <dir>` | Base directory for module instance persistence |
| `--config-dir <dir>` | Isolate this daemon's config/state/tokens dir (run multiple instances; the client must use the same `--config-dir`) |
| `@file.json` (as a `call` arg) | Pass a file's contents as a method argument |
**Daemon commands:**
| Command | Description |
| ------------------------------- | -------------------------------- |
| `status` | Show daemon and module health |
| `load-module <name>` | Load a module into the daemon |
| `unload-module <name>` | Unload a module |
| `reload-module <name>` | Reload (unload + load) a module |
| `list-modules [--loaded]` | List available or loaded modules |
| `module-info <name>` | Show detailed module information |
| `call <module> <method> [args]` | Call a method on a loaded module |
| `watch <module> [--event]` | Watch events from a module |
| `stats` | Show module resource usage |
| `stop` | Stop the daemon |
---
## Part 7: Running in logos-basecamp
### 7.1 Building logos-basecamp
logos-basecamp produces two binary variants:
- **development** (depends on `/nix/store`)
- **portable** (self-contained, used in distributed builds and `.app` bundles)
```bash
# Build the development version
nix build 'github:logos-co/logos-basecamp#app' --out-link ./logos-basecamp
# Run the dev binary
./logos-basecamp/bin/logos-basecamp
# Build the portable/distributed version
nix build 'github:logos-co/logos-basecamp#portable' --out-link ./logos-basecamp-portable
# Or build platform-specific distributions:
nix build 'github:logos-co/logos-basecamp#bin-bundle-dir' # Flat directory bundle
nix build 'github:logos-co/logos-basecamp#bin-appimage' # Linux AppImage
nix build 'github:logos-co/logos-basecamp#bin-macos-app' # macOS .app bundle
```
> **Note:** When installing modules into logos-basecamp, the LGX variant type must match the build type. Dev builds of basecamp expect **dev** LGX variants (e.g., `darwin-arm64-dev`), while portable builds expect **portable** variants (e.g., `darwin-arm64`). Use the `dual` bundler (see [3.2](#32-bundling-with-nix-bundle-lgx)) to produce packages that work with both.
### 7.2 Module Types in logos-basecamp
The application supports three types of modules:
#### Core Modules (Backend)
These are non-UI modules that provide backend functionality. They run in isolated `logos_host` processes and communicate via Qt Remote Objects.
- Loaded via `logos_core_load_plugin()`
- Placed in the **modules directory** (`--modules-dir`)
- Have `"type": "core"` in metadata
#### ui_qml with C++ Backend (Process-Isolated)
These have `"type": "ui_qml"` with both `"main"` (backend plugin) and `"view"` (QML entry point) in `metadata.json`. The C++ backend runs in a separate `ui-host` process; the QML view loads in the host app.
The remote interface is defined in a **`.rep` file** (Qt Remote Objects definition):
```rep
class CalcUiCpp
{
PROP(QString status READWRITE) // auto-synced to QML replica
SLOT(int add(int a, int b)) // callable from QML, returns via Promise
SIGNAL(errorOccurred(QString msg)) // one-shot events
}
```
The `.rep` file is the **single source of truth** — `repc` generates:
- `CalcUiCppSimpleSource` — base class the C++ backend inherits
- `CalcUiCppReplica` — typed replica the QML view uses via `logos.module()`
- A separate `_replica_factory` plugin for typed remoting
The C++ plugin inherits from the generated source + `ViewPluginBase`:
```cpp
class CalcUiCppPlugin : public CalcUiCppSimpleSource,
public CalcUiCppInterface,
public CalcUiCppViewPluginBase { ... };
```
QML accesses the backend via a typed replica:
```qml
readonly property var backend: logos.module("calc_ui_cpp")
// Properties auto-sync:
Text { text: backend.status }
// Return values via Promise:
logos.watch(backend.add(1, 2), function(v) { ... })
```
- Scaffold: `nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend`
- See [Tutorial Part 3](tutorial-cpp-ui-app.md) for a complete walkthrough
#### ui_qml QML-Only (In-Process)
These have `"type": "ui_qml"` with `"view"` but no `"main"` — pure QML, no C++ compilation, no process isolation:
- QML view loads directly in the host app (basecamp / standalone)
- No `.rep` file needed
- Call core modules via the `logos` bridge: `logos.callModule("module", "method", [args])`
- Network access denied, filesystem restricted to module directory
- Scaffold: `nix flake init -t github:logos-co/logos-module-builder#ui-qml`
- See [Tutorial Part 2](tutorial-qml-ui-app.md) for a complete walkthrough
---
## Part 8: Inter-Module Communication
### 8.1 The LogosAPI
Every module receives a `LogosAPI*` pointer when `initLogos()` is called. This is your gateway to communicating with other modules.
```cpp
void MyModulePlugin::initLogos(LogosAPI* logosAPIInstance)
{
logosAPI = logosAPIInstance;
// Get a client for calling another module
LogosAPIClient* client = logosAPI->getClient("other_module");
// Synchronous call (blocks until result is returned)
QVariant result = client->invokeRemoteMethod(
"other_module", // target module name
"someMethod", // method name
arg1, arg2 // arguments (up to 5 positional args)
);
// Async call (preferred -- non-blocking, result delivered via callback)
client->invokeRemoteMethodAsync(
"other_module",
"someMethod",
[](QVariant result) {
// Handle result (called on the main thread)
if (result.isValid()) {
qDebug() << "Got result:" << result;
}
},
arg1, arg2
);
}
```
> **Prefer async calls.** Synchronous `invokeRemoteMethod` blocks the caller's thread until the remote module responds. Use `invokeRemoteMethodAsync` to avoid blocking, especially in UI modules.
### 8.2 The C++ SDK Code Generator
The `logos-cpp-generator` tool (from `logos-cpp-sdk`) inspects a compiled module and generates typed C++ wrapper classes, so you get compile-time type safety instead of raw `invokeRemoteMethod` calls.
#### Getting logos-cpp-generator
The generator is bundled with `logos-cpp-sdk`. It is automatically available:
- **In `nix develop`** -- the module dev shell includes the SDK on PATH
- **Build it directly:**
```bash
nix build 'github:logos-co/logos-cpp-sdk#cpp-generator' --out-link ./cpp-gen
./cpp-gen/bin/logos-cpp-generator --help
```
#### Generating Wrappers
```bash
# Generate wrappers for a single module, from the CONTRACT it ships beside its
# plugin. `--events-from` names that contract, and the wrapper's typed methods,
# record structs and typed on<Event>() accessors all come from it.
logos-cpp-generator /path/to/my_module_plugin.so --output-dir ./generated \
--events-from /path/to/share/logos/my_module.lidl
# A handcrafted Qt module publishes no contract; omit the flag and the wrapper
# comes from the plugin's Qt metaobject, which is then the only description of
# its API that exists.
logos-cpp-generator /path/to/handcrafted_plugin.so --output-dir ./generated
# Generate a wrapper per dependency, each from that dependency's LIDL contract
logos-cpp-generator --metadata metadata.json --general-only --output-dir ./generated \
--dep waku_module=/path/to/waku_module.lidl
# Generate only module files (no umbrella headers)
logos-cpp-generator /path/to/plugin.so --module-only --output-dir ./generated \
--events-from /path/to/share/logos/my_module.lidl
# Generate only umbrella SDK files (assumes module files exist)
logos-cpp-generator --metadata metadata.json --general-only --output-dir ./generated
```
> **Why `--events-from` is not optional for a module that has a contract.** A
> module built with `interface: "universal"` or `"cdylib"` publishes its
> `getMethods()` metadata in the LIDL contract vocabulary (`tstr`, `[uint]`,
> `result`) — that listing is what `lm` and `logoscore` show a human, and Qt
> type names would be the wrong answer for a Qt-free module. The wrapper
> emitter reads Qt type names, so generating from that listing would silently
> produce a wrapper of `QVariant` / `LogosMap`. It refuses instead, naming the
> contract to pass. Nix builds pass it for you: `buildHeaders.nix` finds
> `<module>/share/logos/<name>.lidl`, which `buildPlugin.nix` installed.
#### Using Generated Wrappers
After generation, you get typed wrapper classes with both synchronous and asynchronous methods:
```cpp
#include "logos_sdk.h" // Umbrella header
// In your module's initLogos():
void MyModulePlugin::initLogos(LogosAPI* api) {
logosAPI = api;
// Create the typed SDK wrapper
LogosModules* logos = new LogosModules(api);
// Synchronous call (blocks until result)
QString result = logos->other_module.doSomething("hello");
// Async call (preferred -- non-blocking)
logos->other_module.doSomethingAsync("hello", [](QVariant result) {
qDebug() << "Got:" << result;
});
}
```
The generated `LogosModules` struct provides a member for each module, with methods matching the module's `Q_INVOKABLE` methods. For every method `foo()`, an async variant `fooAsync()` is also generated that takes a callback parameter.
> **Prefer async wrappers.** Use `doSomethingAsync(...)` instead of `doSomething(...)` to avoid blocking the caller's thread. Synchronous calls can cause hangs if the target module is slow to respond.
### Dependency Interfaces
A regular dependency couples a module to **one concrete provider**: you list `other_module` in `dependencies`, and the generated `modules().other_module` wrapper bakes that name into every call. A **dependency interface** instead lets a module declare a *contract* — a list of methods and events — that **any** module exposing a superset of it can satisfy, and bind that contract to a concrete module **chosen at runtime**.
Declare interfaces in `metadata.json` under `interface_dependencies`, alongside (or instead of) `dependencies`:
```json
"interface_dependencies": [
{ "name": "calculator", "file": "interfaces/calculator.h", "impl_class": "ICalculator" }
]
```
| Field | Required | Meaning |
| ------------ | --------------- | ---------------------------------------------------------------------------------------------------- |
| `name` | Yes | Interface identifier → bound wrapper class (`Calculator`) and the `bind_<name>` factory |
| `file` | Yes | Path to the contract: a pure-C++ `.h` (methods + a `logos_events:` block) or a `.lidl` file |
| `impl_class` | For `.h` files | The class inside the header whose signatures define the contract |
| `input` | No | A flake-input name hosting the interface (same wiring as `dependencies`); omit for a local file |
The contract is written in the module's own language — for a universal module, a plain header:
```cpp
// interfaces/calculator.h
class ICalculator {
public:
int64_t add(int64_t a, int64_t b);
std::string libVersion();
logos_events:
void versionReady(const std::string& version);
};
```
The generator emits a **bound** wrapper whose target module is a constructor argument (not baked in), exposed on `LogosModules` as a `bind_<name>(moduleName)` factory. Bind once, then call as usual:
```cpp
#include "logos_sdk.h"
// moduleName is chosen at runtime — config, discovery, user pick, etc.
auto calc = modules().bind_calculator("calc_module");
int64_t sum = calc.add(3, 5); // synchronous
calc.fibonacciAsync(20, [](int64_t v){ ... }); // async (generated alongside)
calc.onVersionReady([](const std::string& v){ ... }); // typed event subscription
```
Binding is **not validated**: a module that does not satisfy the interface surfaces an ordinary remote-call error (a default-valued result), never a crash — so you can swap providers just by changing the bound name. The provider must be loaded at runtime; declaring it in `dependencies` is one way to ensure that, but the interface itself names no module.
See the [Dependency Interfaces tutorial](tutorial-interface-dependencies.md) for an end-to-end walkthrough, and [Composing Modules](tutorial-composing-modules.md) for the concrete-dependency counterpart.
### 8.3 LogosResult
Many module methods return `LogosResult` for structured success/error handling:
```cpp
LogosResult result = logos->my_module.someMethod();
if (result.success) {
// Access the value
QString value = result.getString();
int number = result.getInt();
bool flag = result.getBool();
QVariantMap map = result.getMap();
QVariantList list = result.getList();
// Access nested values by key (for map results)
QString name = result.getString("name");
int count = result.getInt("count", 0); // with default
// Generic typed access
auto custom = result.getValue<MyType>();
} else {
// Access the error
QString error = result.getError();
}
```
To return a `LogosResult` from your module:
```cpp
Q_INVOKABLE LogosResult MyModulePlugin::fetchData(const QString& id) {
if (id.isEmpty()) {
return {false, QVariant(), "ID cannot be empty"};
}
QVariantMap data;
data["id"] = id;
data["name"] = "Example";
data["count"] = 42;
return {true, data};
}
```
### 8.4 Communication Modes
The SDK supports two communication modes:
| Mode | Use Case | Mechanism |
| -------------------- | --------------------------- | ----------------------------------------- |
| **Remote** (default) | Desktop apps | Qt Remote Objects (IPC between processes) |
| **Local** | Mobile apps, single-process | In-process `PluginRegistry` |
Set the mode before creating any `LogosAPI` instances:
```cpp
// For mobile / embedded (all modules in one process)
LogosModeConfig::setMode(LogosMode::Local);
// For desktop (each module in its own process) -- this is the default
LogosModeConfig::setMode(LogosMode::Remote);
```
### 8.5 App-to-App Intents
Everything above is a module calling a **named** module: you know who you want
and you call it. Intents are the other shape — you name a **capability** and let
the shell find a provider.
```qml
// "Somebody show this chat group." The requester never learns who did.
logos.request("chat.group.open", { groupId: "abc123" }, function (res) {
if (res.ok) console.log("opened by", res.data.provider);
else console.log("failed:", res.error);
});
```
Use `callModule` when you depend on a specific module. Use an intent when you
want a capability and any qualified app will do — that is what lets a second
wallet be installed and picked without the calling app knowing it exists.
#### Declaring intents
Both keys go in `metadata.json`, and both are **arrays of objects**:
```json
"provides": [ { "intent": "chat.group.open" } ],
"uses": [ { "intent": "wallet.sign", "cardinality": "single" } ]
```
> **The most common mistake.** A bare string array is silently ignored:
>
> ```json
> "uses": ["wallet.sign"] ← WRONG. Parsed, discarded, no build error.
> "uses": [{"intent": "wallet.sign"}] ← right
> ```
>
> The request then fails with `not_declared` and nothing points at the manifest.
> Check the shell's log for `IntentRegistry:` lines, which name every
> declaration that was skipped and why.
`uses` is mandatory: an app may only request intents it declared. That bounds an
app's reachable capabilities to a set fixed when the package was built, so a
compromised view cannot reach for something the package never asked for.
The `logos.` prefix is reserved for capabilities the shell itself provides and
is refused from any installed package.
#### The three QML symbols
| Symbol | Direction |
| --- | --- |
| `logos.request(intent, params, callback)` | ask for a capability |
| `logos.respond(requestId, ok, data, error)` | answer one you provide |
| `intentRequested(requestId, intent, params, requesterName)` | signal: someone asked you |
A provider handles requests like any other signal:
```qml
Connections {
target: logos
function onIntentRequested(requestId, intent, params, requesterName) {
// Show UI, let the user decide, then answer. Answering later is normal
// and expected — you are not obliged to respond synchronously.
logos.respond(requestId, true, ({ provider: "my_app" }), "");
}
}
```
Three properties of the callback worth relying on:
- **Exactly once.** Every request terminates, including timeouts.
- **Always asynchronous**, even for an immediate failure. No app can come to
depend on a synchronous reply.
- **Real JS values.** `res.data.groupId` works; there is no JSON string to parse.
If you declare `provides` but never connect `intentRequested`, requests to you
end in `timeout` rather than hanging — the shell counts receivers to detect it.
#### The six error codes
`res.error` is one of exactly six values:
| Code | Meaning |
| --- | --- |
| `not_declared` | you did not list this intent in your own `uses` |
| `unavailable` | no provider could service it |
| `bad_request` | your `params` were rejected — fix what you sent |
| `cancelled` | the user dismissed the chooser, or the provider cancelled |
| `timeout` | a provider was reached but never answered |
| `failed` | the provider reported a failure |
A provider may only report `cancelled`, `timeout`, `failed` or `bad_request`.
Anything else it returns is coerced to `failed`. `not_declared` and
`unavailable` are the shell's alone, because both reveal whether a provider
exists at all.
`bad_request` is the one code both the shell and a provider can mint, and that
is deliberate. The shell mints it when `params` cannot cross an app boundary at
all — nested past eight levels, a string over 64 KB, a `QObject*`, a function.
A provider mints it when the values are well-formed but unusable: a missing
required field, an address that is not an address. If only the shell could mint
it, receiving it would prove no provider was ever consulted, and that is an
existence oracle of exactly the kind `unavailable` exists to prevent. Because
both can mint it, the shell's own `bad_request` is held to the same timing floor
as `unavailable` — you cannot tell from the delay which side rejected you.
The distinction from `failed` is what you should do next. `failed` means the
world did not cooperate; retrying is reasonable. `bad_request` means you sent
the wrong thing; retrying unchanged will fail identically. Check the provider's
`provides[].params` in its `metadata.json` (§4.2) to see the shape it expects.
#### Describing what an intent needs — `provides[].params`
A provider can say what payload it expects, alongside the capability itself:
```json
"provides": [
{
"intent": "wallet.send",
"params": [
{ "name": "to", "type": "string", "required": true,
"description": "Destination address" },
{ "name": "amount", "type": "number", "required": true },
{ "name": "memo", "type": "string", "required": false }
]
}
]
```
`type` is one of `string`, `number`, `bool`, `object`, `array`.
The shell checks the payload against this immediately **before dispatch**, and
refuses with `bad_request` if a required field is missing or a value has the
wrong type. The provider never sees a payload it declared unusable.
Three rules worth knowing:
- **Undeclared extra fields pass.** A caller written against a newer version of
a provider must not be broken by an older description, and a provider may
accept more than it lists.
- **No `params` means undescribed, not "takes nothing".** Nothing is validated.
- **Checked after a provider is chosen, never at submit.** Two providers of one
intent may describe it differently, so there is no single spec to check at
submit time — and testing all of them would reveal how many exist.
This is per-*provider*, not per-*intent*: it describes what one app wants, not
what the name means. A published registry of intent definitions is the intended
successor; until then, this is where you look to find out how to call something.
#### When two apps provide the same thing
The shell raises a chooser. What you can rely on as an app author:
- **You never see the list.** Providers are named and drawn entirely by the
shell, using the same labels and icons as the sidebar. A requesting app cannot
influence how a provider is presented, and a provider cannot dress itself up
in the chooser.
- **The list is sorted**, so the order is stable across runs.
- **"Always use this app"** persists the pick for that (requester, intent) pair.
It is re-checked at use, so an app being upgraded does not lose the
preference — and if the remembered provider stops declaring the intent, the
chooser simply comes back.
- **Dismissing gives `cancelled`**, not `unavailable`, so you can distinguish
"the user said no" from "there was nobody to ask". Treat `cancelled` as a
normal outcome, not an error to report.
The chosen provider is brought to the foreground **and left there.** The shell
does not navigate back when your request completes; returning is ordinary
navigation the user drives. Do not write your app expecting to regain focus.
Clearing remembered choices lives in the shell's settings, not in your app.
**`unavailable` is deliberately uninformative.** "Nobody provides this" and "you
were not allowed" are the same answer, delivered on the same timing floor, so an
app cannot use intents to enumerate what you have installed. Do not build logic
that tries to tell them apart — instead, let the request fail and let the shell
handle the fallback.
---
---
## Part 9: Advanced Topics
### 9.1 Tutorials
For hands-on walkthroughs of module development patterns, see the dedicated tutorials:
- **[Wrapping a C Library](tutorial-wrapping-c-library.md)** — create `calc_module` wrapping a vendored C library. Covers external library configuration in `metadata.json`.
- **[Building a QML UI App](tutorial-qml-ui-app.md)** — create `calc_ui`, a QML-only UI plugin that calls a core module via the `logos.callModule()` bridge.
- **[Building a C++ UI Module](tutorial-cpp-ui-app.md)** — build `calc_ui_cpp`, a C++ + QML view module that combines a QML frontend with a C++ backend. The backend exposes `Q_INVOKABLE` methods using the generated typed SDK; the QML view calls them via `logos.callModuleAsync()`.
### 9.2 Module Dependencies
Declare dependencies in your `metadata.json`:
```json
{
"name": "my_module",
"dependencies": ["package_manager", "waku_module"]
}
```
Each entry in `dependencies` must match the `name` field in that module's own `metadata.json`. When adding a dependency as a flake input, the **input attribute name** must also match the dependency name — e.g., `waku_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.
When your module is installed via `lgpm`, its dependencies are automatically resolved and installed first. When loaded via `logos-basecamp`, core module dependencies are loaded before your module.
#### How dependencies are consumed — the LIDL contract
Each module publishes a small, language-neutral **LIDL interface contract** as a cheap flake output (`packages.<system>.lidl`), generated from its source with no plugin compile. When you depend on a module, the builder generates the typed `modules().<dep>` wrapper **from that published LIDL** — so building (or packaging) your module **does not build the dependency module**. The only step that still builds and bundles dependency plugins is the standalone-app run (`nix run` / `#run`), which has to, because it loads them.
This is the same `logos-cpp-generator` from [§8.2](#82-the-c-sdk-code-generator), just driven by the dependency's LIDL contract — the same kind of `.lidl`/`.h` contract `interface_dependencies` uses — instead of inspecting a compiled plugin. Inspecting a compiled plugin (as §8.2 describes) is the manual/standalone path; for declared module dependencies the builder uses the contract path, which is why no dependency plugin is built.
Because the contract is LIDL, the dependency's implementation language doesn't matter: the pipeline is `source → LIDL → C++` for a C++ module today, and `Rust → LIDL → C++` for a Rust module tomorrow — the same generated `modules().<dep>` wrapper either way.
> **Transitional fallback.** A dependency built by an older `logos-module-builder` won't expose a `lidl` output yet; for those the builder falls back to the previous behavior (build the dependency and copy its generated headers), so mixed dependency graphs keep working.
To force a specific contract source for a dependency — a committed `.lidl`, a header in another repo, etc. — add a `dependency_overrides` entry keyed by the dependency name:
```json
"dependencies": ["calc_module"],
"dependency_overrides": {
"calc_module": { "file": "interfaces/calc.lidl" }
}
```
Each override is `{ file, input?, impl_class? }`: `file` is the `.lidl`/`.h` path (relative to this repo, or to the flake `input` if given), and `impl_class` is required for a `.h` file. Most modules never need this — auto-resolution from the dependency's `lidl` output is the default.
### 9.3 Exposing OpenMetrics / Prometheus Metrics
Infra operators monitor logos.dev nodes with Prometheus. The
[`openmetrics`](https://github.com/logos-co/openmetrics-module) module serves an
[OpenMetrics](https://prometheus.io/docs/specs/om/open_metrics_spec/) `/metrics`
HTTP endpoint by querying a configured set of modules — it does not discover
modules or read platform stats, it only calls the modules you list.
To make your module scrapeable, implement one method by convention:
```
collectMetrics() -> LogosMap
```
returning openmetrics-like fields:
```json
{
"metrics": [
{ "name": "storage_blocks_total", "type": "counter", "help": "Total blocks stored", "value": 42 },
{ "name": "storage_peers_connected", "type": "gauge", "help": "Connected peers", "value": 7, "labels": { "protocol": "libp2p" } }
]
}
```
| Field | Meaning |
| -------- | ----------------------------------------------------------------------------------- |
| `name` | metric name (for counters, the OpenMetrics `_total` sample suffix is handled) |
| `type` | `counter`, `gauge`, `histogram`, or `summary` (unknown/missing → `unknown`) |
| `help` | short description |
| `value` | number (bools map to 1/0; numeric strings pass through) |
| `labels` | optional string→string label pairs |
The metrics server adds a `module="<name>"` label to every series automatically.
Modules that don't implement `collectMetrics` (or that error/time out) are skipped, so
one module never breaks a scrape.
**Universal (plain C++) module:**
```cpp
// in <module>_impl.h: LogosMap collectMetrics();
LogosMap MyModuleImpl::collectMetrics() {
LogosMap metrics = LogosMap::array();
metrics.push_back({
{"name", "storage_blocks_total"}, {"type", "counter"},
{"help", "Total blocks stored"}, {"value", m_blockCount}
});
return {{"metrics", metrics}};
}
```
**Legacy (Qt) module** — add `Q_INVOKABLE QVariantMap collectMetrics();` returning the
same `{ "metrics": [...] }` shape as a `QVariantMap`/`QVariantList`.
Then run the metrics server alongside your module and point it at you (daemon
mode passes the JSON arg intact):
```bash
# --config-dir isolates this daemon instance (config/state/tokens) from the
# default ~/.logoscore, so it can run alongside others. -D runs in the
# foreground, so background it and wait for it to be ready.
logoscore -D -m <modules-dir> --config-dir /tmp/om &
until logoscore --config-dir /tmp/om status >/dev/null 2>&1; do sleep 0.2; done
logoscore --config-dir /tmp/om load-module my_module
logoscore --config-dir /tmp/om load-module openmetrics
logoscore --config-dir /tmp/om call openmetrics start '{"port":9090,"modules":["my_module"]}'
curl http://localhost:9090/metrics
```
---
## Reference: Repository Map
| Repository | What It Provides | Key Outputs |
| -------------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- |
| [logos-module-builder](https://github.com/logos-co/logos-module-builder) | Build system / scaffolding | `mkLogosModule`, `mkLogosQmlModule` Nix functions, `LogosModule.cmake`, templates |
| [logos-module](https://github.com/logos-co/logos-module) | Plugin introspection | `liblogos_module.a` (static lib), `lm` (CLI) |
| [logos-cpp-sdk](https://github.com/logos-co/logos-cpp-sdk) | SDK + code generator | `LogosAPI`, `LogosResult`, `logos-cpp-generator`, `PluginInterface` |
| [logos-liblogos](https://github.com/logos-co/logos-liblogos) | Core library | `logos_host`, `liblogos_core` |
| [logos-logoscore-cli](https://github.com/logos-co/logos-logoscore-cli) | Headless CLI runtime | `logoscore` (CLI) |
| [logos-package](https://github.com/logos-co/logos-package) | Package format | `lgx` (CLI), `liblgx` (library) |
| [logos-package-manager](https://github.com/logos-co/logos-package-manager) | Local package management | `lgpm` (CLI) |
| [logos-package-downloader](https://github.com/logos-co/logos-package-downloader) | Online catalog + downloads | `lgpd` (CLI) |
| [logos-standalone-app](https://github.com/logos-co/logos-standalone-app) | Minimal UI module runner | `logos-standalone-app` (loads a single UI plugin for testing) |
| [logos-basecamp](https://github.com/logos-co/logos-basecamp) | Desktop app shell | `LogosApp` (GUI), MDI workspace, plugin loader |
## Reference: CLI Tools Summary
### `lm` -- Module Inspector
```bash
lm <plugin-file> # Show metadata + methods
lm metadata <plugin-file> [--json] # View module metadata
lm methods <plugin-file> [--json] # List Q_INVOKABLE methods
```
### `logoscore` -- Headless Runtime
```bash
# Daemon mode
logoscore -D -m <modules-dir> # Start daemon
logoscore load-module <name> # Load a module
logoscore call <module> <method> [args] # Call a method
logoscore list-modules [--loaded] # List modules
logoscore module-info <name> # Show module details
logoscore status # Daemon health
logoscore stop # Stop daemon
```
### `lgpm` -- Local Package Manager
```bash
./package-manager/bin/lgpm --modules-dir <path> install --file <path.lgx> # Install from local .lgx file
./package-manager/bin/lgpm --modules-dir <path> install --dir <dir> # Install all .lgx files in a directory
./package-manager/bin/lgpm --modules-dir <path> list # List installed packages
./package-manager/bin/lgpm --modules-dir <path> info <pkg> # Show installed package details
```
### `lgpd` -- Package Downloader
```bash
./downloader/bin/lgpd search <query> # Search packages by name/description
./downloader/bin/lgpd list [--category <cat>] # List available packages
./downloader/bin/lgpd categories # List available categories
./downloader/bin/lgpd releases # List recent GitHub releases (up to 30)
./downloader/bin/lgpd info <pkg> # Show package details from catalog
./downloader/bin/lgpd download <pkg> [-o <dir>] # Download .lgx package
./downloader/bin/lgpd --release <tag> download <pkg> # Download from specific release
```
### `logos-cpp-generator` -- SDK Code Generator
```bash
logos-cpp-generator <plugin-file> [--output-dir <dir>] [--module-only] [--events-from <name>.lidl]
logos-cpp-generator --metadata <metadata.json> --general-only --dep <name>=<name>.lidl [--output-dir <dir>]
logos-cpp-generator --metadata <metadata.json> --general-only [--output-dir <dir>]
```
### `nix-bundle-lgx` -- LGX Bundler
```bash
# Preferred: built-in derivation (logos-module-builder includes nix-bundle-lgx)
nix build .#lgx # Dev variant
nix build .#lgx-portable # Portable variant
# Alternative: nix bundle command
nix bundle --bundler github:logos-co/nix-bundle-lgx .#lib # Dev variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#portable .#lib # Portable variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib # Both variants
```
---
## Troubleshooting
### "experimental features" error with Nix
If you see errors about experimental features, either pass the flag:
```bash
nix --extra-experimental-features "nix-command flakes" build
```
Or add to `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
### Module loads but LogosAPI is not available
This happens when running a module outside the full Logos runtime (e.g., in the module viewer). The `LogosAPI` is only available when the module is loaded by `logoscore` or `logos-basecamp`.
### Module not discovered by logos-basecamp
Check that:
1. The module binary is in the correct directory (modules dir for core, plugins dir for UI)
2. The `metadata.json` file is present alongside the binary
3. The `name` field in metadata matches the binary name (e.g., `my_module_plugin.so` for module named `my_module`)
### lgpm install fails
- Check your internet connection (lgpm fetches from GitHub Releases)
- Try specifying a release: `./package-manager/bin/lgpm --release v1.0.0 install my_module`
- For local files: `./package-manager/bin/lgpm install --file ./my_module.lgx`
- Check the target directory is writable: `./package-manager/bin/lgpm --modules-dir ./modules install my_module`
### Checking if a module loaded successfully
Use `logoscore` to verify your module loads and its methods are callable:
```bash
# Start the daemon (runs in the foreground, so background it and wait)
./logos/bin/logoscore -D -m ./modules &
until ./logos/bin/logoscore status >/dev/null 2>&1; do sleep 0.2; done
# Check if the module is listed as loaded
./logos/bin/logoscore list-modules --loaded
# Inspect the module
./logos/bin/logoscore module-info my_module
# Quick check: load the module, call a method, then stop the daemon
./logos/bin/logoscore load-module my_module
./logos/bin/logoscore call my_module greet test
./logos/bin/logoscore stop
```
### UI module `nix run` fails to load dependencies
When running a UI module with `nix run`, the standalone app automatically bundles all module dependencies declared in `metadata.json`. If dependencies fail to load, check the following requirements:
**Requirements for auto-bundled dependencies:**
1. **Module type must be `"ui"` or use `mkLogosQmlModule`** — only UI modules get `apps.default` wired up with the standalone app.
2. **Dependencies must be listed in `metadata.json`** under the `"dependencies"` array:
```json
{
"name": "my_ui_module",
"type": "ui",
"dependencies": ["calc_module", "storage_module"]
}
```
3. **Each dependency must have a matching flake input** — the flake input name must exactly match the dependency name in `metadata.json`:
```nix
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
storage_module.url = "github:logos-co/logos-storage-module";
};
```
4. **Module names must be consistent** — the `"name"` field in each dependency's `metadata.json` must match its flake input name. The build system uses this name to locate the plugin binary (`{name}_plugin.so` / `{name}_plugin.dylib`).
**What changed (no more `logos-standalone-app` input):**
- `logos-standalone-app` is now bundled inside `logos-module-builder` — UI module flakes no longer need it as a separate input.
- No `logosStandalone` parameter is needed in `mkLogosQmlModule`, `mkLogosModule`, or `mkLogosQmlModule` calls.
- Dependencies (including transitive ones) are automatically resolved from the flake input tree, bundled as LGX packages at build time, and extracted into the modules directory at runtime.
- The standalone app uses `logos_core_load_plugin_with_dependencies()` which resolves the full transitive dependency graph via metadata.json files.
**Example C++ UI module `flake.nix` (view module — C++ backend + QML view):**
```nix
{
description = "My UI module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
```
**Example QML UI module `flake.nix`:**
```nix
{
description = "My QML UI module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
};
outputs = inputs@{ logos-module-builder, ... }:
logos-module-builder.lib.mkLogosQmlModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
```
If the module doesn't appear, check:
1. The `modules/` directory contains a subdirectory for your module with `manifest.json` and the plugin binary
2. The variant in the manifest matches your platform (e.g., `darwin-arm64-dev` for dev builds on Apple Silicon)
3. Use `lm` to verify the plugin binary is a valid Qt plugin: `./lm/bin/lm ./modules/my_module/my_module_plugin.dylib`
### Capability module not found
logos-basecamp requires the `capability` module to be installed. It is bundled with basecamp and installed on first launch. If you see errors about it:
1. Check that the `modules/` and `plugins/` directories exist next to `bin/` and `lib/` in the basecamp build output
2. Check that the capability module was extracted to the modules directory
3. Verify the LGX variant type matches your basecamp build (dev variant for dev build, portable for portable build)
### LGX variant mismatch
If a module installs but fails to load, the variant type may not match:
- **Dev build** of logos-basecamp needs **dev** LGX variants (`darwin-arm64-dev`)
- **Portable build** needs **portable** variants (`darwin-arm64`)
- Use `nix build .#lgx` and `nix build .#lgx-portable` to produce each variant separately, or `nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib` for a single package with both variants
### Cross-platform builds
Build on each target platform separately to create `.lgx` packages:
```bash
# On each platform, the built-in derivation produces the correct variant automatically:
nix build .#lgx-portable
# Or using nix bundle for dual variant:
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib
# Then merge platform-specific .lgx files into one:
./lgx/bin/lgx merge my_module-linux.lgx my_module-macos.lgx -o my_module.lgx
```
**Windows is the exception: it is cross-built, never built natively.** There is no
Nix daemon for Windows, so "build on the target platform" does not apply. Build
the `x86_64-windows` target from a Linux machine instead:
```bash
nix build .#packages.x86_64-windows.lgx-portable
```
Two consequences worth knowing before you try it:
- The resulting package declares `windows-x86_64`. Because `lgpm` on the builder
is a Linux binary, laying that package out on the builder needs the explicit
`--platform windows-x86_64` opt-out described in [5.1](#51-the-lgpm-cli).
- A Windows developer runs the same cross-build inside WSL2 and copies the
artifacts out to a Windows path — the toolchain is Linux either way.