mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 17:51:07 +00:00
* ci: drop doctest chain pins — the qt-split chain is fully merged logoscore-cli and module-builder masters now contain the chain; the temporary --release-for pins (added so stacked-branch CI could resolve compatible cross-repo revs) default back to latest releases. * codegen: Qt-free outbound — ApiStyle::Lp typed wrappers over the lp_* C ABI Adds a third generator flavor (ApiStyle::Lp, --api-style lp) whose typed dependency wrappers + LogosModules umbrella call the logos-protocol C ABI directly via a new header-only logos::LpClient, instead of LogosAPIClient. This lets a module make outbound typed calls and event subscriptions with NO Qt in its translation units — Qt stays confined to the QRO transport (inside logos-protocol) and the generated plugin glue. - cpp/logos_lp_client.h: header-only logos::LpClient (lazy lp_client_create on a baked origin; invoke / invokeAsync / subscribe; std<->nlohmann JSON; CallError out-param) + RAII logos::LpSubscription (unsubscribes on drop) + json<->std helpers. The C++ analog of rust-sdk PluginProxy. - generator: makeHeaderLp/makeSourceLp emit the Lp wrappers; the Lp umbrella drops the LogosAPI ctor and bakes this module name as the lp_client origin (LogosModules() default-constructible). Qt/Std emission is byte-unchanged (dispatch added at the top of makeHeader/makeSource). Verified: generator builds; generated wrappers + umbrella compile to .o with ONLY cpp-sdk + logos-protocol headers + nlohmann (no Qt); cpp-sdk tests pass. * cdylib: wire the Qt-free typed dependency surface (modules()) into the impl When a cdylib module declares dependencies, the generated exports now include the Lp umbrella (logos_sdk.h) and construct LogosModules() + maybeSetLogosModules on the impl just before onContextReady — so the author can call modules().<dep>... and subscribe to dep events from a Qt-free cdylib. Guarded on module.depends so dependency-less cdylib modules are byte-unchanged. The umbrella + dep wrappers themselves are produced by the --general-only --api-style lp generation; feeding the dep .lidl files into that during the module build is the remaining build-system wiring (module-builder + plugin-qt dep resolution). * cdylib: wire modules() unconditionally (deps come from metadata, not the .lidl) The umbrella wiring was guarded on the .lidl module.depends, but a cdylib module declares its dependencies in metadata.json#dependencies — the .lidl contract.depends is typically empty — so modules() was left null and a typed outbound call segfaulted. Always include the generated logos_sdk.h umbrella and maybeSetLogosModules(impl, new LogosModules()) before onContextReady; the overload is a no-op for context-less impls and the umbrella codegen emits an (empty) logos_sdk.h for every cdylib, so this is safe in all cases. * fix(headers): ship logos_lp_client.h in the include/cpp source-export root A cdylib module's generated dep wrapper includes "logos_lp_client.h" and, transitively, "logos_result.h". The wrapper is compiled with the cpp-sdk source-export include root (include/cpp), so logos_lp_client.h must sit beside logos_result.h there — a quoted include resolves siblings relative to the including file's directory. Previously logos_lp_client.h shipped only at the top-level include/ (the CMake-export layout via cpp/ CMakeLists.txt), so it pulled in include/logos_result.h while the impl's logos_module_context.h pulled include/cpp/logos_result.h. Those are two distinct realpaths under the symlinkJoin, so #pragma once could not dedup them and StdLogosResult was redefined. Install every std header into both roots so a single TU only ever sees one logos_result.h. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cdylib): route logos_module_accept_token into the protocol TokenManager The generated module-impl export stored accepted tokens in a process-local std::map (g_tokens) that nothing ever read, so a cdylib module's OUTBOUND lp_client (modules().<dep>...) never saw the capability_module bootstrap token the host delivers at load. The automatic requestModule flow then ran unauthenticated: capability_module rejected requestModule, no per-target token was issued, and the cross-module call was rejected (returning a default-constructed result, e.g. 0). Forward the token into lp_token_save, which writes the same TokenManager::instance() singleton the cdylib's lp_client reads. The capability/token handshake now completes and typed Qt-free outbound calls return real results. Drop the dead g_tokens map + mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lidl): exclude LogosModuleContext hooks from header-derived contracts --header-to-lidl parses an impl class's public methods. An impl commonly overrides onContextReady() (and could redeclare a context accessor) in its own public section, so the derived LIDL would include onContextReady / modules / modulePath / instanceId / instancePersistencePath. Those are framework plumbing, not API methods — and feeding them to the cdylib backend breaks cdylib-eligibility (e.g. the inherited accessors' Qt-free return-type check), which is exactly what header-first universal modules now hit. Skip the reserved LogosModuleContext names in the parser so both the Qt --from-header path and the cdylib --header-to-lidl path emit clean, API-only contracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cdylib): support the full std type set in header-derived contracts Routing core universal modules through the cdylib backend surfaced gaps between the cdylib subset and what the std apiStyle handled — a universal module that built under std must also build as a header-first cdylib. - lidl parser: restore the return-shape flags (resultReturn / jsonReturn) from the parsed return TypeExpr, so a header -> .lidl -> cdylib round-trip (the universal path, needed to feed the Qt glue) preserves the semantics the impl-header parser sets from C++ types (StdLogosResult -> result; LogosMap/LogosList -> json). Without this the cdylib codegen/eligibility mis-handled result / map / list returns. - cdylib eligibility + dispatch: `void` is not a lidlBuiltinType, so the parser yields it as a Named "void" (header path uses empty name) — treat both as void in the eligibility check and the dispatch (was relying on lidlTypeToQt=="void", which didn't match Named "void" -> generated an `auto result = <void call>`). - typeSupported: accept `any` (both directions), `void`/`result` (returns), arrays-of-any, and Map ({k:v}/LogosMap) — the Qt-free-via-nlohmann set. Verified: a probe with void / LogosMap / LogosList / StdLogosResult / const returns is cdylib-eligible and dispatches correctly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lidl): carry method/event descriptions across the .lidl round-trip Header-first universal modules go header -> .lidl -> cdylib backend. The impl-header parser captures /// and /** */ doc comments into method/event descriptions, but the .lidl serializer emitted only the signature, so the descriptions were dropped — introspection (lm methods / --json, getMethods) then showed no docs (regressing the wrap-external-lib + tutorial doctests). Serialize each method/event's description as a trailing `description "..."` clause (escaped for the string literal; the lexer already decodes \\ \" \n \t) and parse it back in parseMethodDef/parseEventDef. Module description now escaped too. Verified: /// docs survive header -> .lidl -> getMethods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(lp): bound-interface wrappers are handles over umbrella-owned state The Lp interface (bind_<iface>) wrapper owned its LpClient + RAII subscriptions BY VALUE, so the idiomatic transient handle — modules().bind_calculator(p).fibonacciAsync(...) modules().bind_calculator(p).onVersionReady(...) — tore the client/subscription down when the temporary died, cancelling the async callback and the event subscription. (Sync calls completed before the temporary's destruction, so they worked; the Qt/std flavor works because its handle is thin over a LogosAPI-owned persistent client.) Make the Lp Bound wrapper a THIN, copyable handle over `State { LpClient client; vector<LpSubscription> subs; }` that the LogosModules umbrella OWNS per provider (std::map<provider, unique_ptr<State>>) for the module's lifetime. bind_<iface>(p) creates/looks up the State and returns a handle to it, so a transient handle's async/event registrations outlive it. Concrete (Static) dep wrappers are unchanged — they're already persistent umbrella members, so by-value ownership is fine there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cdylib): lenient bytes-param decode (string / array / tagged) A universal module's bstr (std::vector<uint8_t>) PARAM arrived empty when the caller sent a plain string rather than the tagged {"_bytes": base64url} form — lidlBytesFromJson only accepted the tagged object, so byteArraySize("12345") and byteArraySize(b"\x01..") both saw 0 bytes (the return direction already worked). The std path was lenient (a QString or QByteArray arg both became bytes). Accept all three forms: a plain JSON string (raw UTF-8 bytes), an array of byte values, and the tagged {"_bytes"} form (base64url). Return direction unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cdylib): a number arg to a bytes param decodes as its decimal text byteArraySize("12345") arrives as a JSON number (the logoscore CLI's type auto-detection turns the string "12345" into int 12345), and the Qt path gives QVariant(int)->QByteArray "12345" (5 bytes). The cdylib bstr decode returned 0 for a number. Treat a JSON number as its decimal text bytes (j.dump()), matching the Qt behaviour, so a bare-number arg to a bytes param round-trips identically. Verified: byteArraySize 12345 -> 5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
8.3 KiB
C++
210 lines
8.3 KiB
C++
#pragma once
|
|
|
|
// Qt-FREE typed consumer client over the logos-protocol C ABI (lp_*).
|
|
//
|
|
// This is the std/C++ analog of rust-sdk's PluginProxy: it lets a module's
|
|
// generated typed wrappers call other modules and subscribe to their events
|
|
// WITHOUT touching Qt. The only dependency is logos-protocol's `extern "C"`
|
|
// surface (logos_protocol.h) — Qt stays confined to the QRO transport inside
|
|
// logos-protocol and to the generated Qt-plugin glue, never the module's own
|
|
// translation units.
|
|
//
|
|
// The generated `<Dep>` wrappers (ApiStyle::Lp) hold a `logos::LpClient` and
|
|
// marshal std args -> nlohmann JSON -> lp_invoke -> JSON -> std return. Event
|
|
// subscriptions go through lp_subscribe and are owned by an RAII
|
|
// `LpSubscription` (mirrors rust-sdk's EventSubscription: unsubscribes on
|
|
// destruction so the callback never fires after the owner is gone).
|
|
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <vector>
|
|
|
|
#include "logos_protocol.h" // lp_* C ABI
|
|
#include "logos_call_error.h" // logos::CallError
|
|
#include "logos_result.h" // StdLogosResult
|
|
|
|
namespace logos {
|
|
|
|
// JSON -> std helpers used by the generated ApiStyle::Lp wrappers to decode
|
|
// return values and event payloads. Lenient: a type mismatch yields the
|
|
// default-constructed value (mirrors the Qt path's default-on-failure).
|
|
inline std::vector<std::string> jsonToStringVec(const nlohmann::json& j) {
|
|
std::vector<std::string> out;
|
|
if (j.is_array())
|
|
for (const auto& e : j)
|
|
if (e.is_string()) out.push_back(e.get<std::string>());
|
|
return out;
|
|
}
|
|
|
|
inline StdLogosResult jsonToStdResult(const nlohmann::json& j) {
|
|
StdLogosResult r;
|
|
if (j.is_object()) {
|
|
if (j.contains("success") && j["success"].is_boolean()) r.success = j["success"].get<bool>();
|
|
if (j.contains("value")) r.value = j["value"];
|
|
if (j.contains("error") && j["error"].is_string()) r.error = j["error"].get<std::string>();
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// RAII handle for an lp_subscription. Owns the subscription and the heap
|
|
// callback box; unsubscribes (after which no further callbacks fire) and
|
|
// frees the box on destruction. Move-only.
|
|
class LpSubscription {
|
|
public:
|
|
LpSubscription() = default;
|
|
LpSubscription(lp_subscription* sub, void* cbBox, void (*deleter)(void*))
|
|
: m_sub(sub), m_cbBox(cbBox), m_deleter(deleter) {}
|
|
|
|
LpSubscription(LpSubscription&& o) noexcept { moveFrom(o); }
|
|
LpSubscription& operator=(LpSubscription&& o) noexcept {
|
|
if (this != &o) { reset(); moveFrom(o); }
|
|
return *this;
|
|
}
|
|
LpSubscription(const LpSubscription&) = delete;
|
|
LpSubscription& operator=(const LpSubscription&) = delete;
|
|
~LpSubscription() { reset(); }
|
|
|
|
bool valid() const { return m_sub != nullptr; }
|
|
|
|
private:
|
|
void moveFrom(LpSubscription& o) {
|
|
m_sub = o.m_sub; m_cbBox = o.m_cbBox; m_deleter = o.m_deleter;
|
|
o.m_sub = nullptr; o.m_cbBox = nullptr; o.m_deleter = nullptr;
|
|
}
|
|
void reset() {
|
|
if (m_sub) { lp_unsubscribe(m_sub); m_sub = nullptr; }
|
|
if (m_cbBox && m_deleter) { m_deleter(m_cbBox); m_cbBox = nullptr; }
|
|
}
|
|
lp_subscription* m_sub = nullptr;
|
|
void* m_cbBox = nullptr;
|
|
void (*m_deleter)(void*) = nullptr;
|
|
};
|
|
|
|
// Qt-free typed client for one target module. The lp_client is created lazily
|
|
// on first use, on behalf of `origin` (the calling module's name, baked by the
|
|
// generated umbrella), over the process-default transport with the automatic
|
|
// capability/token flow that logos-protocol provides.
|
|
class LpClient {
|
|
public:
|
|
LpClient(std::string target, std::string origin)
|
|
: m_target(std::move(target)), m_origin(std::move(origin)) {}
|
|
~LpClient() { if (m_client) lp_client_destroy(m_client); }
|
|
LpClient(const LpClient&) = delete;
|
|
LpClient& operator=(const LpClient&) = delete;
|
|
|
|
// Blocking call. `args` is a JSON array. Returns the result JSON value
|
|
// (null on failure); fills `err` when non-null.
|
|
nlohmann::json invoke(const std::string& method,
|
|
const nlohmann::json& args,
|
|
CallError* err) {
|
|
lp_client* c = ensure();
|
|
if (!c) {
|
|
if (err) { err->code = "object_unavailable";
|
|
err->message = "could not create client for " + m_target;
|
|
err->origin = m_target; }
|
|
return nullptr;
|
|
}
|
|
const std::string argsStr = args.dump();
|
|
char* outRes = nullptr;
|
|
char* outErr = nullptr;
|
|
const int rc = lp_invoke(c, method.c_str(), argsStr.c_str(), 0, &outRes, &outErr);
|
|
nlohmann::json result; // null
|
|
if (rc == LP_OK) {
|
|
if (err) err->clear();
|
|
if (outRes) {
|
|
auto parsed = nlohmann::json::parse(outRes, nullptr, /*allow_exceptions=*/false);
|
|
if (!parsed.is_discarded()) result = std::move(parsed);
|
|
}
|
|
} else {
|
|
fillErr(err, outErr, rc);
|
|
}
|
|
if (outRes) lp_string_free(outRes);
|
|
if (outErr) lp_string_free(outErr);
|
|
return result;
|
|
}
|
|
|
|
// Async call. `cb` fires exactly once with the result JSON (null on
|
|
// failure / parse error). Safe to call from any thread.
|
|
void invokeAsync(const std::string& method,
|
|
const nlohmann::json& args,
|
|
std::function<void(nlohmann::json)> cb) {
|
|
lp_client* c = ensure();
|
|
if (!c) { if (cb) cb(nullptr); return; }
|
|
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
|
|
const std::string argsStr = args.dump();
|
|
lp_invoke_async(c, method.c_str(), argsStr.c_str(), 0,
|
|
&LpClient::resultTrampoline, box);
|
|
}
|
|
|
|
// Subscribe to `event`. The payload is delivered as a JSON array. The
|
|
// returned handle owns the subscription — keep it alive (the generated
|
|
// wrapper stores it) for as long as you want the callback to fire.
|
|
LpSubscription subscribe(const std::string& event,
|
|
std::function<void(nlohmann::json)> cb) {
|
|
lp_client* c = ensure();
|
|
if (!c) return {};
|
|
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
|
|
lp_subscription* sub = lp_subscribe(c, event.c_str(), &LpClient::eventTrampoline, box);
|
|
if (!sub) { delete box; return {}; }
|
|
return LpSubscription(sub, box, &LpClient::deleteBox);
|
|
}
|
|
|
|
private:
|
|
using Box = std::function<void(nlohmann::json)>;
|
|
|
|
lp_client* ensure() {
|
|
if (!m_client)
|
|
m_client = lp_client_create(m_target.c_str(), m_origin.c_str(), nullptr, nullptr);
|
|
return m_client;
|
|
}
|
|
|
|
static void resultTrampoline(int ok, const char* json, void* ud) {
|
|
auto* fn = static_cast<Box*>(ud);
|
|
nlohmann::json r; // null
|
|
if (ok && json) {
|
|
auto parsed = nlohmann::json::parse(json, nullptr, false);
|
|
if (!parsed.is_discarded()) r = std::move(parsed);
|
|
}
|
|
(*fn)(std::move(r));
|
|
delete fn; // result callback fires exactly once
|
|
}
|
|
|
|
static void eventTrampoline(const char* /*eventName*/, const char* dataJson, void* ud) {
|
|
auto* fn = static_cast<Box*>(ud);
|
|
nlohmann::json r = nlohmann::json::array();
|
|
if (dataJson) {
|
|
auto parsed = nlohmann::json::parse(dataJson, nullptr, false);
|
|
if (!parsed.is_discarded()) r = std::move(parsed);
|
|
}
|
|
(*fn)(std::move(r));
|
|
}
|
|
|
|
static void deleteBox(void* p) { delete static_cast<Box*>(p); }
|
|
|
|
static void fillErr(CallError* err, const char* errJson, int rc) {
|
|
if (!err) return;
|
|
err->code = "call_failed";
|
|
err->message = "lp_invoke failed (rc=" + std::to_string(rc) + ")";
|
|
err->origin.clear();
|
|
if (errJson) {
|
|
auto j = nlohmann::json::parse(errJson, nullptr, false);
|
|
if (!j.is_discarded() && j.is_object()) {
|
|
if (j.contains("code") && j["code"].is_string()) err->code = j["code"].get<std::string>();
|
|
if (j.contains("message") && j["message"].is_string()) err->message = j["message"].get<std::string>();
|
|
if (j.contains("origin") && j["origin"].is_string()) err->origin = j["origin"].get<std::string>();
|
|
}
|
|
}
|
|
}
|
|
|
|
std::string m_target;
|
|
std::string m_origin;
|
|
lp_client* m_client = nullptr;
|
|
};
|
|
|
|
} // namespace logos
|