Files
logos-cpp-sdk/README.md
Dario LipicarandClaude Opus 5 95d7b3a9c5 feat: split the SDK by capability, retire the provider-header path, and harden the cdylib decode (#138)
* feat(generator): remove --provider-header (interface: "provider")

Every provider now goes through the module-impl C ABI, so the LOGOS_METHOD
dispatch path is gone: parseProviderHeader, generateProviderDispatch,
ParsedMethod and the joinDocLines helper only it used (~300 lines), plus the
test that covered it.

`toQVariantConversion` is NOT removed — it is shared with live emitters — and
its test stays.

The flag is REFUSED rather than dropped. Without that, `--provider-header x.h`
falls through to the plugin-path branch, which reads the flag itself as a
plugin path and reports "Plugin file does not exist: --provider-header" — a
missing-file error for what is really a retired mode. It now exits 2 with a
message pointing at interface: "universal".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sdk): logos_host_services.h — the C++ veneer over the privileged surface

Phase C1. A Qt-free, header-only wrapper over the three trust-root lp_* calls
A3 added, so capability_module can become an ordinary universal module instead
of a hand-written Qt plugin reaching into TokenManager directly.

Deliberately FREE FUNCTIONS, not a LogosModuleContext seam as the plan
sketched. The grant is process-global per IMAGE (host binary and module cdylib
each link their own logos-protocol, so each has its own grant state and its own
TokenManager), so the gate lives in the caller's own image and there is nothing
per-instance to inject; a context seam would imply the privilege is a property
of one impl object, which it is not. It is also markedly cheaper: a seam would
need a new module-impl C ABI export plus lockstep changes in BOTH codegen paths
(the Qt provider glue and the cdylib wrapper).

constantTimeEquals lives here rather than in each caller: the natural spelling
(a == b) leaks the matching-prefix length through timing, and a trust root
comparing tokens with == is the exact bug this file exists to prevent. Ported
from capability_module's own implementation to std::string.

Two things the tests caught that reading had not:

* lp_inform_module_token_to takes SIX arguments (client, auth_token,
  origin_module, module_name, token, timeout_ms), not the three I first wrote.
  The wrapper now mirrors it exactly, with the protocol's own default-timeout
  semantics documented.
* sdk_tests compiles against logos_headers alone, which carries no protocol
  include path. It now resolves logos_protocol.h from LOGOS_PROTOCOL_ROOT,
  accepting either the source layout (cpp/) or a package layout (include/) and
  failing loudly on neither, rather than hard-coding the one in use today.

The suite deliberately does NOT link logos-protocol: the lp_*-calling wrappers
are `inline` and never ODR-used by these tests, so no protocol symbol is
referenced. That is itself the assertion — the veneer must not drag the
protocol library into a header-only consumer. A future test that calls one will
fail to LINK rather than silently pull it in.

Also documents a real gap found while writing it: lp_token_get performs NO
host-service check, so "token_registry" gates ENUMERATION only. The plan claims
that service covers `lp_token_get(any)`; it does not. Flagged at the call site
rather than papered over — if lookup should be gated, the gate belongs in
lp_token_get.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(generator): emit logos_module_grant_host_services in the cdylib exports

Completes the C1 codegen half. A3 declared this entry point in
logos_module_impl.h and documented that the grant MUST cross the C ABI, but the
generator never emitted it, so nothing could open a module's gates.

The body forwards to lp_grant_host_services in the MODULE's own image, which is
the entire point. Verified that premise on a real built module rather than
taking it from the header comment: test_basic_module_cpp_plugin.dylib DEFINES
25 lp_* symbols and imports zero — logos-protocol is statically linked into
each plugin, so the module's gate state really is its own, and a grant recorded
only in the host would leave lp_token_keys() returning null forever. That
failure is silent: null is indistinguishable from an empty token store.

Emitted unconditionally rather than behind a codegen flag. Which modules are
privileged is the host's decision — it pushes nothing to an ordinary module —
and lp_grant_host_services validates the names and fails closed, so a per-module
flag would only add a second place for declaration and capability to disagree.

The comment states the boundary honestly: this is a declaration-and-audit
mechanism, NOT a defence against a hostile module. The cdylib links
logos-protocol, so its own code can call lp_grant_host_services() directly and
self-grant. What the gate buys is that the privilege is explicit, greppable and
off by default. Isolation between modules rests on process separation, the auth
token, and the target's allowedCallers.

Three tests, one per property that could regress independently: the export
exists; its body actually forwards (a stub returning 0 would make every host
push look successful while both gates stayed shut); and it is emitted for an
ordinary module too, not only for privileged ones.

Confirmed in the built artifact: nm on a real universal module lists
_logos_module_grant_host_services alongside the other seven module-impl exports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generator): guard the grant export on the protocol MINOR that added it

The emitted logos_module_grant_host_services calls lp_grant_host_services,
which logos-protocol only gained at MINOR 3. A module built against an older
protocol therefore failed to compile in GENERATED code its author never wrote.
Found by giving logos-template-module a standard flake: its own lock resolves
protocol master, and the build died on `use of undeclared identifier`.

Guarded on LOGOS_PROTOCOL_VERSION_MINOR >= 3. A module built against 0.2 has no
grant entry point at all, which is the same fail-closed state as never being
granted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: point logos_async_result.h at the one LogosModule.cmake

The comment named "logos-plugin-qt/cmake/LogosModule.cmake and its
module-builder twin". There is no twin any more: logos-plugin-qt's copy is
deleted and the file exists once, in logos-module-builder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): rev-pin logos-protocol at c8bab12 (the trust-root surface)

logos_host_services.h is a veneer over lp_token_keys /
lp_inform_module_token_to / lp_grant_host_services, which landed on
logos-protocol's feat/per-client-token-store branch and are NOT on its
master — master is still LOGOS_PROTOCOL_VERSION_MINOR 2, so the `tests`
check could not compile against the previously locked 03842db.

Rev-pinned in the URL rather than left master-tracking, because
`nix flake update` cannot reach a commit that is not on the tracked
branch. Re-point at master once that branch merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(generator): remove --module-dir, and the two dead files it documented

--module-dir walked a directory of BUILT plugins and generated one consumer
wrapper per dependency by dlopen'ing each and reading its QMetaObject. Every
wrapper now comes from a contract instead -- `--general-only` with one
`--dep <name>=<name>.lidl` per dependency -- which builds no dependency plugin
and, unlike introspection, works under cross-compilation.

It is REFUSED rather than ignored, mirroring --provider-header right below it.
Falling through to the dependency LISTING would have exited 0 having generated
nothing: the exact shape that lets a stale caller look green while shipping a
module with no typed API.

