logos-view-module-runtime

Shared runtime for hosting Logos view modules (Qt/QML UI plugins) in a child process, isolated from the main application.

This repo exists so that logos-basecamp, logos-standalone-app, and any future Logos host application can link the same library and use the same ui-host binary instead of each one carrying its own copy.

What's in here

  • logos_view_module_runtime — static C++ library, linked into host applications (or their plugins). Provides:

    • LogosQmlBridgeQObject exposed to QML as Logos. Routes callModule(module, method, args) calls either to a regular backend module via LogosAPI (IPC), or to a view module via a private QRemoteObjectDynamicReplica. Results are serialized to JSON strings so QML always sees a string.
    • LogosIntent.h — the frozen app-to-app intent vocabulary: the six error codes, the intent-name grammar, the payload rules and the result envelope. Header-only and not a QObject, so both a view module and a host's broker include the same definitions and cannot disagree about what an error code means. See "App-to-app intents" below.
    • ViewModuleHost — spawns a ui-host child process for a given view module plugin, generates a unique local socket name, watches stdout for READY, and emits ready(). The parent then points LogosQmlBridge at that socket via setViewModuleSocket(name, socket).
  • ui-host — standalone executable. Loads a single Qt plugin (--path <plugin.so>), calls initLogos(LogosAPI*) on it via reflection (QMetaObject::invokeMethod), and then exposes a QObject on a QRemoteObjectHost at the socket given by --socket. Remoting strategy:

    • Typed remoting (preferred): if the plugin declares the LogosViewPlugin interface (from logos-plugin-qt) via Q_INTERFACES(LogosViewPlugin) so that qobject_cast<LogosViewPlugin*> succeeds, ui-host calls viewPlugin->enableRemoting(&host). The generated <Foo>ViewPluginBase (produced by logos_module(REP_FILE …) in logos-plugin-qt) invokes host->enableRemoting<FooSourceAPI>(backend) so typed replicas on the client side reach the Valid state. The remoted object is viewPlugin->viewObject().
    • Dynamic remoting (fallback): for plugins without a .rep / LogosViewPlugin implementation, ui-host falls back to host.enableRemoting(pluginObject, moduleName), which propagates all Q_INVOKABLEs, slots, signals, and Q_PROPERTYs (with NOTIFY) via a QRemoteObjectDynamicReplica on the client side.

    Any Q_PROPERTY on the remoted object whose value is a QAbstractItemModel* is additionally remoted as a child source named <moduleName>/<propertyName>. Prints READY once it's listening.

View object convention

A view module plugin keeps its plugin-lifecycle class separate from the QObject that QML actually talks to. The preferred path is to inherit the generated <Foo>ViewPluginBase from logos-plugin-qt (produced by logos_module(REP_FILE my_view.rep …)), which implements LogosViewPlugin and wires typed remoting:

class MyPlugin : public MyViewPluginBase {
    Q_OBJECT
    Q_PLUGIN_METADATA(IID "co.logos.MyPlugin" FILE "metadata.json")
    Q_INTERFACES(PluginInterface LogosViewPlugin)
public:
    Q_INVOKABLE void initLogos(LogosAPI* api) {
        m_backend = new MyBackend(api, this);
    }
    QObject* viewObject() override { return m_backend; }
private:
    MyBackend* m_backend = nullptr;
};

ui-host calls viewPlugin->enableRemoting(&host), which internally does host->enableRemoting<MySourceAPI>(m_backend) using the typed source generated from the .rep file. QML on the parent side talks to MyBackend via a typed replica.

For plugins without a .rep file (no LogosViewPlugin implementation), ui-host falls back to dynamic remoting of the plugin object itself — this keeps legacy modules working unchanged.

Architecture

┌────────────────────────────┐         ┌──────────────────────────┐
│ Host app (basecamp / etc.) │         │ ui-host (child process)  │
│                            │         │                          │
│   QML  ──logos.callModule──▶         │   ViewModuleProxy        │
│           │                │ QRO     │     │                    │
│   LogosQmlBridge ──────────┼────────▶│     ▼                    │
│           │                │ local   │   QPluginLoader          │
│           ▼                │ socket  │     │                    │
│   ViewModuleHost ──spawn──▶│         │     ▼                    │
│                            │         │   <view module>.so       │
└────────────────────────────┘         └──────────────────────────┘

Each view module gets its own ui-host process and its own private socket, so a crash or hang in one view module cannot take down the host app or other view modules.

Non-view backend modules continue to use the existing LogosAPI IPC path unchanged — LogosQmlBridge only switches to QRO when the requested module name was previously registered via setViewModuleSocket.

Building

nix build .#default

Outputs:

  • result/lib/liblogos_view_module_runtime.a
  • result/include/ — public headers
  • result/bin/ui-host

