Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# Make project cmake/ helpers discoverable to sub-trees and plugins.
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")
include(GNUInstallDirs) # CMAKE_INSTALL_LIBDIR, etc. used by PjPluginManifest
include(GNUInstallDirs) # CMAKE_INSTALL_LIBDIR, etc. used by PjPluginManifest
include(PjPluginManifest)

# ---------------------------------------------------------------------------
Expand Down
53 changes: 23 additions & 30 deletions cmake/PjPluginManifest.cmake
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
# PjPluginManifest.cmake
#
# CMake helper that emits a plugin manifest sidecar JSON alongside a plugin
# shared library at build time. The sidecar lets a host scan all installed
# plugins at startup without dlopen'ing any — essential when the plugin
# count grows past a dozen or so.
# Helper that emits a human-readable plugin manifest sidecar JSON next to a
# plugin shared library.
#
# The sidecar is deliberately additive: the DSO still exports its manifest
# at runtime via get_plugin_manifest() (family-dependent). The host verifies
# at activation that the sidecar and the DSO agree; on mismatch, the DSO
# wins and a warning is logged.
# The sidecar is for inspection, packaging diagnostics, and developer tooling
# only. PlotJuggler runtime discovery and marketplace validation do not read it;
# the plugin DSO's embedded manifest is the source of truth.
#
# Design: reuse the plugin's existing manifest.json file (the same file
# pj_embed_manifest uses to bake the manifest into the DSO) and augment it
# with two autogenerated keys:
# - "abi_major": matches PJ_ABI_VERSION in the C header at build time.
# - "family": one of data_source, message_parser, toolbox, dialog.
# Everything else (name, version, description, file_extensions, encoding,
# category, etc.) passes through verbatim from the source manifest.json.
# The emitted file mirrors the plugin's manifest.json and adds build/discovery
# hints such as abi_major and family, so developers can inspect a build output
# without dlopen'ing the DSO.
#
# Usage:
# include(PjPluginManifest) # auto-included by the root CMakeLists.txt
Expand All @@ -26,8 +19,8 @@
# MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json
# )
#
# Writes <build-dir>/csv_source_plugin.pjmanifest.json next to the .so,
# and installs it to ${CMAKE_INSTALL_LIBDIR} alongside the DSO.
# Writes <build-dir>/<target>.pjmanifest.json next to the DSO and installs it
# alongside the DSO.

function(pj_emit_plugin_manifest TARGET)
set(_options)
Expand Down Expand Up @@ -63,16 +56,19 @@ function(pj_emit_plugin_manifest TARGET)
# Track manifest edits so CMake reconfigures when the source changes.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ARG_MANIFEST_FILE}")

# Read + validate required keys.
file(READ "${ARG_MANIFEST_FILE}" _src_json)
string(JSON _name ERROR_VARIABLE _err GET "${_src_json}" "name")
if(_err)
message(FATAL_ERROR "${ARG_MANIFEST_FILE}: missing required \"name\" key")
endif()
string(JSON _version ERROR_VARIABLE _err GET "${_src_json}" "version")
if(_err)
message(FATAL_ERROR "${ARG_MANIFEST_FILE}: missing required \"version\" key")
endif()

foreach(_key IN ITEMS id name version)
string(JSON _key_type ERROR_VARIABLE _err TYPE "${_src_json}" "${_key}")
if(_err OR NOT _key_type STREQUAL "STRING")
message(FATAL_ERROR "${ARG_MANIFEST_FILE}: missing required string \"${_key}\" key")
endif()

string(JSON _key_value GET "${_src_json}" "${_key}")
if(_key_value STREQUAL "")
message(FATAL_ERROR "${ARG_MANIFEST_FILE}: required string \"${_key}\" key must not be empty")
endif()
endforeach()

# Augment: add abi_major + family. string(JSON SET) preserves other keys.
set(_sidecar_json "${_src_json}")
Expand All @@ -83,18 +79,15 @@ function(pj_emit_plugin_manifest TARGET)
set(_sidecar_path "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}.pjmanifest.json")
file(WRITE "${_sidecar_path}" "${_sidecar_json}\n")