No nix build changes as a result -- the flag had no caller left in any of them,
so a store-path diff would be empty either way and would prove nothing. The
only observable difference is what the binary does when handed the flag, so
that is what the new `generator-cli` check asserts, by EXIT CODE:

  OK: control - --metadata alone exits 0 and lists dependencies
  OK: --module-dir exits non-zero (status=2)
  OK: --module-dir fails with the removal diagnostic
  OK: --general-only still emits the umbrella

The control matters: without it a non-zero exit could equally mean the binary
is broken. It runs against an EXISTING modules directory too, because the old
code only errored when that directory was missing.

Also deleted, both genuinely dead:

  * cpp/compile.sh -- compiles logos_api.cpp, module_proxy.cpp, token_manager.cpp
    and six headers, NONE of which exist in this repo any more (they moved to
    logos-protocol / logos-qt-host in the host split). The script cannot run.
  * docs/docs.md -- 789 lines with zero inbound references anywhere in the
    workspace, documenting --module-dir and a cpp/ layout that is gone.

cpp-generator/compile.sh is KEPT: logos-module-builder's LogosModule.cmake:404
still invokes it (`add_custom_target(cpp_generator_build ...)`) on the
LOGOS_CPP_SDK_IS_SOURCE branch, and it builds into exactly the
LOGOS_DEPS_ROOT/build/cpp-generator path that file then reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(generator): the umbrella emitter leaves legacy/, and gets its own mode

`cpp-generator/legacy/` held four things and only one was legacy. The shared
emitter library was misfiled there: `generator_lib.{h,cpp}` is already consumed
by the MODERN `experimental/lidl_gen_client.h` and by all 11 tests under
tests/generator/. `lidl_to_json.{h,cpp}` likewise. Both are now
`cpp-generator/`; `legacy/` is down to `main.cpp` + `legacy_main.h`.

The logos_sdk umbrella (`struct LogosModules`) is not legacy either — it is the
CURRENT typed-dependency surface. `LogosModuleContext::modules()` returns it,
so every universal module that calls a declared dependency goes through it, and
LogosModule.cmake runs `--general-only` for every module build. Yet the only
code that could emit it lived inside the directory the plan wants deleted.

So `cpp-generator/main.cpp` gains `--umbrella`, with `--general-only` routed to
the same implementation and dispatched before the fall-through to legacy_main.
The deps-driven emission needed no rewriting: `makeUmbrella{Header,Source}
FromDeps` were already in generator_lib, and legacy/main.cpp merely wrapped
them in file I/O. -352 lines from legacy/main.cpp (827 -> 475), including the
interface-wrapper helpers that only that branch used.

`--general-only` keeps working identically, because LogosModule.cmake and
logos-basecamp both call it. The alias is guarded on `--metadata`, since
`--general-only` was never a standalone mode — without metadata it fell through
and reported the flag as a missing plugin path, and it still does.

The scraping `writeUmbrellaHeader`/`writeUmbrellaSource` are untouched: they
belong to `generateFromPlugin`, the QPluginLoader introspection path, and die
with it.

Verified byte-identical, which is the whole claim of a relocation. An
adversarial pass built its own pre- and post-change binaries and diffed the
emitted `logos_sdk.{h,cpp}` across 12 real metadata.json files x {qt,lp} x
{--general-only,--umbrella}: 48/48 identical, stdout/stderr/exit included, with
a positive control (qt vs lp) confirming the harness can see a difference. Real
modules then built through logos-module-builder against both binaries with
`diff -r` empty, including the compiled plugin. 266/266 tests pass, and the
pre-change tree also reports 266, so no test was silently dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sdk): split the SDK by capability, and add the host façade

Host, module-consumer and module-provider are three distinct capabilities. The
SDK exposed them as one target, so a program linked all three regardless of
what it was. Each now gets its own INTERFACE target:

    ::common    logos_json.h, logos_result.h
    ::consumer  logos_lp_client.h, logos_async_result.h
    ::provider  logos_module_context.h, logos_host_services.h
    ::host      logos_host_core.h        (new)

`logos_headers` stays as an umbrella over all four, so the ~70 existing
consumers are unaffected — logos_module_context.h alone has 66. Migrate to the
narrow targets when touching a repo; additive first, removal second.

NOTE the misnomer the split exposes: `logos_host_services.h` is MODULE-side
despite its name — it is the veneer a privileged module uses for services the
host granted it — so it belongs to ::provider, not ::host. Renaming it touches
9 files across 5 repos, so it is left for a change that can carry that cascade.

── logos_host_core.h ───────────────────────────────────────────────────────

`logos::host::LogosCore`, a plain RAII wrapper over liblogos' logos_core_* C
API, for the four programs that stand up a core (basecamp, logoscore-cli,
standalone-app, module-viewer). They currently open-code the same calls, and
basecamp had already grown a private wrapper for them.

It is deliberately an ORDINARY class — no codegen, no injection seam, no
void*. LogosModuleContext needs `_logosCoreSetContext_`, SFINAE `maybeSet*`
helpers and a void* round-trip because a module impl is user-authored but
FRAMEWORK-instantiated. A host is main(): it constructs this itself. For the
same reason there is no `modules()` here — the host holds its own LogosModules
from its own generated logos_sdk.h, so this header needs no generated type.

What it earns, each tied to a measured hazard:
  * OWNERSHIP. liblogos allocates its char**/char* returns with new[], so
    `delete[]` is correct and free() is undefined behaviour. That rule lived in
    a comment in one repo's .cpp; it is now in one place.
  * ORDERING. Three setters must precede logos_core_start(), stated only in
    comments in logos_core.h. They are constructor arguments here, so the
    illegal order is not expressible.
  * SHAPE. logos_core_get_module_stats() takes no module name and returns one
    blob for every module; stats(name) does that parse once.

The logos_core_* ABI is re-declared rather than included: logos-liblogos
depends on logos-cpp-sdk, so including its header would invert the graph. Every
host already hand-declares it; this makes it one declaration instead of four.

15 tests, 281/281 suite total. They define the extern "C" ABI themselves and
allocate exactly as liblogos does, so the ownership rules are exercised rather
than asserted; the ordering test records call order and pins start() as last.

Also fixes a real gap: nix/include.nix carries its own header list, separate
from cpp/CMakeLists.txt's install(FILES), so a new header silently did not ship
in the export layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(generator): a Qt-typed umbrella that needs no LogosAPI

Splits a consumer's TYPE SURFACE from its TRANSPORT. Until now the qt umbrella
was `explicit LogosModules(LogosAPI* api)` while the lp one was default-
constructible, so "Qt types" implicitly meant "has a LogosAPI" — and a cdylib
module, whose provider surface is the std logos_module_impl.h C ABI and which
holds no LogosAPI anywhere, could not have Qt-typed dependency wrappers at all.
Its generated glue emits `new LogosModules()` unconditionally
(lidl_gen_cdylib.cpp:693), so the combination did not merely misbehave, it did
not compile.

