diff --git a/cpp-generator/experimental/impl_header_parser.cpp b/cpp-generator/experimental/impl_header_parser.cpp index ead1556..a321688 100644 --- a/cpp-generator/experimental/impl_header_parser.cpp +++ b/cpp-generator/experimental/impl_header_parser.cpp @@ -445,8 +445,22 @@ ImplParseResult parseImplHeader(const QString& headerPath, QString decl = line.left(line.size() - 1).trimmed(); MethodDecl md; if (parseMethodLine(decl, md)) { - md.description = joinDocLines(pendingDoc); - result.module.methods.append(md); + // LogosModuleContext lifecycle hooks / context accessors are + // framework plumbing, not part of the module's API contract. + // An impl commonly overrides `onContextReady()` (and could + // re-declare an accessor) in its own public section, so the + // header parser would otherwise emit them into the derived + // LIDL — breaking cdylib eligibility (e.g. the inherited + // accessors' Qt-free-subset check) and exposing non-API + // methods. Skip the reserved names regardless of access. + static const QSet reserved = { + "onContextReady", "modules", "modulePath", + "instanceId", "instancePersistencePath" + }; + if (!reserved.contains(md.name)) { + md.description = joinDocLines(pendingDoc); + result.module.methods.append(md); + } } } pendingDoc.clear(); diff --git a/cpp-generator/experimental/lidl_gen_cdylib.cpp b/cpp-generator/experimental/lidl_gen_cdylib.cpp index e863f09..871edc1 100644 --- a/cpp-generator/experimental/lidl_gen_cdylib.cpp +++ b/cpp-generator/experimental/lidl_gen_cdylib.cpp @@ -9,16 +9,21 @@ bool lidlIsStdConvertible(const TypeExpr& te); namespace { -// The cdylib-supported subset: std-convertible LIDL types only. +// The cdylib-supported subset: std-convertible LIDL types only — the same +// Qt-free set the std apiStyle handled, so any universal module that built +// under std also builds as a header-first cdylib. bool typeSupported(const TypeExpr& te, bool isReturn) { if (te.kind == TypeExpr::Primitive) { if (te.name == "tstr" || te.name == "bstr" || te.name == "int" || te.name == "uint" || te.name == "float64" || te.name == "bool") return true; - // result (StdLogosResult) and any (LogosMap/LogosList via jsonReturn) - // are fine as RETURNS — the generator routes them through nlohmann. - if (isReturn && (te.name == "result" || te.name == "any")) + // any (LogosMap/LogosList/json) routes through nlohmann in either + // direction; result (StdLogosResult) and void only make sense as a + // return. All Qt-free. + if (te.name == "any") + return true; + if (isReturn && (te.name == "result" || te.name == "void")) return true; return false; } @@ -26,8 +31,11 @@ bool typeSupported(const TypeExpr& te, bool isReturn) const TypeExpr& e = te.elements[0]; return e.kind == TypeExpr::Primitive && (e.name == "tstr" || e.name == "int" || e.name == "uint" - || e.name == "float64" || e.name == "bool"); + || e.name == "float64" || e.name == "bool" || e.name == "any"); } + // Maps ({k: v}, i.e. LogosMap) round-trip through nlohmann too. + if (te.kind == TypeExpr::Map) + return true; return false; } @@ -142,8 +150,12 @@ bool lidlCdylibSupported(const ModuleDecl& module, QString* error) return false; } } + // `void` is not a lidlBuiltinType, so the .lidl parser yields it as a + // Named type "void" (the impl-header parser writes "-> void"); an empty + // name is the in-memory void from the header path. Treat both as void. const bool voidReturn = - md.returnType.kind == TypeExpr::Primitive && md.returnType.name.isEmpty(); + md.returnType.name == "void" + || (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.isEmpty()); if (!voidReturn && !md.jsonReturn && !md.resultReturn && !typeSupported(md.returnType, /*isReturn=*/true)) { if (error) @@ -191,7 +203,14 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << "#include \n"; s << "#include \n"; s << "#include \n"; - s << "#include \n\n"; + s << "#include \n"; + // The Qt-free typed dependency surface: LogosModules (behind modules()) + // built from this module's dependencies (metadata.json#dependencies), + // calling the lp_* C ABI — no Qt in the cdylib. The umbrella codegen + // emits logos_sdk.h for every cdylib module (empty when there are no + // dependencies), so this include is always available. + s << "#include \"logos_sdk.h\"\n"; + s << "\n"; // -- shared statics ------------------------------------------------------ s << "namespace {\n\n"; @@ -199,8 +218,6 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << "logos_module_emit_cb g_emitCb = nullptr;\n"; s << "void* g_emitUd = nullptr;\n"; s << "std::mutex g_emitMutex;\n"; - s << "std::map g_tokens;\n"; - s << "std::mutex g_tokensMutex;\n"; s << "std::mutex g_ctxMutex;\n"; s << "bool g_ctxStored = false;\n"; s << "std::string g_ctxPath, g_ctxId, g_ctxPersist;\n"; @@ -236,6 +253,30 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << "std::vector lidlBytesFromJson(const nlohmann::json& j)\n{\n"; s << " std::vector out;\n"; + s << " // Lenient bytes decode (matches the std path, where a QString or\n"; + s << " // QByteArray arg both became bytes): a caller may send the tagged\n"; + s << " // {\"_bytes\": base64url} form, a plain string (raw UTF-8 bytes), or\n"; + s << " // an array of byte values. Only the tagged form needs base64.\n"; + s << " if (j.is_string()) {\n"; + s << " const std::string s = j.get();\n"; + s << " out.assign(s.begin(), s.end());\n"; + s << " return out;\n"; + s << " }\n"; + s << " if (j.is_number()) {\n"; + s << " // A number arg becomes its decimal text as bytes — matches\n"; + s << " // Qt's QVariant(int)->QByteArray, so a caller (or the\n"; + s << " // logoscore CLI's type auto-detection) passing a bare number\n"; + s << " // to a bytes param behaves the same as the Qt path.\n"; + s << " const std::string s = j.dump();\n"; + s << " out.assign(s.begin(), s.end());\n"; + s << " return out;\n"; + s << " }\n"; + s << " if (j.is_array()) {\n"; + s << " for (const auto& e : j)\n"; + s << " if (e.is_number_integer() || e.is_number_unsigned())\n"; + s << " out.push_back(static_cast(e.get() & 0xff));\n"; + s << " return out;\n"; + s << " }\n"; s << " if (!j.is_object() || j.size() != 1 || !j.contains(\"_bytes\") || !j[\"_bytes\"].is_string())\n"; s << " return out;\n"; s << " const std::string s64 = j[\"_bytes\"].get();\n"; @@ -307,6 +348,12 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << " if (!g_emitCb) return;\n"; s << " }\n"; s << " g_hookFired.store(true, std::memory_order_release);\n"; + // Wire the Qt-free typed dependency surface BEFORE onContextReady, so the + // author can call modules().... / subscribe to events from the hook. + // maybeSetLogosModules is a no-op for impls that don't derive + // LogosModuleContext, so this is safe for context-less cdylibs. The + // LogosModules instance lives for the module's lifetime. + s << " _logos_codegen_::maybeSetLogosModules(lidlImpl(), new LogosModules());\n"; s << " _logos_codegen_::maybeSetContext(lidlImpl(), path, id, persist);\n"; s << "}\n\n"; @@ -334,7 +381,12 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, if (i + 1 < md.params.size()) call += ", "; } call += ")"; - const bool voidReturn = lidlTypeToQt(md.returnType) == "void"; + // `void` parses as a Named type "void" from a .lidl (it isn't a + // lidlBuiltinType); empty name is the header path's in-memory void. + const bool voidReturn = + md.returnType.name == "void" + || (md.returnType.kind == TypeExpr::Primitive && md.returnType.name.isEmpty()) + || lidlTypeToQt(md.returnType) == "void"; if (voidReturn) { s << " " << call << ";\n"; s << " return lidlStrdup(\"true\");\n"; @@ -380,9 +432,13 @@ QString lidlMakeModuleImplExports(const ModuleDecl& module, s << "int logos_module_accept_token(const char* module_name, const char* token)\n{\n"; s << " if (!module_name || !token) return -1;\n"; - s << " std::lock_guard lock(g_tokensMutex);\n"; - s << " g_tokens[module_name] = token;\n"; - s << " return 0;\n}\n\n"; + s << " // Seed the protocol's shared TokenManager so this module's OUTBOUND\n"; + s << " // lp_client (modules()....) can authenticate calls. In\n"; + s << " // particular the capability_module bootstrap token the host\n"; + s << " // delivers at load lets the automatic requestModule flow fetch a\n"; + s << " // per-target token on the first cross-module call. lp_token_save\n"; + s << " // writes the same TokenManager::instance() the lp_client reads.\n"; + s << " return lp_token_save(module_name, token);\n}\n\n"; s << "const char* logos_module_get_protocol_version(void)\n{\n"; s << " return LOGOS_PROTOCOL_VERSION_STRING;\n}\n\n"; diff --git a/cpp-generator/experimental/lidl_parser.cpp b/cpp-generator/experimental/lidl_parser.cpp index c5e3c3f..318f7a1 100644 --- a/cpp-generator/experimental/lidl_parser.cpp +++ b/cpp-generator/experimental/lidl_parser.cpp @@ -158,6 +158,30 @@ private: if (!expect(LidlToken::RParen, "method parameters")) return false; if (!expect(LidlToken::Arrow, "method return type")) return false; if (!parseTypeExpr(md.returnType)) return false; + // Optional trailing doc: `-> ret description "..."`. Carries the + // method's doc comment across a .lidl round-trip so introspection + // (lm / getMethods) still surfaces it. + if (at(LidlToken::Description)) { + ++m_pos; + if (!at(LidlToken::StringLit)) { error("Expected string after method 'description'"); return false; } + md.description = current().text; ++m_pos; + } + // Restore the return-shape flags from the parsed type so a .lidl + // round-trip carries the same semantics the impl-header parser sets + // (it derives them from C++ types: StdLogosResult -> result, + // LogosMap/LogosList -> json). Without this, a header-first universal + // module (header -> .lidl -> cdylib backend) loses the flags and the + // cdylib codegen/eligibility mis-handles result / map / list returns. + { + const TypeExpr& rt = md.returnType; + md.resultReturn = (rt.kind == TypeExpr::Primitive && rt.name == "result"); + md.jsonReturn = + rt.kind == TypeExpr::Map + || (rt.kind == TypeExpr::Primitive && rt.name == "any") + || (rt.kind == TypeExpr::Array && rt.elements.size() == 1 + && rt.elements[0].kind == TypeExpr::Primitive + && rt.elements[0].name == "any"); + } mod.methods.append(md); return true; } @@ -169,6 +193,11 @@ private: if (!expect(LidlToken::LParen, "event parameters")) return false; if (!parseParams(ed.params)) return false; if (!expect(LidlToken::RParen, "event parameters")) return false; + if (at(LidlToken::Description)) { + ++m_pos; + if (!at(LidlToken::StringLit)) { error("Expected string after event 'description'"); return false; } + ed.description = current().text; ++m_pos; + } mod.events.append(ed); return true; } diff --git a/cpp-generator/experimental/lidl_serializer.cpp b/cpp-generator/experimental/lidl_serializer.cpp index df8ad3f..a22976e 100644 --- a/cpp-generator/experimental/lidl_serializer.cpp +++ b/cpp-generator/experimental/lidl_serializer.cpp @@ -1,6 +1,17 @@ #include "lidl_serializer.h" #include +// Escape a description for a "..."-delimited LIDL string literal (the lexer +// decodes \\ \" \n \t). Keeps method/event docs intact across a .lidl +// round-trip so introspection (lm / getMethods) still shows them. +static QString lidlEscapeStr(QString in) { + in.replace('\\', "\\\\"); + in.replace('"', "\\\""); + in.replace('\n', "\\n"); + in.replace('\t', "\\t"); + return in; +} + static QString serializeTypeExpr(const TypeExpr& te) { switch (te.kind) { case TypeExpr::Primitive: case TypeExpr::Named: return te.name; @@ -23,16 +34,16 @@ QString lidlSerialize(const ModuleDecl& module) { QTextStream s(&out); s << "module " << module.name << " {\n"; if (!module.version.isEmpty()) s << " version \"" << module.version << "\"\n"; - if (!module.description.isEmpty()) s << " description \"" << module.description << "\"\n"; + if (!module.description.isEmpty()) s << " description \"" << lidlEscapeStr(module.description) << "\"\n"; if (!module.category.isEmpty()) s << " category \"" << module.category << "\"\n"; s << " depends ["; for (int i = 0; i < module.depends.size(); ++i) { s << module.depends[i]; if (i + 1 < module.depends.size()) s << ", "; } s << "]\n"; for (const TypeDecl& td : module.types) { s << "\n type " << td.name << " {\n"; for (const FieldDecl& fd : td.fields) { s << " "; if (fd.optional) s << "? "; s << fd.name << ": " << serializeTypeExpr(fd.type) << "\n"; } s << " }\n"; } if (!module.methods.isEmpty()) s << "\n"; - for (const MethodDecl& md : module.methods) { s << " method " << md.name << "("; serializeParams(s, md.params); s << ") -> " << serializeTypeExpr(md.returnType) << "\n"; } + for (const MethodDecl& md : module.methods) { s << " method " << md.name << "("; serializeParams(s, md.params); s << ") -> " << serializeTypeExpr(md.returnType); if (!md.description.isEmpty()) s << " description \"" << lidlEscapeStr(md.description) << "\""; s << "\n"; } if (!module.events.isEmpty()) s << "\n"; - for (const EventDecl& ed : module.events) { s << " event " << ed.name << "("; serializeParams(s, ed.params); s << ")\n"; } + for (const EventDecl& ed : module.events) { s << " event " << ed.name << "("; serializeParams(s, ed.params); s << ")"; if (!ed.description.isEmpty()) s << " description \"" << lidlEscapeStr(ed.description) << "\""; s << "\n"; } s << "}\n"; return out; } diff --git a/cpp-generator/legacy/generator_lib.cpp b/cpp-generator/legacy/generator_lib.cpp index 73de3ca..28e2a9a 100644 --- a/cpp-generator/legacy/generator_lib.cpp +++ b/cpp-generator/legacy/generator_lib.cpp @@ -196,6 +196,8 @@ static bool isQtRefType(const QString& t) QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode) { + if (apiStyle == ApiStyle::Lp) + return makeHeaderLp(moduleName, className, methods, events, bindMode); QString h; QTextStream s(&h); s << "#pragma once\n"; @@ -379,6 +381,8 @@ QString makeHeader(const QString& moduleName, const QString& className, const QJ QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle, const QJsonArray& events, BindMode bindMode) { + if (apiStyle == ApiStyle::Lp) + return makeSourceLp(moduleName, className, headerBaseName, methods, events, bindMode); QString c; QTextStream s(&c); s << "#include \"" << headerBaseName << "\"\n\n"; @@ -811,3 +815,265 @@ QVector parseProviderHeader(const QString& headerPath, QTextStream return methods; } + +// ─── ApiStyle::Lp (Qt-free) wrapper emission ───────────────────────────── +// +// Same std-typed surface as ApiStyle::Std, but the generated body calls the +// logos-protocol C ABI through logos::LpClient instead of LogosAPIClient, so +// the wrapper's translation unit pulls in no Qt. Used for the cdylib outbound +// path (a Qt-free module calling its dependencies / subscribing to their +// events). The class still holds a single target; Static bakes it, Bound takes +// it at construction (interface dependencies). + +// std value -> nlohmann::json push expression. nlohmann handles +// string/int64/double/bool/vector/json (LogosMap/LogosList) directly; +// StdLogosResult is encoded as its {success,value,error} object. +static QString lpPushExpr(const QString& qtType, const QString& argName) +{ + const QString std = mapParamTypeStd(qtType); + if (std == "StdLogosResult") + return "nlohmann::json{{\"success\", " + argName + ".success}, {\"value\", " + + argName + ".value}, {\"error\", " + argName + ".error}}"; + return argName; +} + +// nlohmann::json -> std value expression for a return value or an event arg. +// Lenient: a type mismatch yields the default-constructed value. +static QString lpFromJsonExpr(const QString& qtType, const QString& jv) +{ + const QString t = mapReturnTypeStd(qtType); + if (t == "void") return QString(); + if (t == "std::string") return "(" + jv + ".is_string() ? " + jv + ".get() : std::string())"; + if (t == "int64_t") return "(" + jv + ".is_number_integer() ? " + jv + ".get() : (" + jv + ".is_number() ? static_cast(" + jv + ".get()) : (int64_t)0))"; + if (t == "double") return "(" + jv + ".is_number() ? " + jv + ".get() : 0.0)"; + if (t == "bool") return "(" + jv + ".is_boolean() ? " + jv + ".get() : false)"; + if (t == "std::vector") return "logos::jsonToStringVec(" + jv + ")"; + if (t == "LogosMap") return "(" + jv + ".is_object() ? " + jv + " : LogosMap::object())"; + if (t == "LogosList") return "(" + jv + ".is_array() ? " + jv + " : LogosList::array())"; + if (t == "StdLogosResult") return "logos::jsonToStdResult(" + jv + ")"; + return jv; +} + +// Build the callback parameter list (std types, by-ref where appropriate) for +// a typed event accessor `on`. +static QString lpEventCbParams(const QJsonArray& evParams) +{ + QString cbParams; + for (int i = 0; i < evParams.size(); ++i) { + const QJsonObject p = evParams.at(i).toObject(); + const QString pt = mapParamTypeStd(p.value("type").toString()); + if (isStdRefType(pt)) cbParams += "const " + pt + "& "; + else cbParams += pt + " "; + cbParams += p.value("name").toString(); + if (i + 1 < evParams.size()) cbParams += ", "; + } + return cbParams; +} + +static QString lpEventAccessorName(const QString& evName) +{ + QString cap = evName; + if (!cap.isEmpty()) cap[0] = cap[0].toUpper(); + return QString("on") + cap; +} + +QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode) +{ + (void)moduleName; + QString h; + QTextStream s(&h); + s << "#pragma once\n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \n"; + s << "#include \"logos_json.h\"\n"; + s << "#include \"logos_result.h\"\n"; + s << "#include \"logos_call_error.h\"\n"; + s << "#include \"logos_lp_client.h\"\n\n"; + + s << "class " << className << " {\n"; + s << "public:\n"; + if (bindMode == BindMode::Bound) { + // Bound (interface) wrappers are THIN, copyable handles over + // umbrella-owned persistent State, so a transient + // `modules().bind_x(provider)` temporary can register an async + // callback / event subscription that OUTLIVES the temporary: the + // LpClient and its RAII subscriptions live in the umbrella for the + // module's lifetime (mirroring the LogosAPI-owned-client model the + // Qt/std flavor relies on). Owning the client by-value in the handle + // would tear the subscription down when the temporary dies. + s << " struct State {\n"; + s << " logos::LpClient client;\n"; + s << " std::vector subs;\n"; + s << " State(const std::string& target, const std::string& origin) : client(target, origin) {}\n"; + s << " };\n"; + s << " explicit " << className << "(State* state) : m_state(state) {}\n\n"; + } else { + s << " explicit " << className << "(const std::string& origin);\n\n"; + } + + // Typed event subscribers — one per declared event. + for (const QJsonValue& ev : events) { + const QJsonObject eo = ev.toObject(); + const QString evName = eo.value("name").toString(); + if (evName.isEmpty()) continue; + s << " bool " << lpEventAccessorName(evName) + << "(std::function callback);\n"; + } + if (!events.isEmpty()) s << "\n"; + + // Methods: sync (with optional CallError out-param) + async overload. + for (const QJsonValue& v : methods) { + const QJsonObject o = v.toObject(); + if (!o.value("isInvokable").toBool()) continue; + const QString name = o.value("name").toString(); + const QString ret = mapReturnTypeStd(o.value("returnType").toString()); + const QJsonArray params = o.value("parameters").toArray(); + + s << " " << ret << " " << name << "("; + for (int i = 0; i < params.size(); ++i) { + const QJsonObject p = params.at(i).toObject(); + const QString pt = mapParamTypeStd(p.value("type").toString()); + if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString(); + else s << pt << " " << p.value("name").toString(); + if (i + 1 < params.size()) s << ", "; + } + if (!params.isEmpty()) s << ", "; + s << "logos::CallError* err = nullptr);\n"; + + const QString asyncCb = (ret == "void") + ? QString("std::function") + : QString("std::function"; + s << " void " << name << "Async("; + for (int i = 0; i < params.size(); ++i) { + const QJsonObject p = params.at(i).toObject(); + const QString pt = mapParamTypeStd(p.value("type").toString()); + if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString(); + else s << pt << " " << p.value("name").toString(); + if (i + 1 < params.size()) s << ", "; + } + if (!params.isEmpty()) s << ", "; + s << asyncCb << " callback);\n"; + } + + s << "\nprivate:\n"; + if (bindMode == BindMode::Bound) { + s << " State* m_state; // umbrella-owned; the handle does not own it\n"; + } else { + s << " logos::LpClient m_client;\n"; + if (!events.isEmpty()) s << " std::vector m_subs;\n"; + } + s << "};\n"; + return h; +} + +QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events, BindMode bindMode) +{ + QString c; + QTextStream s(&c); + s << "#include \"" << headerBaseName << "\"\n"; + s << "#include \n\n"; + + // How the wrapper reaches its persistent LpClient + subscription store. + // Static (concrete dep): owns them by value — the wrapper itself is a + // persistent member of the umbrella. Bound (interface): a thin handle + // over umbrella-owned State, so a transient handle's async/event + // registrations survive (the ctor is inline in the header). + const QString clientExpr = (bindMode == BindMode::Bound) ? "m_state->client" : "m_client"; + const QString subsExpr = (bindMode == BindMode::Bound) ? "m_state->subs" : "m_subs"; + + // Constructor: LpClient(target, origin). Static bakes the dep name in the + // .cpp ctor; Bound's ctor is inline (takes the umbrella-owned State*). + if (bindMode != BindMode::Bound) + s << className << "::" << className << "(const std::string& origin)" + << " : m_client(\"" << moduleName << "\", origin) {}\n\n"; + + // Typed event adapters: subscribe via lp_subscribe (JSON array payload), + // decode into typed args, keep the RAII subscription alive in m_subs. + for (const QJsonValue& ev : events) { + const QJsonObject eo = ev.toObject(); + const QString evName = eo.value("name").toString(); + if (evName.isEmpty()) continue; + const QJsonArray evParams = eo.value("params").toArray(); + s << "bool " << className << "::" << lpEventAccessorName(evName) + << "(std::function callback) {\n"; + s << " if (!callback) return false;\n"; + s << " auto _sub = " << clientExpr << ".subscribe(\"" << evName << "\", [callback](nlohmann::json _a) {\n"; + s << " if (!_a.is_array() || _a.size() < " << evParams.size() << ") return;\n"; + s << " callback("; + for (int i = 0; i < evParams.size(); ++i) { + const QJsonObject p = evParams.at(i).toObject(); + s << lpFromJsonExpr(p.value("type").toString(), QString("_a.at(%1)").arg(i)); + if (i + 1 < evParams.size()) s << ", "; + } + s << ");\n"; + s << " });\n"; + s << " if (!_sub.valid()) return false;\n"; + s << " " << subsExpr << ".push_back(std::move(_sub));\n"; + s << " return true;\n"; + s << "}\n\n"; + } + + // Methods. + for (const QJsonValue& v : methods) { + const QJsonObject o = v.toObject(); + if (!o.value("isInvokable").toBool()) continue; + const QString name = o.value("name").toString(); + const QString qtRet = o.value("returnType").toString(); + const QString ret = mapReturnTypeStd(qtRet); + const QJsonArray params = o.value("parameters").toArray(); + + auto emitParams = [&]() { + for (int i = 0; i < params.size(); ++i) { + const QJsonObject p = params.at(i).toObject(); + const QString pt = mapParamTypeStd(p.value("type").toString()); + if (isStdRefType(pt)) s << "const " << pt << "& " << p.value("name").toString(); + else s << pt << " " << p.value("name").toString(); + if (i + 1 < params.size()) s << ", "; + } + }; + auto emitArgsArray = [&]() { + s << " nlohmann::json _args = nlohmann::json::array();\n"; + for (const QJsonValue& pv : params) { + const QJsonObject p = pv.toObject(); + s << " _args.push_back(" << lpPushExpr(p.value("type").toString(), p.value("name").toString()) << ");\n"; + } + }; + + // Sync + s << ret << " " << className << "::" << name << "("; + emitParams(); + if (!params.isEmpty()) s << ", "; + s << "logos::CallError* err) {\n"; + emitArgsArray(); + if (ret == "void") { + s << " " << clientExpr << ".invoke(\"" << name << "\", _args, err);\n"; + } else { + s << " nlohmann::json _r = " << clientExpr << ".invoke(\"" << name << "\", _args, err);\n"; + s << " return " << lpFromJsonExpr(qtRet, "_r") << ";\n"; + } + s << "}\n\n"; + + // Async + const QString asyncCb = (ret == "void") + ? QString("std::function") + : QString("std::function"; + s << "void " << className << "::" << name << "Async("; + emitParams(); + if (!params.isEmpty()) s << ", "; + s << asyncCb << " callback) {\n"; + s << " if (!callback) return;\n"; + emitArgsArray(); + s << " " << clientExpr << ".invokeAsync(\"" << name << "\", _args, [callback](nlohmann::json _r) {\n"; + if (ret == "void") { + s << " (void)_r; callback();\n"; + } else { + s << " callback(" << lpFromJsonExpr(qtRet, "_r") << ");\n"; + } + s << " });\n"; + s << "}\n\n"; + } + return c; +} diff --git a/cpp-generator/legacy/generator_lib.h b/cpp-generator/legacy/generator_lib.h index 132b7ef..6feb656 100644 --- a/cpp-generator/legacy/generator_lib.h +++ b/cpp-generator/legacy/generator_lib.h @@ -19,7 +19,16 @@ struct ParsedMethod { // is Qt for backward compatibility; `interface: "universal"` modules // flip to Std via the -DLOGOS_API_STYLE=std CMake flag the module // builder threads through. -enum class ApiStyle { Qt, Std }; +// Qt — legacy Qt-typed surface (QString/QVariant…), body via LogosAPIClient. +// Std — std-typed surface, but the body still bridges through QVariant + +// LogosAPIClient (so the wrapper .cpp links qt-sdk). +// Lp — std-typed surface AND a Qt-free body: the wrapper calls the +// logos-protocol C ABI (lp_*) directly via logos::LpClient, so the +// module's translation units never include Qt or link qt-sdk. This is +// the path that lets a cdylib module do outbound typed calls/event +// subscriptions while staying Qt-free (Qt confined to the QRO transport +// inside logos-protocol + the generated plugin glue). +enum class ApiStyle { Qt, Std, Lp }; // Whether the generated wrapper targets ONE fixed module (the historical // behaviour) or binds to a module name chosen at runtime. @@ -65,6 +74,13 @@ QString toQVariantConversion(const QString& type, const QString& argExpr); // already decided; the emitted code never bakes it into a call. QString makeHeader(const QString& moduleName, const QString& className, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static); QString makeSource(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, ApiStyle apiStyle = ApiStyle::Qt, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static); + +// Qt-free (ApiStyle::Lp) wrapper emission. Same std-typed surface as the Std +// flavor, but the generated body calls the logos-protocol C ABI through +// logos::LpClient instead of LogosAPIClient — no Qt in the wrapper's TU. +// makeHeader/makeSource dispatch here when apiStyle == ApiStyle::Lp. +QString makeHeaderLp(const QString& moduleName, const QString& className, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static); +QString makeSourceLp(const QString& moduleName, const QString& className, const QString& headerBaseName, const QJsonArray& methods, const QJsonArray& events = {}, BindMode bindMode = BindMode::Static); QVector parseProviderHeader(const QString& headerPath, QTextStream& err); #endif // GENERATOR_LIB_H diff --git a/cpp-generator/legacy/main.cpp b/cpp-generator/legacy/main.cpp index 179d478..821f338 100644 --- a/cpp-generator/legacy/main.cpp +++ b/cpp-generator/legacy/main.cpp @@ -411,8 +411,73 @@ static bool writeUmbrellaHeader(const QString& genDirPath, QTextStream& err) return true; } -static bool writeUmbrellaHeaderFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err) +static bool writeUmbrellaHeaderFromDeps(const QString& genDirPath, const QJsonArray& deps, const QStringList& interfaceNames, QTextStream& err, ApiStyle apiStyle = ApiStyle::Qt, const QString& originName = QString()) { + // Lp (Qt-free) umbrella: no LogosAPI. Each dep wrapper self-creates its + // lp_client on behalf of `originName` (this module), so the struct is + // default-constructible and the glue just does `new LogosModules()`. + if (apiStyle == ApiStyle::Lp) { + QDir genDir(genDirPath); + QString content; + QTextStream s(&content); + s << "#pragma once\n"; + s << "#include \n"; + if (!interfaceNames.isEmpty()) { + s << "#include \n"; + s << "#include \n"; + } + for (const QJsonValue& v : deps) { + if (!v.isString()) continue; + s << "#include \"" << v.toString() << "_api.h\"\n"; + } + for (const QString& ifaceName : interfaceNames) + s << "#include \"" << ifaceName << "_api.h\"\n"; + s << "\n"; + s << "struct LogosModules {\n"; + s << " LogosModules()"; + bool first = true; + for (const QJsonValue& v : deps) { + if (!v.isString()) continue; + s << (first ? " : " : ",\n "); + first = false; + s << v.toString() << "(\"" << originName << "\")"; + } + s << " {}\n"; + for (const QJsonValue& v : deps) { + if (!v.isString()) continue; + const QString depName = v.toString(); + s << " " << toPascalCase(depName) << " " << depName << ";\n"; + } + // Interface dependencies: bound at runtime. The bound wrapper is a + // THIN handle over per-provider State the umbrella OWNS for the + // module's lifetime — so a transient `modules().bind_x(p)` temporary + // can register an async callback / event subscription that outlives + // it (the LpClient + RAII subscriptions persist in the map). Keyed by + // provider so repeated binds to the same provider share one client. + for (const QString& ifaceName : interfaceNames) { + const QString className = toPascalCase(ifaceName); + s << " " << className << " bind_" << ifaceName << "(const std::string& moduleName) {\n"; + s << " auto& _st = m_" << ifaceName << "_bound[moduleName];\n"; + s << " if (!_st) _st = std::make_unique<" << className << "::State>(moduleName, \"" << originName << "\");\n"; + s << " return " << className << "(_st.get());\n"; + s << " }\n"; + } + for (const QString& ifaceName : interfaceNames) { + const QString className = toPascalCase(ifaceName); + s << " std::map> m_" + << ifaceName << "_bound;\n"; + } + s << "};\n"; + QFile outFile(genDir.filePath("logos_sdk.h")); + if (!outFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + err << "Failed to write umbrella header: " << outFile.fileName() << "\n"; + return false; + } + outFile.write(content.toUtf8()); + outFile.close(); + return true; + } + // Generate logos_sdk.h from metadata.json's dependencies list. The // shape doesn't depend on apiStyle — each dep emits a single // `_api.h` whose class signature shape was already decided @@ -868,9 +933,10 @@ int legacy_main(int argc, char* argv[]) } } if (apiVal == "std") apiStyle = ApiStyle::Std; + else if (apiVal == "lp") apiStyle = ApiStyle::Lp; else if (!apiVal.isEmpty() && apiVal != "qt") { err << "Unknown --api-style value: " << apiVal - << " (expected 'qt' or 'std')\n"; + << " (expected 'qt', 'std', or 'lp')\n"; return 1; } } @@ -1014,8 +1080,11 @@ int legacy_main(int argc, char* argv[]) QStringList interfaceNames; for (const InterfaceSpec& sp : ifaceSpecs) interfaceNames.append(sp.name); - // Generate umbrella headers based on dependencies + interfaces - if (!writeUmbrellaHeaderFromDeps(genDirPath, deps, interfaceNames, err)) { + // Generate umbrella headers based on dependencies + interfaces. + // For the Lp (Qt-free) flavor the umbrella bakes this module's + // name as the lp_client origin. + const QString originName = obj.value("name").toString(); + if (!writeUmbrellaHeaderFromDeps(genDirPath, deps, interfaceNames, err, apiStyle, originName)) { return 7; } if (!writeUmbrellaSourceFromDeps(genDirPath, deps, interfaceNames, err)) { diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index ee1bad3..bb27ef2 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -64,5 +64,6 @@ install(FILES logos_module_context.h logos_json.h logos_result.h + logos_lp_client.h DESTINATION include ) diff --git a/cpp/logos_lp_client.h b/cpp/logos_lp_client.h new file mode 100644 index 0000000..ba67f8a --- /dev/null +++ b/cpp/logos_lp_client.h @@ -0,0 +1,209 @@ +#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 `` 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 +#include +#include +#include + +#include + +#include + +#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 jsonToStringVec(const nlohmann::json& j) { + std::vector out; + if (j.is_array()) + for (const auto& e : j) + if (e.is_string()) out.push_back(e.get()); + 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(); + if (j.contains("value")) r.value = j["value"]; + if (j.contains("error") && j["error"].is_string()) r.error = j["error"].get(); + } + 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 cb) { + lp_client* c = ensure(); + if (!c) { if (cb) cb(nullptr); return; } + auto* box = new std::function(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 cb) { + lp_client* c = ensure(); + if (!c) return {}; + auto* box = new std::function(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; + + 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(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(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(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(); + if (j.contains("message") && j["message"].is_string()) err->message = j["message"].get(); + if (j.contains("origin") && j["origin"].is_string()) err->origin = j["origin"].get(); + } + } + } + + std::string m_target; + std::string m_origin; + lp_client* m_client = nullptr; +}; + +} // namespace logos diff --git a/nix/include.nix b/nix/include.nix index be62077..c0b6774 100644 --- a/nix/include.nix +++ b/nix/include.nix @@ -19,7 +19,16 @@ pkgs.stdenv.mkDerivation { mkdir -p $out/include/cpp - for file in logos_module_context.h logos_json.h logos_result.h; do + # logos_lp_client.h MUST sit in the same directory as the headers it + # includes (logos_result.h, logos_json.h). A cdylib module's generated + # dep wrapper includes "logos_lp_client.h" via the include/cpp source- + # export root, and a quoted include resolves siblings relative to the + # including file. If logos_lp_client.h lived only at the top-level + # include/ (the CMake-export layout) while logos_result.h is reached via + # include/cpp/, a single TU would pull logos_result.h through two + # distinct realpaths and #pragma once could not dedup them + # (redefinition of StdLogosResult). Ship every std header in BOTH roots. + for file in logos_module_context.h logos_json.h logos_result.h logos_lp_client.h; do cp cpp/$file $out/include/cpp/ cp cpp/$file $out/include/ done