This repo had no CI at all. Both open PRs (#20, #21) report "no checks reported on the branch", so every green result claimed for the QML bridge changes came from a local run on one machine, on one platform -- which is how several defects in that change set shipped green in the first place. The suite is already a nix derivation whose checkPhase runs ctest over every test target (nix/test.nix), so building the check IS running the tests; there is no separate run step to keep in sync. Both platforms run because the bridge coordinates processes and sockets, and the two OSes have already disagreed about exactly that elsewhere in the stack. Modelled on logos-protocol's workflow, including the DeterminateSystems installer (cachix/install-nix-action trips over pre-existing nix build users on the macOS runners) and a non-fatal cachix push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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:LogosQmlBridge—QObjectexposed to QML asLogos. RoutescallModule(module, method, args)calls either to a regular backend module viaLogosAPI(IPC), or to a view module via a privateQRemoteObjectDynamicReplica. Results are serialized to JSON strings so QML always sees a string.ViewModuleHost— spawns aui-hostchild process for a given view module plugin, generates a unique local socket name, watches stdout forREADY, and emitsready(). The parent then pointsLogosQmlBridgeat that socket viasetViewModuleSocket(name, socket).
-
ui-host— standalone executable. Loads a single Qt plugin (--path <plugin.so>), callsinitLogos(LogosAPI*)on it via reflection (QMetaObject::invokeMethod), and then exposes a QObject on aQRemoteObjectHostat the socket given by--socket. Remoting strategy:- Typed remoting (preferred): if the plugin declares the
LogosViewPlugininterface (fromlogos-plugin-qt) viaQ_INTERFACES(LogosViewPlugin)so thatqobject_cast<LogosViewPlugin*>succeeds,ui-hostcallsviewPlugin->enableRemoting(&host). The generated<Foo>ViewPluginBase(produced bylogos_module(REP_FILE …)inlogos-plugin-qt) invokeshost->enableRemoting<FooSourceAPI>(backend)so typed replicas on the client side reach theValidstate. The remoted object isviewPlugin->viewObject(). - Dynamic remoting (fallback): for plugins without a
.rep/LogosViewPluginimplementation,ui-hostfalls back tohost.enableRemoting(pluginObject, moduleName), which propagates allQ_INVOKABLEs, slots, signals, andQ_PROPERTYs (withNOTIFY) via aQRemoteObjectDynamicReplicaon the client side.
Any
Q_PROPERTYon the remoted object whose value is aQAbstractItemModel*is additionally remoted as a child source named<moduleName>/<propertyName>. PrintsREADYonce it's listening. - Typed remoting (preferred): if the plugin declares the
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 (recommended)
nix build .#default
Outputs:
result/lib/liblogos_view_module_runtime.aresult/include/— public headersresult/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);
});
}
}
Dependencies
- Qt 6:
Core,Qml,RemoteObjects logos-cpp-sdk(forLogosAPI/logos_api.h)
That's it — deliberately no dependency on logos-liblogos, logos-module, or
any specific module repo, so this runtime stays a thin shared layer.