That was a codegen choice, not a law: the wrapper bodies already run over lp_*.

`--binding api|origin` selects it, defaulting to `api`. A second enum rather
than a third ApiStyle value, deliberately: ApiStyle names the type surface and
is switched on by six emitters (makeHeader/makeSource/returnTypeFor/
paramTypeFor/toWireFor/fromWireFor); a "Qt types, explicit origin" member would
force all six to answer a transport question whose honest answer is "same as
Qt" every time. ApiStyle::Lp ignores the new axis — lp is origin-bound by
construction — and that is asserted rather than assumed.

The emitted umbrella bakes metadata.json#name as the origin literal:

    LogosModules() : test_fullapi_cpp(QStringLiteral("test_fullapi_qtproxy")) {}
    FullApi bind_full_api(const QString& moduleName) {
        return FullApi(QStringLiteral("test_fullapi_qtproxy"), moduleName); }

Origin is the CONSUMER's own name and target is the dep — origin first in both
bind_ overloads. This is the load-bearing property: LpBridge::forTarget derives
origin from `api->moduleName()`, and reusing it silently gives a consumer the
caller's identity, which has already preserved a privilege escalation once in
this tree. An empty metadata name is refused at the CLI (exit 6, naming the
file) and emits `#error` in the header: a module that cannot state its identity
must not compile, and must never be handed a blank or borrowed one.

Verified additive on 172 real metadata.json x 2 api-styles = 344 runs, all
producing output, byte-identical old binary vs new. Mutation control: swapping
bind_<iface>'s (origin, moduleName) to (moduleName, origin) fails the suite at
MakeUmbrellaTest.QtExplicitOriginStatesTheConsumersOwnNameEverywhere. 281 -> 286
tests.

Framing worth keeping: the origin is SELF-ASSERTED from the module's own
metadata and is not attested by the transport. That is not a regression —
`api->moduleName()` is equally process-stated — but "explicit origin" means the
module names itself, not that the host vouches for the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(generator): stop pointing callers at a flag that no longer exists

--backend qt is deleted from logos-qt-generator, and this repo was still
signposting it. main.cpp's refusal said "Use it for --backend qt", and the usage
text advertised --lidl … --backend qt and --from-header … --backend qt. Those
now point at nothing — the exact failure the deletion removes, one repo over.

The refusal names the real replacement chain instead: --backend cdylib here,
then logos-qt-host-generator --backend cdylib for Qt-plugin packaging.

docs/project.md's "Provider Generation" section documented three emitters that
no longer exist; rewritten to state the seam and the two-step pipeline.
docs/spec.md's dataflow diagram showed <name>_qt_glue.h / <name>_dispatch.cpp as
outputs; the diagram is corrected and the sections describing that shape are
marked historical rather than deleted, because the onInit wiring they document
still applies to the cdylib glue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(cpp-generator): merge legacy/ into the generator, dropping its dead half

`legacy/` was never a library with an API surface. Two files, 475 lines, exactly
one exported symbol — `int legacy_main(int, char**)` — with every other
definition `static`, compiled INTO logos-cpp-generator and reached by fallthrough
at the end of main(). So there was nothing to keep separate: it is one mode of
this binary, and it now lives beside the others as plugin_introspect.{cpp,h}
behind `runPluginIntrospectMode()`, named for what it does.

Deleting it was never an option — that premise was checked and refused earlier.
`--general-only` alone has ~10 live callers across 7 repos including the central
module path (buildPlugin.nix:206,211, buildHeaders.nix:221,
LogosModule.cmake:423). The mode is load-bearing; only its packaging was wrong.

110 lines go with the move, all genuinely unreferenced:

  * cppStringEscape — zero callers anywhere.
  * writeUmbrellaHeader / writeUmbrellaSource and the `if (!moduleOnly)` block
    that called them. This is the real prize: a SECOND, directory-SCRAPING
    implementation of logos_sdk.{h,cpp}, unreachable in practice because
    generate-module-headers.sh:60 always passes --module-only. generator_lib's
    deps-driven makeUmbrella*FromDeps is now the only umbrella emitter, so the
    two cannot drift.
  * a dead `QJsonDocument doc(methods);` and its commented-out use.

With the block gone, `--module-only` suppresses nothing, so the parameter and
its plumbing go too. The FLAG stays tolerated rather than rejected, because
generate-module-headers.sh passes it unconditionally — a comment at the old
parse site says so.

Verified: #default builds, and both checks pass — `generator-cli` (which
exercises the CLI surface, including --general-only) and `tests`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(sdk): make the by-name call path a supported API

The dynamic (by-name) invoke path already existed and was already ungated at
every layer — lp_client_create / lp_invoke in the C ABI, logos::LpClient above
it, and the Qt client above that. Nothing checked a host service; there was no
gate to open. What was missing was the ERGONOMICS, which is what turned a
supported capability into something callers reached around the umbrella to get.

Three additive pieces, no gate touched:

1. LogosModuleContext::moduleName() — the module's own registry name, i.e. the
   origin it authenticates as. The typed wrappers bake their origin in at
   codegen time; a by-name call has to state one, and a wrong origin
   authenticates as nobody and fails far from the call site. Set through a NEW
   `_logosCoreSetModuleName_`, deliberately not a fourth parameter on
   `_logosCoreSetContext_`: every generated provider calls that signature, so
   widening it would break each one until regenerated, for a value the
   generator knows statically. Set before the context, so moduleName() is live
   inside onContextReady().

2. LogosModules::dynamic(target) on the origin-bound umbrella — the untyped
   client, with the origin baked in exactly as the typed members' is, and
   cached per target because LpClient owns a connection. The typed members over
   metadata.json#dependencies stay the ordinary way to call another module;
   this is for the cases whose target is a runtime value (a proxy, a router).

3. LpClient::getMethods() over the already-exported lp_get_methods. Invoke
   without introspect is guessing — a caller that cannot ask what exists can
   only hardcode, and a wrong guess fails at runtime like a typo.

Verified: #default builds, and both checks pass (tests, generator-cli).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cdylib): shape-check [any]/{tstr:any} args, and fix the umbrella's includes

TWO fixes; the second is a regression I introduced two commits ago.