# Copy sidecar next to the built DSO so a host scanning the build tree
# finds it beside the .so. Handles out-of-source per-config output dirs.
add_custom_command(
TARGET ${TARGET} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_sidecar_path}"
"$<TARGET_FILE_DIR:${TARGET}>/${TARGET}.pjmanifest.json"
COMMENT "Copying ${TARGET}.pjmanifest.json next to DSO"
COMMENT "Copying human-readable ${TARGET}.pjmanifest.json next to DSO"
VERBATIM
)

# Install sidecar to the same directory as the DSO (MODULE or SHARED).
get_target_property(_type ${TARGET} TYPE)
if(_type STREQUAL "MODULE_LIBRARY" OR _type STREQUAL "SHARED_LIBRARY")
install(FILES "${_sidecar_path}"
Expand Down
2 changes: 1 addition & 1 deletion examples/sdk_consumer/minimal_data_source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ class MinimalDataSource : public PJ::FileSourceBase {

} // namespace

PJ_DATA_SOURCE_PLUGIN(MinimalDataSource, R"({"name":"Minimal","version":"0.1.0"})")
PJ_DATA_SOURCE_PLUGIN(MinimalDataSource, R"({"id":"minimal-data-source","name":"Minimal","version":"0.1.0"})")
2 changes: 2 additions & 0 deletions pj_base/include/pj_base/data_source_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ typedef struct PJ_data_source_vtable_t {
* Static JSON manifest. Compile-time constant string literal.
*
* Required keys:
* "id" — stable plugin identifier (string). Used by the host catalog
* and the marketplace; must be unique per plugin.
* "name" — human-readable plugin name (string).
* "version" — semver version string (string).
*
Expand Down
48 changes: 48 additions & 0 deletions pj_base/include/pj_base/diagnostic_sink.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#pragma once

// Vocabulary types for cross-module diagnostic propagation.
//
// Non-GUI code that wants to surface a problem (a plugin failed to load, a
// manifest is malformed, an external store is unreachable) accepts a
// DiagnosticSink in its constructor or as a parameter and emits Diagnostic
// values through it. A GUI host installs a sink that bridges the events into
// its own event loop (e.g. a Qt signal) and surfaces them to the user.
//
// The sink is type-erased via std::function so callers don't depend on Qt and
// implementations can be lambdas, free functions, or member-function bindings.
// A default-constructed sink is falsy and discards every event — code that
// emits should null-check (`if (sink_) sink_(...)`) or use the convenience
// emit helper. This keeps the no-listener path zero-cost.

#include <chrono>
#include <functional>
#include <string>
#include <utility>

namespace PJ {

enum class DiagnosticLevel { kInfo, kWarning, kError };

struct Diagnostic {
DiagnosticLevel level = DiagnosticLevel::kInfo;
std::string source; ///< Component that produced the event, e.g. "PluginRegistry".
std::string id; ///< Optional plugin/extension id; empty if not applicable.
std::string message;
std::chrono::system_clock::time_point timestamp = std::chrono::system_clock::now();
};

using DiagnosticSink = std::function<void(const Diagnostic&)>;

/// Forwards events to both `a` and `b`. Either may be empty.
inline DiagnosticSink teeSink(DiagnosticSink a, DiagnosticSink b) {
return [a = std::move(a), b = std::move(b)](const Diagnostic& d) {
if (a) {
a(d);
}
if (b) {
b(d);
}
};
}

} // namespace PJ
33 changes: 20 additions & 13 deletions pj_base/include/pj_base/expected.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,23 @@ template <typename E>
/// Minimal value-or-error container.
template <typename T, typename E = std::string>
class Expected {
struct ErrorStorage {
constexpr explicit ErrorStorage(const E& error) : value(error) {}
constexpr explicit ErrorStorage(E&& error) : value(std::move(error)) {}

E value;
};

public:
/// Construct a success value.
constexpr Expected(const T& value) : storage_(value) {}
constexpr Expected(const T& value) : storage_(std::in_place_index<0>, value) {}
/// Construct a success value.
constexpr Expected(T&& value) : storage_(std::move(value)) {}
constexpr Expected(T&& value) : storage_(std::in_place_index<0>, std::move(value)) {}

/// Construct an error state.
constexpr Expected(const Unexpected<E>& error) : storage_(error.value()) {}
constexpr Expected(const Unexpected<E>& error) : storage_(std::in_place_index<1>, error.value()) {}
/// Construct an error state.
constexpr Expected(Unexpected<E>&& error) : storage_(std::move(error).value()) {}
constexpr Expected(Unexpected<E>&& error) : storage_(std::in_place_index<1>, std::move(error).value()) {}

[[nodiscard]] constexpr bool has_value() const noexcept {
return std::holds_alternative<T>(storage_);
Expand All @@ -77,51 +84,51 @@ class Expected {
/// Arrow operator — access success payload members.
[[nodiscard]] constexpr T* operator->() {
PJ_ASSERT(has_value(), "Expected does not contain a value");
return &std::get<T>(storage_);
return &std::get<0>(storage_);
}
[[nodiscard]] constexpr const T* operator->() const {
PJ_ASSERT(has_value(), "Expected does not contain a value");
return &std::get<T>(storage_);
return &std::get<0>(storage_);
}

/// Access success payload. Asserts if this contains an error.
[[nodiscard]] constexpr T& value() & {
PJ_ASSERT(has_value(), "Expected does not contain a value");
return std::get<T>(storage_);
return std::get<0>(storage_);
}

/// Access success payload. Asserts if this contains an error.
[[nodiscard]] constexpr const T& value() const& {
PJ_ASSERT(has_value(), "Expected does not contain a value");
return std::get<T>(storage_);
return std::get<0>(storage_);
}

/// Access success payload. Asserts if this contains an error.
[[nodiscard]] constexpr T&& value() && {
PJ_ASSERT(has_value(), "Expected does not contain a value");
return std::move(std::get<T>(storage_));
return std::move(std::get<0>(storage_));
}

/// Access error payload. Asserts if this contains a value.
[[nodiscard]] constexpr E& error() & {
PJ_ASSERT(!has_value(), "Expected does not contain an error");
return std::get<E>(storage_);
return std::get<1>(storage_).value;
}

/// Access error payload. Asserts if this contains a value.
[[nodiscard]] constexpr const E& error() const& {
PJ_ASSERT(!has_value(), "Expected does not contain an error");
return std::get<E>(storage_);
return std::get<1>(storage_).value;
}

/// Access error payload. Asserts if this contains a value.
[[nodiscard]] constexpr E&& error() && {
PJ_ASSERT(!has_value(), "Expected does not contain an error");
return std::move(std::get<E>(storage_));
return std::move(std::get<1>(storage_).value);
}

private:
std::variant<T, E> storage_;
std::variant<T, ErrorStorage> storage_;
};

/// Specialization for void value type (replaces a status-or-error type).
Expand Down
2 changes: 2 additions & 0 deletions pj_base/include/pj_base/message_parser_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ typedef struct PJ_message_parser_vtable_t {
* Static JSON manifest. Compile-time constant.
*
* Required keys:
* "id" — stable plugin identifier (string). Used by the host catalog
* and the marketplace; must be unique per plugin.
* "name" — human-readable plugin name (string).
* "version" — semver version string (string).
* "encoding" — encoding this parser handles (string). The host uses
Expand Down
48 changes: 48 additions & 0 deletions pj_base/include/pj_base/plugin_abi_export.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#ifndef PJ_PLUGIN_ABI_EXPORT_H
#define PJ_PLUGIN_ABI_EXPORT_H

#include "pj_base/plugin_data_api.h"

// Boot-level ABI symbol the host loader checks before touching any vtable.
//
// Defined at file scope (not inside a macro) so a single TU can use multiple
// PJ_*_PLUGIN(...) macros — e.g. DataSource + Dialog co-resident — without
// producing a same-TU duplicate-symbol error. The header is `#pragma once`,
// so any TU that pulls it in (directly or transitively via a family base
// header) gets exactly one definition.
//
// Linkage attribute lets multiple TUs in the same DSO each emit a definition
// and have the linker fold them into a single COMDAT entry. `used` forces
// emission even though no in-TU code references the symbol — the host reads
// it via dlsym, which the compiler can't see.
//
// Note: the variable is intentionally non-const. A namespace-scope `const`
// variable has internal linkage by default in C++, and MSVC then rejects
// `__declspec(selectany)` on it with error C2496 ("can only be applied to
// data items with external linkage"). `extern "C"` controls language linkage
// (name mangling), not internal/external linkage. Dropping `const` gives the
// variable external linkage on every toolchain so the COMDAT/weak fold is
// well-defined; nothing inside any plugin DSO writes to the symbol — the host
// only reads it once via dlsym during ABI handshake.
//
// The block form `extern "C" { ... }` (rather than the single-decl form
// `extern "C" T x = val;`) is required so that GCC does not treat the
// declaration as `extern` with initializer and trip -Wextern-initializer.
#if defined(_MSC_VER)
#define PJ_PLUGIN_ABI_LINK __declspec(dllexport) __declspec(selectany)
#else
#define PJ_PLUGIN_ABI_LINK \
__attribute__((visibility("default"))) __attribute__((weak)) __attribute__((used))
#endif

extern "C" {
PJ_PLUGIN_ABI_LINK uint32_t pj_plugin_abi_version = PJ_ABI_VERSION;
}

// No-op marker. The actual `pj_plugin_abi_version` definition lives at file
// scope above; this macro exists so each `PJ_*_PLUGIN(...)` family macro can
// document, in-line at its own expansion site, that an ABI-version export is
// part of the contract. The `EXPORT_TAG` argument is intentionally ignored.
#define PJ_EXPORT_PLUGIN_ABI_VERSION(EXPORT_TAG) /* emitted at file scope */

#endif // PJ_PLUGIN_ABI_EXPORT_H
12 changes: 8 additions & 4 deletions pj_base/include/pj_base/plugin_data_api.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ extern "C" {
* up is `pj_plugin_abi_version` (a regular C identifier, not a preprocessor
* token).
*
* Contract for plugin authors: every plugin SDK macro (PJ_DATA_SOURCE_PLUGIN,
* PJ_MESSAGE_PARSER_PLUGIN, etc.) emits `pj_plugin_abi_version` automatically.
* Do not redefine it.
* Contract for plugin authors: the symbol is emitted automatically at file
* scope by `pj_base/plugin_abi_export.h`, which is transitively included by
* every family SDK header (data_source_plugin_base.hpp, dialog_plugin_base.hpp,
* etc.). Weak linkage lets multiple TUs in the same DSO each emit a definition
* and have the linker fold them into one COMDAT entry — so co-resident DSOs
* (e.g. DataSource + Dialog in one .so) work without any extra ceremony.
* Do not redefine it manually.
*
* v4 plugins advertise version 4. Breaking v3→v4 changes:
* - Arrow C Data Interface replaces Arrow IPC bytes at the boundary
Expand All @@ -52,7 +56,7 @@ extern "C" {
/**
* Convention for plugin-loaders:
*
* 1. `dlsym("pj_plugin_abi_version")` — reject if missing or not equal to 3.
* 1. `dlsym("pj_plugin_abi_version")` — reject if missing or not equal to PJ_ABI_VERSION.
* 2. `dlsym("PJ_get_<family>_vtable")` — reject if missing.
* 3. Check `vtable->protocol_version == PJ_<FAMILY>_PROTOCOL_VERSION`.
* 4. Check `vtable->struct_size >= PJ_<FAMILY>_MIN_VTABLE_SIZE`
Expand Down
2 changes: 1 addition & 1 deletion pj_base/include/pj_base/sdk/data_source_patterns.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* return PJ::okStatus();
* }
* };
* PJ_DATA_SOURCE_PLUGIN(MyImporter, R"({"name":"My Importer","version":"1.0.0"})")
* PJ_DATA_SOURCE_PLUGIN(MyImporter, R"({"id":"my-importer","name":"My Importer","version":"1.0.0"})")
* @endcode
*
* @see examples/sdk_consumer/minimal_data_source.cpp for the smallest possible plugin.
Expand Down
6 changes: 4 additions & 2 deletions pj_base/include/pj_base/sdk/data_source_plugin_base.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

#include "pj_base/data_source_protocol.h"
#include "pj_base/expected.hpp"
#include "pj_base/plugin_abi_export.h"
#include "pj_base/sdk/data_source_host_views.hpp"
#include "pj_base/sdk/plugin_data_api.hpp"
#include "pj_base/sdk/service_registry.hpp"
Expand Down Expand Up @@ -133,6 +134,7 @@ class DataSourcePluginBase {
template <typename CreateFn>
static const PJ_data_source_vtable_t* vtableWithCreate(CreateFn create_fn, const char* manifest) {
PJ_ASSERT(manifest != nullptr && manifest[0] == '{', "manifest must be a JSON object");
PJ_ASSERT(std::strstr(manifest, "\"id\"") != nullptr, "manifest must contain an \"id\" key");
PJ_ASSERT(std::strstr(manifest, "\"name\"") != nullptr, "manifest must contain a \"name\" key");
PJ_ASSERT(std::strstr(manifest, "\"version\"") != nullptr, "manifest must contain a \"version\" key");
static const PJ_data_source_vtable_t vt = {
Expand Down Expand Up @@ -229,10 +231,10 @@ class DataSourcePluginBase {
* entry point `PJ_get_data_source_vtable` that the host resolves via dlsym.
*
* @param ClassName The DataSourcePluginBase subclass to instantiate.
* @param manifest String literal JSON manifest (must have "name" and "version").
* @param manifest String literal JSON manifest (must have "id", "name", and "version").
*/
#define PJ_DATA_SOURCE_PLUGIN(ClassName, manifest) \
extern "C" PJ_DATA_SOURCE_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; \
PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_DATA_SOURCE_EXPORT) \
extern "C" PJ_DATA_SOURCE_EXPORT const PJ_data_source_vtable_t* PJ_get_data_source_vtable() noexcept { \
static const PJ_data_source_vtable_t* vt = PJ::DataSourcePluginBase::vtableWithCreate( \
[]() noexcept -> void* { \
Expand Down
4 changes: 3 additions & 1 deletion pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

#include "pj_base/expected.hpp"
#include "pj_base/message_parser_protocol.h"
#include "pj_base/plugin_abi_export.h"
#include "pj_base/sdk/plugin_data_api.hpp"
#include "pj_base/sdk/service_registry.hpp"
#include "pj_base/sdk/service_traits.hpp"
Expand Down Expand Up @@ -87,6 +88,7 @@ class MessageParserPluginBase {
template <typename CreateFn>
static const PJ_message_parser_vtable_t* vtableWithCreate(CreateFn create_fn, const char* manifest) {
PJ_ASSERT(manifest != nullptr && manifest[0] == '{', "manifest must be a JSON object");
PJ_ASSERT(std::strstr(manifest, "\"id\"") != nullptr, "manifest must contain an \"id\" key");
PJ_ASSERT(std::strstr(manifest, "\"name\"") != nullptr, "manifest must contain a \"name\" key");
PJ_ASSERT(std::strstr(manifest, "\"version\"") != nullptr, "manifest must contain a \"version\" key");
PJ_ASSERT(std::strstr(manifest, "\"encoding\"") != nullptr, "manifest must contain an \"encoding\" key");
Expand Down Expand Up @@ -154,7 +156,7 @@ class MessageParserPluginBase {
#include "pj_base/sdk/detail/message_parser_trampolines.hpp"

#define PJ_MESSAGE_PARSER_PLUGIN(ClassName, manifest) \
extern "C" PJ_MESSAGE_PARSER_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; \
PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_MESSAGE_PARSER_EXPORT) \
extern "C" PJ_MESSAGE_PARSER_EXPORT const PJ_message_parser_vtable_t* PJ_get_message_parser_vtable() noexcept { \
static const PJ_message_parser_vtable_t* vt = PJ::MessageParserPluginBase::vtableWithCreate( \
[]() noexcept -> void* { \
Expand Down
Loading
Loading