diff --git a/CMakeLists.txt b/CMakeLists.txt index 1458fbb..b145108 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) # --------------------------------------------------------------------------- diff --git a/cmake/PjPluginManifest.cmake b/cmake/PjPluginManifest.cmake index 1e59e3c..d76e783 100644 --- a/cmake/PjPluginManifest.cmake +++ b/cmake/PjPluginManifest.cmake @@ -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 @@ -26,8 +19,8 @@ # MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json # ) # -# Writes /csv_source_plugin.pjmanifest.json next to the .so, -# and installs it to ${CMAKE_INSTALL_LIBDIR} alongside the DSO. +# Writes /.pjmanifest.json next to the DSO and installs it +# alongside the DSO. function(pj_emit_plugin_manifest TARGET) set(_options) @@ -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}") @@ -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}.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}" diff --git a/examples/sdk_consumer/minimal_data_source.cpp b/examples/sdk_consumer/minimal_data_source.cpp index 78b4464..d2ba655 100644 --- a/examples/sdk_consumer/minimal_data_source.cpp +++ b/examples/sdk_consumer/minimal_data_source.cpp @@ -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"})") diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index a6d19e2..584ede1 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -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). * diff --git a/pj_base/include/pj_base/diagnostic_sink.hpp b/pj_base/include/pj_base/diagnostic_sink.hpp new file mode 100644 index 0000000..d5182b9 --- /dev/null +++ b/pj_base/include/pj_base/diagnostic_sink.hpp @@ -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 +#include +#include +#include + +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; + +/// 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 diff --git a/pj_base/include/pj_base/expected.hpp b/pj_base/include/pj_base/expected.hpp index c9146e4..1e419e0 100644 --- a/pj_base/include/pj_base/expected.hpp +++ b/pj_base/include/pj_base/expected.hpp @@ -44,16 +44,23 @@ template /// Minimal value-or-error container. template 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& error) : storage_(error.value()) {} + constexpr Expected(const Unexpected& error) : storage_(std::in_place_index<1>, error.value()) {} /// Construct an error state. - constexpr Expected(Unexpected&& error) : storage_(std::move(error).value()) {} + constexpr Expected(Unexpected&& error) : storage_(std::in_place_index<1>, std::move(error).value()) {} [[nodiscard]] constexpr bool has_value() const noexcept { return std::holds_alternative(storage_); @@ -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(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(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(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(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(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(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(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(storage_)); + return std::move(std::get<1>(storage_).value); } private: - std::variant storage_; + std::variant storage_; }; /// Specialization for void value type (replaces a status-or-error type). diff --git a/pj_base/include/pj_base/message_parser_protocol.h b/pj_base/include/pj_base/message_parser_protocol.h index 358e15c..e3c06ae 100644 --- a/pj_base/include/pj_base/message_parser_protocol.h +++ b/pj_base/include/pj_base/message_parser_protocol.h @@ -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 diff --git a/pj_base/include/pj_base/plugin_abi_export.h b/pj_base/include/pj_base/plugin_abi_export.h new file mode 100644 index 0000000..59fdfea --- /dev/null +++ b/pj_base/include/pj_base/plugin_abi_export.h @@ -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 diff --git a/pj_base/include/pj_base/plugin_data_api.h b/pj_base/include/pj_base/plugin_data_api.h index 7a93ce7..58dc4cc 100644 --- a/pj_base/include/pj_base/plugin_data_api.h +++ b/pj_base/include/pj_base/plugin_data_api.h @@ -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 @@ -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__vtable")` — reject if missing. * 3. Check `vtable->protocol_version == PJ__PROTOCOL_VERSION`. * 4. Check `vtable->struct_size >= PJ__MIN_VTABLE_SIZE` diff --git a/pj_base/include/pj_base/sdk/data_source_patterns.hpp b/pj_base/include/pj_base/sdk/data_source_patterns.hpp index 94a6ab5..25ffcaf 100644 --- a/pj_base/include/pj_base/sdk/data_source_patterns.hpp +++ b/pj_base/include/pj_base/sdk/data_source_patterns.hpp @@ -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. diff --git a/pj_base/include/pj_base/sdk/data_source_plugin_base.hpp b/pj_base/include/pj_base/sdk/data_source_plugin_base.hpp index bba5f9b..cdfd8ae 100644 --- a/pj_base/include/pj_base/sdk/data_source_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/data_source_plugin_base.hpp @@ -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" @@ -133,6 +134,7 @@ class DataSourcePluginBase { template 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 = { @@ -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* { \ diff --git a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp index 3b1eabc..f2b88a9 100644 --- a/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/message_parser_plugin_base.hpp @@ -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" @@ -87,6 +88,7 @@ class MessageParserPluginBase { template 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"); @@ -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* { \ diff --git a/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp b/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp index a406b6b..94eb80d 100644 --- a/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp @@ -13,6 +13,7 @@ #include #include "pj_base/expected.hpp" +#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" @@ -152,6 +153,7 @@ class ToolboxPluginBase { template static const PJ_toolbox_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_toolbox_vtable_t vt = { @@ -232,7 +234,7 @@ class ToolboxPluginBase { #include "pj_base/sdk/detail/toolbox_trampolines.hpp" #define PJ_TOOLBOX_PLUGIN(ClassName, manifest) \ - extern "C" PJ_TOOLBOX_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; \ + PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_TOOLBOX_EXPORT) \ extern "C" PJ_TOOLBOX_EXPORT const PJ_toolbox_vtable_t* PJ_get_toolbox_vtable() noexcept { \ static const PJ_toolbox_vtable_t* vt = PJ::ToolboxPluginBase::vtableWithCreate( \ []() noexcept -> void* { \ diff --git a/pj_base/include/pj_base/toolbox_protocol.h b/pj_base/include/pj_base/toolbox_protocol.h index c65acb2..f039d1d 100644 --- a/pj_base/include/pj_base/toolbox_protocol.h +++ b/pj_base/include/pj_base/toolbox_protocol.h @@ -93,6 +93,15 @@ typedef struct PJ_toolbox_vtable_t { /** [main-thread] Destroy an instance previously created by create(). */ void (*destroy)(void* ctx) PJ_NOEXCEPT; + /** + * 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). + */ const char* manifest_json; /** [main-thread] Return capability bitmask (PJ_TOOLBOX_CAPABILITY_* flags). */ uint64_t (*capabilities)(void* ctx) PJ_NOEXCEPT; diff --git a/pj_base/tests/data_source_plugin_base_test.cpp b/pj_base/tests/data_source_plugin_base_test.cpp index ddb3c14..75422d2 100644 --- a/pj_base/tests/data_source_plugin_base_test.cpp +++ b/pj_base/tests/data_source_plugin_base_test.cpp @@ -142,7 +142,7 @@ class MockDataSource : public PJ::DataSourcePluginBase { int poll_count_ = 0; }; -constexpr const char* kMockManifest = R"({"name":"Mock DataSource","version":"1.0.0"})"; +constexpr const char* kMockManifest = R"({"id":"mock-data-source","name":"Mock DataSource","version":"1.0.0"})"; const PJ_data_source_vtable_t* mockVtable() { static const PJ_data_source_vtable_t* vt = diff --git a/pj_base/tests/expected_test.cpp b/pj_base/tests/expected_test.cpp index 0daf162..0c6ea96 100644 --- a/pj_base/tests/expected_test.cpp +++ b/pj_base/tests/expected_test.cpp @@ -39,5 +39,15 @@ TEST(ExpectedTest, MutableAccessToError) { EXPECT_EQ(result.error(), "error"); } +TEST(ExpectedTest, AllowsValueAndErrorToUseSameType) { + Expected value_result = std::string("value"); + ASSERT_TRUE(value_result.has_value()); + EXPECT_EQ(value_result.value(), "value"); + + Expected error_result = unexpected(std::string("error")); + ASSERT_FALSE(error_result.has_value()); + EXPECT_EQ(error_result.error(), "error"); +} + } // namespace } // namespace PJ diff --git a/pj_base/tests/message_parser_plugin_base_test.cpp b/pj_base/tests/message_parser_plugin_base_test.cpp index 47eae8e..2e5f43d 100644 --- a/pj_base/tests/message_parser_plugin_base_test.cpp +++ b/pj_base/tests/message_parser_plugin_base_test.cpp @@ -58,7 +58,8 @@ class ThrowingParser : public PJ::MessageParserPluginBase { } }; -constexpr const char* kMockManifest = R"({"name":"Mock Parser","version":"1.0.0","encoding":"json"})"; +constexpr const char* kMockManifest = + R"({"id":"mock-parser","name":"Mock Parser","version":"1.0.0","encoding":"json"})"; const PJ_message_parser_vtable_t* mockVtable() { static const PJ_message_parser_vtable_t* vt = @@ -68,7 +69,8 @@ const PJ_message_parser_vtable_t* mockVtable() { const PJ_message_parser_vtable_t* throwingVtable() { static const PJ_message_parser_vtable_t* vt = PJ::MessageParserPluginBase::vtableWithCreate( - []() -> void* { return new ThrowingParser(); }, R"({"name":"Thrower","version":"0.1.0","encoding":"test"})"); + []() -> void* { return new ThrowingParser(); }, + R"({"id":"throwing-parser","name":"Thrower","version":"0.1.0","encoding":"test"})"); return vt; } diff --git a/pj_datastore/docs/USER_GUIDE.md b/pj_datastore/docs/USER_GUIDE.md index d53f902..437dea0 100644 --- a/pj_datastore/docs/USER_GUIDE.md +++ b/pj_datastore/docs/USER_GUIDE.md @@ -115,14 +115,21 @@ The `name` field in `NamedFieldValue` is `std::string` (not `string_view`). You fields.push_back({prefix + "/" + key, value}); // safe — name is owned ``` -### Bulk Arrow IPC Import +### Bulk Arrow Import -For high-throughput file importers that already have Arrow data: +For high-throughput file importers that already have Arrow data, use the +Arrow C Data Interface (`ArrowArrayStream`). The byte-based `appendArrowIpc` +slot was removed in ABI v4. ```cpp -writeHost().appendArrowIpc(*topic, ipc_stream_bytes, timestamp_column_name); +PJ::sdk::ArrowStreamHolder stream(buildMyStream()); +auto status = writeHost().appendArrowStream(*topic, std::move(stream), "timestamp"); ``` +`ArrowStreamHolder` is an RAII wrapper that auto-releases the stream; the +`std::move` overload disarms it on success. See `pj_base/sdk/arrow.hpp` for +the holder + stream-builder helpers. + --- ## 3. Timestamps diff --git a/pj_datastore/tests/array_expansion_test.cpp b/pj_datastore/tests/array_expansion_test.cpp index cc2ae6a..f01d7c1 100644 --- a/pj_datastore/tests/array_expansion_test.cpp +++ b/pj_datastore/tests/array_expansion_test.cpp @@ -179,7 +179,7 @@ TEST(ArrayExpansionTest, VarLenArray_ExpandIsIdempotent_ShrinkIsNoop) { desc.schema_id = sid; auto topic_id = *writer.registerTopic(ds, desc); - *writer.expandArray(topic_id, "data", 3u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 3u).has_value()); // Try to shrink: must be no-op, return current count (3) auto result = writer.expandArray(topic_id, "data", 2u); @@ -203,7 +203,7 @@ TEST(ArrayExpansionTest, VarLenArray_WriteAndRead_BasicValues) { desc.schema_id = sid; auto topic_id = *writer.registerTopic(ds, desc); - *writer.expandArray(topic_id, "data", 3u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 3u).has_value()); for (int i = 0; i < 4; ++i) { ASSERT_TRUE(writer.beginRow(topic_id, PJ::Timestamp(i) * 1000).has_value()); @@ -291,16 +291,16 @@ TEST(ArrayExpansionTest, MaxObservedArrayLength_TrackedInMetadata) { desc.array_expansion_limit = 10; auto topic_id = *writer.registerTopic(ds, desc); - writer.expandArray(topic_id, "data", 3u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 3u).has_value()); const TopicStorage* storage = engine.getTopicStorage(topic_id); ASSERT_NE(storage, nullptr); EXPECT_EQ(storage->maxObservedArrayLength(), 3u); - writer.expandArray(topic_id, "data", 5u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 5u).has_value()); EXPECT_EQ(storage->maxObservedArrayLength(), 5u); - writer.expandArray(topic_id, "data", 8u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 8u).has_value()); EXPECT_EQ(storage->maxObservedArrayLength(), 8u); } @@ -322,15 +322,15 @@ TEST(ArrayExpansionTest, TruncatedSampleCount_TrackedOnClamping) { const TopicStorage* storage = engine.getTopicStorage(topic_id); // expand to 10 exceeds limit=3 → 1 truncation - writer.expandArray(topic_id, "data", 10u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 10u).has_value()); EXPECT_EQ(storage->truncatedSampleCount(), 1u); // expand to 2 — no-op (current=3 >= 2); no new truncation - writer.expandArray(topic_id, "data", 2u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 2u).has_value()); EXPECT_EQ(storage->truncatedSampleCount(), 1u); // expand to 20 — actual=3 (same as current), but still truncated (20 > limit) - writer.expandArray(topic_id, "data", 20u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 20u).has_value()); EXPECT_EQ(storage->truncatedSampleCount(), 2u); } @@ -348,7 +348,7 @@ TEST(ArrayExpansionTest, Metadata_ExposedViaTopicMetadata) { desc.array_expansion_limit = 4; auto topic_id = *writer.registerTopic(ds, desc); - writer.expandArray(topic_id, "data", 10u); // clamped to 4; 1 truncation; max_observed=10 + ASSERT_TRUE(writer.expandArray(topic_id, "data", 10u).has_value()); // clamped to 4; 1 truncation; max_observed=10 ASSERT_TRUE(writer.beginRow(topic_id, 1000).has_value()); writer.set(topic_id, 0, 1.0); @@ -384,7 +384,7 @@ TEST(ArrayExpansionTest, CrossChunkExpansion_OldChunksHaveFewerColumns) { auto topic_id = *writer.registerTopic(ds, desc); // Phase 1: expand to 2, write 10 rows (8 auto-sealed + 2 in builder) - *writer.expandArray(topic_id, "data", 2u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 2u).has_value()); for (int i = 0; i < 10; ++i) { ASSERT_TRUE(writer.beginRow(topic_id, PJ::Timestamp(i) * 1000).has_value()); writer.set(topic_id, 0, i * 1.0); @@ -449,7 +449,7 @@ TEST(ArrayExpansionTest, UnsetElementsAutoNullFilled) { desc.schema_id = sid; auto topic_id = *writer.registerTopic(ds, desc); - *writer.expandArray(topic_id, "data", 4u); + ASSERT_TRUE(writer.expandArray(topic_id, "data", 4u).has_value()); // Write row with only first 2 elements; cols 2 and 3 are unset ASSERT_TRUE(writer.beginRow(topic_id, 1000).has_value()); diff --git a/pj_marketplace/CMakeLists.txt b/pj_marketplace/CMakeLists.txt index 736f818..2063c59 100644 --- a/pj_marketplace/CMakeLists.txt +++ b/pj_marketplace/CMakeLists.txt @@ -32,9 +32,11 @@ add_library(pj_marketplace STATIC src/core/DownloadManager.cpp src/core/ExtensionManager.cpp src/core/PlatformUtils.cpp + src/core/QtDiagnosticBridge.cpp src/core/RegistryManager.cpp include/pj_marketplace/download_manager.hpp include/pj_marketplace/extension_manager.hpp + include/pj_marketplace/qt_diagnostic_bridge.hpp include/pj_marketplace/registry_manager.hpp ) @@ -80,14 +82,28 @@ target_link_libraries(pj_marketplace_ui PUBLIC ) # pj_base provides expected.hpp (header-only for our usage). -# When built as part of plotjuggler_core the target already exists; -# in standalone mode we expose the include directory directly. +# When built as part of plotjuggler_core the targets already exist; +# in standalone mode we synthesize a pj_plugin_catalog target so downstream +# consumers always link a single, non-duplicated symbol set regardless of build mode. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) - target_include_directories(pj_marketplace PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/../pj_base/include - ) + find_package(nlohmann_json REQUIRED) + if(NOT TARGET pj_plugin_catalog) + add_library(pj_plugin_catalog STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/src/plugin_catalog.cpp + ) + target_include_directories(pj_plugin_catalog PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_base/include + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/include + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/dialog_protocol/include + ) + target_link_libraries(pj_plugin_catalog PUBLIC + nlohmann_json::nlohmann_json + ${CMAKE_DL_LIBS} + ) + endif() + target_link_libraries(pj_marketplace PUBLIC pj_plugin_catalog) else() - target_link_libraries(pj_marketplace PUBLIC pj_base) + target_link_libraries(pj_marketplace PUBLIC pj_base pj_plugin_catalog) endif() target_compile_options(pj_marketplace PRIVATE ${PJ_WARNING_FLAGS}) @@ -143,22 +159,34 @@ set_target_properties(registry_manager_test PROPERTIES target_compile_options(registry_manager_test PRIVATE -Wall -Wextra) add_test(NAME registry_manager_test COMMAND registry_manager_test) -add_executable(extension_manager_test - tests/extension_manager_test.cpp -) - -target_link_libraries(extension_manager_test PRIVATE - pj_marketplace - Qt6::Network - Qt6::Test - GTest::gtest - LibArchive::LibArchive -) -set_target_properties(extension_manager_test PROPERTIES - RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/tests -) -target_compile_options(extension_manager_test PRIVATE ${PJ_WARNING_FLAGS}) -add_test(NAME extension_manager_test COMMAND extension_manager_test) +if(TARGET mock_data_source_plugin AND TARGET mock_file_source_plugin + AND TARGET mock_data_source_v2_plugin AND TARGET missing_id_data_source_plugin) + add_executable(extension_manager_test + tests/extension_manager_test.cpp + ) + target_compile_definitions(extension_manager_test PRIVATE + PJ_MARKETPLACE_TESTING + PJ_MOCK_DATA_SOURCE_PLUGIN_PATH="$" + PJ_MOCK_DATA_SOURCE_V2_PLUGIN_PATH="$" + PJ_MOCK_FILE_SOURCE_PLUGIN_PATH="$" + PJ_MISSING_ID_PLUGIN_PATH="$" + ) + add_dependencies(extension_manager_test mock_data_source_plugin mock_file_source_plugin + mock_data_source_v2_plugin missing_id_data_source_plugin) + + target_link_libraries(extension_manager_test PRIVATE + pj_marketplace + Qt6::Network + Qt6::Test + GTest::gtest + LibArchive::LibArchive + ) + set_target_properties(extension_manager_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/tests + ) + target_compile_options(extension_manager_test PRIVATE ${PJ_WARNING_FLAGS}) + add_test(NAME extension_manager_test COMMAND extension_manager_test) +endif() # Integration test — requires network access, excluded from CTest by default. # Run manually: ./tests/extension_manager_integration_test diff --git a/pj_marketplace/README.md b/pj_marketplace/README.md index c1f4bcd..24a9d57 100644 --- a/pj_marketplace/README.md +++ b/pj_marketplace/README.md @@ -47,7 +47,7 @@ cd build && ctest --output-on-failure pj_marketplace/ ├── src/ │ ├── core/ -│ │ ├── DownloadManager.cpp/.h # HTTP download + checksum + extraction +│ │ ├── DownloadManager.cpp/.h # HTTP download + checksum + libarchive extraction │ │ ├── ExtensionManager.cpp/.h # Install/uninstall/update lifecycle │ │ ├── PlatformUtils.cpp/.h # Cross-platform paths and detection │ │ └── RegistryManager.cpp/.h # Remote registry fetching @@ -57,6 +57,7 @@ pj_marketplace/ │ └── Platform.h # Platform-specific artifact ├── tests/ │ ├── download_manager_test.cpp +│ ├── extension_manager_test.cpp │ └── registry_manager_test.cpp ├── build.sh # Standalone build script ├── conanfile.txt # Conan dependencies diff --git a/pj_marketplace/documentation/ARCHITECTURE.md b/pj_marketplace/documentation/ARCHITECTURE.md index c0e7e7c..b3d632a 100644 --- a/pj_marketplace/documentation/ARCHITECTURE.md +++ b/pj_marketplace/documentation/ARCHITECTURE.md @@ -32,7 +32,7 @@ rectangle "PlotJuggler" { component "Extension Manager" as em folder "extensions/" as local ui --> em - em --> local : scan manifest.json + em --> local : scan plugin DSOs } reg ..> ui : HTTPS fetch @@ -89,7 +89,7 @@ The C++ ecosystem has multiple dependency managers, and PlotJuggler has used sev | **Pixi** | Under observation | It's gaining traction in the ROS community. Offers reproducible environments similar to conda but lighter. | | **Colcon** | Abandoned | Was necessary for ROS 1/2 integration, but added unnecessary complexity outside that context. | -The current decision is to **use Conan for the plugin template**, but design the system so that generated artifacts are independent of the build tool. A ZIP with a `.so` and a `manifest.json` works the same whether it was generated with Conan, Pixi, or manual compilation. +The current decision is to **use Conan for the plugin template**, but design the system so that generated artifacts are independent of the build tool. A ZIP with one or more plugin DSOs works the same whether it was generated with Conan, Pixi, or manual compilation; installed metadata is read from each DSO's embedded manifest. **Pixi timeline:** 1. **Short term:** Template uses Conan (already works, already tested) @@ -114,32 +114,31 @@ This means a simple JSON file is more than sufficient as a registry. We don't ne ### 3.1 Core Components +Public headers live under `include/pj_marketplace/`; sources are split between `src/core/` (CamelCase) and `src/ui/` (snake_case to match `.ui` filenames). + ``` -marketplace/ +pj_marketplace/ ├── CMakeLists.txt ├── main.cpp -├── src/ -│ ├── models/ -│ │ ├── Extension.h # Extension metadata struct -│ │ ├── InstalledExtension.h # Local installation info -│ │ └── Registry.h # Full registry model -│ ├── core/ -│ │ ├── RegistryManager.h/cpp # Fetch, parse, cache registry -│ │ ├── ExtensionManager.h/cpp # Install, uninstall, update -│ │ ├── DownloadManager.h/cpp # HTTP download with progress -│ │ └── PlatformUtils.h/cpp # OS detection, paths -│ ├── ui/ -│ │ ├── MarketplaceWindow.h/cpp # Main window/dialog -│ │ ├── ExtensionListWidget.h/cpp # Extension list (table or list) -│ │ ├── ExtensionDetailDialog.h/cpp # Detail dialog (Approach A - POC) -│ │ ├── ExtensionDetailWidget.h/cpp # Detail panel (Approach B - future) -│ │ └── StatusBarManager.h/cpp # Progress/status -│ └── utils/ -│ ├── ChecksumVerifier.h/cpp # SHA256 verification -│ └── ZipExtractor.h/cpp # ZIP decompression -└── resources/ - ├── icons/ - └── marketplace.qrc +├── include/ +│ └── pj_marketplace/ +│ ├── extension.hpp # Extension metadata struct +│ ├── installed_extension.hpp # Local installation record +│ ├── extension_manager.hpp # Install/uninstall/update API + signals +│ ├── registry_manager.hpp # Registry fetch/parse API +│ ├── download_manager.hpp # HTTP + checksum + libarchive extraction +│ ├── platform_utils.hpp # OS detection, standard paths +│ ├── marketplace_window.hpp # Main dialog +│ └── extension_detail_dialog.hpp # Per-extension detail dialog +└── src/ + ├── core/ + │ ├── ExtensionManager.cpp + │ ├── RegistryManager.cpp + │ ├── DownloadManager.cpp + │ └── PlatformUtils.cpp + └── ui/ + ├── marketplace_window.{cpp,ui} + └── extension_detail_dialog.{cpp,ui} ``` ### 3.2 Data Models @@ -176,7 +175,6 @@ struct InstalledExtension { QDateTime install_date; QString path; bool enabled; - QString backup_path; // Optional }; ``` @@ -185,10 +183,8 @@ struct InstalledExtension { | Component | Responsibility | Dependencies | |-----------|---------------|--------------| | **RegistryManager** | Fetch JSON, parse, cache with TTL | QNetworkAccessManager | -| **ExtensionManager** | Install, uninstall, update, rollback | DownloadManager, ZipExtractor, PlatformUtils | -| **DownloadManager** | HTTP GET with progress signals | QNetworkAccessManager | -| **ChecksumVerifier** | SHA256 verification | QCryptographicHash | -| **ZipExtractor** | Extract ZIP to directory | QuaZip/minizip | +| **ExtensionManager** | Install, uninstall, update, staged promotion | DownloadManager, PlatformUtils, plugin catalog | +| **DownloadManager** | HTTP GET with progress, SHA256 verification, ZIP extraction | QNetworkAccessManager, QCryptographicHash, libarchive | | **PlatformUtils** | Detect OS, get paths | Qt platform macros | #### ExtensionManager — Constructor Design @@ -207,8 +203,25 @@ ExtensionManager(DownloadManager* downloader, **Design decisions:** - No `setExtensionsDir()` public setter — directory is fixed at construction time - No `detectPlatform()` private method — delegated to `PlatformUtils::currentPlatform()` -- Local installation state (`QMap`) is a private member of `ExtensionManager` — populated at construction by scanning `extensions_dir` and reading each subdirectory's `manifest.json`; testability is preserved via the `extensions_dir` parameter pointing to a temp directory -- No `installed.json` — disk is the source of truth; `manifest.json` inside each extension directory provides `id` and `version` +- Local installation state (`QMap`) is a private cache in `ExtensionManager` — populated at construction by scanning `extensions_dir`, loading plugin DSOs, and reading their embedded manifests; testability is preserved via the `extensions_dir` parameter pointing to a temp directory +- No local installed-state sidecars — disk is scanned, but `id` and `version` come from the embedded DSO manifest +- Windows staged updates write a transient `.pj_pending_install` intent containing the registry id/version. It is deleted after promotion and exists only so restart-time validation can compare the staged DSO against the registry request that created it. Both the id and the version inside the intent file are validated against the same safe-path/regex rules used elsewhere, so a tampered intent cannot escape `extensions_dir`. +- Embedding apps may seed the marketplace with a loaded-plugin snapshot before first render. That snapshot is initialization data, not a second source of truth; the embedded manifest remains the authority for installed state. +- **Pending queues drained at construction.** `ExtensionManager::initComponents()` runs `applyPendingUninstalls()` then `applyPendingInstalls()` before computing the installed snapshot, so restart-deferred work is processed regardless of which `MarketplaceWindow` constructor (or host wiring) ends up using the manager. +- **Restart-cleanup marker honors write failures.** `schedulePendingUninstall` returns `bool`; if the marker file cannot be written the in-memory entry is left intact and `uninstallError` is emitted, so a Windows uninstall that cannot mark the directory does not silently revert on the next start. +- **Broken staged installs are quarantined, not retried forever.** When `applyPendingInstalls` fails to remove a rejected stage, the directory is renamed to `.pj_quarantine__/` next to it. The next startup ignores quarantine entries and reports the path in the diagnostic so the user can inspect and clean it up. + +#### ExtensionManager — Diagnostic propagation + +`ExtensionManager` exposes its diagnostics three ways simultaneously: + +| Channel | Audience | Notes | +|---------|----------|-------| +| `diagnostics()` accessor + 50-entry ring buffer | UI snapshot at any time | The marketplace window's "Diagnostics" dialog reads this. | +| `diagnosticReported(QString id, QString message, bool is_error)` Qt signal | Standalone marketplace UI | Pushes into the status bar. | +| Optional `PJ::DiagnosticSink` constructor parameter | Embedding hosts | Lets `PluginRegistry`, `ExtensionManager`, and any other module feed one chronological stream into a single GUI sink. | + +See `pj_base/include/pj_base/diagnostic_sink.hpp` for the sink contract; the standalone `pj_marketplace_app` does not pass a sink, preserving the previous behavior unchanged. --- @@ -216,6 +229,15 @@ ExtensionManager(DownloadManager* downloader, ### 4.1 Installation Flow +Both the immediate (Linux/macOS) and deferred (Windows) paths extract the +download into a hidden transaction directory (`.pj_install__/`) on +the same filesystem as its final destination, so the eventual rename is +atomic. The DSO is **dlopened and its embedded manifest validated** inside +the transaction directory before promotion, then **re-validated at the final +location** after the rename — this catches DSOs that depend on rpath/relative +paths that hold in the staging area but break in `extensions/`. On failure +the transaction directory is removed and no partial state survives. + ![Installation Flow](diagrams/installation-flow.png)
@@ -232,13 +254,20 @@ start :Download ZIP; :Verify SHA256; if (Checksum OK?) then (yes) - :Extract to temp; - :Validate manifest; if (Is update?) then (yes) :Backup current; endif - :Move to extensions/; - :Read manifest.json → register in memory; + :Extract to .pj_install__/ (transaction dir); + :Load DSO manifest; + :Validate registry id/version; + :Atomic rename to extensions//; + :Re-validate promoted DSO (post-promotion gate); + if (Re-validation OK?) then (yes) + :Register discovery cache; + else (no) + :Move to .pj_quarantine__/; + :Notify install error; + endif else (no) :Error: invalid checksum; endif @@ -262,21 +291,25 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .pending/{id}/; -note right: Staging folder +:Extract to .pj_install__/ (transaction dir under .extension_staging/); +:Load DSO manifest; +:Validate registry id/version; +:Atomic rename to .extension_staging//; +:Write .extension_staging//.pj_pending_install intent; :Notify "Restart required"; stop start :PlotJuggler restarts; -:Move .pending/{id}/ to extensions/{id}/; -note right: Previous backup in\n.backup/{id}-{ver}/ -:Load plugin; -if (Load successful?) then (yes) +:applyPendingInstalls() scans .extension_staging/; +:Read .pj_pending_install intent; +:Validate staged DSO manifest; +if (Valid?) then (yes) + :Move .extension_staging// to extensions//; :Plugin active; else (no) - :Restore from backup; - :Notify rollback; + :Move to .pj_quarantine__/; + :Notify install error; endif stop @enduml @@ -285,6 +318,19 @@ stop ### 4.3 Rollback Flow +Every successful update — Linux, macOS, and Windows — moves the previous +version into `.backup/-/` before the new version takes its +place. On Linux/macOS this happens synchronously inside `update()`; on +Windows it happens at restart inside `applyPendingInstalls()`, just before +the staged directory is renamed over the existing one. If the staged +promotion fails after the backup move, `applyPendingInstalls()` attempts +to roll the backup back into place; if even the rollback fails, the +diagnostic surfaces both paths so the user can recover manually. + +Automatic *post-load* rollback (restoring from backup if the freshly +installed plugin later fails to load) is deferred. The backup directory +is the manual recovery point. + ![Rollback Flow](diagrams/rollback-flow.png)
@@ -293,7 +339,7 @@ stop ```plantuml @startuml skinparam backgroundColor white -title Rollback Flow +title Rollback Flow (Deferred) start :PlotJuggler starts; @@ -303,11 +349,11 @@ while (More plugins?) is (yes) if (Load OK?) then (yes) :Plugin active; else (no) - if (Backup exists?) then (yes) - :Restore backup; - else (no) - :Disable extension; - endif + :Report load failure; + note right + Automatic backup restore + is deferred. + end note endif endwhile (no) :System ready; @@ -322,30 +368,30 @@ stop ### 5.1 Installation Directories +The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.local/share/plotjuggler/`, macOS: `~/Library/Application Support/plotjuggler/`, Windows: `%LOCALAPPDATA%/plotjuggler/`). + ``` -~/.plotjuggler/ -├── extensions/ # Active plugins +/ +├── extensions/ # Active plugins │ ├── ros2-streaming/ -│ │ ├── manifest.json │ │ ├── libros2_streaming.so │ │ └── ros2_streaming.ui │ └── csv-loader/ -│ ├── manifest.json │ └── libcsv_loader.so -├── .pending/ # Staging area (Windows) -│ └── ros2-streaming/ # Ready to install on restart -├── .backup/ # Rollback backups -│ ├── ros2-streaming-1.2.2/ -│ └── csv-loader-0.9.0/ -└── .cache/ # Registry cache - └── registry.json +├── .extension_staging/ # Staging area (all platforms — Windows uses it +│ │ # for restart-time install; Linux/macOS +│ │ # use it as the post-promotion validation gate) +│ └── ros2-streaming/ # Ready to install on restart (Windows) +│ └── .pj_pending_install # Intent file (Windows-only) +└── .backup/ # Pre-update backups (all platforms); automatic rollback deferred — restore manually + ├── ros2-streaming-1.2.2/ + └── csv-loader-0.9.0/ ``` ### 5.2 Extension ZIP Structure ``` ros2-streaming-linux-x86_64.zip -├── manifest.json # Required: extension metadata ├── libros2_streaming.so # Required: compiled plugin(s) ├── ros2_streaming.ui # Optional: Qt Creator UI file ├── README.md # Optional: description @@ -397,7 +443,7 @@ Binary compatibility (ABI) is the biggest technical challenge: ### 6.3 Compatibility Policy -- Each plugin declares `min_plotjuggler_version` in manifest +- The registry declares `min_plotjuggler_version` for each extension - If SDK changes incompatibly, PlotJuggler provides internal adapter - **Existing plugins are never broken by PlotJuggler updates** - Stability target: Qt LTS 6.8 (support until 2028) @@ -408,44 +454,12 @@ Binary compatibility (ABI) is the biggest technical challenge: ### 7.1 CMakeLists.txt (Marketplace) -```cmake -cmake_minimum_required(VERSION 3.16) -project(pj_marketplace VERSION 1.0.0 LANGUAGES CXX) +The actual CMakeLists.txt is the source of truth — see `pj_marketplace/CMakeLists.txt`. Notable points: -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) - -find_package(Qt6 REQUIRED COMPONENTS Widgets Network) -find_package(QuaZip-Qt6 REQUIRED) # Or alternative ZIP library - -add_library(pj_marketplace SHARED - src/models/Extension.cpp - src/core/RegistryManager.cpp - src/core/ExtensionManager.cpp - src/core/DownloadManager.cpp - src/core/PlatformUtils.cpp - src/ui/MarketplaceWindow.cpp - src/ui/ExtensionListWidget.cpp - src/ui/ExtensionCardDelegate.cpp - src/ui/ExtensionDetailWidget.cpp - src/utils/ChecksumVerifier.cpp - src/utils/ZipExtractor.cpp - resources/marketplace.qrc -) - -target_link_libraries(pj_marketplace PRIVATE - Qt6::Widgets - Qt6::Network - QuaZip::QuaZip -) - -target_include_directories(pj_marketplace PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}/src -) -``` +- The marketplace splits into two static libs: `pj_marketplace` (core: ExtensionManager, RegistryManager, DownloadManager, PlatformUtils) and `pj_marketplace_ui` (MarketplaceWindow, ExtensionDetailDialog). +- `pj_marketplace` depends on `pj_plugin_catalog` (from `pj_plugins/`) for embedded-DSO-manifest discovery; the standalone build inlines the same `plugin_catalog.cpp` source. +- C++20, `-Wall -Wextra -Werror -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-qual -Wconversion -Woverloaded-virtual -Wpedantic`. +- Tests built only when fixture plugin targets exist (`mock_data_source_plugin`, `mock_file_source_plugin`, `mock_data_source_v2_plugin`, `missing_id_data_source_plugin`). ### 7.2 Dummy Plugin CMakeLists.txt (POC) @@ -468,23 +482,21 @@ set_target_properties(dummy_extension PROPERTIES ) install(TARGETS dummy_extension DESTINATION .) -install(FILES manifest.json DESTINATION .) ``` **dummy_plugin.cpp:** ```cpp -extern "C" { - const char* getPluginMetadata() { - return R"({ - "id": "dummy-extension", - "name": "Dummy Extension", - "version": "1.0.0" - })"; - } -} +#include + +class DummySource final : public PJ::DataSourcePluginBase { + // Implement the SDK interface... +}; + +PJ_DATA_SOURCE_PLUGIN(DummySource, + R"({"id":"dummy-extension","name":"Dummy Extension","version":"1.0.0"})") ``` -> **Note:** Each dummy extension folder is an independent C++ project with its own CMakeLists.txt. No Qt dependency means trivial cross-platform compilation. +> **Note:** Each dummy extension folder is an independent C++ project with its own CMakeLists.txt. The plugin DSO embeds its manifest through the SDK export macro; there is no Qt dependency in the plugin. ### 7.3 Real Plugin Template CMakeLists.txt (Post-POC) @@ -514,7 +526,6 @@ set_target_properties(my_plugin PROPERTIES install(TARGETS my_plugin DESTINATION .) install(FILES my_dialog.ui DESTINATION .) -install(FILES manifest.json DESTINATION .) install(FILES README.md LICENSE DESTINATION .) ``` @@ -600,7 +611,6 @@ plotjuggler/extension-template/ ├── CMakeLists.txt ├── conanfile.py ├── pixi.toml # Future alternative -├── manifest.json.in ├── conan_profiles/ │ ├── linux_static │ ├── windows_static @@ -875,7 +885,7 @@ This more elaborate approach can be implemented after the POC if a richer UX is └──────────────────────────────────────────────────────────────────┘ ``` -**Qt Widget Hierarchy (Approach B):** +**Qt Widget Hierarchy (conceptual target; current code keeps cards in `MarketplaceWindow` and opens `ExtensionDetailDialog` for details):** ``` MarketplaceWindow (QMainWindow or QDialog) @@ -910,7 +920,7 @@ MarketplaceWindow (QMainWindow or QDialog) | GUI Framework | Qt 6 Widgets | QML | Consistency with PlotJuggler | | HTTP Client | QNetworkAccessManager | libcurl | Already in Qt, no extra deps | | JSON Parsing | QJsonDocument | nlohmann/json | Already in Qt | -| ZIP Library | QuaZip | minizip, libzip | Qt integration, well maintained | +| ZIP Library | libarchive | QuaZip, minizip, libzip | Already used by `DownloadManager`; supports ZIP extraction without Qt-specific archive wrappers | | Checksum | QCryptographicHash | OpenSSL | Already in Qt | | Build System | CMake + Conan | Meson, Bazel | Industry standard, team experience | diff --git a/pj_marketplace/documentation/PLAN.md b/pj_marketplace/documentation/PLAN.md deleted file mode 100644 index fc29b1e..0000000 --- a/pj_marketplace/documentation/PLAN.md +++ /dev/null @@ -1,341 +0,0 @@ -# PlotJuggler Marketplace — Implementation Plan - -> **Version:** 1.0.0 -> **Last Updated:** 2026-03-05 -> **Status:** In Progress -> **Deadline:** 31 March 2026 - ---- - -## 1. Project Timeline - -A working prototype integrated into PlotJuggler is expected by the end of March / early April 2026. - ---- - -## 2. Sprint Overview - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 1 (5-11 March): Standalone POC │ -│ Deliverable: Qt app with dummy plugins, works on Linux AND Windows │ -│ Note: Dummy plugins only have getMetadata() function │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 2 (12-18 March): PlotJuggler Integration │ -│ Deliverable: Marketplace opens as dialog INSIDE PlotJuggler │ -│ ★ 16 March: Convergence with Davide on real plugin interfaces │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 3 (19-25 March): Real Plugin End-to-End │ -│ Deliverable: Install REAL plugin from marketplace, works in PJ │ -│ Note: Davide traveling to Japan (work continues autonomously) │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 4 (26-31 March): Polish + Buffer │ -│ Deliverable: Demo to Davide, documentation, fixes │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Requirements Coverage - -### P0 (Minimum Viable) — 100% in March - -| ID | Requirement | Week | Status | -|----|-------------|------|--------| -| F-01 | Fetch and parse registry JSON | W1 | ⬜ TODO | -| F-02 | List extensions with cards | W1 | ⬜ TODO | -| F-03 | Search by name, description, tags | W1 | ⬜ TODO | -| F-04 | Filter by category | W1 | ⬜ TODO | -| F-05 | Show extension detail | W1 | ⬜ TODO | -| F-06 | Download ZIP with SHA256 | W1 | ⬜ TODO | -| F-07 | Extract to extensions dir | W1 | ⬜ TODO | -| F-08 | Register in installed.json | W1 | ⬜ TODO | -| F-09 | Detect updates | W3 | ⬜ TODO | -| F-10 | Uninstall extension | W1 | ⬜ TODO | - -### Integration (Critical Path) - -| ID | Requirement | Week | Status | -|----|-------------|------|--------| -| F-A1 | Menu: Plugins → Marketplace | W2 | ⬜ TODO | -| F-A2 | Hook with plugin loading | W2 | ⬜ TODO | -| F-A3 | Example plugin (CSV Loader) | W3 | ⬜ TODO | -| F-A4 | Test registry on GitHub | W3 | ⬜ TODO | - -### Deferred to April+ - -| ID | Requirement | Reason | -|----|-------------|--------| -| F-11 | Cache with TTL | Direct fetch works | -| F-12 | Backup on updates | V1 can be simple | -| F-13 | Automatic rollback | NOT PRIORITY per Davide (2026-03-05) | -| F-15 | Enable/Disable | Uninstall/reinstall | -| F-16 | Cancel download | Nice-to-have | -| F-17 | Update All | One by one OK | -| F-18 | Confirmation dialogs | If time in W4 | -| F-19-23 | Polish features | Post-MVP | - -> **Note (2026-03-05 meeting):** Windows support moved to Week 1. Rollback explicitly deprioritized by Davide. - ---- - -## 4. Week 1: Standalone MVP (5-11 March) - -### Daily Breakdown - -| Day | Date | Tasks | Deliverable | -|-----|------|-------|-------------| -| Thu | 5 Mar | Setup + Data structs + Attend Data Store presentation 11am | CMake+Qt6, Extension.h | -| Fri | 6 Mar | UI skeleton: MarketplaceWindow + list | Window with splitter | -| Mon | 9 Mar | ExtensionCardDelegate + search | Cards, filter works | -| Tue | 10 Mar | RegistryManager: fetch + parse | Loads from GitHub | -| Wed | 11 Mar | DownloadManager + SHA256 + Zip | Installs dummy ZIP | - -### TODO Week 1 - -- [ ] Create folder `pj_marketplace` in PlotJuggler Core -- [ ] Setup CMakeLists.txt with Qt6, Conan -- [ ] Create Extension.h struct -- [ ] Create InstalledExtension.h struct -- [ ] Create MarketplaceWindow (QMainWindow) -- [ ] Add QSplitter (sidebar + detail) -- [ ] Create ExtensionListWidget with QListView -- [ ] Create ExtensionCardDelegate (custom painting) -- [ ] Add search QLineEdit -- [ ] Add category QComboBox filter -- [ ] Create RegistryManager class -- [ ] Implement fetch with QNetworkAccessManager -- [ ] Implement JSON parsing -- [ ] Create DownloadManager class -- [ ] Implement progress signals -- [ ] Create ChecksumVerifier (SHA256) -- [ ] Create ZipExtractor (QuaZip) -- [ ] Create ExtensionManager — inject DownloadManager, ZipExtractor via constructor; installed state managed internally via private loadState()/saveState() -- [ ] Use PlatformUtils::extensionsDir() as default extensions directory (no setExtensionsDir setter) -- [ ] Delegate platform detection to PlatformUtils::currentPlatform() (no private detectPlatform()) -- [ ] Implement install flow -- [ ] Implement uninstall flow -- [ ] Create dummy registry on GitHub for testing -- [ ] Create dummy extension ZIP for testing - -### Success Criteria Week 1 - -- [ ] App opens and shows extensions -- [ ] Search "dummy" finds extension -- [ ] Click Install → downloads → extracts → shows as installed -- [ ] Click Uninstall → removes - ---- - -## 5. Week 2: PlotJuggler Integration (12-18 March) - -### Daily Breakdown - -| Day | Date | Tasks | Deliverable | -|-----|------|-------|-------------| -| Thu | 12 Mar | Extract marketplace as library | libpj_marketplace.so | -| Fri | 13 Mar | Add entry point in PlotJuggler | Menu item | -| Mon | 16 Mar | Integrate as QDialog | Opens inside PJ | -| Tue | 17 Mar | Hook with plugin loader | PJ detects installed | -| Wed | 18 Mar | Testing + fixes | Full flow in PJ | - -### TODO Week 2 - -- [ ] Refactor standalone → library -- [ ] Create MarketplaceDialog (QDialog wrapper) -- [ ] Add menu action in PlotJuggler -- [ ] Connect to plugin loading system -- [ ] Handle "restart required" case -- [ ] Test install flow from inside PJ -- [ ] Test uninstall flow from inside PJ -- [ ] Fix integration issues - -### Success Criteria Week 2 - -- [ ] PlotJuggler: Plugins → Marketplace works -- [ ] Dialog shows marketplace UI -- [ ] Can install extension from inside PJ -- [ ] Extension appears in correct directory - ---- - -## 6. Week 3: Real Plugin End-to-End (19-25 March) - -### Daily Breakdown - -| Day | Date | Tasks | Deliverable | -|-----|------|-------|-------------| -| Thu | 19 Mar | Create example plugin: CSV Loader | Minimal plugin | -| Fri | 20 Mar | Package as ZIP with manifest | csv-loader.zip | -| Mon | 23 Mar | Publish to test registry | GitHub Release | -| Tue | 24 Mar | Test: install from marketplace | Plugin appears | -| Wed | 25 Mar | Test: use the plugin | Load CSV file | - -### TODO Week 3 - -- [ ] Create SimpleCsvLoader plugin (~100 lines) -- [ ] Create manifest.json for it -- [ ] Package as ZIP -- [ ] Create GitHub repo for test registry -- [ ] Create registry.json with csv-loader -- [ ] Upload ZIP as GitHub Release -- [ ] Test: marketplace shows csv-loader -- [ ] Test: install downloads and extracts -- [ ] Test: restart PJ, plugin loads -- [ ] Test: load a CSV file with plugin -- [ ] Implement update detection (F-09) - -### Success Criteria Week 3 - -- [ ] CSV Loader appears in marketplace -- [ ] Click Install → downloads and installs -- [ ] Restart PlotJuggler → plugin available -- [ ] Load CSV file → data appears - ---- - -## 7. Week 4: Polish + Buffer (26-31 March) - -### Daily Breakdown - -| Day | Date | Tasks | Deliverable | -|-----|------|-------|-------------| -| Thu | 26 Mar | Bug fixes from testing | Stability | -| Fri | 27 Mar | Error messages UX | Better feedback | -| Mon | 30 Mar | README + documentation | Docs | -| Tue | 31 Mar | **DEMO TO DAVIDE** | Presentation | - -### TODO Week 4 - -- [ ] Fix all known bugs -- [ ] Improve error messages -- [ ] Add confirmation dialogs (F-18) if time -- [ ] Write README for pj_marketplace -- [ ] Document how to add extensions -- [ ] Prepare demo script -- [ ] **Demo to Davide** - -### Demo Checklist - -- [ ] Open PlotJuggler -- [ ] Go to Plugins → Marketplace -- [ ] See list of extensions -- [ ] Search for "csv" -- [ ] Install CSV Loader -- [ ] Close marketplace -- [ ] Verify plugin is available -- [ ] Load a CSV file -- [ ] Show data in PlotJuggler - ---- - -## 8. Risks and Mitigations - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| Qt6/Conan setup complex | Medium | High | Use Davide's monorepo config | -| PJ integration harder than expected | High | High | Start Week 2 early, ask Davide | -| Plugin SDK not ready | Medium | High | Use existing plugin as base | -| Scope creep | High | High | This document is the scope. NO more | -| Bugs in Week 4 | Medium | Medium | Week 4 is buffer, not features | - ---- - -## 9. Communication Plan - -### Check-ins with Davide - -| Date | Demo | Content | -|------|------|---------| -| 11 Mar | Week 1 | "Standalone works" | -| 18 Mar | Week 2 | "Now inside PlotJuggler" | -| 25 Mar | Week 3 | "Real plugin installed from marketplace" | -| 31 Mar | Final | "Complete prototype" | - -### Daily Standups - -- **Time:** 10am daily -- **Format:** 2 min max - 1. Yesterday: what completed - 2. Today: what working on - 3. Blockers: if any - -### If Blocked - -1. Communicate immediately -2. Propose alternative -3. Adjust scope if needed - ---- - -## 10. Definition of Done - -### For Week 1 (Standalone MVP) -- [ ] Code compiles on Linux -- [ ] All TODO items checked -- [ ] Success criteria met -- [ ] Committed to repo - -### For Week 2 (Integration) -- [ ] Marketplace opens from PJ menu -- [ ] Install works from inside PJ -- [ ] Code reviewed by Davide - -### For Week 3 (End-to-End) -- [ ] Real plugin installs and works -- [ ] Test registry published -- [ ] Documented - -### For Final Demo -- [ ] Demo script executed successfully -- [ ] Davide approves -- [ ] No critical bugs - ---- - -## 11. Post-March Roadmap (April+) - -After the March deadline, these items can be addressed: - -1. **Windows support** — Staging system -2. **macOS support** — Testing and fixes -3. **Rollback** — Automatic restoration -4. **Cache** — Registry caching with TTL -5. **CI Template** — For external developers -6. **Polish** — Icons, changelog, metrics - ---- - -## Document Maintenance - -- Update TODO checkboxes as work progresses -- Add new items if discovered during implementation -- Move completed items to "Done" section -- **Delete this file when project is complete** - ---- - -## Done - -*(Move completed items here)* - -### Week 1 -- (none yet) - -### Week 2 -- (none yet) - -### Week 3 -- (none yet) - -### Week 4 -- (none yet) diff --git a/pj_marketplace/documentation/REQUIREMENTS.md b/pj_marketplace/documentation/REQUIREMENTS.md index 79387d4..6706df7 100644 --- a/pj_marketplace/documentation/REQUIREMENTS.md +++ b/pj_marketplace/documentation/REQUIREMENTS.md @@ -30,20 +30,21 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | | Search | Search by name, description, tags, and publisher | | | Category filtering | Data Loader, Data Streamer, Parser, Toolbox | | | Extension detail | Panel with complete information, changelog, and dependencies | +| | Startup snapshot | Host app may seed the marketplace with already-loaded plugin ids/versions before first render | | **Installation** | Secure download | ZIP artifact download with SHA256 verification | | | Automatic extraction | Decompression to extensions directory | | | Platform detection | Automatic selection of correct artifact (Linux/Windows/macOS) | -| **Updates** | Update detection | Local vs registry version comparison (semver) | +| **Updates** | Update detection | Local vs registry version comparison (semver); local-newer state is surfaced explicitly | | | Individual update | Update a specific extension | | | Bulk update | "Update All" for multiple extensions | | | Automatic backup | Backup of previous version before updating | -| **Uninstallation** | Clean removal | Directory deletion + local state update | +| **Uninstallation** | Clean removal | Directory deletion + installed cache refresh | | | Confirmation | Confirmation dialog before uninstalling | -| **Management** | Enable/Disable | Activate/deactivate extensions without uninstalling | -| | Rollback | Automatic restoration if a plugin fails to load | -| | Persistent state | Installed state derived from disk — each extension's manifest.json is the source of truth | -| | Registry URL settings | Configure registry URL at runtime via ⚙ settings dialog; change triggers immediate refresh | +| **Management** | Backup diagnostics | Report retained backup paths when an update install fails | +| | Persistent state | Installed state derived from plugin DSOs; each embedded plugin manifest is the source of truth | +| | Registry URL settings | Configure registry URL at runtime via ⚙ settings dialog; rejects URLs with non-http(s)/file scheme; change triggers immediate refresh | | | Registry URL persistence | Last configured registry URL saved and restored between sessions | +| | Unified diagnostics | All lifecycle events (install / staged-promotion / quarantine / uninstall failures, registry-fetch errors) are surfaced through `ExtensionManager::diagnosticReported`, the in-memory ring buffer, AND an optional `PJ::DiagnosticSink` so embedding hosts can fold marketplace events into their own diagnostic stream | | **UI/UX** | Download progress | Progress bar in status bar | | | Notifications | Status messages and available update alerts | | | Context menu | Quick actions per installed extension | @@ -55,7 +56,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | **Build** | Cross-platform compilation | Matrix build for Linux, Windows, and macOS | | | Static linking | All dependencies embedded in the artifact | | | Dependency management | Support for Conan (current) and Pixi (future) | -| **Packaging** | ZIP generation | Automatic packaging with manifest, binaries, and resources | +| **Packaging** | ZIP generation | Automatic packaging with plugin binaries and resources | | | Checksums | Automatic SHA256 generation per artifact | | | Versioning | Version extraction from git tag | | **Publishing** | GitHub Release | Automatic release creation with attached artifacts | @@ -87,7 +88,23 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | **Registry** | Static JSON file with the catalog of available extensions. | | **Plugin SDK** | Abstract library (no Qt) that plugins use for UI and data access. | | **Artifact** | Compiled binary of an extension for a specific platform. | -| **Manifest** | JSON file inside the ZIP describing the extension contents. | +| **Embedded manifest** | JSON string exported by each plugin DSO describing the installed plugin. | + +--- + +## 3a. Out of scope + +These concerns are intentionally **not** part of `pj_marketplace` and belong +elsewhere in the host application: + +- **Enable/disable installed plugins.** Toggling a plugin on or off without + uninstalling it is a host-application concern (e.g. a per-user config knob + in `pj_app` that filters which discovered DSOs the loader instantiates), + not a marketplace concern. The marketplace's job ends at "installed on + disk"; whether the host chooses to load that DSO at startup is decided by + the host's plugin loader and config. +- **Runtime hot-reload of plugin instances.** Out of scope per PJ4_PLAN + non-goals. --- @@ -104,8 +121,8 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | F-05 | Show selected extension detail | Clicking an extension shows full information panel | | F-06 | Download ZIP with SHA256 verification | Download fails if checksum doesn't match | | F-07 | Extract ZIP to extensions directory | ZIP contents are extracted to correct location | -| F-08 | Register installed extension | Installed state is derived from disk by scanning extensions_dir and reading manifest.json from each subdirectory | -| F-09 | Detect updates (local vs registry version) | User sees "Update available" badge when newer version exists | +| F-08 | Register installed extension | Installed state is derived from disk by scanning extension DSOs and reading each embedded plugin manifest | +| F-09 | Detect updates (local vs registry version) | User sees "Update available" badge when registry is newer, and "Local newer" when the installed version is ahead | | F-10 | Uninstall extension | User can remove installed extensions | ### 4.2 P1 — Robustness @@ -116,9 +133,8 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | F-25 | Persist registry URL between sessions | The last configured registry URL is saved and automatically restored on next launch | | F-11 | Local registry cache with TTL | Registry is cached locally, refreshed after expiration | | F-12 | Backup previous version on updates | Old version saved before overwriting | -| F-13 | Automatic rollback if plugin fails | If plugin crashes on load, previous version is restored | +| F-13 | Automatic rollback if plugin fails | Deferred; backups may exist, but automatic restore is not implemented | | F-14 | Windows staging: apply on restart | Updates downloaded but applied only after restart (Windows) | -| F-15 | Enable/Disable without uninstalling | User can deactivate extension without removing files | | F-16 | Cancel download in progress | User can abort a download | | F-17 | Update All | Single action to update all extensions with available updates | | F-18 | Confirmation dialogs | User confirms before install/uninstall/update actions | @@ -197,11 +213,11 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact 4. System shows confirmation dialog 5. User confirms 6. System removes extension files -7. System updates local state +7. System refreshes installed cache -**Postconditions:** Extension removed, local state updated +**Postconditions:** Extension removed, installed cache refreshed -### UC-04: Plugin Fails to Load (Rollback) +### UC-04: Plugin Fails to Load (Rollback Deferred) **Actor:** System **Preconditions:** Extension recently updated, backup exists @@ -209,11 +225,10 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact 1. PlotJuggler starts 2. System attempts to load plugin 3. Plugin crashes/fails -4. System detects failure -5. System restores backup version -6. System notifies user of rollback +4. System reports the plugin load failure +5. Automatic backup restore is deferred -**Postconditions:** Previous version restored, user notified +**Postconditions:** User is notified; any backup remains available for manual recovery ### UC-05: Developer Publishes Extension @@ -222,7 +237,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact **Flow:** 1. Developer creates tag (v1.0.0) 2. CI compiles for all platforms -3. CI packages ZIPs with manifest +3. CI packages ZIPs with plugin binaries and resources 4. CI creates GitHub Release 5. CI submits PR to registry repository 6. Registry validates schema and URLs @@ -270,7 +285,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | Scenario | Expected Behavior | |----------|-------------------| | Extension requires newer PlotJuggler | Show warning, prevent install | -| Downgrade requested | Allow with warning | +| Downgrade requested | Reject with a diagnostic; keep the local install unchanged | | Same version reinstall | Ask confirmation, then reinstall | ### 8.4 Windows-Specific @@ -278,16 +293,17 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | Scenario | Expected Behavior | |----------|-------------------| | Plugin DLL in use (can't overwrite) | Stage update, apply on restart | -| User cancels pending update | Remove staged files | +| Invalid staged update | Remove staged files and leave active install untouched | | PlotJuggler crashes before applying update | Pending update remains for next start | ### 8.5 Plugin Loading | Scenario | Expected Behavior | |----------|-------------------| -| Plugin crashes on load | Rollback to backup if exists, else disable | +| Plugin crashes on load | Report load failure; automatic rollback is deferred | | Plugin incompatible with current SDK | Clear error message, don't load | -| Manifest missing or invalid | Extension marked as corrupted | +| Embedded manifest missing or invalid | Reject install or staged promotion with diagnostics | +| Marketplace opens in a host app | Seed the initial UI from the host's loaded-plugin snapshot, then reconcile with disk refreshes on demand | --- @@ -297,7 +313,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact - **No backend server** — All hosting via GitHub (serverless) - **No Qt dependency in plugins** — Plugins use abstract SDK only -- **No database** — JSON files for registry and local state +- **No database** — Registry data is JSON; installed state is discovered from embedded DSO manifests, not a local state database - **No user accounts** — Anonymous usage - **No telemetry** — No data collection without consent @@ -383,20 +399,22 @@ The minimum viable product is successful if: ### 11.2 Installed State There is no separate local state file. Installed extensions are discovered at runtime by -scanning `extensions_dir` and reading the `manifest.json` present in each subdirectory. -The `manifest.json` is part of the artifact ZIP and is never modified by the marketplace. +scanning `extensions_dir`, loading candidate plugin DSOs, and reading each DSO's embedded +plugin manifest. The marketplace never writes installed-state or plugin-manifest sidecars. +Windows staged updates use a transient `.pj_pending_install` intent file so restart-time +promotion can revalidate the staged DSO against the registry id/version that created it. -Fields read from `manifest.json`: +Fields read from the embedded plugin manifest: | Field | Source | |-------|--------| -| `id` | `manifest.json → "id"` | -| `version` | `manifest.json → "version"` | +| `id` | Embedded plugin manifest key `"id"` | +| `version` | Embedded plugin manifest key `"version"` | | `install_date` | Last-modified timestamp of the extension root directory | | `path` | The scanned subdirectory itself | | `enabled` | Always `true` by default (no persistence yet) | -### 11.3 Extension Manifest Schema +### 11.3 Registry Extension Schema ```json { @@ -416,18 +434,9 @@ Fields read from `manifest.json`: --- -## 12. Pending Decisions - -| # | Topic | Options | Impact | -|---|-------|---------|--------| -| 1 | ZIP library | QuaZip vs minizip vs libzip | Build complexity | -| 2 | Markdown rendering | QTextBrowser vs plain text | README display | -| 3 | Metrics source | Registry JSON vs GitHub API | Data freshness | -| 4 | Icons | URL in registry vs bundled in ZIP | Download size | -| 5 | Semver parsing | C++ library vs string compare | Correctness | -| 6 | New extension registration | Manual PR vs automated | Developer experience | -| 7 | Pixi timeline | When to add as alternative | Community adoption | -| 8 | Paid plugins | License management approach | Business model | +## 12. Open Follow-Ups + +Remaining implementation follow-ups are tracked in [TODO.md](TODO.md). --- diff --git a/pj_marketplace/documentation/SPRINT_PROPOSAL.md b/pj_marketplace/documentation/SPRINT_PROPOSAL.md deleted file mode 100644 index 2f6a938..0000000 --- a/pj_marketplace/documentation/SPRINT_PROPOSAL.md +++ /dev/null @@ -1,296 +0,0 @@ -# PlotJuggler Marketplace — Sprint Proposal - -> **Target:** Integrated prototype by end of March / early April 2026 -> **Owner:** Pablo (IBRobotics) - ---- - -## 1. Aggressive Prioritization: What's IN and What's OUT - -### MUST HAVE (March - 4 weeks) - -| # | Feature | Why Critical | -|---|---------|--------------| -| 1 | Fetch registry JSON | Nothing works without this | -| 2 | Show extension list | Minimum UX | -| 3 | Search and filter | Basic usability | -| 4 | Install extension (download + extract) | Core value | -| 5 | Verify checksum | Minimum security | -| 6 | Detect updates | Value proposition | -| 7 | Uninstall | Complete flow | -| 8 | **INTEGRATION in PlotJuggler** | Month's goal | -| 9 | 1 working dummy plugin | End-to-end proof | - -### DEFERRED (April+) - -| Feature | Why It Can Wait | -|---------|-----------------| -| Automatic rollback | NOT PRIORITY per Davide (2026-03-05 meeting) | -| Enable/Disable | Can uninstall/reinstall | -| Local cache with TTL | Direct network fetch works | -| Backup on updates | First version simple | -| Extension icons | Text works | -| Changelog UI | README is enough | -| Multiple registries | One registry suffices | -| Complete GitHub CI Template | Manual is OK for beta | -| Metrics (downloads, rating) | Later phase | - -> **Update (2026-03-05):** Windows support moved to Week 1 per Davide's request. POC must work on both Linux and Windows. - ---- - -## 2. High-Level View (4 Weeks) - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 1 (5-11 March): Standalone POC │ -│ Deliverable: Qt app with dummy plugins, works on Linux AND Windows │ -│ Note: Dummy plugins only have getMetadata() function │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 2 (12-18 March): PlotJuggler Integration │ -│ Deliverable: Marketplace opens as dialog INSIDE PlotJuggler │ -│ ★ 16 March: Convergence with Davide on real plugin interfaces │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 3 (19-25 March): Real Plugin End-to-End │ -│ Deliverable: Install REAL plugin from marketplace, works in PJ │ -│ Note: Davide traveling to Japan (work continues autonomously) │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ WEEK 4 (26-31 March): Polish + Buffer │ -│ Deliverable: Demo to Davide, documentation, fixes │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. Week 1: Standalone MVP (5-11 March) - -### Requirements to Implement - -| ID | Requirement | -|----|-------------| -| F-01 | Fetch and parse registry JSON from configurable URL | -| F-02 | List extensions in sidebar with cards | -| F-03 | Search by name, description, tags | -| F-04 | Filter by category | -| F-05 | Show selected extension detail | -| F-06 | Download ZIP with SHA256 verification | -| F-07 | Extract ZIP to extensions directory | -| F-08 | Register installed extension (installed.json) | -| F-10 | Uninstall extension | - -### Daily Breakdown - -| Day | Date | Main Task | Deliverable | -|-----|------|-----------|-------------| -| Thu | 5 Mar | Setup + Data structs + Attend Data Store presentation 11am | CMake+Qt6 working, Extension.h | -| Fri | 6 Mar | UI skeleton: MarketplaceWindow + list | Window with splitter | -| Mon | 9 Mar | ExtensionCardDelegate + search | Nice cards, filter works | -| Tue | 10 Mar | RegistryManager: fetch + parse JSON | Loads from GitHub | -| Wed | 11 Mar | DownloadManager + SHA256 + ZipExtractor | Installs dummy ZIP | - -### Success Criteria Week 1 - -- [ ] App opens and shows extensions from GitHub -- [ ] Can search "dummy" and find the extension -- [ ] Click "Install" → downloads → extracts → appears as installed -- [ ] Click "Uninstall" → removed -- [ ] **Works on Linux AND Windows** (per Davide 2026-03-05) - ---- - -## 4. Week 2: PlotJuggler Integration (12-18 March) - -### Requirements to Implement - -| ID | Requirement | -|----|-------------| -| F-A1 | Integration in PlotJuggler (Plugins → Marketplace menu) | -| F-A2 | Hook with PlotJuggler's plugin loading system | - -### Daily Breakdown - -| Day | Date | Main Task | Deliverable | -|-----|------|-----------|-------------| -| Thu | 12 Mar | Extract marketplace as library | libpj_marketplace.so | -| Fri | 13 Mar | Create entry point in PlotJuggler | Menu: Plugins → Marketplace | -| Mon | 16 Mar | Integrated modal dialog | Opens as QDialog inside PJ | -| Tue | 17 Mar | Hook with plugin loading system | PJ detects installed plugins | -| Wed | 18 Mar | Integration testing + fixes | Full flow inside PJ | - -### Success Criteria Week 2 - -- [ ] From PlotJuggler: Plugins → Marketplace works -- [ ] Dialog opens with marketplace UI -- [ ] Can install an extension from inside PJ -- [ ] Installed extension appears in correct directory - ---- - -## 5. Week 3: Real Plugin End-to-End (19-25 March) - -### Requirements to Implement - -| ID | Requirement | -|----|-------------| -| F-09 | Detect updates (local vs registry version) | -| F-A3 | Functional example plugin (CSV Loader) | -| F-A4 | Test registry on GitHub | - -### Daily Breakdown - -| Day | Date | Main Task | Deliverable | -|-----|------|-----------|-------------| -| Thu | 19 Mar | Create example plugin: Simple CSV Loader | Minimal plugin | -| Fri | 20 Mar | Package as ZIP with manifest | csv-loader-linux-x86_64.zip | -| Mon | 23 Mar | Publish to test registry | GitHub Release + registry.json | -| Tue | 24 Mar | Testing: install from marketplace | Plugin appears in PJ | -| Wed | 25 Mar | Testing: use the plugin | Load a real CSV file | - -### Success Criteria Week 3 - -- [ ] CSV Loader appears in marketplace -- [ ] Click Install → downloads and installs -- [ ] Restart PlotJuggler → plugin is available -- [ ] Load a CSV file → data appears in PlotJuggler - ---- - -## 6. Week 4: Polish + Buffer (26-31 March) - -### Requirements to Implement (if time permits) - -| ID | Requirement | Priority | -|----|-------------|----------| -| F-16 | Cancel download in progress | ⚠️ Nice-to-have | -| F-18 | Confirmation dialogs | ⚠️ Nice-to-have | -| - | Bug fixes and edge cases | 🎯 Critical | -| - | Minimal documentation | 🎯 Critical | - -### Daily Breakdown - -| Day | Date | Main Task | Deliverable | -|-----|------|-----------|-------------| -| Thu | 26 Mar | Fix bugs found in testing | Stability | -| Fri | 27 Mar | Improve error messages | UX | -| Mon | 30 Mar | Write README + documentation | Minimal docs | -| Tue | 31 Mar | **DEMO TO DAVIDE** | Presentation | - -### Demo Checklist - -- [ ] Open PlotJuggler -- [ ] Go to Plugins → Marketplace -- [ ] See extension list -- [ ] Search for "csv" -- [ ] Install CSV Loader -- [ ] Close marketplace -- [ ] Verify plugin is available -- [ ] Load a CSV file -- [ ] Show data in PlotJuggler - ---- - -## 7. Requirements Coverage Summary - -### P0 (Minimum Viable) — 100% in March - -| ID | Requirement | Week | Status | -|----|-------------|------|--------| -| F-01 | Fetch and parse registry JSON | W1 | ⬜ | -| F-02 | List extensions with cards | W1 | ⬜ | -| F-03 | Search by name, description, tags | W1 | ⬜ | -| F-04 | Filter by category | W1 | ⬜ | -| F-05 | Show extension detail | W1 | ⬜ | -| F-06 | Download ZIP with SHA256 | W1 | ⬜ | -| F-07 | Extract to extensions dir | W1 | ⬜ | -| F-08 | Register in installed.json | W1 | ⬜ | -| F-09 | Detect updates | W3 | ⬜ | -| F-10 | Uninstall extension | W1 | ⬜ | - -### Integration (Critical Path) - -| ID | Requirement | Week | Status | -|----|-------------|------|--------| -| F-A1 | Menu: Plugins → Marketplace | W2 | ⬜ | -| F-A2 | Hook with plugin loading | W2 | ⬜ | -| F-A3 | Example plugin (CSV Loader) | W3 | ⬜ | -| F-A4 | Test registry on GitHub | W3 | ⬜ | - -### Coverage Summary - -``` -MARCH TOTAL: 10/10 P0 (100%) - 0-2/8 P1 (0-25%) - 0/5 P2 (0%) - 4/4 Additional (100%) -``` - ---- - -## 8. Risks and Mitigations - -| Risk | Probability | Impact | Mitigation | -|------|-------------|--------|------------| -| Qt6/Conan setup complex | Medium | High | Use Davide's monorepo config | -| PJ integration harder than expected | High | High | Start Week 2 early, ask Davide for help | -| Plugin SDK not ready | Medium | High | Use existing plugin as base | -| Scope creep | High | High | This document IS the scope. NO more features | -| Bugs in Week 4 | Medium | Medium | Week 4 is buffer, not features | - ---- - -## 9. Communication Plan - -### Check-ins with Davide - -| Date | Milestone | Content | -|------|-----------|---------| -| 11 Mar | Week 1 | "Look, it works standalone" | -| 18 Mar | Week 2 | "Now it's inside PlotJuggler" | -| 25 Mar | Week 3 | "This plugin was installed from the marketplace" | -| 31 Mar | Final | "Here's the complete prototype" | - -### If Something Goes Wrong - -1. **Communicate immediately** — Don't wait for problems to pile up -2. **Propose alternative** — Not just the problem, also the solution -3. **Adjust scope** — Better to deliver less but working - ---- - -## 10. What's NOT in This Plan (and That's OK) - -1. ~~**Windows**: Linux only~~ → **UPDATED:** Windows included in Week 1 (2026-03-05) -2. **Automatic rollback**: NOT PRIORITY per Davide (2026-03-05) -3. **Enable/Disable**: Uninstall/reinstall works -4. **Icons**: Text only -5. **Changelog UI**: README in details panel -6. **Multiple registries**: One hardcoded registry -7. **Complete CI Template**: Manual documentation -8. **Sophisticated cache**: Direct fetch every time -9. **macOS**: Phase 2 (April) - ---- - -## 11. Success Metrics - -### End of March - -- [ ] Working prototype available -- [ ] Real plugin installs and works -- [ ] Integration in PlotJuggler complete -- [ ] Demo executed successfully - -### Quantitative - -- 10/10 P0 requirements implemented -- 4/4 integration requirements implemented -- 1 real plugin working end-to-end diff --git a/pj_marketplace/documentation/TODO.md b/pj_marketplace/documentation/TODO.md new file mode 100644 index 0000000..6208a42 --- /dev/null +++ b/pj_marketplace/documentation/TODO.md @@ -0,0 +1,15 @@ +# PlotJuggler Marketplace — TODO + +This file tracks the remaining work that is intentionally deferred or still open. + +## Product follow-ups + +- Automatic rollback / restore from backup after a failed plugin load. +- Local registry cache with TTL, if we decide to reintroduce caching. +- macOS packaging and runtime validation. + +## Maintenance follow-ups + +- Keep marketplace docs aligned with behavior changes. +- Add Windows/macOS coverage where CI or local runners make that practical. +- Revisit registry-side metadata decisions only if the contract changes again. diff --git a/pj_marketplace/documentation/USER_MANUAL.md b/pj_marketplace/documentation/USER_MANUAL.md index 5487754..f077bb2 100644 --- a/pj_marketplace/documentation/USER_MANUAL.md +++ b/pj_marketplace/documentation/USER_MANUAL.md @@ -48,11 +48,13 @@ The marketplace window shows a list of all extensions with their status: | Column | Content | |--------|---------| | **Name** | Extension name | -| **Version** | Current version | -| **Status** | `[install]`, `[installed]`, or `[update]` | +| **Version** | Installed version when available, otherwise registry version; registry version is shown for comparison when they differ | +| **Status** | `[install]`, `[installed]`, `[update]`, or `[local newer]` | **To see extension details:** Double-click on any extension to open a detail dialog with full information (description, author, changelog). +If PlotJuggler already has the plugin loaded at startup, the marketplace is seeded with that loaded state before the first render so the card version matches what the app already opened. + **Quick tip:** Hover over an extension to see a brief description tooltip. ### 2.3 Searching and Filtering @@ -68,9 +70,7 @@ The marketplace window shows a list of all extensions with their status: - Parser - Toolbox -**Quick filters:** -- `@installed` — Show only installed extensions -- `@updates` — Show extensions with available updates +**Quick filters:** *(planned — not yet implemented)* ### 2.4 Installing an Extension @@ -89,7 +89,7 @@ The marketplace window shows a list of all extensions with their status: 2. Click on the extension 3. Click **Update** 4. The old version is automatically backed up -5. If something goes wrong, the old version is restored +5. If something goes wrong, the old version remains in `.backup/` and can be recovered manually **Update All:** Click "Update All" in the toolbar to update all extensions at once @@ -102,14 +102,7 @@ The marketplace window shows a list of all extensions with their status: ### 2.7 Enabling/Disabling Extensions -You can disable an extension without uninstalling: -1. Click on the installed extension -2. Click **Disable** -3. Extension remains installed but won't load - -To re-enable: -1. Click on the disabled extension -2. Click **Enable** +*Planned — see [TODO.md](TODO.md). Today, removing an extension requires Uninstall.* --- @@ -144,7 +137,7 @@ To re-enable: 2. **Modify the plugin:** - Edit `src/my_plugin.cpp` - - Update `manifest.json.in` with your extension info + - Update the embedded manifest string passed to the SDK export macro - Add UI in `ui/my_dialog.ui` (optional) 3. **Build locally:** @@ -155,7 +148,7 @@ To re-enable: ``` 4. **Test locally:** - - Copy built files to `~/.plotjuggler/extensions/my-extension/` + - Copy built files to your platform's extensions directory (Linux: `~/.local/share/plotjuggler/extensions/my-extension/` — see §5.1 for other OSes) - Open PlotJuggler and verify plugin loads 5. **Release:** @@ -175,36 +168,23 @@ To re-enable: - Review the auto-generated PR - Merge to add to public marketplace -### 3.2 Extension Manifest - -Every extension needs a `manifest.json`: - -```json -{ - "id": "my-extension", - "version": "1.0.0", - "min_plotjuggler_version": "4.0.0", - "plugins": [ - { - "name": "MyPlugin", - "type": "data_loader", - "library": "libmy_plugin", - "ui_file": "my_dialog.ui" - } - ] -} +### 3.2 Embedded Plugin Manifest + +Every plugin DSO must export an embedded manifest through the SDK macro: + +```cpp +PJ_DATA_SOURCE_PLUGIN(MyPlugin, + R"({"id":"my-extension","name":"My Extension","version":"1.0.0"})") ``` | Field | Required | Description | |-------|----------|-------------| | `id` | Yes | Unique identifier (lowercase, hyphens) | +| `name` | Yes | Human-readable plugin name | | `version` | Yes | Semantic version (X.Y.Z) | -| `min_plotjuggler_version` | Yes | Minimum compatible PJ version | -| `plugins` | Yes | Array of plugins in this extension | -| `plugins[].name` | Yes | C++ class name | -| `plugins[].type` | Yes | data_loader, data_streamer, parser, toolbox | -| `plugins[].library` | Yes | Library name without extension | -| `plugins[].ui_file` | No | Qt Designer .ui file | +| `encoding` | Parsers only | Message encoding handled by a parser plugin | +| `file_extensions` | No | File suffixes handled by a file source plugin | +| `capabilities` | No | Optional capability tags | ### 3.3 Plugin Types @@ -228,6 +208,12 @@ Every extension needs a `manifest.json`: ## 4. Troubleshooting +### 4.0 Diagnostics dialog + +The marketplace toolbar shows a **Details** button whenever there are recent diagnostics. Click it to open a read-only log of marketplace lifecycle events (install/uninstall/staged-promotion failures, quarantine moves, registry-fetch failures). The most recent error also appears in the status bar; a successful registry refresh clears the sticky error so progress messages aren't suppressed. + +When the marketplace runs **inside** a host application (e.g. PlotJuggler), the host can subscribe to the same diagnostic stream alongside its own plugin-load events; see ARCHITECTURE.md §3.3 *ExtensionManager — Diagnostic propagation*. + ### 4.1 Common Issues | Problem | Cause | Solution | @@ -236,7 +222,12 @@ Every extension needs a `manifest.json`: | "Download failed" | Network issue | Check internet, try again | | "Checksum mismatch" | Corrupted download | Try again, report if persistent | | "Cannot update (Windows)" | DLL in use | Restart PlotJuggler | -| "Extension disappeared" | Rollback occurred | Check logs, previous version restored | +| "Installed version is newer" | Local plugin is ahead of registry | Downgrade is blocked; keep the local version | +| "Update failed after backup" | New artifact did not install | Check marketplace diagnostics for the retained backup path | +| "Post-promotion validation failed" | The DSO loads in the staging area but not from `extensions/` (rpath/dep issue) | The install is rolled back; check the diagnostic for the linker error | +| "Could not mark … for restart cleanup" | Marketplace could not write the `.pj_pending_uninstall` marker (Windows; permissions or AV) | The uninstall is **not** scheduled; resolve the file-permission issue and retry | +| "Moved to quarantine: …" | A previous staged update could not be removed; it has been moved aside | Inspect the quarantined directory and delete it manually once safe | +| "Invalid registry URL" | The Settings dialog rejected a malformed URL | Use a `http://`, `https://`, or `file://` URL | ### 4.2 Log Locations @@ -248,18 +239,19 @@ Every extension needs a `manifest.json`: ### 4.3 Reset Marketplace -If the marketplace is broken: +If the marketplace is broken, remove the extensions and staging directories under your platform's config root (see §5.1): ```bash -# Linux/macOS -rm -rf ~/.plotjuggler/extensions/ -rm ~/.plotjuggler/installed.json -rm -rf ~/.plotjuggler/.cache/ +# Linux +rm -rf ~/.local/share/plotjuggler/extensions/ +rm -rf ~/.local/share/plotjuggler/.extension_staging/ + +# macOS +rm -rf ~/Library/Application\ Support/plotjuggler/extensions/ # Windows -rmdir /s %USERPROFILE%\.plotjuggler\extensions -del %USERPROFILE%\.plotjuggler\installed.json -rmdir /s %USERPROFILE%\.plotjuggler\.cache +rmdir /s %LOCALAPPDATA%\plotjuggler\extensions +rmdir /s %LOCALAPPDATA%\plotjuggler\.extension_staging ``` ### 4.4 Reporting Bugs @@ -278,27 +270,34 @@ rmdir /s %USERPROFILE%\.plotjuggler\.cache ### 5.1 Directory Structure +The marketplace uses the OS-standard writable data location (resolved by `QStandardPaths::GenericDataLocation`): + +| OS | Root | +|----|------| +| Linux | `~/.local/share/plotjuggler/` | +| macOS | `~/Library/Application Support/plotjuggler/` | +| Windows | `%LOCALAPPDATA%/plotjuggler/` | + +Inside that root: + ``` -~/.plotjuggler/ -├── extensions/ # Installed extensions +/ +├── extensions/ # Active installed extensions │ └── my-extension/ -│ ├── manifest.json │ └── libmy_plugin.so -├── .pending/ # Staged updates (Windows) -├── .backup/ # Backup of previous versions -├── .cache/ # Registry cache -│ └── registry.json -└── installed.json # Local state +├── .extension_staging/ # Staging area (all platforms — Windows uses it +│ │ # for restart-time installs; Linux/macOS +│ │ # use it as the post-promotion validation gate) +│ └── my-extension/ +│ └── .pj_pending_install # Intent file (Windows-only restart-time apply) +└── .backup/ # Pre-update backups (all platforms); automatic rollback deferred — restore manually ``` ### 5.2 Registry URL **Default:** `https://raw.githubusercontent.com/plotjuggler/marketplace-registry/main/registry.json` -**Custom registry:** Set in PlotJuggler settings or environment variable: -```bash -export PLOTJUGGLER_REGISTRY_URL=https://your-company.com/registry.json -``` +**Custom registry:** Open the marketplace, click ⚙ Settings, paste the new URL, click OK. The URL is persisted under `QSettings("PlotJuggler", "Marketplace")/registry_url` and restored on next launch. ### 5.3 Supported Platforms @@ -339,7 +338,7 @@ This is the **PlotJuggler Marketplace**, an extension distribution system for Pl | `REQUIREMENTS.md` | What the system should do | | `ARCHITECTURE.md` | How the system is designed | | `USER_MANUAL.md` | This file - how to use it | -| `PLAN.md` | Current work plan and TODOs | +| `TODO.md` | Remaining work and deferred follow-ups | ### 6.3 Common Tasks @@ -367,7 +366,7 @@ This is the **PlotJuggler Marketplace**, an extension distribution system for Pl | Installation logic | `src/core/ExtensionManager.cpp` | | Download handling | `src/core/DownloadManager.cpp` | | Main UI | `src/ui/MarketplaceWindow.cpp` | -| Extension list | `src/ui/ExtensionListWidget.cpp` | +| Extension detail dialog | `src/ui/extension_detail_dialog.cpp` | | Data models | `src/models/` | ### 6.5 Testing diff --git a/pj_marketplace/documentation/diagrams/architecture.puml b/pj_marketplace/documentation/diagrams/architecture.puml index 5bfc0c5..3ff8db9 100644 --- a/pj_marketplace/documentation/diagrams/architecture.puml +++ b/pj_marketplace/documentation/diagrams/architecture.puml @@ -12,9 +12,9 @@ rectangle "GitHub" { rectangle "PlotJuggler" { component "Marketplace UI" as ui component "Extension Manager" as em - database "installed.json" as local + folder "extensions/\nplugin DSOs" as local ui --> em - em --> local + em --> local : discover embedded manifests } reg ..> ui : HTTPS fetch diff --git a/pj_marketplace/documentation/diagrams/installation-flow.puml b/pj_marketplace/documentation/diagrams/installation-flow.puml index 1d975c5..981483d 100644 --- a/pj_marketplace/documentation/diagrams/installation-flow.puml +++ b/pj_marketplace/documentation/diagrams/installation-flow.puml @@ -8,13 +8,13 @@ start :Download ZIP; :Verify SHA256; if (Checksum OK?) then (yes) - :Extract to temp; - :Validate manifest; if (Is update?) then (yes) :Backup current; endif - :Move to extensions/; - :Update installed.json; + :Extract to extensions/; + :Load DSO manifest; + :Validate registry id/version; + :Refresh installed cache; else (no) :Error: invalid checksum; endif diff --git a/pj_marketplace/documentation/diagrams/rollback-flow.puml b/pj_marketplace/documentation/diagrams/rollback-flow.puml index 8ea2d56..580b1e5 100644 --- a/pj_marketplace/documentation/diagrams/rollback-flow.puml +++ b/pj_marketplace/documentation/diagrams/rollback-flow.puml @@ -1,6 +1,6 @@ @startuml skinparam backgroundColor white -title Rollback Flow +title Rollback Flow (Deferred) start :PlotJuggler starts; @@ -10,11 +10,11 @@ while (More plugins?) is (yes) if (Load OK?) then (yes) :Plugin active; else (no) - if (Backup exists?) then (yes) - :Restore backup; - else (no) - :Disable extension; - endif + :Report load failure; + note right + Automatic backup restore + is deferred. + end note endif endwhile (no) :System ready; diff --git a/pj_marketplace/documentation/diagrams/windows-staging.puml b/pj_marketplace/documentation/diagrams/windows-staging.puml index bb481de..f445af8 100644 --- a/pj_marketplace/documentation/diagrams/windows-staging.puml +++ b/pj_marketplace/documentation/diagrams/windows-staging.puml @@ -5,21 +5,23 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .pending/{id}/; -note right: Staging folder +:Extract to .extension_staging/{id}/; +:Load DSO manifest; +:Validate registry id/version; +:Write .pj_pending_install intent; :Notify "Restart required"; stop start :PlotJuggler restarts; -:Move .pending/{id}/ to extensions/{id}/; -note right: Previous backup in\n.backup/{id}-{ver}/ -:Load plugin; -if (Load successful?) then (yes) +:Read .pj_pending_install intent; +:Validate staged DSO manifest; +if (Valid?) then (yes) +:Move .extension_staging/{id}/ to extensions/{id}/; :Plugin active; else (no) - :Restore from backup; - :Notify rollback; + :Remove broken stage; + :Notify install error; endif stop @enduml diff --git a/pj_marketplace/documentation/plotjuggler-marketplace-spec-v1.0.0-en.md b/pj_marketplace/documentation/plotjuggler-marketplace-spec-v1.0.0-en.md index 354456f..4c472d8 100644 --- a/pj_marketplace/documentation/plotjuggler-marketplace-spec-v1.0.0-en.md +++ b/pj_marketplace/documentation/plotjuggler-marketplace-spec-v1.0.0-en.md @@ -39,9 +39,8 @@ Development will begin with a standalone prototype to validate the concept, with 13. [Code Structure](#13-code-structure) 14. [Functional Requirements](#14-functional-requirements) 15. [Non-Functional Requirements](#15-non-functional-requirements) -16. [Implementation Plan](#16-implementation-plan) -17. [Pending Decisions](#17-pending-decisions) -18. [Acceptance Criteria](#18-acceptance-criteria) +16. [Remaining Work](#16-remaining-work) +17. [Acceptance Criteria](#17-acceptance-criteria) --- @@ -62,11 +61,10 @@ Development will begin with a standalone prototype to validate the concept, with | | Individual update | Update a specific extension | | | Bulk update | "Update All" for multiple extensions | | | Automatic backup | Backup of previous version before updating | -| **Uninstallation** | Clean removal | Directory deletion + local state update | +| **Uninstallation** | Clean removal | Directory deletion + installed cache refresh | | | Confirmation | Confirmation dialog before uninstalling | -| **Management** | Enable/Disable | Activate/deactivate extensions without uninstalling | -| | Rollback | Automatic restoration if a plugin fails to load | -| | Persistent state | Local storage of installed extensions (JSON) | +| **Management** | Backup diagnostics | Report retained backup paths when an update install fails | +| | Persistent state | Installed state derived from embedded plugin manifests | | **UI/UX** | Download progress | Progress bar in status bar | | | Notifications | Status messages and available update alerts | | | Context menu | Quick actions per installed extension | @@ -78,7 +76,7 @@ Development will begin with a standalone prototype to validate the concept, with | **Build** | Cross-platform compilation | Matrix build for Linux, Windows, and macOS | | | Static linking | All dependencies embedded in the artifact | | | Dependency management | Support for Conan (current) and Pixi (future) | -| **Packaging** | ZIP generation | Automatic packaging with manifest, binaries, and resources | +| **Packaging** | ZIP generation | Automatic packaging with plugin binaries and resources | | | Checksums | Automatic SHA256 generation per artifact | | | Versioning | Version extraction from git tag | | **Publishing** | GitHub Release | Automatic release creation with attached artifacts | @@ -110,7 +108,7 @@ Development will begin with a standalone prototype to validate the concept, with | **Registry** | Static JSON file on GitHub with the catalog of available extensions. | | **Plugin SDK** | Abstract library (no Qt) that plugins use for UI and data access. | | **Artifact** | Compiled binary of an extension for a specific platform. | -| **Manifest** | JSON file inside the ZIP describing the extension contents. | +| **Embedded manifest** | JSON string exported by each plugin DSO describing the installed plugin. | --- @@ -132,7 +130,11 @@ This architecture has an additional advantage: **any company can have their own ![System Architecture](diagrams/architecture.png) -### 3.3 Design Principles +### 3.3 Diagnostic propagation + +`ExtensionManager` exposes its lifecycle events through three channels at once: a 50-entry ring buffer accessible via `diagnostics()`, the existing `diagnosticReported(QString id, QString message, bool is_error)` Qt signal, and an optional `PJ::DiagnosticSink` (declared in `pj_base/include/pj_base/diagnostic_sink.hpp`) that is fed in addition to the other two when the host passes one to the constructor. The sink is a `std::function` carrying a level (Info / Warning / Error), a `source` ("ExtensionManager", "PluginRegistry", ...), an optional plugin/extension `id`, a message, and a timestamp. Hosts that wire the same sink into both `ExtensionManager` and any non-marketplace component (e.g. `PJ::PluginRuntimeCatalog`) see one unified, ordered diagnostic stream they can render in a status bar, dialog, or log file. Modules in pure C++ remain Qt-free; `PJ::QtDiagnosticBridge` in `pj_marketplace` converts each event into a queued signal emission. + +### 3.4 Design Principles The design is guided by several principles that emerged from previous experiences with plugin systems: @@ -181,7 +183,7 @@ The C++ ecosystem has multiple dependency managers, and PlotJuggler has used sev | **Pixi** | Under observation | It's gaining traction in the ROS community. Offers reproducible environments similar to conda but lighter. | | **Colcon** | Abandoned | Was necessary for ROS 1/2 integration, but added unnecessary complexity outside that context. | -The current decision is to **use Conan for the plugin template**, but design the system so that generated artifacts are independent of the build tool. A ZIP with a `.so` and a `manifest.json` works the same whether it was generated with Conan, Pixi, or manual compilation. +The current decision is to **use Conan for the plugin template**, but design the system so that generated artifacts are independent of the build tool. A ZIP with one or more plugin DSOs works the same whether it was generated with Conan, Pixi, or manual compilation; installed metadata is read from the embedded manifest exported by each DSO. ### 4.4 Pixi: A Future Bet @@ -297,22 +299,9 @@ github.com/plotjuggler/marketplace-registry/ ### 5.4 Local State -Local JSON file recording installed extensions: - -```json -{ - "installed": [ - { - "id": "ros2-streaming", - "version": "1.2.3", - "install_date": "2026-03-04T10:30:00Z", - "path": "/home/user/.plotjuggler/extensions/ros2-streaming/", - "enabled": true, - "backup_path": "/home/user/.plotjuggler/extensions/.backup/ros2-streaming-1.2.2/" - } - ] -} -``` +There is no local state JSON. `ExtensionManager` rebuilds its installed cache by +scanning `extensions/`, loading candidate plugin DSOs, and reading each DSO's +embedded manifest. The remote registry remains the pre-install catalog. --- @@ -322,29 +311,17 @@ Local JSON file recording installed extensions: ``` ros2-streaming-linux-x86_64.zip -├── manifest.json ← Extension metadata ├── libros2_streaming.so ← Compiled plugin(s) ├── ros2_streaming.ui ← Qt Creator UI file (pure XML) ├── README.md ← Description (optional) └── LICENSE ← License ``` -### 6.2 Manifest +### 6.2 Embedded Plugin Manifest -```json -{ - "id": "ros2-streaming", - "version": "1.2.3", - "min_plotjuggler_version": "4.0.0", - "plugins": [ - { - "name": "ROS2StreamerPlugin", - "type": "data_streamer", - "library": "libros2_streaming", - "ui_file": "ros2_streaming.ui" - } - ] -} +```cpp +PJ_DATA_SOURCE_PLUGIN(ROS2StreamerPlugin, + R"({"id":"ros2-streaming","name":"ROS 2 Streaming","version":"1.2.3"})") ``` ### 6.3 Compilation Requirements @@ -384,7 +361,6 @@ set_target_properties(my_plugin PROPERTIES install(TARGETS my_plugin DESTINATION .) install(FILES my_dialog.ui DESTINATION .) -install(FILES manifest.json DESTINATION .) install(FILES README.md LICENSE DESTINATION .) ``` @@ -459,7 +435,7 @@ package = "cmake --install build/release --prefix dist && cd dist && zip -r ../a 1. Developer creates a tag (`git tag v1.2.3`) 2. GitHub Actions detects the tag and runs the release workflow 3. Compiles for all 3 platforms in parallel (matrix build) -4. Packages artifacts in ZIPs with manifest and checksums +4. Packages artifacts in ZIPs with plugin binaries and checksums 5. Creates a GitHub Release with attached artifacts 6. Generates an automatic PR to the registry with the new version 7. PR is automatically validated (schema, URLs, checksums) @@ -473,21 +449,31 @@ package = "cmake --install build/release --prefix dist && cd dist && zip -r ../a 2. Current platform is verified 3. Corresponding ZIP is downloaded 4. SHA256 checksum is verified -5. Extracted to temporary directory -6. Manifest is validated -7. If update, current version is backed up -8. Moved to extensions directory -9. Local state updated (installed.json) +5. If update on a non-staged platform, current version is backed up +6. Extracted to extensions directory +7. Plugin DSO is loaded and its embedded manifest is validated against the registry id/version +8. Installed cache is refreshed from discovery -### 8.3 Automatic Rollback +When the marketplace opens inside a host application, it may be seeded with the host's +already-loaded plugin snapshot before the first render. That snapshot is initialization +data only; the embedded manifest remains the authority for installed version reporting. + +### 8.3 Backup and Rollback Status ![Rollback Flow](diagrams/rollback-flow.png) -1. PlotJuggler starts and loads plugins -2. If a plugin fails (crash/segfault): - - If backup exists → restore previous version - - If no backup → disable extension -3. Notify user of rollback/disabling +Every successful update — Linux, macOS, and Windows — moves the previous +version into `.backup/-/` before the new version takes its +place. On Linux/macOS this happens synchronously inside `update()`. On +Windows it happens at restart inside `applyPendingInstalls()`, just before +the staged directory is renamed over the existing one; if the rename fails +after the backup, the marketplace attempts to roll the backup back into +place, and if that also fails the diagnostic surfaces both paths so the +user can recover manually. + +Automatic *post-load* rollback (restoring from backup if the freshly +installed plugin later fails to load) is deferred. The backup directory +is the manual recovery point. --- @@ -511,7 +497,6 @@ plotjuggler/extension-template/ ├── CMakeLists.txt ├── conanfile.py ├── pixi.toml ← Future alternative -├── manifest.json.in ├── conan_profiles/ │ ├── linux_static │ ├── windows_static @@ -607,7 +592,7 @@ This means a plugin compiled today will continue to work when PlotJuggler migrat The commitment to plugin developers: -- Each plugin declares `min_plotjuggler_version` in its manifest +- The registry declares `min_plotjuggler_version` for each extension - If the SDK changes incompatibly, PlotJuggler provides an internal adapter - **Existing plugins are never broken by PlotJuggler updates** - Stability target: Qt LTS 6.8 (support until 2028) @@ -633,27 +618,33 @@ The solution is a staging system similar to what Windows installers use: The flow is: 1. User clicks "Update" -2. New version downloads to a temporary folder (`.pending/`) -3. Message shown: "Update will be applied when PlotJuggler restarts" -4. When PlotJuggler starts: - - Detects pending updates - - Backs up current version to `.backup/` - - Moves new version from `.pending/` to `extensions/` - - Loads the plugin -5. If plugin fails to load, automatically restores from backup +2. New version downloads to a hidden transaction folder `.pj_install__/` (created under `.extension_staging/` on Windows, under `extensions/` on Linux/macOS) +3. The staged DSO is loaded and its embedded manifest is validated against the registry id/version +4. A transient `.pj_pending_install` intent is written with the registry id/version +5. Message shown: "Update will be applied when PlotJuggler restarts" +6. When PlotJuggler starts: + - Reads `.pj_pending_install` + - Validates the intent's id/version against safe-path/regex rules (rejects path traversal or non-semver tokens) + - Revalidates the staged DSO against that intent + - Moves the new version from `.extension_staging/` to `extensions/` + - Re-validates the DSO from its final location (catches rpath/dep issues that hold in staging but break in `extensions/`) +7. If any validation step fails the active install is left untouched. The broken stage is removed; if removal also fails (file lock), the directory is renamed to `.pj_quarantine__/` and the path is included in the diagnostic so the user can clean it up manually instead of facing the same error every startup. ### 11.3 Directory Structure +The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.local/share/plotjuggler/`, macOS: `~/Library/Application Support/plotjuggler/`, Windows: `%LOCALAPPDATA%/plotjuggler/`). + ``` -~/.plotjuggler/ +/ ├── extensions/ ← Active plugins │ ├── ros2-streaming/ │ └── csv-loader/ -├── .pending/ ← Staging (Windows) -├── .backup/ ← Backups for rollback -│ ├── ros2-streaming-1.2.2/ -│ └── csv-loader-0.9.0/ -└── installed.json ← Local state +├── .extension_staging/ ← Staging area (all platforms; Windows uses it for restart-time installs, Linux/macOS as the post-promotion validation gate) +│ └── plugin-id/ +│ └── .pj_pending_install ← Intent file (Windows-only) +└── .backup/ ← Pre-update backups (all platforms); automatic rollback deferred — restore manually + ├── ros2-streaming-1.2.2/ + └── csv-loader-0.9.0/ ``` --- @@ -781,7 +772,7 @@ The detail panel includes: - Icon (64x64) - Name and publisher - Metrics (downloads, rating) -- Action buttons (Install/Update/Disable/Uninstall) +- Action buttons (Install/Update/Uninstall) - Metadata (category, tags, platforms, minimum version) - Tabs: Details (README), Changelog, Dependencies @@ -790,9 +781,14 @@ The detail panel includes: | State | Actions | | --------------------------- | -------------------------- | | Not installed | Install | -| Installed, up-to-date | Disable, Uninstall | -| Installed, update available | Update, Disable, Uninstall | -| Disabled | Enable, Uninstall | +| Installed, up-to-date | Uninstall | +| Installed, update available | Update, Uninstall | +| Installed, local newer | Local newer, Uninstall | + +Enabling or disabling an installed extension without uninstalling it is **out +of scope** for the marketplace — it belongs to the host application's plugin +loader / config (e.g. a per-user knob in `pj_app` that filters which +discovered DSOs are instantiated at startup). ### 12.5 Dialogs @@ -802,7 +798,7 @@ The detail panel includes: | Confirm Uninstall | Click Uninstall | "Remove {name}?" | | Restart Required | Post install/update | "Restart to activate changes?" | | Update All | Multiple updates | List of extensions to update | -| Rollback | Plugin fails | "Extension failed. Rollback?" | +| Diagnostics | Plugin/install fails | Recent lifecycle diagnostics | --- @@ -817,7 +813,6 @@ marketplace/ │ │ ├── Extension.h │ │ ├── InstalledExtension.h │ │ ├── Registry.h -│ │ └── LocalState.h │ ├── core/ │ │ ├── RegistryManager.h/cpp │ │ ├── ExtensionManager.h/cpp @@ -825,13 +820,7 @@ marketplace/ │ │ └── PlatformUtils.h/cpp │ ├── ui/ │ │ ├── MarketplaceWindow.h/cpp -│ │ ├── ExtensionListWidget.h/cpp -│ │ ├── ExtensionCardDelegate.h/cpp -│ │ ├── ExtensionDetailWidget.h/cpp -│ │ └── StatusBarManager.h/cpp -│ └── utils/ -│ ├── ChecksumVerifier.h/cpp -│ └── ZipExtractor.h/cpp +│ │ └── ExtensionDetailDialog.h/cpp └── resources/ ├── icons/ └── marketplace.qrc @@ -852,7 +841,7 @@ marketplace/ | F-05 | Show selected extension detail | | F-06 | Download ZIP with SHA256 verification | | F-07 | Extract ZIP to extensions directory | -| F-08 | Register installed extension (installed.json) | +| F-08 | Register installed extension from embedded DSO manifest | | F-09 | Detect updates (local vs registry version) | | F-10 | Uninstall extension | @@ -862,9 +851,8 @@ marketplace/ | ---- | ----------------------------------- | | F-11 | Local registry cache with TTL | | F-12 | Backup previous version on updates | -| F-13 | Automatic rollback if plugin fails | +| F-13 | Automatic rollback if plugin fails (deferred) | | F-14 | Windows staging: apply on restart | -| F-15 | Enable/Disable without uninstalling | | F-16 | Cancel download in progress | | F-17 | Update All | | F-18 | Confirmation dialogs | @@ -898,76 +886,13 @@ marketplace/ --- -## 16. Implementation Plan - -### Phase 1: Skeleton + Mock (Day 1-2) - -- CMake + Qt6 project setup -- Data structs (Extension, InstalledExtension) -- MarketplaceWindow with QSplitter -- ExtensionListWidget with custom cards -- ExtensionDetailWidget with tabs -- Hardcoded mock data -- Functional search and filter - -**Deliverable:** App that shows list, navigates, and filters. - -### Phase 2: Networking + Registry (Day 2-3) - -- RegistryManager: fetch JSON, parsing, cache -- DownloadManager: download with progress -- SHA256 verification -- Status bar with progress -- Network error handling - -**Deliverable:** App that loads registry from GitHub. - -### Phase 3: Extension Management (Day 3-4) - -- ExtensionManager: install, uninstall -- Update detection (semver) -- Update flow with backup -- Local state persistence -- Enable/Disable - -**Deliverable:** Complete management cycle. - -### Phase 4: Platform + Polish (Day 4-5) - -- Windows staging -- Rollback mechanism -- Update All -- Confirmation dialogs -- Edge-case error handling -- Visual polish - -**Deliverable:** Prototype ready for demo. - -### Phase 5: Integration (Future) - -- Extract core as library -- Integrate into PlotJuggler -- Hook with plugin loading system -- Update notification at startup - ---- - -## 17. Pending Decisions +## 16. Remaining Work -| # | Topic | Options | -| --- | -------------------------- | --------------------------------- | -| 1 | ZIP library | QuaZip vs minizip vs libzip | -| 2 | Markdown rendering | QTextBrowser vs plain text | -| 3 | Metrics | Registry JSON vs GitHub API | -| 4 | Icons | URL in registry vs bundled in ZIP | -| 5 | Semver parsing | C++ library vs string compare | -| 6 | New extension registration | Manual PR to registry | -| 7 | Pixi timeline | When it complements Conan | -| 8 | Paid plugins | License management (future) | +Open follow-ups are tracked in [TODO.md](TODO.md). --- -## 18. Acceptance Criteria +## 17. Acceptance Criteria The prototype is successful if: diff --git a/pj_marketplace/include/pj_marketplace/extension.hpp b/pj_marketplace/include/pj_marketplace/extension.hpp index 1a42371..4bbcea1 100644 --- a/pj_marketplace/include/pj_marketplace/extension.hpp +++ b/pj_marketplace/include/pj_marketplace/extension.hpp @@ -7,17 +7,20 @@ namespace PJ { +// Download artifact for one platform in the registry. struct Platform { QString url; QString checksum; ///< Format: "sha256:" }; +// Registry-declared plugin entry kept for backward-compatible metadata display. struct ExtensionPlugin { QString name; ///< Plugin class name QString type; ///< "data_loader" | "data_streamer" | "parser" | "toolbox" QString library; ///< Library filename without extension }; +// Extension record as received from the marketplace registry. struct Extension { QString id; QString name; diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index a885041..7196393 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -1,10 +1,13 @@ #pragma once +#include +#include #include #include #include #include +#include "pj_base/diagnostic_sink.hpp" #include "pj_marketplace/extension.hpp" #include "pj_marketplace/installed_extension.hpp" #include "pj_marketplace/platform_utils.hpp" @@ -13,119 +16,148 @@ namespace PJ { class DownloadManager; -// Orchestrates the full install/uninstall/update lifecycle for marketplace extensions. -// -// Responsibilities: -// - Resolves the correct download artifact for the current platform -// - On Linux: delegates the full pipeline (download + checksum + extraction) to -// DownloadManager, then registers the extension immediately -// - On Windows update: extracts to a staging directory (.pending/) because in-use -// DLLs cannot be overwritten; the extension becomes active after the next restart -// - On Windows fresh install: installs directly (no staging needed — no DLL loaded) -// - On Windows uninstall: if the directory cannot be removed (DLL still mapped), -// schedules it for deletion at the next startup via applyPendingUninstalls() -// - At startup: applies any pending staged installs via applyPendingInstalls() -// and deletes any directories deferred from a previous uninstall via applyPendingUninstalls() -// - Discovers installed extensions by scanning extensions_dir and reading manifest.json -// -// All constructor dependencies are injected, so tests can pass a DownloadManager stub -// and temp directories to exercise the full flow without touching the real filesystem -// or the network. -// -// Only one install/update can run at a time. Calling install() while an operation is -// in progress emits installError() and returns immediately. +// One user-visible diagnostic emitted by marketplace lifecycle operations. +struct ExtensionDiagnostic { + QString id; ///< Registry or embedded plugin id, when known. + QString message; ///< Human-readable diagnostic. + bool is_error = false; + QDateTime timestamp; ///< UTC timestamp. +}; + +// Manages marketplace extension installs, updates, uninstalls, and startup cleanup. +// Installed metadata is derived from embedded DSO manifests, not local sidecars. class ExtensionManager : public QObject { Q_OBJECT public: - // Convenience constructor: creates an owned DownloadManager and uses the - // standard user paths. Equivalent to the injecting constructor with defaults. + // Creates an owned DownloadManager and uses the standard user directories. ExtensionManager(); - // `extensions_dir` and `pending_dir` default to the standard user paths. - // Pass QTemporaryDir paths in tests to get a clean, isolated state. + // Uses the supplied downloader and directories; tests pass isolated temp paths. + // The optional sink receives the same diagnostics as diagnosticReported() — + // hosts can subscribe to one stream that also carries non-marketplace events. explicit ExtensionManager( - DownloadManager* downloader, - const QString& extensions_dir = PlatformUtils::extensionsDir(), - const QString& pending_dir = PlatformUtils::pendingDir(), + DownloadManager* downloader, const QString& extensions_dir = PlatformUtils::extensionsDir(), + const QString& pending_dir = PlatformUtils::pendingDir(), DiagnosticSink sink = {}, QObject* parent = nullptr); - // Starts an async install of `ext` for the running platform. - // Emits installStarted() synchronously before the download begins. - // No-op (emits installError) if another install is already in progress or if - // the extension is already installed — use update() to upgrade. + // Starts an async install for the current platform. void install(const Extension& ext); - // Synchronously deletes // and removes the entry from - // memory. Emits uninstallFinished(id, false) if the directory cannot - // be removed (e.g. a DLL is still loaded on Windows — F-14 staging is deferred). + // Removes an installed extension or schedules Windows cleanup after restart. void uninstall(const QString& extension_id); - // Moves the current version to ~/.plotjuggler/.backup/-/ and - // re-installs from the registry. If the rename fails (cross-device or DLL locked - // on Windows) the old directory is deleted instead so the install gets a clean target. - // On success the backup path is recorded in installed.json so that future automatic - // rollback (F-13, April+) can find it. + // Replaces an installed extension, staging on Windows and backing up elsewhere. void update(const Extension& ext); - // Moves any staged extensions from .pending/ into extensions/ and registers them. - // Should be called once at application startup. On Linux this is always a no-op - // because staging is never used, but it is safe to call on any platform. + // Promotes validated staged installs from PlatformUtils::pendingDir(). void applyPendingInstalls(); - // Deletes any extension directories that could not be removed during a previous - // uninstall() because their DLL was still loaded (Windows only). - // Should be called once at application startup. Safe to call on any platform. + // Deletes extension directories previously marked for restart cleanup. void applyPendingUninstalls(); + // Returns true when the latest disk scan found this extension id. bool isInstalled(const QString& id) const; - // Returns true if the extension is staged in the pending directory and will - // become active after the next restart (Windows update path). + // Rebuilds installed state by scanning extension directories for plugin DSOs. + void refreshInstalledFromDisk(); + + // Replaces the installed-state cache with a caller-provided snapshot. + void setInstalledExtensions(QMap installed); + + // Returns true when a staged install has a matching intent and valid DSO. bool hasPendingInstall(const QString& id) const; - // Returns true if the extension directory contains a pending-uninstall marker - // and will be deleted at the next startup (Windows uninstall path). + // Returns true when an installed directory is marked for restart cleanup. bool hasPendingUninstall(const QString& id) const; - // Compares the registry version against the installed one using QVersionNumber, - // which handles multi-segment semver correctly ("1.10.0" > "1.9.0"). - // Returns false if the extension is not installed. + // Returns true when the installed version is newer than the registry version. + bool hasNewerInstalledVersion(const Extension& ext) const; + + // Compares registry and installed versions using QVersionNumber. bool hasUpdate(const Extension& ext) const; - // Snapshot of the currently installed extensions, keyed by id. + // Returns the current installed-extension snapshot keyed by id. QMap installedExtensions() const; + // Returns recent lifecycle diagnostics for UI display. + QList diagnostics() const; + + // Clears the in-memory diagnostic history. + void clearDiagnostics(); + + // Root directory where extension DSOs are discovered and managed. + QString extensionsDir() const { return extensions_dir_; } + +#ifdef PJ_MARKETPLACE_TESTING + // Test hook for forcing direct or staged install paths. + void testDoInstall(const Extension& ext, bool staging, bool allow_existing = false) { + doInstall(ext, staging, allow_existing); + } + +#endif + signals: + // Emitted when an install or update starts. void installStarted(const QString& id); + + // Emitted with percentage progress for the active download. void installProgress(const QString& id, int percent); + + // Emitted when install or update completes. void installFinished(const QString& id, bool success); - // Human-readable description of what went wrong; always followed by installFinished(id, false). + + // Human-readable failure detail; followed by installFinished(id, false). void installError(const QString& id, const QString& error_message); + // Emitted on Windows when the extension is staged and will be active after a restart. void installPendingRestart(const QString& id); + // Emitted when uninstall completes. void uninstallFinished(const QString& id, bool success); + + // Human-readable uninstall failure detail. void uninstallError(const QString& id, const QString& error_message); - // Emitted on Windows when the extension is deregistered but its directory could not - // be removed (DLL still loaded). The directory will be deleted on the next startup - // via applyPendingUninstalls(). + + // Emitted when uninstall requires restart cleanup. void uninstallPendingRestart(const QString& id); + // Emitted whenever a diagnostic is appended to diagnostics(). + void diagnosticReported(const QString& id, const QString& message, bool is_error); + private: // Called by both constructors to finish setup after members are assigned. void initComponents(); - void doInstall(const Extension& ext, bool staging); - void loadState(); - void saveState(); + // Shared install implementation for direct and staged destinations. + void doInstall(const Extension& ext, bool staging, bool allow_existing = false); + + // Disconnects downloader signals for the current operation. void disconnectDlConns(); - void savePendingMeta(const Extension& ext); - void schedulePendingUninstall(const QString& path); + + // Writes the restart-cleanup marker into an installed extension directory. + // Returns false if the marker file could not be created — caller must NOT remove + // the in-memory entry in that case, otherwise the directory will leak. + bool schedulePendingUninstall(const QString& path); + + // Appends a diagnostic and notifies observers. + void reportDiagnostic(const QString& id, const QString& message, bool is_error); + + // Emits installError + installFinished(false) and records a diagnostic. + void emitInstallFailure(const QString& id, const QString& message); + + // Stamps a freshly-promoted directory with its absolute path + mtime and + // adds it to the installed_ map under `id`. Caller supplies the record + // already populated from the embedded manifest. + void registerInstalledExtension(const QString& id, const QString& dst, InstalledExtension record); + + // Emits uninstallError + uninstallFinished(false) and records a diagnostic. + void emitUninstallFailure(const QString& id, const QString& message); DownloadManager* downloader_ = nullptr; QString extensions_dir_; QString pending_dir_; + DiagnosticSink sink_; QMap installed_; @@ -137,6 +169,11 @@ class ExtensionManager : public QObject { bool disk_space_checked_ = false; // Set before calling cancel() to preserve the real reason shown to the user. QString cancel_reason_; + // Transaction directory used by the currently running fetch/extract operation. + QString pending_extract_dir_; + // Non-Windows update backup location, used for failure diagnostics. + QString pending_backup_path_; + QList diagnostics_; // Stored so we can disconnect cleanly after each operation completes. QMetaObject::Connection dl_progress_conn_; diff --git a/pj_marketplace/include/pj_marketplace/installed_extension.hpp b/pj_marketplace/include/pj_marketplace/installed_extension.hpp index 446ade5..eb4c495 100644 --- a/pj_marketplace/include/pj_marketplace/installed_extension.hpp +++ b/pj_marketplace/include/pj_marketplace/installed_extension.hpp @@ -5,13 +5,13 @@ namespace PJ { +// Installed extension discovered from an embedded plugin manifest on disk. struct InstalledExtension { QString id; ///< Matches Extension::id from the registry QString version; QDateTime install_date; QString path; ///< Absolute path to ~/.plotjuggler/extensions// bool enabled = true; - QString backup_path; ///< Optional: populated when a previous version was kept }; } // namespace PJ diff --git a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp index a34503a..9b37bc4 100644 --- a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp +++ b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp @@ -1,11 +1,15 @@ #pragma once +#include #include -#include #include + #include "pj_marketplace/extension.hpp" +#include "pj_marketplace/installed_extension.hpp" -namespace Ui { class MarketplaceWindow; } +namespace Ui { +class MarketplaceWindow; +} namespace PJ { @@ -13,53 +17,103 @@ class DownloadManager; class ExtensionManager; class RegistryManager; +// Marketplace dialog that renders registry extensions and local install state. class MarketplaceWindow : public QDialog { Q_OBJECT public: explicit MarketplaceWindow(const QUrl& registry_url, QWidget* parent = nullptr); - // Overload for callers that own an ExtensionManager and want to inject it. - // The window does not take ownership of ext_mgr. - explicit MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& registry_url, - QWidget* parent = nullptr); + // Uses an externally owned ExtensionManager, mainly for tests and embedding. + explicit MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& registry_url, QWidget* parent = nullptr); + + // Uses an externally owned ExtensionManager and a caller-provided installed snapshot. + explicit MarketplaceWindow( + ExtensionManager* ext_mgr, const QUrl& registry_url, const QMap& installed, + QWidget* parent = nullptr); ~MarketplaceWindow() override; - bool installationsChanged() const { return installations_changed_; } + // Returns true when install state changed while the dialog was open. + bool installationsChanged() const { + return installations_changed_; + } protected: + // Handles card hover styling and delegated button events. bool eventFilter(QObject* obj, QEvent* event) override; + // Refreshes installed state before cards are painted. + void showEvent(QShowEvent* event) override; + private slots: + // Updates the search filter. void onSearchChanged(const QString& text); + + // Updates the category filter. void onCategoryChanged(int index); + + // Refetches registry data and refreshes installed state. void onRefreshClicked(); + + // Queues updates for every installed extension with a newer registry version. void onUpdateAllClicked(); + + // Opens the registry URL settings dialog. void onSettingsClicked(); + + // Opens a read-only view of recent marketplace diagnostics. + void onDiagnosticsClicked(); + + // Runs the primary install/update action for one extension card. void onActionButtonClicked(const QString& ext_id); + + // Confirms and uninstalls one installed extension. void onUninstallButtonClicked(const QString& ext_id); private: + // Creates widgets from the .ui file and configures fixed UI affordances. void setupUi(); + + // Connects registry, extension-manager, and widget signals. void setupSignals(); + + // Rebuilds extension cards from the current filtered list. void populateCards(); + + // Applies search and category filters to the registry list. void applyFilters(); + + // Updates the status label; error statuses remain sticky until a user action clears them. void setStatus(const QString& msg, bool is_error = false); + + // Allows the next non-error status update to replace an error. + void clearStickyStatus(); + + // Shows the newest diagnostic in the status bar, if one exists. + void showLatestDiagnostic(); + + // Shows or hides the diagnostics button based on diagnostic history. + void updateDiagnosticsButton(); + + // Opens the detail dialog for one registry extension. void openDetail(const QString& ext_id); + + // Processes one pending bulk-update item at a time. void processInstallQueue(); - Ui::MarketplaceWindow* ui_ = nullptr; - DownloadManager* download_mgr_ = nullptr; - RegistryManager* registry_mgr_ = nullptr; - ExtensionManager* ext_mgr_ = nullptr; - QUrl registry_url_; + Ui::MarketplaceWindow* ui_ = nullptr; + DownloadManager* download_mgr_ = nullptr; + RegistryManager* registry_mgr_ = nullptr; + ExtensionManager* ext_mgr_ = nullptr; + QUrl registry_url_; QList extensions_; // populated from RegistryManager::fetchFinished QList filtered_; QList update_queue_; - QSet pending_restart_ids_; - bool installations_changed_ = false; + bool installations_changed_ = false; + bool status_error_sticky_ = false; + bool initial_snapshot_provided_ = false; }; } // namespace PJ diff --git a/pj_marketplace/include/pj_marketplace/platform_utils.hpp b/pj_marketplace/include/pj_marketplace/platform_utils.hpp index ba2edea..ecaa8ed 100644 --- a/pj_marketplace/include/pj_marketplace/platform_utils.hpp +++ b/pj_marketplace/include/pj_marketplace/platform_utils.hpp @@ -15,6 +15,7 @@ class PlatformUtils { // Format: "-", e.g. "linux-x86_64", "windows-x86_64", "macos-arm64". static QString currentPlatform(); + // Returns true on Windows builds. static bool isWindows(); // Returns the shared library extension for the current platform: @@ -32,7 +33,7 @@ class PlatformUtils { // ~/.plotjuggler/extensions/ — active, loaded extensions. static QString extensionsDir(); - // ~/.plotjuggler/.pending/ — staging area for extensions awaiting a restart (Windows only). + // /.extension_staging/ — restart staging for Windows updates. static QString pendingDir(); // ~/.plotjuggler/.backup/ — pre-update backups (F-12, deferred to April+). diff --git a/pj_marketplace/include/pj_marketplace/qt_diagnostic_bridge.hpp b/pj_marketplace/include/pj_marketplace/qt_diagnostic_bridge.hpp new file mode 100644 index 0000000..c69a4f1 --- /dev/null +++ b/pj_marketplace/include/pj_marketplace/qt_diagnostic_bridge.hpp @@ -0,0 +1,30 @@ +#pragma once + +// QtDiagnosticBridge adapts the Qt-free PJ::DiagnosticSink vocabulary to a +// queued Qt signal. Hosts can pass sink() to non-GUI components and connect the +// signal to their UI without duplicating thread-marshalling code. + +#include +#include + +#include "pj_base/diagnostic_sink.hpp" + +namespace PJ { + +class QtDiagnosticBridge : public QObject { + Q_OBJECT + + public: + // Creates a bridge that emits diagnostics on its QObject thread. + explicit QtDiagnosticBridge(QObject* parent = nullptr); + + // Safe to call from any thread. The returned sink may outlive this object: + // queued delivery is guarded by QPointer. + DiagnosticSink sink(); + + signals: + // Emitted on the bridge object's thread for each received diagnostic. + void diagnosticReported(int level, QString source, QString id, QString message); +}; + +} // namespace PJ diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 4057009..eb965ff 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -1,83 +1,292 @@ -#include "pj_marketplace/extension_manager.hpp" - #include #include #include #include -#include -#include -#include +#include #include +#include #include +#include #include "pj_marketplace/download_manager.hpp" +#include "pj_marketplace/extension_manager.hpp" #include "pj_marketplace/platform_utils.hpp" +#include "pj_plugins/host/plugin_catalog.hpp" namespace PJ { +namespace { + +static constexpr const char* kPendingUninstallMarker = ".pj_pending_uninstall"; +static constexpr const char* kPendingInstallIntent = ".pj_pending_install"; +static constexpr const char* kQuarantinePrefix = ".pj_quarantine_"; +static constexpr int kMaxDiagnostics = 50; + +QString extRoot(const QString& extensions_dir, const QString& id) { + return QDir(extensions_dir).absoluteFilePath(id); +} + +QString pendingRoot(const QString& pending_dir, const QString& id) { + return QDir(pending_dir).absoluteFilePath(id); +} + +struct DirectoryDiscovery { + bool found_plugin = false; + QString error; + InstalledExtension record; +}; + +struct PendingInstallIntent { + bool valid = false; + QString id; + QString version; + QString error; +}; + +QString pendingInstallIntentPath(const QString& root) { + return QDir(root).absoluteFilePath(kPendingInstallIntent); +} + +QString invalidExtensionIdReason(const QString& id) { + if (id.isEmpty()) { + return "Extension id is empty"; + } + if (id == "." || id == ".." || id.contains('/') || id.contains('\\')) { + return QString("Extension id \"%1\" is not safe for filesystem paths").arg(id); + } + return {}; +} + +QString makeTransactionRoot(const QString& parent, const QString& id) { + return QDir(parent).absoluteFilePath( + QString(".pj_install_%1_%2").arg(id, QUuid::createUuid().toString(QUuid::Id128))); +} + +QString candidateRoot(const QString& transaction_root, const QString& id) { + return QDir(transaction_root).absoluteFilePath(id); +} + +void removeDirectoryIfSet(const QString& path) { + if (!path.isEmpty()) { + QDir(path).removeRecursively(); + } +} + +bool isTransactionDirectoryName(const QString& name) { + return name.startsWith(".pj_install_"); +} + +QString validateTransactionContents(const QString& transaction_root, const QString& expected_id) { + const QDir tx_dir(transaction_root); + const QFileInfoList entries = + tx_dir.entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); + if (entries.size() != 1 || !entries.first().isDir() || entries.first().fileName() != expected_id) { + return QString("Downloaded artifact must contain exactly one top-level directory named \"%1\"").arg(expected_id); + } + return {}; +} + +DirectoryDiscovery discoverExtensionDirectory(const QString& ext_root) { + DirectoryDiscovery result; + const auto scan = scanPluginDsos(std::filesystem::path(ext_root.toStdString())); + if (!scan) { + result.error = QString::fromStdString(scan.error()); + return result; + } + + for (const auto& diag : scan->diagnostics) { + qWarning( + "ExtensionManager: plugin discovery diagnostic for '%s': %s", diag.path.string().c_str(), diag.message.c_str()); + } + + if (scan->plugins.empty()) { + if (!scan->diagnostics.empty()) { + result.error = QString::fromStdString(scan->diagnostics.front().message); + return result; + } + result.error = QStringLiteral("no valid plugin DSO found"); + return result; + } + + const PluginDescriptor& first = scan->plugins.front(); + for (const PluginDescriptor& descriptor : scan->plugins) { + if (descriptor.id != first.id) { + result.error = QStringLiteral("multiple embedded plugin ids in one extension directory: \"%1\" and \"%2\"") + .arg(QString::fromStdString(first.id), QString::fromStdString(descriptor.id)); + return result; + } + if (descriptor.version != first.version) { + result.error = QStringLiteral("multiple embedded plugin versions in one extension directory for \"%1\"") + .arg(QString::fromStdString(first.id)); + return result; + } + } + + result.found_plugin = true; + result.record.id = QString::fromStdString(first.id); + result.record.version = QString::fromStdString(first.version); + result.record.install_date = QFileInfo(ext_root).lastModified(); + result.record.path = ext_root; + result.record.enabled = true; + return result; +} + +QString validateRegistryIntent( + const DirectoryDiscovery& discovered, const QString& registry_id, const QString& registry_version) { + if (!discovered.found_plugin) { + return QString("Installed artifact is not a valid plugin: %1").arg(discovered.error); + } + if (discovered.record.id != registry_id) { + return QString("Embedded plugin id \"%1\" does not match registry id \"%2\"") + .arg(discovered.record.id, registry_id); + } + if (discovered.record.version != registry_version) { + return QString("Embedded plugin version \"%1\" does not match registry version \"%2\"") + .arg(discovered.record.version, registry_version); + } + return {}; +} + +bool writePendingInstallIntent(const QString& root, const Extension& ext, QString* error) { + QFile file(pendingInstallIntentPath(root)); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (error != nullptr) { + *error = QString("Could not write staged install intent: %1").arg(file.errorString()); + } + return false; + } + + const QByteArray data = ext.id.toUtf8() + '\n' + ext.version.toUtf8() + '\n'; + if (file.write(data) != data.size()) { + if (error != nullptr) { + *error = QString("Could not write staged install intent: %1").arg(file.errorString()); + } + return false; + } + return true; +} + +PendingInstallIntent readPendingInstallIntent(const QString& root) { + PendingInstallIntent intent; + QFile file(pendingInstallIntentPath(root)); + if (!file.exists()) { + intent.error = "Staged install is missing registry intent"; + return intent; + } + if (!file.open(QIODevice::ReadOnly)) { + intent.error = QString("Could not read staged install intent: %1").arg(file.errorString()); + return intent; + } + + const QList lines = file.readAll().split('\n'); + if (lines.size() < 2 || lines[0].trimmed().isEmpty() || lines[1].trimmed().isEmpty()) { + intent.error = "Staged install registry intent is invalid"; + return intent; + } + + const QString id = QString::fromUtf8(lines[0].trimmed()); + const QString version = QString::fromUtf8(lines[1].trimmed()); + // Defend against tampered or corrupted intent files: a path-traversal id, or a + // version that contains anything outside the semver alphabet, must not be + // trusted later as a directory name or version comparison input. + if (const QString id_error = invalidExtensionIdReason(id); !id_error.isEmpty()) { + intent.error = QString("Staged install registry intent has unsafe id: %1").arg(id_error); + return intent; + } + static const QRegularExpression kVersionRe(QStringLiteral("^[0-9A-Za-z._+-]+$")); + if (!kVersionRe.match(version).hasMatch()) { + intent.error = QString("Staged install registry intent has unsafe version \"%1\"").arg(version); + return intent; + } + + intent.valid = true; + intent.id = id; + intent.version = version; + return intent; +} + +} // namespace + // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- ExtensionManager::ExtensionManager() - : QObject(nullptr), - extensions_dir_(PlatformUtils::extensionsDir()), - pending_dir_(PlatformUtils::pendingDir()) { + : QObject(nullptr), extensions_dir_(PlatformUtils::extensionsDir()), pending_dir_(PlatformUtils::pendingDir()) { initComponents(); } -ExtensionManager::ExtensionManager(DownloadManager* downloader, const QString& extensions_dir, - const QString& pending_dir, QObject* parent) - : QObject(parent), downloader_(downloader), extensions_dir_(extensions_dir), pending_dir_(pending_dir) { - initComponents(); +ExtensionManager::ExtensionManager( + DownloadManager* downloader, const QString& extensions_dir, const QString& pending_dir, DiagnosticSink sink, + QObject* parent) + : QObject(parent), + downloader_(downloader), + extensions_dir_(extensions_dir), + pending_dir_(pending_dir), + sink_(std::move(sink)) { + initComponents(); } void ExtensionManager::initComponents() { if (!downloader_) { downloader_ = new DownloadManager(this); } - QDir().mkpath(extensions_dir_); - loadState(); + if (!QDir().mkpath(extensions_dir_)) { + reportDiagnostic({}, QString("Could not create extensions directory \"%1\"").arg(extensions_dir_), true); + } + // Drain any restart-deferred work before computing the installed snapshot, so the + // result reflects post-promotion / post-cleanup reality regardless of which + // MarketplaceWindow ctor (or host wiring) ends up using this manager. + applyPendingUninstalls(); + applyPendingInstalls(); + refreshInstalledFromDisk(); } -static constexpr const char* kManifestFileName = "manifest.json"; -static constexpr const char* kPendingUninstallMarker = ".pj_pending_uninstall"; - // --------------------------------------------------------------------------- // Public interface // --------------------------------------------------------------------------- void ExtensionManager::install(const Extension& ext) { + refreshInstalledFromDisk(); doInstall(ext, /*staging=*/false); } -void ExtensionManager::doInstall(const Extension& ext, bool staging) { +void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_existing) { + if (const QString id_error = invalidExtensionIdReason(ext.id); !id_error.isEmpty()) { + emitInstallFailure(ext.id, id_error); + return; + } + if (!pending_id_.isEmpty()) { - emit installError(ext.id, QString("Install of \"%1\" is already in progress").arg(pending_id_)); - emit installFinished(ext.id, false); + emitInstallFailure(ext.id, QString("Install of \"%1\" is already in progress").arg(pending_id_)); return; } - if (isInstalled(ext.id)) { - emit installError(ext.id, QString("Extension \"%1\" is already installed").arg(ext.id)); - emit installFinished(ext.id, false); + if (!allow_existing && isInstalled(ext.id)) { + emitInstallFailure(ext.id, QString("Extension \"%1\" is already installed").arg(ext.id)); return; } - // Resolve the artifact for the running platform before touching the network. const QString platform = PlatformUtils::currentPlatform(); if (!ext.platforms.contains(platform)) { - emit installError(ext.id, QString("No artifact available for platform \"%1\"").arg(platform)); - emit installFinished(ext.id, false); + emitInstallFailure(ext.id, QString("No artifact available for platform \"%1\"").arg(platform)); return; } const Platform& artifact = ext.platforms[platform]; - // When staging (Windows update), DLLs that are currently loaded cannot be overwritten. - // Extract to a staging directory (.pending/) and let the user restart to activate. + // Extraction goes into a hidden transaction directory on the same filesystem + // as the final destination, so the eventual rename is atomic. For deferred + // (Windows) staging that's pending_dir_; for immediate promotion we extract + // beside extensions_dir_ and rename in-place after validation. + // The DSO is dlopened and its embedded manifest verified inside the + // transaction directory BEFORE the rename, then re-verified at the final + // location AFTER the rename — see the post-promotion check below. const QString dest_dir = staging ? pending_dir_ : extensions_dir_; + QDir().mkpath(dest_dir); + const QString transaction_root = makeTransactionRoot(dest_dir, ext.id); pending_id_ = ext.id; + pending_extract_dir_ = transaction_root; emit installStarted(ext.id); dl_progress_conn_ = @@ -88,8 +297,6 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { if (total > 0 && !disk_space_checked_) { disk_space_checked_ = true; - // Extracted content is typically 2-4x the compressed size; 3 is a conservative estimate. - // If Content-Length is absent (total == 0) the check is skipped entirely. constexpr qint64 kExtractionOverheadFactor = 3; if (QStorageInfo(extensions_dir_).bytesAvailable() < total * kExtractionOverheadFactor) { cancel_reason_ = "Not enough disk space to install the extension"; @@ -102,62 +309,107 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { emit installProgress(pending_id_, percent); }); - // Capture ext and staging by value: they may go out of scope before the fetch completes. - dl_finished_conn_ = connect(downloader_, &DownloadManager::finished, this, [this, ext, staging](int id) { - if (id != pending_op_id_) { - return; - } - disconnectDlConns(); - disk_space_checked_ = false; + dl_finished_conn_ = + connect(downloader_, &DownloadManager::finished, this, [this, ext, staging, transaction_root](int id) { + if (id != pending_op_id_) { + return; + } + disconnectDlConns(); + disk_space_checked_ = false; - const QString finished_id = pending_id_; - pending_id_.clear(); - pending_op_id_ = -1; + const QString finished_id = pending_id_; + pending_id_.clear(); + pending_op_id_ = -1; - if (staging) { - emit installPendingRestart(finished_id); - } else { - const QString ext_root = extensions_dir_ + "/" + ext.id; - - InstalledExtension record; - record.id = ext.id; - record.version = ext.version; - record.install_date = QDateTime::currentDateTimeUtc(); - record.path = ext_root; - record.enabled = true; - - QFile manifest_file(ext_root + "/" + kManifestFileName); - if (manifest_file.open(QIODevice::ReadOnly)) { - const QJsonObject manifest = QJsonDocument::fromJson(manifest_file.readAll()).object(); - if (!manifest["version"].toString().isEmpty()) { - record.version = manifest["version"].toString(); + auto failAfterExtraction = [&](const QString& message) { + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + emitInstallFailure(finished_id, message); + }; + + if (const QString tx_error = validateTransactionContents(transaction_root, ext.id); !tx_error.isEmpty()) { + failAfterExtraction(tx_error); + return; } - } - installed_[ext.id] = record; - emit installFinished(finished_id, true); - } - }); + const QString root = candidateRoot(transaction_root, ext.id); + const DirectoryDiscovery discovered = discoverExtensionDirectory(root); + const QString validation_error = validateRegistryIntent(discovered, ext.id, ext.version); + if (!validation_error.isEmpty()) { + failAfterExtraction(validation_error); + return; + } - dl_failed_conn_ = connect(downloader_, &DownloadManager::failed, this, [this](int id, const QString& error) { - if (id != pending_op_id_) { - return; - } - disconnectDlConns(); - disk_space_checked_ = false; + if (staging) { + QString intent_error; + if (!writePendingInstallIntent(root, ext, &intent_error)) { + failAfterExtraction(intent_error); + return; + } - const QString failed_id = pending_id_; - pending_id_.clear(); - pending_op_id_ = -1; + const QString staged_root = pendingRoot(pending_dir_, ext.id); + if (QDir(staged_root).exists() && !QDir(staged_root).removeRecursively()) { + failAfterExtraction(QString("Could not replace existing staged install directory \"%1\"").arg(staged_root)); + return; + } + if (!QDir().rename(root, staged_root)) { + failAfterExtraction(QString("Could not stage install to \"%1\"").arg(staged_root)); + return; + } - // Partial files are intentionally preserved on failure: the directory may have contained - // a previous valid installation that pre-dates this failed attempt. Cleanup on cancel - // is handled separately because a cancel is always user-initiated on a fresh install. - emit installError(failed_id, error); - emit installFinished(failed_id, false); - }); + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + pending_backup_path_.clear(); + emit installPendingRestart(finished_id); + return; + } - dl_cancelled_conn_ = connect(downloader_, &DownloadManager::cancelled, this, [this](int id) { + const QString dst = extRoot(extensions_dir_, ext.id); + if (QDir(dst).exists() && !QDir(dst).removeRecursively()) { + failAfterExtraction(QString("Could not replace existing extension directory \"%1\"").arg(dst)); + return; + } + if (!QDir().rename(root, dst)) { + failAfterExtraction(QString("Could not promote install to \"%1\"").arg(dst)); + return; + } + + // Double-check: the DSO loaded from the staging area; confirm it still + // loads from its final location. Catches issues like rpath/relative-path + // assumptions that hold in pending_dir_ but break in extensions_dir_. + const DirectoryDiscovery final_check = discoverExtensionDirectory(dst); + const QString final_error = validateRegistryIntent(final_check, ext.id, ext.version); + if (!final_error.isEmpty()) { + QDir(dst).removeRecursively(); + failAfterExtraction(QString("Post-promotion validation failed: %1").arg(final_error)); + return; + } + + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + pending_backup_path_.clear(); + registerInstalledExtension(ext.id, dst, final_check.record); + emit installFinished(finished_id, true); + }); + + dl_failed_conn_ = + connect(downloader_, &DownloadManager::failed, this, [this, transaction_root](int id, const QString& error) { + if (id != pending_op_id_) { + return; + } + disconnectDlConns(); + disk_space_checked_ = false; + + const QString failed_id = pending_id_; + pending_id_.clear(); + pending_op_id_ = -1; + pending_extract_dir_.clear(); + + removeDirectoryIfSet(transaction_root); + emitInstallFailure(failed_id, error); + }); + + dl_cancelled_conn_ = connect(downloader_, &DownloadManager::cancelled, this, [this, transaction_root](int id) { if (id != pending_op_id_) { return; } @@ -167,25 +419,23 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { pending_id_.clear(); pending_op_id_ = -1; disk_space_checked_ = false; + pending_extract_dir_.clear(); - // Remove any partial files written to disk before the cancel arrived. - // Both possible locations are cleaned regardless of platform to handle edge cases. - QDir(extensions_dir_ + "/" + cancelled_id).removeRecursively(); - QDir(pending_dir_ + "/" + cancelled_id).removeRecursively(); + removeDirectoryIfSet(transaction_root); const QString reason = cancel_reason_.isEmpty() ? "Installation was cancelled" : cancel_reason_; cancel_reason_.clear(); - emit installError(cancelled_id, reason); - emit installFinished(cancelled_id, false); + emitInstallFailure(cancelled_id, reason); }); - pending_op_id_ = downloader_->fetch(QUrl(artifact.url), artifact.checksum, dest_dir); + pending_op_id_ = downloader_->fetch(QUrl(artifact.url), artifact.checksum, transaction_root); } void ExtensionManager::uninstall(const QString& extension_id) { + refreshInstalledFromDisk(); + if (!installed_.contains(extension_id)) { - emit uninstallError(extension_id, QString("Extension \"%1\" is not installed").arg(extension_id)); - emit uninstallFinished(extension_id, false); + emitUninstallFailure(extension_id, QString("Extension \"%1\" is not installed").arg(extension_id)); return; } @@ -193,15 +443,17 @@ void ExtensionManager::uninstall(const QString& extension_id) { if (!QDir(dir_path).removeRecursively()) { if (PlatformUtils::isWindows()) { - // The DLL is still mapped by the host process. Deregister the extension immediately - // and mark the directory for deletion at the next startup. - schedulePendingUninstall(dir_path); + if (!schedulePendingUninstall(dir_path)) { + emitUninstallFailure( + extension_id, + QString("Could not mark \"%1\" for restart cleanup; uninstall not scheduled").arg(dir_path)); + return; + } installed_.remove(extension_id); emit uninstallPendingRestart(extension_id); } else { - emit uninstallError( + emitUninstallFailure( extension_id, QString("Could not remove directory \"%1\" — the plugin may still be loaded").arg(dir_path)); - emit uninstallFinished(extension_id, false); } return; } @@ -211,44 +463,51 @@ void ExtensionManager::uninstall(const QString& extension_id) { } void ExtensionManager::update(const Extension& ext) { - QString backup_path; + refreshInstalledFromDisk(); + + if (PlatformUtils::isWindows()) { + if (hasNewerInstalledVersion(ext)) { + emitInstallFailure( + ext.id, QString("Installed version \"%1\" is newer than registry version \"%2\"; downgrade is not allowed") + .arg(installed_[ext.id].version, ext.version)); + return; + } + doInstall(ext, /*staging=*/true, /*allow_existing=*/true); + return; + } + + const QString platform = PlatformUtils::currentPlatform(); + if (!ext.platforms.contains(platform)) { + emitInstallFailure(ext.id, QString("No artifact available for platform \"%1\"").arg(platform)); + return; + } if (installed_.contains(ext.id)) { const QString current_version = installed_[ext.id].version; - const QString current_path = installed_[ext.id].path; + const QString current_path = installed_[ext.id].path; + + if (QVersionNumber::compare(QVersionNumber::fromString(current_version), QVersionNumber::fromString(ext.version)) > + 0) { + emitInstallFailure( + ext.id, QString("Installed version \"%1\" is newer than registry version \"%2\"; downgrade is not allowed") + .arg(current_version, ext.version)); + return; + } - // Back up the current version before downloading the new one (F-12). - // If the install subsequently fails, the files remain in backup_path and - // can be restored manually until automatic rollback (F-13, April+) is implemented. const QString candidate = PlatformUtils::backupDir() + "/" + ext.id + "-" + current_version; QDir().mkpath(PlatformUtils::backupDir()); if (!QDir().rename(current_path, candidate)) { - emit installError(ext.id, - QString("Could not back up \"%1\" — update aborted to prevent data loss") - .arg(current_path)); - emit installFinished(ext.id, false); + emitInstallFailure( + ext.id, QString("Could not back up \"%1\" — update aborted to prevent data loss").arg(current_path)); return; } - backup_path = candidate; - + pending_backup_path_ = candidate; installed_.remove(ext.id); } - // Once the install completes successfully, attach the backup location to the - // new record so future rollback code (F-13, April+) can find it. - if (!backup_path.isEmpty()) { - connect(this, &ExtensionManager::installFinished, this, - [this, backup_path](const QString& finished_id, bool success) { - if (success && installed_.contains(finished_id)) { - installed_[finished_id].backup_path = backup_path; - saveState(); - } - }, - Qt::SingleShotConnection); - } - - doInstall(ext, PlatformUtils::isWindows()); + // The Windows branch returned earlier; here we always promote immediately. + doInstall(ext, /*staging=*/false, /*allow_existing=*/true); } void ExtensionManager::applyPendingInstalls() { @@ -257,87 +516,172 @@ void ExtensionManager::applyPendingInstalls() { return; } - for (const QFileInfo& entry : pending.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - const QString src = entry.absoluteFilePath(); - const QString dst = extensions_dir_ + "/" + entry.fileName(); - - // manifest.json is part of the artifact — read it before moving the directory. - QFile manifest_file(src + "/" + kManifestFileName); - if (!manifest_file.open(QIODevice::ReadOnly)) { + for (const QFileInfo& entry : + pending.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) { + const QString staged_dir = entry.absoluteFilePath(); + const QString staged_name = entry.fileName(); + if (isTransactionDirectoryName(staged_name)) { + removeDirectoryIfSet(staged_dir); continue; } - const QJsonObject manifest = QJsonDocument::fromJson(manifest_file.readAll()).object(); - manifest_file.close(); + if (staged_name.startsWith(kQuarantinePrefix)) { + // Leftover from a previous failed promotion. Skip — manual inspection only. + continue; + } + + auto failStagedInstall = [&](const QString& signal_id, const QString& message) { + qWarning( + "ExtensionManager: staged install '%s' failed validation: %s", qPrintable(staged_dir), qPrintable(message)); + QString final_message = message; + if (!QDir(staged_dir).removeRecursively()) { + // Removal can fail on Windows when the DSO is still locked by another + // process. Move the broken stage aside so the next startup does not + // re-validate the same payload and emit the same diagnostic forever. + const QString quarantine = QDir(pending_dir_).absoluteFilePath( + QString(kQuarantinePrefix) + entry.fileName() + "_" + QUuid::createUuid().toString(QUuid::Id128)); + if (QDir().rename(staged_dir, quarantine)) { + final_message += QString(" Moved to quarantine: \"%1\".").arg(quarantine); + } else { + final_message += QString(" Could not remove or quarantine \"%1\" — manual cleanup required.").arg(staged_dir); + } + } + emitInstallFailure(signal_id, final_message); + }; - const QString id = manifest["id"].toString(); - if (id.isEmpty()) { + if (staged_name.isEmpty()) { + failStagedInstall(staged_name, "Staged install directory has no id"); continue; } - // Remove any existing installation so the rename cannot fail on a non-empty target. - QDir(dst).removeRecursively(); + const PendingInstallIntent intent = readPendingInstallIntent(staged_dir); + if (!intent.valid) { + failStagedInstall(staged_name, intent.error); + continue; + } + if (intent.id != staged_name) { + failStagedInstall( + staged_name, + QString("Staged install directory \"%1\" does not match registry id \"%2\"").arg(staged_name, intent.id)); + continue; + } - if (!QDir().rename(src, dst)) { + const DirectoryDiscovery discovered = discoverExtensionDirectory(staged_dir); + const QString validation_error = validateRegistryIntent(discovered, intent.id, intent.version); + if (!validation_error.isEmpty()) { + failStagedInstall(intent.id, validation_error); continue; } - InstalledExtension record; - record.id = id; - record.version = manifest["version"].toString(); - record.install_date = QDateTime::currentDateTimeUtc(); - record.path = dst; - record.enabled = true; + const QString dst = extRoot(extensions_dir_, intent.id); + + // Mirror the Linux/macOS backup that `update()` performs synchronously: + // move the existing dir aside before the staged version takes its place, + // so a Windows update never silently destroys the previous install. + pending_backup_path_.clear(); + DirectoryDiscovery existing; + if (QDir(dst).exists()) { + existing = discoverExtensionDirectory(dst); + const QString version_tag = (existing.found_plugin && !existing.record.version.isEmpty()) + ? existing.record.version + : QString("unknown-") + QUuid::createUuid().toString(QUuid::Id128); + + QDir().mkpath(PlatformUtils::backupDir()); + QString candidate = QDir(PlatformUtils::backupDir()).absoluteFilePath(intent.id + "-" + version_tag); + if (QDir(candidate).exists()) { + // Leftover backup from an earlier failed update with the same + // id/version. Don't clobber it — keep both. + candidate += "_" + QUuid::createUuid().toString(QUuid::Id128); + } - installed_[id] = record; - emit installFinished(id, true); + if (!QDir().rename(dst, candidate)) { + qWarning("ExtensionManager: failed to back up '%s' before promoting staged install", qPrintable(dst)); + emitInstallFailure( + intent.id, + QString("Could not back up \"%1\" before update — staged install left in \"%2\"").arg(dst, staged_dir)); + continue; + } + pending_backup_path_ = candidate; + installed_.remove(intent.id); + } + + if (!QDir().rename(staged_dir, dst)) { + qWarning( + "ExtensionManager: failed to promote staged install '%s' to '%s'", qPrintable(staged_dir), qPrintable(dst)); + QString message = QString("Could not promote staged install to \"%1\"").arg(dst); + + // Best-effort rollback so the user is never left with no extension. If + // the rollback rename also fails, leave pending_backup_path_ set so + // emitInstallFailure appends "Previous version remains in backup". + if (!pending_backup_path_.isEmpty() && QDir().rename(pending_backup_path_, dst)) { + message += " Previous version restored."; + pending_backup_path_.clear(); + if (existing.found_plugin) { + registerInstalledExtension(intent.id, dst, existing.record); + } + } + emitInstallFailure(intent.id, message); + continue; + } + + QFile::remove(pendingInstallIntentPath(dst)); + registerInstalledExtension(intent.id, dst, discovered.record); + pending_backup_path_.clear(); + emit installFinished(intent.id, true); } } void ExtensionManager::applyPendingUninstalls() { const QDir dir(extensions_dir_); - for (const QFileInfo& entry : dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { + for (const QFileInfo& entry : dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) { if (!QFile::exists(entry.absoluteFilePath() + "/" + kPendingUninstallMarker)) { continue; } - QFile manifest_file(entry.absoluteFilePath() + "/" + kManifestFileName); - QString id; - if (manifest_file.open(QIODevice::ReadOnly)) { - id = QJsonDocument::fromJson(manifest_file.readAll()).object()["id"].toString(); - manifest_file.close(); - } - if (QDir(entry.absoluteFilePath()).removeRecursively() && !id.isEmpty()) { - installed_.remove(id); + const QString id = entry.fileName(); + if (QDir(entry.absoluteFilePath()).removeRecursively()) { + if (!id.isEmpty()) { + installed_.remove(id); + } + } else { + // Leave the marker in place so the next startup retries; surface so the + // user sees that a deferred uninstall is stuck. + reportDiagnostic( + id, + QString("Could not remove extension directory \"%1\"; restart the application or close any process using it") + .arg(entry.absoluteFilePath()), + true); } } } - bool ExtensionManager::isInstalled(const QString& id) const { return installed_.contains(id); } bool ExtensionManager::hasPendingInstall(const QString& id) const { - const QDir pending(pending_dir_); - if (!pending.exists()) { + if (!invalidExtensionIdReason(id).isEmpty()) { return false; } - for (const QFileInfo& entry : pending.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - QFile manifest_file(entry.absoluteFilePath() + "/" + kManifestFileName); - if (!manifest_file.open(QIODevice::ReadOnly)) { - continue; - } - const QString manifest_id = QJsonDocument::fromJson(manifest_file.readAll()).object()["id"].toString(); - if (manifest_id == id) { - emit const_cast(this)->installPendingRestart(id); - return true; - } + const QString root = pendingRoot(pending_dir_, id); + const PendingInstallIntent intent = readPendingInstallIntent(root); + if (!intent.valid || intent.id != id) { + return false; } - return false; + const DirectoryDiscovery discovered = discoverExtensionDirectory(root); + return validateRegistryIntent(discovered, intent.id, intent.version).isEmpty(); } bool ExtensionManager::hasPendingUninstall(const QString& id) const { - const QString path = extensions_dir_ + "/" + id; - return QFile::exists(path + "/" + kPendingUninstallMarker); + return QFile::exists(extRoot(extensions_dir_, id) + "/" + kPendingUninstallMarker); +} + +bool ExtensionManager::hasNewerInstalledVersion(const Extension& ext) const { + if (!installed_.contains(ext.id)) { + return false; + } + + const QVersionNumber installed_ver = QVersionNumber::fromString(installed_[ext.id].version); + const QVersionNumber registry_ver = QVersionNumber::fromString(ext.version); + return QVersionNumber::compare(installed_ver, registry_ver) > 0; } bool ExtensionManager::hasUpdate(const Extension& ext) const { @@ -345,8 +689,6 @@ bool ExtensionManager::hasUpdate(const Extension& ext) const { return false; } - // QVersionNumber handles multi-segment comparison correctly: - // "1.10.0" > "1.9.0", unlike a raw string compare which would invert them. const QVersionNumber installed_ver = QVersionNumber::fromString(installed_[ext.id].version); const QVersionNumber latest = QVersionNumber::fromString(ext.version); return QVersionNumber::compare(latest, installed_ver) > 0; @@ -356,6 +698,14 @@ QMap ExtensionManager::installedExtensions() const return installed_; } +QList ExtensionManager::diagnostics() const { + return diagnostics_; +} + +void ExtensionManager::clearDiagnostics() { + diagnostics_.clear(); +} + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -367,79 +717,85 @@ void ExtensionManager::disconnectDlConns() { disconnect(dl_cancelled_conn_); } -void ExtensionManager::schedulePendingUninstall(const QString& path) { +bool ExtensionManager::schedulePendingUninstall(const QString& path) { QFile marker(path + "/" + kPendingUninstallMarker); - marker.open(QIODevice::WriteOnly); // content irrelevant; existence is the signal + // Content is irrelevant; existence is the signal. Failure here means the next + // startup's applyPendingUninstalls() will not see the marker and the directory + // would leak forever — surface it so the caller can fail the uninstall. + return marker.open(QIODevice::WriteOnly); } -void ExtensionManager::savePendingMeta(const Extension& ext) { - QJsonObject obj; - obj["id"] = ext.id; - obj["version"] = ext.version; - obj["install_date"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); +void ExtensionManager::reportDiagnostic(const QString& id, const QString& message, bool is_error) { + diagnostics_.append(ExtensionDiagnostic{id, message, is_error, QDateTime::currentDateTimeUtc()}); + while (diagnostics_.size() > kMaxDiagnostics) { + diagnostics_.removeFirst(); + } + emit diagnosticReported(id, message, is_error); + if (sink_) { + sink_(Diagnostic{ + is_error ? DiagnosticLevel::kError : DiagnosticLevel::kInfo, + "ExtensionManager", + id.toStdString(), + message.toStdString(), + std::chrono::system_clock::now(), + }); + } +} - QFile file(pending_dir_ + "/" + ext.id + "/pj_meta.json"); - if (file.open(QIODevice::WriteOnly)) { - file.write(QJsonDocument(obj).toJson()); +void ExtensionManager::emitInstallFailure(const QString& id, const QString& message) { + QString diagnostic = message; + if (!pending_backup_path_.isEmpty()) { + diagnostic += QString(" Previous version remains in backup: \"%1\".").arg(pending_backup_path_); } + pending_backup_path_.clear(); + reportDiagnostic(id, diagnostic, true); + emit installError(id, diagnostic); + emit installFinished(id, false); } -// --------------------------------------------------------------------------- -// Private — state persistence -// --------------------------------------------------------------------------- +void ExtensionManager::emitUninstallFailure(const QString& id, const QString& message) { + reportDiagnostic(id, message, true); + emit uninstallError(id, message); + emit uninstallFinished(id, false); +} -void ExtensionManager::loadState() { - const QDir dir(extensions_dir_); - for (const QFileInfo& entry : dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - const QString ext_root = entry.absoluteFilePath(); +void ExtensionManager::registerInstalledExtension(const QString& id, const QString& dst, InstalledExtension record) { + record.path = dst; + record.install_date = QFileInfo(dst).lastModified(); + installed_[id] = record; +} - if (QFile::exists(ext_root + "/" + kPendingUninstallMarker)) { +void ExtensionManager::refreshInstalledFromDisk() { + QMap discovered; + const QDir dir(extensions_dir_); + for (const QFileInfo& entry : dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) { + const QString root = entry.absoluteFilePath(); + if (isTransactionDirectoryName(entry.fileName())) { + removeDirectoryIfSet(root); + continue; + } + if (QFile::exists(root + "/" + kPendingUninstallMarker)) { continue; } - QFile manifest_file(ext_root + "/" + kManifestFileName); - if (!manifest_file.open(QIODevice::ReadOnly)) { + const DirectoryDiscovery item = discoverExtensionDirectory(root); + if (!item.found_plugin) { + qWarning("ExtensionManager: ignoring extension directory '%s': %s", qPrintable(root), qPrintable(item.error)); continue; } - const QJsonObject manifest = QJsonDocument::fromJson(manifest_file.readAll()).object(); - const QString id = manifest["id"].toString(); - if (id.isEmpty()) { + if (discovered.contains(item.record.id)) { + qWarning( + "ExtensionManager: duplicate embedded extension id '%s' in '%s'; keeping first", qPrintable(item.record.id), + qPrintable(root)); continue; } + discovered[item.record.id] = item.record; + } + installed_ = std::move(discovered); +} - InstalledExtension inst; - inst.id = id; - inst.version = manifest["version"].toString(); - inst.install_date = entry.lastModified(); - inst.path = ext_root; - inst.enabled = true; - - installed_[id] = inst; - } -} - -void ExtensionManager::saveState() { - // QJsonArray array; - // for (const InstalledExtension& inst : installed_) { - // QJsonObject obj; - // obj["id"] = inst.id; - // obj["version"] = inst.version; - // obj["install_date"] = inst.install_date.toString(Qt::ISODate); - // obj["path"] = inst.path; - // obj["enabled"] = inst.enabled; - // if (!inst.backup_path.isEmpty()) { - // obj["backup_path"] = inst.backup_path; - // } - // array.append(obj); - // } - - // const QJsonDocument doc(QJsonObject{{"installed", array}}); - - // static constexpr const char* kStateFileName = "/installed.json"; - // QFile file(extensions_dir_ + kStateFileName); - // if (file.open(QIODevice::WriteOnly)) { - // file.write(doc.toJson()); - // } +void ExtensionManager::setInstalledExtensions(QMap installed) { + installed_ = std::move(installed); } } // namespace PJ diff --git a/pj_marketplace/src/core/PlatformUtils.cpp b/pj_marketplace/src/core/PlatformUtils.cpp index 23b3659..c0b49b6 100644 --- a/pj_marketplace/src/core/PlatformUtils.cpp +++ b/pj_marketplace/src/core/PlatformUtils.cpp @@ -53,7 +53,7 @@ QString PlatformUtils::extensionsDir() { } QString PlatformUtils::pendingDir() { - return configDir() + "/.extension_windows_staging"; + return configDir() + "/.extension_staging"; } QString PlatformUtils::backupDir() { diff --git a/pj_marketplace/src/core/QtDiagnosticBridge.cpp b/pj_marketplace/src/core/QtDiagnosticBridge.cpp new file mode 100644 index 0000000..c91aa6c --- /dev/null +++ b/pj_marketplace/src/core/QtDiagnosticBridge.cpp @@ -0,0 +1,31 @@ +#include "pj_marketplace/qt_diagnostic_bridge.hpp" + +#include +#include +#include + +namespace PJ { + +QtDiagnosticBridge::QtDiagnosticBridge(QObject* parent) : QObject(parent) {} + +DiagnosticSink QtDiagnosticBridge::sink() { + QPointer guard(this); + return [guard](const Diagnostic& diagnostic) { + if (!guard) { + return; + } + QMetaObject::invokeMethod( + guard.data(), + [guard, diagnostic]() { + if (!guard) { + return; + } + emit guard->diagnosticReported( + static_cast(diagnostic.level), QString::fromStdString(diagnostic.source), + QString::fromStdString(diagnostic.id), QString::fromStdString(diagnostic.message)); + }, + Qt::QueuedConnection); + }; +} + +} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockDownloadManager.cpp b/pj_marketplace/src/core/mock/MockDownloadManager.cpp deleted file mode 100644 index 933e378..0000000 --- a/pj_marketplace/src/core/mock/MockDownloadManager.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include "core/mock/MockDownloadManager.h" - -namespace PJ { - -MockDownloadManager::MockDownloadManager(QObject* parent) : DownloadManager(parent) {} - -int MockDownloadManager::fetch(const QUrl& /*url*/, const QString& /*expectedChecksum*/, - const QString& /*destinationDir*/) { - const int id = next_id_++; - - auto* timer = new QTimer(this); - ops_[id] = MockOp{timer, 0}; - - emit started(id); - - connect(timer, &QTimer::timeout, this, [this, id]() { - auto it = ops_.find(id); - if (it == ops_.end()) return; - - it->tick++; - const qint64 received = (MockOp::kTotalBytes * it->tick) / MockOp::kTicks; - emit progress(id, received, MockOp::kTotalBytes); - - if (it->tick >= MockOp::kTicks) { - it->timer->stop(); - it->timer->deleteLater(); - ops_.erase(it); - emit finished(id); - } - }); - - timer->start(100); - return id; -} - -void MockDownloadManager::cancel(int id) { - auto it = ops_.find(id); - if (it == ops_.end()) return; - - it->timer->stop(); - it->timer->deleteLater(); - ops_.erase(it); - emit cancelled(id); -} - -} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockDownloadManager.h b/pj_marketplace/src/core/mock/MockDownloadManager.h deleted file mode 100644 index 2719ce1..0000000 --- a/pj_marketplace/src/core/mock/MockDownloadManager.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "pj_marketplace/download_manager.hpp" - -namespace PJ { - -/// Simulates the full DownloadManager pipeline using QTimers instead of real HTTP. -/// -/// fetch() returns an id immediately and then emits: -/// started(id), progress(id, 0..total, total) every 100 ms, finished(id) -/// -/// cancel() stops the timer and emits cancelled(id). -class MockDownloadManager : public DownloadManager { - Q_OBJECT - - public: - explicit MockDownloadManager(QObject* parent = nullptr); - ~MockDownloadManager() override = default; - - int fetch(const QUrl& url, const QString& expectedChecksum, - const QString& destinationDir) override; - - void cancel(int id) override; - - private: - struct MockOp { - QTimer* timer; - int tick = 0; - static constexpr int kTicks = 10; - static constexpr qint64 kTotalBytes = 1024 * 1024; // 1 MiB (fake) - }; - - QMap ops_; - int next_id_ = 1; -}; - -} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockExtensionManager.cpp b/pj_marketplace/src/core/mock/MockExtensionManager.cpp deleted file mode 100644 index ba33447..0000000 --- a/pj_marketplace/src/core/mock/MockExtensionManager.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "core/mock/MockExtensionManager.h" - -namespace PJ { - -MockExtensionManager::MockExtensionManager(QObject* parent) - : ExtensionManager(nullptr, "/tmp/pj_mock_ext", "/tmp/pj_mock_pending", parent) { - mock_installed_["csv-loader"] = - InstalledExtension{"csv-loader", "1.0.0", {}, "/usr/lib/plotjuggler/csv-loader.so", true, {}}; - mock_installed_["ros2-streaming"] = InstalledExtension{ - "ros2-streaming", "1.1.0", {}, "/usr/lib/plotjuggler/ros2-streaming.so", true, {}}; -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -void MockExtensionManager::install(const Extension& ext) { - if (progress_timer_ && progress_timer_->isActive()) { - emit installError(ext.id, "Another installation is already in progress"); - return; - } - startMockOperation(ext, false); -} - -void MockExtensionManager::uninstall(const QString& extension_id) { - mock_installed_.remove(extension_id); - emit uninstallFinished(extension_id, true); -} - -void MockExtensionManager::update(const Extension& ext) { - if (progress_timer_ && progress_timer_->isActive()) { - emit installError(ext.id, "Another installation is already in progress"); - return; - } - startMockOperation(ext, true); -} - -bool MockExtensionManager::isInstalled(const QString& id) const { - return mock_installed_.contains(id); -} - -bool MockExtensionManager::hasUpdate(const Extension& ext) const { - if (!mock_installed_.contains(ext.id)) return false; - return mock_installed_[ext.id].version != ext.version; -} - -QMap MockExtensionManager::installedExtensions() const { - return mock_installed_; -} - -// ─── Mock progress simulation ──────────────────────────────────────────────── - -void MockExtensionManager::startMockOperation(const Extension& ext, bool is_update) { - pending_ext_ = ext; - pending_is_update_ = is_update; - tick_ = 0; - - emit installStarted(ext.id); - - progress_timer_ = new QTimer(this); - connect(progress_timer_, &QTimer::timeout, this, [this]() { - tick_++; - emit installProgress(pending_ext_.id, tick_ * (100 / kTicks)); - - if (tick_ >= kTicks) { - progress_timer_->stop(); - progress_timer_->deleteLater(); - progress_timer_ = nullptr; - - if (pending_is_update_) { - mock_installed_[pending_ext_.id].version = pending_ext_.version; - } else { - mock_installed_[pending_ext_.id] = InstalledExtension{ - pending_ext_.id, pending_ext_.version, {}, - "/usr/lib/plotjuggler/" + pending_ext_.id + ".so", true, {}}; - } - - emit installFinished(pending_ext_.id, true); - } - }); - progress_timer_->start(100); -} - -} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockExtensionManager.h b/pj_marketplace/src/core/mock/MockExtensionManager.h deleted file mode 100644 index a5d8b0f..0000000 --- a/pj_marketplace/src/core/mock/MockExtensionManager.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "pj_marketplace/extension_manager.hpp" -#include "pj_marketplace/installed_extension.hpp" - -namespace PJ { - -/// Simulates the full ExtensionManager lifecycle using QTimers instead of real downloads. -/// -/// Pre-populated with two installed extensions (csv-loader v1.0.0, ros2-streaming v1.1.0). -/// install() / update() emit installStarted → installProgress(0..100) → installFinished(true). -/// uninstall() removes the entry and emits uninstallFinished(true) immediately. -/// -/// Used by the standalone Marketplace app until the real ExtensionManager is wired up. -class MockExtensionManager : public ExtensionManager { - Q_OBJECT - - public: - explicit MockExtensionManager(QObject* parent = nullptr); - ~MockExtensionManager() override = default; - - void install(const Extension& ext) override; - void uninstall(const QString& extension_id) override; - void update(const Extension& ext) override; - - bool isInstalled(const QString& id) const override; - bool hasUpdate(const Extension& ext) const override; - QMap installedExtensions() const override; - - private: - void startMockOperation(const Extension& ext, bool is_update); - - QMap mock_installed_; - QTimer* progress_timer_ = nullptr; - Extension pending_ext_; - bool pending_is_update_ = false; - int tick_ = 0; - - static constexpr int kTicks = 10; -}; - -} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockRegistryManager.cpp b/pj_marketplace/src/core/mock/MockRegistryManager.cpp deleted file mode 100644 index c51ef96..0000000 --- a/pj_marketplace/src/core/mock/MockRegistryManager.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "core/mock/MockRegistryManager.h" - -namespace PJ { - -MockRegistryManager::MockRegistryManager(QObject* parent) : RegistryManager(parent) { - { - Extension e; - e.id = "csv-loader"; e.name = "CSV Loader"; - e.description = "Load CSV/TSV files with automatic column detection and configurable delimiters."; - e.author = "PlotJuggler Team"; e.publisher = "PlotJuggler"; - e.license = "MIT"; e.website = "https://github.com/facontidavide/PlotJuggler"; - e.category = "data_loader"; e.tags = {"csv", "tsv", "file"}; - e.version = "1.0.0"; e.min_plotjuggler_version = "3.8.0"; - e.changelog["1.0.0"] = "Initial release with CSV and TSV support."; - mock_extensions_.append(e); - } - { - Extension e; - e.id = "ros2-streaming"; e.name = "ROS 2 Streaming"; - e.description = "Stream topics live from a ROS 2 network via DDS. Supports QoS configuration."; - e.author = "ROS Community"; e.publisher = "ros-community"; - e.license = "Apache-2.0"; e.website = "https://github.com/ros-community/plotjuggler-ros2"; - e.category = "data_streamer"; e.tags = {"ros2", "dds", "live", "robotics"}; - e.version = "1.2.0"; e.min_plotjuggler_version = "3.8.0"; - e.changelog["1.2.0"] = "Added QoS profile selector and topic type filter."; - e.changelog["1.1.0"] = "Initial public release."; - mock_extensions_.append(e); - } - { - Extension e; - e.id = "mcap-loader"; e.name = "MCAP Loader"; - e.description = "Load MCAP log files (Foxglove format). Supports multi-channel time-indexed data."; - e.author = "Foxglove Technologies"; e.publisher = "foxglove"; - e.license = "MIT"; e.website = "https://foxglove.dev"; - e.category = "data_loader"; e.tags = {"mcap", "foxglove", "log"}; - e.version = "2.1.0"; e.min_plotjuggler_version = "3.9.0"; - e.changelog["2.1.0"] = "Performance improvements for large MCAP files."; - e.changelog["2.0.0"] = "Full MCAP spec v2 compliance."; - mock_extensions_.append(e); - } - { - Extension e; - e.id = "fft-toolbox"; e.name = "FFT Toolbox"; - e.description = "Frequency analysis tools: FFT, spectrogram, windowing functions (Hann, Blackman)."; - e.author = "Signal Processing Labs"; e.publisher = "spl"; - e.license = "GPL-3.0"; e.website = "https://github.com/spl/plotjuggler-fft"; - e.category = "toolbox"; e.tags = {"fft", "frequency", "spectrum", "signal"}; - e.version = "0.9.2"; e.min_plotjuggler_version = "3.8.0"; - e.changelog["0.9.2"] = "Added Blackman-Harris window."; - e.changelog["0.9.0"] = "Beta release."; - mock_extensions_.append(e); - } - { - Extension e; - e.id = "can-parser"; e.name = "CAN Bus Parser"; - e.description = "Parse CAN bus messages using DBC database files. Decodes signals automatically."; - e.author = "Automotive Tools Group"; e.publisher = "atg"; - e.license = "MIT"; e.website = "https://github.com/atg/plotjuggler-can"; - e.category = "parser"; e.tags = {"can", "dbc", "automotive", "bus"}; - e.version = "1.3.1"; e.min_plotjuggler_version = "3.8.0"; - e.changelog["1.3.1"] = "Fixed signed integer decoding for CAN signals."; - e.changelog["1.3.0"] = "DBC multiplexed messages support."; - mock_extensions_.append(e); - } - { - Extension e; - e.id = "ros-bundle"; e.name = "ROS Bundle"; - e.description = "All-in-one bundle: ROS 1 bag loader, ROS 2 streaming, and rosout log viewer."; - e.author = "PlotJuggler Team"; e.publisher = "PlotJuggler"; - e.license = "LGPL-2.1"; e.website = "https://github.com/facontidavide/PlotJuggler"; - e.category = "bundle"; e.tags = {"ros", "ros2", "bag", "bundle"}; - e.version = "3.0.0"; e.min_plotjuggler_version = "3.9.0"; - e.changelog["3.0.0"] = "Unified ROS 1+2 bundle. Requires PlotJuggler 3.9+."; - mock_extensions_.append(e); - } -} - -void MockRegistryManager::fetchRegistry(const QUrl& /*url*/) { - // Data is already in mock_extensions_; emit synchronously so the caller - // sees a populated catalog before returning from this call. - emit fetchStarted(); - emit fetchFinished(true); -} - -QList MockRegistryManager::extensions() const { - return mock_extensions_; -} - -Extension MockRegistryManager::findById(const QString& id) const { - for (const auto& ext : mock_extensions_) - if (ext.id == id) return ext; - return {}; -} - -} // namespace PJ diff --git a/pj_marketplace/src/core/mock/MockRegistryManager.h b/pj_marketplace/src/core/mock/MockRegistryManager.h deleted file mode 100644 index 51f9192..0000000 --- a/pj_marketplace/src/core/mock/MockRegistryManager.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include -#include "pj_marketplace/registry_manager.hpp" -#include "pj_marketplace/extension.hpp" - -namespace PJ { - -/// Provides a hard-coded extension catalog without any network access. -/// -/// fetchRegistry() emits fetchStarted() and fetchFinished(true) synchronously -/// so that MarketplaceWindow is populated immediately on construction, exactly -/// as the inline setup_mock_catalog() did before this refactoring. -class MockRegistryManager : public RegistryManager { - Q_OBJECT - - public: - explicit MockRegistryManager(QObject* parent = nullptr); - ~MockRegistryManager() override = default; - - void fetchRegistry(const QUrl& url) override; - QList extensions() const override; - Extension findById(const QString& id) const override; - - private: - QList mock_extensions_; -}; - -} // namespace PJ diff --git a/pj_marketplace/src/ui/extension_detail_dialog.cpp b/pj_marketplace/src/ui/extension_detail_dialog.cpp index baf59cb..24c2bce 100644 --- a/pj_marketplace/src/ui/extension_detail_dialog.cpp +++ b/pj_marketplace/src/ui/extension_detail_dialog.cpp @@ -1,16 +1,17 @@ #include "pj_marketplace/extension_detail_dialog.hpp" -#include "ui_extension_detail_dialog.h" #include #include #include #include #include +#include + +#include "ui_extension_detail_dialog.h" namespace PJ { -ExtensionDetailDialog::ExtensionDetailDialog(const Extension& ext, const QString& installed_version, - QWidget* parent) +ExtensionDetailDialog::ExtensionDetailDialog(const Extension& ext, const QString& installed_version, QWidget* parent) : QDialog(parent), ui_(new Ui::ExtensionDetailDialog) { ui_->setupUi(this); setWindowTitle(ext.name + " — Details"); @@ -24,13 +25,26 @@ ExtensionDetailDialog::ExtensionDetailDialog(const Extension& ext, const QString // ── Metadata row ─────────────────────────────────────────────────────────── QStringList meta; - if (!ext.publisher.isEmpty()) meta << ext.publisher; - if (!ext.category.isEmpty()) meta << ext.category; - if (!ext.license.isEmpty()) meta << ext.license; - if (!ext.min_plotjuggler_version.isEmpty()) + if (!ext.publisher.isEmpty()) { + meta << ext.publisher; + } + if (!ext.category.isEmpty()) { + meta << ext.category; + } + if (!ext.license.isEmpty()) { + meta << ext.license; + } + if (!ext.min_plotjuggler_version.isEmpty()) { meta << "requires PJ " + ext.min_plotjuggler_version + "+"; - if (!installed_version.isEmpty()) - meta << "installed: v" + installed_version; + } + const bool local_is_newer = !installed_version.isEmpty() && QVersionNumber::compare( + QVersionNumber::fromString(installed_version), + QVersionNumber::fromString(ext.version)) > 0; + if (!installed_version.isEmpty()) { + meta + << (local_is_newer ? "installed: v" + installed_version + " (newer than registry)" + : "installed: v" + installed_version); + } ui_->meta_lbl->setText(meta.join(" \u2022 ")); // ── Tags (chips) ── dynamic: created per extension ──────────────────────── @@ -49,21 +63,25 @@ ExtensionDetailDialog::ExtensionDetailDialog(const Extension& ext, const QString ui_->desc_lbl->setText(ext.description); // ── Buttons ── state-dependent visibility and style ──────────────────────── - const bool installed = !installed_version.isEmpty(); + const bool installed = !installed_version.isEmpty(); const bool has_update = installed && installed_version != ext.version; ui_->github_btn->setEnabled(!ext.website.isEmpty()); const QString website = ext.website; - connect(ui_->github_btn, &QPushButton::clicked, this, - [website]() { if (!website.isEmpty()) QDesktopServices::openUrl(QUrl(website)); }); + connect(ui_->github_btn, &QPushButton::clicked, this, [website]() { + if (!website.isEmpty()) { + QDesktopServices::openUrl(QUrl(website)); + } + }); if (!installed || has_update) { - const QString lbl = has_update ? "Update \u2B06" : "Install"; - const QString style = has_update - ? "QPushButton { background:#e6a817; color:white; border:none; border-radius:4px; padding:4px 14px; }" - "QPushButton:hover { background:#f0b82a; }" - : "QPushButton { background:#2196f3; color:white; border:none; border-radius:4px; padding:4px 14px; }" - "QPushButton:hover { background:#42a5f5; }"; + const QString lbl = has_update ? "Update \u2B06" : "Install"; + const QString style = + has_update + ? "QPushButton { background:#e6a817; color:white; border:none; border-radius:4px; padding:4px 14px; }" + "QPushButton:hover { background:#f0b82a; }" + : "QPushButton { background:#2196f3; color:white; border:none; border-radius:4px; padding:4px 14px; }" + "QPushButton:hover { background:#42a5f5; }"; ui_->action_btn->setText(lbl); ui_->action_btn->setStyleSheet(style); ui_->action_btn->setVisible(true); diff --git a/pj_marketplace/src/ui/marketplace_window.cpp b/pj_marketplace/src/ui/marketplace_window.cpp index ff287f4..8e2c635 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -1,39 +1,80 @@ #include "pj_marketplace/marketplace_window.hpp" -#include "pj_marketplace/extension_detail_dialog.hpp" -#include "ui_marketplace_window.h" -#include "pj_marketplace/download_manager.hpp" -#include "pj_marketplace/extension_manager.hpp" -#include "pj_marketplace/platform_utils.hpp" -#include "pj_marketplace/registry_manager.hpp" #include #include #include -#include -#include -#include #include -#include #include +#include +#include #include #include #include #include +#include +#include #include +#include #include +#include "pj_marketplace/download_manager.hpp" +#include "pj_marketplace/extension_detail_dialog.hpp" +#include "pj_marketplace/extension_manager.hpp" +#include "pj_marketplace/platform_utils.hpp" +#include "pj_marketplace/registry_manager.hpp" +#include "ui_marketplace_window.h" + namespace PJ { static constexpr const char* kDefaultRegistryUrl = "https://raw.githubusercontent.com/PlotJuggler/pj-plugin-registry" "/refs/heads/development/registry.json"; +namespace { + +bool installedStatesEqual(const QMap& lhs, const QMap& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (auto it = lhs.cbegin(); it != lhs.cend(); ++it) { + const auto rhs_it = rhs.find(it.key()); + if (rhs_it == rhs.cend()) { + return false; + } + const InstalledExtension& a = it.value(); + const InstalledExtension& b = rhs_it.value(); + if (a.id != b.id || a.version != b.version || a.enabled != b.enabled) { + return false; + } + } + return true; +} + +} // namespace + MarketplaceWindow::MarketplaceWindow(const QUrl& registry_url, QWidget* parent) : QDialog(parent), ui_(new Ui::MarketplaceWindow) { download_mgr_ = new DownloadManager(this); registry_mgr_ = new RegistryManager(this); - ext_mgr_ = new ExtensionManager(download_mgr_, PlatformUtils::extensionsDir(), - PlatformUtils::pendingDir(), this); + ext_mgr_ = new ExtensionManager( + download_mgr_, PlatformUtils::extensionsDir(), PlatformUtils::pendingDir(), /*sink*/ {}, this); + QSettings settings("PlotJuggler", "Marketplace"); + const QString saved = settings.value("registry_url").toString(); + registry_url_ = saved.isEmpty() ? registry_url : QUrl(saved); + + ui_->setupUi(this); + setupUi(); + setupSignals(); + updateDiagnosticsButton(); + showLatestDiagnostic(); + // applyPendingUninstalls/applyPendingInstalls already ran in ExtensionManager::initComponents(). + registry_mgr_->fetchRegistry(registry_url_); +} + +MarketplaceWindow::MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& registry_url, QWidget* parent) + : QDialog(parent), ui_(new Ui::MarketplaceWindow) { + registry_mgr_ = new RegistryManager(this); + ext_mgr_ = ext_mgr; QSettings settings("PlotJuggler", "Marketplace"); const QString saved = settings.value("registry_url").toString(); registry_url_ = saved.isEmpty() ? registry_url : QUrl(saved); @@ -41,15 +82,18 @@ MarketplaceWindow::MarketplaceWindow(const QUrl& registry_url, QWidget* parent) ui_->setupUi(this); setupUi(); setupSignals(); - ext_mgr_->applyPendingInstalls(); + updateDiagnosticsButton(); + showLatestDiagnostic(); registry_mgr_->fetchRegistry(registry_url_); } -MarketplaceWindow::MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& registry_url, - QWidget* parent) +MarketplaceWindow::MarketplaceWindow( + ExtensionManager* ext_mgr, const QUrl& registry_url, const QMap& installed, + QWidget* parent) : QDialog(parent), ui_(new Ui::MarketplaceWindow) { registry_mgr_ = new RegistryManager(this); ext_mgr_ = ext_mgr; + initial_snapshot_provided_ = true; QSettings settings("PlotJuggler", "Marketplace"); const QString saved = settings.value("registry_url").toString(); registry_url_ = saved.isEmpty() ? registry_url : QUrl(saved); @@ -57,6 +101,9 @@ MarketplaceWindow::MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& regi ui_->setupUi(this); setupUi(); setupSignals(); + ext_mgr_->setInstalledExtensions(installed); + updateDiagnosticsButton(); + showLatestDiagnostic(); registry_mgr_->fetchRegistry(registry_url_); } @@ -71,123 +118,141 @@ void MarketplaceWindow::setupUi() { ui_->update_all_btn_->setFixedWidth(90); ui_->update_all_btn_->setEnabled(false); - ui_->category_combo_->addItem("All categories", ""); - ui_->category_combo_->addItem("Data Loader", "data_loader"); - ui_->category_combo_->addItem("Data Streamer", "data_stream"); - ui_->category_combo_->addItem("Message Parser", "message_parser"); - ui_->category_combo_->addItem("Toolbox", "toolbox"); - - connect(ui_->search_edit_, &QLineEdit::textChanged, - this, &MarketplaceWindow::onSearchChanged); - connect(ui_->category_combo_, QOverload::of(&QComboBox::currentIndexChanged), - this, &MarketplaceWindow::onCategoryChanged); - connect(ui_->refresh_btn_, &QPushButton::clicked, - this, &MarketplaceWindow::onRefreshClicked); - connect(ui_->update_all_btn_, &QPushButton::clicked, - this, &MarketplaceWindow::onUpdateAllClicked); - connect(ui_->settings_btn_, &QPushButton::clicked, - this, &MarketplaceWindow::onSettingsClicked); + ui_->category_combo_->addItem("All categories", ""); + ui_->category_combo_->addItem("Data Loader", "data_loader"); + ui_->category_combo_->addItem("Data Streamer", "data_streamer"); + ui_->category_combo_->addItem("Message Parser", "parser"); + ui_->category_combo_->addItem("Toolbox", "toolbox"); + + connect(ui_->search_edit_, &QLineEdit::textChanged, this, &MarketplaceWindow::onSearchChanged); + connect( + ui_->category_combo_, QOverload::of(&QComboBox::currentIndexChanged), this, + &MarketplaceWindow::onCategoryChanged); + connect(ui_->refresh_btn_, &QPushButton::clicked, this, &MarketplaceWindow::onRefreshClicked); + connect(ui_->update_all_btn_, &QPushButton::clicked, this, &MarketplaceWindow::onUpdateAllClicked); + connect(ui_->settings_btn_, &QPushButton::clicked, this, &MarketplaceWindow::onSettingsClicked); + connect(ui_->diagnostics_btn_, &QPushButton::clicked, this, &MarketplaceWindow::onDiagnosticsClicked); } // ─── Signal wiring ─────────────────────────────────────────────────────────── void MarketplaceWindow::setupSignals() { // RegistryManager - connect(registry_mgr_, &RegistryManager::fetchStarted, this, - [this]() { setStatus("Loading registry..."); }); + connect(registry_mgr_, &RegistryManager::fetchStarted, this, [this]() { setStatus("Loading registry..."); }); connect(registry_mgr_, &RegistryManager::fetchFinished, this, [this](bool success) { if (!success) { setStatus("Failed to load registry", true); return; } + // A successful refresh is a strong "things are working" signal; let it + // override any old sticky error so progress messages aren't suppressed. + clearStickyStatus(); extensions_ = registry_mgr_->extensions(); applyFilters(); setStatus("Ready — " + QString::number(extensions_.size()) + " extensions loaded"); }); + connect(ext_mgr_, &ExtensionManager::installPendingRestart, this, [this](const QString& id) { + ui_->progress_bar_->setVisible(false); + status_error_sticky_ = false; + populateCards(); + setStatus(QString("Extension %1 staged — will be active after restart").arg(id)); + processInstallQueue(); + }); - connect(ext_mgr_, &ExtensionManager::installPendingRestart, this, - [this](const QString& id) { - pending_restart_ids_.insert(id); - ui_->progress_bar_->setVisible(false); - populateCards(); - setStatus("Extension staged — will be active after restart"); - processInstallQueue(); - }); - - - connect(ext_mgr_, &ExtensionManager::uninstallPendingRestart, this, - [this](const QString& id) { - pending_restart_ids_.insert(id); - ui_->progress_bar_->setVisible(false); - populateCards(); - setStatus("Extension staged — will be uninstalled after restart"); - }); + connect(ext_mgr_, &ExtensionManager::uninstallPendingRestart, this, [this](const QString& id) { + ui_->progress_bar_->setVisible(false); + status_error_sticky_ = false; + populateCards(); + setStatus(QString("Extension %1 staged — will be uninstalled after restart").arg(id)); + }); - connect(registry_mgr_, &RegistryManager::fetchError, this, - [this](const QString& error) { setStatus("Registry error: " + error, true); }); + connect(registry_mgr_, &RegistryManager::fetchError, this, [this](const QString& error) { + setStatus("Registry error: " + error, true); + }); // ExtensionManager connect(ext_mgr_, &ExtensionManager::installStarted, this, [this](const QString& id) { ui_->progress_bar_->setValue(0); ui_->progress_bar_->setRange(0, 100); ui_->progress_bar_->setVisible(true); - for (const auto& ext : extensions_) - if (ext.id == id) { setStatus("Installing " + ext.name + "..."); break; } + for (const auto& ext : extensions_) { + if (ext.id == id) { + setStatus("Installing " + ext.name + "..."); + break; + } + } + }); + + connect(ext_mgr_, &ExtensionManager::installProgress, this, [this](const QString& /*id*/, int percent) { + ui_->progress_bar_->setValue(percent); + }); + + connect(ext_mgr_, &ExtensionManager::installFinished, this, [this](const QString& id, bool success) { + ui_->progress_bar_->setVisible(false); + if (success) { + installations_changed_ = true; + } + populateCards(); + if (success) { + status_error_sticky_ = false; + for (const auto& ext : extensions_) { + if (ext.id == id) { + setStatus("Installed " + ext.name + " v" + ext.version); + break; + } + } + } + // On failure the status was already set by installError — do not overwrite it. + processInstallQueue(); + }); + + connect(ext_mgr_, &ExtensionManager::installError, this, [this](const QString& /*id*/, const QString& error) { + ui_->progress_bar_->setVisible(false); + setStatus("Installation failed: " + error, true); + // Queue advance lives in installFinished only — installError + installFinished both + // fire from emitInstallFailure, so advancing here would double-pop the queue. + }); + + connect(ext_mgr_, &ExtensionManager::uninstallFinished, this, [this](const QString& id, bool success) { + if (success) { + status_error_sticky_ = false; + installations_changed_ = true; + populateCards(); + for (const auto& ext : extensions_) { + if (ext.id == id) { + setStatus("Uninstalled " + ext.name); + break; + } + } + } + // On failure the status was already set by uninstallError — do not overwrite it. }); - connect(ext_mgr_, &ExtensionManager::installProgress, this, - [this](const QString& /*id*/, int percent) { - ui_->progress_bar_->setValue(percent); - }); - - connect(ext_mgr_, &ExtensionManager::installFinished, this, - [this](const QString& id, bool success) { - ui_->progress_bar_->setVisible(false); - if (success) installations_changed_ = true; - populateCards(); - if (success) { - for (const auto& ext : extensions_) - if (ext.id == id) { - setStatus("Installed " + ext.name + " v" + ext.version); - break; - } - } - // On failure the status was already set by installError — do not overwrite it. - processInstallQueue(); - }); - - connect(ext_mgr_, &ExtensionManager::installError, this, - [this](const QString& /*id*/, const QString& error) { - ui_->progress_bar_->setVisible(false); - setStatus("Installation failed: " + error, true); - processInstallQueue(); - }); - - connect(ext_mgr_, &ExtensionManager::uninstallFinished, this, - [this](const QString& id, bool success) { - if (success) { - installations_changed_ = true; - populateCards(); - for (const auto& ext : extensions_) - if (ext.id == id) { setStatus("Uninstalled " + ext.name); break; } - } - // On failure the status was already set by uninstallError — do not overwrite it. - }); - - connect(ext_mgr_, &ExtensionManager::uninstallError, this, - [this](const QString& /*id*/, const QString& error) { - setStatus("Uninstall failed: " + error, true); - }); + connect(ext_mgr_, &ExtensionManager::uninstallError, this, [this](const QString& /*id*/, const QString& error) { + setStatus("Uninstall failed: " + error, true); + }); + + connect( + ext_mgr_, &ExtensionManager::diagnosticReported, this, + [this](const QString& /*id*/, const QString& message, bool is_error) { + updateDiagnosticsButton(); + if (is_error) { + setStatus("Marketplace diagnostic: " + message, true); + } + }); } // ─── Cards Population ───────────────────────────────────────────────────────── void MarketplaceWindow::populateCards() { - while (ui_->cards_layout_->count() > 1) + while (ui_->cards_layout_->count() > 1) { delete ui_->cards_layout_->takeAt(0)->widget(); + } + const auto installed = ext_mgr_->installedExtensions(); + bool has_updatable = false; for (const Extension& ext : filtered_) { const QString ext_id = ext.id; @@ -216,11 +281,20 @@ void MarketplaceWindow::populateCards() { f.setBold(true); name_lbl->setFont(f); + const bool has_update = ext_mgr_->hasUpdate(ext); + const bool has_newer_local = ext_mgr_->hasNewerInstalledVersion(ext); + if (has_update) { + has_updatable = true; + } + QString version_text = ext.version; - if (ext_mgr_->hasUpdate(ext)) { - const auto installed = ext_mgr_->installedExtensions(); - if (installed.contains(ext.id)) - version_text = installed[ext.id].version + " \u2192 " + ext.version; + if (installed.contains(ext.id)) { + version_text = installed[ext.id].version; + if (has_update) { + version_text += " \u2192 " + ext.version; + } else if (has_newer_local) { + version_text += " \u2191 " + ext.version; + } } auto* version_lbl = new QLabel(version_text, card); version_lbl->setStyleSheet("color: palette(text);"); @@ -228,8 +302,7 @@ void MarketplaceWindow::populateCards() { auto* btn_box = new QHBoxLayout(); btn_box->setSpacing(6); - if (pending_restart_ids_.contains(ext.id) || ext_mgr_->hasPendingInstall(ext.id) || - ext_mgr_->hasPendingUninstall(ext.id)) { + if (ext_mgr_->hasPendingInstall(ext.id) || ext_mgr_->hasPendingUninstall(ext.id)) { auto* badge = new QPushButton("Needs Restart", card); badge->setFixedWidth(90); badge->setEnabled(false); @@ -237,17 +310,24 @@ void MarketplaceWindow::populateCards() { "QPushButton:disabled { background:#e6a817; color:white; border:none;" " border-radius:4px; padding:4px 0px; font-weight:bold; }"); btn_box->addWidget(badge); - } else if (ext_mgr_->hasUpdate(ext)) { + } else if (has_update) { auto* btn = new QPushButton("Update \u2B06", card); btn->setFixedWidth(90); btn->setStyleSheet( "QPushButton { background:#e6a817; color:white; border:none;" " border-radius:4px; padding:4px 0px; font-weight:bold; }" "QPushButton:hover { background:#f0b820; }"); - connect(btn, &QPushButton::clicked, this, - [this, ext_id]() { onActionButtonClicked(ext_id); }); + connect(btn, &QPushButton::clicked, this, [this, ext_id]() { onActionButtonClicked(ext_id); }); btn_box->addWidget(btn); - } else if (ext_mgr_->isInstalled(ext.id)) { + } else if (has_newer_local) { + auto* badge = new QPushButton("Local newer", card); + badge->setFixedWidth(90); + badge->setEnabled(false); + badge->setStyleSheet( + "QPushButton:disabled { background:#607d8b; color:white; border:none;" + " border-radius:4px; padding:4px 0px; font-weight:bold; }"); + btn_box->addWidget(badge); + } else if (installed.contains(ext.id)) { auto* badge = new QPushButton("Installed", card); badge->setFixedWidth(90); badge->setEnabled(false); @@ -262,8 +342,7 @@ void MarketplaceWindow::populateCards() { "QPushButton { background:#2196f3; color:white; border:none;" " border-radius:4px; padding:4px 0px; font-weight:bold; }" "QPushButton:hover { background:#42a5f5; }"); - connect(btn, &QPushButton::clicked, this, - [this, ext_id]() { onActionButtonClicked(ext_id); }); + connect(btn, &QPushButton::clicked, this, [this, ext_id]() { onActionButtonClicked(ext_id); }); btn_box->addWidget(btn); } @@ -285,13 +364,6 @@ void MarketplaceWindow::populateCards() { ui_->cards_layout_->insertWidget(ui_->cards_layout_->count() - 1, card); } - bool has_updatable = false; - for (const auto& ext : filtered_) { - if (ext_mgr_->hasUpdate(ext)) { - has_updatable = true; - break; - } - } ui_->update_all_btn_->setEnabled(has_updatable && update_queue_.isEmpty()); } @@ -300,7 +372,9 @@ void MarketplaceWindow::populateCards() { bool MarketplaceWindow::eventFilter(QObject* obj, QEvent* event) { if (event->type() == QEvent::MouseButtonDblClick) { const QString ext_id = static_cast(obj)->property("ext_id").toString(); - if (!ext_id.isEmpty()) openDetail(ext_id); + if (!ext_id.isEmpty()) { + openDetail(ext_id); + } return true; } return QDialog::eventFilter(obj, event); @@ -308,15 +382,15 @@ bool MarketplaceWindow::eventFilter(QObject* obj, QEvent* event) { void MarketplaceWindow::openDetail(const QString& ext_id) { for (const auto& ext : filtered_) { - if (ext.id != ext_id) continue; + if (ext.id != ext_id) { + continue; + } const auto installed = ext_mgr_->installedExtensions(); - const QString installed_version = - installed.contains(ext_id) ? installed[ext_id].version : QString{}; + const QString installed_version = installed.contains(ext_id) ? installed[ext_id].version : QString{}; ExtensionDetailDialog dlg(ext, installed_version, this); - connect(&dlg, &ExtensionDetailDialog::installRequested, this, - [this, ext_id]() { onActionButtonClicked(ext_id); }); - connect(&dlg, &ExtensionDetailDialog::uninstallRequested, this, - [this, ext_id]() { onUninstallButtonClicked(ext_id); }); + connect(&dlg, &ExtensionDetailDialog::installRequested, this, [this, ext_id]() { onActionButtonClicked(ext_id); }); + connect( + &dlg, &ExtensionDetailDialog::uninstallRequested, this, [this, ext_id]() { onUninstallButtonClicked(ext_id); }); dlg.exec(); return; } @@ -325,43 +399,103 @@ void MarketplaceWindow::openDetail(const QString& ext_id) { // ─── Filtering ──────────────────────────────────────────────────────────────── void MarketplaceWindow::applyFilters() { - const QString search = ui_->search_edit_->text().toLower(); + const QString search = ui_->search_edit_->text().toLower(); const QString category = ui_->category_combo_->currentData().toString(); filtered_.clear(); for (const auto& ext : extensions_) { - if (!category.isEmpty() && ext.category != category) continue; + if (!category.isEmpty() && ext.category != category) { + continue; + } if (!search.isEmpty()) { - bool match = ext.name.toLower().contains(search) || - ext.description.toLower().contains(search); - if (!match) - for (const auto& tag : ext.tags) - if (tag.toLower().contains(search)) { match = true; break; } - if (!match) continue; + bool match = ext.name.toLower().contains(search) || ext.description.toLower().contains(search); + if (!match) { + for (const auto& tag : ext.tags) { + if (tag.toLower().contains(search)) { + match = true; + break; + } + } + } + if (!match) { + continue; + } } filtered_.append(ext); } populateCards(); - setStatus(QString::number(filtered_.size()) + " of " + - QString::number(extensions_.size()) + " extensions shown"); + setStatus(QString::number(filtered_.size()) + " of " + QString::number(extensions_.size()) + " extensions shown"); } void MarketplaceWindow::setStatus(const QString& msg, bool is_error) { + if (!is_error && status_error_sticky_) { + return; + } + status_error_sticky_ = is_error; ui_->status_label_->setText(msg); ui_->status_label_->setStyleSheet(is_error ? "color: #d32f2f; font-weight: bold;" : ""); } +void MarketplaceWindow::clearStickyStatus() { + status_error_sticky_ = false; +} + +void MarketplaceWindow::showLatestDiagnostic() { + const QList diagnostics = ext_mgr_->diagnostics(); + if (diagnostics.isEmpty()) { + return; + } + const ExtensionDiagnostic& diagnostic = diagnostics.back(); + setStatus("Marketplace diagnostic: " + diagnostic.message, diagnostic.is_error); +} + +void MarketplaceWindow::updateDiagnosticsButton() { + const int count = ext_mgr_->diagnostics().size(); + ui_->diagnostics_btn_->setVisible(count > 0); + ui_->diagnostics_btn_->setText(count > 1 ? QString("Details (%1)").arg(count) : "Details"); +} + // ─── Slots ──────────────────────────────────────────────────────────────────── -void MarketplaceWindow::onSearchChanged(const QString& /*text*/) { applyFilters(); } -void MarketplaceWindow::onCategoryChanged(int /*index*/) { applyFilters(); } +void MarketplaceWindow::onSearchChanged(const QString& /*text*/) { + applyFilters(); +} +void MarketplaceWindow::onCategoryChanged(int /*index*/) { + applyFilters(); +} void MarketplaceWindow::onRefreshClicked() { + clearStickyStatus(); setStatus("Refreshing..."); + const auto before = ext_mgr_->installedExtensions(); + ext_mgr_->refreshInstalledFromDisk(); + if (!installedStatesEqual(ext_mgr_->installedExtensions(), before)) { + installations_changed_ = true; + } + populateCards(); registry_mgr_->fetchRegistry(registry_url_); } +void MarketplaceWindow::showEvent(QShowEvent* event) { + if (ext_mgr_ != nullptr) { + if (initial_snapshot_provided_) { + initial_snapshot_provided_ = false; + populateCards(); + } else { + const auto before = ext_mgr_->installedExtensions(); + ext_mgr_->refreshInstalledFromDisk(); + if (!installedStatesEqual(ext_mgr_->installedExtensions(), before)) { + installations_changed_ = true; + populateCards(); + } + } + updateDiagnosticsButton(); + showLatestDiagnostic(); + } + QDialog::showEvent(event); +} + void MarketplaceWindow::onSettingsClicked() { QDialog dlg(this); dlg.setWindowTitle("Marketplace Settings"); @@ -372,7 +506,7 @@ void MarketplaceWindow::onSettingsClicked() { url_edit->setPlaceholderText(kDefaultRegistryUrl); layout->addRow("Registry URL:", url_edit); - auto* extensions_path = new QLineEdit(PlatformUtils::extensionsDir(), &dlg); + auto* extensions_path = new QLineEdit(ext_mgr_->extensionsDir(), &dlg); extensions_path->setReadOnly(true); extensions_path->setStyleSheet("QLineEdit { background: palette(window); }"); layout->addRow("Extensions path:", extensions_path); @@ -387,11 +521,20 @@ void MarketplaceWindow::onSettingsClicked() { return; } - const QUrl new_url(url_edit->text().trimmed()); + const QString text = url_edit->text().trimmed(); + const QUrl new_url(text); + if (text.isEmpty() || !new_url.isValid() || (new_url.scheme() != "http" && new_url.scheme() != "https" + && new_url.scheme() != "file")) { + QMessageBox::warning( + this, "Invalid registry URL", + QString("\"%1\" is not a valid http(s) or file URL. The registry URL was not changed.").arg(text)); + return; + } if (new_url == registry_url_) { return; } + clearStickyStatus(); registry_url_ = new_url; QSettings("PlotJuggler", "Marketplace").setValue("registry_url", registry_url_.toString()); @@ -401,33 +544,71 @@ void MarketplaceWindow::onSettingsClicked() { void MarketplaceWindow::onActionButtonClicked(const QString& ext_id) { for (const auto& ext : filtered_) { - if (ext.id != ext_id) continue; - if (ext_mgr_->hasUpdate(ext)) + if (ext.id != ext_id) { + continue; + } + clearStickyStatus(); + if (ext_mgr_->hasUpdate(ext)) { ext_mgr_->update(ext); - else if (!ext_mgr_->isInstalled(ext.id)) + } else if (ext_mgr_->hasNewerInstalledVersion(ext)) { + setStatus("Installed version is newer than registry version", true); + } else if (!ext_mgr_->isInstalled(ext.id)) { ext_mgr_->install(ext); + } return; } } void MarketplaceWindow::onUninstallButtonClicked(const QString& ext_id) { + clearStickyStatus(); ext_mgr_->uninstall(ext_id); } void MarketplaceWindow::onUpdateAllClicked() { + clearStickyStatus(); update_queue_.clear(); for (const auto& ext : filtered_) { - if (ext_mgr_->hasUpdate(ext)) + if (ext_mgr_->hasUpdate(ext)) { update_queue_.append(ext); + } + } + if (update_queue_.isEmpty()) { + return; } - if (update_queue_.isEmpty()) return; ui_->update_all_btn_->setEnabled(false); setStatus("Updating " + QString::number(update_queue_.size()) + " extensions..."); processInstallQueue(); } +void MarketplaceWindow::onDiagnosticsClicked() { + QDialog dlg(this); + dlg.setWindowTitle("Marketplace Diagnostics"); + dlg.resize(640, 360); + + auto* layout = new QVBoxLayout(&dlg); + auto* text = new QPlainTextEdit(&dlg); + text->setReadOnly(true); + + QStringList lines; + for (const ExtensionDiagnostic& diagnostic : ext_mgr_->diagnostics()) { + const QString level = diagnostic.is_error ? "ERROR" : "INFO"; + const QString id = diagnostic.id.isEmpty() ? "-" : diagnostic.id; + lines.append(QString("[%1] %2 %3: %4") + .arg(diagnostic.timestamp.toLocalTime().toString(Qt::ISODate), level, id, diagnostic.message)); + } + text->setPlainText(lines.isEmpty() ? "No diagnostics." : lines.join('\n')); + layout->addWidget(text); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dlg); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.exec(); +} + void MarketplaceWindow::processInstallQueue() { - if (update_queue_.isEmpty()) return; + if (update_queue_.isEmpty()) { + return; + } ext_mgr_->update(update_queue_.takeFirst()); } diff --git a/pj_marketplace/src/ui/marketplace_window.ui b/pj_marketplace/src/ui/marketplace_window.ui index 19de8c5..f9b815b 100644 --- a/pj_marketplace/src/ui/marketplace_window.ui +++ b/pj_marketplace/src/ui/marketplace_window.ui @@ -72,7 +72,7 @@ QScrollArea > QWidget > QWidget { background: palette(mid); } 6 0 0 - 0 + 6 0 @@ -98,10 +98,20 @@ QScrollArea > QWidget > QWidget { background: palette(mid); } 0 - - - - + + + + + Details + Show marketplace diagnostics + false + + 8016777215 + + + + + false 100 0 diff --git a/pj_marketplace/tests/extension_manager_check_plugin_management.cpp b/pj_marketplace/tests/extension_manager_check_plugin_management.cpp index 9ebdcef..6dd2efe 100644 --- a/pj_marketplace/tests/extension_manager_check_plugin_management.cpp +++ b/pj_marketplace/tests/extension_manager_check_plugin_management.cpp @@ -16,9 +16,9 @@ #include #include "pj_marketplace/download_manager.hpp" +#include "pj_marketplace/extension.hpp" #include "pj_marketplace/extension_manager.hpp" #include "pj_marketplace/registry_manager.hpp" -#include "pj_marketplace/extension.hpp" namespace PJ { namespace { @@ -43,7 +43,8 @@ TEST(ExtensionManagerIntegrationTest, InstallCanBusParserUsingRegistry) { QSignalSpy registry_finished(®istry, &RegistryManager::fetchFinished); QSignalSpy registry_error(®istry, &RegistryManager::fetchError); - registry.fetchRegistry(QUrl("https://raw.githubusercontent.com/Intelligent-Behavior-Robots/pj-plugin-registry/main/registry.json")); + registry.fetchRegistry( + QUrl("https://raw.githubusercontent.com/Intelligent-Behavior-Robots/pj-plugin-registry/main/registry.json")); ASSERT_TRUE(waitForSignal(registry_finished, 5000)) << "RegistryManager did not finish parsing"; ASSERT_TRUE(registry_finished.first().at(0).toBool()) @@ -60,7 +61,7 @@ TEST(ExtensionManagerIntegrationTest, InstallCanBusParserUsingRegistry) { // 3. Prepare destination directories // --------------------------------------------------------------------------- const QString ext_dir = QStringLiteral(RESULTS_DIR) + "/extensions"; - const QString pending_dir = QStringLiteral(RESULTS_DIR) + "/.pending"; + const QString pending_dir = QStringLiteral(RESULTS_DIR) + "/.extension_staging"; ASSERT_TRUE(QDir().mkpath(ext_dir)) << "Could not create extensions directory: " << ext_dir.toStdString(); ASSERT_TRUE(QDir().mkpath(pending_dir)) << "Could not create pending directory: " << pending_dir.toStdString(); @@ -87,8 +88,7 @@ TEST(ExtensionManagerIntegrationTest, InstallCanBusParserUsingRegistry) { << ext.platforms.value(QStringLiteral("linux-x86_64")).url.toStdString(); EXPECT_TRUE(spy_finished.first().at(1).toBool()) - << "Install failed: " - << (spy_error.isEmpty() ? "" : spy_error.first().at(1).toString().toStdString()); + << "Install failed: " << (spy_error.isEmpty() ? "" : spy_error.first().at(1).toString().toStdString()); EXPECT_TRUE(spy_error.isEmpty()) << "Unexpected installError: " << (spy_error.isEmpty() ? "" : spy_error.first().at(1).toString().toStdString()); diff --git a/pj_marketplace/tests/extension_manager_test.cpp b/pj_marketplace/tests/extension_manager_test.cpp index bf5145b..095219b 100644 --- a/pj_marketplace/tests/extension_manager_test.cpp +++ b/pj_marketplace/tests/extension_manager_test.cpp @@ -11,6 +11,10 @@ // [8] Platform detection: currentPlatform() format and registry key resolution // [9] applyPendingUninstalls: deferred directory cleanup via marker file +#include "pj_marketplace/extension_manager.hpp" + +#include +#include #include #include @@ -20,8 +24,6 @@ #include #include #include -#include -#include #include #include #include @@ -29,13 +31,9 @@ #include #include -#include -#include - #include "pj_marketplace/download_manager.hpp" -#include "pj_marketplace/extension_manager.hpp" -#include "pj_marketplace/platform_utils.hpp" #include "pj_marketplace/extension.hpp" +#include "pj_marketplace/platform_utils.hpp" namespace PJ { namespace { @@ -53,6 +51,14 @@ bool waitForSignal(QSignalSpy& spy, int timeout_ms = 5000) { return !spy.isEmpty(); } +bool waitForInstallOutcome(QSignalSpy& finished, QSignalSpy& pending_restart, int timeout_ms = 5000) { + QDeadlineTimer deadline(timeout_ms); + while (finished.isEmpty() && pending_restart.isEmpty() && !deadline.hasExpired()) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + } + return !finished.isEmpty() || !pending_restart.isEmpty(); +} + // Minimal HTTP/1.1 server that answers every request with a fixed in-memory body. // Binds to a random loopback port; no external network required. class LocalHttpServer { @@ -64,12 +70,13 @@ class LocalHttpServer { socket->setParent(&server_); QObject::connect(socket, &QTcpSocket::readyRead, [this, socket]() { socket->readAll(); // discard the HTTP request — content is irrelevant for tests - const QByteArray header = "HTTP/1.1 200 OK\r\n" - "Content-Type: application/octet-stream\r\n" - "Content-Length: " + - QByteArray::number(body_.size()) + - "\r\n" - "Connection: close\r\n\r\n"; + const QByteArray header = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Length: " + + QByteArray::number(body_.size()) + + "\r\n" + "Connection: close\r\n\r\n"; socket->write(header + body_); socket->flush(); socket->disconnectFromHost(); @@ -81,7 +88,9 @@ class LocalHttpServer { return QUrl(QStringLiteral("http://127.0.0.1:%1/").arg(server_.serverPort())); } - void setBody(const QByteArray& body) { body_ = body; } + void setBody(const QByteArray& body) { + body_ = body; + } private: QTcpServer server_; @@ -115,23 +124,83 @@ QByteArray buildZip(const QMap& files) { return QByteArray(buf.data(), static_cast(used)); } -// Returns a minimal ZIP that mimics a real artifact: an / root directory containing -// manifest.json (with id and version) and a placeholder binary. The / prefix matches -// the CI packaging convention, so extraction to extensions_dir produces the correct -// extensions_dir//manifest.json layout that loadState() expects. +QByteArray readAll(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +QString pluginPathForId(const QString& ext_id, const QString& version = "1.0.0") { + if (ext_id == "mock-data-source" && version == "2.0.0") { + return QStringLiteral(PJ_MOCK_DATA_SOURCE_V2_PLUGIN_PATH); + } + if (ext_id == "mock-data-source") { + return QStringLiteral(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH); + } + if (ext_id == "mock-file-source") { + return QStringLiteral(PJ_MOCK_FILE_SOURCE_PLUGIN_PATH); + } + if (ext_id == "missing-id-source") { + return QStringLiteral(PJ_MISSING_ID_PLUGIN_PATH); + } + return {}; +} + +QString pluginFileName() { + return "plugin" + QString::fromStdString(PlatformUtils::pluginExtension()); +} + +bool writePendingIntentForTest(const QString& staged_dir, const QString& id, const QString& version = "1.0.0") { + QFile intent(QDir(staged_dir).absoluteFilePath(".pj_pending_install")); + if (!intent.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + const QByteArray data = id.toUtf8() + '\n' + version.toUtf8() + '\n'; + return intent.write(data) == data.size(); +} + +bool copyFixturePlugin(const QString& dst_dir, const QString& fixture_id, const QString& version = "1.0.0") { + QDir().mkpath(dst_dir); + QFile::remove(QDir(dst_dir).absoluteFilePath(pluginFileName())); + return QFile::copy(pluginPathForId(fixture_id, version), QDir(dst_dir).absoluteFilePath(pluginFileName())); +} + +bool directoryHasNoChildren(const QString& path) { + const QFileInfoList entries = + QDir(path).entryInfoList(QDir::Dirs | QDir::Files | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot); + return entries.isEmpty(); +} + +QByteArray pluginZipWithDso(const QString& ext_id, const QString& fixture_id, const QString& version = "1.0.0") { + const QByteArray plugin = readAll(pluginPathForId(fixture_id, version)); + if (plugin.isEmpty()) { + return buildZip({{ext_id + "/not-a-plugin.txt", "missing fixture plugin"}}); + } + return buildZip({ + {ext_id + "/" + pluginFileName(), plugin}, + }); +} + +// Returns a ZIP with an / root directory and a real plugin DSO whose +// embedded manifest is the installed-state source of truth. There is no local +// metadata sidecar. QByteArray dummyPluginZip(const QString& ext_id, const QString& version = "1.0.0") { - const QByteArray manifest = - QJsonDocument(QJsonObject{{"id", ext_id}, {"version", version}}).toJson(); + return pluginZipWithDso(ext_id, ext_id, version); +} + +QByteArray pluginZipWithTwoDsos(const QString& ext_id, const QString& first_id, const QString& second_id) { + const QString suffix = QString::fromStdString(PlatformUtils::pluginExtension()); return buildZip({ - {ext_id + "/" + ext_id + ".plugin", "placeholder binary content"}, - {ext_id + "/manifest.json", manifest}, + {ext_id + "/first" + suffix, readAll(pluginPathForId(first_id))}, + {ext_id + "/second" + suffix, readAll(pluginPathForId(second_id))}, }); } // Builds an Extension whose download artifact for the current platform points to `url`. // Checksum is empty by default so DownloadManager skips SHA-256 verification. -Extension makeExtension(const QString& id, const QString& version, const QUrl& url, - const QString& checksum = {}) { +Extension makeExtension(const QString& id, const QString& version, const QUrl& url, const QString& checksum = {}) { Extension ext; ext.id = id; ext.name = id; @@ -151,6 +220,12 @@ Extension makeExtension(const QString& id, const QString& version, const QUrl& u class ExtensionManagerTest : public ::testing::Test { protected: void SetUp() override { + // Several tests place their own QTemporaryDir under PlatformUtils::configDir() + // (next to backupDir()) so QDir::rename() into the backup is an atomic same-fs + // move. QTemporaryDir refuses to create itself when the parent does not exist, + // which is exactly the state of a fresh CI runner. Pre-create configDir() here + // so those constructions succeed regardless of host history. + ASSERT_TRUE(QDir().mkpath(PlatformUtils::configDir())); ASSERT_TRUE(ext_dir_.isValid()); ASSERT_TRUE(pending_dir_.isValid()); downloader_ = new DownloadManager(); @@ -176,8 +251,8 @@ class ExtensionManagerTest : public ::testing::Test { // A fresh install downloads the ZIP, extracts it, registers the extension, and // emits the correct signal sequence: installStarted → installFinished(id, true). TEST_F(ExtensionManagerTest, InstallDirectRegistersExtension) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_started(mgr_, &ExtensionManager::installStarted); QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); @@ -187,36 +262,36 @@ TEST_F(ExtensionManagerTest, InstallDirectRegistersExtension) { // installStarted must be synchronous — no event loop needed. ASSERT_EQ(spy_started.count(), 1); - EXPECT_EQ(spy_started.first().at(0).toString(), "csv-loader"); + EXPECT_EQ(spy_started.first().at(0).toString(), "mock-data-source"); ASSERT_TRUE(waitForSignal(spy_finished)) << "installFinished not received within 5 s"; ASSERT_EQ(spy_finished.count(), 1); - EXPECT_EQ(spy_finished.first().at(0).toString(), "csv-loader"); + EXPECT_EQ(spy_finished.first().at(0).toString(), "mock-data-source"); EXPECT_TRUE(spy_finished.first().at(1).toBool()) << "install must succeed"; EXPECT_TRUE(spy_error.isEmpty()); - EXPECT_TRUE(mgr_->isInstalled("csv-loader")); - EXPECT_EQ(mgr_->installedExtensions()["csv-loader"].version, "1.0.0"); + EXPECT_TRUE(mgr_->isInstalled("mock-data-source")); + EXPECT_EQ(mgr_->installedExtensions()["mock-data-source"].version, "1.0.0"); } // The extracted content lands under extensions_dir// after a successful install. TEST_F(ExtensionManagerTest, InstallCreatesExtensionDirectory) { - server_.setBody(dummyPluginZip("can-bus-parser")); - const Extension ext = makeExtension("can-bus-parser", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-file-source")); + const Extension ext = makeExtension("mock-file-source", "1.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext); ASSERT_TRUE(waitForSignal(spy)); ASSERT_TRUE(spy.first().at(1).toBool()); - EXPECT_TRUE(QDir(ext_dir_.path() + "/can-bus-parser").exists()); + EXPECT_TRUE(QDir(ext_dir_.path() + "/mock-file-source").exists()); } // installProgress signals are forwarded during the download phase. // Each signal must carry the correct extension id and a percent in [0, 100]. TEST_F(ExtensionManagerTest, InstallEmitsProgressSignals) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_progress(mgr_, &ExtensionManager::installProgress); QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); @@ -226,7 +301,7 @@ TEST_F(ExtensionManagerTest, InstallEmitsProgressSignals) { EXPECT_GE(spy_progress.count(), 1); for (const QList& args : spy_progress) { - EXPECT_EQ(args.at(0).toString(), "csv-loader"); + EXPECT_EQ(args.at(0).toString(), "mock-data-source"); const int pct = args.at(1).toInt(); EXPECT_GE(pct, 0); EXPECT_LE(pct, 100); @@ -240,8 +315,8 @@ TEST_F(ExtensionManagerTest, InstallEmitsProgressSignals) { // Calling install() for an extension that is already installed must emit installError // immediately — it must not start a new download. TEST_F(ExtensionManagerTest, InstallRejectsAlreadyInstalledExtension) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); // First install — must succeed. QSignalSpy spy_first(mgr_, &ExtensionManager::installFinished); @@ -253,7 +328,7 @@ TEST_F(ExtensionManagerTest, InstallRejectsAlreadyInstalledExtension) { QSignalSpy spy_error(mgr_, &ExtensionManager::installError); mgr_->install(ext); ASSERT_EQ(spy_error.count(), 1); - EXPECT_EQ(spy_error.first().at(0).toString(), "csv-loader"); + EXPECT_EQ(spy_error.first().at(0).toString(), "mock-data-source"); EXPECT_FALSE(spy_error.first().at(1).toString().isEmpty()); } @@ -264,11 +339,10 @@ TEST_F(ExtensionManagerTest, InstallBlocksConcurrentRequests) { // download pending indefinitely without burning CPU or requiring a timeout. QTcpServer hanging_server; hanging_server.listen(QHostAddress::LocalHost, 0); - const QUrl hanging_url = - QUrl(QStringLiteral("http://127.0.0.1:%1/").arg(hanging_server.serverPort())); + const QUrl hanging_url = QUrl(QStringLiteral("http://127.0.0.1:%1/").arg(hanging_server.serverPort())); - const Extension ext_a = makeExtension("csv-loader", "1.0.0", hanging_url); - const Extension ext_b = makeExtension("can-bus-parser", "1.0.0", hanging_url); + const Extension ext_a = makeExtension("mock-data-source", "1.0.0", hanging_url); + const Extension ext_b = makeExtension("mock-file-source", "1.0.0", hanging_url); QSignalSpy spy_error(mgr_, &ExtensionManager::installError); @@ -277,7 +351,7 @@ TEST_F(ExtensionManagerTest, InstallBlocksConcurrentRequests) { mgr_->install(ext_b); // must be rejected immediately; pending_id_ is already set ASSERT_EQ(spy_error.count(), 1); - EXPECT_EQ(spy_error.first().at(0).toString(), "can-bus-parser"); + EXPECT_EQ(spy_error.first().at(0).toString(), "mock-file-source"); EXPECT_FALSE(spy_error.first().at(1).toString().isEmpty()); } @@ -302,31 +376,140 @@ TEST_F(ExtensionManagerTest, InstallRejectsUnsupportedPlatform) { EXPECT_EQ(spy_started.count(), 0) << "installStarted must not fire when the platform is unsupported"; } +TEST_F(ExtensionManagerTest, InstallRejectsEmbeddedIdMismatch) { + server_.setBody(pluginZipWithDso("registry-id", "mock-data-source")); + const Extension ext = makeExtension("registry-id", "1.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("Embedded plugin id")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/registry-id").exists()); + EXPECT_FALSE(mgr_->isInstalled("registry-id")); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); +} + +TEST_F(ExtensionManagerTest, InstallRejectsEmbeddedVersionMismatch) { + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "2.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("Embedded plugin version")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); +} + +TEST_F(ExtensionManagerTest, InstallRejectsManifestMissingId) { + server_.setBody(dummyPluginZip("missing-id-source")); + const Extension ext = makeExtension("missing-id-source", "1.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("not a valid plugin")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/missing-id-source").exists()); +} + +TEST_F(ExtensionManagerTest, InstallRejectsExtensionDirectoryWithConflictingEmbeddedIds) { + server_.setBody(pluginZipWithTwoDsos("mock-data-source", "mock-data-source", "mock-file-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("multiple embedded plugin ids")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/mock-data-source").exists()); +} + +TEST_F(ExtensionManagerTest, InstallRejectsWrongTopLevelDirectoryWithoutLeavingStrays) { + server_.setBody(pluginZipWithDso("wrong-root", "mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("top-level directory")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/wrong-root").exists()); + EXPECT_FALSE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + EXPECT_TRUE(directoryHasNoChildren(ext_dir_.path())); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); +} + +TEST_F(ExtensionManagerTest, InstallRejectsExtraTopLevelDirectoryWithoutLeavingStrays) { + server_.setBody(buildZip({ + {"mock-data-source/" + pluginFileName(), readAll(pluginPathForId("mock-data-source"))}, + {"unrelated-extension/" + pluginFileName(), readAll(pluginPathForId("mock-file-source"))}, + })); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->install(ext); + + ASSERT_TRUE(waitForSignal(spy_finished)); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("top-level directory")); + EXPECT_FALSE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + EXPECT_FALSE(QDir(ext_dir_.path() + "/unrelated-extension").exists()); + EXPECT_TRUE(directoryHasNoChildren(ext_dir_.path())); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); + EXPECT_FALSE(mgr_->isInstalled("unrelated-extension")); +} + // --------------------------------------------------------------------------- // [3] Uninstall // --------------------------------------------------------------------------- // A successful uninstall removes the extension directory and clears it from memory. TEST_F(ExtensionManagerTest, UninstallRemovesDirectoryAndState) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_install(mgr_, &ExtensionManager::installFinished); mgr_->install(ext); ASSERT_TRUE(waitForSignal(spy_install)); ASSERT_TRUE(spy_install.first().at(1).toBool()); - const QString ext_path = ext_dir_.path() + "/csv-loader"; + const QString ext_path = ext_dir_.path() + "/mock-data-source"; ASSERT_TRUE(QDir(ext_path).exists()); QSignalSpy spy_uninstall(mgr_, &ExtensionManager::uninstallFinished); - mgr_->uninstall("csv-loader"); + mgr_->uninstall("mock-data-source"); ASSERT_EQ(spy_uninstall.count(), 1); - EXPECT_EQ(spy_uninstall.first().at(0).toString(), "csv-loader"); + EXPECT_EQ(spy_uninstall.first().at(0).toString(), "mock-data-source"); EXPECT_TRUE(spy_uninstall.first().at(1).toBool()); - EXPECT_FALSE(mgr_->isInstalled("csv-loader")); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); EXPECT_FALSE(QDir(ext_path).exists()); } @@ -354,35 +537,42 @@ TEST_F(ExtensionManagerTest, UninstallUnknownExtensionEmitsError) { // The new version is registered with the correct version string after completion. TEST_F(ExtensionManagerTest, UpdateReinstallsWithNewVersion) { // Ensure clean backup state before test (in case previous run failed mid-test). - QDir(PlatformUtils::backupDir() + "/csv-loader-1.0.0").removeRecursively(); + QDir(PlatformUtils::backupDir() + "/mock-data-source-1.0.0").removeRecursively(); - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext_v1 = makeExtension("csv-loader", "1.0.0", server_.url()); + QTemporaryDir local_ext_dir(QDir(PlatformUtils::backupDir()).absoluteFilePath("../test_ext_XXXXXX")); + ASSERT_TRUE(local_ext_dir.isValid()); - QSignalSpy spy_install(mgr_, &ExtensionManager::installFinished); - mgr_->install(ext_v1); + DownloadManager local_dl; + ExtensionManager local_mgr(&local_dl, local_ext_dir.path(), pending_dir_.path()); + + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext_v1 = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_install(&local_mgr, &ExtensionManager::installFinished); + local_mgr.install(ext_v1); ASSERT_TRUE(waitForSignal(spy_install)); ASSERT_TRUE(spy_install.first().at(1).toBool()); // Prepare a "new version" and trigger the update. spy_install.clear(); - server_.setBody(dummyPluginZip("csv-loader", "2.0.0")); - const Extension ext_v2 = makeExtension("csv-loader", "2.0.0", server_.url()); - mgr_->update(ext_v2); + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); + local_mgr.update(ext_v2); ASSERT_TRUE(waitForSignal(spy_install)); EXPECT_TRUE(spy_install.first().at(1).toBool()); - EXPECT_EQ(mgr_->installedExtensions()["csv-loader"].version, "2.0.0"); + EXPECT_EQ(local_mgr.installedExtensions()["mock-data-source"].version, "2.0.0"); + + QDir(PlatformUtils::backupDir() + "/mock-data-source-1.0.0").removeRecursively(); } -// After a successful update the old version directory must exist in backupDir() -// and the new installed record must carry the backup_path. +// After a successful update the old version directory must exist in backupDir(). // // ext_dir is placed under the same filesystem root as backupDir() (~/.plotjuggler/) // so that QDir::rename() can do an atomic move without a cross-device copy. TEST_F(ExtensionManagerTest, UpdateBacksUpOldVersionOnSuccess) { // Ensure clean backup state before test (in case previous run failed mid-test). - QDir(PlatformUtils::backupDir() + "/csv-loader-1.0.0").removeRecursively(); + QDir(PlatformUtils::backupDir() + "/mock-data-source-1.0.0").removeRecursively(); // Place the extension directory on the same filesystem as backupDir(). QTemporaryDir local_ext_dir(QDir(PlatformUtils::backupDir()).absoluteFilePath("../test_ext_XXXXXX")); @@ -391,32 +581,30 @@ TEST_F(ExtensionManagerTest, UpdateBacksUpOldVersionOnSuccess) { DownloadManager local_dl; ExtensionManager local_mgr(&local_dl, local_ext_dir.path(), pending_dir_.path()); - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext_v1 = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext_v1 = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_install(&local_mgr, &ExtensionManager::installFinished); local_mgr.install(ext_v1); ASSERT_TRUE(waitForSignal(spy_install)); ASSERT_TRUE(spy_install.first().at(1).toBool()); - ASSERT_TRUE(QFile::exists(local_ext_dir.path() + "/csv-loader/csv-loader.plugin")); + ASSERT_TRUE(QFile::exists(local_ext_dir.path() + "/mock-data-source/" + pluginFileName())); spy_install.clear(); - server_.setBody(dummyPluginZip("csv-loader", "2.0.0")); - const Extension ext_v2 = makeExtension("csv-loader", "2.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); local_mgr.update(ext_v2); ASSERT_TRUE(waitForSignal(spy_install)); EXPECT_TRUE(spy_install.first().at(1).toBool()) << "update must succeed"; - EXPECT_EQ(local_mgr.installedExtensions()["csv-loader"].version, "2.0.0"); + EXPECT_EQ(local_mgr.installedExtensions()["mock-data-source"].version, "2.0.0"); - const QString backup_path = PlatformUtils::backupDir() + "/csv-loader-1.0.0"; - EXPECT_TRUE(QDir(backup_path).exists()) << "backup directory must exist after update"; - EXPECT_TRUE(QFile::exists(backup_path + "/csv-loader.plugin")) - << "original plugin file must be preserved in backup"; - EXPECT_EQ(local_mgr.installedExtensions()["csv-loader"].backup_path, backup_path); + const QString backup_dir = PlatformUtils::backupDir() + "/mock-data-source-1.0.0"; + EXPECT_TRUE(QDir(backup_dir).exists()) << "backup directory must exist after update"; + EXPECT_TRUE(QFile::exists(backup_dir + "/" + pluginFileName())) << "original plugin file must be preserved in backup"; - QDir(backup_path).removeRecursively(); + QDir(backup_dir).removeRecursively(); } // When the install step fails after the backup, the old version files must still @@ -431,8 +619,8 @@ TEST_F(ExtensionManagerTest, UpdateKeepsBackupWhenInstallFails) { DownloadManager local_dl; ExtensionManager local_mgr(&local_dl, local_ext_dir.path(), pending_dir_.path()); - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext_v1 = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext_v1 = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_install(&local_mgr, &ExtensionManager::installFinished); local_mgr.install(ext_v1); @@ -443,7 +631,7 @@ TEST_F(ExtensionManagerTest, UpdateKeepsBackupWhenInstallFails) { // Serve garbage data — libarchive will fail to extract it and DownloadManager // will emit failed(), which propagates to installFinished(id, false). server_.setBody(QByteArray("not_a_valid_zip")); - const Extension ext_v2 = makeExtension("csv-loader", "2.0.0", server_.url()); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); QSignalSpy spy_error(&local_mgr, &ExtensionManager::installError); local_mgr.update(ext_v2); @@ -452,37 +640,39 @@ TEST_F(ExtensionManagerTest, UpdateKeepsBackupWhenInstallFails) { EXPECT_FALSE(spy_install.first().at(1).toBool()) << "install must have failed"; EXPECT_FALSE(spy_error.isEmpty()) << "installError must be emitted on failure"; - EXPECT_FALSE(local_mgr.isInstalled("csv-loader")); + EXPECT_FALSE(local_mgr.isInstalled("mock-data-source")); - const QString backup_path = PlatformUtils::backupDir() + "/csv-loader-1.0.0"; - EXPECT_TRUE(QDir(backup_path).exists()) - << "backup must survive a failed install — files are recoverable"; - EXPECT_TRUE(QFile::exists(backup_path + "/csv-loader.plugin")) + const QString backup_dir = PlatformUtils::backupDir() + "/mock-data-source-1.0.0"; + EXPECT_TRUE(QDir(backup_dir).exists()) << "backup must survive a failed install — files are recoverable"; + EXPECT_TRUE(QFile::exists(backup_dir + "/" + pluginFileName())) << "original plugin binary must be preserved in backup"; + EXPECT_TRUE(spy_error.first().at(1).toString().contains(backup_dir)); + ASSERT_FALSE(local_mgr.diagnostics().isEmpty()); + EXPECT_TRUE(local_mgr.diagnostics().back().message.contains(backup_dir)); - QDir(backup_path).removeRecursively(); + QDir(backup_dir).removeRecursively(); } // --------------------------------------------------------------------------- // [5] hasUpdate — version comparison // // Extension data below mirrors the registry.json fixture: -// csv-loader v1.0.0 -// can-bus-parser v1.0.0 +// mock-data-source v1.0.0 +// mock-file-source v1.0.0 // --------------------------------------------------------------------------- // Returns false when the extension is not installed. TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseWhenNotInstalled) { Extension ext; - ext.id = "csv-loader"; + ext.id = "mock-data-source"; ext.version = "1.0.0"; EXPECT_FALSE(mgr_->hasUpdate(ext)); } // Returns false when the installed and registry versions are identical. TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseForSameVersion) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext); @@ -493,8 +683,8 @@ TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseForSameVersion) { // Returns true when the registry version is strictly higher than the installed one. TEST_F(ExtensionManagerTest, HasUpdateReturnsTrueForNewerVersion) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext_v1 = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext_v1 = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext_v1); @@ -505,26 +695,40 @@ TEST_F(ExtensionManagerTest, HasUpdateReturnsTrueForNewerVersion) { EXPECT_TRUE(mgr_->hasUpdate(ext_v2)); } -// QVersionNumber must compare multi-segment versions numerically, not lexically: -// "1.10.0" > "1.9.0" — a raw string compare would invert this result. +// QVersionNumber must compare versions numerically, not lexically: +// "10.0.0" > "2.0.0" — a raw string compare would invert this result. TEST_F(ExtensionManagerTest, HasUpdateHandlesMultiSegmentVersionsCorrectly) { - server_.setBody(dummyPluginZip("can-bus-parser", "1.9.0")); - const Extension ext_installed = makeExtension("can-bus-parser", "1.9.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_installed = makeExtension("mock-data-source", "2.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext_installed); ASSERT_TRUE(waitForSignal(spy)); - // "1.10.0" is numerically greater but lexically smaller than "1.9.0". + // "10.0.0" is numerically greater but lexically smaller than "2.0.0". Extension ext_registry = ext_installed; - ext_registry.version = "1.10.0"; + ext_registry.version = "10.0.0"; EXPECT_TRUE(mgr_->hasUpdate(ext_registry)); } +TEST_F(ExtensionManagerTest, HasNewerInstalledVersionReturnsTrueWhenLocalVersionIsAhead) { + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); + + QSignalSpy spy(mgr_, &ExtensionManager::installFinished); + mgr_->install(ext_v2); + ASSERT_TRUE(waitForSignal(spy)); + + Extension ext_v1 = ext_v2; + ext_v1.version = "1.0.0"; + EXPECT_TRUE(mgr_->hasNewerInstalledVersion(ext_v1)); + EXPECT_FALSE(mgr_->hasUpdate(ext_v1)); +} + // Returns false when the registry version is older than the installed one (downgrade scenario). TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseForOlderVersion) { - server_.setBody(dummyPluginZip("csv-loader", "2.0.0")); - const Extension ext_v2 = makeExtension("csv-loader", "2.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext_v2); @@ -535,63 +739,201 @@ TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseForOlderVersion) { EXPECT_FALSE(mgr_->hasUpdate(ext_v1)); } +TEST_F(ExtensionManagerTest, UpdateRejectsDowngradeWhenInstalledVersionIsNewer) { + server_.setBody(dummyPluginZip("mock-data-source", "2.0.0")); + const Extension ext_v2 = makeExtension("mock-data-source", "2.0.0", server_.url()); + + QSignalSpy spy_install(mgr_, &ExtensionManager::installFinished); + mgr_->install(ext_v2); + ASSERT_TRUE(waitForSignal(spy_install)); + ASSERT_TRUE(spy_install.first().at(1).toBool()); + + server_.setBody(dummyPluginZip("mock-data-source", "1.0.0")); + const Extension ext_v1 = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_update(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + mgr_->update(ext_v1); + + ASSERT_TRUE(waitForSignal(spy_update)); + EXPECT_FALSE(spy_update.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("newer than registry")); + EXPECT_EQ(mgr_->installedExtensions()["mock-data-source"].version, "2.0.0"); +} + // --------------------------------------------------------------------------- // [6] applyPendingInstalls — Windows post-restart staging simulation // -// On Windows, DLLs in use cannot be overwritten, so install() extracts to -// .pending// instead. On the next startup, applyPendingInstalls() moves -// the directory into extensions/ and registers it using the manifest.json -// already present in the artifact. These tests create that directory structure +// On Windows, DLLs in use cannot be overwritten, so update() stages into +// the configured pending directory. On the next startup, applyPendingInstalls() moves +// the directory into extensions/ and registers it from the DSO's embedded manifest. +// These tests create that directory structure // manually and verify the promotion logic on any platform (the function is // always safe to call). // --------------------------------------------------------------------------- // applyPendingInstalls() promotes a staged extension to extensions/ and registers it. TEST_F(ExtensionManagerTest, ApplyPendingInstallsPromotesStagedExtension) { - // Replicate what DownloadManager::fetch() produces on Windows: an / directory - // with the artifact contents, including manifest.json. - const QString staged_dir = pending_dir_.path() + "/mcap-loader"; - ASSERT_TRUE(QDir().mkpath(staged_dir)); - - QFile plugin_file(staged_dir + "/mcap-loader.plugin"); - ASSERT_TRUE(plugin_file.open(QIODevice::WriteOnly)); - plugin_file.write("placeholder binary"); - plugin_file.close(); - - QFile manifest_file(staged_dir + "/manifest.json"); - ASSERT_TRUE(manifest_file.open(QIODevice::WriteOnly)); - manifest_file.write( - QJsonDocument(QJsonObject{{"id", "mcap-loader"}, {"version", "1.0.0"}}).toJson()); - manifest_file.close(); + const QString staged_dir = pending_dir_.path() + "/mock-data-source"; + ASSERT_TRUE(copyFixturePlugin(staged_dir, "mock-data-source")); + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "mock-data-source")); QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); mgr_->applyPendingInstalls(); ASSERT_EQ(spy_finished.count(), 1); - EXPECT_EQ(spy_finished.first().at(0).toString(), "mcap-loader"); + EXPECT_EQ(spy_finished.first().at(0).toString(), "mock-data-source"); EXPECT_TRUE(spy_finished.first().at(1).toBool()); // Extension must be queryable as installed. - EXPECT_TRUE(mgr_->isInstalled("mcap-loader")); - EXPECT_EQ(mgr_->installedExtensions()["mcap-loader"].version, "1.0.0"); + EXPECT_TRUE(mgr_->isInstalled("mock-data-source")); + EXPECT_EQ(mgr_->installedExtensions()["mock-data-source"].version, "1.0.0"); // The active directory lives under extensions_dir, not pending_dir. - EXPECT_TRUE(QDir(ext_dir_.path() + "/mcap-loader").exists()); + EXPECT_TRUE(QDir(ext_dir_.path() + "/mock-data-source").exists()); EXPECT_FALSE(QDir(staged_dir).exists()); } -// An entry in .pending/ that lacks manifest.json is silently skipped — it may be -// a leftover from an incomplete extraction and must not cause a crash or bad state. -TEST_F(ExtensionManagerTest, ApplyPendingInstallsSkipsDirectoryWithoutMetaFile) { +TEST_F(ExtensionManagerTest, StageInstallRejectsEmbeddedIdMismatchBeforeRestart) { + server_.setBody(pluginZipWithDso("registry-id", "mock-data-source")); + const Extension ext = makeExtension("registry-id", "1.0.0", server_.url()); + + QSignalSpy spy_pending(mgr_, &ExtensionManager::installPendingRestart); + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->testDoInstall(ext, /*staging=*/true); + + ASSERT_TRUE(waitForInstallOutcome(spy_finished, spy_pending)); + EXPECT_EQ(spy_pending.count(), 0); + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "registry-id"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("Embedded plugin id")); + EXPECT_FALSE(QDir(pending_dir_.path() + "/registry-id").exists()); +} + +TEST_F(ExtensionManagerTest, StageInstallRejectsEmbeddedVersionMismatchBeforeRestart) { + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "2.0.0", server_.url()); + + QSignalSpy spy_pending(mgr_, &ExtensionManager::installPendingRestart); + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + + mgr_->testDoInstall(ext, /*staging=*/true); + + ASSERT_TRUE(waitForInstallOutcome(spy_finished, spy_pending)); + EXPECT_EQ(spy_pending.count(), 0); + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "mock-data-source"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("Embedded plugin version")); + EXPECT_FALSE(QDir(pending_dir_.path() + "/mock-data-source").exists()); +} + +TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsStagedVersionMismatchAgainstRegistryIntent) { + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_pending(mgr_, &ExtensionManager::installPendingRestart); + QSignalSpy spy_stage_finished(mgr_, &ExtensionManager::installFinished); + + mgr_->testDoInstall(ext, /*staging=*/true); + + ASSERT_TRUE(waitForInstallOutcome(spy_stage_finished, spy_pending)); + ASSERT_EQ(spy_pending.count(), 1); + ASSERT_EQ(spy_stage_finished.count(), 0); + + const QString staged_plugin = pending_dir_.path() + "/mock-data-source/" + pluginFileName(); + ASSERT_TRUE(QFile::remove(staged_plugin)); + ASSERT_TRUE(QFile::copy(pluginPathForId("mock-data-source", "2.0.0"), staged_plugin)); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + mgr_->applyPendingInstalls(); + + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "mock-data-source"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("Embedded plugin version")); + EXPECT_FALSE(QDir(pending_dir_.path() + "/mock-data-source").exists()); + EXPECT_FALSE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); +} + +// A staged directory that lacks the registry-intent marker is rejected and removed; +// otherwise every startup would silently skip the same broken stage forever. +TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsEmptyStagingDirectory) { const QString staged_dir = pending_dir_.path() + "/bad-extension"; ASSERT_TRUE(QDir().mkpath(staged_dir)); - // Intentionally omit manifest.json to simulate a broken staging directory. - QSignalSpy spy(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); mgr_->applyPendingInstalls(); - EXPECT_EQ(spy.count(), 0); + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "bad-extension"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(spy_error.first().at(1).toString().contains("registry intent")); EXPECT_FALSE(mgr_->isInstalled("bad-extension")); + EXPECT_FALSE(QDir(staged_dir).exists()); + ASSERT_FALSE(mgr_->diagnostics().isEmpty()); + EXPECT_TRUE(mgr_->diagnostics().back().message.contains("registry intent")); +} + +// Windows update path: when applyPendingInstalls() promotes a staged update over an +// existing install, it must move the previous version into PlatformUtils::backupDir() +// before the rename, mirroring the synchronous backup that update() performs on +// Linux/macOS. Without this, a Windows update would silently overwrite the previous +// version with no recovery path. +TEST_F(ExtensionManagerTest, ApplyPendingInstallsBacksUpExistingExtensionBeforePromotion) { + // Place ext_dir + pending_dir on the same filesystem as backupDir() so all the + // QDir::rename moves are atomic (no cross-device copy fallback). + QTemporaryDir local_ext_dir(QDir(PlatformUtils::backupDir()).absoluteFilePath("../test_ext_XXXXXX")); + QTemporaryDir local_pending_dir(QDir(PlatformUtils::backupDir()).absoluteFilePath("../test_pending_XXXXXX")); + ASSERT_TRUE(local_ext_dir.isValid()); + ASSERT_TRUE(local_pending_dir.isValid()); + // Clean any stale backup from a previous failed run. + QDir(PlatformUtils::backupDir() + "/mock-data-source-1.0.0").removeRecursively(); + + DownloadManager local_dl; + ExtensionManager local_mgr(&local_dl, local_ext_dir.path(), local_pending_dir.path()); + + // 1. Install v1 directly to populate extensions//. + server_.setBody(dummyPluginZip("mock-data-source")); + QSignalSpy spy_install(&local_mgr, &ExtensionManager::installFinished); + local_mgr.install(makeExtension("mock-data-source", "1.0.0", server_.url())); + ASSERT_TRUE(waitForSignal(spy_install)); + ASSERT_TRUE(spy_install.first().at(1).toBool()); + spy_install.clear(); + + // 2. Manually stage a v2 update with intent file (mirrors what doInstall(staging=true) + // leaves on disk on Windows before the user restarts). + const QString staged_dir = local_pending_dir.path() + "/mock-data-source"; + ASSERT_TRUE(copyFixturePlugin(staged_dir, "mock-data-source", "2.0.0")); + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "mock-data-source", "2.0.0")); + + // 3. Restart-time apply. + local_mgr.applyPendingInstalls(); + + ASSERT_EQ(spy_install.count(), 1); + EXPECT_TRUE(spy_install.first().at(1).toBool()) << "staged update must promote"; + EXPECT_EQ(local_mgr.installedExtensions()["mock-data-source"].version, "2.0.0"); + + // 4. The previous version must be preserved in backup, recoverable manually. + const QString backup_dir = PlatformUtils::backupDir() + "/mock-data-source-1.0.0"; + EXPECT_TRUE(QDir(backup_dir).exists()) + << "applyPendingInstalls must back up the previous version before overwriting it"; + EXPECT_TRUE(QFile::exists(backup_dir + "/" + pluginFileName())) + << "previous plugin file must survive in backup for manual rollback"; + + QDir(backup_dir).removeRecursively(); } // applyPendingInstalls() is a no-op when the pending directory contains no sub-directories. @@ -601,23 +943,105 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsIsNoOpForEmptyDirectory) { EXPECT_EQ(spy.count(), 0); } -// Multiple staged extensions in .pending/ are all promoted in a single call. +// Multiple staged extensions in the pending directory are all promoted in a single call. TEST_F(ExtensionManagerTest, ApplyPendingInstallsPromotesMultipleExtensions) { - for (const QString& id : QStringList{"csv-loader", "can-bus-parser"}) { + for (const QString& id : QStringList{"mock-data-source", "mock-file-source"}) { const QString staged = pending_dir_.path() + "/" + id; - ASSERT_TRUE(QDir().mkpath(staged)); - - QFile f(staged + "/manifest.json"); - ASSERT_TRUE(f.open(QIODevice::WriteOnly)); - f.write(QJsonDocument(QJsonObject{{"id", id}, {"version", "1.0.0"}}).toJson()); + ASSERT_TRUE(copyFixturePlugin(staged, id)); + ASSERT_TRUE(writePendingIntentForTest(staged, id)); } QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->applyPendingInstalls(); EXPECT_EQ(spy.count(), 2); - EXPECT_TRUE(mgr_->isInstalled("csv-loader")); - EXPECT_TRUE(mgr_->isInstalled("can-bus-parser")); + EXPECT_TRUE(mgr_->isInstalled("mock-data-source")); + EXPECT_TRUE(mgr_->isInstalled("mock-file-source")); +} + +TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsEmbeddedIdMismatch) { + const QString staged_dir = pending_dir_.path() + "/registry-id"; + ASSERT_TRUE(copyFixturePlugin(staged_dir, "mock-data-source")); + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "registry-id")); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + mgr_->applyPendingInstalls(); + + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "registry-id"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_FALSE(QDir(ext_dir_.path() + "/registry-id").exists()); + EXPECT_FALSE(mgr_->isInstalled("registry-id")); + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); +} + +TEST_F(ExtensionManagerTest, ApplyPendingInstallsKeepsExistingInstallWhenStagedUpdateFailsValidation) { + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy_install(mgr_, &ExtensionManager::installFinished); + mgr_->install(ext); + ASSERT_TRUE(waitForSignal(spy_install)); + ASSERT_TRUE(spy_install.first().at(1).toBool()); + ASSERT_TRUE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + + const QString staged_dir = pending_dir_.path() + "/mock-data-source"; + ASSERT_TRUE(copyFixturePlugin(staged_dir, "mock-file-source")); + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "mock-data-source")); + + spy_install.clear(); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + mgr_->applyPendingInstalls(); + + ASSERT_EQ(spy_install.count(), 1); + EXPECT_FALSE(spy_install.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_TRUE(mgr_->isInstalled("mock-data-source")); + EXPECT_TRUE(QDir(ext_dir_.path() + "/mock-data-source").exists()); + EXPECT_FALSE(QDir(staged_dir).exists()); +} + +TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsBrokenDso) { + const QString staged_dir = pending_dir_.path() + "/bad-extension"; + ASSERT_TRUE(QDir().mkpath(staged_dir)); + + QFile broken(QDir(staged_dir).absoluteFilePath(pluginFileName())); + ASSERT_TRUE(broken.open(QIODevice::WriteOnly)); + broken.write("not a shared library"); + broken.close(); + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "bad-extension")); + + QSignalSpy spy_finished(mgr_, &ExtensionManager::installFinished); + QSignalSpy spy_error(mgr_, &ExtensionManager::installError); + mgr_->applyPendingInstalls(); + + ASSERT_EQ(spy_finished.count(), 1); + EXPECT_EQ(spy_finished.first().at(0).toString(), "bad-extension"); + EXPECT_FALSE(spy_finished.first().at(1).toBool()); + ASSERT_EQ(spy_error.count(), 1); + EXPECT_FALSE(QDir(ext_dir_.path() + "/bad-extension").exists()); +} + +TEST_F(ExtensionManagerTest, HasPendingInstallRequiresDsoAndRegistryIntent) { + const QString staged_dir = pending_dir_.path() + "/mock-data-source"; + ASSERT_TRUE(QDir().mkpath(staged_dir)); + + QSignalSpy spy_pending(mgr_, &ExtensionManager::installPendingRestart); + EXPECT_FALSE(mgr_->hasPendingInstall("mock-data-source")); + EXPECT_EQ(spy_pending.count(), 0); + + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "mock-data-source")); + EXPECT_FALSE(mgr_->hasPendingInstall("mock-data-source")); + ASSERT_TRUE(QFile::remove(QDir(staged_dir).absoluteFilePath(".pj_pending_install"))); + + ASSERT_TRUE(copyFixturePlugin(staged_dir, "mock-data-source")); + EXPECT_FALSE(mgr_->hasPendingInstall("mock-data-source")); + + ASSERT_TRUE(writePendingIntentForTest(staged_dir, "mock-data-source")); + EXPECT_TRUE(mgr_->hasPendingInstall("mock-data-source")); + EXPECT_EQ(spy_pending.count(), 0); } // --------------------------------------------------------------------------- @@ -627,8 +1051,8 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsPromotesMultipleExtensions) { // A new ExtensionManager pointing to the same directory discovers the same extensions // by scanning disk — this simulates an application restart. TEST_F(ExtensionManagerTest, StatePersistsAcrossManagerRestarts) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->install(ext); @@ -639,25 +1063,55 @@ TEST_F(ExtensionManagerTest, StatePersistsAcrossManagerRestarts) { DownloadManager downloader2; ExtensionManager mgr2(&downloader2, ext_dir_.path(), pending_dir_.path()); - EXPECT_TRUE(mgr2.isInstalled("csv-loader")); - EXPECT_EQ(mgr2.installedExtensions()["csv-loader"].version, "1.0.0"); + EXPECT_TRUE(mgr2.isInstalled("mock-data-source")); + EXPECT_EQ(mgr2.installedExtensions()["mock-data-source"].version, "1.0.0"); } // Uninstalling removes the directory from disk; a fresh manager scanning the same // directory must not report the extension as installed. TEST_F(ExtensionManagerTest, UninstallRemovesEntryFromPersistentState) { - server_.setBody(dummyPluginZip("csv-loader")); - const Extension ext = makeExtension("csv-loader", "1.0.0", server_.url()); + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); QSignalSpy spy_install(mgr_, &ExtensionManager::installFinished); mgr_->install(ext); ASSERT_TRUE(waitForSignal(spy_install)); - mgr_->uninstall("csv-loader"); + mgr_->uninstall("mock-data-source"); DownloadManager downloader2; ExtensionManager mgr2(&downloader2, ext_dir_.path(), pending_dir_.path()); - EXPECT_FALSE(mgr2.isInstalled("csv-loader")); + EXPECT_FALSE(mgr2.isInstalled("mock-data-source")); +} + +TEST_F(ExtensionManagerTest, RefreshEvictsExtensionWhenDsoIsRemovedExternally) { + server_.setBody(dummyPluginZip("mock-data-source")); + const Extension ext = makeExtension("mock-data-source", "1.0.0", server_.url()); + + QSignalSpy spy(mgr_, &ExtensionManager::installFinished); + mgr_->install(ext); + ASSERT_TRUE(waitForSignal(spy)); + ASSERT_TRUE(spy.first().at(1).toBool()); + ASSERT_TRUE(QFile::remove(ext_dir_.path() + "/mock-data-source/" + pluginFileName())); + + mgr_->refreshInstalledFromDisk(); + + EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); + EXPECT_TRUE(mgr_->installedExtensions().isEmpty()); +} + +TEST_F(ExtensionManagerTest, LoadStateIgnoresDirectoriesWithoutValidPluginDso) { + const QString invalid_dir = ext_dir_.path() + "/old-layout"; + ASSERT_TRUE(QDir().mkpath(invalid_dir)); + QFile readme(invalid_dir + "/README.txt"); + ASSERT_TRUE(readme.open(QIODevice::WriteOnly)); + readme.write("not a plugin"); + readme.close(); + + mgr_->refreshInstalledFromDisk(); + + EXPECT_FALSE(mgr_->isInstalled("old-layout")); + EXPECT_TRUE(mgr_->installedExtensions().isEmpty()); } // A new manager pointing to an empty extensions directory starts with no installed @@ -678,7 +1132,7 @@ TEST_F(ExtensionManagerTest, FreshManagerHasNoInstalledExtensions) { // A directory containing the marker is removed by applyPendingUninstalls(). TEST_F(ExtensionManagerTest, ApplyPendingUninstallsRemovesMarkedDirectory) { - const QString ext_path = ext_dir_.path() + "/csv-loader"; + const QString ext_path = ext_dir_.path() + "/mock-data-source"; ASSERT_TRUE(QDir().mkpath(ext_path)); QFile marker(ext_path + "/.pj_pending_uninstall"); ASSERT_TRUE(marker.open(QIODevice::WriteOnly)); @@ -691,7 +1145,7 @@ TEST_F(ExtensionManagerTest, ApplyPendingUninstallsRemovesMarkedDirectory) { // A directory without the marker is left untouched. TEST_F(ExtensionManagerTest, ApplyPendingUninstallsIgnoresUnmarkedDirectory) { - const QString ext_path = ext_dir_.path() + "/csv-loader"; + const QString ext_path = ext_dir_.path() + "/mock-data-source"; ASSERT_TRUE(QDir().mkpath(ext_path)); mgr_->applyPendingUninstalls(); @@ -712,8 +1166,7 @@ TEST_F(ExtensionManagerTest, ApplyPendingUninstallsIsNoOpForEmptyDirectory) { TEST(PlatformDetectionTest, CurrentPlatformHasExpectedFormat) { const QString platform = PlatformUtils::currentPlatform(); EXPECT_FALSE(platform.isEmpty()); - EXPECT_TRUE(platform.contains('-')) - << "Expected '-' format, got: " << platform.toStdString(); + EXPECT_TRUE(platform.contains('-')) << "Expected '-' format, got: " << platform.toStdString(); } // On the primary Linux x86_64 build/CI host, the reported platform must match the @@ -738,7 +1191,7 @@ TEST(PlatformDetectionTest, CurrentPlatformResolvesRegistryArtifact) { EXPECT_TRUE(ext.platforms.contains(PlatformUtils::currentPlatform())) << "Platform '" << PlatformUtils::currentPlatform().toStdString() - << "' is not listed in the csv-loader registry entry"; + << "' is not listed in the mock-data-source registry entry"; } // On Linux, install() must write directly to extensions_dir (no staging). diff --git a/pj_marketplace/tests/registry_manager_test.cpp b/pj_marketplace/tests/registry_manager_test.cpp index d89a720..d760f61 100644 --- a/pj_marketplace/tests/registry_manager_test.cpp +++ b/pj_marketplace/tests/registry_manager_test.cpp @@ -6,6 +6,8 @@ // [3] Emits fetchStarted, fetchFinished, and fetchError with the right values // [4] Handles network errors gracefully (connection refused, invalid JSON, missing fields) +#include "pj_marketplace/registry_manager.hpp" + #include #include @@ -17,8 +19,6 @@ #include #include -#include "pj_marketplace/registry_manager.hpp" - // --------------------------------------------------------------------------- // Minimal HTTP/1.1 server — serves one fixed JSON body per connection // --------------------------------------------------------------------------- @@ -212,7 +212,10 @@ TEST_F(RegistryManagerTest, ParsesOptionalStringFields) { mgr.fetchRegistry(server_->url()); ASSERT_TRUE(spy_finished.wait(3000)); - const Extension& ext = mgr.extensions().at(0); + const QList exts = mgr.extensions(); + ASSERT_EQ(exts.size(), 1); + + const Extension& ext = exts.at(0); EXPECT_EQ(ext.description, "Load CSV/TSV files"); EXPECT_EQ(ext.author, "Test Author"); EXPECT_EQ(ext.publisher, "test-org"); diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index d76b226..e8f2d68 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -1,14 +1,33 @@ add_subdirectory(dialog_protocol) +# --------------------------------------------------------------------------- +# pj_plugin_catalog — host-side embedded-manifest discovery +# --------------------------------------------------------------------------- + +find_package(nlohmann_json REQUIRED) + +add_library(pj_plugin_catalog STATIC + src/plugin_catalog.cpp +) +target_include_directories(pj_plugin_catalog PUBLIC include) +target_compile_features(pj_plugin_catalog PUBLIC cxx_std_20) +target_compile_options(pj_plugin_catalog PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(pj_plugin_catalog + PUBLIC + pj_base + PRIVATE + pj_dialog_protocol + ${CMAKE_DL_LIBS} + nlohmann_json::nlohmann_json +) + # --------------------------------------------------------------------------- # pj_data_source_host — host-side DataSource library loader # --------------------------------------------------------------------------- add_library(pj_data_source_host STATIC src/data_source_library.cpp - src/plugin_catalog.cpp ) -find_package(nlohmann_json REQUIRED) target_include_directories(pj_data_source_host PUBLIC include) target_compile_features(pj_data_source_host PUBLIC cxx_std_20) target_compile_options(pj_data_source_host PRIVATE ${PJ_WARNING_FLAGS}) @@ -16,9 +35,9 @@ target_link_libraries(pj_data_source_host PUBLIC pj_base pj_dialog_protocol + pj_plugin_catalog PRIVATE ${CMAKE_DL_LIBS} - nlohmann_json::nlohmann_json ) if(PJ_BUILD_TESTS) @@ -50,6 +69,21 @@ target_compile_features(mock_file_source_plugin PRIVATE cxx_std_20) target_compile_options(mock_file_source_plugin PRIVATE ${PJ_WARNING_FLAGS}) target_link_libraries(mock_file_source_plugin PRIVATE pj_base) +add_library(missing_id_data_source_plugin SHARED tests/missing_id_data_source_plugin.cpp) +target_compile_features(missing_id_data_source_plugin PRIVATE cxx_std_20) +target_compile_options(missing_id_data_source_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(missing_id_data_source_plugin PRIVATE pj_base) + +add_library(mock_data_source_v2_plugin SHARED tests/mock_data_source_v2_plugin.cpp) +target_compile_features(mock_data_source_v2_plugin PRIVATE cxx_std_20) +target_compile_options(mock_data_source_v2_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(mock_data_source_v2_plugin PRIVATE pj_base) + +add_library(invalid_optional_manifest_data_source_plugin SHARED tests/invalid_optional_manifest_data_source_plugin.cpp) +target_compile_features(invalid_optional_manifest_data_source_plugin PRIVATE cxx_std_20) +target_compile_options(invalid_optional_manifest_data_source_plugin PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(invalid_optional_manifest_data_source_plugin PRIVATE pj_base) + endif() # PJ_BUILD_TESTS # --------------------------------------------------------------------------- @@ -110,6 +144,25 @@ target_link_libraries(pj_toolbox_host ${CMAKE_DL_LIBS} ) +# --------------------------------------------------------------------------- +# pj_plugin_runtime_catalog — shared host-side plugin loading catalog +# --------------------------------------------------------------------------- + +add_library(pj_plugin_runtime_catalog STATIC + src/plugin_runtime_catalog.cpp +) +target_include_directories(pj_plugin_runtime_catalog PUBLIC include) +target_compile_features(pj_plugin_runtime_catalog PUBLIC cxx_std_20) +target_compile_options(pj_plugin_runtime_catalog PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(pj_plugin_runtime_catalog + PUBLIC + pj_data_source_host + pj_message_parser_host + pj_toolbox_host + pj_plugin_catalog + pj_base +) + if(PJ_BUILD_TESTS) # --------------------------------------------------------------------------- @@ -198,25 +251,24 @@ target_link_libraries(toolbox_plugin_test PRIVATE add_test(NAME toolbox_plugin_test COMMAND toolbox_plugin_test) # --------------------------------------------------------------------------- -# Plugin catalog (sidecar scanner) test — no dlopen, filesystem-only. +# Plugin catalog DSO scanner test. # --------------------------------------------------------------------------- add_executable(plugin_catalog_test tests/plugin_catalog_test.cpp) +target_compile_definitions(plugin_catalog_test PRIVATE + PJ_MOCK_DATA_SOURCE_PLUGIN_PATH="$" + PJ_MOCK_JSON_PARSER_PLUGIN_PATH="$" + PJ_MOCK_TOOLBOX_PLUGIN_PATH="$" + PJ_MOCK_DIALOG_PLUGIN_PATH="$" + PJ_MISSING_ID_PLUGIN_PATH="$" + PJ_INVALID_OPTIONAL_PLUGIN_PATH="$" +) target_compile_options(plugin_catalog_test PRIVATE ${PJ_WARNING_FLAGS}) target_link_libraries(plugin_catalog_test PRIVATE - pj_data_source_host pj_base GTest::gtest_main + pj_plugin_catalog GTest::gtest_main ) -# If the ported plugins are part of this build, point the integration test -# at their output directory so it can scan real sidecars. Deferred via a -# generator expression because csv_source_plugin is added later in the -# top-level CMakeLists traversal (pj_ported_plugins/ after pj_plugins/). -if(PJ_HAS_PORTED_PLUGINS) - target_compile_definitions(plugin_catalog_test PRIVATE - PJ_PORTED_PLUGINS_BIN_DIR="$" - ) - # Ensure the test waits for plugins to be built so their sidecars exist. - add_dependencies(plugin_catalog_test csv_source_plugin mcap_source_plugin - parquet_source_plugin ulog_source_plugin) -endif() +add_dependencies(plugin_catalog_test mock_data_source_plugin mock_json_parser_plugin + mock_toolbox_plugin mock_dialog_plugin missing_id_data_source_plugin + invalid_optional_manifest_data_source_plugin) add_test(NAME plugin_catalog_test COMMAND plugin_catalog_test) endif() # PJ_BUILD_TESTS diff --git a/pj_plugins/dialog_protocol/examples/mock_dialog.cpp b/pj_plugins/dialog_protocol/examples/mock_dialog.cpp index 08a6038..d4de98f 100644 --- a/pj_plugins/dialog_protocol/examples/mock_dialog.cpp +++ b/pj_plugins/dialog_protocol/examples/mock_dialog.cpp @@ -58,6 +58,7 @@ class MockDialog : public PJ::DialogPluginTyped { public: std::string manifest() const override { return R"({ + "id": "mock-dialog", "name": "Mock Dialog", "version": "1.0.0", "description": "A minimal dialog plugin for testing" diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp index b321cd6..7ba1c05 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/dialog_plugin_base.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -236,16 +237,23 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { } // namespace PJ -/// Macro to export the vtable entry point for a plugin class. +/// Macro to export a Dialog plugin from a DSO. /// -/// Emits two things: -/// 1. The `PJ_get_dialog_vtable()` C symbol the host loader resolves -/// via `dlsym`. Always present, same shape since v1. -/// 2. A specialisation of `PJ::dialogVtableFor()` that lets +/// Works whether the dialog is the only plugin in the DSO or co-resident with +/// another family (DataSource, MessageParser, Toolbox). The shared +/// `pj_plugin_abi_version` export uses weak linkage, so duplicate definitions +/// across family macros in the same DSO collapse into one COMDAT entry. +/// +/// Emits three things: +/// 1. The boot-level `pj_plugin_abi_version` symbol the host loader checks +/// first (via `PJ_EXPORT_PLUGIN_ABI_VERSION`). +/// 2. The `PJ_get_dialog_vtable()` C symbol the host resolves via `dlsym`. +/// 3. A specialisation of `PJ::dialogVtableFor()` that lets /// other plugin code (notably a host's `getDialog()` override) obtain /// the vtable pointer type-safely via `PJ::borrowDialog(member)` — /// no `extern "C"` forward declaration required in the plugin source. #define PJ_DIALOG_PLUGIN(ClassName) \ + PJ_EXPORT_PLUGIN_ABI_VERSION(PJ_DIALOG_EXPORT) \ extern "C" PJ_DIALOG_EXPORT const PJ_dialog_vtable_t* PJ_get_dialog_vtable() noexcept { \ static const PJ_dialog_vtable_t* vt = PJ::DialogPluginBase::vtableWithCreate([]() noexcept -> void* { \ try { \ diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index a006393..0e7ae63 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -9,9 +9,16 @@ these is an ABI break and requires a v5 bump. `pj_plugin_abi_version` as a `const uint32_t` symbol independent of any vtable. The host `dlsym`s it BEFORE fetching the family vtable; missing or mismatched symbol is a fail-fast rejection with a specific - error. Emitted automatically by `PJ_DATA_SOURCE_PLUGIN`, - `PJ_MESSAGE_PARSER_PLUGIN`, `PJ_TOOLBOX_PLUGIN` macros. Current value - is `PJ_ABI_VERSION == 4`. + error. The symbol is emitted at file scope by + `pj_base/include/pj_base/plugin_abi_export.h`, which is transitively + included by every family SDK base header + (`data_source_plugin_base.hpp`, `dialog_plugin_base.hpp`, + `message_parser_plugin_base.hpp`, `toolbox_plugin_base.hpp`). Weak + linkage (`__attribute__((weak))` / `__declspec(selectany)`) folds + duplicate definitions across translation units in one DSO, so a single + .so can host multiple plugin families (e.g. DataSource + Dialog) with + one `PJ_*_PLUGIN(...)` macro per family — no duplicate-symbol error. + Current value is `PJ_ABI_VERSION == 4`. 2. **Min-vtable-size floor, pinned at v4.0.** Each family header defines `PJ__MIN_VTABLE_SIZE` — the byte count of the vtable as @@ -114,19 +121,15 @@ release, and its changes roll into v4): the ABI headers carries a `[main-thread]` / `[stream-thread]` / `[thread-safe]` comment. Host-side runtime checking is optional (reserved for a future `"pj.thread_check.v1"` service). -- **Sidecar-based plugin discovery.** `pj_emit_plugin_manifest` (CMake - helper in `cmake/PjPluginManifest.cmake`) writes a - `.pjmanifest.json` beside each DSO at build and install - time. The sidecar is the DSO's own `manifest.json` plus two - autogenerated keys — `"abi_major"` (matches `PJ_ABI_VERSION`) and - `"family"` (one of `data_source`, `message_parser`, `toolbox`, - `dialog`). Host-side `PJ::scanPluginSidecars(dir)` (in - `pj_plugins/host/plugin_catalog.hpp`) parses every sidecar in a - directory into `PluginDescriptor` records — name, version, category, - file extensions, encoding, capabilities — WITHOUT dlopen'ing any - shared library. On activation the host dlopens the DSO, calls - `get_plugin_manifest`, and warns (not errors) if the two disagree — - DSO truth wins. +- **Embedded-manifest plugin discovery.** Each DSO exports a + family-specific protocol vtable with embedded metadata (`manifest_json` + for data sources, parsers, and toolboxes; `get_manifest()` for dialogs). + Host-side `PJ::scanPluginDsos(dir)` (in + `pj_plugins/host/plugin_catalog.hpp`) walks platform plugin libraries, + loads each candidate, validates the ABI and protocol vtable, and parses + `id`, `name`, `version`, family-specific fields, and optional metadata + directly from the embedded manifest. Broken or incompatible candidates + are reported as diagnostics while discovery continues. - **No more RTLD_DEEPBIND.** The loader uses `RTLD_NOW | RTLD_LOCAL` only (DEEPBIND was a documented ASAN/allocator-interposition trap). Plugin-local symbol isolation is left to `-fvisibility=hidden`. @@ -153,10 +156,11 @@ that had been developed in the unreleased v3 iteration): - **Typed borrowed dialog.** `get_dialog_context()` returning `void*` is replaced by `get_dialog()` returning a `PJ_borrowed_dialog_t` fat pointer `{ctx, const PJ_dialog_vtable_t* vtable}`. -- **Uniform plugin-vtable prefix.** Every family vtable starts with - `protocol_version, struct_size, create, destroy, manifest_json, - capabilities, bind, save_config, load_config` in that order. Host-side - generic code can iterate all families through a common header layout. +- **Uniform core plugin-vtable prefix.** Data source, message parser, and + toolbox vtables start with `protocol_version, struct_size, create, + destroy, manifest_json, capabilities, bind, save_config, load_config` in + that order. Dialogs expose a GUI-oriented protocol with + `get_manifest()`/`get_ui_content()` instead. Service traits (`pj_base/sdk/service_traits.hpp`, `sdk/toolbox_plugin_base.hpp`) map canonical names to their ABI type and @@ -274,11 +278,11 @@ at load time. Mismatches produce a clear error. | DataSource (stream) | `StreamSourceBase` | `onStart()`, `onPoll()`, `onStop()`, `extraCapabilities()` | same macro | | MessageParser | `MessageParserPluginBase` | `parse()` | `PJ_MESSAGE_PARSER_PLUGIN(Class, manifest)` | | Toolbox | `ToolboxPluginBase` | `capabilities()` | `PJ_TOOLBOX_PLUGIN(Class, manifest)` | -| Dialog | `DialogPluginTyped` | `manifest()`, `ui_content()`, `widget_data()`, event handlers | `PJ_DIALOG_PLUGIN(Class)` | +| Dialog | `DialogPluginTyped` | `manifest()`, `ui_content()`, `widget_data()`, event handlers | `PJ_DIALOG_PLUGIN(Class)` (works standalone or co-resident with another family) | All SDK base classes: - Generate the C vtable via `vtableWithCreate()` at static init. -- Validate the manifest JSON string literal (required keys) via `PJ_ASSERT`. +- Validate compile-time manifest JSON string literals (required keys) via `PJ_ASSERT`; dialog manifests are runtime strings and are validated by the host catalog when inspected. - Catch all C++ exceptions in trampolines, store via `setLastError()`, and return `false`/`null` across the ABI boundary. @@ -306,6 +310,27 @@ Loaders also provide `resolveDialogVtable()` to find the dialog vtable in a plugin `.so` that exports both a family vtable and a dialog vtable (e.g. a DataSource with an embedded dialog). +### 5.1 Host-side diagnostic propagation + +Host code that loads plugins (e.g. `pj_proto_app::PluginRegistry`) accepts an +optional `PJ::DiagnosticSink` (`pj_base/include/pj_base/diagnostic_sink.hpp`) +in its constructor. The sink is a `std::function` +the host invokes for every plugin-load lifecycle event — failed `dlopen`, +missing required manifest fields, malformed JSON, successful loads, +hot-reload detection, etc. Each event carries a level +(`kInfo`/`kWarning`/`kError`), a `source` string, an optional plugin id, a +message, and a timestamp. + +Embedding apps wire one sink into both their host loaders and +`pj_marketplace::ExtensionManager` so the GUI shows one chronological +diagnostic stream covering both module families. Pure-C++ host loaders +remain Qt-free; `PJ::QtDiagnosticBridge` in `pj_marketplace` provides the +thin Qt adapter that marshals each event onto the Qt event loop via +`Qt::QueuedConnection` and emits a Qt signal the GUI can connect to. + +A default-constructed sink discards events at zero cost, so loaders that +take no sink behave as before. + ## 6. RAII Handles Each family has a move-only RAII handle: diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 900da1c..e83917c 100644 --- a/pj_plugins/docs/data-source-guide.md +++ b/pj_plugins/docs/data-source-guide.md @@ -19,7 +19,7 @@ internals) and communicate through a stable C ABI. 1. Subclass `PJ::FileSourceBase` (file importer) or `PJ::StreamSourceBase` (live stream), or `PJ::DataSourcePluginBase` for full control. 2. Implement the required virtuals (see Common Patterns below). -3. Export with `PJ_DATA_SOURCE_PLUGIN(YourClass, R"({"name":"...","version":"..."})")` +3. Export with `PJ_DATA_SOURCE_PLUGIN(YourClass, R"({"id":"...","name":"...","version":"..."})")` 4. Build as a shared library linking `pj_base` A complete example lives at `pj_plugins/examples/mock_data_source.cpp`. @@ -74,7 +74,7 @@ manifest string literal (see Manifest Schema below): ```cpp PJ_DATA_SOURCE_PLUGIN(MyCsvLoader, - R"({"name":"CSV Loader","version":"1.0.0","file_extensions":[".csv"]})") + R"({"id":"csv-loader","name":"CSV Loader","version":"1.0.0","file_extensions":[".csv"]})") ``` This generates the `extern "C"` entry point that the host resolves via dlsym. @@ -199,7 +199,7 @@ class CsvFileLoader : public PJ::FileSourceBase { }; PJ_DATA_SOURCE_PLUGIN(CsvFileLoader, - R"({"name":"CSV File Loader","version":"1.0.0",)" + R"({"id":"csv-file-loader","name":"CSV File Loader","version":"1.0.0",)" R"("description":"Import numeric CSV files",)" R"("file_extensions":[".csv",".tsv"]})") ``` @@ -350,7 +350,7 @@ class UdpReceiver : public PJ::StreamSourceBase { }; PJ_DATA_SOURCE_PLUGIN(UdpReceiver, - R"({"name":"UDP Receiver","version":"1.0.0",)" + R"({"id":"udp-receiver","name":"UDP Receiver","version":"1.0.0",)" R"("description":"Receive datagrams on UDP 9870 with delegated parsing"})") ``` @@ -543,6 +543,7 @@ it without instantiating the plugin. | Key | Type | Required | Description | |-----|------|----------|-------------| +| `id` | string | yes | Stable plugin identifier — used by the host catalog and the marketplace. Must be unique per plugin. | | `name` | string | yes | Human-readable plugin name. | | `version` | string | yes | Semver version string. | | `description` | string | no | Short description of the plugin. | @@ -551,6 +552,7 @@ it without instantiating the plugin. Example: ```json { + "id": "csv-loader", "name": "CSV Loader", "version": "1.0.0", "description": "Import numeric CSV files", @@ -788,7 +790,7 @@ class MySource : public PJ::StreamSourceBase { **3. Export both vtables** at file scope: ```cpp -PJ_DATA_SOURCE_PLUGIN(MySource, R"({"name":"My Source","version":"1.0.0"})") +PJ_DATA_SOURCE_PLUGIN(MySource, R"({"id":"my-source","name":"My Source","version":"1.0.0"})") PJ_DIALOG_PLUGIN(MyDialog) ``` diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 77207e6..4d19541 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -19,7 +19,7 @@ renders the widgets, and relays events to the plugin over the C vtable. 1. Subclass `PJ::DialogPluginTyped`. 2. Provide inline Qt Designer `.ui` XML via `ui_content()`. -3. Override `manifest()` with a JSON string (name, version, etc.). +3. Override `manifest()` with a JSON string (`id`, name, version, etc.). 4. Implement `widget_data()` to push state to the UI. 5. Override the typed event handlers you need (`onTextChanged`, `onIndexChanged`, `onToggled`, etc.) — return `true` when state changes. @@ -44,6 +44,7 @@ class MyDialog : public PJ::DialogPluginTyped { public: std::string manifest() const override { return R"({ + "id": "my-dialog", "name": "My Dialog", "version": "1.0.0", "description": "Example dialog plugin" @@ -225,6 +226,22 @@ target_link_libraries(my_dialog_plugin PRIVATE pj_dialog_sdk) No Qt dependency is needed in the plugin — only the host links Qt. +## Manifest Schema + +`manifest()` returns a JSON string. Unlike the other plugin families, the +dialog manifest is built at runtime (not a string literal in the vtable), but +the same required keys apply. + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `id` | string | yes | Stable plugin identifier — used by the host catalog and the marketplace. Must be unique per plugin. | +| `name` | string | yes | Human-readable plugin name. | +| `version` | string | yes | Semver version string. | +| `description` | string | no | Short description of the dialog. | + +The host validates these keys when it inspects the dialog vtable; manifests +missing a required string are rejected. + ## The Reactive Loop The dialog protocol follows a simple reactive cycle: @@ -551,9 +568,9 @@ class MySource : public PJ::StreamSourceBase { MyDialog dialog_; }; -PJ_DATA_SOURCE_PLUGIN(MySource, R"({"name":"My Source","version":"1.0.0"})") -PJ_DIALOG_PLUGIN(MyDialog) // also specialises PJ::dialogVtableFor() - // so PJ::borrowDialog picks up the right vtable. +PJ_DATA_SOURCE_PLUGIN(MySource, R"({"id":"my-source","name":"My Source","version":"1.0.0"})") +PJ_DIALOG_PLUGIN(MyDialog) // also specialises PJ::dialogVtableFor() + // so PJ::borrowDialog picks up the right vtable. ``` The host resolves both vtables, creates a borrowed `DialogHandle` from the diff --git a/pj_plugins/docs/message-parser-guide.md b/pj_plugins/docs/message-parser-guide.md index 6dafa82..ed80a73 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -23,7 +23,7 @@ the host, which routes them to the appropriate parser based on encoding name. 1. Subclass `PJ::MessageParserPluginBase` 2. Override `parse()` (required) and optionally `bindSchema()`, `saveConfig()`, `loadConfig()` -3. Export with `PJ_MESSAGE_PARSER_PLUGIN(YourClass, R"({"name":"...","version":"...","encoding":"..."})")` +3. Export with `PJ_MESSAGE_PARSER_PLUGIN(YourClass, R"({"id":"...","name":"...","version":"...","encoding":"..."})")` 4. Build as a shared library linking `pj_base` A complete example lives at `pj_plugins/examples/mock_json_parser.cpp`. @@ -79,7 +79,7 @@ manifest string literal (see Manifest Schema below): ```cpp PJ_MESSAGE_PARSER_PLUGIN(MyJsonParser, - R"({"name":"JSON Parser","version":"1.0.0","encoding":"json"})") + R"({"id":"json-parser","name":"JSON Parser","version":"1.0.0","encoding":"json"})") ``` This generates the `extern "C"` entry point that the host resolves via dlsym. @@ -323,6 +323,7 @@ it without instantiating the plugin. | Key | Type | Required | Description | |-----|------|----------|-------------| +| `id` | string | yes | Stable plugin identifier — used by the host catalog and the marketplace. Must be unique per plugin. | | `name` | string | yes | Human-readable plugin name. | | `version` | string | yes | Semver version string. | | `encoding` | string | yes | Encoding this parser handles, e.g. `"json"`, `"protobuf"`, `"ros1msg"`. The host uses this to match binding requests to parsers. | @@ -330,6 +331,7 @@ it without instantiating the plugin. Example: ```json { + "id": "protobuf-parser", "name": "Protobuf Parser", "version": "1.0.0", "encoding": "protobuf" diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index f2ed23d..b09a635 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -25,7 +25,7 @@ editor, custom data transforms. 1. Subclass `PJ::ToolboxPluginBase` 2. Override `capabilities()` (required) and optionally `bind()` (for acquiring services), `saveConfig()`, `loadConfig()`, `getDialog()` -3. Export with `PJ_TOOLBOX_PLUGIN(YourClass, R"({"name":"...","version":"..."})")` +3. Export with `PJ_TOOLBOX_PLUGIN(YourClass, R"({"id":"...","name":"...","version":"..."})")` 4. If you ship an embedded dialog, also declare it as a `DialogPluginTyped` subclass and add `PJ_DIALOG_PLUGIN(YourDialog)` 5. Build as a shared library linking `pj_base` (+ `pj_dialog_sdk` if @@ -98,7 +98,7 @@ At file scope, after the class definition: ```cpp PJ_TOOLBOX_PLUGIN(MyToolbox, - R"({"name":"My Toolbox","version":"1.0.0",)" + R"({"id":"my-toolbox","name":"My Toolbox","version":"1.0.0",)" R"("description":"Apply FFT to selected signals"})") ``` @@ -231,6 +231,7 @@ it without instantiating the plugin. | Key | Type | Required | Description | |-----|------|----------|-------------| +| `id` | string | yes | Stable plugin identifier — used by the host catalog and the marketplace. Must be unique per plugin. | | `name` | string | yes | Human-readable plugin name. | | `version` | string | yes | Semver version string. | | `description` | string | no | Short description of the plugin. | @@ -238,6 +239,7 @@ it without instantiating the plugin. Example: ```json { + "id": "fft-toolbox", "name": "FFT Toolbox", "version": "1.0.0", "description": "Apply FFT transforms to selected signals" @@ -313,6 +315,6 @@ row-of-fields shape. See - `pj_plugins/examples/mock_toolbox.cpp` — minimal test fixture that exercises the full `ToolboxPluginBase` API surface: capabilities, config persistence, host binding, and dialog context. -- `pj_ported_plugins/toolbox_quaternion/quaternion_plugin_test.cpp` — - end-to-end test using `ToolboxTestStore` to drive the quaternion toolbox - through several real scenarios. +- `pj_plugins/tests/toolbox_plugin_test.cpp` — end-to-end host-side test + using `PJ::ToolboxTestStore` (in `pj_plugins/include/pj_plugins/testing/`) + to drive a toolbox plugin through ingest, transform, and config scenarios. diff --git a/pj_plugins/examples/mock_data_source.cpp b/pj_plugins/examples/mock_data_source.cpp index d849794..bbc7bf0 100644 --- a/pj_plugins/examples/mock_data_source.cpp +++ b/pj_plugins/examples/mock_data_source.cpp @@ -133,5 +133,5 @@ class MockDataSource : public PJ::DataSourcePluginBase { } // namespace PJ_DATA_SOURCE_PLUGIN( - MockDataSource, R"({"name":"Mock DataSource","version":"1.0.0",)" + MockDataSource, R"({"id":"mock-data-source","name":"Mock DataSource","version":"1.0.0",)" R"("description":"Test data source for protocol and host integration"})") diff --git a/pj_plugins/examples/mock_file_source.cpp b/pj_plugins/examples/mock_file_source.cpp index 7a73cd1..ebac0ab 100644 --- a/pj_plugins/examples/mock_file_source.cpp +++ b/pj_plugins/examples/mock_file_source.cpp @@ -56,6 +56,6 @@ class MockFileSource : public PJ::FileSourceBase { } // namespace PJ_DATA_SOURCE_PLUGIN( - MockFileSource, R"({"name":"Mock File Source","version":"1.0.0",)" + MockFileSource, R"({"id":"mock-file-source","name":"Mock File Source","version":"1.0.0",)" R"("description":"Test FileSourceBase lifecycle and progress",)" R"("file_extensions":[".mock"]})") diff --git a/pj_plugins/examples/mock_json_parser.cpp b/pj_plugins/examples/mock_json_parser.cpp index 48846cd..9cd1a32 100644 --- a/pj_plugins/examples/mock_json_parser.cpp +++ b/pj_plugins/examples/mock_json_parser.cpp @@ -22,4 +22,5 @@ class MockJsonParser : public PJ::MessageParserPluginBase { } // namespace -PJ_MESSAGE_PARSER_PLUGIN(MockJsonParser, R"({"name":"Mock JSON Parser","version":"1.0.0","encoding":"json"})") +PJ_MESSAGE_PARSER_PLUGIN( + MockJsonParser, R"({"id":"mock-json-parser","name":"Mock JSON Parser","version":"1.0.0","encoding":"json"})") diff --git a/pj_plugins/examples/mock_schema_parser.cpp b/pj_plugins/examples/mock_schema_parser.cpp index 62e4155..6d1eba1 100644 --- a/pj_plugins/examples/mock_schema_parser.cpp +++ b/pj_plugins/examples/mock_schema_parser.cpp @@ -109,4 +109,6 @@ class MockSchemaParser : public PJ::MessageParserPluginBase { } // namespace -PJ_MESSAGE_PARSER_PLUGIN(MockSchemaParser, R"({"name":"Mock Schema Parser","version":"1.0.0","encoding":"csv_pair"})") +PJ_MESSAGE_PARSER_PLUGIN( + MockSchemaParser, + R"({"id":"mock-schema-parser","name":"Mock Schema Parser","version":"1.0.0","encoding":"csv_pair"})") diff --git a/pj_plugins/examples/mock_source_with_dialog.cpp b/pj_plugins/examples/mock_source_with_dialog.cpp index 919f3f9..23723d2 100644 --- a/pj_plugins/examples/mock_source_with_dialog.cpp +++ b/pj_plugins/examples/mock_source_with_dialog.cpp @@ -370,7 +370,7 @@ class MockStreamerSource : public PJ::StreamSourceBase { }; PJ_DATA_SOURCE_PLUGIN( - MockStreamerSource, R"({"name":"Mock Streamer Source","version":"1.0.0",)" + MockStreamerSource, R"({"id":"mock-streamer-source","name":"Mock Streamer Source","version":"1.0.0",)" R"("description":"Combined DataSource+Dialog mock for integration testing"})") PJ_DIALOG_PLUGIN(MockStreamerDialog) diff --git a/pj_plugins/examples/mock_toolbox.cpp b/pj_plugins/examples/mock_toolbox.cpp index b194314..e8a6c56 100644 --- a/pj_plugins/examples/mock_toolbox.cpp +++ b/pj_plugins/examples/mock_toolbox.cpp @@ -79,5 +79,5 @@ class MockToolbox : public PJ::ToolboxPluginBase { } // namespace PJ_TOOLBOX_PLUGIN( - MockToolbox, R"({"name":"Mock Toolbox","version":"1.0.0",)" + MockToolbox, R"({"id":"mock-toolbox","name":"Mock Toolbox","version":"1.0.0",)" R"("description":"Test toolbox for protocol and host integration"})") diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index b77cdc9..d13f35c 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -2,23 +2,12 @@ /** * @file plugin_catalog.hpp - * @brief Pre-dlopen plugin discovery via `.pjmanifest.json` sidecars. + * @brief Plugin discovery from each DSO's embedded manifest. * - * Each v4 plugin DSO ships with a sidecar JSON file written next to it by - * CMake's pj_emit_plugin_manifest helper. The host scans a directory for - * these sidecars at startup, building a catalog of what's available — - * WITHOUT dlopen'ing any DSO. dlopen happens only when the user actually - * activates a plugin. - * - * This matters at scale: at 20-50 plugins the cold-start cost of dlopen'ing - * every candidate (for file-extension filters, parser encodings, toolbox - * menus, etc.) becomes noticeable and noisy. The sidecar scan keeps - * startup proportional to the number of JSON files, not the number of - * shared libraries. - * - * On activation, the host dlopens the DSO, calls get_plugin_manifest(), - * and verifies the runtime manifest matches the sidecar. Mismatch is a - * warning, not a fatal error — DSO truth wins. + * Plugin DSOs export a family-specific vtable whose `manifest_json` field is + * the source of truth for local plugin metadata. The scanner walks plugin files, + * inspects those exports, parses the embedded manifest, and reports both valid + * descriptors and diagnostics for candidates that could not be used. */ #include @@ -30,7 +19,7 @@ namespace PJ { -/// Plugin family as advertised by the sidecar's "family" key. +/// Plugin family inferred from the DSO's exported protocol vtable. enum class PluginFamily : uint32_t { kUnknown = 0, kDataSource = 1, @@ -39,16 +28,16 @@ enum class PluginFamily : uint32_t { kDialog = 4, }; -/// Plugin descriptor parsed from a single `.pjmanifest.json` sidecar. -/// All fields except `dso_path`, `abi_major`, `family`, `name`, and -/// `version` are optional and may be empty. +/// Plugin descriptor parsed from a DSO's embedded manifest. +/// All fields except `dso_path`, `family`, `id`, `name`, and `version` are +/// optional and may be empty. struct PluginDescriptor { - std::filesystem::path sidecar_path; - std::filesystem::path dso_path; // inferred as sidecar_path minus ".pjmanifest.json" plus platform DSO suffix + std::filesystem::path dso_path; uint32_t abi_major = 0; PluginFamily family = PluginFamily::kUnknown; + std::string id; std::string name; std::string version; std::string description; @@ -58,15 +47,26 @@ struct PluginDescriptor { std::vector capabilities; ///< optional capability tags }; -/// Scan a directory (non-recursive) for `*.pjmanifest.json` sidecars and -/// return the parsed descriptors. Invalid sidecars are skipped silently. -/// Returns an error only for filesystem-level problems (missing/unreadable -/// directory). -/// -/// Does NOT dlopen anything. -[[nodiscard]] Expected> scanPluginSidecars(const std::filesystem::path& directory); +/// Diagnostic for a candidate DSO that could not produce a valid descriptor. +struct PluginDiagnostic { + std::filesystem::path path; + std::string message; +}; + +/// Result of a directory scan: valid descriptors plus per-DSO diagnostics. +struct PluginScanResult { + std::vector plugins; + std::vector diagnostics; +}; + +/// Inspect one DSO and return its embedded plugin descriptor. +[[nodiscard]] Expected inspectPluginDso(const std::filesystem::path& dso_path); + +/// Recursively scan a directory for platform plugin DSOs. Invalid candidates are +/// reported in diagnostics while discovery continues. +[[nodiscard]] Expected scanPluginDsos(const std::filesystem::path& directory); -/// Human-readable name for a family. Inverse of the string used in the sidecar. +/// Human-readable name for a plugin family. [[nodiscard]] std::string_view toString(PluginFamily family) noexcept; } // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp new file mode 100644 index 0000000..ca5d03f --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include + +#include "pj_base/diagnostic_sink.hpp" +#include "pj_plugins/host/data_source_library.hpp" +#include "pj_plugins/host/message_parser_library.hpp" +#include "pj_plugins/host/plugin_catalog.hpp" +#include "pj_plugins/host/toolbox_library.hpp" + +namespace PJ { + +// Loaded DataSource plugin plus metadata used by host UIs and sessions. +struct RuntimeDataSourcePlugin { + DataSourceLibrary library; + std::string path; + std::string name; + std::string id; + std::string version; + std::vector file_extensions; + uint64_t capabilities = 0; + std::filesystem::file_time_type loaded_mtime; +}; + +// Loaded MessageParser plugin plus lookup metadata. +struct RuntimeMessageParserPlugin { + MessageParserLibrary library; + std::string path; + std::string name; + std::string id; + std::string version; + std::vector encodings; + std::filesystem::file_time_type loaded_mtime; +}; + +// Loaded Toolbox plugin plus metadata used by Tools menus. +struct RuntimeToolboxPlugin { + ToolboxLibrary library; + std::string path; + std::string name; + std::string id; + std::string version; + uint64_t capabilities = 0; + std::filesystem::file_time_type loaded_mtime; +}; + +// Shared host-side runtime catalog for discovering and loading plugin DSOs. +class PluginRuntimeCatalog { + public: + // Creates a catalog rooted at plugin_dir and reporting through sink. + explicit PluginRuntimeCatalog( + std::filesystem::path plugin_dir = {}, DiagnosticSink sink = {}, + std::string diagnostic_source = "PluginRuntimeCatalog"); + + // Replaces the directory scanned by scanDirectory() and reload(). + void setPluginDir(std::filesystem::path plugin_dir); + + // Replaces the optional diagnostic sink. + void setDiagnosticSink(DiagnosticSink sink); + + // Clears current state and loads every valid plugin under plugin_dir. + void scanDirectory(); + + // Reconciles loaded state with disk and returns true if it changed. + [[nodiscard]] bool reload(); + + // Returns loaded DataSource plugins. + [[nodiscard]] const std::vector& dataSources() const { return data_sources_; } + + // Returns loaded MessageParser plugins. + [[nodiscard]] const std::vector& messageParsers() const { return message_parsers_; } + + // Returns loaded Toolbox plugins. + [[nodiscard]] const std::vector& toolboxes() const { return toolbox_plugins_; } + + // Returns file-import capable DataSource plugins. + [[nodiscard]] std::vector fileImportSources(); + + // Returns file-import capable DataSource plugins. + [[nodiscard]] std::vector fileImportSources() const; + + // Returns streaming-capable DataSource plugins. + [[nodiscard]] std::vector streamSources(); + + // Returns streaming-capable DataSource plugins. + [[nodiscard]] std::vector streamSources() const; + + // Finds file-import DataSources that handle ext. + [[nodiscard]] std::vector findSourcesForExtension(std::string_view ext); + + // Finds file-import DataSources that handle ext. + [[nodiscard]] std::vector findSourcesForExtension(std::string_view ext) const; + + // Finds a MessageParser by encoding name. + [[nodiscard]] RuntimeMessageParserPlugin* findParserByEncoding(std::string_view encoding); + + // Finds a MessageParser by encoding name. + [[nodiscard]] const RuntimeMessageParserPlugin* findParserByEncoding(std::string_view encoding) const; + + // Builds a QFileDialog-compatible filter string. + [[nodiscard]] std::string buildFileFilter() const; + + // Lists parser encodings as a JSON string array. + [[nodiscard]] std::string listAvailableEncodings() const; + + private: + // Loads a descriptor using the family-specific loader. + bool loadAndRegister(const PluginDescriptor& descriptor); + + // Loads and records one DataSource plugin. + bool loadAndRegisterDataSource(const PluginDescriptor& descriptor); + + // Loads and records one MessageParser plugin. + bool loadAndRegisterMessageParser(const PluginDescriptor& descriptor); + + // Loads and records one Toolbox plugin. + bool loadAndRegisterToolbox(const PluginDescriptor& descriptor); + + // Removes any loaded plugin whose path matches path. + bool evictByPath(const std::string& path); + + // Returns the loaded mtime for path, or a default value. + [[nodiscard]] std::filesystem::file_time_type loadedMtimeForPath(const std::string& path) const; + + // Emits one diagnostic through the optional sink. + void report(DiagnosticLevel level, const std::string& id, std::string message) const; + + // Emits diagnostics produced by DSO discovery. + void reportScanDiagnostics(const PluginScanResult& scan) const; + + std::filesystem::path plugin_dir_; + DiagnosticSink sink_; + std::string diagnostic_source_; + std::vector data_sources_; + std::vector message_parsers_; + std::vector toolbox_plugins_; +}; + +} // namespace PJ diff --git a/pj_plugins/src/detail/library_loader.hpp b/pj_plugins/src/detail/library_loader.hpp index cfdb9b6..d5bf9f4 100644 --- a/pj_plugins/src/detail/library_loader.hpp +++ b/pj_plugins/src/detail/library_loader.hpp @@ -20,12 +20,10 @@ inline Expected loadLibraryHandle(std::string_view path) { // LOAD_WITH_ALTERED_SEARCH_PATH adds the directory of the loaded DLL to the // search path for resolving its dependencies — matches dlopen's default on // Linux. Without it, deps are only searched in the .exe directory, System32 - // and PATH, so plugins cannot ship their own sibling DLLs - HMODULE module = LoadLibraryExA(std::string(path).c_str(), nullptr, - LOAD_WITH_ALTERED_SEARCH_PATH); + // and PATH, so plugins cannot ship their own sibling DLLs + HMODULE module = LoadLibraryExA(std::string(path).c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); if (module == nullptr) { - return unexpected("LoadLibraryExA failed (error " + - std::to_string(GetLastError()) + ")"); + return unexpected("LoadLibraryExA failed (error " + std::to_string(GetLastError()) + ")"); } return reinterpret_cast(module); #else @@ -43,7 +41,6 @@ inline Expected loadLibraryHandle(std::string_view path) { // is instead achieved by building plugins with -fvisibility=hidden and // explicitly marking only the boot-level exports // (pj_plugin_abi_version + PJ_get__vtable) as default visible. - // See cmake/PjPluginManifest.cmake for the plugin build flags. int flags = RTLD_NOW | RTLD_LOCAL; void* handle = dlopen(std::string(path).c_str(), flags); if (handle == nullptr) { @@ -81,11 +78,14 @@ inline Expected resolveSymbol(void* handle, const char* symbol_name) { inline Expected checkPluginAbiVersion(void* handle) { auto sym = resolveSymbol(handle, "pj_plugin_abi_version"); if (!sym) { - return unexpected(std::string("plugin missing pj_plugin_abi_version symbol")); + return unexpected(std::string("plugin missing pj_plugin_abi_version symbol: ") + sym.error()); } const auto* plugin_abi = static_cast(*sym); if (plugin_abi == nullptr || *plugin_abi != PJ_ABI_VERSION) { - return unexpected(std::string("plugin pj_plugin_abi_version mismatch (expected 4)")); + const std::string actual = plugin_abi == nullptr ? "null" : std::to_string(*plugin_abi); + return unexpected( + std::string("plugin pj_plugin_abi_version mismatch (expected ") + std::to_string(PJ_ABI_VERSION) + ", got " + + actual + ")"); } return {}; } diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 4801b09..0236bbd 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -1,18 +1,24 @@ #include "pj_plugins/host/plugin_catalog.hpp" #include -#include +#include +#include #include -#include +#include +#include #include #include +#include "detail/library_loader.hpp" +#include "pj_base/data_source_protocol.h" +#include "pj_base/message_parser_protocol.h" +#include "pj_base/toolbox_protocol.h" +#include "pj_plugins/dialog_protocol.h" + namespace PJ { namespace { -constexpr std::string_view kSidecarSuffix = ".pjmanifest.json"; - #if defined(_WIN32) constexpr std::string_view kDsoSuffix = ".dll"; #elif defined(__APPLE__) @@ -21,106 +27,237 @@ constexpr std::string_view kDsoSuffix = ".dylib"; constexpr std::string_view kDsoSuffix = ".so"; #endif -PluginFamily parseFamily(std::string_view s) noexcept { - if (s == "data_source") { - return PluginFamily::kDataSource; +struct ManifestCandidate { + PluginFamily family = PluginFamily::kUnknown; + std::string manifest_json; +}; + +struct LibraryHandleCloser { + void operator()(void* handle) const { + detail::closeLibraryHandle(handle); + } +}; + +bool hasDsoSuffix(const std::filesystem::path& path) { + return path.extension().string() == kDsoSuffix; +} + +// Direct-vtable families share the exact same probe sequence: resolve symbol, +// call entry, check protocol_version, check struct_size, read manifest_json. +// Only the family-specific types and constants vary. +template +Expected probeDirectVtable( + void* handle, const char* symbol, const char* family_name, uint32_t expected_protocol, size_t min_vtable_size, + PluginFamily family) { + auto sym = detail::resolveSymbol(handle, symbol); + if (!sym) { + return unexpected(sym.error()); } - if (s == "message_parser") { - return PluginFamily::kMessageParser; + const Vtable* vt = reinterpret_cast(*sym)(); + if (vt == nullptr) { + return unexpected(std::string(symbol) + " returned null"); } - if (s == "toolbox") { - return PluginFamily::kToolbox; + if (vt->protocol_version != expected_protocol) { + return unexpected(std::string(family_name) + " protocol version mismatch"); } - if (s == "dialog") { - return PluginFamily::kDialog; + if (vt->struct_size < min_vtable_size) { + return unexpected(std::string(family_name) + " vtable smaller than v4.0 baseline"); } - return PluginFamily::kUnknown; + return ManifestCandidate{family, vt->manifest_json == nullptr ? "" : vt->manifest_json}; } -/// Best-effort decode of a single sidecar file. Returns empty optional on -/// anything malformed (missing required keys, JSON parse error, etc.). -std::optional decodeSidecar(const std::filesystem::path& sidecar_path) { - std::ifstream in(sidecar_path); - if (!in) { - return std::nullopt; +Expected tryDataSource(void* handle) { + return probeDirectVtable( + handle, "PJ_get_data_source_vtable", "DataSource", PJ_DATA_SOURCE_PROTOCOL_VERSION, + PJ_DATA_SOURCE_MIN_VTABLE_SIZE, PluginFamily::kDataSource); +} + +Expected tryMessageParser(void* handle) { + return probeDirectVtable( + handle, "PJ_get_message_parser_vtable", "MessageParser", PJ_MESSAGE_PARSER_PROTOCOL_VERSION, + PJ_MESSAGE_PARSER_MIN_VTABLE_SIZE, PluginFamily::kMessageParser); +} + +Expected tryToolbox(void* handle) { + return probeDirectVtable( + handle, "PJ_get_toolbox_vtable", "Toolbox", PJ_TOOLBOX_PLUGIN_PROTOCOL_VERSION, PJ_TOOLBOX_MIN_VTABLE_SIZE, + PluginFamily::kToolbox); +} + +Expected tryDialog(void* handle) { + auto sym = detail::resolveSymbol(handle, "PJ_get_dialog_vtable"); + if (!sym) { + return unexpected(sym.error()); } - nlohmann::json j; - try { - in >> j; - } catch (const nlohmann::json::parse_error&) { - return std::nullopt; + auto entry = reinterpret_cast(*sym); + const PJ_dialog_vtable_t* vt = entry(); + if (vt == nullptr) { + return unexpected(std::string("PJ_get_dialog_vtable returned null")); } - if (!j.is_object()) { - return std::nullopt; + if (vt->protocol_version != PJ_DIALOG_PROTOCOL_VERSION) { + return unexpected(std::string("Dialog protocol version mismatch")); + } + if (vt->struct_size < sizeof(PJ_dialog_vtable_t)) { + return unexpected(std::string("Dialog vtable smaller than v4.0 baseline")); + } + if (vt->create == nullptr || vt->destroy == nullptr || vt->get_manifest == nullptr) { + return unexpected(std::string("Dialog vtable missing required lifecycle slots")); } - PluginDescriptor d; - d.sidecar_path = sidecar_path; - - // Required keys. Reject sidecars that are missing any of these. - if (!j.contains("name") || !j["name"].is_string()) { - return std::nullopt; + void* ctx = vt->create(); + if (ctx == nullptr) { + return unexpected(std::string("PJ_dialog_vtable_t::create returned null")); } - if (!j.contains("version") || !j["version"].is_string()) { - return std::nullopt; + const char* manifest = vt->get_manifest(ctx); + std::string manifest_json = manifest == nullptr ? "" : manifest; + vt->destroy(ctx); + return ManifestCandidate{PluginFamily::kDialog, std::move(manifest_json)}; +} + +Expected findEmbeddedManifest(void* handle) { + std::vector errors; + + if (auto candidate = tryDataSource(handle)) { + return *candidate; + } else { + errors.push_back("data_source: " + candidate.error()); } - if (!j.contains("abi_major") || !j["abi_major"].is_number_integer()) { - return std::nullopt; + + if (auto candidate = tryMessageParser(handle)) { + return *candidate; + } else { + errors.push_back("message_parser: " + candidate.error()); } - if (!j.contains("family") || !j["family"].is_string()) { - return std::nullopt; + + if (auto candidate = tryToolbox(handle)) { + return *candidate; + } else { + errors.push_back("toolbox: " + candidate.error()); } - d.name = j["name"].get(); - d.version = j["version"].get(); - d.abi_major = j["abi_major"].get(); - d.family = parseFamily(j["family"].get()); - if (d.family == PluginFamily::kUnknown) { - return std::nullopt; + if (auto candidate = tryDialog(handle)) { + return *candidate; + } else { + errors.push_back("dialog: " + candidate.error()); } - // Optional fields. - if (j.contains("description") && j["description"].is_string()) { - d.description = j["description"].get(); + std::ostringstream out; + out << "no supported plugin vtable found"; + for (const auto& error : errors) { + out << "; " << error; } - if (j.contains("category") && j["category"].is_string()) { - d.category = j["category"].get(); + return unexpected(out.str()); +} + +Expected> readStringArray(const nlohmann::json& j, std::string_view key) { + std::vector values; + const auto it = j.find(std::string(key)); + if (it == j.end()) { + return values; } - if (j.contains("encoding") && j["encoding"].is_string()) { - d.encoding = j["encoding"].get(); + if (!it->is_array()) { + return unexpected(std::string("plugin embedded manifest key must be an array of strings: ") + std::string(key)); } - if (j.contains("file_extensions") && j["file_extensions"].is_array()) { - for (const auto& e : j["file_extensions"]) { - if (e.is_string()) { - d.file_extensions.push_back(e.get()); - } + for (const auto& value : *it) { + if (!value.is_string()) { + return unexpected(std::string("plugin embedded manifest key contains a non-string value: ") + std::string(key)); } + values.push_back(value.get()); } - if (j.contains("capabilities") && j["capabilities"].is_array()) { - for (const auto& c : j["capabilities"]) { - if (c.is_string()) { - d.capabilities.push_back(c.get()); - } + return values; +} + +Expected decodeManifest( + const std::filesystem::path& dso_path, PluginFamily family, std::string_view manifest_json) { + if (manifest_json.empty()) { + return unexpected(std::string("plugin embedded manifest is empty")); + } + + nlohmann::json j; + try { + j = nlohmann::json::parse(manifest_json); + } catch (const nlohmann::json::exception& e) { + return unexpected(std::string("plugin embedded manifest is invalid JSON: ") + e.what()); + } + + if (!j.is_object()) { + return unexpected(std::string("plugin embedded manifest must be a JSON object")); + } + + auto requiredString = [&](std::string_view key) -> Expected { + const auto it = j.find(std::string(key)); + if (it == j.end() || !it->is_string() || it->get().empty()) { + return unexpected(std::string("plugin embedded manifest missing required string key: ") + std::string(key)); + } + return it->get(); + }; + auto optionalString = [&](std::string_view key) -> Expected { + const auto it = j.find(std::string(key)); + if (it == j.end()) { + return std::string{}; + } + if (!it->is_string()) { + return unexpected(std::string("plugin embedded manifest key must be a string: ") + std::string(key)); } + return it->get(); + }; + + PluginDescriptor d; + d.dso_path = dso_path; + d.abi_major = PJ_ABI_VERSION; + d.family = family; + + auto id = requiredString("id"); + if (!id) { + return unexpected(id.error()); + } + auto name = requiredString("name"); + if (!name) { + return unexpected(name.error()); + } + auto version = requiredString("version"); + if (!version) { + return unexpected(version.error()); + } + + d.id = *id; + d.name = *name; + d.version = *version; + + auto description = optionalString("description"); + if (!description) { + return unexpected(description.error()); + } + auto category = optionalString("category"); + if (!category) { + return unexpected(category.error()); + } + auto file_extensions = readStringArray(j, "file_extensions"); + if (!file_extensions) { + return unexpected(file_extensions.error()); + } + auto capabilities = readStringArray(j, "capabilities"); + if (!capabilities) { + return unexpected(capabilities.error()); } - // Infer the DSO path: sidecar is ".pjmanifest.json"; DSO is - // "". On Linux, plugin DSOs built by us are - // usually "lib.so", but our CMake put them in the same directory - // without the "lib" prefix handling. Try both. - const auto stem_wo_ext = sidecar_path.stem().stem(); // drop ".pjmanifest" then ".json" - auto parent = sidecar_path.parent_path(); - std::filesystem::path candidate = parent / (stem_wo_ext.string() + std::string(kDsoSuffix)); - if (std::filesystem::exists(candidate)) { - d.dso_path = candidate; + d.description = *description; + d.category = *category; + d.file_extensions = *file_extensions; + d.capabilities = *capabilities; + + if (family == PluginFamily::kMessageParser) { + auto encoding = requiredString("encoding"); + if (!encoding) { + return unexpected(encoding.error()); + } + d.encoding = *encoding; } else { - candidate = parent / (std::string("lib") + stem_wo_ext.string() + std::string(kDsoSuffix)); - if (std::filesystem::exists(candidate)) { - d.dso_path = candidate; - } else { - // Leave dso_path empty — host will note "DSO not found for sidecar". - d.dso_path = parent / (stem_wo_ext.string() + std::string(kDsoSuffix)); + auto encoding = optionalString("encoding"); + if (!encoding) { + return unexpected(encoding.error()); } + d.encoding = *encoding; } return d; @@ -144,40 +281,83 @@ std::string_view toString(PluginFamily family) noexcept { return "unknown"; } -Expected> scanPluginSidecars(const std::filesystem::path& directory) { +Expected inspectPluginDso(const std::filesystem::path& dso_path) { + if (!hasDsoSuffix(dso_path)) { + return unexpected(std::string("not a platform plugin DSO: ") + dso_path.string()); + } + auto withPath = [&](const std::string& error) { return dso_path.string() + ": " + error; }; + + auto handle = detail::loadLibraryHandle(dso_path.string()); + if (!handle) { + return unexpected(withPath(handle.error())); + } + std::unique_ptr library(*handle); + + if (auto abi = detail::checkPluginAbiVersion(library.get()); !abi) { + return unexpected(withPath(abi.error())); + } + + auto candidate = findEmbeddedManifest(library.get()); + if (!candidate) { + return unexpected(withPath(candidate.error())); + } + + auto descriptor = decodeManifest(dso_path, candidate->family, candidate->manifest_json); + if (!descriptor) { + return unexpected(withPath(descriptor.error())); + } + return *descriptor; +} + +Expected scanPluginDsos(const std::filesystem::path& directory) { std::error_code ec; if (!std::filesystem::exists(directory, ec)) { - return unexpected(std::string("plugin directory does not exist: ") + directory.string()); + return unexpected(std::string("plugin directory not found: ") + directory.string()); } if (!std::filesystem::is_directory(directory, ec)) { return unexpected(std::string("plugin path is not a directory: ") + directory.string()); } - std::vector result; - for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) { - if (ec) { - break; - } - if (!entry.is_regular_file()) { - continue; - } - const auto& path = entry.path(); - const auto name = path.filename().string(); - if (name.size() < kSidecarSuffix.size()) { - continue; - } - if (!std::equal(kSidecarSuffix.rbegin(), kSidecarSuffix.rend(), name.rbegin())) { - continue; + PluginScanResult result; + // Use the increment-with-error-code overload so a single inaccessible subtree + // (e.g. one extension dir owned by another user) doesn't terminate the entire + // scan and silently hide every later extension from the marketplace. + std::filesystem::recursive_directory_iterator it(directory, ec); + if (ec) { + result.diagnostics.push_back({directory, "directory iteration failed: " + ec.message()}); + return result; + } + const std::filesystem::recursive_directory_iterator end; + while (it != end) { + const auto entry = *it; + std::error_code entry_ec; + const bool is_file = entry.is_regular_file(entry_ec); + if (!entry_ec && is_file && hasDsoSuffix(entry.path())) { + auto descriptor = inspectPluginDso(entry.path()); + if (descriptor) { + result.plugins.push_back(std::move(*descriptor)); + } else { + result.diagnostics.push_back({entry.path(), descriptor.error()}); + } + } else if (entry_ec) { + result.diagnostics.push_back({entry.path(), "stat failed: " + entry_ec.message()}); } - if (auto d = decodeSidecar(path); d.has_value()) { - result.push_back(std::move(*d)); + + std::error_code inc_ec; + it.increment(inc_ec); + if (inc_ec) { + result.diagnostics.push_back({entry.path(), "directory iteration failed: " + inc_ec.message()}); + // Skip the unreadable subtree but continue with the rest of the scan. + it.pop(); } } - // Deterministic order for reproducible catalogs. - std::sort(result.begin(), result.end(), [](const PluginDescriptor& a, const PluginDescriptor& b) { - return a.sidecar_path < b.sidecar_path; + std::sort(result.plugins.begin(), result.plugins.end(), [](const PluginDescriptor& a, const PluginDescriptor& b) { + return a.dso_path < b.dso_path; }); + std::sort( + result.diagnostics.begin(), result.diagnostics.end(), + [](const PluginDiagnostic& a, const PluginDiagnostic& b) { return a.path < b.path; }); return result; } diff --git a/pj_plugins/src/plugin_runtime_catalog.cpp b/pj_plugins/src/plugin_runtime_catalog.cpp new file mode 100644 index 0000000..a43bc7f --- /dev/null +++ b/pj_plugins/src/plugin_runtime_catalog.cpp @@ -0,0 +1,406 @@ +#include "pj_plugins/host/plugin_runtime_catalog.hpp" + +#include +#include +#include +#include + +#include "pj_base/data_source_protocol.h" + +namespace PJ { + +namespace { + +std::string canonicalPath(const std::filesystem::path& path) { + std::error_code ec; + auto canon = std::filesystem::weakly_canonical(path, ec); + return (ec ? path : canon).string(); +} + +std::filesystem::file_time_type safeMtime(const std::filesystem::path& path) { + std::error_code ec; + const auto mtime = std::filesystem::last_write_time(path, ec); + return ec ? std::filesystem::file_time_type{} : mtime; +} + +std::string normalizeExtension(std::string ext) { + if (!ext.empty() && ext.front() != '.') { + ext.insert(ext.begin(), '.'); + } + return ext; +} + +template +std::vector mutablePtrs(std::vector& plugins, uint64_t capability) { + std::vector out; + for (auto& plugin : plugins) { + if ((plugin.capabilities & capability) != 0) { + out.push_back(&plugin); + } + } + return out; +} + +template +std::vector constPtrs(const std::vector& plugins, uint64_t capability) { + std::vector out; + for (const auto& plugin : plugins) { + if ((plugin.capabilities & capability) != 0) { + out.push_back(&plugin); + } + } + return out; +} + +} // namespace + +PluginRuntimeCatalog::PluginRuntimeCatalog( + std::filesystem::path plugin_dir, DiagnosticSink sink, std::string diagnostic_source) + : plugin_dir_(std::move(plugin_dir)), + sink_(std::move(sink)), + diagnostic_source_(std::move(diagnostic_source)) {} + +void PluginRuntimeCatalog::setPluginDir(std::filesystem::path plugin_dir) { + plugin_dir_ = std::move(plugin_dir); +} + +void PluginRuntimeCatalog::setDiagnosticSink(DiagnosticSink sink) { + sink_ = std::move(sink); +} + +void PluginRuntimeCatalog::scanDirectory() { + data_sources_.clear(); + message_parsers_.clear(); + toolbox_plugins_.clear(); + + auto scan = scanPluginDsos(plugin_dir_); + if (!scan) { + report(DiagnosticLevel::kError, {}, scan.error()); + return; + } + reportScanDiagnostics(*scan); + + for (const PluginDescriptor& descriptor : scan->plugins) { + if (!loadAndRegister(descriptor)) { + report( + DiagnosticLevel::kError, descriptor.id, + descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + + " plugin"); + } + } +} + +bool PluginRuntimeCatalog::reload() { + auto scan = scanPluginDsos(plugin_dir_); + if (!scan) { + report(DiagnosticLevel::kError, {}, scan.error()); + return false; + } + reportScanDiagnostics(*scan); + + std::vector on_disk; + on_disk.reserve(scan->plugins.size()); + for (const PluginDescriptor& descriptor : scan->plugins) { + on_disk.push_back(canonicalPath(descriptor.dso_path)); + } + + bool changed = false; + auto drop_missing = [&](auto& vec, std::string_view family) { + const auto before = vec.size(); + std::erase_if(vec, [&](const auto& plugin) { + const bool gone = std::find(on_disk.begin(), on_disk.end(), plugin.path) == on_disk.end(); + if (gone) { + report(DiagnosticLevel::kInfo, plugin.id, "Unloaded " + std::string(family) + ": " + plugin.path); + } + return gone; + }); + changed = changed || vec.size() != before; + }; + drop_missing(data_sources_, "DataSource"); + drop_missing(message_parsers_, "MessageParser"); + drop_missing(toolbox_plugins_, "Toolbox"); + + for (const PluginDescriptor& descriptor : scan->plugins) { + const std::string path = canonicalPath(descriptor.dso_path); + const auto disk_mtime = safeMtime(descriptor.dso_path); + if (disk_mtime == std::filesystem::file_time_type{}) { + report(DiagnosticLevel::kWarning, descriptor.id, descriptor.dso_path.string() + ": could not read mtime"); + continue; + } + + const auto prior_mtime = loadedMtimeForPath(path); + const bool already_loaded = prior_mtime != std::filesystem::file_time_type{}; + if (already_loaded && disk_mtime <= prior_mtime) { + continue; + } + if (already_loaded) { + evictByPath(path); + changed = true; + } + if (loadAndRegister(descriptor)) { + changed = true; + } else { + report( + DiagnosticLevel::kError, descriptor.id, + descriptor.dso_path.string() + ": failed to load " + std::string(toString(descriptor.family)) + + " plugin"); + } + } + + return changed; +} + +bool PluginRuntimeCatalog::loadAndRegister(const PluginDescriptor& descriptor) { + switch (descriptor.family) { + case PluginFamily::kDataSource: + return loadAndRegisterDataSource(descriptor); + case PluginFamily::kMessageParser: + return loadAndRegisterMessageParser(descriptor); + case PluginFamily::kToolbox: + return loadAndRegisterToolbox(descriptor); + case PluginFamily::kDialog: + report( + DiagnosticLevel::kWarning, descriptor.id, + descriptor.dso_path.string() + ": standalone dialog plugin \"" + descriptor.name + + "\" discovered; dialogs are loaded through owning plugins"); + return false; + case PluginFamily::kUnknown: + break; + } + return false; +} + +bool PluginRuntimeCatalog::loadAndRegisterDataSource(const PluginDescriptor& descriptor) { + auto result = DataSourceLibrary::load(descriptor.dso_path.string()); + if (!result) { + report(DiagnosticLevel::kError, descriptor.id, descriptor.dso_path.string() + ": " + result.error()); + return false; + } + + RuntimeDataSourcePlugin loaded; + loaded.library = std::move(*result); + loaded.path = canonicalPath(descriptor.dso_path); + loaded.loaded_mtime = safeMtime(descriptor.dso_path); + loaded.id = descriptor.id; + loaded.name = descriptor.name; + loaded.version = descriptor.version; + loaded.capabilities = loaded.library.createHandle().capabilities(); + loaded.file_extensions.reserve(descriptor.file_extensions.size()); + for (const auto& ext : descriptor.file_extensions) { + loaded.file_extensions.push_back(normalizeExtension(ext)); + } + + report(DiagnosticLevel::kInfo, loaded.id, "Loaded DataSource " + loaded.name + " from " + loaded.path); + data_sources_.push_back(std::move(loaded)); + return true; +} + +bool PluginRuntimeCatalog::loadAndRegisterMessageParser(const PluginDescriptor& descriptor) { + auto result = MessageParserLibrary::load(descriptor.dso_path.string()); + if (!result) { + report(DiagnosticLevel::kError, descriptor.id, descriptor.dso_path.string() + ": " + result.error()); + return false; + } + + RuntimeMessageParserPlugin loaded; + loaded.library = std::move(*result); + loaded.path = canonicalPath(descriptor.dso_path); + loaded.loaded_mtime = safeMtime(descriptor.dso_path); + loaded.id = descriptor.id; + loaded.name = descriptor.name; + loaded.version = descriptor.version; + if (!descriptor.encoding.empty()) { + loaded.encodings.push_back(descriptor.encoding); + } + + report(DiagnosticLevel::kInfo, loaded.id, "Loaded MessageParser " + loaded.name + " from " + loaded.path); + message_parsers_.push_back(std::move(loaded)); + return true; +} + +bool PluginRuntimeCatalog::loadAndRegisterToolbox(const PluginDescriptor& descriptor) { + auto result = ToolboxLibrary::load(descriptor.dso_path.string()); + if (!result) { + report(DiagnosticLevel::kError, descriptor.id, descriptor.dso_path.string() + ": " + result.error()); + return false; + } + + RuntimeToolboxPlugin loaded; + loaded.library = std::move(*result); + loaded.path = canonicalPath(descriptor.dso_path); + loaded.loaded_mtime = safeMtime(descriptor.dso_path); + loaded.id = descriptor.id; + loaded.name = descriptor.name; + loaded.version = descriptor.version; + loaded.capabilities = loaded.library.createHandle().capabilities(); + + report(DiagnosticLevel::kInfo, loaded.id, "Loaded Toolbox " + loaded.name + " from " + loaded.path); + toolbox_plugins_.push_back(std::move(loaded)); + return true; +} + +bool PluginRuntimeCatalog::evictByPath(const std::string& path) { + bool removed = false; + auto erase_path = [&](auto& vec) { + const auto before = vec.size(); + std::erase_if(vec, [&](const auto& plugin) { return plugin.path == path; }); + removed = removed || vec.size() != before; + }; + erase_path(data_sources_); + erase_path(message_parsers_); + erase_path(toolbox_plugins_); + return removed; +} + +std::filesystem::file_time_type PluginRuntimeCatalog::loadedMtimeForPath(const std::string& path) const { + auto find_mtime = [&](const auto& vec) { + auto it = std::find_if(vec.begin(), vec.end(), [&](const auto& plugin) { return plugin.path == path; }); + return it == vec.end() ? std::filesystem::file_time_type{} : it->loaded_mtime; + }; + if (auto mtime = find_mtime(data_sources_); mtime != std::filesystem::file_time_type{}) { + return mtime; + } + if (auto mtime = find_mtime(message_parsers_); mtime != std::filesystem::file_time_type{}) { + return mtime; + } + return find_mtime(toolbox_plugins_); +} + +std::vector PluginRuntimeCatalog::fileImportSources() { + return mutablePtrs(data_sources_, PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT); +} + +std::vector PluginRuntimeCatalog::fileImportSources() const { + return constPtrs(data_sources_, PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT); +} + +std::vector PluginRuntimeCatalog::streamSources() { + return mutablePtrs(data_sources_, PJ_DATA_SOURCE_CAPABILITY_CONTINUOUS_STREAM); +} + +std::vector PluginRuntimeCatalog::streamSources() const { + return constPtrs(data_sources_, PJ_DATA_SOURCE_CAPABILITY_CONTINUOUS_STREAM); +} + +std::vector PluginRuntimeCatalog::findSourcesForExtension(std::string_view ext) { + std::vector out; + for (auto& source : data_sources_) { + if ((source.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) == 0) { + continue; + } + if (std::find(source.file_extensions.begin(), source.file_extensions.end(), ext) != source.file_extensions.end()) { + out.push_back(&source); + } + } + return out; +} + +std::vector PluginRuntimeCatalog::findSourcesForExtension(std::string_view ext) const { + std::vector out; + for (const auto& source : data_sources_) { + if ((source.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) == 0) { + continue; + } + if (std::find(source.file_extensions.begin(), source.file_extensions.end(), ext) != source.file_extensions.end()) { + out.push_back(&source); + } + } + return out; +} + +RuntimeMessageParserPlugin* PluginRuntimeCatalog::findParserByEncoding(std::string_view encoding) { + for (auto& parser : message_parsers_) { + if (std::find(parser.encodings.begin(), parser.encodings.end(), encoding) != parser.encodings.end()) { + return &parser; + } + } + return nullptr; +} + +const RuntimeMessageParserPlugin* PluginRuntimeCatalog::findParserByEncoding(std::string_view encoding) const { + for (const auto& parser : message_parsers_) { + if (std::find(parser.encodings.begin(), parser.encodings.end(), encoding) != parser.encodings.end()) { + return &parser; + } + } + return nullptr; +} + +std::string PluginRuntimeCatalog::buildFileFilter() const { + std::string all_exts; + std::string per_plugin; + for (const auto& source : data_sources_) { + if ((source.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) == 0 || + source.file_extensions.empty()) { + continue; + } + + if (!per_plugin.empty()) { + per_plugin += ";;"; + } + per_plugin += source.name + " ("; + for (size_t i = 0; i < source.file_extensions.size(); ++i) { + if (i > 0) { + per_plugin += " "; + } + per_plugin += "*" + source.file_extensions[i]; + if (!all_exts.empty()) { + all_exts += " "; + } + all_exts += "*" + source.file_extensions[i]; + } + per_plugin += ")"; + } + + std::string filter; + if (!all_exts.empty()) { + filter = "All supported files (" + all_exts + ")"; + if (!per_plugin.empty()) { + filter += ";;" + per_plugin; + } + } else { + filter = per_plugin; + } + if (!filter.empty()) { + filter += ";;"; + } + filter += "All files (*)"; + return filter; +} + +std::string PluginRuntimeCatalog::listAvailableEncodings() const { + std::vector unique_encodings; + for (const auto& parser : message_parsers_) { + for (const auto& encoding : parser.encodings) { + if (std::find(unique_encodings.begin(), unique_encodings.end(), encoding) == unique_encodings.end()) { + unique_encodings.push_back(encoding); + } + } + } + + std::ostringstream out; + out << '['; + for (size_t i = 0; i < unique_encodings.size(); ++i) { + if (i > 0) { + out << ','; + } + out << '"' << unique_encodings[i] << '"'; + } + out << ']'; + return out.str(); +} + +void PluginRuntimeCatalog::report(DiagnosticLevel level, const std::string& id, std::string message) const { + if (!sink_) { + return; + } + sink_(Diagnostic{level, diagnostic_source_, id, std::move(message), std::chrono::system_clock::now()}); +} + +void PluginRuntimeCatalog::reportScanDiagnostics(const PluginScanResult& scan) const { + for (const auto& diagnostic : scan.diagnostics) { + report(DiagnosticLevel::kError, {}, diagnostic.path.string() + ": " + diagnostic.message); + } +} + +} // namespace PJ diff --git a/pj_plugins/tests/invalid_optional_manifest_data_source_plugin.cpp b/pj_plugins/tests/invalid_optional_manifest_data_source_plugin.cpp new file mode 100644 index 0000000..92bbc15 --- /dev/null +++ b/pj_plugins/tests/invalid_optional_manifest_data_source_plugin.cpp @@ -0,0 +1,75 @@ +#include "pj_base/data_source_protocol.h" + +extern "C" PJ_DATA_SOURCE_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; + +namespace { + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool bind(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool save(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr const char* kJson = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool load(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool ok(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +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_DATA_SOURCE_PROTOCOL_VERSION, + sizeof(PJ_data_source_vtable_t), + create, + destroy, + R"({"id":"invalid-optional-source","name":"Invalid Optional Source","version":"1.0.0","description":42})", + capabilities, + bind, + save, + load, + ok, + stop, + ok, + ok, + ok, + state, + dialog, + extension, + }; + return &vt; +} diff --git a/pj_plugins/tests/missing_id_data_source_plugin.cpp b/pj_plugins/tests/missing_id_data_source_plugin.cpp new file mode 100644 index 0000000..d443641 --- /dev/null +++ b/pj_plugins/tests/missing_id_data_source_plugin.cpp @@ -0,0 +1,75 @@ +#include "pj_base/data_source_protocol.h" + +extern "C" PJ_DATA_SOURCE_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; + +namespace { + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool ok3(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool okSave(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr const char* kJson = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool okLoad(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool okError(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +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_DATA_SOURCE_PROTOCOL_VERSION, + sizeof(PJ_data_source_vtable_t), + create, + destroy, + R"({"name":"Missing Id Source","version":"1.0.0"})", + capabilities, + ok3, + okSave, + okLoad, + okError, + stop, + okError, + okError, + okError, + state, + dialog, + extension, + }; + return &vt; +} diff --git a/pj_plugins/tests/mock_data_source_v2_plugin.cpp b/pj_plugins/tests/mock_data_source_v2_plugin.cpp new file mode 100644 index 0000000..85c4e44 --- /dev/null +++ b/pj_plugins/tests/mock_data_source_v2_plugin.cpp @@ -0,0 +1,75 @@ +#include "pj_base/data_source_protocol.h" + +extern "C" PJ_DATA_SOURCE_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; + +namespace { + +void* create() noexcept { + return reinterpret_cast(0x1); +} + +void destroy(void*) noexcept {} + +uint64_t capabilities(void*) noexcept { + return 0; +} + +bool bind(void*, PJ_service_registry_t, PJ_error_t*) noexcept { + return true; +} + +bool save(void*, PJ_string_view_t* out_json, PJ_error_t*) noexcept { + static constexpr const char* kJson = "{}"; + if (out_json != nullptr) { + out_json->data = kJson; + out_json->size = 2; + } + return true; +} + +bool load(void*, PJ_string_view_t, PJ_error_t*) noexcept { + return true; +} + +bool ok(void*, PJ_error_t*) noexcept { + return true; +} + +void stop(void*) noexcept {} + +PJ_data_source_state_t state(void*) noexcept { + return PJ_DATA_SOURCE_STATE_IDLE; +} + +PJ_borrowed_dialog_t dialog(void*) noexcept { + return PJ_borrowed_dialog_t{nullptr, nullptr}; +} + +const void* extension(void*, PJ_string_view_t) noexcept { + return nullptr; +} + +} // namespace + +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_DATA_SOURCE_PROTOCOL_VERSION, + sizeof(PJ_data_source_vtable_t), + create, + destroy, + R"({"id":"mock-data-source","name":"Mock DataSource","version":"2.0.0"})", + capabilities, + bind, + save, + load, + ok, + stop, + ok, + ok, + ok, + state, + dialog, + extension, + }; + return &vt; +} diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index 1357090..4b47858 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -1,14 +1,9 @@ -/** - * @file plugin_catalog_test.cpp - * @brief Tests for the sidecar-based plugin discovery scanner (Phase 1d). - * - * The scanner is pure filesystem + JSON — no dlopen. We write synthetic - * sidecars into a temp directory and verify the descriptors round-trip. - */ #include "pj_plugins/host/plugin_catalog.hpp" #include +#include +#include #include #include #include @@ -16,6 +11,16 @@ namespace PJ { namespace { +std::string pluginFileName(const std::string& stem) { +#if defined(_WIN32) + return stem + ".dll"; +#elif defined(__APPLE__) + return stem + ".dylib"; +#else + return stem + ".so"; +#endif +} + class PluginCatalogTest : public ::testing::Test { protected: void SetUp() override { @@ -30,105 +35,97 @@ class PluginCatalogTest : public ::testing::Test { std::filesystem::remove_all(dir_, ec); } - void writeSidecar(const std::string& stem, const std::string& json) { - std::ofstream out(dir_ / (stem + ".pjmanifest.json")); - out << json; + std::filesystem::path copyPlugin(const std::string& source, const std::string& name) { + const std::filesystem::path dst = dir_ / name; + std::filesystem::copy_file(source, dst, std::filesystem::copy_options::overwrite_existing); + return dst; } std::filesystem::path dir_; }; TEST_F(PluginCatalogTest, MissingDirectoryReturnsError) { - auto result = scanPluginSidecars("/nonexistent/path/xyz"); + auto result = scanPluginDsos("/nonexistent/path/xyz"); EXPECT_FALSE(result.has_value()); } -TEST_F(PluginCatalogTest, EmptyDirectoryReturnsEmptyVector) { - auto result = scanPluginSidecars(dir_); +TEST_F(PluginCatalogTest, EmptyDirectoryReturnsEmptyResult) { + auto result = scanPluginDsos(dir_); ASSERT_TRUE(result.has_value()) << result.error(); - EXPECT_TRUE(result->empty()); + EXPECT_TRUE(result->plugins.empty()); + EXPECT_TRUE(result->diagnostics.empty()); } -TEST_F(PluginCatalogTest, ValidSidecarDecodes) { - writeSidecar("my_plugin", R"({ - "name": "My Plugin", - "version": "1.2.3", - "abi_major": 4, - "family": "data_source", - "description": "A test plugin", - "category": "File", - "file_extensions": [".csv", ".tsv"] - })"); - - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()) << result.error(); - ASSERT_EQ(result->size(), 1U); - const auto& d = (*result)[0]; - - EXPECT_EQ(d.name, "My Plugin"); - EXPECT_EQ(d.version, "1.2.3"); - EXPECT_EQ(d.abi_major, 4U); - EXPECT_EQ(d.family, PluginFamily::kDataSource); - EXPECT_EQ(d.description, "A test plugin"); - EXPECT_EQ(d.category, "File"); - ASSERT_EQ(d.file_extensions.size(), 2U); - EXPECT_EQ(d.file_extensions[0], ".csv"); - EXPECT_EQ(d.file_extensions[1], ".tsv"); - EXPECT_EQ(d.sidecar_path.filename(), "my_plugin.pjmanifest.json"); +TEST_F(PluginCatalogTest, InspectDataSourceDsoUsesEmbeddedManifest) { + auto descriptor = inspectPluginDso(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-data-source"); + EXPECT_EQ(descriptor->name, "Mock DataSource"); + EXPECT_EQ(descriptor->version, "1.0.0"); + EXPECT_EQ(descriptor->family, PluginFamily::kDataSource); } -TEST_F(PluginCatalogTest, MalformedJsonIsSkipped) { - writeSidecar("broken", "{ this is not valid json"); - writeSidecar("good", R"({"name":"G","version":"1","abi_major":4,"family":"toolbox"})"); +TEST_F(PluginCatalogTest, InspectMessageParserRequiresEncoding) { + auto descriptor = inspectPluginDso(PJ_MOCK_JSON_PARSER_PLUGIN_PATH); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-json-parser"); + EXPECT_EQ(descriptor->family, PluginFamily::kMessageParser); + EXPECT_EQ(descriptor->encoding, "json"); +} - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->size(), 1U); - EXPECT_EQ((*result)[0].name, "G"); +TEST_F(PluginCatalogTest, InspectToolboxDsoUsesEmbeddedManifest) { + auto descriptor = inspectPluginDso(PJ_MOCK_TOOLBOX_PLUGIN_PATH); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-toolbox"); + EXPECT_EQ(descriptor->family, PluginFamily::kToolbox); } -TEST_F(PluginCatalogTest, MissingRequiredKeyIsSkipped) { - writeSidecar("no_version", R"({"name":"X","abi_major":4,"family":"dialog"})"); - writeSidecar("no_family", R"({"name":"Y","version":"1","abi_major":4})"); - writeSidecar("complete", R"({"name":"Z","version":"1","abi_major":4,"family":"message_parser"})"); +TEST_F(PluginCatalogTest, InspectDialogDsoUsesEmbeddedManifest) { + auto descriptor = inspectPluginDso(PJ_MOCK_DIALOG_PLUGIN_PATH); + ASSERT_TRUE(descriptor.has_value()) << descriptor.error(); + EXPECT_EQ(descriptor->id, "mock-dialog"); + EXPECT_EQ(descriptor->family, PluginFamily::kDialog); +} - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->size(), 1U); - EXPECT_EQ((*result)[0].name, "Z"); - EXPECT_EQ((*result)[0].family, PluginFamily::kMessageParser); +TEST_F(PluginCatalogTest, MissingIdManifestIsRejected) { + auto descriptor = inspectPluginDso(PJ_MISSING_ID_PLUGIN_PATH); + ASSERT_FALSE(descriptor.has_value()); + EXPECT_NE(descriptor.error().find("id"), std::string::npos); } -TEST_F(PluginCatalogTest, UnknownFamilyIsSkipped) { - writeSidecar("bogus", R"({"name":"B","version":"1","abi_major":4,"family":"something_else"})"); - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - EXPECT_TRUE(result->empty()); +TEST_F(PluginCatalogTest, InvalidOptionalManifestFieldIsReportedAsDiagnostic) { + copyPlugin(PJ_INVALID_OPTIONAL_PLUGIN_PATH, pluginFileName("invalid_optional")); + + auto result = scanPluginDsos(dir_); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_TRUE(result->plugins.empty()); + ASSERT_EQ(result->diagnostics.size(), 1U); + EXPECT_NE(result->diagnostics[0].message.find("description"), std::string::npos); + EXPECT_NE(result->diagnostics[0].message.find("invalid_optional"), std::string::npos); } -TEST_F(PluginCatalogTest, NonSidecarFilesAreIgnored) { - writeSidecar("p1", R"({"name":"P1","version":"1","abi_major":4,"family":"data_source"})"); - // Write a non-sidecar file - std::ofstream(dir_ / "random.txt") << "hello"; - std::ofstream(dir_ / "libp1.so") << "fake binary"; +TEST_F(PluginCatalogTest, ScanContinuesAfterBrokenDso) { + copyPlugin(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, pluginFileName("valid")); + std::ofstream(dir_ / pluginFileName("broken")) << "not a shared library"; + std::ofstream(dir_ / "notes.txt") << "not a candidate"; - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->size(), 1U); - EXPECT_EQ((*result)[0].name, "P1"); + auto result = scanPluginDsos(dir_); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->plugins.size(), 1U); + EXPECT_EQ(result->plugins[0].id, "mock-data-source"); + ASSERT_EQ(result->diagnostics.size(), 1U); + EXPECT_EQ(result->diagnostics[0].path.filename(), pluginFileName("broken")); } TEST_F(PluginCatalogTest, ResultIsSortedByPath) { - writeSidecar("zz_plugin", R"({"name":"Z","version":"1","abi_major":4,"family":"toolbox"})"); - writeSidecar("aa_plugin", R"({"name":"A","version":"1","abi_major":4,"family":"toolbox"})"); - writeSidecar("mm_plugin", R"({"name":"M","version":"1","abi_major":4,"family":"toolbox"})"); - - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->size(), 3U); - EXPECT_EQ((*result)[0].name, "A"); - EXPECT_EQ((*result)[1].name, "M"); - EXPECT_EQ((*result)[2].name, "Z"); + copyPlugin(PJ_MOCK_TOOLBOX_PLUGIN_PATH, pluginFileName("zz_plugin")); + copyPlugin(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, pluginFileName("aa_plugin")); + + auto result = scanPluginDsos(dir_); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_EQ(result->plugins.size(), 2U); + EXPECT_EQ(result->plugins[0].dso_path.filename(), pluginFileName("aa_plugin")); + EXPECT_EQ(result->plugins[1].dso_path.filename(), pluginFileName("zz_plugin")); } TEST_F(PluginCatalogTest, FamilyToStringRoundTrip) { @@ -141,30 +138,3 @@ TEST_F(PluginCatalogTest, FamilyToStringRoundTrip) { } // namespace } // namespace PJ - -// --------------------------------------------------------------------------- -// Integration test: scan the actual build-tree plugin directory if present. -// Lets us verify that the pj_emit_plugin_manifest CMake helper produces -// sidecars that scanPluginSidecars actually consumes correctly. -// --------------------------------------------------------------------------- - -#ifdef PJ_PORTED_PLUGINS_BIN_DIR -TEST(PluginCatalogIntegration, ScansPortedPluginsBinDir) { - const std::filesystem::path bin_dir = PJ_PORTED_PLUGINS_BIN_DIR; - if (!std::filesystem::exists(bin_dir)) { - GTEST_SKIP() << "ported plugins bin dir not present: " << bin_dir; - } - - auto result = PJ::scanPluginSidecars(bin_dir); - ASSERT_TRUE(result.has_value()) << result.error(); - - // Every entry must parse cleanly and have abi_major == 4. - EXPECT_FALSE(result->empty()) << "no sidecars found in " << bin_dir; - for (const auto& d : *result) { - EXPECT_EQ(d.abi_major, 4U) << "sidecar " << d.sidecar_path << " has abi_major != 4"; - EXPECT_NE(d.family, PJ::PluginFamily::kUnknown); - EXPECT_FALSE(d.name.empty()); - EXPECT_FALSE(d.version.empty()); - } -} -#endif diff --git a/pj_proto_app/CMakeLists.txt b/pj_proto_app/CMakeLists.txt index fc64a1b..9c3d14d 100644 --- a/pj_proto_app/CMakeLists.txt +++ b/pj_proto_app/CMakeLists.txt @@ -22,6 +22,7 @@ target_link_libraries(pj_proto_app PRIVATE pj_data_source_host pj_message_parser_host pj_toolbox_host + pj_plugin_runtime_catalog pj_dialog_engine_qt pj_marketplace pj_marketplace_ui @@ -29,3 +30,33 @@ target_link_libraries(pj_proto_app PRIVATE Qt6::Widgets Qt6::Charts ) + +if(PJ_BUILD_TESTS) + add_executable(proto_plugin_registry_test + tests/plugin_registry_test.cpp + src/plugin_registry.cpp + ) + target_include_directories(proto_plugin_registry_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + target_compile_definitions(proto_plugin_registry_test PRIVATE + PJ_MOCK_DATA_SOURCE_PLUGIN_PATH="$" + PJ_MOCK_JSON_PARSER_PLUGIN_PATH="$" + ) + target_link_libraries(proto_plugin_registry_test PRIVATE + pj_datastore + pj_data_source_host + pj_message_parser_host + pj_toolbox_host + pj_plugin_runtime_catalog + pj_marketplace + GTest::gtest_main + nlohmann_json::nlohmann_json + Qt6::Core + ) + add_dependencies(proto_plugin_registry_test mock_data_source_plugin mock_json_parser_plugin) + set_target_properties(proto_plugin_registry_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/tests + ) + add_test(NAME proto_plugin_registry_test COMMAND proto_plugin_registry_test) +endif() diff --git a/pj_proto_app/src/main_window.cpp b/pj_proto_app/src/main_window.cpp index e3e8b35..c3f7ce2 100644 --- a/pj_proto_app/src/main_window.cpp +++ b/pj_proto_app/src/main_window.cpp @@ -1,14 +1,18 @@ #include "main_window.hpp" #include +#include +#include #include #include #include #include #include #include +#include #include #include +#include #include #include #include @@ -17,8 +21,10 @@ #include #include "pj_datastore/reader.hpp" +#include "pj_marketplace/download_manager.hpp" #include "pj_marketplace/extension_manager.hpp" #include "pj_marketplace/marketplace_window.hpp" +#include "pj_marketplace/platform_utils.hpp" #include "pj_plugins/host_qt/dialog_engine.hpp" #include "plugin_registry.hpp" @@ -112,17 +118,27 @@ ShowMessageBoxCallback makeMessageBoxCallback(QWidget* parent) { } // namespace -MainWindow::MainWindow(const std::string& plugin_dir, QWidget* parent) - : QMainWindow(parent), registry_(plugin_dir), tree_model_(engine_) { +MainWindow::MainWindow(const std::string& plugin_dir, QWidget* parent) : QMainWindow(parent), tree_model_(engine_) { auto td_result = engine_.createTimeDomain("default"); if (td_result) { default_td_id_ = *td_result; } - ext_mgr_ = std::make_unique(); - ext_mgr_->applyPendingInstalls(); - ext_mgr_->applyPendingUninstalls(); - registry_.scanDirectory(); + // Bridge constructed FIRST so subsystems built below can route diagnostics + // through it. Sink is thread-safe and outlive-safe via a QPointer. + diag_bridge_ = new PJ::QtDiagnosticBridge(this); + connect(diag_bridge_, &PJ::QtDiagnosticBridge::diagnosticReported, this, &MainWindow::onDiagnosticReported); + + registry_ = std::make_unique(plugin_dir, diag_bridge_->sink()); + + // ExtensionManager: forwards its existing diagnostics through the same sink + // so plugin-load and marketplace events feed one chronological UI stream. + auto* downloader = new PJ::DownloadManager(this); + ext_mgr_ = std::make_unique( + downloader, PJ::PlatformUtils::extensionsDir(), PJ::PlatformUtils::pendingDir(), diag_bridge_->sink(), this); + // initComponents() already drained pending install/uninstall queues. + + registry_->scanDirectory(); // --- Toolbar --- auto* toolbar = addToolBar("Main"); @@ -186,6 +202,17 @@ MainWindow::MainWindow(const std::string& plugin_dir, QWidget* parent) layout->addWidget(splitter, 1); setCentralWidget(central); + // --- Status bar --- + // statusBar()->showMessage(...) is the transient channel for warnings/errors + // pushed via diag_bridge_; the corner button opens the full diagnostics log. + diagnostics_action_ = new QAction("Diagnostics…", this); + connect(diagnostics_action_, &QAction::triggered, this, &MainWindow::onShowDiagnosticsDialog); + auto* diag_btn = new QPushButton("Diagnostics", this); + diag_btn->setFlat(true); + diag_btn->setToolTip("Open the recent diagnostics log"); + connect(diag_btn, &QPushButton::clicked, this, &MainWindow::onShowDiagnosticsDialog); + statusBar()->addPermanentWidget(diag_btn); + // --- Signals --- connect(chart_panel_, &ChartPanel::seriesDropped, this, [this]() { auto [begin, end] = computeVisibleRange(); @@ -205,7 +232,7 @@ void MainWindow::loadFile(const QString& file_path) { if (!ext.isEmpty()) { ext = "." + ext; } - auto sources = registry_.findSourcesForExtension(ext.toStdString()); + auto sources = registry_->findSourcesForExtension(ext.toStdString()); if (sources.empty()) { qWarning("No DataSource plugin handles %s files", qPrintable(ext)); @@ -218,7 +245,7 @@ void MainWindow::loadFile(const QString& file_path) { auto display_name = QFileInfo(file_path).fileName().toStdString(); auto session = - std::make_unique(engine_, source->library, default_td_id_, display_name, ®istry_, this); + std::make_unique(engine_, source->library, default_td_id_, display_name, registry_.get(), this); session->setMessageBoxCallback(makeMessageBoxCallback(this)); if (!session->bindForDialog()) { qWarning("Bind failed for '%s': %s", display_name.c_str(), session->lastError().c_str()); @@ -260,7 +287,7 @@ void MainWindow::plotFirstFields(int count) { void MainWindow::onLoadFile() { QSettings settings; auto last_dir = settings.value("ProtoApp/lastLoadDir", "").toString(); - auto filter = registry_.buildFileFilter(); + auto filter = registry_->buildFileFilter(); auto file_path = QFileDialog::getOpenFileName(this, "Load File", last_dir, QString::fromStdString(filter)); if (file_path.isEmpty()) { return; @@ -271,7 +298,7 @@ void MainWindow::onLoadFile() { if (!ext.isEmpty()) { ext = "." + ext; } - auto sources = registry_.findSourcesForExtension(ext.toStdString()); + auto sources = registry_->findSourcesForExtension(ext.toStdString()); if (sources.empty()) { QMessageBox::warning(this, "No Plugin", "No DataSource plugin handles " + ext + " files."); @@ -312,7 +339,7 @@ void MainWindow::onLoadFile() { // Create session early so the dialog can call listAvailableEncodings() auto session = - std::make_unique(engine_, source->library, default_td_id_, display_name, ®istry_, this); + std::make_unique(engine_, source->library, default_td_id_, display_name, registry_.get(), this); session->setMessageBoxCallback(makeMessageBoxCallback(this)); // Bind the plugin with the full service registry BEFORE showing the dialog @@ -333,7 +360,7 @@ void MainWindow::onLoadFile() { if (borrowed.ctx != nullptr) { auto dialog_handle = PJ::DialogHandle::borrowed(*vt_result, borrowed.ctx); PJ::DialogEngineConfig engine_config; - engine_config.parser_dialog_provider = makeParserDialogProvider(®istry_); + engine_config.parser_dialog_provider = makeParserDialogProvider(registry_.get()); PJ::DialogEngine dialog_engine(std::move(dialog_handle), engine_config); if (dialog_engine.showDialog(this) == PJ::DialogResult::kRejected) { return; @@ -356,7 +383,7 @@ void MainWindow::onLoadFile() { } void MainWindow::onStartStream() { - auto sources = registry_.streamSources(); + auto sources = registry_->streamSources(); if (sources.empty()) { QMessageBox::warning(this, "No Plugin", "No streaming DataSource plugins found."); return; @@ -386,7 +413,7 @@ void MainWindow::onStartStream() { // Create session first so the dialog runs on the SAME handle that will stream. // This matches the original plugin architecture: one object, one socket. auto session = - std::make_unique(engine_, source->library, default_td_id_, source->name, ®istry_, this); + std::make_unique(engine_, source->library, default_td_id_, source->name, registry_.get(), this); session->setMessageBoxCallback(makeMessageBoxCallback(this)); // Bind the plugin with the full service registry BEFORE showing the dialog @@ -410,7 +437,7 @@ void MainWindow::onStartStream() { if (borrowed.ctx != nullptr) { auto dialog_handle = PJ::DialogHandle::borrowed(*vt_result, borrowed.ctx); PJ::DialogEngineConfig engine_config; - engine_config.parser_dialog_provider = makeParserDialogProvider(®istry_); + engine_config.parser_dialog_provider = makeParserDialogProvider(registry_.get()); engine_config.initial_parser_config = saved_parser_config; PJ::DialogEngine dialog_engine(std::move(dialog_handle), engine_config); if (dialog_engine.showDialog(this) == PJ::DialogResult::kRejected) { @@ -443,7 +470,7 @@ void MainWindow::onStartStream() { } void MainWindow::startDummyStream() { - auto sources = registry_.streamSources(); + auto sources = registry_->streamSources(); LoadedDataSource* dummy = nullptr; for (auto* s : sources) { if (s->name == "Dummy Streamer") { @@ -457,7 +484,7 @@ void MainWindow::startDummyStream() { } auto session = - std::make_unique(engine_, dummy->library, default_td_id_, dummy->name, ®istry_, this); + std::make_unique(engine_, dummy->library, default_td_id_, dummy->name, registry_.get(), this); session->setMessageBoxCallback(makeMessageBoxCallback(this)); if (!session->bindForDialog()) { qWarning("Dummy Streamer bind failed: %s", session->lastError().c_str()); @@ -672,7 +699,7 @@ void MainWindow::restartSession(DataSourceSession* session) { tree_model_.hideDataset(dataset_id); // Create and start a new session with the same config - auto new_session = std::make_unique(engine_, library, default_td_id_, name, ®istry_, this); + auto new_session = std::make_unique(engine_, library, default_td_id_, name, registry_.get(), this); new_session->setMessageBoxCallback(makeMessageBoxCallback(this)); if (!new_session->bindForDialog()) { qWarning("Restart bind failed: %s", new_session->lastError().c_str()); @@ -715,20 +742,21 @@ std::pair MainWindow::computeVisibleRange() const void MainWindow::onOpenMarketplace() { const QUrl registry_url( "https://raw.githubusercontent.com/PlotJuggler/pj-plugin-registry/refs/heads/development/registry.json"); - PJ::MarketplaceWindow window(ext_mgr_.get(), registry_url, this); + const auto loaded_snapshot = registry_->loadedExtensionsSnapshot(); + PJ::MarketplaceWindow window(ext_mgr_.get(), registry_url, loaded_snapshot, this); window.resize(700, 500); window.exec(); if (window.installationsChanged()) { toolbox_sessions_.clear(); open_toolbox_dialogs_ = 0; - registry_.reload(); + registry_->reload(); tools_menu_->clear(); setupToolboxPanels(tools_menu_); } } void MainWindow::setupToolboxPanels(QMenu* tools_menu) { - for (const auto& tb : registry_.allToolboxes()) { + for (const auto& tb : registry_->allToolboxes()) { auto session = std::make_unique( engine_, const_cast(tb.library), colormap_registry_, tb.name, this); if (!session->init()) { @@ -769,4 +797,53 @@ void MainWindow::setupToolboxPanels(QMenu* tools_menu) { } } +void MainWindow::onDiagnosticReported(int level, QString source, QString id, QString message) { + // Cap retained history; older entries are dropped silently. The dialog shows + // "(N earlier diagnostics elided)" when the cap is hit. + static constexpr int kMaxKept = 200; + diagnostics_.append(UiDiagnostic{level, source, id, message, QDateTime::currentDateTimeUtc()}); + while (diagnostics_.size() > kMaxKept) { + diagnostics_.removeFirst(); + } + + // Status bar surfaces only warnings and errors; info-level events stay in the + // dialog so the user isn't flashed with every successful plugin load. + const auto lv = static_cast(level); + if (lv == PJ::DiagnosticLevel::kWarning || lv == PJ::DiagnosticLevel::kError) { + const QString prefix = (lv == PJ::DiagnosticLevel::kError) ? QStringLiteral("Error") : QStringLiteral("Warning"); + const QString display = source.isEmpty() ? message : source + ": " + message; + statusBar()->showMessage(prefix + " — " + display, /*timeout_ms=*/8000); + } + Q_UNUSED(id); +} + +void MainWindow::onShowDiagnosticsDialog() { + QDialog dlg(this); + dlg.setWindowTitle("Diagnostics"); + dlg.resize(720, 420); + + auto* layout = new QVBoxLayout(&dlg); + auto* text = new QPlainTextEdit(&dlg); + text->setReadOnly(true); + + QStringList lines; + for (const UiDiagnostic& d : diagnostics_) { + const char* lv = + (d.level == static_cast(PJ::DiagnosticLevel::kError)) ? "ERROR" + : (d.level == static_cast(PJ::DiagnosticLevel::kWarning)) ? "WARN" + : "INFO"; + const QString src = d.source.isEmpty() ? QStringLiteral("-") : d.source; + const QString id = d.id.isEmpty() ? QStringLiteral("-") : d.id; + lines.append(QString("[%1] %2 %3 %4: %5") + .arg(d.timestamp.toLocalTime().toString(Qt::ISODate), lv, src, id, d.message)); + } + text->setPlainText(lines.isEmpty() ? "No diagnostics." : lines.join('\n')); + layout->addWidget(text); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dlg); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.exec(); +} + } // namespace proto diff --git a/pj_proto_app/src/main_window.hpp b/pj_proto_app/src/main_window.hpp index 7628d64..4dabf26 100644 --- a/pj_proto_app/src/main_window.hpp +++ b/pj_proto_app/src/main_window.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include #include @@ -15,6 +18,7 @@ #include "data_source_session.hpp" #include "pj_datastore/colormap_registry.hpp" #include "pj_datastore/engine.hpp" +#include "pj_marketplace/qt_diagnostic_bridge.hpp" #include "plugin_registry.hpp" #include "series_tree_model.hpp" #include "toolbox_session.hpp" @@ -46,6 +50,8 @@ class MainWindow : public QMainWindow { void onClearPlots(); void onRefreshTimer(); void onTreeContextMenu(const QPoint& pos); + void onDiagnosticReported(int level, QString source, QString id, QString message); + void onShowDiagnosticsDialog(); private: void setupToolboxPanels(QMenu* tools_menu); @@ -62,10 +68,22 @@ class MainWindow : public QMainWindow { PJ::DataEngine engine_; PJ::ColorMapRegistry colormap_registry_; PJ::TimeDomainId default_td_id_ = 0; - PluginRegistry registry_; + PJ::QtDiagnosticBridge* diag_bridge_ = nullptr; + std::unique_ptr registry_; std::vector> sessions_; SeriesTreeModel tree_model_; + // Recent diagnostics, capped, shown in the Diagnostics dialog. + struct UiDiagnostic { + int level; + QString source; + QString id; + QString message; + QDateTime timestamp; + }; + QList diagnostics_; + QAction* diagnostics_action_ = nullptr; + std::unique_ptr ext_mgr_; QTreeView* tree_view_ = nullptr; diff --git a/pj_proto_app/src/plugin_registry.cpp b/pj_proto_app/src/plugin_registry.cpp index 9305b8b..88f7a5e 100644 --- a/pj_proto_app/src/plugin_registry.cpp +++ b/pj_proto_app/src/plugin_registry.cpp @@ -1,353 +1,78 @@ #include "plugin_registry.hpp" -#include +#include -#include "pj_marketplace/platform_utils.hpp" -#include -#include -#include +#include namespace proto { -PluginRegistry::PluginRegistry(std::string_view plugin_dir) : plugin_dir_(plugin_dir) {} - -bool PluginRegistry::loadAndRegisterDataSource(const std::filesystem::path& so_path) { - auto result = PJ::DataSourceLibrary::load(so_path.string()); - if (!result) { - std::cerr << " [DataSourceLibrary::load] " << so_path.filename().string() << " -> " - << result.error() << "\n"; - return false; - } - LoadedDataSource loaded; - loaded.library = std::move(*result); - loaded.path = so_path.string(); - loaded.loaded_mtime = std::filesystem::last_write_time(so_path); - - auto handle = loaded.library.createHandle(); - loaded.capabilities = handle.capabilities(); - try { - auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.name = manifest.value("name", so_path.stem().string()); - if (manifest.contains("file_extensions")) { - for (const auto& ext : manifest["file_extensions"]) { - loaded.file_extensions.push_back(ext.get()); - } - } - } catch (...) { - loaded.name = so_path.stem().string(); - } - std::cerr << "Loaded DataSource: " << loaded.name << " from " << loaded.path << "\n"; - data_sources_.push_back(std::move(loaded)); - return true; -} - -bool PluginRegistry::loadAndRegisterMessageParser(const std::filesystem::path& so_path) { - auto result = PJ::MessageParserLibrary::load(so_path.string()); - if (!result) { - std::cerr << " [MessageParserLibrary::load] " << so_path.filename().string() << " -> " - << result.error() << "\n"; - return false; - } - LoadedMessageParser loaded; - loaded.library = std::move(*result); - loaded.path = so_path.string(); - loaded.loaded_mtime = std::filesystem::last_write_time(so_path); - - auto handle = loaded.library.createHandle(); - try { - auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.name = manifest.value("name", so_path.stem().string()); - // Helper to push encoding(s) from a JSON value (string or array of strings) - auto push_encodings = [&](const nlohmann::json& enc) { - if (enc.is_array()) { - for (const auto& e : enc) { - if (e.is_string()) { - loaded.encodings.push_back(e.get()); - } - } - } - else { - std::cerr << "Error: 'encoding' field must be an array in plugin '" << loaded.name << "' (" << so_path.string() << "). Single string format is no longer supported." << std::endl; - } - }; - // Primary encoding field - if (manifest.contains("encoding")) { - push_encodings(manifest["encoding"]); - } - } catch (...) { - loaded.name = so_path.stem().string(); - } - std::cerr << "Loaded MessageParser: " << loaded.name << " from " << loaded.path << "\n"; - message_parsers_.push_back(std::move(loaded)); - return true; -} - -bool PluginRegistry::loadAndRegisterToolbox(const std::filesystem::path& so_path) { - auto result = PJ::ToolboxLibrary::load(so_path.string()); - if (!result) { - std::cerr << " [ToolboxLibrary::load] " << so_path.filename().string() << " -> " - << result.error() << "\n"; - return false; - } - LoadedToolbox loaded; - loaded.library = std::move(*result); - loaded.path = so_path.string(); - loaded.loaded_mtime = std::filesystem::last_write_time(so_path); - - auto handle = loaded.library.createHandle(); - loaded.capabilities = handle.capabilities(); - try { - auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.name = manifest.value("name", so_path.stem().string()); - } catch (...) { - loaded.name = so_path.stem().string(); - } - std::cerr << "Loaded Toolbox: " << loaded.name << " from " << loaded.path << "\n"; - toolbox_plugins_.push_back(std::move(loaded)); - return true; -} +PluginRegistry::PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink) + : catalog_(std::filesystem::path(std::string(plugin_dir)), std::move(sink), "PluginRegistry") {} void PluginRegistry::scanDirectory() { - namespace fs = std::filesystem; - - std::error_code ec; - const auto abs = fs::absolute(plugin_dir_, ec); - std::cerr << "[scanDirectory] plugin_dir_ = '" << plugin_dir_ << "' absolute = '" - << abs.string() << "' (ec=" << ec.message() << ")\n"; - - if (!fs::is_directory(plugin_dir_, ec)) { - std::cerr << "Plugin directory not found: " << plugin_dir_ - << " (is_directory ec=" << ec.message() << ")\n"; - return; - } - - const std::string expected_ext = PJ::PlatformUtils::pluginExtension(); - std::cerr << "[scanDirectory] expected extension = '" << expected_ext << "'\n"; - - std::size_t walked = 0; - std::size_t matched = 0; - for (const auto& entry : fs::recursive_directory_iterator(plugin_dir_, ec)) { - ++walked; - const bool is_file = entry.is_regular_file(); - const auto ext = entry.path().extension().string(); - std::cerr << "[scanDirectory] visit: " << entry.path() << " (regular_file=" << is_file - << " ext='" << ext << "')\n"; - if (!is_file || ext != expected_ext) { - continue; - } - ++matched; - const bool ok_ds = loadAndRegisterDataSource(entry.path()); - const bool ok_mp = !ok_ds && loadAndRegisterMessageParser(entry.path()); - const bool ok_tb = !ok_ds && !ok_mp && loadAndRegisterToolbox(entry.path()); - if (!ok_ds && !ok_mp && !ok_tb) { - std::cerr << "Failed to load plugin: " << entry.path() << "\n"; - } - } - std::cerr << "[scanDirectory] done. walked=" << walked << " matched=" << matched - << " (iter ec=" << ec.message() << ")\n"; + catalog_.scanDirectory(); } void PluginRegistry::reload() { - namespace fs = std::filesystem; - - if (!fs::is_directory(plugin_dir_)) { - std::cerr << "Plugin directory not found: " << plugin_dir_ << "\n"; - return; - } - - // Collect all plugin files currently on disk - std::vector on_disk; - for (const auto& entry : fs::recursive_directory_iterator(plugin_dir_)) { - if (entry.is_regular_file() && - entry.path().extension() == PJ::PlatformUtils::pluginExtension()) { - on_disk.push_back(entry.path()); - } - } - - // Remove entries whose .so no longer exists on disk - auto is_gone = [&](const std::string& p) { - return std::none_of(on_disk.begin(), on_disk.end(), - [&](const fs::path& dp) { return dp.string() == p; }); - }; - std::erase_if(data_sources_, [&](const LoadedDataSource& ds) { - if (is_gone(ds.path)) { - std::cerr << "Unloaded DataSource (removed): " << ds.path << "\n"; - return true; - } - return false; - }); - std::erase_if(message_parsers_, [&](const LoadedMessageParser& mp) { - if (is_gone(mp.path)) { - std::cerr << "Unloaded MessageParser (removed): " << mp.path << "\n"; - return true; - } - return false; - }); - std::erase_if(toolbox_plugins_, [&](const LoadedToolbox& tb) { - if (is_gone(tb.path)) { - std::cerr << "Unloaded Toolbox (removed): " << tb.path << "\n"; - return true; - } - return false; - }); - - // Load new .so files; reload modified ones - for (const auto& so_path : on_disk) { - const std::string path_str = so_path.string(); - const auto disk_mtime = fs::last_write_time(so_path); - - auto ds_it = std::find_if(data_sources_.begin(), data_sources_.end(), - [&](const LoadedDataSource& ds) { return ds.path == path_str; }); - if (ds_it != data_sources_.end()) { - if (disk_mtime <= ds_it->loaded_mtime) { - continue; - } - std::cerr << "Reloading updated DataSource: " << path_str << "\n"; - data_sources_.erase(ds_it); - } else { - auto mp_it = std::find_if(message_parsers_.begin(), message_parsers_.end(), - [&](const LoadedMessageParser& mp) { return mp.path == path_str; }); - if (mp_it != message_parsers_.end()) { - if (disk_mtime <= mp_it->loaded_mtime) { - continue; - } - std::cerr << "Reloading updated MessageParser: " << path_str << "\n"; - message_parsers_.erase(mp_it); - } else { - auto tb_it = std::find_if(toolbox_plugins_.begin(), toolbox_plugins_.end(), - [&](const LoadedToolbox& tb) { return tb.path == path_str; }); - if (tb_it != toolbox_plugins_.end()) { - if (disk_mtime <= tb_it->loaded_mtime) { - continue; - } - std::cerr << "Reloading updated Toolbox: " << path_str << "\n"; - toolbox_plugins_.erase(tb_it); - } - } - } - - if (!loadAndRegisterDataSource(so_path) && - !loadAndRegisterMessageParser(so_path) && - !loadAndRegisterToolbox(so_path)) { - std::cerr << "Failed to load plugin: " << path_str << "\n"; - } - } + (void)catalog_.reload(); } - std::vector PluginRegistry::fileImportSources() { - std::vector result; - for (auto& ds : data_sources_) { - if (ds.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT) { - result.push_back(&ds); - } - } - return result; + return catalog_.fileImportSources(); } std::vector PluginRegistry::streamSources() { - std::vector result; - for (auto& ds : data_sources_) { - if (ds.capabilities & PJ_DATA_SOURCE_CAPABILITY_CONTINUOUS_STREAM) { - result.push_back(&ds); - } - } - return result; + return catalog_.streamSources(); } std::string PluginRegistry::buildFileFilter() const { - // Collect all extensions and per-plugin filters - std::string all_exts; - std::string per_plugin; - for (const auto& ds : data_sources_) { - if (!(ds.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT)) { - continue; - } - if (ds.file_extensions.empty()) { - continue; - } - - if (!per_plugin.empty()) { - per_plugin += ";;"; - } - per_plugin += ds.name + " ("; - for (size_t i = 0; i < ds.file_extensions.size(); ++i) { - if (i > 0) { - per_plugin += " "; - } - per_plugin += "*" + ds.file_extensions[i]; - // Collect for "All supported" entry - if (!all_exts.empty()) { - all_exts += " "; - } - all_exts += "*" + ds.file_extensions[i]; - } - per_plugin += ")"; - } - - std::string filter; - if (!all_exts.empty()) { - filter = "All supported files (" + all_exts + ")"; - if (!per_plugin.empty()) { - filter += ";;" + per_plugin; - } - } else { - filter = per_plugin; - } - if (!filter.empty()) { - filter += ";;"; - } - filter += "All files (*)"; - return filter; + return catalog_.buildFileFilter(); } std::vector PluginRegistry::findSourcesForExtension(std::string_view ext) { - std::vector result; - for (auto& ds : data_sources_) { - if (!(ds.capabilities & PJ_DATA_SOURCE_CAPABILITY_FINITE_IMPORT)) { - continue; - } - for (const auto& supported_ext : ds.file_extensions) { - if (supported_ext == ext) { - result.push_back(&ds); - break; - } - } - } - return result; + return catalog_.findSourcesForExtension(ext); } LoadedMessageParser* PluginRegistry::findParserByEncoding(std::string_view encoding) { - for (auto& mp : message_parsers_) { - for (const auto& enc : mp.encodings) { - if (enc == encoding) { - return ∓ - } - } - } - return nullptr; + return catalog_.findParserByEncoding(encoding); } std::string PluginRegistry::listAvailableEncodings() const { - std::vector unique_encodings; - for (const auto& parser : message_parsers_) { - for (const auto& enc : parser.encodings) { - if (std::find(unique_encodings.begin(), unique_encodings.end(), enc) == unique_encodings.end()) { - unique_encodings.push_back(enc); - } - } - } + return catalog_.listAvailableEncodings(); +} + +QMap PluginRegistry::loadedExtensionsSnapshot() const { + QMap snapshot; - // Build JSON array - std::string json = "["; - for (size_t i = 0; i < unique_encodings.size(); ++i) { - if (i > 0) { - json += ","; + auto add_loaded = [&](const std::string& id, const std::string& version, const std::string& path) { + if (id.empty()) { + return; + } + const QString qid = QString::fromStdString(id); + if (snapshot.contains(qid)) { + return; } - json += "\"" + unique_encodings[i] + "\""; + + PJ::InstalledExtension record; + record.id = qid; + record.version = QString::fromStdString(version); + record.path = QString::fromStdString(path); + record.install_date = QFileInfo(record.path).lastModified(); + record.enabled = true; + snapshot.insert(qid, record); + }; + + for (const auto& source : catalog_.dataSources()) { + add_loaded(source.id, source.version, source.path); + } + for (const auto& parser : catalog_.messageParsers()) { + add_loaded(parser.id, parser.version, parser.path); } - json += "]"; - return json; + for (const auto& toolbox : catalog_.toolboxes()) { + add_loaded(toolbox.id, toolbox.version, toolbox.path); + } + + return snapshot; } } // namespace proto diff --git a/pj_proto_app/src/plugin_registry.hpp b/pj_proto_app/src/plugin_registry.hpp index 2922c16..1151074 100644 --- a/pj_proto_app/src/plugin_registry.hpp +++ b/pj_proto_app/src/plugin_registry.hpp @@ -1,81 +1,64 @@ #pragma once #include -#include #include #include #include -#include "pj_plugins/host/data_source_library.hpp" -#include "pj_plugins/host/message_parser_library.hpp" -#include "pj_plugins/host/toolbox_library.hpp" +#include +#include -namespace proto { - -struct LoadedDataSource { - PJ::DataSourceLibrary library; - std::string path; - std::string name; - std::vector file_extensions; - uint64_t capabilities = 0; - std::filesystem::file_time_type loaded_mtime; -}; +#include "pj_base/diagnostic_sink.hpp" +#include "pj_marketplace/installed_extension.hpp" +#include "pj_plugins/host/plugin_runtime_catalog.hpp" -struct LoadedMessageParser { - PJ::MessageParserLibrary library; - std::string path; - std::string name; - std::vector encodings; - std::filesystem::file_time_type loaded_mtime; -}; +namespace proto { -struct LoadedToolbox { - PJ::ToolboxLibrary library; - std::string path; - std::string name; - uint64_t capabilities = 0; - std::filesystem::file_time_type loaded_mtime; -}; +using LoadedDataSource = PJ::RuntimeDataSourcePlugin; +using LoadedMessageParser = PJ::RuntimeMessageParserPlugin; +using LoadedToolbox = PJ::RuntimeToolboxPlugin; +// Proto-app compatibility wrapper over PJ::PluginRuntimeCatalog. class PluginRegistry { public: - explicit PluginRegistry(std::string_view plugin_dir); + // Creates a registry rooted at plugin_dir with optional diagnostics. + explicit PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink = {}); + // Clears current state and loads every valid plugin. void scanDirectory(); + + // Reconciles loaded state with current files on disk. void reload(); + // Returns file-import capable DataSource plugins. [[nodiscard]] std::vector fileImportSources(); + + // Returns streaming-capable DataSource plugins. [[nodiscard]] std::vector streamSources(); + + // Builds a QFileDialog-compatible filter string. [[nodiscard]] std::string buildFileFilter() const; + + // Finds file-import DataSources that handle ext. [[nodiscard]] std::vector findSourcesForExtension(std::string_view ext); - /// Find a parser library by encoding name (e.g. "cdr", "protobuf", "json"). + // Finds a parser library by encoding name. [[nodiscard]] LoadedMessageParser* findParserByEncoding(std::string_view encoding); - /// Get all loaded message parsers. - [[nodiscard]] const std::vector& allMessageParsers() const { return message_parsers_; } + // Returns all loaded message parsers. + [[nodiscard]] const std::vector& allMessageParsers() const { return catalog_.messageParsers(); } - /// List all unique encodings from loaded parsers as a JSON array string. - /// Returns e.g. ["json","cbor","protobuf"]. Returns "[]" if no parsers loaded. + // Lists parser encodings as a JSON string array. [[nodiscard]] std::string listAvailableEncodings() const; - /// Get all loaded toolbox plugins. - [[nodiscard]] const std::vector& allToolboxes() const { return toolbox_plugins_; } + // Returns all loaded toolbox plugins. + [[nodiscard]] const std::vector& allToolboxes() const { return catalog_.toolboxes(); } - private: - /// Try to load a DataSource plugin and register it. Returns true on success. - bool loadAndRegisterDataSource(const std::filesystem::path& so_path); + // Builds a marketplace-style installed snapshot from loaded manifests. + [[nodiscard]] QMap loadedExtensionsSnapshot() const; - /// Try to load a MessageParser plugin and register it. Returns true on success. - bool loadAndRegisterMessageParser(const std::filesystem::path& so_path); - - /// Try to load a Toolbox plugin and register it. Returns true on success. - bool loadAndRegisterToolbox(const std::filesystem::path& so_path); - - std::string plugin_dir_; - std::vector data_sources_; - std::vector message_parsers_; - std::vector toolbox_plugins_; + private: + PJ::PluginRuntimeCatalog catalog_; }; } // namespace proto diff --git a/pj_proto_app/tests/plugin_registry_test.cpp b/pj_proto_app/tests/plugin_registry_test.cpp new file mode 100644 index 0000000..b25c4f5 --- /dev/null +++ b/pj_proto_app/tests/plugin_registry_test.cpp @@ -0,0 +1,101 @@ +#include "plugin_registry.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "pj_base/diagnostic_sink.hpp" + +namespace proto { + +TEST(PluginRegistryTest, LoadedExtensionsSnapshotUsesLoadedManifestVersion) { + QTemporaryDir temp_dir; + ASSERT_TRUE(temp_dir.isValid()); + + const QString plugin_dir = temp_dir.filePath("plugins"); + ASSERT_TRUE(QDir().mkpath(plugin_dir)); + + const QString dst = plugin_dir + "/" + QFileInfo(QStringLiteral(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH)).fileName(); + ASSERT_TRUE(QFile::copy(QStringLiteral(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH), dst)); + + PluginRegistry registry(plugin_dir.toStdString()); + registry.scanDirectory(); + + const auto snapshot = registry.loadedExtensionsSnapshot(); + ASSERT_TRUE(snapshot.contains("mock-data-source")); + EXPECT_EQ(snapshot["mock-data-source"].version, "1.0.0"); +} + +TEST(PluginRegistryTest, MessageParserManifestAcceptsStringEncoding) { + QTemporaryDir temp_dir; + ASSERT_TRUE(temp_dir.isValid()); + + const QString plugin_dir = temp_dir.filePath("plugins"); + ASSERT_TRUE(QDir().mkpath(plugin_dir)); + + const QString dst = plugin_dir + "/" + QFileInfo(QStringLiteral(PJ_MOCK_JSON_PARSER_PLUGIN_PATH)).fileName(); + ASSERT_TRUE(QFile::copy(QStringLiteral(PJ_MOCK_JSON_PARSER_PLUGIN_PATH), dst)); + + PluginRegistry registry(plugin_dir.toStdString()); + registry.scanDirectory(); + + const auto* parser = registry.findParserByEncoding("json"); + ASSERT_NE(parser, nullptr); + EXPECT_EQ(parser->id, "mock-json-parser"); + EXPECT_EQ(parser->version, "1.0.0"); + EXPECT_EQ(registry.listAvailableEncodings(), "[\"json\"]"); + + const auto snapshot = registry.loadedExtensionsSnapshot(); + ASSERT_TRUE(snapshot.contains("mock-json-parser")); + EXPECT_EQ(snapshot["mock-json-parser"].version, "1.0.0"); +} + +// A capturing sink confirms diagnostics flow out of PluginRegistry — the GUI +// path depends on this contract, and a regression here would silently break +// every error message the user normally sees in the status bar. +TEST(PluginRegistryTest, ScanDirectoryEmitsErrorDiagnosticForBrokenDso) { + QTemporaryDir temp_dir; + ASSERT_TRUE(temp_dir.isValid()); + + const QString plugin_dir = temp_dir.filePath("plugins"); + ASSERT_TRUE(QDir().mkpath(plugin_dir)); + + // A junk file with the right suffix exercises the DSO load failure path. + const std::string ext = ".so"; + const QString broken = plugin_dir + "/not_a_real_plugin" + QString::fromStdString(ext); + QFile junk(broken); + ASSERT_TRUE(junk.open(QIODevice::WriteOnly)); + junk.write("not a shared library"); + junk.close(); + + std::vector captured; + PJ::DiagnosticSink sink = [&captured](const PJ::Diagnostic& d) { captured.push_back(d); }; + + PluginRegistry registry(plugin_dir.toStdString(), sink); + registry.scanDirectory(); + + const bool any_error = std::any_of(captured.begin(), captured.end(), [](const PJ::Diagnostic& d) { + return d.level == PJ::DiagnosticLevel::kError && d.source == "PluginRegistry" + && d.message.find("not_a_real_plugin") != std::string::npos; + }); + EXPECT_TRUE(any_error) << "expected a kError diagnostic mentioning the broken DSO"; +} + +TEST(PluginRegistryTest, MissingPluginDirectoryEmitsErrorDiagnostic) { + std::vector captured; + PJ::DiagnosticSink sink = [&captured](const PJ::Diagnostic& d) { captured.push_back(d); }; + + PluginRegistry registry("/nonexistent/path/that/does/not/exist", sink); + registry.scanDirectory(); + + ASSERT_FALSE(captured.empty()); + EXPECT_EQ(captured.front().level, PJ::DiagnosticLevel::kError); + EXPECT_NE(captured.front().message.find("not found"), std::string::npos); +} + +} // namespace proto