1. jsonArgToStd() returned the raw json for LogosList / LogosMap — "untyped JSON
   passes through, as it always has". Arity was checked, type was not, so a
   scalar reached a `[any]` parameter untouched. A proxy forwarding it through a
   Qt-typed consumer then turned "notalist" into
   ["n","o","t","a","l","i","s","t"], because qvariant_cast reads a QString as a
   sequential container — and the downstream provider saw a well-formed array
   with nothing left to refuse. Now emits logos::jsonRequireArray /
   jsonRequireObject; the throw lands in the dispatch's existing catch as
   {"code":"dispatch_failed"}. Bare `any` stays raw, deliberately: it declares
   nothing, so there is nothing to check it against.

   Measured on the conformance matrix: closes all 8 failing cells (498/22/12/8
   -> 500/18/12/2), and a whole-matrix per-cell diff shows exactly 14 status
   changes, every one inside the two hostile cases. The other 518 cells are
   byte-identical in status and value. The remaining 2 are the fix working — the
   C++ cdylib provider now refuses the same input, which cases.json still pins
   as lenient via expect_by_provider; that is a registry edit, and known.json's
   Q1b already names this exact outcome as the intended fix.

2. The Lp umbrella emitted `logos::LpClient& dynamic(...)` and a
   std::map<..., std::unique_ptr<logos::LpClient>> while <map>, <memory> and
   logos_lp_client.h were conditional on interfaceNames. A module WITH
   dependencies compiled by accident, because <dep>_api.h drags the header in
   transitively. A module with NO dependencies and no interfaces includes
   nothing else and failed outright with "no type named 'LpClient' in namespace
   'logos'". test_fullapi_cpp is exactly that shape.

   I shipped that in 1be71bb and did not catch it: I verified #default and both
   checks, which are the SDK's own targets, not a dependency-free consumer of
   its codegen. `.#logos-test-modules--test_fullapi_cpp` is the case that
   exercises it and now builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: use logos-co/setup-nix-cache-action for Nix setup and caching

Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): track logos-protocol master again

