feat: the Qt host runtime and the cdylib-glue generator

This repo was the Qt plugin BUILD backend — pure Nix functions plus a CMake
module, with no C++ target at all. But the two things a Qt host actually needs
at runtime lived in logos-qt-sdk, where they are neither a language wrapper over
the protocol C API nor codegen:

  LogosAPI / LogosAPIProvider   owns a LogosTransportHost per transport,
                                constructs ModuleProxy / ModuleHandshakeProxy,
                                publishes the handshake surface, seeds trust
                                anchors, injects the token validator
  LogosProviderBase             the base every generated provider derives
  PluginInterface               the Qt plugin loading contract
  the cdylib->Qt glue generator the emitter that wraps a language-neutral
                                cdylib in a Qt plugin

They move here, so "swap the plugin technology" is a one-repo change.

ADDITIVE: the sources are COPIED and logos-qt-sdk is untouched. Removing them
there now would turn nine downstream masters red at once; that comes later,
after consumers are repointed.

qt_provider_object (and logos_qt_arg_decode, which its QMetaObject dispatch
needs) is carried deliberately even though it is legacy: logos_api_provider
falls back to wrapping a plain QObject in it, and the modules that rely on that
have not been migrated yet. It goes once they are.

The generator links Qt Core and logos-lidl only — never logos-cpp-sdk. It needed
exactly one helper from that SDK's shared frontend, lidlToPascalCase (~12
lines), which is inlined instead, the same way logos-view-module does it. It
also REFUSES `--backend <anything but cdylib>` rather than ignoring the flag:
callers are migrating from a tool where --backend was required and dispatched
on, so silently treating `--backend qt` as cdylib would emit confidently wrong
artifacts with a zero exit.

`rawLib`, `lib` and `cmake-module` deliberately do not reference the new
derivations, so a consumer that only wants the Nix build functions never
realises a Qt/protocol build. Proven, not assumed: with both new inputs
overridden to a bogus flake, cmake-module and rawLib still evaluate while
logos-qt-host fails — so the override bites and the cheap outputs really never
touch it.

