From eac67af9e3f25e7d0228edf05014ce4c19ba9f2a Mon Sep 17 00:00:00 2001 From: Dario Gabriel Lipicar Date: Wed, 12 Aug 2026 21:46:35 -0300 Subject: [PATCH] feat: the Qt host runtime and the cdylib-glue generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 --- .github/workflows/ci.yml | 6 + README.md | 31 +- core/interface.h | 29 ++ cpp/CMakeLists.txt | 129 +++++ cpp/logos-qt-hostConfig.cmake.in | 14 + cpp/logos_api.cpp | 118 +++++ cpp/logos_api.h | 271 ++++++++++ cpp/logos_api_provider.cpp | 306 ++++++++++++ cpp/logos_api_provider.h | 123 +++++ cpp/logos_provider_object.cpp | 48 ++ cpp/logos_provider_object.h | 77 +++ cpp/logos_qt_arg_decode.cpp | 75 +++ cpp/logos_qt_arg_decode.h | 233 +++++++++ cpp/qt_provider_object.cpp | 544 +++++++++++++++++++++ cpp/qt_provider_object.h | 47 ++ flake.lock | 52 ++ flake.nix | 52 +- nix/qt-host-generator.nix | 33 ++ nix/qt-host.nix | 68 +++ qt-host-generator/CMakeLists.txt | 25 + qt-host-generator/lidl_compat.h | 42 ++ qt-host-generator/lidl_emit_common.h | 35 ++ qt-host-generator/lidl_gen_cdylib_glue.cpp | 311 ++++++++++++ qt-host-generator/lidl_gen_cdylib_glue.h | 14 + qt-host-generator/main.cpp | 118 +++++ tests/test-qt-host-generator.nix | 108 ++++ 26 files changed, 2902 insertions(+), 7 deletions(-) create mode 100644 core/interface.h create mode 100644 cpp/CMakeLists.txt create mode 100644 cpp/logos-qt-hostConfig.cmake.in create mode 100644 cpp/logos_api.cpp create mode 100644 cpp/logos_api.h create mode 100644 cpp/logos_api_provider.cpp create mode 100644 cpp/logos_api_provider.h create mode 100644 cpp/logos_provider_object.cpp create mode 100644 cpp/logos_provider_object.h create mode 100644 cpp/logos_qt_arg_decode.cpp create mode 100644 cpp/logos_qt_arg_decode.h create mode 100644 cpp/qt_provider_object.cpp create mode 100644 cpp/qt_provider_object.h create mode 100644 nix/qt-host-generator.nix create mode 100644 nix/qt-host.nix create mode 100644 qt-host-generator/CMakeLists.txt create mode 100644 qt-host-generator/lidl_compat.h create mode 100644 qt-host-generator/lidl_emit_common.h create mode 100644 qt-host-generator/lidl_gen_cdylib_glue.cpp create mode 100644 qt-host-generator/lidl_gen_cdylib_glue.h create mode 100644 qt-host-generator/main.cpp create mode 100644 tests/test-qt-host-generator.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65ecd30..bc01dc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/README.md b/README.md index 0bc2e28..6eb1c9e 100644 --- a/README.md +++ b/README.md @@ -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..cmake-module` | `LogosModule.cmake` — the CMake half of the plugin build. Also the `default` package. | +| `packages..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..logos-qt-host-generator` | Emits the Qt plugin glue around a cdylib module's C ABI (`_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 +``` diff --git a/core/interface.h b/core/interface.h new file mode 100644 index 0000000..8b30e27 --- /dev/null +++ b/core/interface.h @@ -0,0 +1,29 @@ +#ifndef PLUGIN_INTERFACE_H +#define PLUGIN_INTERFACE_H + +#include +#include +#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 diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 0000000..a9d067e --- /dev/null +++ b/cpp/CMakeLists.txt @@ -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 + $ + $ +) + +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) diff --git a/cpp/logos-qt-hostConfig.cmake.in b/cpp/logos-qt-hostConfig.cmake.in new file mode 100644 index 0000000..8a7dec4 --- /dev/null +++ b/cpp/logos-qt-hostConfig.cmake.in @@ -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) diff --git a/cpp/logos_api.cpp b/cpp/logos_api.cpp new file mode 100644 index 0000000..0daedd0 --- /dev/null +++ b/cpp/logos_api.cpp @@ -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 +#include + +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"); +} + +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(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(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))); +} diff --git a/cpp/logos_api.h b/cpp/logos_api.h new file mode 100644 index 0000000..7bb2c7b --- /dev/null +++ b/cpp/logos_api.h @@ -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 +#include +#include +#include + +#include +#include +#include + +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(cfg.protocol), + std::hash{}(cfg.host), + cfg.port, + std::hash{}(cfg.caFile), + std::hash{}(cfg.certFile), + std::hash{}(cfg.keyFile), + cfg.verifyPeer, + static_cast(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(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(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 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 m_capabilityModuleTransport; +}; + +#endif // LOGOS_API_H \ No newline at end of file diff --git a/cpp/logos_api_provider.cpp b/cpp/logos_api_provider.cpp new file mode 100644 index 0000000..dc738bb --- /dev/null +++ b/cpp/logos_api_provider.cpp @@ -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 +#include + +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(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(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(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(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); +} diff --git a/cpp/logos_api_provider.h b/cpp/logos_api_provider.h new file mode 100644 index 0000000..990f95f --- /dev/null +++ b/cpp/logos_api_provider.h @@ -0,0 +1,123 @@ +#ifndef LOGOS_API_PROVIDER_H +#define LOGOS_API_PROVIDER_H + +#include "logos_transport_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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; + 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> m_transports; + QString m_registryUrl; + QMap 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 diff --git a/cpp/logos_provider_object.cpp b/cpp/logos_provider_object.cpp new file mode 100644 index 0000000..5e24323 --- /dev/null +++ b/cpp/logos_provider_object.cpp @@ -0,0 +1,48 @@ +#include "logos_provider_object.h" +#include "logos_api.h" +#include "token_manager.h" +#include + +// 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(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; + } +} diff --git a/cpp/logos_provider_object.h b/cpp/logos_provider_object.h new file mode 100644 index 0000000..8c3ab97 --- /dev/null +++ b/cpp/logos_provider_object.h @@ -0,0 +1,77 @@ +#ifndef LOGOS_PROVIDER_OBJECT_H +#define LOGOS_PROVIDER_OBJECT_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 diff --git a/cpp/logos_qt_arg_decode.cpp b/cpp/logos_qt_arg_decode.cpp new file mode 100644 index 0000000..9172180 --- /dev/null +++ b/cpp/logos_qt_arg_decode.cpp @@ -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 the generated dispatch uses, so the +// two provider sites cannot answer differently for the same argument. +template +QtArgVerdict decodeAs(const QVariant& in, const std::string& path, QVariant& out, + std::string& error) +{ + try { + out = QVariant::fromValue(detail::QtArgCodec::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(in, path, out, error); + case QMetaType::Int: return decodeAs(in, path, out, error); + case QMetaType::UInt: return decodeAs(in, path, out, error); + case QMetaType::Short: return decodeAs(in, path, out, error); + case QMetaType::UShort: return decodeAs(in, path, out, error); + case QMetaType::Long: return decodeAs(in, path, out, error); + case QMetaType::ULong: return decodeAs(in, path, out, error); + case QMetaType::LongLong: return decodeAs(in, path, out, error); + case QMetaType::ULongLong: return decodeAs(in, path, out, error); + case QMetaType::Double: return decodeAs(in, path, out, error); + case QMetaType::Float: return decodeAs(in, path, out, error); + + case QMetaType::QString: return decodeAs(in, path, out, error); + case QMetaType::QByteArray: return decodeAs(in, path, out, error); + case QMetaType::QStringList: return decodeAs(in, path, out, error); + case QMetaType::QVariantList: return decodeAs(in, path, out, error); + case QMetaType::QVariantMap: return decodeAs(in, path, out, error); + case QMetaType::QJsonArray: return decodeAs(in, path, out, error); + case QMetaType::QJsonObject: return decodeAs(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 diff --git a/cpp/logos_qt_arg_decode.h b/cpp/logos_qt_arg_decode.h new file mode 100644 index 0000000..cd636e8 --- /dev/null +++ b/cpp/logos_qt_arg_decode.h @@ -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. +// +// 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 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. 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include "logos_codec.h" +#include "logos_json_convert.h" +#include "logos_types.h" // LogosResult + +namespace logos { + +namespace detail { + +// QtArgCodec::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 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 +struct QtArgCodec>> { + static T from(const QVariant& v, const std::string& path) + { + return logos::fromJson(qvariantToNlohmann(v), path); + } +}; + +template <> struct QtArgCodec { + static QString from(const QVariant& v, const std::string& path) + { + return QString::fromStdString( + logos::fromJson(qvariantToNlohmann(v), path)); + } +}; + +// bstr — Codec> 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 { + static QByteArray from(const QVariant& v, const std::string& path) + { + const std::vector bytes = + logos::fromJson>(qvariantToNlohmann(v), path); + return QByteArray(reinterpret_cast(bytes.data()), + static_cast(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 { + static QStringList from(const QVariant& v, const std::string& path) + { + const std::vector items = + logos::fromJson>(qvariantToNlohmann(v), path); + QStringList out; + out.reserve(static_cast(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 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 { + static QVariantList from(const QVariant& v, const std::string& path) + { + logos::fromJson>(qvariantToNlohmann(v), path); + return v.toList(); + } +}; + +template <> struct QtArgCodec { + static QVariantMap from(const QVariant& v, const std::string& path) + { + logos::fromJson>( + qvariantToNlohmann(v), path); + return v.toMap(); + } +}; + +template <> struct QtArgCodec { + static QJsonArray from(const QVariant& v, const std::string& path) + { + logos::fromJson>(qvariantToNlohmann(v), path); + return qvariant_cast(v); + } +}; + +template <> struct QtArgCodec { + static QJsonObject from(const QVariant& v, const std::string& path) + { + logos::fromJson>( + qvariantToNlohmann(v), path); + return qvariant_cast(v); + } +}; + +// `any` — verbatim, deliberately. See the header comment. +template <> struct QtArgCodec { + 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 { + static LogosResult from(const QVariant& v, const std::string&) + { + return v.value(); + } +}; + +} // 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 +T qtArgFromVariant(const QVariant& v, const std::string& path) +{ + return detail::QtArgCodec>::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 +QVariantList qtArgListOf(const QVariant& v, const std::string& path) +{ + logos::fromJson>(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 diff --git a/cpp/qt_provider_object.cpp b/cpp/qt_provider_object.cpp new file mode 100644 index 0000000..40c97dc --- /dev/null +++ b/cpp/qt_provider_object.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +// ── QMetaObject dispatch helpers (moved from module_proxy.cpp) ────────────── + +#define INVOKE_METHOD_WITH_RETURN(returnType, castType) \ + do { \ + castType* result = static_cast(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 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 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 from a single QVariantList — and because QVariantList is + // implicitly convertible to QVariant, QList's initializer_list + // 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{}; + 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(data); } + ); + break; + } + case QMetaType::LongLong: { + auto value = new qlonglong(arg.toLongLong()); + scopedArgs.emplace_back( + Q_ARG(qlonglong, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::ULongLong: { + auto value = new qulonglong(arg.toULongLong()); + scopedArgs.emplace_back( + Q_ARG(qulonglong, *value), + [](const void* data) { delete static_cast(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(data); } + ); + break; + } + case QMetaType::QVariantMap: { + auto value = new QVariantMap(arg.toMap()); + scopedArgs.emplace_back( + Q_ARG(QVariantMap, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::QStringList: { + auto value = new QStringList(arg.toStringList()); + scopedArgs.emplace_back( + Q_ARG(QStringList, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::QByteArray: { + auto value = new QByteArray(arg.toByteArray()); + scopedArgs.emplace_back( + Q_ARG(QByteArray, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::QUrl: { + auto value = new QUrl(arg.toUrl()); + scopedArgs.emplace_back( + Q_ARG(QUrl, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::Bool: { + auto value = new bool(arg.toBool()); + scopedArgs.emplace_back( + Q_ARG(bool, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::Double: { + auto value = new double(arg.toDouble()); + scopedArgs.emplace_back( + Q_ARG(double, *value), + [](const void* data) { delete static_cast(data); } + ); + break; + } + case QMetaType::Float: { + auto value = new float(arg.toFloat()); + scopedArgs.emplace_back( + Q_ARG(float, *value), + [](const void* data) { delete static_cast(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(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(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(m_module); + return pi ? pi->name() : QString(); +} + +QString QtProviderObject::providerVersion() const +{ + PluginInterface* pi = qobject_cast(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(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()) { + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, nullptr, nullptr); + if (success) result = QVariant(true); + } else if (returnType == QMetaType::fromType()) { + bool v = false; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "bool"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + int v = 0; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "int"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + double v = 0.0; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "double"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + float v = 0.0f; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "float"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + QString v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QString"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + LogosResult v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "LogosResult"); + if (success) result = QVariant::fromValue(v); + } else if (returnType == QMetaType::fromType()) { + QVariant v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariant"); + if (success) result = v; + } else if (returnType == QMetaType::fromType()) { + QJsonArray v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QJsonArray"); + if (success) result = QVariant(v); + } else if (returnType == QMetaType::fromType()) { + QVariantList v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariantList"); + if (success) result = QVariant::fromValue(v); + } else if (returnType == QMetaType::fromType()) { + QVariantMap v; + success = invokeMethodByArgCount(m_module, methodName, coercedArgs, &v, "QVariantMap"); + if (success) result = QVariant::fromValue(v); + } else if (returnType == QMetaType::fromType()) { + 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(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" diff --git a/cpp/qt_provider_object.h b/cpp/qt_provider_object.h new file mode 100644 index 0000000..b3956e1 --- /dev/null +++ b/cpp/qt_provider_object.h @@ -0,0 +1,47 @@ +#ifndef QT_PROVIDER_OBJECT_H +#define QT_PROVIDER_OBJECT_H + +#include "logos_provider_object.h" +#include + +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 diff --git a/flake.lock b/flake.lock index 7c2b6e6..7dd986a 100644 --- a/flake.lock +++ b/flake.lock @@ -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" diff --git a/flake.nix b/flake.nix index 4f38fee..d086814 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/nix/qt-host-generator.nix b/nix/qt-host-generator.nix new file mode 100644 index 0000000..8c5a31c --- /dev/null +++ b/nix/qt-host-generator.nix @@ -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"; + }; +} diff --git a/nix/qt-host.nix b/nix/qt-host.nix new file mode 100644 index 0000000..fb80cbf --- /dev/null +++ b/nix/qt-host.nix @@ -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; + }; +} diff --git a/qt-host-generator/CMakeLists.txt b/qt-host-generator/CMakeLists.txt new file mode 100644 index 0000000..81ad8e9 --- /dev/null +++ b/qt-host-generator/CMakeLists.txt @@ -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) diff --git a/qt-host-generator/lidl_compat.h b/qt-host-generator/lidl_compat.h new file mode 100644 index 0000000..0a7f477 --- /dev/null +++ b/qt-host-generator/lidl_compat.h @@ -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 +#include +#include + +// 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 diff --git a/qt-host-generator/lidl_emit_common.h b/qt-host-generator/lidl_emit_common.h new file mode 100644 index 0000000..902bbdc --- /dev/null +++ b/qt-host-generator/lidl_emit_common.h @@ -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 + +#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 diff --git a/qt-host-generator/lidl_gen_cdylib_glue.cpp b/qt-host-generator/lidl_gen_cdylib_glue.cpp new file mode 100644 index 0000000..16f519b --- /dev/null +++ b/qt-host-generator/lidl_gen_cdylib_glue.cpp @@ -0,0 +1,311 @@ +#include "lidl_gen_cdylib_glue.h" +#include "lidl_emit_common.h" + +#include +#include + +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 \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \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 \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + } + s << "#include \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 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 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 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()))\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 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 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(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()))\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; +} diff --git a/qt-host-generator/lidl_gen_cdylib_glue.h b/qt-host-generator/lidl_gen_cdylib_glue.h new file mode 100644 index 0000000..ad6e116 --- /dev/null +++ b/qt-host-generator/lidl_gen_cdylib_glue.h @@ -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 +#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); diff --git a/qt-host-generator/main.cpp b/qt-host-generator/main.cpp new file mode 100644 index 0000000..482ce9e --- /dev/null +++ b/qt-host-generator/main.cpp @@ -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: +// +// _cdylib_glue.h the *CdylibProvider (LogosProviderBase) and the +// *CdylibPlugin (PluginInterface + +// LogosProviderPlugin, Q_PLUGIN_METADATA) +// _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 +// [--concurrency multi] [--output-dir ] + +#include +#include +#include +#include + +#include "lidl_compat.h" +#include "lidl_gen_cdylib_glue.h" + +namespace { + +struct Out { QString file; QString content; }; + +int writeAll(const QList& 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 \n" + " [--concurrency multi] [--output-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 _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 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; +} diff --git a/tests/test-qt-host-generator.nix b/tests/test-qt-host-generator.nix new file mode 100644 index 0000000..f8d78c7 --- /dev/null +++ b/tests/test-qt-host-generator.nix @@ -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 +''