The rev pin on feat/per-client-token-store existed because the trust-root
surface it needed lived only on that branch while protocol master was still
LOGOS_PROTOCOL_VERSION_MINOR 2. Both comments here said to re-point once it
merged; it has (logos-protocol#59), and master is 0.4.0 — MINOR 4, carrying
lp_token_keys, lp_inform_module_token_to, lp_grant_host_services and
TokenManager::forIdentity / isolateIdentity.

That matters beyond compiling: the cdylib glue's grant forwarding is guarded on
MINOR >= 3, so a master pin taken too early would not have failed loudly — it
would have dropped the grant silently. The guard now opens.

Verified against master rather than assumed: #default builds and the checks pass
(cpp-sdk `tests`, plugin-qt `qt-host-generator`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the generator has no legacy/ directory any more

This should have been part of the merge commit and was not. `docs/project.md`
still listed `legacy/` in the project tree, named `legacy_main()` as the
fallthrough target, and described `--api-style` as being threaded through
`legacy/main.cpp` → `generateFromPlugin` / `writeUmbrellaHeader` — a directory,
a function and two emitters that no longer exist. `docs/spec.md` still pointed
backwards-compatibility at `legacy_main()`.

Corrected to `plugin_introspect.{cpp,h}` / `runPluginIntrospectMode()`, with the
provenance kept rather than erased: the tree entry says what it was and why it
was never a library, because "there used to be a legacy generator" is the
question a reader will actually arrive with.

The umbrella line gets the deletion too — `writeUmbrellaHeader` /
`writeUmbrellaSource` were the second, directory-scraping implementation of
logos_sdk.{h,cpp}, and their absence is the point: `makeUmbrella*FromDeps` is
now the only umbrella emitter, so the two cannot drift.

Two nearby lines were stale independently of that move and are fixed with it:

  * "the legacy emitters in tests/generator/" — those tests cover the SHARED
    generator_lib emitters (test_make_header/source/umbrella, the type maps),
    which are not legacy and never were.
  * "consumer wrappers real modules get come from legacy/main.cpp →
    generateInterfaceWrappers" — generateInterfaceWrappers is in main.cpp and
    was already there, so that path was misattributed before this branch.

Every other use of "legacy" in these docs is left alone: `interface: "legacy"`,
legacy Qt types (QVariantMap / QVariantList / QStringList), legacy Q_INVOKABLE
modules and the legacy consumer path are all real things that still exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: correct generator ownership after the Qt host split

Comments and docs across the repo still claimed this generator emits the Qt
plugin glue, and pointed `--backend qt` users at logos-qt-generator. Neither
is true: the Qt-plugin (provider) glue is emitted by logos-plugin-qt's
logos-qt-host-generator --backend cdylib, on top of the C ABI this tool emits
with --backend cdylib, and logos-qt-generator owns only `consumer` and `ui`
(it refuses the flag too).

Also corrects the LogosModuleContext header, whose comments described the
retired Qt provider path throughout — `<name>_events.cpp` marshalling into a
QVariantList and a provider `onInit` setting the context. The emitted file is
`<name>_events_cdylib.cpp`, it marshals into nlohmann::json, and the C-ABI
export TU installs the callback. The runtime path it named
(runtime_qt/host/module_initializer.cpp) no longer exists.

The only behaviour change is the text of three --backend/--from-header error
messages, which named the wrong tool. Nothing asserts on them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generator): refuse --backend qt before validating --impl-class

The `backend == "qt"` refusal sat below the --impl-class / --impl-header
requirement checks, so `--backend qt` alone never reached it: it exited 1 with
"Error: --backend qt requires --impl-class <ClassName>", which reads as though
qt would work given one more flag. qt was removed; no flag rescues it.

Hoisted the refusal (and the unsupported-backend error) above those checks.
The cdylib branch returns on every path before this point, so the two checks
could only ever gate a backend that was about to be rejected anyway; they are
dropped rather than left unreachable.

`--backend qt` now exits 6 with the removal message, `--backend bogus` exits 1,
and cdylib is untouched. checks.generator-cli and checks.tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(doctests): skip the qt-api-events spec, which builds a provider module

This PR removes `logos-cpp-generator --provider-header`. The watcher fixture in
doctests/cpp-sdk-qt-api-events.test.yaml is `interface: "provider"`, so
logos-module-builder reaches "generating provider dispatch (qt_watcher_module)"
and the generator refuses:

    Error: --provider-header was removed.

That one build failure cascades through the rest of the spec (install, load,
subscriptionAccepted, greetThrough, greetedCount, lastGreeted), which is the
whole of the red on both ubuntu-latest and macos-latest. notifier_module is
`interface: universal` and builds fine.

Skipped rather than rewritten. The replacement shape that keeps the Qt-typed
dependency wrappers without the retired provider dispatch is
`interface: universal` + `codegen.consumer_api_style: "qt"`, and
logos-module-builder master does not carry that key yet — while this spec pins
the builder to master. It arrives with the B4 stack.

The spec file is kept and annotated, not deleted, because it covers two things
nothing else does: the Qt-TYPED wrapper emission (separately generated code
from the lp path, so lp-path specs cannot catch a bug in it) and a subscription
made from onInit() before the dependency is reachable. Both are UNTESTED until
this is restored — a known, accepted gap recorded in the spec header and in the
workflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:49:30 -03:00

30 KiB

Layering note: since the protocol extraction and the Qt split, this repo is the Qt-free base SDK — header-only developer surface for universal module implementations (logos_module_context.h, logos_result.h, logos_json.h) plus the logos-cpp-generator code generator (Qt-free outputs: std typed wrappers, the logos_sdk umbrella, cdylib C-ABI impl-exports, LIDL derivation). Generated Qt glue comes from two other binaries: the Qt-typed consumer wrappers and the ui_qml view-plugin glue from logos-qt-sdk's logos-qt-generator (--backend consumer / --backend ui), and the Qt-plugin (provider) glue from logos-plugin-qt's logos-qt-host-generator --backend cdylib. Transports, the consumer core, the Qt LogosResult and the lp_* C ABI live in logos-protocol; the Qt developer layer is published as logos-qt-sdk's CMake package (logos-qt-sdk::logos_qt_sdk), but the code behind it — LogosAPI, LogosProviderBase, the QObject adapter — now lives in logos-plugin-qt's logos-qt-host.

logos-cpp-sdk

How to Build

The project includes a Nix flake for reproducible builds with a modular structure:

Build Complete SDK (Library + Headers + Generator)

# Build everything (default)
nix build

# Or explicitly
nix build '.#logos-cpp-sdk'
nix build '.#default'

The result will include:

  • /bin/logos-cpp-generator - Code generator binary
  • /lib/cmake/logos-cpp-sdk/ - The CMake package (find_package(logos-cpp-sdk)). There is no compiled library here: the base SDK is header-only since the transports moved to logos-protocol
  • /include/ and /include/cpp/ - The same headers in both roots (the CMake-export layout and the source-export layout). A quoted include has to resolve its siblings from whichever root pulled it in, so both are shipped
  • /share/lidl-frontend/ - The shared C++/Qt codegen helpers logos-qt-sdk's logos-qt-generator compiles against

Build Individual Components

# Build only the generator binary (outputs to /bin)
nix build '.#logos-cpp-bin'

# Build only the CMake package (header-only: /include + /lib/cmake, no archive)
nix build '.#logos-cpp-lib'

# Build only the headers, in the source-export layout (/include and /include/cpp)
nix build '.#logos-cpp-include'

# Legacy alias for generator
nix build '.#cpp-generator'

Development Shell

# Enter development shell with all dependencies
nix develop

Note: In zsh, you need to quote the target (e.g., '.#logos-cpp-sdk') to prevent glob expansion.

If you don't have flakes enabled globally, add experimental flags:

nix build '.#logos-cpp-sdk' --extra-experimental-features 'nix-command flakes'

The compiled artifacts can be found at result/

Modular Architecture

The nix build system is organized into modular files in the /nix directory:

  • nix/default.nix - Common configuration (dependencies, flags, metadata)
  • nix/bin.nix - Generator binary compilation
  • nix/lib.nix - Header-only SDK: installs the headers + the CMake package
  • nix/include.nix - Header installation (source-export layout)
  • nix/tests.nix - gtest suite (build + run via nix build '.#tests')
  • nix/tests-generator-cli.nix - The generator-cli check: runs the built binary, which is the only place a retired CLI flag can be asserted on (the gtest suite links the generator's internals and never executes it)

Run Tests

# Build and run all tests (build fails if any test fails)
nix build '.#tests'

# Run the built binary against its retired/renamed CLI flags
nix build '.#checks.<system>.generator-cli'

The three test binaries are available in result/bin/ and can be re-run with filters:

./result/bin/sdk_tests --gtest_filter="LogosModuleContextTest.*"
./result/bin/generator_tests --gtest_filter="*PascalCase*"
./result/bin/experimental_tests --gtest_filter="*Cdylib*"

Manual Build

Building the Code Generator

cd cpp-generator
./compile.sh

compile.sh builds into <parent-of-this-repo>/build/cpp-generator, so the binary lands at ../../build/cpp-generator/bin/logos-cpp-generator relative to this checkout (it assumes the checkout directory is named logos-cpp-sdk).

CMake must be able to resolve two out-of-tree dependencies for this to work: find_package(logos-lidl) — the canonical LIDL frontend the generator links — and the logos-protocol headers, via -DLOGOS_PROTOCOL_ROOT= / the LOGOS_PROTOCOL_ROOT environment variable / a sibling ../../logos-protocol checkout. The Nix build (above) wires both for you.

Usage

Code Generator

The logos-cpp-generator tool generates C++ wrapper code for Logos plugins.

Basic Usage

# Generate the wrapper for a single BUILT plugin (uses default output directory).
# This path loads the plugin and reads its Qt metaobject / getMethods(), so it
# only works for a plugin built for the machine running the generator.
logos-cpp-generator /path/to/plugin.dylib

# Specify custom output directory
logos-cpp-generator /path/to/plugin.dylib --output-dir /custom/output/path

# `--module-only` is accepted here but is a no-op: this path only ever emits the
# module pair. It is kept because existing callers still pass it.
logos-cpp-generator /path/to/plugin.dylib --output-dir /custom/output --module-only

Generate from Metadata

# List dependencies from metadata.json
logos-cpp-generator --metadata /path/to/metadata.json

# Generate a wrapper per dependency, each from that dependency's LIDL contract
logos-cpp-generator --metadata /path/to/metadata.json --umbrella \
  --dep waku_module=/path/to/waku_module.lidl

# Generate only the umbrella (assumes the module wrapper files already exist)
logos-cpp-generator --metadata /path/to/metadata.json --umbrella

# Generate the umbrella into a custom output directory
logos-cpp-generator --metadata /path/to/metadata.json --umbrella --output-dir /custom/output

--general-only is an exact alias for --umbrella (it is the spelling LogosModule.cmake, buildPlugin.nix and buildHeaders.nix all pass today), so the two run the same single implementation.

Options

--output-dir /path/to/output

  • Default: If not specified, generated files are placed in logos-cpp-sdk/cpp/generated/
  • Custom: Specify any directory for the generated files
  • The output directory will be created automatically if it doesn't exist

--module-only

  • On the plugin path (logos-cpp-generator /path/to/plugin.dylib) it is accepted and ignored — that path only ever emits the requested module's <name>_api.h / <name>_api.cpp pair. generate-module-headers.sh always passes the flag, so it stays tolerated rather than rejected
  • On the --lidl client-stub path it is honoured: it suppresses the umbrella (logos_sdk.*) and emits only the module pair

--umbrella (alias: --general-only)

  • When specified with --metadata, generates only the umbrella SDK files
  • Assumes module wrapper files already exist in the output directory
  • Generates: logos_sdk.h, logos_sdk.cpp. There is no core_manager_api.* — the runtime's core manager was never a LogosModules member, and the generator emits no wrapper for it; apps that need to manage the core use liblogos' C API
  • The umbrella headers will include references to all modules listed in the metadata's dependencies array
  • For each dependency (e.g., "waku_module"), it will:
    • Include waku_module_api.h in the header
    • Include waku_module_api.cpp in the source
    • Create a WakuModule waku_module; member in the LogosModules struct
  • Takes one --dep <name>=<path/to/<name>.lidl> per dependency and generates that dependency's wrapper from its contract, so no dependency plugin has to be built (and it works under cross-compilation)
  • --interface <name>=<file.lidl|file.h>[=<ImplClass>] does the same for an interface dependency, which additionally gets a bind_<name>(provider) factory
  • --api-style qt|lp picks the type surface (see API style below); --binding api|origin picks whether the umbrella holds a LogosAPI or states this module's own name as the call origin

--provider-header — REMOVED

  • Generated the LOGOS_METHOD-marked provider dispatch behind interface: "provider". Both are gone: every provider now goes through the module-impl C ABI
  • The generator refuses the flag with a message naming interface: "universal", where a plain src/<name>_impl.h is the contract

--module-dir /path/to/modules — REMOVED

  • Generated a wrapper per dependency by loading each dependency's BUILT plugin from a modules directory and reading its Qt metaobject
  • The generator now refuses the flag rather than ignoring it; use --umbrella with --dep as above

Generated Files

Plugin path (logos-cpp-generator /path/to/plugin.dylib), with or without --module-only:

  • <module>_api.h and <module>_api.cpp — the wrapper for that one plugin, and nothing else

With --umbrella / --general-only:

  • logos_sdk.h and logos_sdk.cpp — the umbrella that aggregates the wrappers
  • Plus one <name>_api.{h,cpp} pair per --dep / --interface spec passed

With --lidl <contract> --backend cdylib --impl-class <C>:

  • <name>_types.h, <name>_module_impl.cpp, and — when the contract declares events — <name>_events_cdylib.cpp

With --from-header <impl.h> --backend cdylib: the same three, plus the derived <name>.lidl. --header-to-lidl emits only the .lidl.

Typical Workflow

A common workflow is to generate module wrappers separately, then generate the umbrella SDK:

# Step 1: Generate individual module wrappers
logos-cpp-generator /path/to/plugin1.dylib --output-dir ./generated
logos-cpp-generator /path/to/plugin2.dylib --output-dir ./generated

# Step 2: Generate the umbrella SDK (references the modules from step 1)
logos-cpp-generator --metadata metadata.json --umbrella --output-dir ./generated

This approach gives you fine-grained control over which modules to include and allows rebuilding just the umbrella headers without regenerating all module wrappers.

The three call surfaces on a generated wrapper

Every LIDL method foo(...) -> T produces three entry points:

// 1. sync — optional error out-channel, optional deadline. Both trailing and
//    defaulted, so `dep.foo(a, b)` and `dep.foo(a, b, &err)` are unchanged.
T    foo(params, logos::CallError* err = nullptr, Timeout timeout = Timeout());

// 2. async, value only — the historical form, unchanged.
void fooAsync(params, std::function<void(T)> cb, Timeout timeout = Timeout());

// 3. async, value + error.
void fooAsyncResult(params, std::function<void(logos::AsyncResult<T>)> cb,
                    Timeout timeout = Timeout());

Use (3) whenever a default-constructed T is also a legal success value — which is almost always. fooAsync hands the callback a bare T, so a failed call and a provider that genuinely returned 0 / "" / false are indistinguishable; that is exactly the ambiguity the sync form's CallError* exists to resolve.

dep.balanceAsyncResult(account, [](logos::AsyncResult<qlonglong> r) {
    if (!r.ok()) {                       // r.error is {code, message, origin}
        qWarning() << "balance failed:" << r.error.code.c_str();
        return;
    }
    use(r.value);                        // now known to be a real answer
});

logos::AsyncResult<T> (logos_async_result.h) is { T value; CallError error; } plus ok(); AsyncResult<void> carries only the error, so a void-returning method has the same callback shape as every other one.

The name is deliberately distinct rather than an overload of fooAsync: two overloads differing only in std::function<void(T)> vs std::function<void(AsyncResult<T>)> are ambiguous for a generic lambda ([](auto v){…}), which would break existing call sites.

Qt-free (--api-style lp) wrappers spell the deadline int timeout_ms = 0 (<= 0 selects the protocol default) because Timeout lives in a Qt header, and they do not yet get fooAsyncResult — logos-protocol's lp_invoke_async does not report the call error to its callback, so an AsyncResult there would report success on a failed call.

Universal modules: LogosModuleContext

Universal (codegen-driven) modules — those built from a plain src/<name>_impl.h header rather than a handcrafted QObject plugin — don't see the raw LogosAPI at all. The contract is derived from that header: the module's ordinary public methods are its API, with no marker of any kind (there used to be a LOGOS_METHOD marker under interface: "provider"; both are gone). metadata.json#codegen.impl_class / codegen.impl_header name the class and the header when they differ from the defaults (<Name>Impl in src/<name>_impl.h). Instead of a LogosAPI, the generated C-ABI export TU (<name>_module_impl.cpp) populates a narrow LogosModuleContext base class with everything an impl typically needs:

  • Three host-injected properties exposed as typed getters
  • A LogosModules aggregate for calling other modules

An impl opts in by inheriting from LogosModuleContext (defined in logos_module_context.h):

#include <logos_module_context.h>
#include <logos_json.h>
#include "logos_sdk.h"      // generated at build time; defines LogosModules

class MyModuleImpl : public LogosModuleContext {
public:
    LogosMap doWork(const std::string& input) {
        // Cross-module call through the flat LogosModules aggregator.
        // Because this module is `interface: "universal"`, mkLogosModule.nix
        // passed -DLOGOS_API_STYLE=lp to the codegen, so every <Dep>
        // wrapper takes/returns std types — no Qt at the call site.
        std::string reply = modules().some_dep.echo(input);
        // ...
    }

protected:
    void onContextReady() override {
        // One-time setup: the getters below are now readable.
        // Fires exactly once, before any method dispatch.
        std::string dataDir = instancePersistencePath();
        // open files, prime caches, etc.
    }
};

Documenting methods: a doc comment (/// or /** … */) directly above a method declaration becomes that method's description in the generated getMethods() output, so it surfaces in lm methods, logoscore module-info, and Basecamp's Methods list — no describe call needed:

/// Processes the input and returns a result map.
LogosMap doWork(const std::string& input);

Plain // and /* … */ comments are ignored (so section separators don't leak into the API). See cpp-generator/docs/spec.mdMethod documentation for details.

Documenting events: events are the other half of a module's API — declared in a logos_events: section and surfaced the same way. A doc comment above an event declaration becomes that event's description. Events are reported inside the generated getMethods() output (each entry tagged type: "event", methods tagged type: "method"); the framework exposes filtered views — getPluginMethods(), getPluginEvents(), getPluginInterface() — so the event surfaces in lm events, logoscore module-info's Events section, and Basecamp's Interface screen:

logos_events:
    /// Emitted once the user has authenticated.
    /// Carries the freshly issued session token.
    void userLoggedIn(const std::string& userId, const std::string& token);

Event entries carry type: "event", name, signature, parameters[], and description (no returnType — events are fire-and-forget). Folding events into getMethods() rather than adding a getEvents() vtable method keeps the provider ABI stable across SDK versions. See cpp-generator/docs/spec.mdEvent documentation for details.

Available getters:

Getter Description
modulePath() Directory containing the module's plugin file. Useful for loading bundled resources (icons, QML files, schema docs).
moduleName() This module's own registry name — the name other modules address it by, and the origin it authenticates as. The typed wrappers bake their origin in at codegen time; a by-name call has to state it.
instanceId() Stable per-instance ID assigned by the host. Two side-by-side instances of the same module get distinct IDs.
instancePersistencePath() Per-instance writable data directory the host owns the lifecycle of. The canonical place for module state (config, caches, small databases). Wiped on uninstall; survives upgrades.
isContextReady() True once the framework has populated the getters above. Flipped before onContextReady() fires, so helpers that may run earlier (e.g. during construction in tests that bypass the framework) can guard on it.
modules() The module's flat LogosModules aggregate — one accessor per metadata.json#dependencies entry, plus a bind_<name>(provider) factory per interface dependency and — on the lp surface universal modules get — an untyped dynamic(target) escape hatch returning a logos::LpClient (the runtime's core manager is deliberately not there; apps that need to manage the core do so via liblogos' C API). LogosModules is forward-declared in the SDK header and made complete by the impl's #include "logos_sdk.h", so the call site just writes modules().some_dep.someMethod(...). Each accessor's wrapper class signatures use the type surface picked at THIS module's build time (see "API style" below).

API style: Qt vs std

Each module's build picks one API style for the generated <Module> client wrappers and the LogosModules umbrella — they're mutually exclusive, no composite output:

metadata.json#interface LOGOS_API_STYLE Wrapper signatures
"cdylib", or "universal" with type other than ui_qml lp std::string, std::vector<std::string>, LogosMap, LogosList, int64_t, StdLogosResult
"legacy" / absent, and "universal" with type: "ui_qml" qt (default) QString, QStringList, QVariantList, QVariantMap, qlonglong/qulonglong, LogosResult

The valid interface values are "legacy" (the default when the key is absent), "universal" and "cdylib". A fourth, "provider" — the LOGOS_METHOD-marked Qt provider — was removed; logos-module-builder now throws on it rather than silently generating no glue.

A third value, std, used to name a std-typed surface whose body still went through QVariant + LogosAPIClient. It was retired once universal modules moved to lp; --api-style=std is now rejected outright rather than aliased, so a stale build fails loudly instead of silently getting Qt signatures.

mkLogosModule.nix reads interface and threads -DLOGOS_API_STYLE=lp through to the codegen for universal modules; everyone else defaults to Qt and stays bit-for-bit backward compatible. Inside the universal module's .cpp, the call site is:

// Universal module (api-style=lp):
std::string reply = modules().some_dep.echo("hi");

…and in a handcrafted Qt module the same call is:

// Legacy module, or a universal ui_qml view plugin (api-style=qt):
QString reply = modules().some_dep.echo(QString("hi"));

The two carry the same values; the lp wrapper marshals them over the logos-protocol C ABI (lp_*) instead of QVariant, so the calling translation unit needs zero Qt headers and links no qt-sdk.

Migrating to std types: The default is derived from interface (plus type, per the table above). A handcrafted module that wants std types should switch to interface: "universal". There is one override key — metadata.json#codegen.consumer_api_style — and only one direction of it is reachable: a module packaged as a cdylib may ask for "qt" (Qt-typed, origin-bound wrappers). A Qt-plugin module asking for "lp" is refused, because nothing would populate the token store the lp wrappers read, and every outbound call would come back as a default value with no error raised.

All getters return empty / null values when the module is loaded outside a host that provisions a context (CLI tests, unit tests using the impl directly). The onContextReady() hook still fires once at framework load time; tests that bypass the framework can call _logosCoreSetContext_ / _logosCoreSetLogosModulesPtr_ directly to simulate.

Codegen does NOT require inheritance — modules that don't inherit LogosModuleContext compile unchanged. The generated export TU routes every wire-up through SFINAE'd helpers (_logos_codegen_::maybeSetModuleName / maybeSetContext / maybeSetLogosModules / maybeSetEmitEvent), called from a one-shot latch that the first logos_module_dispatch / logos_module_set_context / logos_module_set_emit_callback trips; the non-inheriting overloads collapse to no-ops.

Events: logos_events:

Universal modules declare events in a Qt-signals:-style logos_events: section. The codegen parses each prototype, emits the matching method bodies in a sidecar <name>_events_cdylib.cpp (Qt-MOC style), and ships a <name>.lidl file describing them so consumer-side codegen can produce typed subscribers:

#include <logos_module_context.h>

class MyModuleImpl : public LogosModuleContext {
public:
    void doWork() {
        userLoggedIn("alice", 12345);              // typed emit — same name as the declaration
    }

logos_events:                                       // expands to `public:`; parsed by the codegen
    void userLoggedIn(const std::string& userId, int64_t timestamp);
    void messageReceived(const std::string& from, const std::string& body);
};

The author writes only the declarations; the codegen supplies the bodies (analogous to Qt MOC for signals:). Each call marshals typed args into an nlohmann::json array and routes them through LogosModuleContext::emitEventImpl_ → the logos_module_emit_cb the host installed via logos_module_set_emit_callback → the host's own event channel. (The marshalling used to be into a QVariantList handed to LogosProviderBase::emitEvent; that path belonged to the Qt provider glue, which a universal module no longer has — its whole impl side is Qt-free.) No wire-format change.

Consumer side — typed on<EventName>(...) accessors are generated on the dep's <Module> wrapper. On the Qt surface a generic on(eventName, callback) channel sits alongside them as a forward-compat escape hatch; the lp surface has only the typed accessors (reach for logos::LpClient::subscribe directly if you need an untyped one):

// From any module that depends on the one declaring the events:
modules().my_module.onUserLoggedIn(
    [](const std::string& userId, int64_t timestamp) {
        // typed args, no manual QVariantList unpacking
    });

The accessor's parameter types follow the consumer's own --api-style (so a universal consumer sees const std::string& / int64_t, a handcrafted Qt consumer sees const QString& / qlonglong).

API

LogosResult

Where it lives: the Qt LogosResult shown below is not in this repo — it is declared in logos-protocol's cpp/logos_types.h, along with LogosResultException. What this repo's logos_result.h ships is the Qt-free StdLogosResult ({ bool success; nlohmann::json value; std::string error; }), which is what a universal module returns; the generated glue converts it to the Qt LogosResult for Qt callers. The section below describes the Qt-typed consumer surface.

LogosResult provides a structured way to return either a value or an error from synchronous method calls.

If the success attribute is true, you can retrieve the value using a cast. Otherwise, retrieve the error which should be a string (though not enforced).

The success attribute should ALWAYS be asserted. Accessing the value of an errored LogosResult or the error of a valid LogosResult will result in a LogosResultException being thrown.

Example

LogosResult result = m_logos->my_module.someMethod();
if (result.success) {
    // Use shorthand
    QString value = result.getString();
    // Or
    QString value = result.getValue<QString>();
} else {
    // Use shorthand
    QString error = result.getError();
    // Or
    QString error = result.getError<QString>();
}

Complex objects

Let's say you need to return a complex object. In the SDK, you have to build your type with primitive like QVariantMap:

// Received JSON: {"cid": "QmXyz...", "filename": "photo.jpg", "size": 2048576, "mimetype": "image/jpeg"}

QVariantMap manifest;
manifest["cid"] = "QmXyz...";
manifest["filename"] = "photo.jpg";
manifest["size"] = 2048576;
manifest["mimetype"] = "image/jpeg";
return {true, manifest};

And then to consume by using the shorthand function:

LogosResult result = m_logos->my_plugin.someMethod(cid);
if (result.success) {
  QString cid = result.getString("cid");
  // You can define a default value as well
  QString cid = result.getString("cid", "unknown");
}

Or you can use the value directly:

LogosResult result = m_logos->my_plugin.someMethod(cid);
if (result.success) {
    QVariantMap manifest = result.getMap();
    QString cid = manifest["cid"].toString();
}

Same thing for a list, you can use QVariantList:

QVariantList manifests;

QVariantMap m1;
m1["cid"] = "QmAbc...";
m1["filename"] = "document.pdf";
m1["size"] = 1024000;
manifests.append(m1);

QVariantMap m2;
m2["cid"] = "QmDef...";
m2["filename"] = "image.png";
m2["size"] = 512000;
manifests.append(m2);

return {true, manifests};

To consume it using the shorthand function:

LogosResult result = m_logos->my_plugin.someMethod();
if (result.success) {
    for (int i = 0; i < list.size(); ++i) {
        QString cid = result.getString(i, "cid");
        // You can define a default value as well
        QString cid = result.getString(0, "cid", "unknown");
    }
}

Or you can use the value directly:

LogosResult result = m_logos->my_plugin.someMethod();
if (result.success) {
    QVariantList list = result.getList();
    for (const QVariant& item : list) {
        QVariantMap manifest = item.toMap();
        QString cid = manifest["cid"].toString();
    }
}

Consuming the SDK

The SDK installs a CMake package. Consumers use find_package:

find_package(logos-cpp-sdk REQUIRED)
target_link_libraries(my_target PRIVATE logos-cpp-sdk::logos_headers)

Every target is an INTERFACE library — the base SDK is header-only, so there is no archive to link and nothing to resolve beyond nlohmann_json, which the package config pulls in with find_dependency. (It used to also re-resolve Qt6 Core/RemoteObjects, Boost system and OpenSSL for a static archive that referenced them; the transports that needed those moved to logos-protocol.)

logos_headers is the umbrella over four narrower targets, split by what a program actually is — take the narrow one when touching a repo:

Target Headers For
logos-cpp-sdk::logos_common logos_json.h, logos_result.h The shared value types; everything below links it
logos-cpp-sdk::logos_consumer logos_lp_client.h, logos_async_result.h CALLING other modules — also where the generated <dep>_api.{h,cpp} and logos_sdk.h compile
logos-cpp-sdk::logos_provider logos_module_context.h, logos_host_services.h IMPLEMENTING a module
logos-cpp-sdk::logos_host logos_host_core.h STANDING UP a core and loading modules (basecamp, logoscore-cli, standalone-app, module-viewer). A module never needs this

Transports

Where they live: none of the types in this section are in this repo any more. LogosTransportConfig / LogosTransportSet / LogosTransportConfigGlobal / LogosProtocol are declared in logos-protocol (cpp/logos_transport_config.h), and LogosAPI — which consumes them — in logos-plugin-qt's logos-qt-host (published through the logos-qt-sdk CMake package). The section is kept here because it is the shape a Qt host still writes.

The runtime supports multiple transports, selected via LogosTransportConfig:

Protocol Backend Use case
LocalSocket Qt Remote Objects over QLocalSocket In-host, module-to-module (default)
Tcp Boost.Asio + JSON/CBOR framing Cross-host or container-to-host
TcpSsl Boost.Asio + OpenSSL + JSON/CBOR framing Same as TCP, with TLS

A LogosTransportSet (= std::vector<LogosTransportConfig>) lets a single provider publish on multiple endpoints simultaneously (e.g. local socket for in-process clients + TCP+SSL for remote ones):

LogosTransportConfig local;  // protocol = LocalSocket (default)

LogosTransportConfig tls;
tls.protocol = LogosProtocol::TcpSsl;
tls.host     = "0.0.0.0";
tls.port     = 7443;
tls.caFile   = "/etc/logos/ca.pem";
tls.certFile = "/etc/logos/server.pem";
tls.keyFile  = "/etc/logos/server.key";

LogosAPI* api = new LogosAPI("core_service", LogosTransportSet{local, tls}, this);

For processes that want to override the process-wide default, use LogosTransportConfigGlobal::setDefault() once at startup before any LogosAPI is constructed.

Requirements

These are what building this repo needs. A consumer of the installed SDK needs only nlohmann_json — see Consuming the SDK above.

Build Tools

  • CMake (3.14 or later)
  • Ninja build system
  • pkg-config

Dependencies

  • logos-lidl — the canonical LIDL frontend; the generator links it via find_package(logos-lidl) rather than embedding a copy
  • logos-protocol — headers only, located via LOGOS_PROTOCOL_ROOT
  • Qt6 (qtbase) — the generator itself is a Qt Core program (QCoreApplication, QPluginLoader, QJson*)
  • Qt6 Remote Objects (qtremoteobjects)
  • Boost (system)
  • OpenSSL
  • nlohmann_json

Supported Platforms

  • macOS (aarch64-darwin, x86_64-darwin)
  • Linux (aarch64-linux, x86_64-linux)