Three QML symbols, meant never to change:
logos.request(intent, params, callback)
logos.intentRequested(requestId, intent, params, requesterName)
logos.respond(requestId, ok, data, error)
request() returns void deliberately. The requester never receives a
requestId, so it cannot forge a respond() for its own request and cannot
hold a handle to whoever answered — the single property that lets the
router underneath be replaced without any app noticing.
LogosIntent.h carries the shared vocabulary (five error codes, the name
grammar, payload rules, the {ok,data,error} envelope) as a header-only,
non-QObject namespace, so a host's broker and this bridge can never
disagree about what a legal name or a legal error is.
LogosIntentRouter.h is the seam. The bridge does NO policy: no `uses`
check, no grammar check, no payload check, no provider lookup. All of
that belongs to the router implementation, which is expected to be
DELETED when the core runtime takes over provider selection. Policy
placed in the bridge would survive that deletion and turn removing it
into a migration for every installed app.
10 KiB
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.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 aQObject, 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 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);
});
}
}
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:
respondtakes all four arguments. A provider that omitserroron a failure path must not fall into reporting success, so there are no defaults.requesterNameis 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 only —
isCanonicalPayload()bounds depth, size and type, and refusesQObject*andQJSValue. That is what stops one app handing another a live handle into its engine.respondflattens engine-bound values on the way out for the same reason. - A provider may only report
cancelled,timeout,failedorbad_request.normalizeError()coerces anything else, becausenot_declaredandunavailablecarry 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'slogos-qt-host(forLogosAPI/logos_api.h) — the Qt host runtime, linked aslogos-qt-host::logos_qt_hostlogos-protocol(token_manager.h,module_proxy.h,remote_transport.h, …) — carried transitively bylogos-qt-hostlogos-cpp-sdk(header-only types, vialogos-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.