mirror of
https://github.com/logos-co/logos-view-module-runtime.git
synced 2026-08-31 04:51:07 +00:00
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.
403 lines
20 KiB
C++
403 lines
20 KiB
C++
#pragma once
|
|
|
|
#include <QObject>
|
|
#include <QString>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QJSValue>
|
|
#include <QHash>
|
|
#include <QMap>
|
|
#include <QSet>
|
|
#include <QStringList>
|
|
#include <QPair>
|
|
|
|
// logos::ConsumerIdentity — what a host gets back from logos::admitConsumer.
|
|
// By value in the signature below rather than forward-declared, because a
|
|
// caller has to be able to construct one to call it.
|
|
#include "logos_consumer.h"
|
|
|
|
#include <QJSValue>
|
|
#include <QJSEngine> // QPointer<QJSEngine> member needs the complete type
|
|
#include <QPointer>
|
|
|
|
class LogosAPI;
|
|
class TokenManager;
|
|
class QRemoteObjectNode;
|
|
class QAbstractItemModelReplica;
|
|
class LogosViewReplicaFactory;
|
|
class QPluginLoader;
|
|
class LogosIntentRouter;
|
|
|
|
// ── LogosQmlBridge ───────────────────────────────────────────────────────────
|
|
//
|
|
// Single entry point for QML to talk to modules.
|
|
//
|
|
// Non-view (backend) modules — go through LogosAPI IPC:
|
|
// logos.callModule(name, method, [args]) // sync, returns JSON
|
|
// logos.callModuleAsync(name, method, [args], cb, ms) // async, JSON payload
|
|
//
|
|
// View modules — each view module ships a typed replica factory plugin
|
|
// (generated by logos_module() from a .rep file). The host loads it and
|
|
// exposes the typed replica directly to QML, so this API is dead simple:
|
|
//
|
|
// property var backend: logos.module("my_view_module")
|
|
// Text { text: backend.someProperty }
|
|
// Button { onClicked: backend.someSlot() }
|
|
// Connections { target: backend; function onSomeSignal() { ... } }
|
|
//
|
|
// // Enums declared in the .rep are available under the QML URI the
|
|
// // factory registered (typically "Logos.<ModuleName>"):
|
|
// import Logos.MyViewModule 1.0
|
|
// Text { text: backend.status === MyViewModule.Active ? "on" : "off" }
|
|
//
|
|
// // QAbstractItemModel* Q_PROPERTYs are still remoted separately:
|
|
// ListView { model: logos.model("my_view_module", "items") }
|
|
//
|
|
class LogosQmlBridge : public QObject {
|
|
Q_OBJECT
|
|
public:
|
|
explicit LogosQmlBridge(LogosAPI* api, QObject* parent = nullptr);
|
|
~LogosQmlBridge() override;
|
|
|
|
// ── App-to-app intents — THE FROZEN SURFACE ─────────────────────────
|
|
//
|
|
// Three symbols, and they will not change. See LogosIntent.h for the error
|
|
// codes, the name grammar and the envelope shape.
|
|
//
|
|
// Requester:
|
|
// logos.request("wallet.send", { chain_id: 1, to: addr },
|
|
// function (res) {
|
|
// if (res.ok) showReceipt(res.data.tx_hash)
|
|
// else showError(res.error)
|
|
// })
|
|
//
|
|
// Provider:
|
|
// Connections {
|
|
// target: logos
|
|
// function onIntentRequested(requestId, intent, params, requesterName) {
|
|
// logos.respond(requestId, true, { tx_hash: hash }, "")
|
|
// }
|
|
// }
|
|
//
|
|
// request() returns VOID on purpose. The requester never receives a
|
|
// requestId, so it cannot forge a respond() for its own request and cannot
|
|
// hold a handle to whoever answered. That single property is what lets the
|
|
// router underneath be replaced without any app noticing.
|
|
//
|
|
// The callback fires EXACTLY ONCE and ALWAYS ASYNCHRONOUSLY — even for an
|
|
// immediate failure with no router attached. No app can accidentally come
|
|
// to depend on a synchronous reply.
|
|
//
|
|
// `params` and `res.data` reach QML as real JS values, not JSON strings:
|
|
// res.data.tx_hash works, JSON.parse(res.data) does not. This differs from
|
|
// callModuleAsync deliberately.
|
|
Q_INVOKABLE void request(const QString& intent,
|
|
const QVariantMap& params,
|
|
QJSValue callback);
|
|
|
|
// Answer a request from intentRequested. All four arguments required: a
|
|
// provider that omits `error` on a failure path must not default into a
|
|
// success. Forwarded verbatim — see LogosIntentRouter.
|
|
Q_INVOKABLE void respond(const QString& requestId,
|
|
bool ok,
|
|
const QVariant& data,
|
|
const QString& error);
|
|
|
|
/**
|
|
* @brief A bridge that calls out AS an ADMITTED consumer, not as its host.
|
|
*
|
|
* The QML a view module ships runs INSIDE the host process, and until the
|
|
* per-plugin identity work the bridge handed to it was the host's own
|
|
* LogosAPI. That is not a cosmetic mislabelling: a LogosAPI carries the
|
|
* token store its calls present from, and the host's store is an ambient
|
|
* ring holding every loaded module's root auth token. A QML view given that
|
|
* bridge could call any module in the system — including ones it never
|
|
* declared — and the call authorised on the first try, with no
|
|
* `requestModule` anywhere in the log, because the target's own token was
|
|
* already sitting in the store.
|
|
*
|
|
* TAKES A ConsumerIdentity RATHER THAN A NAME, and that is the whole
|
|
* change. The predecessor of this function took a name and called
|
|
* LogosAPI::forIdentity, which is only HALF an identity: an isolated store,
|
|
* a credential nobody minted, and no registration at capability_module. Its
|
|
* own documentation admitted the gap ("the host still has to make
|
|
* `identity` a known caller"), and leaving that to the caller is exactly
|
|
* how two hosts ended up doing it differently and one of them not at all.
|
|
* logos::admitConsumer does all of it, in the one order that has no window
|
|
* in it, and hands back the object this takes.
|
|
*
|
|
* Returns NULLPTR for a falsy ConsumerIdentity; that is fatal for the view
|
|
* and must not be softened into "use the host's bridge instead".
|
|
*
|
|
* The returned bridge OWNS the LogosAPI in `consumer` (it is reparented to
|
|
* the bridge), so the caller frees exactly one object.
|
|
*/
|
|
static LogosQmlBridge* forConsumer(const logos::ConsumerIdentity& consumer,
|
|
QObject* parent = nullptr);
|
|
|
|
/** @brief The identity this bridge's calls are made as. */
|
|
QString identity() const;
|
|
|
|
/**
|
|
* @brief The token store this bridge's calls present tokens FROM.
|
|
*
|
|
* The identity that matters is this one, not the name: origin is never
|
|
* consulted on the hot path, which reads this store first and mints only on
|
|
* a miss. A bridge whose store is `&TokenManager::instance()` is running on
|
|
* the host's ambient ring however it is labelled.
|
|
*/
|
|
TokenManager* tokenStore() const;
|
|
|
|
// ── Backend (non-view) module calls via LogosAPI IPC ────────────────
|
|
//
|
|
// Synchronous: it must answer now, so it cannot wait for a module the way
|
|
// callModuleAsync() does. If the module is still starting it waits a short
|
|
// bounded time and then returns an error naming callModuleAsync — it does
|
|
// NOT sit in the transport's full acquire budget on the GUI thread, which
|
|
// is what a call issued from Component.onCompleted used to do.
|
|
//
|
|
// PREFER callModuleAsync() for anything a view's first paint depends on.
|
|
// A startup call is exactly the case this form handles worst.
|
|
Q_INVOKABLE QString callModule(const QString& module,
|
|
const QString& method,
|
|
const QVariantList& args = QVariantList());
|
|
|
|
// Asynchronous, and the right tool during startup: a module that is not
|
|
// reachable yet is NOT an error here. The call is held and dispatched as
|
|
// soon as the module appears — including one installed mid-session by the
|
|
// package manager — and nothing blocks the GUI thread while it waits.
|
|
//
|
|
// timeoutMs: if > 0, the callback is invoked with an error payload when
|
|
// no reply arrives within that many milliseconds. Default 30000 (30s);
|
|
// pass 0 to disable. That deadline now bounds the WAIT for the module as
|
|
// well as the call itself, so a module that never appears still answers.
|
|
Q_INVOKABLE void callModuleAsync(const QString& module,
|
|
const QString& method,
|
|
const QVariantList& args,
|
|
QJSValue callback,
|
|
int timeoutMs = 30000);
|
|
|
|
// ── View module API ─────────────────────────────────────────────────
|
|
|
|
// Returns the typed replica for a view module. Safe to call before the
|
|
// replica is Valid — QML will see default-constructed values until then
|
|
// and property NOTIFYs fire once the source meta arrives.
|
|
Q_INVOKABLE QObject* module(const QString& moduleName);
|
|
|
|
// Returns a QAbstractItemModelReplica* for a QAbstractItemModel*
|
|
// Q_PROPERTY on the view module. ui-host auto-remotes every such
|
|
// property as "<module>/<propertyName>".
|
|
//
|
|
// prefetch=true acquires with QtRemoteObjects::PrefetchData, so the
|
|
// replica caches all roles for the initial rowCount before it reports
|
|
// populated — kills the "row exists but roles are still null" flash
|
|
// that FetchRootSize (the default) shows. Only safe for bounded /
|
|
// paged models; a raw source of 100k rows would fetch 100k *
|
|
// roles up front.
|
|
Q_INVOKABLE QObject* model(const QString& moduleName,
|
|
const QString& modelName,
|
|
bool prefetch = false);
|
|
|
|
// True once the view module's replica is Valid (source meta received).
|
|
Q_INVOKABLE bool isViewModuleReady(const QString& moduleName) const;
|
|
|
|
// Re-emit viewModuleReadyChanged for every replica that is already Valid.
|
|
//
|
|
// Replicas outlive the QML engine (they are parented here, with
|
|
// CppOwnership), so a view rebuilt against a fresh engine — hot reload —
|
|
// gets the cached replica back from module() without any state transition.
|
|
// Its Connections were created after the original Valid edge and would
|
|
// otherwise wait forever for a signal that already fired. Hosts call this
|
|
// once the new object tree is complete.
|
|
void replayViewModuleState();
|
|
|
|
// Watch a QRemoteObjectPendingCall returned by a replica slot call and
|
|
// invoke callbacks with the result. Replaces QtRemoteObjects.watch() so
|
|
// QML plugins don't need to import QtRemoteObjects (keeping the sandbox
|
|
// locked down).
|
|
//
|
|
// Usage:
|
|
// logos.watch(backend.add(1, 2),
|
|
// function(value) { display.text = value },
|
|
// function(error) { console.log(error) })
|
|
Q_INVOKABLE void watch(const QVariant& pendingCall,
|
|
QJSValue onSuccess,
|
|
QJSValue onError = QJSValue());
|
|
|
|
// Called by the host application during setup.
|
|
void setViewModuleSocket(const QString& moduleName, const QString& socketName);
|
|
void setViewReplicaPlugin(const QString& moduleName, const QString& pluginPath);
|
|
|
|
// ── Intent host-side API — NOT reachable from QML ────────────────────
|
|
//
|
|
// None are Q_INVOKABLE or slots, so none appear in the metaobject. That
|
|
// absence IS the trust boundary: an app must not install its own router,
|
|
// deliver a result to itself, or read another request's id. A test asserts
|
|
// it. A null router is legal — request() then answers "unavailable".
|
|
void setIntentRouter(LogosIntentRouter* router);
|
|
|
|
// Emitted synchronously so the returned receiver count is meaningful:
|
|
// 0 means the provider declared the intent but is not listening, which the
|
|
// frozen surface cannot report any other way. Hosts must not connect to
|
|
// intentRequested themselves — it would inflate the count.
|
|
int deliverIntentRequest(const QString& requestId,
|
|
const QString& intent,
|
|
const QVariantMap& params,
|
|
const QString& requesterName);
|
|
|
|
// Complete a pending request. Erases before invoking, so a second delivery
|
|
// for the same id is a no-op by construction rather than by a guard.
|
|
void deliverIntentResult(const QString& requestId, const QVariantMap& envelope);
|
|
|
|
// Hot reload: drop every pending callback uninvoked and tell the router
|
|
// which ids went, since their QJSValues belong to a dying engine.
|
|
void abandonPendingIntents();
|
|
|
|
QStringList pendingIntentRequestIds() const;
|
|
|
|
// Called when the view module's child process exits unexpectedly.
|
|
void notifyViewModuleCrashed(const QString& moduleName);
|
|
|
|
// ── Test-friendly helpers ───────────────────────────────────────────
|
|
//
|
|
// Stateless serializer for a QVariant result into the JSON string
|
|
// shape QML consumers expect. Exposed as a public static method so
|
|
// unit tests (and callers who want the same format without going
|
|
// through callModule()) can exercise it directly.
|
|
static QString serializeResultForTesting(const QVariant& result);
|
|
|
|
// Introspection helpers used by unit tests to assert routing state
|
|
// without needing a real QRemoteObjectNode.
|
|
bool hasViewModuleSocket(const QString& moduleName) const;
|
|
QString viewModuleSocket(const QString& moduleName) const;
|
|
QString viewReplicaPluginPath(const QString& moduleName) const;
|
|
|
|
// Subscribe to events emitted by core (non-view) modules. When the
|
|
// module emits an event, the moduleEventReceived signal fires.
|
|
//
|
|
// Usage:
|
|
// Component.onCompleted: logos.onModuleEvent("calc_module", "resultReady")
|
|
// Connections {
|
|
// target: logos
|
|
// function onModuleEventReceived(moduleName, eventName, data) {
|
|
// if (eventName === "resultReady")
|
|
// console.log("Got result:", JSON.stringify(data))
|
|
// }
|
|
// }
|
|
//
|
|
// Component.onCompleted is the RIGHT place to call this even though the
|
|
// module is usually not reachable yet at that point: the subscription is
|
|
// accepted and armed as soon as the module appears — including a module
|
|
// installed mid-session by the package manager. Nothing blocks the GUI
|
|
// thread.
|
|
//
|
|
// ONE THING IT DOES NOT PROMISE: arming is not retroactive and the
|
|
// transports do not buffer. There is a brief window — between the module's
|
|
// socket appearing and its replica going Valid, whose length depends on the
|
|
// platform and the load — in which an emitted event reaches nobody. A
|
|
// module that fires a one-shot
|
|
// "ready" event synchronously inside its own init() can still be missed, by
|
|
// this path and by the blocking one it replaced alike. If a module's
|
|
// startup event matters, it must also expose a method the view can call
|
|
// after subscribing.
|
|
//
|
|
// Calling it twice for the same (module, event) is a no-op, so a view that
|
|
// re-runs Component.onCompleted on reload will not receive doubled events.
|
|
// The de-duplication is verified against the registry, not assumed, so a
|
|
// subscription that was dropped underneath the bridge is re-armed by a
|
|
// second call rather than silently swallowed.
|
|
//
|
|
// Returns true if the subscription was ACCEPTED — which is not the same as
|
|
// "is live right now". A module that is merely unreachable is still an
|
|
// acceptance: that is the whole point of this call. It returns false in
|
|
// five cases, all of them a fault in the request or in the bridge's own
|
|
// setup rather than a module that has yet to appear:
|
|
// - no LogosAPI;
|
|
// - an empty module or event name;
|
|
// - a VIEW module (those emit through their typed replica; use
|
|
// logos.module(name) and a Connections block instead);
|
|
// - no client for the module — getClient() returned null, which means
|
|
// LogosAPI could not build one at all, not that the module is down;
|
|
// - the client refused the subscription (id 0). Defensive: the arguments
|
|
// that make onEventWhenAvailable() refuse are all rejected above, so
|
|
// this is unreachable today. It is documented because the two contracts
|
|
// have to agree, and one of them changing is how they stop agreeing.
|
|
// Retrying any of these with the same arguments returns false again.
|
|
Q_INVOKABLE bool onModuleEvent(const QString& moduleName, const QString& eventName);
|
|
|
|
// Diagnostics: "<module>::<event>" for every subscription made via
|
|
// onModuleEvent() that has been accepted but has not armed yet. Empty once
|
|
// every subscription is live. Exposed to QML so a view (or a test) can tell
|
|
// "waiting for the module" apart from "subscribed, module is just quiet".
|
|
Q_INVOKABLE QStringList pendingEventSubscriptions() const;
|
|
|
|
signals:
|
|
void viewModuleReadyChanged(const QString& moduleName, bool ready);
|
|
void viewModuleCrashed(const QString& moduleName);
|
|
void moduleEventReceived(const QString& moduleName,
|
|
const QString& eventName,
|
|
const QVariantList& data);
|
|
|
|
// FROZEN. A provider handles this to receive a request routed to it.
|
|
// requesterName is host-attested — the router knows who called by
|
|
// construction — so it is trustworthy in a way a self-declared name in the
|
|
// payload would not be. It may be empty when the requester is the shell.
|
|
void intentRequested(const QString& requestId,
|
|
const QString& intent,
|
|
const QVariantMap& params,
|
|
const QString& requesterName);
|
|
|
|
private:
|
|
void dropViewModuleCaches(const QString& moduleName);
|
|
QRemoteObjectNode* getOrCreateNode(const QString& moduleName);
|
|
LogosViewReplicaFactory* loadFactory(const QString& moduleName);
|
|
QJSValue toJsValue(const QVariant& v) const;
|
|
void failIntentLocally(const QString& requestId, const QString& errorCode);
|
|
|
|
LogosAPI* m_logosAPI;
|
|
|
|
// A pending request: the caller's callback, plus the engine it came from.
|
|
//
|
|
// Held in a NAMED MEMBER rather than captured in a lambda the way
|
|
// callModuleAsync's fire-once latch is, because three things outside the
|
|
// call have to reach it: the destructor, a hot reload, and the router
|
|
// delivering a result minutes later.
|
|
//
|
|
// The engine is stamped per record so a result arriving after a hot reload
|
|
// can be dropped rather than invoked against a QJSValue whose engine is
|
|
// gone.
|
|
struct PendingIntent {
|
|
QJSValue callback;
|
|
QPointer<QJSEngine> engine;
|
|
// The generation this request was accepted in.
|
|
quint64 generation = 0;
|
|
};
|
|
|
|
LogosIntentRouter* m_intentRouter = nullptr;
|
|
QHash<QString, PendingIntent> m_pendingIntents;
|
|
|
|
// Bumped by abandonPendingIntents(). Never reset: a wrapped counter would
|
|
// let a stale delivery match a live generation.
|
|
quint64 m_intentGeneration = 0;
|
|
|
|
// Per-view-module state
|
|
QMap<QString, QString> m_viewModuleSockets;
|
|
QMap<QString, QString> m_replicaPluginPaths;
|
|
QMap<QString, QRemoteObjectNode*> m_replicaNodes;
|
|
QMap<QString, QPluginLoader*> m_factoryLoaders;
|
|
QMap<QString, LogosViewReplicaFactory*> m_factories;
|
|
QMap<QString, QObject*> m_replicas;
|
|
QMap<QString, QAbstractItemModelReplica*> m_modelReplicas;
|
|
|
|
// (module, event) -> subscription id already handed to the transport. QML
|
|
// re-runs Component.onCompleted on a view reload, and the layer below
|
|
// deliberately does not de-duplicate (two lp_subscribe calls to the same
|
|
// event are two real subscriptions), so the de-dupe belongs here.
|
|
//
|
|
// The id, not just the key, because this record can go stale: onModuleEvent
|
|
// checks it against LogosAPIClient::eventSubscriptionState() before
|
|
// short-circuiting, so a subscription that was dropped underneath us is
|
|
// re-armed instead of being swallowed as a duplicate.
|
|
QHash<QPair<QString, QString>, quint64> m_eventSubscriptions;
|
|
};
|