CMake (manual)

cmake -S . -B build -GNinja \
  -DLOGOS_CPP_SDK_ROOT=/path/to/logos-cpp-sdk
cmake --build build
cmake --install build --prefix ./out

LOGOS_CPP_SDK_ROOT is required and must point at an installed logos-cpp-sdk (provides logos_api.h and liblogos_sdk).

Consuming from another repo

In the consumer's flake.nix:

inputs.logos-view-module-runtime.url = "github:logos-co/logos-view-module-runtime";

Pass the package into the consumer's app derivation and forward it as a CMake variable:

cmakeFlags = [
  "-DLOGOS_VIEW_MODULE_RUNTIME_ROOT=${logosViewModuleRuntime}"
];

In the consumer's CMakeLists.txt:

target_include_directories(my_app PRIVATE ${LOGOS_VIEW_MODULE_RUNTIME_ROOT}/include)
target_link_directories(my_app PRIVATE ${LOGOS_VIEW_MODULE_RUNTIME_ROOT}/lib)
target_link_libraries(my_app PRIVATE logos_view_module_runtime)

The ui-host binary should be copied into the app's bin/ directory at install time so ViewModuleHost can QProcess::start("ui-host", ...) it:

cp ${logosViewModuleRuntime}/bin/ui-host $out/bin/ui-host

Using the bridge

auto* api = new LogosAPI(/* ... */);
auto* bridge = new LogosQmlBridge(api, this);
engine.rootContext()->setContextProperty("logos", bridge);

// For a view module, spawn its host process and wire the bridge to its socket
auto* host = new ViewModuleHost(this);
connect(host, &ViewModuleHost::ready, this, [bridge, host] {
    bridge->setViewModuleSocket("my_view_module", host->socketName());
});
if (!host->spawn("my_view_module", "/path/to/my_view_module.so")) {
    qWarning() << "Failed to start view module host";
}

From QML:

import QtQuick
Item {
    Component.onCompleted: {
        // Prefer the async form for view modules — the sync callModule() blocks
        // the QML/JS event loop while waiting for the QRO reply.
        logos.callModuleAsync("my_view_module", "getStatus", [], function(payload) {
            const result = JSON.parse(payload);
            console.log(result.value);
        });
    }
}

App-to-app intents

One app asks for a capability; the shell decides who services it. This repo owns the frozen half of that surface — the part apps compile against — and nothing else. Resolution, consent and dispatch are host policy and live in the host (in Basecamp, IntentBroker).

Three members on the bridge, plus LogosIntent.h:

// Ask. You never name a provider, and never learn which apps are installed.
logos.request("wallet.send", { to: "0xabc", amount: 12.5 }, function (res) {
    if (res.ok) console.log(res.data.txHash)
    else        console.log(res.error)   // one of six codes
})

// Answer, if you declared `provides` in metadata.json.
Connections {
    target: logos
    function onIntentRequested(requestId, intent, params, requesterName) {
        logos.respond(requestId, true, { txHash: "0x…" }, "")
    }
}

Frozen means these signatures do not change: request, respond, intentRequested, the codes in LogosIntent.h, and the payload bounds. A host may replace everything behind them.

Points a host implementer has to honour, because the surface assumes them:

  • respond takes all four arguments. A provider that omits error on a failure path must not fall into reporting success, so there are no defaults.
  • requesterName is host-attested. The router knows who called by construction; it is not read from the payload, and a caller cannot forge it.
  • Payloads are plain data onlyisCanonicalPayload() bounds depth, size and type, and refuses QObject* and QJSValue. That is what stops one app handing another a live handle into its engine. respond flattens engine-bound values on the way out for the same reason.
  • A provider may only report cancelled, timeout, failed or bad_request. normalizeError() coerces anything else, because not_declared and unavailable carry meaning a provider is not entitled to assert — both reveal whether a provider exists at all.
  • Every request terminates exactly once, asynchronously, even on immediate failure.

Full reference: logos-basecamp/docs/app-to-app-intents.md and logos-tutorial/guide-intents-for-app-developers.md.

Dependencies

  • Qt 6: Core, Qml, RemoteObjects
  • logos-plugin-qt's logos-qt-host (for LogosAPI / logos_api.h) — the Qt host runtime, linked as logos-qt-host::logos_qt_host
  • logos-protocol (token_manager.h, module_proxy.h, remote_transport.h, …) — carried transitively by logos-qt-host
  • logos-cpp-sdk (header-only types, via logos-cpp-sdk::logos_headers)

That's it — deliberately no dependency on logos-liblogos, logos-module, or any specific module repo, so this runtime stays a thin shared layer.

S
Description
taking care of running UI apps backend /Qt plugin in its own separate process
Readme
661 KiB
Languages
C++ 91.5%
CMake 5%
Nix 3.5%