Behaviour preservation is the whole claim of a relocation, so it is measured:
the emitted glue is BYTE-IDENTICAL to logos-qt-generator --backend cdylib over
every one of the 20 .lidl contracts in the workspace, in both single and
concurrency:multi mode (40 pairs, exit codes included), and single vs multi do
differ from each other, so both code paths were really exercised. The built
liblogos_qt_host.a is byte-identical to liblogos_qt_sdk.a, exporting the same
390 symbols.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-16 09:07:04 -03:00
co-authored by Claude Opus 5
parent 8846fc5626
commit eac67af9e3
26 changed files with 2902 additions and 7 deletions
+6
View File
@@ -27,3 +27,9 @@ jobs:
- name: Header generator guard test
run: nix build '.#checks.x86_64-linux.header-generator-guard'
- name: Build Qt host runtime
run: nix build '.#checks.x86_64-linux.qt-host'
- name: Qt host glue generator test
run: nix build '.#checks.x86_64-linux.qt-host-generator'
+29 -2
View File
@@ -1,5 +1,32 @@
# logos-plugin-qt
The Qt-specific build logic (CMake module, plugin compilation, header generation) as a standalone backend that logos-module-builder delegates to.
Everything specific to running a Logos module as a **Qt 6 plugin**, in one repo:
the build logic logos-module-builder delegates to, and the runtime that build
produces plugins against. Keeping both here is what lets the plugin technology
be swapped without touching the module builder or individual modules.
This enables swapping the plugin technology without changing the module builder or individual modules.
## Outputs
| Output | What it is |
|---|---|
| `lib` / `rawLib` | The Nix build functions (`buildPlugin`, `buildHeaders`, `devShellInputs`). `rawLib` takes its Logos deps as arguments; `lib` pre-fills them from this flake. |
| `packages.<sys>.cmake-module` | `LogosModule.cmake` — the CMake half of the plugin build. Also the `default` package. |
| `packages.<sys>.logos-qt-host` | The **Qt host runtime** a plugin links: `LogosAPI`, `LogosAPIProvider`, `LogosProviderBase` + the `LOGOS_PROVIDER` / `LOGOS_METHOD` macros, the legacy `QtProviderObject` adapter, and `core/interface.h`. Static library, headers, and a `find_package(logos-qt-host)` config. |
| `packages.<sys>.logos-qt-host-generator` | Emits the Qt plugin glue around a cdylib module's C ABI (`<name>_cdylib_glue.{h,cpp}`) from its LIDL contract. |
The first two are pure Nix / CMake and stay that way: nothing reachable from
`lib`, `rawLib` or `cmake-module` mentions the two C++ derivations, so a
consumer that only wants the build functions never realises a Qt or protocol
build to get them.
## Layout
```
lib/ the Nix build functions (buildPlugin, buildHeaders)
cmake/ LogosModule.cmake
cpp/ the Qt host runtime library (logos-qt-host)
core/interface.h the legacy Qt plugin interface (PluginInterface)
qt-host-generator/ the cdylib -> Qt-plugin glue emitter
nix/ derivations for the two C++ outputs
tests/ flake checks
```
+29
View File
@@ -0,0 +1,29 @@
#ifndef PLUGIN_INTERFACE_H
#define PLUGIN_INTERFACE_H
#include <QtPlugin>
#include <QString>
#include "../cpp/logos_api.h"
// Define the common base interface for all modules
class PluginInterface
{
public:
virtual ~PluginInterface() {}
// Common plugin methods
virtual QString name() const = 0;
virtual QString version() const = 0;
// TODO: this should be defined here and removed from the modules, but needs some work
// Q_INVOKABLE void initLogos(LogosAPI* logosAPIInstance);
LogosAPI* logosAPI = nullptr;
};
// Define the interface ID used by Qt's plugin system
#define PluginInterface_iid "com.example.PluginInterface"
Q_DECLARE_INTERFACE(PluginInterface, PluginInterface_iid)
#endif // PLUGIN_INTERFACE_H
+129
View File
@@ -0,0 +1,129 @@
cmake_minimum_required(VERSION 3.14)
project(LogosQtHost)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core RemoteObjects)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core RemoteObjects)
# The protocol layer — transports, consumer core, token manager, the abstract
# LogosProviderObject interface and the lp_* C ABI. This library is the Qt
# HOST runtime on top of it: the object a Qt plugin is handed (LogosAPI), the
# provider side that publishes it (LogosAPIProvider), and the provider base
# classes a generated or hand-written plugin derives.
if(NOT DEFINED LOGOS_PROTOCOL_ROOT)
if(DEFINED ENV{LOGOS_PROTOCOL_ROOT})
set(LOGOS_PROTOCOL_ROOT "$ENV{LOGOS_PROTOCOL_ROOT}")
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../../logos-protocol/cpp/logos_protocol.h")
set(LOGOS_PROTOCOL_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../logos-protocol")
endif()
endif()
if(EXISTS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol")
# Installed protocol package → proper layered link.
find_package(logos-protocol REQUIRED
PATHS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol" NO_DEFAULT_PATH)
set(LP_TARGET logos-protocol::logos_protocol)
elseif(EXISTS "${LOGOS_PROTOCOL_ROOT}/cpp/CMakeLists.txt")
# Source checkout → build it as a subproject (dev convenience).
add_subdirectory("${LOGOS_PROTOCOL_ROOT}/cpp"
"${CMAKE_BINARY_DIR}/logos-protocol-build")
set(LP_TARGET logos_protocol)
else()
message(FATAL_ERROR "logos-protocol not found. Set LOGOS_PROTOCOL_ROOT to an "
"installed logos-protocol prefix or a source checkout.")
endif()
set(QT_HOST_SOURCES
logos_api.cpp
logos_api.h
logos_api_provider.cpp
logos_api_provider.h
logos_provider_object.cpp
logos_provider_object.h
qt_provider_object.cpp
qt_provider_object.h
# Not part of the host runtime proper, but qt_provider_object.cpp's
# QMetaObject dispatch decodes every incoming argument through
# logos::qtArgDecode. Carrying the legacy adapter means carrying this.
logos_qt_arg_decode.cpp
logos_qt_arg_decode.h
)
add_library(logos_qt_host STATIC ${QT_HOST_SOURCES})
target_link_libraries(logos_qt_host PUBLIC
${LP_TARGET}
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::RemoteObjects
)
target_include_directories(logos_qt_host PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:include>
)
set_target_properties(logos_qt_host PROPERTIES
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib"
)
install(TARGETS logos_qt_host
EXPORT logos-qt-hostTargets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
install(EXPORT logos-qt-hostTargets
FILE logos-qt-hostTargets.cmake
NAMESPACE logos-qt-host::
DESTINATION lib/cmake/logos-qt-host
)
include(CMakePackageConfigHelpers)
configure_package_config_file(
"${CMAKE_CURRENT_SOURCE_DIR}/logos-qt-hostConfig.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/logos-qt-hostConfig.cmake"
INSTALL_DESTINATION lib/cmake/logos-qt-host
)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/logos-qt-hostConfigVersion.cmake"
VERSION 0.1.0
COMPATIBILITY SameMajorVersion
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/logos-qt-hostConfig.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/logos-qt-hostConfigVersion.cmake"
DESTINATION lib/cmake/logos-qt-host
)
set(QT_HOST_PUBLIC_HEADERS
logos_api.h
logos_api_provider.h
logos_provider_object.h
qt_provider_object.h
logos_qt_arg_decode.h
)
# Headers keep their historical names so existing `#include "logos_api.h"`
# lines resolve unchanged once consumers add this prefix's include dir.
install(FILES ${QT_HOST_PUBLIC_HEADERS} DESTINATION include)
# Legacy Qt plugin interface (PluginInterface / initLogos(LogosAPI*)) —
# installed at include/core/ exactly where logos-cpp-sdk, and then
# logos-qt-sdk, shipped it. Consumers put include/core on the include path.
install(FILES
${CMAKE_CURRENT_SOURCE_DIR}/../core/interface.h
DESTINATION include/core
)
# interface.h reaches LogosAPI as `#include "../cpp/logos_api.h"`, i.e. it
# resolves RELATIVE TO ITSELF, so from include/core/ it needs include/cpp/ to
# exist. logos-qt-sdk satisfied that by shipping a second, source-export copy
# of the headers from a separate derivation; here the same mirror is part of
# the one install, which keeps the prefix self-consistent on its own. The flat
# copy above stays the one consumers include by name — this exists only so the
# relative shape inside interface.h keeps resolving, unchanged.
install(FILES ${QT_HOST_PUBLIC_HEADERS} DESTINATION include/cpp)
+14
View File
@@ -0,0 +1,14 @@
@PACKAGE_INIT@
# logos-qt-hostConfig.cmake — consumed by `find_package(logos-qt-host)`.
# Re-resolves the runtime's dependencies (Qt + logos-protocol, which itself
# propagates Boost / OpenSSL / nlohmann_json) before importing the target.
include(CMakeFindDependencyMacro)
find_dependency(Qt6 REQUIRED COMPONENTS Core RemoteObjects)
find_dependency(logos-protocol REQUIRED)
include("${CMAKE_CURRENT_LIST_DIR}/logos-qt-hostTargets.cmake")
check_required_components(logos-qt-host)
+118
View File
@@ -0,0 +1,118 @@
#include "logos_api.h"
#include "logos_api_client.h"
#include "logos_api_provider.h"
#include "logos_thread_marshal.h"
#include "token_manager.h"
#include <QVariant>
#include <string>
LogosAPI::LogosAPI(const QString& module_name, QObject *parent)
: LogosAPI(module_name, LogosTransportSet{}, parent)
{
}
LogosAPI::LogosAPI(const QString& module_name,
LogosTransportSet transports,
QObject *parent)
: QObject(parent)
, m_module_name(module_name)
, m_provider(nullptr)
, m_token_manager(nullptr)
{
m_provider = new LogosAPIProvider(m_module_name, std::move(transports), this);
m_token_manager = &TokenManager::instance();
qRegisterMetaType<LogosResult>("LogosResult");
}
LogosAPI::LogosAPI(const std::string& module_name, QObject *parent)
: LogosAPI(QString::fromStdString(module_name), parent)
{
}
LogosAPI::~LogosAPI()
{
// Provider and client will be automatically deleted as child objects
// Token manager is a singleton, so we don't delete it
}
LogosAPIProvider* LogosAPI::getProvider() const
{
return m_provider;
}
LogosAPIClient* LogosAPI::getClient(const QString& target_module) const
{
// The no-transport overload is just shorthand for "use the
// process-global default" — the explicit-transport overload below
// is the single resolution path. Mode-awareness lives in the
// factory, so this delegation preserves Mock/Local semantics.
return getClient(target_module, LogosTransportConfigGlobal::getDefault());
}
LogosAPIClient* LogosAPI::getClient(const std::string& target_module) const
{
return getClient(QString::fromStdString(target_module));
}
LogosAPIClient* LogosAPI::getClient(const QString& target_module,
const LogosTransportConfig& transport) const
{
// Create the client (and its consumers + transport replicas) on this
// LogosAPI's owner thread — the module's main/event-loop thread — even when
// called from a worker thread (e.g. an HTTP handler). Qt Remote Objects
// replicas only work on the thread that created them, so construction (and
// the cache it populates) must happen there. invokeRemoteMethod() then
// marshals calls back to the same thread. See logos_thread_marshal.h.
return logos::runOnOwnerThread(const_cast<LogosAPI*>(this),
[&]() -> LogosAPIClient* {
// Single cache, single construction path. Key composition mirrors
// the factory's resolution rule (see LogosAPIClientCacheKey in
// logos_api.h):
// - Mock/Local mode: every cfg collapses to one cache slot per
// target — switching cfg returns the same MockTransport-backed
// client instead of allocating a duplicate.
// - Remote mode: every distinguishing field of cfg matters, so
// two callers with different TLS/codec settings get separate
// clients (no risk of silently reusing an insecure transport).
//
// The capability_module transport — used by the client's
// auto-`requestModule` flow — falls back to the registered
// override (if any) or the global default. Two-arg getClient
// intentionally doesn't expose a second transport here; callers
// that care register the capability_module transport once via
// setCapabilityModuleTransport() and the rest is plumbing.
const LogosAPIClientCacheKey key{
target_module, LogosModeConfig::getMode(), transport};
auto it = m_clients.constFind(key);
if (it != m_clients.constEnd()) return it.value();
const LogosTransportConfig capabilityTransport =
m_capabilityModuleTransport.has_value()
? *m_capabilityModuleTransport
: LogosTransportConfigGlobal::getDefault();
LogosAPIClient* client = new LogosAPIClient(
target_module, m_module_name, m_token_manager,
transport, capabilityTransport,
const_cast<LogosAPI*>(this));
m_clients.insert(key, client);
return client;
});
}
TokenManager* LogosAPI::getTokenManager() const
{
return m_token_manager;
}
void LogosAPI::setCapabilityModuleTransport(const LogosTransportConfig& transport)
{
m_capabilityModuleTransport = transport;
}
bool LogosAPI::setProperty(const char* name, const std::string& value)
{
return QObject::setProperty(name, QVariant(QString::fromStdString(value)));
}
+271
View File
@@ -0,0 +1,271 @@
#ifndef LOGOS_API_H
#define LOGOS_API_H
#include "logos_mode.h"
#include "logos_shared_api.h"
#include "logos_transport_config.h"
#include "logos_types.h"
#include <QHash>
#include <QHashFunctions>
#include <QObject>
#include <QString>
#include <functional>
#include <optional>
#include <string>
class LogosAPIClient;
class LogosAPIProvider;
class TokenManager;
// qHash for LogosTransportConfig — combined with operator== from
// logos_transport_config.h, this lets QHash use it as a key. Lives here
// rather than in logos_transport_config.h so that header stays Qt-free
// (the SDK is being de-Qt'd; only Qt-using consumers like the cache
// here pull in the QHash adapter).
//
// Every field that distinguishes one explicit-transport client from
// another contributes to the hash; otherwise two callers with different
// TLS or codec settings could land on the same bucket and cache-alias
// onto a single client.
inline size_t qHash(const LogosTransportConfig& cfg, size_t seed = 0) noexcept
{
return qHashMulti(seed,
static_cast<int>(cfg.protocol),
std::hash<std::string>{}(cfg.host),
cfg.port,
std::hash<std::string>{}(cfg.caFile),
std::hash<std::string>{}(cfg.certFile),
std::hash<std::string>{}(cfg.keyFile),
cfg.verifyPeer,
static_cast<int>(cfg.codec));
}
// LogosAPIClient cache key. Mirrors the factory's transport-resolution
// rule so two callers that would observe the same connection share a
// cached client:
//
// - Mock / Local mode → transport is ignored at construction; key
// ignores it too. Switching mode changes the
// key (so cached clients don't bleed across
// mode switches in tests).
// - Remote mode → cfg picks the wire endpoint; key includes
// the full LogosTransportConfig.
//
// Without the mode-aware comparison, calling
// `getClient(x, tcp)` and `getClient(x, tcp_ssl)` in Mock mode would
// allocate two clients pointing at functionally identical
// MockTransportConnections.
struct LogosAPIClientCacheKey {
QString target;
LogosMode mode;
LogosTransportConfig transport; // only compared when mode == Remote
};
inline bool operator==(const LogosAPIClientCacheKey& a,
const LogosAPIClientCacheKey& b) noexcept
{
if (a.target != b.target) return false;
if (a.mode != b.mode) return false;
return a.mode == LogosMode::Remote ? a.transport == b.transport : true;
}
inline size_t qHash(const LogosAPIClientCacheKey& k, size_t seed = 0) noexcept
{
if (k.mode == LogosMode::Remote) {
return qHashMulti(seed, k.target, static_cast<int>(k.mode), k.transport);
}
// Mock / Local: transport is irrelevant — leave it out of the hash
// so it can't bias which bucket the key lands in.
return qHashMulti(seed, k.target, static_cast<int>(k.mode));
}
/**
* @brief LogosAPI provides a unified interface to the Logos SDK
*
* This class initializes and keeps instances of the client provider and token manager.
*
* LOGOS_SHARED_API because this is the object handed across the DLL boundary:
* the host constructs a LogosAPI inside liblogos_core and passes the pointer to
* the UI plugin through PluginInterface::logosAPI. Its constructor caches
* `&TokenManager::instance()`, so on PE a plugin that links its own copy of
* logos_api.cpp.obj caches a DIFFERENT singleton than the one the host wrote
* the token into. Importing instead of re-linking is what makes the two agree.
* See logos_shared_api.h in logos-protocol.
*/
class LOGOS_SHARED_API LogosAPI : public QObject
{
Q_OBJECT
public:
/**
* @brief Construct a new LogosAPI instance
* @param module_name The name of this module
* @param parent Parent QObject
*/
explicit LogosAPI(const QString& module_name, QObject *parent = nullptr);
/**
* @brief Construct a new LogosAPI with an explicit transport set.
*
* `transports` is empty ⇒ use the process-global default (back-compat).
* Non-empty ⇒ provider publishes on every configured transport
* (e.g. a daemon listing both LocalSocket and TCP+SSL so the CLI has
* a fast in-process path *and* remote clients have a secure path).
*/
LogosAPI(const QString& module_name,
LogosTransportSet transports,
QObject *parent = nullptr);
/**
* @brief Construct a new LogosAPI instance (const char* overload — resolves ambiguity)
*/
explicit LogosAPI(const char* module_name, QObject *parent = nullptr)
: LogosAPI(QString(module_name), parent) {}
/**
* @brief Construct with const char* and explicit transport set.
*/
LogosAPI(const char* module_name, LogosTransportSet transports, QObject *parent = nullptr)
: LogosAPI(QString(module_name), std::move(transports), parent) {}
/**
* @brief Construct a new LogosAPI instance (std::string overload)
*/
explicit LogosAPI(const std::string& module_name, QObject *parent = nullptr);
/**
* @brief Construct with std::string and explicit transport set.
*/
LogosAPI(const std::string& module_name, LogosTransportSet transports, QObject *parent = nullptr)
: LogosAPI(QString::fromStdString(module_name), std::move(transports), parent) {}
/**
* @brief Destructor
*/
~LogosAPI();
/**
* @brief The name of the module this LogosAPI belongs to.
*
* Every outbound call already carries it (LogosAPIClient's `origin`), it
* was just never readable from the outside. Generated consumer wrappers
* need it: a wrapper that reaches the transport through the lp C ABI must
* name its origin at client-creation time, and the only handle it is given
* is this object. Reading it here keeps the wrapper's constructor
* signature — `(LogosAPI*)` / `(LogosAPI*, const QString&)` — unchanged,
* so no call site moves.
*/
QString moduleName() const { return m_module_name; }
/**
* @brief Get the client provider instance
* @return LogosAPIProvider* Pointer to the provider
*/
LogosAPIProvider* getProvider() const;
/**
* @brief Get the client instance for communicating with a module
* @param target_module The module to communicate with
* @return LogosAPIClient* Pointer to the client
*/
LogosAPIClient* getClient(const QString& target_module) const;
/**
* @brief Get the client instance — const char* overload (resolves ambiguity)
*/
LogosAPIClient* getClient(const char* target_module) const
{ return getClient(QString(target_module)); }
/**
* @brief Get the client instance for communicating with a module (std::string overload)
*/
LogosAPIClient* getClient(const std::string& target_module) const;
/**
* @brief Get a client that uses an *explicit* transport instead of
* the process-global default.
*
* Use this when the caller needs to dial one module over a
* particular protocol without side-effecting the rest of the
* process. Canonical case: a CLI that talks only to `core_service`
* over tcp_ssl — using `LogosTransportConfigGlobal::setDefault` for
* that would also flip the same process's `LogosAPIProvider` into
* trying to bind a tcp_ssl server, which the CLI has no cert for.
*
* Cached per (target_module, full LogosTransportConfig) — repeat
* calls with the same target *and* the same transport return the
* same client. The cache key covers every config field that can
* distinguish two clients (protocol, host, port, codec, all TLS
* settings), via the operator== / qHash defined alongside
* LogosTransportConfig, so two callers with different TLS or codec
* settings always get separate clients — no risk of silently
* reusing an insecure connection where a secure one was asked for.
*/
LogosAPIClient* getClient(const QString& target_module,
const LogosTransportConfig& transport) const;
/**
* @brief Get the token manager instance
* @return TokenManager* Pointer to the token manager
*/
TokenManager* getTokenManager() const;
/**
* @brief Set the transport used by the SDK's auto-`requestModule`
* token-fetch flow (inside LogosAPIClient::invokeRemoteMethod{,Async}).
*
* That flow always dials `capability_module` to fetch a per-target
* token, regardless of which module is the actual call target.
* Without an explicit transport it falls through to
* LogosTransportConfigGlobal::getDefault() (LocalSocket), which
* times out 20 s when capability_module is reachable only on TCP
* (e.g. CLI on host, daemon in container).
*
* Callers that have read the daemon's per-module advertised
* transports (e.g. from logoscore's daemon.json) should register
* capability_module's transport here so getClient builds each
* LogosAPIClient with the right capability_consumer.
*
* The setting only affects clients constructed *after* this call
* — clients already in the cache keep whatever capability transport
* they were built with.
*/
void setCapabilityModuleTransport(const LogosTransportConfig& transport);
using QObject::setProperty;
/**
* @brief Set a dynamic property from a UTF-8 std::string (delegates to QVariant + QString).
*/
bool setProperty(const char* name, const std::string& value);
private:
QString m_module_name;
LogosAPIProvider* m_provider;
// Single cache for both getClient overloads. Keyed by a
// mode-aware composite (LogosAPIClientCacheKey above) so that:
// - Mock/Local mode buckets ignore transport (the factory does too)
// - Remote mode keys include the full LogosTransportConfig
// - the no-transport overload resolves to the same key as an
// explicit caller passing LogosTransportConfigGlobal::getDefault()
// - mode switches don't return stale clients from the previous mode
mutable QHash<LogosAPIClientCacheKey, LogosAPIClient*> m_clients;
TokenManager* m_token_manager;
// ABI note: this private layout is consumed by every plugin that
// statically links libsdk. Inserting a field above m_token_manager
// shifts its offset and SILENTLY breaks plugins compiled before
// the change — they read garbage where m_token_manager used to
// live, getClient() then constructs LogosAPIClients with a bogus
// TokenManager*, and the first cross-process call segfaults.
// Append new private members at the END only. (Long-term cure:
// pimpl this class so sizeof / offsets become opaque.)
//
// Optional override for the capability_module transport used by
// each LogosAPIClient's pre-built m_capability_consumer. Set via
// setCapabilityModuleTransport(). nullopt = use the global default.
std::optional<LogosTransportConfig> m_capabilityModuleTransport;
};
#endif // LOGOS_API_H
+306
View File
@@ -0,0 +1,306 @@
#include "logos_api_provider.h"
#include "logos_object.h"
#include "logos_provider_object.h"
#include "qt_provider_object.h"
#include "module_proxy.h"
#include "logos_api.h"
#include "logos_instance.h"
#include "logos_transport.h"
#include "logos_transport_factory.h"
#include "token_manager.h"
#include <QDebug>
#include <string>
LogosAPIProvider::LogosAPIProvider(const QString& module_name,
LogosTransportSet transports,
QObject *parent)
: QObject(parent)
, m_registryUrl(LogosInstance::id(module_name))
, m_moduleProxy(nullptr)
, m_qtProviderObject(nullptr)
{
// Helper: defer-construct one host and only retain it if the
// factory actually returned something. createHost() can return
// nullptr — e.g. PlainTransportHost::start() failure (TCP bind,
// SSL cert load) — and we don't want to leave a null entry that
// would crash the publish/unpublish paths later.
auto pushHost = [&](auto&& host, const char* label) {
if (host) {
m_transports.push_back(std::forward<decltype(host)>(host));
} else {
qWarning() << "LogosAPIProvider: createHost returned null"
<< "for" << module_name << label
<< "— transport disabled";
}
};
if (transports.empty()) {
// Back-compat: one host, chosen by the global mode + transport config.
pushHost(LogosTransportFactory::createHost(m_registryUrl), "(default)");
} else {
// One host per configured transport — lets a single provider serve
// its object on several endpoints simultaneously (local-socket +
// TCP, TCP + TCP+SSL, etc.).
for (const auto& cfg : transports)
pushHost(LogosTransportFactory::createHost(cfg, m_registryUrl), "(per-cfg)");
}
}
LogosAPIProvider::~LogosAPIProvider()
{
if (!m_registeredObjectName.isEmpty()) {
// Defensive: m_transports should never contain nullptr (the
// ctor filters them out via pushHost), but guard here too —
// a future code path that pushes directly without going
// through pushHost would otherwise crash on shutdown.
for (auto& t : m_transports) {
if (t) t->unpublishObject(m_registeredObjectName);
}
}
if (!m_registeredHandshakeName.isEmpty()) {
for (auto& t : m_transports) {
if (t) t->unpublishObject(m_registeredHandshakeName);
}
}
}
// QObject* path: auto-detects LogosProviderPlugin; falls back to QtProviderObject wrapper
bool LogosAPIProvider::registerObject(const QString& name, QObject* object)
{
if (!object) {
qWarning() << "LogosAPIProvider: Cannot register null object";
return false;
}
if (name.isEmpty()) {
qWarning() << "LogosAPIProvider: Cannot register object with empty name";
return false;
}
if (m_moduleProxy) {
qCritical() << "LogosAPIProvider: Object already registered. Only one registration per provider is allowed";
return false;
}
// Check if this plugin implements LogosProviderPlugin (new API)
LogosProviderPlugin* providerPlugin = qobject_cast<LogosProviderPlugin*>(object);
if (providerPlugin) {
qDebug() << "[LogosProviderObject] LogosAPIProvider: detected LogosProviderPlugin for" << name;
LogosProviderObject* provider = providerPlugin->createProviderObject();
if (provider) {
return registerObject(name, provider);
}
qWarning() << "LogosAPIProvider: createProviderObject() returned null for" << name;
}
// Legacy path: wrap QObject in QtProviderObject adapter
qDebug() << "[LogosProviderObject] LogosAPIProvider: wrapping QObject in QtProviderObject for" << name;
m_qtProviderObject = new QtProviderObject(object, this);
// Handshake surface before init, business object after — see the
// LogosProviderObject overload for why.
publishHandshake(name, m_qtProviderObject);
m_qtProviderObject->init(qobject_cast<LogosAPI*>(parent()));
return publishProvider(name, m_qtProviderObject);
}
bool LogosAPIProvider::registerObject(const std::string& name, QObject* object)
{
return registerObject(QString::fromStdString(name), object);
}
// New path: LogosProviderObject* -> ModuleProxy -> transport
bool LogosAPIProvider::registerObject(const QString& name, LogosProviderObject* provider)
{
if (!provider) {
qWarning() << "LogosAPIProvider: Cannot register null provider";
return false;
}
if (name.isEmpty()) {
qWarning() << "LogosAPIProvider: Cannot register provider with empty name";
return false;
}
if (m_moduleProxy) {
qCritical() << "LogosAPIProvider: Object already registered. Only one registration per provider is allowed";
return false;
}
qDebug() << "[LogosProviderObject] LogosAPIProvider: registering LogosProviderObject directly for" << name;
// Publish the handshake surface BEFORE the initializer runs, and the
// business object after it, as always. The initializer is synchronous and
// routinely calls out — including capability_module's requestModule, which
// capability answers by pushing a token back to this very module. With only
// the business object, that push was unsatisfiable: capability waited for a
// source that could not appear until the initializer returned, and the
// initializer could not return until capability answered.
//
// Publishing the token-only surface early breaks that circle without
// changing what callers of real methods see: they still block at acquire
// until the business object appears, exactly as before.
publishHandshake(name, provider);
provider->init(qobject_cast<LogosAPI*>(parent()));
return publishProvider(name, provider);
}
void LogosAPIProvider::setTokenValidator(TokenValidator validator)
{
// Store first, then forward the member — a single source of truth, so the
// proxy and the pending copy can't diverge if the validator carries state.
m_pendingValidator = std::move(validator);
if (m_moduleProxy) {
m_moduleProxy->setTokenValidator(m_pendingValidator);
}
}
void LogosAPIProvider::seedHandshakeTrustAnchor()
{
QObject* api = parent();
if (!api) {
return;
}
// The host publishes its token as a property on the LogosAPI object before
// calling registerObject (logos-module-loader-qt module_initializer.cpp).
// An older host that does not set it leaves the store empty — the handshake
// surface then refuses, and the consumer falls back to the business object
// exactly as it did before this surface existed.
const QString hostToken = api->property("authToken").toString();
if (hostToken.isEmpty()) {
qDebug() << "[LogosProviderObject] LogosAPIProvider: no authToken property"
<< "- handshake surface will refuse until the initializer seeds the"
<< "token store; consumers fall back to the business object";
return;
}
// Never overwrite an entry the module already holds: this runs before init(),
// so a non-empty value here came from somewhere with more context than us.
TokenManager& tokens = TokenManager::instance();
for (const QString& key : { QStringLiteral("core"), QStringLiteral("capability_module") }) {
if (tokens.getToken(key).isEmpty()) {
tokens.saveToken(key, hostToken);
}
}
}
void LogosAPIProvider::publishHandshake(const QString& name, LogosProviderObject* provider)
{
// The handshake proxy needs the ModuleProxy that will own the token store,
// so build that now; publishProvider() reuses it rather than making another.
if (!m_moduleProxy) {
m_moduleProxy = new ModuleProxy(provider, this);
if (m_pendingValidator) {
m_moduleProxy->setTokenValidator(m_pendingValidator);
}
}
// Seed the trust anchor BEFORE the surface goes live.
//
// ModuleProxy::informModuleToken only accepts a caller whose token matches
// TokenManager's "core" or "capability_module" entry. Those entries are
// written by the module's own initializer — the generated cdylib glue reads
// the host's `authToken` property and calls logos_module_accept_token("core")
// / ("capability_module") — and init() runs AFTER this point, between
// publishHandshake and publishProvider.
//
// That is precisely the window this surface exists to serve, so without this
// the store is empty for the whole window and every push that arrives is
// refused: the surface would be reachable and useless, and capability_module
// would report a failed grant. (Measured before this line existed: the
// rejection appeared in 29 of 34 runs and never in the pre-fix baseline.)
//
// This grants nothing new. It installs the same host-issued token the
// initializer installs moments later, just early enough to be usable.
seedHandshakeTrustAnchor();
m_handshakeProxy = new ModuleHandshakeProxy(m_moduleProxy, this);
const QString handshakeName = logos::handshakeObjectName(name);
bool published = false;
for (auto& t : m_transports) {
if (!t) continue;
if (t->publishObject(handshakeName, m_handshakeProxy)) published = true;
}
if (published) {
m_registeredHandshakeName = handshakeName;
qDebug() << "[LogosProviderObject] LogosAPIProvider: published handshake surface"
<< handshakeName << "- token delivery is reachable while" << name
<< "initializes";
} else {
// Not fatal: a transport that cannot carry the handshake surface just
// means capability_module falls back to the business object, which is
// exactly how things worked before this existed.
qDebug() << "[LogosProviderObject] LogosAPIProvider: no transport published"
<< handshakeName << "- capability_module will fall back to" << name;
}
}
bool LogosAPIProvider::publishProvider(const QString& name, LogosProviderObject* provider)
{
// publishHandshake() may already have created the proxy (it needs the token
// store to exist before the initializer runs); reuse it so the token a peer
// delivered early is the one the business object consults.
if (!m_moduleProxy) {
m_moduleProxy = new ModuleProxy(provider, this);
}
// Apply a validator installed before registration, before the proxy is
// published on any transport (so no call can slip in unvalidated).
if (m_pendingValidator) {
m_moduleProxy->setTokenValidator(m_pendingValidator);
}
// Publish on every configured transport. Success = any transport
// accepted the publish (follow-up: surface per-transport failures).
bool success = false;
for (auto& t : m_transports) {
if (!t) continue; // see ~LogosAPIProvider — defensive null-skip.
if (t->publishObject(name, m_moduleProxy)) success = true;
}
if (success) {
m_registeredObjectName = name;
qDebug() << "[LogosProviderObject] LogosAPIProvider: successfully published" << name;
} else {
qCritical() << "LogosAPIProvider: Failed to publish" << name;
}
return success;
}
QString LogosAPIProvider::registryUrl() const
{
return m_registryUrl;
}
bool LogosAPIProvider::saveToken(const QString& from_module_name, const QString& token)
{
if (!m_moduleProxy) {
qWarning() << "LogosAPIProvider: Cannot save token - no module proxy available";
return false;
}
qDebug() << "LogosAPIProvider: Delegating saveToken to module proxy for:" << from_module_name;
return m_moduleProxy->saveToken(from_module_name, token);
}
void LogosAPIProvider::onEventResponse(LogosObject* object, const QString& eventName, const QVariantList& data)
{
qDebug() << "[LogosObject] LogosAPIProvider::onEventResponse" << eventName << "-> LogosObject::emitEvent";
if (eventName.isEmpty()) {
qWarning() << "LogosAPIProvider: Event name cannot be empty";
return;
}
if (!object) {
qWarning() << "LogosAPIProvider: Cannot emit event on null object";
return;
}
object->emitEvent(eventName, data);
}
+123
View File
@@ -0,0 +1,123 @@
#ifndef LOGOS_API_PROVIDER_H
#define LOGOS_API_PROVIDER_H
#include "logos_transport_config.h"
#include <QObject>
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QMap>
#include <functional>
#include <memory>
#include <string>
class LogosTransportHost;
class LogosObject;
class ModuleProxy;
class ModuleHandshakeProxy;
class LogosProviderObject;
class QtProviderObject;
/**
* @brief LogosAPIProvider handles registering objects for access by consumers
*
* Supports two registration paths:
* 1. registerObject(name, QObject*) — wraps in QtProviderObject, then ModuleProxy
* 2. registerObject(name, LogosProviderObject*) — wraps directly in ModuleProxy
* Both paths converge at ModuleProxy -> transport.
*/
class LogosAPIProvider : public QObject
{
Q_OBJECT
public:
/**
* @param module_name The module this provider belongs to.
* @param transports Optional per-instance transport override. When empty,
* the provider uses the process-global default. This
* is what lets a daemon expose `core_service` on
* TCP/TLS while keeping module-to-module traffic on
* the local-socket default.
*/
explicit LogosAPIProvider(const QString& module_name,
LogosTransportSet transports = {},
QObject *parent = nullptr);
// Back-compat overload
LogosAPIProvider(const QString& module_name, QObject *parent)
: LogosAPIProvider(module_name, LogosTransportSet{}, parent) {}
~LogosAPIProvider();
/**
* @brief Register a legacy QObject-based plugin.
* Wraps in QtProviderObject, then ModuleProxy.
*/
bool registerObject(const QString& name, QObject* object);
/**
* @brief Register a legacy QObject-based plugin — const char* overload (resolves ambiguity)
*/
bool registerObject(const char* name, QObject* object)
{ return registerObject(QString(name), object); }
/**
* @brief Register a legacy QObject-based plugin (std::string overload).
*/
bool registerObject(const std::string& name, QObject* object);
/**
* @brief Register a new-API LogosProviderObject plugin.
* Wraps directly in ModuleProxy.
*/
bool registerObject(const QString& name, LogosProviderObject* provider);
QString registryUrl() const;
bool saveToken(const QString& from_module_name, const QString& token);
// Install an extra token authorizer, forwarded to the ModuleProxy. Consulted
// in addition to the built-in issued-token scan, with the call's transport
// ("local" | "tcp" | "tcp_ssl") so local_only tokens can be enforced. The
// daemon backs this with TokenStore::lookupByToken so operator-issued named
// tokens authorize. Safe to call before or after registerObject(); a
// validator set before registration is applied when the proxy is created.
using TokenValidator = std::function<bool(const QString& token,
const QString& transportProtocol)>;
void setTokenValidator(TokenValidator validator);
public slots:
void onEventResponse(LogosObject* object, const QString& eventName, const QVariantList& data);
private:
bool publishProvider(const QString& name, LogosProviderObject* provider);
// Publishes the token-only handshake surface (logos::handshakeObjectName)
// before the module's initializer runs, so capability_module can deliver a
// token to a module that is still starting up. Best-effort: a transport that
// declines it simply leaves capability_module falling back to the business
// object, which is the pre-existing behaviour.
void publishHandshake(const QString& name, LogosProviderObject* provider);
// Installs the host-issued token as this module's "core"/"capability_module"
// trust anchor before the handshake surface is published. Without it the
// surface is reachable but refuses every push for the whole pre-init window
// it exists to cover, because the initializer that normally seeds those
// entries has not run yet. Never overwrites an existing entry, and is a no-op
// on a host that does not publish an `authToken` property.
void seedHandshakeTrustAnchor();
// One host per configured transport. Single-entry vector for back-compat.
std::vector<std::unique_ptr<LogosTransportHost>> m_transports;
QString m_registryUrl;
QMap<QString, QString> m_tokens;
ModuleProxy* m_moduleProxy;
ModuleHandshakeProxy* m_handshakeProxy = nullptr;
QString m_registeredHandshakeName;
QtProviderObject* m_qtProviderObject;
QString m_registeredObjectName;
// Appended (never inserted mid-list): keeps every pre-existing member's
// offset identical between builds, so a LogosAPIProvider is layout-safe even
// if a future co-located consumer ever accessed one across a version
// boundary. Held until the proxy exists (registerObject may come after the
// setter).
TokenValidator m_pendingValidator;
};
#endif // LOGOS_API_PROVIDER_H
+48
View File
@@ -0,0 +1,48 @@
#include "logos_provider_object.h"
#include "logos_api.h"
#include "token_manager.h"
#include <QDebug>
// The LogosProviderObject universal-interface defaults and Std bridges moved
// to logos-protocol (logos_provider_interface.cpp) together with the abstract
// interface. What remains here is LogosProviderBase — the developer-facing
// base class — because it talks to LogosAPI, which layers above the protocol.
// ---------------------------------------------------------------------------
// LogosProviderBase
// ---------------------------------------------------------------------------
void LogosProviderBase::init(void* apiInstance)
{
m_logosAPI = static_cast<LogosAPI*>(apiInstance);
qDebug() << "[LogosProviderObject] LogosProviderBase::init called";
onInit(m_logosAPI);
}
bool LogosProviderBase::informModuleToken(const QString& moduleName, const QString& token)
{
if (!m_logosAPI) {
qWarning() << "[LogosProviderObject] informModuleToken: LogosAPI not available";
return false;
}
TokenManager* tokenManager = m_logosAPI->getTokenManager();
if (!tokenManager) {
qWarning() << "[LogosProviderObject] informModuleToken: TokenManager not available";
return false;
}
qDebug() << "[LogosProviderObject] Saving token for module:" << moduleName;
tokenManager->saveToken(moduleName, token);
return true;
}
void LogosProviderBase::emitEvent(const QString& eventName, const QVariantList& data)
{
if (m_eventCallback) {
qDebug() << "[LogosProviderObject] emitEvent:" << eventName;
m_eventCallback(eventName, data);
} else {
qWarning() << "[LogosProviderObject] emitEvent: no listener set for" << eventName;
}
}
+77
View File
@@ -0,0 +1,77 @@
#ifndef LOGOS_PROVIDER_OBJECT_H
#define LOGOS_PROVIDER_OBJECT_H
#include <QString>
#include <QVariant>
#include <QVariantList>
#include <QJsonArray>
#include <nlohmann/json.hpp>
#include <functional>
#include <string>
#include <vector>
#include "logos_json_convert.h"
// The abstract LogosProviderObject interface (the provider-side counterpart
// of LogosObject, wrapped by ModuleProxy) moved to logos-protocol with the
// transport layer — see logos_provider_interface.h. This header keeps its
// historical name and continues to carry the developer-facing pieces:
// LogosProviderBase (which hands a LogosAPI* to module code, hence it lives
// here above the protocol layer), LogosProviderPlugin, and the
// LOGOS_PROVIDER / LOGOS_METHOD macros.
#include "logos_provider_interface.h"
class LogosAPI;
// ---------------------------------------------------------------------------
// LogosProviderBase — convenience base class for new-API modules
//
// Handles framework plumbing so the developer only writes business logic.
// callMethod() and getMethods() are provided by generated code produced
// by logos-cpp-generator --provider-header (analogous to Qt MOC).
// ---------------------------------------------------------------------------
class LogosProviderBase : public LogosProviderObject {
public:
// These two are implemented by generated code (logos_provider_dispatch.cpp):
// QVariant callMethod(const QString& methodName, const QVariantList& args) override;
// QJsonArray getMethods() override;
void setEventListener(EventCallback callback) override { m_eventCallback = callback; }
bool informModuleToken(const QString& moduleName, const QString& token) override;
void init(void* apiInstance) override;
protected:
void emitEvent(const QString& eventName, const QVariantList& data);
virtual void onInit(LogosAPI* api) {}
LogosAPI* logosAPI() const { return m_logosAPI; }
private:
EventCallback m_eventCallback;
LogosAPI* m_logosAPI = nullptr;
};
// LogosProviderPlugin (the plugin-detection interface) now lives in
// logos-protocol's logos_provider_interface.h, included above — it stays
// visible to existing includers of this header.
// ---------------------------------------------------------------------------
// Macros — the developer-facing API
// ---------------------------------------------------------------------------
// LOGOS_PROVIDER: declares providerName/providerVersion and a private typedef.
// Place at the top of the class body (like Q_OBJECT).
#define LOGOS_PROVIDER(ClassName, Name, Version) \
public: \
QString providerName() const override { return Name; } \
QString providerVersion() const override { return Version; } \
QVariant callMethod(const QString& methodName, const QVariantList& args) override; \
QJsonArray getMethods() override; \
private: \
using _LogosProviderThisType = ClassName;
// LOGOS_METHOD: marks a method as callable by the framework.
// Expands to nothing — scanned by logos-cpp-generator to produce
// callMethod() dispatch and getMethods() metadata (like Q_INVOKABLE + MOC).
#define LOGOS_METHOD
#endif // LOGOS_PROVIDER_OBJECT_H
+75
View File
@@ -0,0 +1,75 @@
#include "logos_qt_arg_decode.h"
namespace logos {
namespace {
// One case per QMetaType the Qt type mapping can produce for a parameter.
// Each hands off to the SAME QtArgCodec<T> the generated dispatch uses, so the
// two provider sites cannot answer differently for the same argument.
template <class T>
QtArgVerdict decodeAs(const QVariant& in, const std::string& path, QVariant& out,
std::string& error)
{
try {
out = QVariant::fromValue(detail::QtArgCodec<T>::from(in, path));
return QtArgVerdict::Ok;
} catch (const CodecError& e) {
error = e.what();
return QtArgVerdict::Rejected;
}
}
} // namespace
QtArgVerdict qtArgDecode(const QVariant& in, QMetaType paramType,
const std::string& path, QVariant& out,
std::string& error)
{
switch (paramType.id()) {
case QMetaType::Bool: return decodeAs<bool>(in, path, out, error);
case QMetaType::Int: return decodeAs<int>(in, path, out, error);
case QMetaType::UInt: return decodeAs<unsigned int>(in, path, out, error);
case QMetaType::Short: return decodeAs<short>(in, path, out, error);
case QMetaType::UShort: return decodeAs<unsigned short>(in, path, out, error);
case QMetaType::Long: return decodeAs<long>(in, path, out, error);
case QMetaType::ULong: return decodeAs<unsigned long>(in, path, out, error);
case QMetaType::LongLong: return decodeAs<qlonglong>(in, path, out, error);
case QMetaType::ULongLong: return decodeAs<qulonglong>(in, path, out, error);
case QMetaType::Double: return decodeAs<double>(in, path, out, error);
case QMetaType::Float: return decodeAs<float>(in, path, out, error);
case QMetaType::QString: return decodeAs<QString>(in, path, out, error);
case QMetaType::QByteArray: return decodeAs<QByteArray>(in, path, out, error);
case QMetaType::QStringList: return decodeAs<QStringList>(in, path, out, error);
case QMetaType::QVariantList: return decodeAs<QVariantList>(in, path, out, error);
case QMetaType::QVariantMap: return decodeAs<QVariantMap>(in, path, out, error);
case QMetaType::QJsonArray: return decodeAs<QJsonArray>(in, path, out, error);
case QMetaType::QJsonObject: return decodeAs<QJsonObject>(in, path, out, error);
// `any`, QUrl, QChar, enums, pointers, LogosResult, anything a module
// author invented: no LIDL counterpart, so no rule to check against. The
// caller keeps whatever it did before — see the header comment.
default:
return QtArgVerdict::Unchecked;
}
}
nlohmann::json dispatchFailedJson(const std::string& origin,
const std::string& message)
{
return nlohmann::json{{"code", "dispatch_failed"},
{"message", message},
{"origin", origin}};
}
QVariant dispatchFailedVariant(const QString& origin, const QString& message)
{
QVariantMap m;
m.insert(QStringLiteral("code"), QStringLiteral("dispatch_failed"));
m.insert(QStringLiteral("message"), message);
m.insert(QStringLiteral("origin"), origin);
return m;
}
} // namespace logos
+233
View File
@@ -0,0 +1,233 @@
#ifndef LOGOS_QT_ARG_DECODE_H
#define LOGOS_QT_ARG_DECODE_H
// ---------------------------------------------------------------------------
// logos_qt_arg_decode.h — decoding an incoming RPC argument into the Qt type a
// provider method DECLARES. The Qt face of logos::fromJson<T>.
//
// WHY THIS EXISTS. A Qt-typed provider used to hand every incoming argument to
// a Qt conversion — `args.at(0).toULongLong()` in the generated dispatch,
// `QVariant::convert(paramType)` in the QMetaObject one. Both COERCE rather
// than check, so a hostile value never reached the author's method body wearing
// the shape it arrived in:
//
// echoUint(-1) ran the body with 18446744073709551615
// echoInt(3.7) ran it with 4
// echoBool(1) ran it with true
// echoMap(5) ran it with {}
// stringLength(42) ran it with "42"
//
// Every non-Qt surface — the generated cdylib dispatch, the Rust provider, the
// plain wire — decodes the same argument through logos::fromJson<T> and answers
// {"code":"dispatch_failed", ...} instead. That asymmetry meant argument
// validation was not something a Qt module could rely on: the author's own
// precondition checks never saw the value the caller actually sent.
//
// THE RULE IS THE CODEC'S. Nothing here re-derives what a legal value is. The
// argument is encoded to canonical JSON with the same qvariantToNlohmann every
// other Qt hop uses and handed to logos::fromJson<T>. The codec already owns
// the nuance a hand-written check gets wrong — a WHOLE-VALUED float is a legal
// integer (JSON does not distinguish 3 from 3.0, and an argument-typing CLI
// produces 3.0 for "3.0") while 3.7 is refused; `bstr` keeps its documented
// lenient form. A stricter check written here would diverge the moment the
// codec learned something new, which is the failure this whole layer exists to
// prevent.
//
// WHAT IS NOT CHECKED, AND WHY
// * `any` (QVariant) declares nothing, so there is nothing to check it
// against. The value is handed on EXACTLY as it arrived — not round-tripped
// through JSON, which would reinterpret a one-key `_bytes` map as bytes.
// * LogosList / LogosMap (QVariantList / QVariantMap) are shape-checked
// (array-ness / object-ness) and then handed on unchanged. Element types
// are erased by the Qt spelling itself: `[uint]` and `[any]` are both
// QVariantList, so array-ness is the whole of the declared type at this
// layer. Rebuilding the payload from JSON instead of passing it through
// would retype nested elements (an int element would arrive as qlonglong)
// for no validation gain.
// * Types with no LIDL counterpart (QUrl, enums, pointers, LogosResult as a
// parameter) are left to the caller's existing conversion. The codec has no
// rule for them and inventing one would reject arguments that work today.
// ---------------------------------------------------------------------------
#include <QByteArray>
#include <QJsonArray>
#include <QJsonObject>
#include <QMetaType>
#include <QString>
#include <QStringList>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
#include <nlohmann/json.hpp>
#include <cstdint>
#include <map>
#include <string>
#include <type_traits>
#include <vector>
#include "logos_codec.h"
#include "logos_json_convert.h"
#include "logos_types.h" // LogosResult
namespace logos {
namespace detail {
// QtArgCodec<T>::from(v, path) -> T, throwing logos::CodecError on a mismatch.
// An unsupported T leaves the trait incomplete, so a generator emitting a type
// nobody taught this file about fails to COMPILE naming the type — never
// silently falls back to a coercion.
template <class T, class Enable = void> struct QtArgCodec;
// bool / int / uint / qlonglong / qulonglong / double / float — straight
// through to the canonical codec, which owns signedness, range and the
// whole-valued-float rule. `int` is checked against int32, not widened: a
// method declaring `int` and handed 4294967296 used to run with 0.
template <class T>
struct QtArgCodec<T, std::enable_if_t<std::is_arithmetic_v<T>>> {
static T from(const QVariant& v, const std::string& path)
{
return logos::fromJson<T>(qvariantToNlohmann(v), path);
}
};
template <> struct QtArgCodec<QString, void> {
static QString from(const QVariant& v, const std::string& path)
{
return QString::fromStdString(
logos::fromJson<std::string>(qvariantToNlohmann(v), path));
}
};
// bstr — Codec<std::vector<uint8_t>> is the lenient decoder by design (a Qt
// consumer passing a QString and a CLI typing its arguments both produce a
// plain string for a byte parameter), so that leniency is inherited here rather
// than re-decided.
template <> struct QtArgCodec<QByteArray, void> {
static QByteArray from(const QVariant& v, const std::string& path)
{
const std::vector<uint8_t> bytes =
logos::fromJson<std::vector<uint8_t>>(qvariantToNlohmann(v), path);
return QByteArray(reinterpret_cast<const char*>(bytes.data()),
static_cast<qsizetype>(bytes.size()));
}
};
// [tstr] — the one container whose element type the Qt spelling preserves, so
// it is the one container whose elements are checked.
template <> struct QtArgCodec<QStringList, void> {
static QStringList from(const QVariant& v, const std::string& path)
{
const std::vector<std::string> items =
logos::fromJson<std::vector<std::string>>(qvariantToNlohmann(v), path);
QStringList out;
out.reserve(static_cast<qsizetype>(items.size()));
for (const std::string& s : items) out.append(QString::fromStdString(s));
return out;
}
};
// LogosList — shape only (see the header comment). Decoding into
// vector<nlohmann::json> is what produces the array-ness check AND the codec's
// own diagnostic ("expected array at arg0, got string"); the value handed on is
// the caller's, untouched.
template <> struct QtArgCodec<QVariantList, void> {
static QVariantList from(const QVariant& v, const std::string& path)
{
logos::fromJson<std::vector<nlohmann::json>>(qvariantToNlohmann(v), path);
return v.toList();
}
};
template <> struct QtArgCodec<QVariantMap, void> {
static QVariantMap from(const QVariant& v, const std::string& path)
{
logos::fromJson<std::map<std::string, nlohmann::json>>(
qvariantToNlohmann(v), path);
return v.toMap();
}
};
template <> struct QtArgCodec<QJsonArray, void> {
static QJsonArray from(const QVariant& v, const std::string& path)
{
logos::fromJson<std::vector<nlohmann::json>>(qvariantToNlohmann(v), path);
return qvariant_cast<QJsonArray>(v);
}
};
template <> struct QtArgCodec<QJsonObject, void> {
static QJsonObject from(const QVariant& v, const std::string& path)
{
logos::fromJson<std::map<std::string, nlohmann::json>>(
qvariantToNlohmann(v), path);
return qvariant_cast<QJsonObject>(v);
}
};
// `any` — verbatim, deliberately. See the header comment.
template <> struct QtArgCodec<QVariant, void> {
static QVariant from(const QVariant& v, const std::string&) { return v; }
};
// No LIDL counterpart as a PARAMETER; kept at today's behaviour so a provider
// declaring one still compiles and still behaves the way it did.
template <> struct QtArgCodec<LogosResult, void> {
static LogosResult from(const QVariant& v, const std::string&)
{
return v.value<LogosResult>();
}
};
} // namespace detail
// Compile-time entry point — what a generator emits, one call per parameter,
// with the type the author WROTE. Throws logos::CodecError naming the path.
template <class T>
T qtArgFromVariant(const QVariant& v, const std::string& path)
{
return detail::QtArgCodec<std::decay_t<T>>::from(v, path);
}
// `[Elem]` for a caller that still KNOWS the element type.
//
// A C++ signature spells every typed numeric array QVariantList, so neither the
// QMetaObject dispatch nor a generator scanning a C++ header can check the
// elements — array-ness is all they have. A LIDL-driven generator does have the
// element type, and this is where it spends it. The value handed on is still
// the caller's list, for the reason given in the header comment.
template <class Elem>
QVariantList qtArgListOf(const QVariant& v, const std::string& path)
{
logos::fromJson<std::vector<Elem>>(qvariantToNlohmann(v), path);
return v.toList();
}
// Runtime entry point — for the QMetaObject dispatch, which knows a parameter
// only as a QMetaType.
enum class QtArgVerdict {
Ok, // `out` holds a value of exactly `paramType`
Rejected, // `error` holds the codec's diagnostic
Unchecked, // no LIDL counterpart; the caller keeps its own conversion
};
QtArgVerdict qtArgDecode(const QVariant& in, QMetaType paramType,
const std::string& path, QVariant& out,
std::string& error);
// The canonical rejection value: the same {code, message, origin} object the
// generated cdylib dispatch and the Rust provider return, so a rejected call
// reads identically whichever provider produced it.
nlohmann::json dispatchFailedJson(const std::string& origin,
const std::string& message);
// The same object as a QVariantMap, for a provider whose dispatch returns
// QVariant. Round-trips faithfully on every transport (qt_local pass-through,
// QtRO serialization, and the plain wire's map<->JSON).
QVariant dispatchFailedVariant(const QString& origin, const QString& message);
} // namespace logos
#endif // LOGOS_QT_ARG_DECODE_H
+544
View File
@@ -0,0 +1,544 @@
#include "qt_provider_object.h"
#include "../core/interface.h"
#include "logos_api.h"
#include "token_manager.h"
#include "logos_types.h"
#include "logos_qt_arg_decode.h"
#include <QDebug>
#include <QMetaObject>
#include <QMetaMethod>
#include <QMetaType>
#include <QJsonArray>
#include <QJsonObject>
#include <QStringList>
#include <QUrl>
// ── QMetaObject dispatch helpers (moved from module_proxy.cpp) ──────────────
#define INVOKE_METHOD_WITH_RETURN(returnType, castType) \
do { \
castType* result = static_cast<castType*>(returnValue); \
switch (args.size()) { \
case 0: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result)); \
case 1: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result), scopedArgs[0].arg); \
case 2: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result), scopedArgs[0].arg, scopedArgs[1].arg); \
case 3: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result), scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg); \
case 4: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result), scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg, scopedArgs[3].arg); \
case 5: \
return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, Q_RETURN_ARG(returnType, *result), scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg, scopedArgs[3].arg, scopedArgs[4].arg); \
default: \
qWarning() << "QtProviderObject: Currently supports 0-5 arguments. Got:" << args.size(); \
return false; \
} \
} while(0)
namespace {
class ScopedQArg {
public:
ScopedQArg(QMetaMethodArgument a, std::function<void(const void*)> d)
: arg(a), deleter(std::move(d)) {}
~ScopedQArg() {
if (deleter) {
deleter(arg.data);
}
}
ScopedQArg(ScopedQArg&& other)
: arg(std::move(other.arg)), deleter(std::move(other.deleter)) {
other.deleter = nullptr;
}
ScopedQArg& operator=(ScopedQArg&&) = delete;
ScopedQArg(const ScopedQArg&) = delete;
ScopedQArg& operator=(const ScopedQArg&) = delete;
QMetaMethodArgument arg;
private:
std::function<void(const void*)> deleter;
};
// Every `new T(...)` below is a copy-construction from a value that is
// ALREADY a T, and every one of them uses parentheses deliberately.
//
// With braces, `new QVariantList{arg.toList()}` is list-initialization of a
// QList<QVariant> from a single QVariantList — and because QVariantList is
// implicitly convertible to QVariant, QList's initializer_list<QVariant>
// constructor is also viable. Which one wins is exactly the question CWG
// 2137 covers, and the compilers answer it differently: Clang picks the
// copy constructor, GCC picks the initializer-list one and WRAPS the value,
// so a two-element [1,2] reached the method body as [[1,2]], size 1. That
// is why this only ever failed on Linux CI.
//
// Parentheses cannot select an initializer-list constructor, so they mean
// the same thing on both compilers. QVariantList was the only type here the
// divergence could reach — QStringList and QVariantMap are safe only
// because QString and std::pair are not constructible from their own
// container — which is precisely why the rule is applied uniformly rather
// than to the one case that bit.
auto toScopedQArgs(const QVariantList& args)
{
auto scopedArgs = std::vector<ScopedQArg>{};
for (const auto& arg : args) {
switch (arg.typeId()) {
case QMetaType::Int: {
auto value = new int(arg.toInt());
scopedArgs.emplace_back(
Q_ARG(int, *value),
[](const void* data) { delete static_cast<const int*>(data); }
);
break;
}
case QMetaType::LongLong: {
auto value = new qlonglong(arg.toLongLong());
scopedArgs.emplace_back(
Q_ARG(qlonglong, *value),
[](const void* data) { delete static_cast<const qlonglong*>(data); }
);
break;
}
case QMetaType::ULongLong: {
auto value = new qulonglong(arg.toULongLong());
scopedArgs.emplace_back(
Q_ARG(qulonglong, *value),
[](const void* data) { delete static_cast<const qulonglong*>(data); }
);
break;
}
// A typed-numeric array ([int]/[uint]/[float64]/[bool]) maps to
// QVariantList (only [tstr] maps to QStringList). Without this
// case such an arg fell to the QString default below, stringified
// to "" and failed the typed invokeMethod → an EMPTY list arrived.
case QMetaType::QVariantList: {
auto value = new QVariantList(arg.toList());
scopedArgs.emplace_back(
Q_ARG(QVariantList, *value),
[](const void* data) { delete static_cast<const QVariantList*>(data); }
);
break;
}
case QMetaType::QVariantMap: {
auto value = new QVariantMap(arg.toMap());
scopedArgs.emplace_back(
Q_ARG(QVariantMap, *value),
[](const void* data) { delete static_cast<const QVariantMap*>(data); }
);
break;
}
case QMetaType::QStringList: {
auto value = new QStringList(arg.toStringList());
scopedArgs.emplace_back(
Q_ARG(QStringList, *value),
[](const void* data) { delete static_cast<const QStringList*>(data); }
);
break;
}
case QMetaType::QByteArray: {
auto value = new QByteArray(arg.toByteArray());
scopedArgs.emplace_back(
Q_ARG(QByteArray, *value),
[](const void* data) { delete static_cast<const QByteArray*>(data); }
);
break;
}
case QMetaType::QUrl: {
auto value = new QUrl(arg.toUrl());
scopedArgs.emplace_back(
Q_ARG(QUrl, *value),
[](const void* data) { delete static_cast<const QUrl*>(data); }
);
break;
}
case QMetaType::Bool: {
auto value = new bool(arg.toBool());
scopedArgs.emplace_back(
Q_ARG(bool, *value),
[](const void* data) { delete static_cast<const bool*>(data); }
);
break;
}
case QMetaType::Double: {
auto value = new double(arg.toDouble());
scopedArgs.emplace_back(
Q_ARG(double, *value),
[](const void* data) { delete static_cast<const double*>(data); }
);
break;
}
case QMetaType::Float: {
auto value = new float(arg.toFloat());
scopedArgs.emplace_back(
Q_ARG(float, *value),
[](const void* data) { delete static_cast<const float*>(data); }
);
break;
}
case QMetaType::QString:
default: {
auto value = new QString(arg.toString());
scopedArgs.emplace_back(
Q_ARG(QString, *value),
[](const void* data) { delete static_cast<const QString*>(data); }
);
break;
}
}
}
return scopedArgs;
}
bool invokeMethodByArgCount(QObject *module, const QString& methodName, const QVariantList& args, void* returnValue, const char* returnTypeName)
{
QByteArray methodNameBytes = methodName.toUtf8();
const char* methodNameCStr = methodNameBytes.constData();
auto scopedArgs = toScopedQArgs(args);
if (returnValue == nullptr) {
switch (args.size()) {
case 0: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection);
case 1: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, scopedArgs[0].arg);
case 2: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, scopedArgs[0].arg, scopedArgs[1].arg);
case 3: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg);
case 4: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg, scopedArgs[3].arg);
case 5: return QMetaObject::invokeMethod(module, methodNameCStr, Qt::DirectConnection, scopedArgs[0].arg, scopedArgs[1].arg, scopedArgs[2].arg, scopedArgs[3].arg, scopedArgs[4].arg);
default:
qWarning() << "QtProviderObject: Currently supports 0-5 arguments. Got:" << args.size();
return false;
}
} else if (strcmp(returnTypeName, "bool") == 0) {
INVOKE_METHOD_WITH_RETURN(bool, bool);
} else if (strcmp(returnTypeName, "int") == 0) {
INVOKE_METHOD_WITH_RETURN(int, int);
} else if (strcmp(returnTypeName, "double") == 0) {
INVOKE_METHOD_WITH_RETURN(double, double);
} else if (strcmp(returnTypeName, "float") == 0) {
INVOKE_METHOD_WITH_RETURN(float, float);
} else if (strcmp(returnTypeName, "QString") == 0) {
INVOKE_METHOD_WITH_RETURN(QString, QString);
} else if (strcmp(returnTypeName, "LogosResult") == 0) {
INVOKE_METHOD_WITH_RETURN(LogosResult, LogosResult);
} else if (strcmp(returnTypeName, "QVariant") == 0) {
INVOKE_METHOD_WITH_RETURN(QVariant, QVariant);
} else if (strcmp(returnTypeName, "QJsonArray") == 0) {
INVOKE_METHOD_WITH_RETURN(QJsonArray, QJsonArray);
} else if (strcmp(returnTypeName, "QVariantList") == 0) {
INVOKE_METHOD_WITH_RETURN(QVariantList, QVariantList);
} else if (strcmp(returnTypeName, "QVariantMap") == 0) {
INVOKE_METHOD_WITH_RETURN(QVariantMap, QVariantMap);
} else if (strcmp(returnTypeName, "QStringList") == 0) {
INVOKE_METHOD_WITH_RETURN(QStringList, QStringList);
} else {
qWarning() << "QtProviderObject: Unsupported return type:" << returnTypeName;
return false;
}
}
}
// ── QtProviderObject implementation ─────────────────────────────────────────
QtProviderObject::QtProviderObject(QObject* module, QObject* parent)
: QObject(parent)
, m_module(module)
{
if (m_module) {
connect(m_module, SIGNAL(eventResponse(QString, QVariantList)),
this, SLOT(onWrappedEventResponse(QString, QVariantList)));
qDebug() << "[LogosProviderObject] QtProviderObject: connected to QObject eventResponse signal";
}
}
QtProviderObject::~QtProviderObject()
{
qDebug() << "[LogosProviderObject] QtProviderObject: destroyed";
}
void QtProviderObject::onWrappedEventResponse(const QString& eventName, const QVariantList& data)
{
if (m_eventCallback) {
m_eventCallback(eventName, data);
}
}
void QtProviderObject::init(void* apiInstance)
{
if (!m_module) return;
LogosAPI* api = static_cast<LogosAPI*>(apiInstance);
int methodIndex = m_module->metaObject()->indexOfMethod("initLogos(LogosAPI*)");
if (methodIndex != -1) {
qDebug() << "[LogosProviderObject] QtProviderObject: calling initLogos on wrapped QObject";
QMetaObject::invokeMethod(m_module, "initLogos",
Qt::DirectConnection,
Q_ARG(LogosAPI*, api));
} else {
qDebug() << "[LogosProviderObject] QtProviderObject: wrapped QObject has no initLogos, skipping";
}
}
QString QtProviderObject::providerName() const
{
PluginInterface* pi = qobject_cast<PluginInterface*>(m_module);
return pi ? pi->name() : QString();
}
QString QtProviderObject::providerVersion() const
{
PluginInterface* pi = qobject_cast<PluginInterface*>(m_module);
return pi ? pi->version() : QString();
}
void QtProviderObject::setEventListener(EventCallback callback)
{
m_eventCallback = std::move(callback);
}
QVariant QtProviderObject::callMethod(const QString& methodName, const QVariantList& args)
{
if (!m_module) {
qWarning() << "[LogosProviderObject] QtProviderObject::callMethod: null module";
return QVariant();
}
if (methodName.isEmpty()) {
qWarning() << "[LogosProviderObject] QtProviderObject::callMethod: empty method name";
return QVariant();
}
// Special-case getPluginMethods (framework-level, not on the wrapped plugin)
if (methodName == "getPluginMethods" && args.isEmpty()) {
return QVariant(getMethods());
}
// Special-case getPluginEvents / getPluginInterface. Legacy Qt modules have
// no logos_events: section, so getMethods() (built here from QMetaObject)
// only ever contains methods: events are always empty and the interface is
// just the methods list.
if (methodName == "getPluginEvents" && args.isEmpty()) {
return QVariant(QJsonArray());
}
if (methodName == "getPluginInterface" && args.isEmpty()) {
return QVariant(getMethods());
}
// No auth check here by design: this adapter has no token parameter and is
// only ever reached through ModuleProxy::callRemoteMethod, which authorizes
// the caller's token before dispatching (see ModuleProxy::isAuthorized).
// The checks below are sanity guards on the wrapped plugin, not authz.
PluginInterface* pluginInterface = qobject_cast<PluginInterface*>(m_module);
if (!pluginInterface) {
qWarning() << "[LogosProviderObject] QtProviderObject::callMethod: module is not a PluginInterface";
return QVariant();
}
LogosAPI* api = pluginInterface->logosAPI;
if (!api) {
qWarning() << "[LogosProviderObject] QtProviderObject::callMethod: LogosAPI not available";
return QVariant();
}
// Find method via QMetaObject
const QMetaObject* metaObject = m_module->metaObject();
int methodIndex = -1;
for (int i = 0; i < metaObject->methodCount(); ++i) {
QMetaMethod method = metaObject->method(i);
if (method.name() == methodName && method.parameterCount() == args.size()) {
methodIndex = i;
break;
}
}
if (methodIndex == -1) {
qWarning() << "[LogosProviderObject] QtProviderObject: method not found:" << methodName
<< "with" << args.size() << "arguments";
return QVariant();
}
QMetaMethod method = metaObject->method(methodIndex);
QMetaType returnType = method.returnMetaType();
// Decode args against the types the method actually declares.
//
// This used to be a bare QVariant::convert(paramType), which COERCES: an
// argument the declared type cannot represent silently became one it can
// (echoInt(3.7) ran the body with 4; echoBool("hello") with true;
// stringLength(42) with "42"), so the author's own precondition checks
// never saw the value the caller sent. logos::qtArgDecode routes the value
// through the canonical codec instead — the same rule the cdylib dispatch,
// the Rust provider and the plain wire apply — and a mismatch becomes the
// canonical {"code":"dispatch_failed", ...} answer rather than a plausible
// wrong value.
//
// A type the codec has no rule for (QUrl, an enum, a pointer) reports
// Unchecked and keeps the old conversion verbatim: inventing a rule for it
// here would reject arguments that work today.
QVariantList coercedArgs = args;
for (int i = 0; i < method.parameterCount() && i < coercedArgs.size(); ++i) {
QMetaType paramType = method.parameterMetaType(i);
QVariant decoded;
std::string reason;
const logos::QtArgVerdict verdict = logos::qtArgDecode(
coercedArgs[i], paramType, "arg" + std::to_string(i), decoded, reason);
if (verdict == logos::QtArgVerdict::Rejected) {
const QString message = QString::fromStdString(reason);
qWarning() << "[LogosProviderObject] QtProviderObject: rejected"
<< methodName << "-" << message;
return logos::dispatchFailedVariant(providerName(), message);
}
if (verdict == logos::QtArgVerdict::Ok) {
coercedArgs[i] = decoded;
continue;
}
if (coercedArgs[i].metaType() != paramType) {
QVariant converted = coercedArgs[i];
if (converted.convert(paramType)) {
coercedArgs[i] = converted;
} else {
qWarning() << "[LogosProviderObject] QtProviderObject: could not convert arg" << i
<< "from" << coercedArgs[i].typeName()
<< "to" << paramType.name()
<< "for method" << methodName;
}
}
}
bool success = false;
QVariant result;
if (returnType == QMetaType::fromType<void>()) {
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, nullptr, nullptr);
if (success) result = QVariant(true);
} else if (returnType == QMetaType::fromType<bool>()) {
bool v = false;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "bool");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<int>()) {
int v = 0;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "int");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<double>()) {
double v = 0.0;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "double");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<float>()) {
float v = 0.0f;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "float");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<QString>()) {
QString v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QString");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<LogosResult>()) {
LogosResult v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "LogosResult");
if (success) result = QVariant::fromValue(v);
} else if (returnType == QMetaType::fromType<QVariant>()) {
QVariant v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariant");
if (success) result = v;
} else if (returnType == QMetaType::fromType<QJsonArray>()) {
QJsonArray v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QJsonArray");
if (success) result = QVariant(v);
} else if (returnType == QMetaType::fromType<QVariantList>()) {
QVariantList v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariantList");
if (success) result = QVariant::fromValue(v);
} else if (returnType == QMetaType::fromType<QVariantMap>()) {
QVariantMap v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariantMap");
if (success) result = QVariant::fromValue(v);
} else if (returnType == QMetaType::fromType<QStringList>()) {
QStringList v;
success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QStringList");
if (success) result = QVariant(v);
} else {
qWarning() << "[LogosProviderObject] QtProviderObject: unsupported return type:"
<< returnType.name() << "for method:" << methodName;
return QVariant();
}
if (!success) {
qWarning() << "[LogosProviderObject] QtProviderObject: failed to invoke" << methodName;
}
return result;
}
bool QtProviderObject::informModuleToken(const QString& moduleName, const QString& token)
{
PluginInterface* pluginInterface = qobject_cast<PluginInterface*>(m_module);
if (!pluginInterface) {
qWarning() << "[LogosProviderObject] QtProviderObject::informModuleToken: not a PluginInterface";
return false;
}
LogosAPI* api = pluginInterface->logosAPI;
if (!api) {
qWarning() << "[LogosProviderObject] QtProviderObject::informModuleToken: LogosAPI not available";
return false;
}
TokenManager* tokenManager = api->getTokenManager();
if (!tokenManager) {
qWarning() << "[LogosProviderObject] QtProviderObject::informModuleToken: TokenManager not available";
return false;
}
qDebug() << "[LogosProviderObject] QtProviderObject: saving token for module:" << moduleName;
tokenManager->saveToken(moduleName, token);
return true;
}
QJsonArray QtProviderObject::getMethods()
{
if (!m_module) return QJsonArray();
QJsonArray methodsArray;
const QMetaObject* metaObject = m_module->metaObject();
for (int i = 0; i < metaObject->methodCount(); ++i) {
QMetaMethod method = metaObject->method(i);
if (method.enclosingMetaObject() != metaObject) {
continue;
}
QJsonObject methodObj;
methodObj["signature"] = QString::fromUtf8(method.methodSignature());
methodObj["name"] = QString::fromUtf8(method.name());
methodObj["returnType"] = QString::fromUtf8(method.typeName());
methodObj["isInvokable"] = method.isValid() &&
(method.methodType() == QMetaMethod::Method || method.methodType() == QMetaMethod::Slot);
if (method.parameterCount() > 0) {
QJsonArray params;
for (int p = 0; p < method.parameterCount(); ++p) {
QJsonObject paramObj;
paramObj["type"] = QString::fromUtf8(method.parameterTypeName(p));
QByteArrayList paramNames = method.parameterNames();
if (p < paramNames.size() && !paramNames.at(p).isEmpty()) {
paramObj["name"] = QString::fromUtf8(paramNames.at(p));
} else {
paramObj["name"] = QString("param%1").arg(p);
}
params.append(paramObj);
}
methodObj["parameters"] = params;
}
methodsArray.append(methodObj);
}
return methodsArray;
}
#include "moc_qt_provider_object.cpp"
+47
View File
@@ -0,0 +1,47 @@
#ifndef QT_PROVIDER_OBJECT_H
#define QT_PROVIDER_OBJECT_H
#include "logos_provider_object.h"
#include <QObject>
class PluginInterface;
// LEGACY, and carried here deliberately. This adapter is slated for deletion,
// but LogosAPIProvider::registerObject still falls back to it for any plugin
// that exposes a plain QObject instead of a LogosProviderPlugin, and the
// modules that rely on that fallback have not been migrated yet. Dropping it
// while relocating the host runtime would have changed behaviour for them, so
// it comes across unchanged; it goes once the last legacy module is migrated.
/**
* @brief Adapter that wraps an existing QObject-based plugin as a LogosProviderObject.
*
* This allows legacy plugins (using Q_INVOKABLE / Qt signals) to work through
* the new LogosProviderObject interface without any changes to the plugin code.
* All dispatch goes through QMetaObject — the same path that ModuleProxy used
* to handle directly.
*/
class QtProviderObject : public QObject, public LogosProviderObject {
Q_OBJECT
public:
explicit QtProviderObject(QObject* module, QObject* parent = nullptr);
~QtProviderObject() override;
QVariant callMethod(const QString& methodName, const QVariantList& args) override;
bool informModuleToken(const QString& moduleName, const QString& token) override;
QJsonArray getMethods() override;
void setEventListener(EventCallback callback) override;
void init(void* apiInstance) override;
QString providerName() const override;
QString providerVersion() const override;
private slots:
void onWrappedEventResponse(const QString& eventName, const QVariantList& data);
private:
QObject* m_module;
EventCallback m_eventCallback;
};
#endif // QT_PROVIDER_OBJECT_H
Generated
+52
View File
@@ -1,5 +1,30 @@
{
"nodes": {
"logos-lidl": {
"inputs": {
"logos-nix": [
"logos-nix"
],
"nixpkgs": [
"logos-lidl",
"logos-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1786415321,
"narHash": "sha256-Oe98SavQSVGBIY7WIc8RQ5l+Bl4KLsdmjb+PyscfdNw=",
"owner": "logos-co",
"repo": "logos-lidl",
"rev": "ffeebf2e90fa0c65e8c486988271fe0ca029d1e1",
"type": "github"
},
"original": {
"owner": "logos-co",
"repo": "logos-lidl",
"type": "github"
}
},
"logos-module": {
"inputs": {
"logos-nix": "logos-nix",
@@ -61,6 +86,31 @@
"type": "github"
}
},
"logos-protocol": {
"inputs": {
"logos-nix": [
"logos-nix"
],
"nixpkgs": [
"logos-protocol",
"logos-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1786572376,
"narHash": "sha256-BOWenBBnlSikjS9ZVgJg6XJTa6ylov9Zxe0RarDN5iA=",
"owner": "logos-co",
"repo": "logos-protocol",
"rev": "e6d5b575c25d9d26827aa4c6a9a203b175a7e0d8",
"type": "github"
},
"original": {
"owner": "logos-co",
"repo": "logos-protocol",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1759036355,
@@ -127,8 +177,10 @@
},
"root": {
"inputs": {
"logos-lidl": "logos-lidl",
"logos-module": "logos-module",
"logos-nix": "logos-nix_2",
"logos-protocol": "logos-protocol",
"nixpkgs": [
"logos-nix",
"nixpkgs"
+47 -5
View File
@@ -1,5 +1,5 @@
{
description = "Logos Qt Plugin Backend builds Logos modules as Qt 6 plugins";
description = "Logos Qt Plugin Backend builds Logos modules as Qt 6 plugins, and the Qt host runtime they link";
inputs = {
logos-nix.url = "github:logos-co/logos-nix";
@@ -7,9 +7,19 @@
# When used via logos-module-builder, logosModule is injected by the builder.
logos-module.url = "github:logos-co/logos-module";
nixpkgs.follows = "logos-nix/nixpkgs";
# The transport / consumer / token layer logos-qt-host is the Qt face of.
logos-protocol = {
url = "github:logos-co/logos-protocol";
inputs.logos-nix.follows = "logos-nix";
};
# The canonical LIDL frontend logos-qt-host-generator parses contracts with.
logos-lidl = {
url = "github:logos-co/logos-lidl";
inputs.logos-nix.follows = "logos-nix";
};
};
outputs = { self, nixpkgs, logos-module, ... }:
outputs = { self, nixpkgs, logos-module, logos-protocol, logos-lidl, ... }:
let
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
@@ -45,17 +55,39 @@
# Raw export: no deps — for use by logos-module-builder
rawLib = rawLib;
# Provide the cmake module as a package
packages = forAllSystems ({ pkgs, ... }: {
# The C++ half of this backend: the Qt host runtime a plugin links, and
# the generator that emits the plugin around a cdylib module's C ABI.
#
# These are deliberately NOT reachable from `lib` / `rawLib` /
# `cmake-module`. A consumer that only wants the Nix build functions or
# the CMake module (logos-module-builder's common path) must not be made
# to realise a Qt + protocol build to get them, and under Nix's laziness
# it is not — as long as nothing in those attributes mentions these.
packages = forAllSystems ({ pkgs, system, ... }: {
cmake-module = pkgs.runCommand "logos-qt-plugin-cmake" {} ''
mkdir -p $out/share/cmake/LogosModule
cp ${./cmake/LogosModule.cmake} $out/share/cmake/LogosModule/LogosModule.cmake
'';
logos-qt-host = import ./nix/qt-host.nix {
inherit pkgs;
src = ./.;
protocolLib = logos-protocol.packages.${system}.logos-protocol-lib;
};
logos-qt-host-generator = import ./nix/qt-host-generator.nix {
inherit pkgs;
src = ./qt-host-generator;
logos-lidl = logos-lidl.packages.${system}.logos-lidl;
};
# Unchanged: `default` is still the CMake module, so `nix build` on
# this repo stays the cheap pure-Nix output it has always been.
default = self.packages.${pkgs.system}.cmake-module;
});
# Tests
checks = forAllSystems ({ pkgs, ... }: {
checks = forAllSystems ({ pkgs, system, ... }: {
# Build a vanilla Qt plugin with no Logos SDK deps
vanilla-plugin = import ./tests/test-vanilla-plugin.nix {
inherit pkgs;
@@ -71,6 +103,16 @@
header-generator-guard = import ./tests/test-header-generator-guard.nix {
inherit pkgs;
};
# The Qt host runtime compiles and installs a usable CMake package.
qt-host = self.packages.${system}.logos-qt-host;
# Drive the glue generator over a real contract and assert on the
# emitted C++.
qt-host-generator = import ./tests/test-qt-host-generator.nix {
inherit pkgs;
generator = self.packages.${system}.logos-qt-host-generator;
};
});
# Dev shell for working on the backend itself
+33
View File
@@ -0,0 +1,33 @@
# Builds logos-qt-host-generator — the cdylib -> Qt-plugin glue emitter.
#
# Qt Core plus the canonical LIDL frontend, and deliberately nothing else: the
# one shared-frontend helper this backend uses is inlined (see
# qt-host-generator/lidl_emit_common.h), so no SDK appears in this repo's
# inputs on account of the generator.
{ pkgs, src, logos-lidl }:
pkgs.stdenv.mkDerivation {
pname = "logos-qt-host-generator";
version = "0.1.0";
inherit src;
nativeBuildInputs = [
pkgs.cmake
pkgs.ninja
pkgs.qt6.wrapQtAppsNoGuiHook
];
buildInputs = [
pkgs.qt6.qtbase
logos-lidl
];
cmakeFlags = [ "-GNinja" ];
meta = with pkgs.lib; {
description = "Emits the Qt plugin glue around a cdylib module's C ABI";
platforms = platforms.unix;
mainProgram = "logos-qt-host-generator";
};
}
+68
View File
@@ -0,0 +1,68 @@
# Builds logos-qt-host — the Qt HOST RUNTIME a Logos Qt plugin links against:
# LogosAPI (the object handed to initLogos), LogosAPIProvider (the transport
# hosts, ModuleProxy/handshake publication and token-validator injection),
# LogosProviderBase + the LOGOS_PROVIDER/LOGOS_METHOD macros, and the legacy
# QMetaObject adapter. A static library plus its headers and CMake package
# config, so a plugin build can `find_package(logos-qt-host)`.
{ pkgs, src, protocolLib }:
pkgs.stdenv.mkDerivation {
pname = "logos-qt-host";
version = "0.1.0";
inherit src;
nativeBuildInputs = [
pkgs.cmake
pkgs.ninja
pkgs.pkg-config
pkgs.qt6.wrapQtAppsNoGuiHook
];
buildInputs = [
pkgs.qt6.qtbase
pkgs.qt6.qtremoteobjects
pkgs.boost
pkgs.openssl
pkgs.nlohmann_json
protocolLib
];
# Same propagation policy as logos-protocol / logos-qt-sdk: Qt is excluded
# (setup-hook ordering), the protocol and the plain transport's deps are
# carried so a consumer's find_dependency() resolves them.
propagatedBuildInputs = [
pkgs.boost
pkgs.openssl
pkgs.nlohmann_json
protocolLib
];
# The CMake project is cpp/, but the source tree must be the repo root:
# cpp/CMakeLists.txt installs ../core/interface.h alongside its own headers.
dontUseCmakeConfigure = true;
buildPhase = ''
runHook preBuild
mkdir -p build-qt-host
cd build-qt-host
cmake ../cpp -GNinja -DCMAKE_INSTALL_PREFIX=$out \
-DLOGOS_PROTOCOL_ROOT=${protocolLib}
ninja
cd ..
runHook postBuild
'';
installPhase = ''
runHook preInstall
cmake --install build-qt-host
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Logos Qt host runtime LogosAPI, provider base classes, Qt plugin glue";
platforms = platforms.unix;
};
}
+25
View File
@@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.16)
project(LogosQtHostGenerator VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Core)
# logos-lidl: the canonical, language-neutral LIDL frontend (lexer/parser/AST).
# Every Logos generator links the same one, so the parsed surface cannot skew
# between them. Nothing else is needed here — the single helper this backend
# borrowed from logos-cpp-sdk's shared frontend is inlined in
# lidl_emit_common.h, which is what keeps that SDK out of this repo's inputs.
find_package(logos-lidl REQUIRED)
add_executable(logos-qt-host-generator
main.cpp
lidl_gen_cdylib_glue.cpp
)
target_include_directories(logos-qt-host-generator PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(logos-qt-host-generator PRIVATE Qt6::Core logos-lidl::logos_lidl)
install(TARGETS logos-qt-host-generator RUNTIME DESTINATION bin)
+42
View File
@@ -0,0 +1,42 @@
#ifndef LIDL_COMPAT_H
#define LIDL_COMPAT_H
// Bridge the copied cdylib-glue backend onto the canonical logos-lidl frontend.
//
// The backend (lidl_gen_cdylib_glue.{h,cpp}) came across from logos-qt-sdk
// BYTE-IDENTICAL, so it still spells the AST types unqualified and still
// streams std::string straight into a QTextStream. This header supplies
// exactly those two affordances and nothing else. logos-cpp-sdk's
// share/lidl-frontend has a much larger header of the same name (records
// checks, the serializer/validator shims, the Qt/std type-name mappers); the
// cdylib backend touches none of it, and pulling it in would put a
// logos-cpp-sdk dependency on this repo for no gain.
#include "lidl/ast.hpp"
#include "lidl/parser.hpp"
#include <QString>
#include <QTextStream>
#include <string>
// The canonical AST, in the global scope the backend references it from.
using lidl::MethodDecl;
using lidl::ModuleDecl;
using lidl::TypeExpr;
// std::string -> QString, and let QTextStream accept std::string directly so
// emission of AST string fields (`s << md.name`) compiles unchanged.
inline QString qs(const std::string& s) { return QString::fromStdString(s); }
inline QTextStream& operator<<(QTextStream& s, const std::string& v)
{
return s << QString::fromStdString(v);
}
using LidlParseResult = lidl::ParseResult;
inline lidl::ParseResult lidlParse(const QString& source)
{
return lidl::parse(source.toStdString());
}
#endif // LIDL_COMPAT_H
+35
View File
@@ -0,0 +1,35 @@
#ifndef LIDL_EMIT_COMMON_H
#define LIDL_EMIT_COMMON_H
// The one shared-frontend helper the cdylib glue backend uses, inlined.
//
// logos-cpp-sdk distributes a header of this name under share/lidl-frontend
// with four functions; the cdylib backend calls exactly one of them,
// lidlToPascalCase, to derive the plugin class stem from the module name.
// Twelve lines of definition are a far smaller thing to own than a
// logos-cpp-sdk input on this repo, so the definition lives here and the
// backend source stays byte-identical to the copy it came from.
//
// This is the same rule every other Logos generator applies to a module name
// (logos-view-module's view-generator inlines it too); it must not drift, or
// the class names in the emitted glue stop matching the ones the rest of the
// toolchain expects.
#include <QString>
#include "lidl_compat.h"
inline QString lidlToPascalCase(const QString& name)
{
QString out;
bool cap = true;
for (QChar c : name) {
if (!c.isLetterOrNumber()) { cap = true; continue; }
if (cap) { out.append(c.toUpper()); cap = false; }
else { out.append(c.toLower()); }
}
if (out.isEmpty()) return QString("Module");
return out;
}
#endif // LIDL_EMIT_COMMON_H
+311
View File
@@ -0,0 +1,311 @@
#include "lidl_gen_cdylib_glue.h"
#include "lidl_emit_common.h"
#include <QStringList>
#include <QTextStream>
QString lidlMakeCdylibGlueHeader(const ModuleDecl& module, bool multi)
{
const QString className = lidlToPascalCase(qs(module.name));
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "//\n";
s << "// The UNIFORM Qt-plugin glue over the common module-impl C ABI\n";
s << "// (logos_module_impl.h). Identical regardless of the module's source\n";
s << "// language (C++ or Rust): it only knows the C symbols, which are\n";
s << "// linked in from the module's cdylib. logos_host loads it unchanged.\n";
s << "#pragma once\n\n";
s << "#include \"interface.h\"\n";
s << "#include \"logos_api.h\"\n";
s << "#include \"logos_provider_object.h\"\n";
s << "#include \"logos_json_convert.h\"\n";
s << "#include \"logos_module_impl.h\"\n";
s << "#include \"logos_types.h\"\n";
s << "#include <QObject>\n";
s << "#include <QJsonArray>\n";
s << "#include <QJsonDocument>\n";
s << "#include <QSet>\n";
s << "#include <QString>\n";
s << "#include <QVariant>\n";
s << "#include <QVariantList>\n";
if (multi) {
// concurrency:"multi" defers behind the ordinary callMethod: a worker
// runs the handler and the result is pushed back as a completion event
// (logos_async_dispatch.h) — no new provider/host vtable method.
s << "#include \"logos_async_dispatch.h\"\n";
s << "#include <QVariantMap>\n";
s << "#include <atomic>\n";
s << "#include <cstdint>\n";
s << "#include <QThread>\n";
}
s << "#include <nlohmann/json.hpp>\n\n";
// LogosProviderBase (not the bare interface): its informModuleToken saves
// into the HOST-stack TokenManager — what ModuleProxy validates INBOUND
// calls against — exactly as C++ provider modules do. The glue then ALSO
// forwards every token across the C ABI for the cdylib's own stack.
s << "class " << className << "CdylibProvider : public LogosProviderBase {\n";
s << "public:\n";
if (multi) {
s << " // concurrency:\"multi\": callMethod does NOT block — it hands the call to a\n";
s << " // worker and returns a pending sentinel at once, then the worker pushes the\n";
s << " // result back as a completion event. This is the SAME callMethod slot every\n";
s << " // provider has — there is no extra provider/host vtable method, so the\n";
s << " // provider ABI is unchanged and an old host loads + forwards this module.\n";
}
s << " QVariant callMethod(const QString& methodName, const QVariantList& args) override;\n";
s << " QJsonArray getMethods() override;\n";
s << " bool informModuleToken(const QString& moduleName, const QString& token) override;\n";
s << " void setEventListener(EventCallback callback) override;\n";
s << " QString providerName() const override { return QStringLiteral(\"" << module.name << "\"); }\n";
s << " QString providerVersion() const override { return QStringLiteral(\"" << (module.version.empty() ? QStringLiteral("1.0.0") : qs(module.version)) << "\"); }\n";
s << "protected:\n";
s << " void onInit(LogosAPI* api) override;\n";
s << "private:\n";
s << " EventCallback m_eventCallback;\n";
s << " static void emitTrampoline(const char* eventName, const char* dataJson, void* userData);\n";
if (multi)
s << " std::atomic<std::uint64_t> m_callCounter{0}; // unique deferred-call ids\n";
s << "};\n\n";
// The root plugin must also implement PluginInterface — logos_host's
// module_initializer hard-requires it before any provider detection.
s << "class " << className << "CdylibPlugin : public QObject, public PluginInterface, public LogosProviderPlugin {\n";
s << " Q_OBJECT\n";
s << " Q_PLUGIN_METADATA(IID LogosProviderPlugin_iid FILE \"metadata.json\")\n";
s << " Q_INTERFACES(PluginInterface LogosProviderPlugin)\n";
s << "public:\n";
s << " QString name() const override { return QStringLiteral(\"" << module.name << "\"); }\n";
s << " QString version() const override { return QStringLiteral(\"" << (module.version.empty() ? QStringLiteral("1.0.0") : qs(module.version)) << "\"); }\n";
s << " LogosProviderObject* createProviderObject() override {\n";
s << " return new " << className << "CdylibProvider();\n";
s << " }\n";
s << "};\n";
return c;
}
QString lidlMakeCdylibGlueSource(const ModuleDecl& module, bool multi)
{
const QString className = lidlToPascalCase(qs(module.name));
const QString provider = className + "CdylibProvider";
// Which methods return StdLogosResult — the glue re-materializes the Qt
// LogosResult QVariant callers expect on the wire.
QStringList resultMethods;
for (const MethodDecl& md : module.methods)
if (md.resultReturn) resultMethods << qs(md.name);
// Which methods return `void`. Derived from the shared contract, so every
// cdylib backend converges here regardless of the language behind the C ABI.
//
// `void` is not a LIDL builtin — it parses as Named("void") — and the
// backends handled that Named differently: the C++ cdylib has an arm that
// returns "true", the Rust one falls to its catch-all and returns JSON null.
// Null is the failure token further up (logos_json_convert turns it into an
// invalid QVariant, which core_service reports as METHOD_FAILED), so the same
// void method answered `true` from one provider and "the call failed" from
// the other, on a contract they share.
QStringList voidMethods;
for (const MethodDecl& md : module.methods)
if (!md.resultReturn && md.returnType.name == "void") voidMethods << qs(md.name);
QString c;
QTextStream s(&c);
s << "// AUTO-GENERATED by logos-cpp-generator --cdylib -- do not edit\n";
s << "#include \"" << module.name << "_cdylib_glue.h\"\n\n";
if (!multi) {
s << "QVariant " << provider << "::callMethod(const QString& methodName, const QVariantList& args)\n{\n";
s << " nlohmann::json jArgs = nlohmann::json::array();\n";
s << " for (const QVariant& a : args)\n";
s << " jArgs.push_back(logos::qvariantToNlohmann(a));\n";
s << " const std::string dumped = jArgs.dump();\n";
s << " char* result = logos_module_dispatch(methodName.toUtf8().constData(), dumped.c_str());\n";
s << " if (!result) return QVariant();\n";
s << " nlohmann::json jResult = nlohmann::json::parse(result, nullptr, false);\n";
s << " logos_module_string_free(result);\n";
s << " if (jResult.is_discarded()) return QVariant();\n";
if (!voidMethods.isEmpty()) {
s << " // `void` methods answer QVariant(true) whatever the cdylib put on the\n";
s << " // C ABI. An invalid QVariant is this slot's failure token, so a void\n";
s << " // method needs SOME value to mean \"it ran\".\n";
s << " static const QSet<QString> kVoidMethods = {";
for (int i = 0; i < voidMethods.size(); ++i) {
s << "QStringLiteral(\"" << voidMethods[i] << "\")";
if (i + 1 < voidMethods.size()) s << ", ";
}
s << "};\n";
s << " if (kVoidMethods.contains(methodName)) return QVariant(true);\n";
}
if (!resultMethods.isEmpty()) {
s << " // StdLogosResult-returning methods: re-materialize the Qt LogosResult\n";
s << " // QVariant the wire expects ({success, value, error} crossed the C ABI).\n";
s << " static const QSet<QString> kResultMethods = {";
for (int i = 0; i < resultMethods.size(); ++i) {
s << "QStringLiteral(\"" << resultMethods[i] << "\")";
if (i + 1 < resultMethods.size()) s << ", ";
}
s << "};\n";
s << " if (kResultMethods.contains(methodName) && jResult.is_object()) {\n";
s << " LogosResult lr;\n";
s << " lr.success = jResult.value(\"success\", false);\n";
s << " lr.value = logos::nlohmannToQVariant(jResult.value(\"value\", nlohmann::json()));\n";
s << " lr.error = jResult.contains(\"error\") && jResult[\"error\"].is_string()\n";
s << " ? QVariant(QString::fromStdString(jResult[\"error\"].get<std::string>()))\n";
s << " : QVariant();\n";
s << " return QVariant::fromValue(lr);\n";
s << " }\n";
}
s << " return logos::nlohmannToQVariant(jResult);\n";
s << "}\n\n";
} else {
// concurrency:"multi": callMethod does NOT block. Marshal the args, hand
// the call to a worker thread (the cdylib's logos_module_dispatch is
// thread-safe in multi mode), and return a PENDING SENTINEL immediately so
// the dispatch thread is freed for other callers. When the worker
// finishes it pushes the result back as a completion event keyed by
// callId, over the provider's existing event listener; the consumer
// transport awaits it. This rides the ORDINARY callMethod slot — there is
// no new provider/host vtable method, so the provider ABI is unchanged and
// an old host loads + forwards this module unmodified.
s << "QVariant " << provider << "::callMethod(const QString& methodName, const QVariantList& args)\n{\n";
s << " nlohmann::json jArgs = nlohmann::json::array();\n";
s << " for (const QVariant& a : args)\n";
s << " jArgs.push_back(logos::qvariantToNlohmann(a));\n";
s << " const std::string dumped = jArgs.dump();\n";
s << " const std::string method = methodName.toStdString();\n";
if (!resultMethods.isEmpty()) {
s << " static const QSet<QString> kResultMethods = {";
for (int i = 0; i < resultMethods.size(); ++i) {
s << "QStringLiteral(\"" << resultMethods[i] << "\")";
if (i + 1 < resultMethods.size()) s << ", ";
}
s << "};\n";
s << " const bool isResultMethod = kResultMethods.contains(methodName);\n";
}
if (!voidMethods.isEmpty()) {
s << " static const QSet<QString> kVoidMethods = {";
for (int i = 0; i < voidMethods.size(); ++i) {
s << "QStringLiteral(\"" << voidMethods[i] << "\")";
if (i + 1 < voidMethods.size()) s << ", ";
}
s << "};\n";
s << " const bool isVoidMethod = kVoidMethods.contains(methodName);\n";
}
s << " const QString callId = QStringLiteral(\"lc-%1\").arg(\n";
s << " static_cast<qulonglong>(m_callCounter.fetch_add(1, std::memory_order_relaxed)));\n";
s << " EventCallback eventCb = m_eventCallback; // copied for the worker\n";
// Run the handler on a real QThread, not a raw std::thread: if the handler
// makes an outbound module->module call it spins nested QEventLoops (to
// acquire the QtRO replica and await a deferred reply), and only a genuine
// QThread carries a Qt event dispatcher that can drive those — an adopted
// std::thread cannot pump the QtRO socket, so such calls would hang. The
// client stays owned by this worker (inline, no cross-thread marshaling).
if (!resultMethods.isEmpty())
s << " QThread* worker = QThread::create([method, dumped, isResultMethod, callId, eventCb]() {\n";
else
s << " QThread* worker = QThread::create([method, dumped, callId, eventCb]() {\n";
s << " char* result = logos_module_dispatch(method.c_str(), dumped.c_str());\n";
s << " QVariant value;\n";
s << " if (result) {\n";
s << " nlohmann::json jResult = nlohmann::json::parse(result, nullptr, false);\n";
s << " logos_module_string_free(result);\n";
s << " if (!jResult.is_discarded()) {\n";
if (!voidMethods.isEmpty()) {
s << " if (isVoidMethod) {\n";
s << " value = QVariant(true);\n";
s << " } else\n";
}
if (!resultMethods.isEmpty()) {
s << " if (isResultMethod && jResult.is_object()) {\n";
s << " LogosResult lr;\n";
s << " lr.success = jResult.value(\"success\", false);\n";
s << " lr.value = logos::nlohmannToQVariant(jResult.value(\"value\", nlohmann::json()));\n";
s << " lr.error = jResult.contains(\"error\") && jResult[\"error\"].is_string()\n";
s << " ? QVariant(QString::fromStdString(jResult[\"error\"].get<std::string>()))\n";
s << " : QVariant();\n";
s << " value = QVariant::fromValue(lr);\n";
s << " } else {\n";
s << " value = logos::nlohmannToQVariant(jResult);\n";
s << " }\n";
} else {
s << " { value = logos::nlohmannToQVariant(jResult); }\n";
}
s << " }\n";
s << " }\n";
s << " if (eventCb)\n";
s << " eventCb(logos::callCompleteEvent(), QVariantList{ callId, value });\n";
s << " });\n";
s << " QObject::connect(worker, &QThread::finished, worker, &QThread::deleteLater);\n";
s << " worker->start();\n";
s << " QVariantMap pending;\n";
s << " pending[logos::pendingCallKey()] = callId;\n";
s << " return pending;\n";
s << "}\n\n";
}
s << "QJsonArray " << provider << "::getMethods()\n{\n";
s << " char* json = logos_module_get_methods();\n";
s << " if (!json) return QJsonArray();\n";
s << " QJsonDocument doc = QJsonDocument::fromJson(QByteArray(json));\n";
s << " logos_module_string_free(json);\n";
s << " return doc.isArray() ? doc.array() : QJsonArray();\n";
s << "}\n\n";
s << "bool " << provider << "::informModuleToken(const QString& moduleName, const QString& token)\n{\n";
s << " // Host-stack save first: ModuleProxy validates INBOUND calls against\n";
s << " // the host's TokenManager (LogosProviderBase saves there).\n";
s << " const bool hostOk = LogosProviderBase::informModuleToken(moduleName, token);\n";
s << " // Then forward across the C ABI: the cdylib's own protocol stack\n";
s << " // (a separate static copy) authenticates the module's OUTBOUND calls.\n";
s << " const bool implOk = logos_module_accept_token(moduleName.toUtf8().constData(),\n";
s << " token.toUtf8().constData()) == 0;\n";
s << " return hostOk && implOk;\n";
s << "}\n\n";
s << "void " << provider << "::setEventListener(EventCallback callback)\n{\n";
s << " m_eventCallback = std::move(callback);\n";
s << " logos_module_set_emit_callback(&" << provider << "::emitTrampoline, this);\n";
s << "}\n\n";
s << "void " << provider << "::emitTrampoline(const char* eventName, const char* dataJson, void* userData)\n{\n";
s << " auto* self = static_cast<" << provider << "*>(userData);\n";
s << " if (!self || !self->m_eventCallback || !eventName) return;\n";
s << " nlohmann::json payload = dataJson\n";
s << " ? nlohmann::json::parse(dataJson, nullptr, false)\n";
s << " : nlohmann::json::array();\n";
s << " if (payload.is_discarded() || !payload.is_array()) payload = nlohmann::json::array();\n";
s << " self->m_eventCallback(QString::fromUtf8(eventName),\n";
s << " logos::nlohmannArgsToQVariantList(payload));\n";
s << "}\n\n";
s << "void " << provider << "::onInit(LogosAPI* api)\n{\n";
s << " QObject* obj = api;\n";
s << " if (!obj) return;\n";
s << " // Token FIRST: the cdylib runs its own protocol stack (a separate\n";
s << " // static copy with its own TokenManager). Seed it with the\n";
s << " // host-issued auth token the initializer surfaces as a property\n";
s << " // (set before registerObject, so it is visible here), under the\n";
s << " // same keys the initializer uses (\"core\" / \"capability_module\")\n";
s << " // — this authenticates the module's OUTBOUND calls (incl. the\n";
s << " // capability requestModule flow). Seeding before the context\n";
s << " // forward means on_context_ready/onContextReady can already make\n";
s << " // authenticated calls whenever the impl's ready-latch fires.\n";
s << " const QString authToken = obj->property(\"authToken\").toString();\n";
s << " if (!authToken.isEmpty()) {\n";
s << " logos_module_accept_token(\"core\", authToken.toUtf8().constData());\n";
s << " logos_module_accept_token(\"capability_module\", authToken.toUtf8().constData());\n";
s << " }\n";
s << " // Context LAST — comes from the host's property stamping on the\n";
s << " // LogosAPI object, forwarded across the C ABI; the cdylib never\n";
s << " // sees Qt. (The impl fires its context-ready hook once BOTH the\n";
s << " // context and the emit callback have been delivered.)\n";
s << " logos_module_set_context(\n";
s << " obj->property(\"modulePath\").toString().toUtf8().constData(),\n";
s << " obj->property(\"instanceId\").toString().toUtf8().constData(),\n";
s << " obj->property(\"instancePersistencePath\").toString().toUtf8().constData());\n";
s << "}\n";
return c;
}
+14
View File
@@ -0,0 +1,14 @@
// The Qt half of the cdylib module path: the uniform Qt-plugin glue that
// wraps the (language-agnostic) module-impl C ABI. The Qt-FREE half — the
// C-ABI impl-exports around a C++ impl class — stays with logos-cpp-sdk's
// generator; this glue is emitted by logos-qt-generator.
#pragma once
#include <QString>
#include "lidl_compat.h"
// `multi` ⇒ the module was built with concurrency:"multi": also emit a
// callMethodAsync override that drives the cdylib's logos_module_dispatch_async
// (concurrent handler execution). Default false = single (sync callMethod only).
QString lidlMakeCdylibGlueHeader(const ModuleDecl& module, bool multi = false);
QString lidlMakeCdylibGlueSource(const ModuleDecl& module, bool multi = false);
+118
View File
@@ -0,0 +1,118 @@
// logos-qt-host-generator — the cdylib -> Qt-plugin glue for a Logos module.
//
// A cdylib module (Rust, or C++ compiled to the same shape) exposes the
// language-neutral module-impl C ABI (logos_module_impl.h) and nothing else.
// This tool emits the Qt plugin that logos_host actually loads around it:
//
// <name>_cdylib_glue.h the *CdylibProvider (LogosProviderBase) and the
// *CdylibPlugin (PluginInterface +
// LogosProviderPlugin, Q_PLUGIN_METADATA)
// <name>_cdylib_glue.cpp callMethod / getMethods / informModuleToken /
// setEventListener / onInit, each forwarding across
// the C ABI
//
// The glue is uniform: it only knows the C symbols, so it is identical
// whatever language sits behind them. That is why it belongs with the Qt
// plugin BACKEND (this repo) rather than with a language SDK — the C-ABI
// impl-exports on the other side of that boundary are logos-cpp-generator's
// job, and the two halves meet only at logos_module_impl.h.
//
// Usage:
// logos-qt-host-generator --lidl <contract.lidl>
// [--concurrency multi] [--output-dir <dir>]
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QTextStream>
#include "lidl_compat.h"
#include "lidl_gen_cdylib_glue.h"
namespace {
struct Out { QString file; QString content; };
int writeAll(const QList<Out>& outs, const QString& dir,
QTextStream& out, QTextStream& err)
{
for (const Out& o : outs) {
const QString abs = QDir(dir).filePath(o.file);
QFile f(abs);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
err << "Failed to write: " << abs << "\n";
return 1;
}
f.write(o.content.toUtf8());
out << "Generated: " << abs << "\n";
}
return 0;
}
QString argValue(const QStringList& args, const QString& flag)
{
const int i = args.indexOf(flag);
return (i != -1 && i + 1 < args.size()) ? args.at(i + 1) : QString();
}
} // namespace
int main(int argc, char* argv[])
{
QCoreApplication app(argc, argv);
QTextStream out(stdout);
QTextStream err(stderr);
const QStringList args = app.arguments();
const QString lidlPath = argValue(args, "--lidl");
// concurrency:"multi" (from metadata.json, fed by the builder) ⇒ emit the
// deferred dispatch instead: callMethod hands the call to a worker and
// returns a pending sentinel, and the result comes back as a completion
// event. Anything other than the exact word "multi" means single.
const bool multi = argValue(args, "--concurrency") == QStringLiteral("multi");
QString outputDir = argValue(args, "--output-dir");
if (lidlPath.isEmpty()) {
err << "Usage: logos-qt-host-generator --lidl <contract.lidl>\n"
" [--concurrency multi] [--output-dir <dir>]\n";
return 1;
}
// This binary emits ONE backend, so --backend is not a mode selector here.
// Accept the value the multi-backend generator used for this path, and
// REFUSE any other, rather than ignoring the flag: callers are migrating
// from a tool where --backend was required and dispatched on, so silently
// treating `--backend qt` as cdylib would hand back confidently wrong
// artifacts (the qt backend emits <name>_qt_glue.h + _dispatch.cpp +
// _events.cpp — different files entirely) with a zero exit status.
const QString backend = argValue(args, "--backend");
if (!backend.isEmpty() && backend != QStringLiteral("cdylib")) {
err << "Error: logos-qt-host-generator only emits the cdylib backend, "
<< "but --backend " << backend << " was requested.\n";
return 2;
}
if (outputDir.isEmpty())
outputDir = QDir::current().filePath("generated");
QDir().mkpath(outputDir);
QFile f(lidlPath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
err << "Failed to open LIDL file: " << lidlPath << "\n";
return 3;
}
LidlParseResult pr = lidlParse(QString::fromUtf8(f.readAll()));
if (pr.hasError()) {
err << lidlPath << ":" << pr.errorLine << ":" << pr.errorColumn
<< ": " << pr.error << "\n";
return 4;
}
const ModuleDecl& mod = pr.module;
QList<Out> outs;
outs.append({qs(mod.name) + "_cdylib_glue.h", lidlMakeCdylibGlueHeader(mod, multi)});
outs.append({qs(mod.name) + "_cdylib_glue.cpp", lidlMakeCdylibGlueSource(mod, multi)});
const int rc = writeAll(outs, outputDir, out, err);
out.flush();
return rc;
}
+108
View File
@@ -0,0 +1,108 @@
# Drives logos-qt-host-generator over a real LIDL contract and asserts on the
# C++ it emits. The generator has no other test surface — nothing downstream
# compiles its output inside this repo — so these greps are what stands between
# a contract-parsing or emission regression and a module that fails to build
# (or, worse, builds and forwards the wrong thing across the C ABI).
{ pkgs, generator }:
pkgs.runCommand "logos-qt-host-generator-test" {
nativeBuildInputs = [ generator ];
} ''
mkdir -p work && cd work
cat > sample.lidl <<'EOF'
module sample_probe {
version "2.1.0"
method whoAmI() -> tstr
method echoInt(v: int) -> int
method doVoid() -> void
method makeResult(ok: bool) -> result
event tickEvent(v: tstr)
}
EOF
# ---- single (default) concurrency ------------------------------------
logos-qt-host-generator --lidl sample.lidl --output-dir out
for f in sample_probe_cdylib_glue.h sample_probe_cdylib_glue.cpp; do
test -f "out/$f" || { echo "MISSING: $f"; exit 1; }
done
h=out/sample_probe_cdylib_glue.h
c=out/sample_probe_cdylib_glue.cpp
# The class stem is PascalCase of the module name; the provider derives
# LogosProviderBase (NOT the bare interface) because that is what saves the
# token into the host-stack TokenManager ModuleProxy validates against, and
# the plugin must also implement PluginInterface or logos_host's
# module_initializer refuses it before any provider detection.
grep -q "class SampleProbeCdylibProvider : public LogosProviderBase" $h \
|| { echo "provider class name/base wrong"; exit 1; }
grep -q "class SampleProbeCdylibPlugin : public QObject, public PluginInterface, public LogosProviderPlugin" $h \
|| { echo "plugin class name/bases wrong"; exit 1; }
grep -q 'Q_PLUGIN_METADATA(IID LogosProviderPlugin_iid FILE "metadata.json")' $h \
|| { echo "plugin metadata macro missing"; exit 1; }
grep -q 'providerName() const override { return QStringLiteral("sample_probe"); }' $h \
|| { echo "module name not carried into providerName()"; exit 1; }
grep -q 'providerVersion() const override { return QStringLiteral("2.1.0"); }' $h \
|| { echo "version not carried from the contract"; exit 1; }
# Every C-ABI entry point the glue exists to forward across. Losing any one
# of these is a module that loads and then silently does nothing.
for sym in logos_module_dispatch logos_module_string_free \
logos_module_get_methods logos_module_accept_token \
logos_module_set_emit_callback logos_module_set_context; do
grep -q "$sym" $c || { echo "C-ABI forwarding lost: $sym"; exit 1; }
done
# `void` and `result` returns are the two shapes the glue has to special-case
# (an invalid QVariant is this slot's failure token, so a void method needs
# SOME value; a result has to be re-materialized as a Qt LogosResult).
grep -q 'kVoidMethods = {QStringLiteral("doVoid")}' $c \
|| { echo "void method set not derived from the contract"; exit 1; }
grep -q 'kResultMethods = {QStringLiteral("makeResult")}' $c \
|| { echo "result method set not derived from the contract"; exit 1; }
# Single concurrency: callMethod BLOCKS on the C ABI and returns the answer.
grep -q "char\* result = logos_module_dispatch(methodName.toUtf8().constData()" $c \
|| { echo "single-concurrency callMethod is not the blocking dispatch"; exit 1; }
if grep -q "pendingCallKey" $c; then
echo "single concurrency emitted the deferred path"; exit 1
fi
# ---- concurrency: multi ----------------------------------------------
# A different code path entirely: callMethod hands the call to a worker and
# returns a pending sentinel, and the result arrives as a completion event.
logos-qt-host-generator --lidl sample.lidl --concurrency multi --output-dir out-multi
hm=out-multi/sample_probe_cdylib_glue.h
cm=out-multi/sample_probe_cdylib_glue.cpp
grep -q '#include "logos_async_dispatch.h"' $hm \
|| { echo "multi header missing the async-dispatch include"; exit 1; }
grep -q "m_callCounter" $hm \
|| { echo "multi header missing the deferred-call id counter"; exit 1; }
# A real QThread, not a raw std::thread: a handler making an outbound
# module->module call spins nested QEventLoops, which only a QThread's event
# dispatcher can drive.
grep -q "QThread::create" $cm \
|| { echo "multi source does not run the handler on a QThread"; exit 1; }
grep -q "pending\[logos::pendingCallKey()\] = callId;" $cm \
|| { echo "multi source does not return the pending sentinel"; exit 1; }
grep -q "eventCb(logos::callCompleteEvent(), QVariantList{ callId, value });" $cm \
|| { echo "multi source does not push the completion event"; exit 1; }
# ---- refusals ---------------------------------------------------------
# No contract at all, and an unparseable one, must both FAIL rather than
# emit half a plugin.
if logos-qt-host-generator --output-dir out-bad; then
echo "generator accepted a run with no --lidl"; exit 1
fi
echo 'this file is not a LIDL contract' > broken.lidl
if logos-qt-host-generator --lidl broken.lidl --output-dir out-bad; then
echo "generator accepted an unparseable contract"; exit 1
fi
touch $out
''