From d8d3d26f081af7ee24a754f8014a8821a4006edf Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 03:51:37 +0200 Subject: [PATCH 01/12] fix(marketplace): make isInstalled filesystem-honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtensionManager::isInstalled previously trusted the in-memory installed_ QMap, with no cross-check against the on-disk path. When a user removed a plugin's .so or .dll outside the marketplace UI (rm, package cleanup, etc.) the map kept reporting the extension as installed and the UI rendered phantom "Installed" badges. Make isInstalled validate the path on read: if the cache says yes but the file is gone, evict the stale entry and emit a new extensionEvictedExternally(QString) signal so downstream consumers (the PJ4 ExtensionCatalogService catalog vectors, marketplace dialog) can refresh their views without an explicit reload trip. Add reconcileInstalledWithDisk() as a public batch variant — walks every entry once and evicts all stale ones. Used by the marketplace dialog's Refresh button and showEvent (next commit). Cost: ~30 LOC. const_cast is the price of preserving the const-correct public API; every UI caller (populateCards) iterates per-paint, so a non-const variant would have rippled through call sites. --- .../pj_marketplace/extension_manager.hpp | 16 +++++++++ pj_marketplace/src/core/ExtensionManager.cpp | 34 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index a885041..d1765e7 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -78,8 +78,18 @@ class ExtensionManager : public QObject { // Should be called once at application startup. Safe to call on any platform. void applyPendingUninstalls(); + // Returns true if the extension is in the in-memory installed map AND its + // .so/.dll is still on disk. If the path has vanished (user deleted it + // outside the marketplace UI), self-evicts the stale entry and returns + // false. Subsequent reads then see the truth. bool isInstalled(const QString& id) const; + // Walks every entry in the in-memory installed map and verifies the on-disk + // path still exists. Evicts stale records and emits extensionEvictedExternally + // for each. Called from the marketplace dialog's Refresh button and showEvent + // so the displayed state always matches disk on dialog open. + void reconcileInstalledWithDisk(); + // Returns true if the extension is staged in the pending directory and will // become active after the next restart (Windows update path). bool hasPendingInstall(const QString& id) const; @@ -112,6 +122,12 @@ class ExtensionManager : public QObject { // via applyPendingUninstalls(). void uninstallPendingRestart(const QString& id); + // Emitted when isInstalled() or reconcileInstalledWithDisk() observes that an + // installed plugin's path no longer exists on disk and self-evicts the + // in-memory record. UI layers (and PJ4's ExtensionCatalogService) can use this + // to refresh views without an explicit marketplace reload trip. + void extensionEvictedExternally(const QString& id); + private: // Called by both constructors to finish setup after members are assigned. void initComponents(); diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 4057009..e352189 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -313,7 +313,39 @@ void ExtensionManager::applyPendingUninstalls() { bool ExtensionManager::isInstalled(const QString& id) const { - return installed_.contains(id); + auto it = installed_.find(id); + if (it == installed_.end()) { + return false; + } + if (QFileInfo::exists(it->path)) { + return true; + } + // The .so/.dll was removed externally. Evict the stale entry so subsequent + // reads see the truth and so a future install of the same id starts clean. + // const_cast is the price of a const-correct public API; every UI caller + // (populateCards) iterates per-paint and would otherwise observe the lie. + qWarning("ExtensionManager: evicting stale installed entry for '%s' — file vanished: %s", + qPrintable(id), qPrintable(it->path)); + auto* self = const_cast(this); + self->installed_.erase(it); + emit self->extensionEvictedExternally(id); + return false; +} + +void ExtensionManager::reconcileInstalledWithDisk() { + // Snapshot the keys first; we can't mutate `installed_` while iterating it. + QStringList stale_ids; + for (auto it = installed_.constBegin(); it != installed_.constEnd(); ++it) { + if (!QFileInfo::exists(it->path)) { + stale_ids << it.key(); + } + } + for (const QString& id : stale_ids) { + qWarning("ExtensionManager: reconcile evicting '%s' — file vanished: %s", + qPrintable(id), qPrintable(installed_[id].path)); + installed_.remove(id); + emit extensionEvictedExternally(id); + } } bool ExtensionManager::hasPendingInstall(const QString& id) const { From 222395a604fed1b2660145739c9b49f1db9d4c57 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 03:51:44 +0200 Subject: [PATCH 02/12] fix(marketplace-ui): self-heal phantom cards on Refresh and showEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook the existing Refresh button to reconcileInstalledWithDisk() so a manual click evicts stale entries. Also override QDialog::showEvent to reconcile every time the dialog opens — cheap (one stat per installed entry), and means the user never sees a phantom "Installed" card without taking explicit action. When reconciliation actually evicts something, set installations_changed_ so MainWindow's post-close catalog.reload() pipeline runs and the streaming combo updates too. --- .../pj_marketplace/marketplace_window.hpp | 5 ++++ pj_marketplace/src/ui/marketplace_window.cpp | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp index a34503a..27e127a 100644 --- a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp +++ b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp @@ -31,6 +31,11 @@ class MarketplaceWindow : public QDialog { protected: bool eventFilter(QObject* obj, QEvent* event) override; + // Reconciles `installed_` against disk on every dialog open so phantom + // entries (`.so` removed externally between sessions) don't render as + // "Installed" badges. See ExtensionManager::reconcileInstalledWithDisk. + void showEvent(QShowEvent* event) override; + private slots: void onSearchChanged(const QString& text); void onCategoryChanged(int index); diff --git a/pj_marketplace/src/ui/marketplace_window.cpp b/pj_marketplace/src/ui/marketplace_window.cpp index ff287f4..2a12839 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -359,9 +359,34 @@ void MarketplaceWindow::onCategoryChanged(int /*index*/) { applyFilters( void MarketplaceWindow::onRefreshClicked() { setStatus("Refreshing..."); + // Reconcile in-memory installed_ against disk first so the cards painted + // during the registry-fetch latency already show the correct state. If any + // entry was stale, mark installations changed so MainWindow's post-close + // catalog.reload() runs. + const int before = ext_mgr_->installedExtensions().size(); + ext_mgr_->reconcileInstalledWithDisk(); + if (ext_mgr_->installedExtensions().size() != before) { + installations_changed_ = true; + } + populateCards(); registry_mgr_->fetchRegistry(registry_url_); } +void MarketplaceWindow::showEvent(QShowEvent* event) { + // Self-heal stale "Installed" badges every time the dialog becomes visible. + // Cheap (one stat per installed entry) and prevents the user from ever + // seeing a phantom card without taking any explicit action. + if (ext_mgr_ != nullptr) { + const int before = ext_mgr_->installedExtensions().size(); + ext_mgr_->reconcileInstalledWithDisk(); + if (ext_mgr_->installedExtensions().size() != before) { + installations_changed_ = true; + populateCards(); + } + } + QDialog::showEvent(event); +} + void MarketplaceWindow::onSettingsClicked() { QDialog dlg(this); dlg.setWindowTitle("Marketplace Settings"); From fcbc7aaa2e88ded401a51fce38744d58adf03938 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 04:10:14 +0200 Subject: [PATCH 03/12] fix(marketplace): drop manifest.json sidecar in favor of pj_meta.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-disk manifest.json was being abused for two purposes: plugin self-description (capabilities, file_extensions, name, encodings) AND host bookkeeping (id, version, install_date). Plugin self-description is already authoritatively exposed by the .so's vt_->manifest_json (see PJ_data_source_vtable_t in data_source_protocol.h:251), and that's the only thing ExtensionCatalogService::loadAndRegister* reads. Sidecar manifest.json was therefore redundant for the plugin-loading path AND a divergence risk: nothing prevents a plugin author's compile-time vtable manifest from disagreeing with the bundled manifest.json. Phase 1 split: keep host bookkeeping in a host-owned pj_meta.json (id, version, install_date) that ExtensionManager writes itself at install completion, and stop reading the plugin-author manifest.json entirely. Phase 2 (a separate doc/spec change) will tell plugin authors they can drop manifest.json from their install ZIPs. Concrete changes in ExtensionManager.cpp: - doInstall completion: drop the manifest.json read that was overriding the registry struct's version (lines 129-135). The Extension struct passed to install() is now the authoritative version source. Write pj_meta.json into extensions_dir_// via the new writeInstalledMeta() helper. Same pattern for the staging branch (writes pj_meta.json into pending_dir_//). - applyPendingInstalls: directory name IS the id (install always creates pending_dir_//), so no file-read is needed to recover it. Version is read from pj_meta.json with a manifest.json fallback for staged installs from before this change. Adds an empty-dir guard so a bare staging directory is skipped instead of producing a phantom installation. - applyPendingUninstalls and hasPendingInstall: same — directory name is the id, no file read. - loadState: directory name = id; version comes from pj_meta.json with manifest.json fallback for legacy installs. The fallback can be removed once all users have re-installed at least once. - savePendingMeta (which was dead code — never called) is renamed and rewired to writeInstalledMeta(ext, dir), parameterized on the target directory so it can serve both pending-staging and direct-install paths. Tests: - dummyPluginZip no longer bakes manifest.json; the test artifact is just the placeholder binary, matching the new packaging convention. - legacyPluginZipWithManifest helper added for backward-compat tests. - New LoadStateFallsBackToManifestJsonForLegacyInstalls test exercises the legacy-install path: a directory with only manifest.json (no pj_meta.json) must still be visible in installedExtensions() after a fresh ExtensionManager loads its state. - ApplyPendingInstallsSkipsDirectoryWithoutMetaFile renamed to ApplyPendingInstallsSkipsEmptyStagingDirectory and updated to assert the empty-dir guard rather than the manifest-presence check. The pre-existing UpdateReinstallsWithNewVersion failure is unaffected (unrelated to manifest reading; involves the update->backup->reinstall flow). --- .../pj_marketplace/extension_manager.hpp | 7 +- pj_marketplace/src/core/ExtensionManager.cpp | 113 ++++++++++-------- .../tests/extension_manager_test.cpp | 64 ++++++++-- 3 files changed, 126 insertions(+), 58 deletions(-) diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index d1765e7..3a4e80f 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -136,7 +136,12 @@ class ExtensionManager : public QObject { void loadState(); void saveState(); void disconnectDlConns(); - void savePendingMeta(const Extension& ext); + // Writes pj_meta.json (host-owned bookkeeping: id, version, install_date) + // into /. This is the persistent record that loadState() reads at + // startup; it replaces the plugin-author-supplied manifest.json sidecar + // for host-side bookkeeping. Plugin self-description (capabilities, + // file_extensions, name, etc.) still comes from the .so's vt_->manifest_json. + void writeInstalledMeta(const Extension& ext, const QString& dir); void schedulePendingUninstall(const QString& path); DownloadManager* downloader_ = nullptr; diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index e352189..ae5acf5 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -115,24 +115,24 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { pending_op_id_ = -1; if (staging) { + // Persist host-owned metadata into the staged dir so applyPendingInstalls + // (next startup) can read id/version without parsing the plugin-author + // manifest.json sidecar. + writeInstalledMeta(ext, pending_dir_ + "/" + ext.id); emit installPendingRestart(finished_id); } else { const QString ext_root = extensions_dir_ + "/" + ext.id; InstalledExtension record; record.id = ext.id; - record.version = ext.version; + record.version = ext.version; // authoritative source: registry struct 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(); - } - } + // Persist host-owned bookkeeping. loadState() at next startup reads this + // file (with a manifest.json fallback for legacy installs). + writeInstalledMeta(ext, ext_root); installed_[ext.id] = record; emit installFinished(finished_id, true); @@ -259,20 +259,33 @@ void ExtensionManager::applyPendingInstalls() { 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)) { + // Directory name IS the id — install() always creates pending_dir_//, + // so we don't need to parse a sidecar to recover it. + const QString id = entry.fileName(); + if (id.isEmpty()) { continue; } - const QJsonObject manifest = QJsonDocument::fromJson(manifest_file.readAll()).object(); - manifest_file.close(); - - const QString id = manifest["id"].toString(); - if (id.isEmpty()) { + // Sanity: a real staged install always contains at least the plugin binary. + // A bare directory is a sign of a broken staging operation; skip it rather + // than promote a phantom installation that would later confuse loadState. + if (QDir(src).entryInfoList(QDir::Files | QDir::NoDotAndDotDot).isEmpty()) { continue; } + const QString dst = extensions_dir_ + "/" + id; + + // Version comes from the host-owned pj_meta.json that doInstall() wrote + // when staging completed. Fall back to manifest.json for legacy staged + // installs from before the sidecar removal. + QString version; + QFile pj_meta_file(src + "/pj_meta.json"); + if (pj_meta_file.open(QIODevice::ReadOnly)) { + version = QJsonDocument::fromJson(pj_meta_file.readAll()).object()["version"].toString(); + } else { + QFile legacy(src + "/" + kManifestFileName); + if (legacy.open(QIODevice::ReadOnly)) { + version = QJsonDocument::fromJson(legacy.readAll()).object()["version"].toString(); + } + } // Remove any existing installation so the rename cannot fail on a non-empty target. QDir(dst).removeRecursively(); @@ -283,7 +296,7 @@ void ExtensionManager::applyPendingInstalls() { InstalledExtension record; record.id = id; - record.version = manifest["version"].toString(); + record.version = version; record.install_date = QDateTime::currentDateTimeUtc(); record.path = dst; record.enabled = true; @@ -299,12 +312,8 @@ void ExtensionManager::applyPendingUninstalls() { 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(); - } + // Directory name IS the id — install() always creates extensions_dir_//. + const QString id = entry.fileName(); if (QDir(entry.absoluteFilePath()).removeRecursively() && !id.isEmpty()) { installed_.remove(id); } @@ -349,22 +358,13 @@ void ExtensionManager::reconcileInstalledWithDisk() { } bool ExtensionManager::hasPendingInstall(const QString& id) const { - const QDir pending(pending_dir_); - if (!pending.exists()) { + // Directory name IS the id; if pending_dir_// exists, the install is staged. + const QString staged = pending_dir_ + "/" + id; + if (!QFileInfo::exists(staged)) { 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; - } - } - return false; + emit const_cast(this)->installPendingRestart(id); + return true; } bool ExtensionManager::hasPendingUninstall(const QString& id) const { @@ -404,16 +404,18 @@ void ExtensionManager::schedulePendingUninstall(const QString& path) { marker.open(QIODevice::WriteOnly); // content irrelevant; existence is the signal } -void ExtensionManager::savePendingMeta(const Extension& ext) { +void ExtensionManager::writeInstalledMeta(const Extension& ext, const QString& dir) { QJsonObject obj; obj["id"] = ext.id; obj["version"] = ext.version; obj["install_date"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - QFile file(pending_dir_ + "/" + ext.id + "/pj_meta.json"); - if (file.open(QIODevice::WriteOnly)) { - file.write(QJsonDocument(obj).toJson()); + QFile file(dir + "/pj_meta.json"); + if (!file.open(QIODevice::WriteOnly)) { + qWarning("ExtensionManager: failed to write pj_meta.json under '%s'", qPrintable(dir)); + return; } + file.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); } // --------------------------------------------------------------------------- @@ -429,19 +431,30 @@ void ExtensionManager::loadState() { continue; } - QFile manifest_file(ext_root + "/" + kManifestFileName); - if (!manifest_file.open(QIODevice::ReadOnly)) { - continue; - } - const QJsonObject manifest = QJsonDocument::fromJson(manifest_file.readAll()).object(); - const QString id = manifest["id"].toString(); + // Directory name IS the id (install always creates extensions_dir_//). + const QString id = entry.fileName(); if (id.isEmpty()) { continue; } + // Version comes from the host-owned pj_meta.json. For installations made + // before the sidecar removal, fall back to the plugin-author manifest.json + // — that fallback can be deleted once all users have re-installed at least + // once under the new layout. + QString version; + QFile pj_meta_file(ext_root + "/pj_meta.json"); + if (pj_meta_file.open(QIODevice::ReadOnly)) { + version = QJsonDocument::fromJson(pj_meta_file.readAll()).object()["version"].toString(); + } else { + QFile legacy(ext_root + "/" + kManifestFileName); + if (legacy.open(QIODevice::ReadOnly)) { + version = QJsonDocument::fromJson(legacy.readAll()).object()["version"].toString(); + } + } + InstalledExtension inst; inst.id = id; - inst.version = manifest["version"].toString(); + inst.version = version; inst.install_date = entry.lastModified(); inst.path = ext_root; inst.enabled = true; diff --git a/pj_marketplace/tests/extension_manager_test.cpp b/pj_marketplace/tests/extension_manager_test.cpp index bf5145b..a8ac864 100644 --- a/pj_marketplace/tests/extension_manager_test.cpp +++ b/pj_marketplace/tests/extension_manager_test.cpp @@ -115,11 +115,21 @@ 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 dummyPluginZip(const QString& ext_id, const QString& version = "1.0.0") { +// Returns a minimal ZIP that mimics a real artifact: an / root directory +// containing only a placeholder binary. Plugin self-description (capabilities, +// file_extensions, name, etc.) is read from the .so's vt_->manifest_json at +// load time; the host-owned pj_meta.json bookkeeping file is written by +// ExtensionManager at install completion. There is no plugin-author sidecar. +QByteArray dummyPluginZip(const QString& ext_id, const QString& /*version*/ = "1.0.0") { + return buildZip({ + {ext_id + "/" + ext_id + ".plugin", "placeholder binary content"}, + }); +} + +// Legacy ZIP shape that bakes manifest.json into the artifact. Used by the +// backward-compat test that exercises loadState's fallback for installations +// from before the sidecar removal. +QByteArray legacyPluginZipWithManifest(const QString& ext_id, const QString& version = "1.0.0") { const QByteArray manifest = QJsonDocument(QJsonObject{{"id", ext_id}, {"version", version}}).toJson(); return buildZip({ @@ -582,10 +592,13 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsPromotesStagedExtension) { // 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, ApplyPendingInstallsSkipsEmptyStagingDirectory) { 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. + // Intentionally leave the directory empty to simulate a broken staging + // operation. Under the new sidecar-free layout the directory name would + // otherwise be taken as the id; the empty-dir guard prevents a phantom + // installation from being recorded. QSignalSpy spy(mgr_, &ExtensionManager::installFinished); mgr_->applyPendingInstalls(); @@ -704,6 +717,43 @@ TEST_F(ExtensionManagerTest, ApplyPendingUninstallsIsNoOpForEmptyDirectory) { mgr_->applyPendingUninstalls(); // must not crash } +// Backward compatibility: a pre-existing installation laid out by an older host +// version (manifest.json present, no pj_meta.json) must still be visible in +// installedExtensions() after a fresh ExtensionManager loads its state. +TEST_F(ExtensionManagerTest, LoadStateFallsBackToManifestJsonForLegacyInstalls) { + // Simulate a legacy install by extracting a legacy ZIP straight into the + // extensions directory under /, without ever calling install(). + QTemporaryDir legacy_extensions(QDir::tempPath() + "/legacy_ext_XXXXXX"); + ASSERT_TRUE(legacy_extensions.isValid()); + const QString id = "csv-loader"; + const QString version = "1.2.3"; + ASSERT_TRUE(QDir(legacy_extensions.path()).mkpath(id)); + const QByteArray manifest = + QJsonDocument(QJsonObject{{"id", id}, {"version", version}}).toJson(); + QFile mfile(legacy_extensions.path() + "/" + id + "/manifest.json"); + ASSERT_TRUE(mfile.open(QIODevice::WriteOnly)); + mfile.write(manifest); + mfile.close(); + // Touch the placeholder binary so isInstalled() filesystem check passes too + // (resilience-fix invariant: isInstalled stats the path). + QFile bin(legacy_extensions.path() + "/" + id + "/" + id + ".plugin"); + ASSERT_TRUE(bin.open(QIODevice::WriteOnly)); + bin.write("placeholder"); + bin.close(); + + // A fresh manager rooted on the legacy directory should reconstruct + // installed_ from manifest.json without help from pj_meta.json. + DownloadManager dl; + ExtensionManager mgr(&dl, legacy_extensions.path(), pending_dir_.path()); + EXPECT_TRUE(mgr.isInstalled(id)); + EXPECT_EQ(mgr.installedExtensions()[id].version, version); + + // Backward-compat helper coverage: the legacy ZIP shape we use elsewhere + // round-trips through the host as expected. + const QByteArray bytes = legacyPluginZipWithManifest(id, version); + EXPECT_FALSE(bytes.isEmpty()); +} + // --------------------------------------------------------------------------- // [8] Platform detection // --------------------------------------------------------------------------- From f3a5f0d4f92600f7d2fb788966f4bb3fb0652b68 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 05:55:39 +0200 Subject: [PATCH 04/12] fix expected --- pj_base/include/pj_base/expected.hpp | 33 +++++++++++++++++----------- pj_base/tests/expected_test.cpp | 10 +++++++++ 2 files changed, 30 insertions(+), 13 deletions(-) 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/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 From b8663ac9b023f9586e21b8f4ec784c07e7e3a6a8 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 06:18:20 +0200 Subject: [PATCH 05/12] refactor(plugins,marketplace): make embedded DSO manifest the single source of truth Eliminate every local plugin metadata sidecar. The host already dlopens each installed extension at startup; the manifest_json string compiled into the DSO vtable is the authoritative source for id/name/version/family. Reading that directly removes a class of "phantom card" / drift bugs the prior commits on this branch were chasing. pj_plugins: - Replace .pjmanifest.json sidecar discovery with scanPluginDsos(), which dlopens each candidate, probes the four family vtables, parses the embedded manifest_json, and returns descriptors plus per-file diagnostics. A single broken DSO no longer aborts discovery. - Require id, name, version on every embedded manifest; message parsers also require encoding. Missing keys produce a diagnostic, not a silent skip. - Delete cmake/PjPluginManifest.cmake (pj_emit_plugin_manifest helper) and all call sites; first-class plugins now embed "id" in their manifest_json. pj_marketplace: - ExtensionManager no longer writes or reads pj_meta.json/manifest.json. Installed state is rebuilt from disk by scanning plugin DSOs. - Validate registry id and version against the embedded manifest on every install, update, and pending-restart promotion. Mismatch fails cleanly and removes the freshly extracted directory. - Defer Windows staged-install validation to applyPendingInstalls() at next startup, when the staged DSO becomes the loaded one (the post-restart load IS the validation). Persist registry intent in a .pj_pending_install marker so promotion can revalidate id+version. - hasPendingInstall is a pure marker-existence check; no const_cast, no signal emission, no recursive directory walk. Closes the latent recursive re-entry bug where populateCards -> hasPendingInstall -> emit -> populateCards. - Drop pending_restart_ids_ from MarketplaceWindow; predicate-only model. - populateCards caches installedExtensions() once before the per-card loop and avoids recomputing hasUpdate per ext. - Templatize the three direct-vtable family probes in plugin_catalog.cpp via a shared probeDirectVtable helper while keeping the named try* functions for call-site readability. Tests: - extension_manager_test.cpp rewritten around real plugin DSO fixtures instead of ZIP-local JSON. Adds coverage for embedded id/version mismatch, intra-directory id conflicts, broken DSO rejection, staged-promotion validation, and external eviction. 51/51 tests pass. - New DSO fixtures: missing_id_data_source_plugin.cpp, mock_data_source_v2_plugin.cpp. - Mock*Manager classes deleted; the rewritten tests no longer need them. pj_base: - Expected now uses ErrorStorage to disambiguate variant when T==E (e.g. Expected); test added. - SDK base classes assert "id" presence in the embedded manifest at vtable construction. Documentation in pj_marketplace/ and pj_plugins/ updated to describe embedded DSO manifest as the source of truth and to remove all references to local sidecars. Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 2 - cmake/PjPluginManifest.cmake | 104 --- examples/sdk_consumer/minimal_data_source.cpp | 2 +- .../pj_base/sdk/data_source_patterns.hpp | 2 +- .../pj_base/sdk/data_source_plugin_base.hpp | 3 +- .../sdk/message_parser_plugin_base.hpp | 1 + .../pj_base/sdk/toolbox_plugin_base.hpp | 1 + .../tests/data_source_plugin_base_test.cpp | 2 +- .../tests/message_parser_plugin_base_test.cpp | 6 +- pj_datastore/tests/array_expansion_test.cpp | 22 +- pj_marketplace/CMakeLists.txt | 55 +- pj_marketplace/README.md | 3 +- pj_marketplace/documentation/ARCHITECTURE.md | 110 ++- pj_marketplace/documentation/PLAN.md | 16 +- pj_marketplace/documentation/REQUIREMENTS.md | 43 +- .../documentation/SPRINT_PROPOSAL.md | 10 +- pj_marketplace/documentation/USER_MANUAL.md | 46 +- .../documentation/diagrams/architecture.puml | 4 +- .../diagrams/installation-flow.puml | 8 +- .../documentation/diagrams/rollback-flow.puml | 12 +- .../diagrams/windows-staging.puml | 14 +- .../plotjuggler-marketplace-spec-v1.0.0-en.md | 103 +-- .../pj_marketplace/extension_manager.hpp | 53 +- .../pj_marketplace/installed_extension.hpp | 1 - .../pj_marketplace/marketplace_window.hpp | 31 +- pj_marketplace/src/core/ExtensionManager.cpp | 478 ++++++------- .../src/core/mock/MockDownloadManager.cpp | 46 -- .../src/core/mock/MockDownloadManager.h | 41 -- .../src/core/mock/MockExtensionManager.cpp | 82 --- .../src/core/mock/MockExtensionManager.h | 46 -- .../src/core/mock/MockRegistryManager.cpp | 95 --- .../src/core/mock/MockRegistryManager.h | 29 - pj_marketplace/src/ui/marketplace_window.cpp | 308 ++++----- .../tests/extension_manager_test.cpp | 640 ++++++++++++------ .../tests/registry_manager_test.cpp | 9 +- pj_plugins/CMakeLists.txt | 60 +- .../dialog_protocol/examples/mock_dialog.cpp | 1 + .../pj_plugins/sdk/dialog_plugin_base.hpp | 12 +- pj_plugins/docs/ARCHITECTURE.md | 35 +- pj_plugins/docs/data-source-guide.md | 14 +- pj_plugins/docs/dialog-plugin-guide.md | 9 +- pj_plugins/docs/message-parser-guide.md | 6 +- pj_plugins/docs/toolbox-guide.md | 6 +- pj_plugins/examples/mock_data_source.cpp | 2 +- pj_plugins/examples/mock_file_source.cpp | 2 +- pj_plugins/examples/mock_json_parser.cpp | 3 +- pj_plugins/examples/mock_schema_parser.cpp | 4 +- .../examples/mock_source_with_dialog.cpp | 4 +- pj_plugins/examples/mock_toolbox.cpp | 2 +- .../pj_plugins/host/plugin_catalog.hpp | 58 +- pj_plugins/src/detail/library_loader.hpp | 16 +- pj_plugins/src/plugin_catalog.cpp | 315 ++++++--- .../tests/missing_id_data_source_plugin.cpp | 75 ++ .../tests/mock_data_source_v2_plugin.cpp | 75 ++ pj_plugins/tests/plugin_catalog_test.cpp | 164 ++--- 55 files changed, 1677 insertions(+), 1614 deletions(-) delete mode 100644 cmake/PjPluginManifest.cmake delete mode 100644 pj_marketplace/src/core/mock/MockDownloadManager.cpp delete mode 100644 pj_marketplace/src/core/mock/MockDownloadManager.h delete mode 100644 pj_marketplace/src/core/mock/MockExtensionManager.cpp delete mode 100644 pj_marketplace/src/core/mock/MockExtensionManager.h delete mode 100644 pj_marketplace/src/core/mock/MockRegistryManager.cpp delete mode 100644 pj_marketplace/src/core/mock/MockRegistryManager.h create mode 100644 pj_plugins/tests/missing_id_data_source_plugin.cpp create mode 100644 pj_plugins/tests/mock_data_source_v2_plugin.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1458fbb..810be36 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,6 @@ 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(PjPluginManifest) # --------------------------------------------------------------------------- # Options diff --git a/cmake/PjPluginManifest.cmake b/cmake/PjPluginManifest.cmake deleted file mode 100644 index 1e59e3c..0000000 --- a/cmake/PjPluginManifest.cmake +++ /dev/null @@ -1,104 +0,0 @@ -# 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. -# -# 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. -# -# 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. -# -# Usage: -# include(PjPluginManifest) # auto-included by the root CMakeLists.txt -# add_library(csv_source_plugin SHARED csv_source.cpp) -# pj_emit_plugin_manifest(csv_source_plugin -# FAMILY data_source -# 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. - -function(pj_emit_plugin_manifest TARGET) - set(_options) - set(_oneValueArgs FAMILY MANIFEST_FILE ABI_MAJOR) - set(_multiValueArgs) - cmake_parse_arguments(ARG "${_options}" "${_oneValueArgs}" "${_multiValueArgs}" ${ARGN}) - - if(NOT ARG_FAMILY) - message(FATAL_ERROR "pj_emit_plugin_manifest(${TARGET}): FAMILY is required") - endif() - - set(_valid_families data_source message_parser toolbox dialog) - list(FIND _valid_families "${ARG_FAMILY}" _family_idx) - if(_family_idx LESS 0) - message(FATAL_ERROR - "pj_emit_plugin_manifest(${TARGET}): FAMILY \"${ARG_FAMILY}\" is invalid. " - "Must be one of: ${_valid_families}") - endif() - - if(NOT ARG_MANIFEST_FILE) - set(ARG_MANIFEST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/manifest.json") - endif() - if(NOT EXISTS "${ARG_MANIFEST_FILE}") - message(FATAL_ERROR - "pj_emit_plugin_manifest(${TARGET}): MANIFEST_FILE not found: ${ARG_MANIFEST_FILE}") - endif() - - if(NOT ARG_ABI_MAJOR) - # Matches PJ_ABI_VERSION in pj_base/plugin_data_api.h. Bump in lockstep. - set(ARG_ABI_MAJOR 4) - endif() - - # 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() - - # Augment: add abi_major + family. string(JSON SET) preserves other keys. - set(_sidecar_json "${_src_json}") - string(JSON _sidecar_json SET "${_sidecar_json}" "abi_major" "${ARG_ABI_MAJOR}") - string(JSON _sidecar_json SET "${_sidecar_json}" "family" "\"${ARG_FAMILY}\"") - - # Write to build tree. The file lives next to the DSO. - 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" - 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}" - DESTINATION "${CMAKE_INSTALL_LIBDIR}" - ) - endif() -endfunction() 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/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..656f623 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 @@ -133,6 +133,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,7 +230,7 @@ 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; \ 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..8b9f4a6 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 @@ -87,6 +87,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"); 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..f1d6f47 100644 --- a/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp +++ b/pj_base/include/pj_base/sdk/toolbox_plugin_base.hpp @@ -152,6 +152,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 = { 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/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/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..4376e54 100644 --- a/pj_marketplace/CMakeLists.txt +++ b/pj_marketplace/CMakeLists.txt @@ -83,11 +83,20 @@ target_link_libraries(pj_marketplace_ui PUBLIC # When built as part of plotjuggler_core the target already exists; # in standalone mode we expose the include directory directly. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + find_package(nlohmann_json REQUIRED) + target_sources(pj_marketplace PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/src/plugin_catalog.cpp + ) target_include_directories(pj_marketplace PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../pj_base/include + ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/include + ) + target_link_libraries(pj_marketplace PUBLIC + nlohmann_json::nlohmann_json + ${CMAKE_DL_LIBS} ) 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 +152,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..1292e96 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) @@ -125,18 +125,12 @@ marketplace/ │ │ └── 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 +│ │ ├── ExtensionManager.h/cpp # Install, uninstall, update, staged promotion +│ │ ├── DownloadManager.h/cpp # HTTP download, checksum, libarchive extraction │ │ └── 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 +│ │ └── ExtensionDetailDialog.h/cpp # Detail dialog └── resources/ ├── icons/ └── marketplace.qrc @@ -176,7 +170,6 @@ struct InstalledExtension { QDateTime install_date; QString path; bool enabled; - QString backup_path; // Optional }; ``` @@ -185,10 +178,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 +198,9 @@ 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. --- @@ -232,13 +224,13 @@ start :Download ZIP; :Verify SHA256; if (Checksum OK?) then (yes) - :Extract to temp; - :Validate manifest; + :Extract to extensions/; + :Load DSO manifest; + :Validate registry id/version; if (Is update?) then (yes) :Backup current; endif - :Move to extensions/; - :Read manifest.json → register in memory; + :Register discovery cache; else (no) :Error: invalid checksum; endif @@ -263,20 +255,22 @@ title Windows Staging Flow start :Download ZIP; :Extract to .pending/{id}/; -note right: Staging folder +:Load DSO manifest; +:Validate registry id/version; +:Write .pj_pending_install intent; :Notify "Restart required"; stop start :PlotJuggler restarts; +:Read .pj_pending_install intent; +:Validate staged DSO manifest; +if (Valid?) then (yes) :Move .pending/{id}/ to extensions/{id}/; -note right: Previous backup in\n.backup/{id}-{ver}/ -:Load plugin; -if (Load successful?) then (yes) :Plugin active; else (no) - :Restore from backup; - :Notify rollback; + :Remove broken stage; + :Notify install error; endif stop @enduml @@ -285,6 +279,10 @@ stop ### 4.3 Rollback Flow +Automatic rollback is deferred. Non-Windows updates keep the previous version in +`.backup/`, but the marketplace does not currently restore it automatically if a +plugin later fails to load. + ![Rollback Flow](diagrams/rollback-flow.png)
@@ -293,7 +291,7 @@ stop ```plantuml @startuml skinparam backgroundColor white -title Rollback Flow +title Rollback Flow (Deferred) start :PlotJuggler starts; @@ -303,11 +301,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; @@ -326,15 +324,13 @@ stop ~/.plotjuggler/ ├── 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 +├── .backup/ # Non-Windows update backups; automatic rollback deferred │ ├── ros2-streaming-1.2.2/ │ └── csv-loader-0.9.0/ └── .cache/ # Registry cache @@ -345,7 +341,6 @@ stop ``` 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 +392,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) @@ -419,7 +414,7 @@ set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Network) -find_package(QuaZip-Qt6 REQUIRED) # Or alternative ZIP library +find_package(LibArchive REQUIRED) add_library(pj_marketplace SHARED src/models/Extension.cpp @@ -428,18 +423,15 @@ add_library(pj_marketplace SHARED 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 + src/ui/marketplace_window.cpp + src/ui/extension_detail_dialog.cpp resources/marketplace.qrc ) target_link_libraries(pj_marketplace PRIVATE Qt6::Widgets Qt6::Network - QuaZip::QuaZip + LibArchive::LibArchive ) target_include_directories(pj_marketplace PUBLIC @@ -468,23 +460,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 +504,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 +589,6 @@ plotjuggler/extension-template/ ├── CMakeLists.txt ├── conanfile.py ├── pixi.toml # Future alternative -├── manifest.json.in ├── conan_profiles/ │ ├── linux_static │ ├── windows_static @@ -875,7 +863,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 +898,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 index fc29b1e..6ce2bd8 100644 --- a/pj_marketplace/documentation/PLAN.md +++ b/pj_marketplace/documentation/PLAN.md @@ -2,9 +2,13 @@ > **Version:** 1.0.0 > **Last Updated:** 2026-03-05 -> **Status:** In Progress +> **Status:** Historical planning document; not the current implementation contract > **Deadline:** 31 March 2026 +> Current implementation notes: installed state is discovered from embedded DSO +> manifests, ZIP extraction is handled by `DownloadManager` through libarchive, +> and automatic rollback remains deferred. + --- ## 1. Project Timeline @@ -58,7 +62,7 @@ A working prototype integrated into PlotJuggler is expected by the end of March | 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-08 | Register from embedded DSO manifest | W1 | ⬜ TODO | | F-09 | Detect updates | W3 | ⬜ TODO | | F-10 | Uninstall extension | W1 | ⬜ TODO | @@ -118,8 +122,8 @@ A working prototype integrated into PlotJuggler is expected by the end of March - [ ] 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 DownloadManager/libarchive for ZIP extraction +- [ ] Create ExtensionManager — inject DownloadManager via constructor; installed state is rebuilt by scanning plugin DSOs - [ ] Use PlatformUtils::extensionsDir() as default extensions directory (no setExtensionsDir setter) - [ ] Delegate platform detection to PlatformUtils::currentPlatform() (no private detectPlatform()) - [ ] Implement install flow @@ -175,7 +179,7 @@ A working prototype integrated into PlotJuggler is expected by the end of March | 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 | +| Fri | 20 Mar | Package as ZIP with embedded plugin 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 | @@ -183,7 +187,7 @@ A working prototype integrated into PlotJuggler is expected by the end of March ### TODO Week 3 - [ ] Create SimpleCsvLoader plugin (~100 lines) -- [ ] Create manifest.json for it +- [ ] Add embedded manifest to the plugin export - [ ] Package as ZIP - [ ] Create GitHub repo for test registry - [ ] Create registry.json with csv-loader diff --git a/pj_marketplace/documentation/REQUIREMENTS.md b/pj_marketplace/documentation/REQUIREMENTS.md index 79387d4..9f7c38d 100644 --- a/pj_marketplace/documentation/REQUIREMENTS.md +++ b/pj_marketplace/documentation/REQUIREMENTS.md @@ -41,7 +41,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | | 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 | +| | 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; change triggers immediate refresh | | | Registry URL persistence | Last configured registry URL saved and restored between sessions | | **UI/UX** | Download progress | Progress bar in status bar | @@ -55,7 +55,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 +87,7 @@ 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. | --- @@ -104,7 +104,7 @@ 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-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 newer version exists | | F-10 | Uninstall extension | User can remove installed extensions | @@ -116,7 +116,7 @@ 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 | @@ -201,7 +201,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact **Postconditions:** Extension removed, local state updated -### 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 +209,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 +221,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 @@ -278,16 +277,16 @@ 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 | +| Manifest missing or invalid | Reject install or staged promotion with diagnostics | --- @@ -383,20 +382,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 { @@ -420,7 +421,7 @@ Fields read from `manifest.json`: | # | Topic | Options | Impact | |---|-------|---------|--------| -| 1 | ZIP library | QuaZip vs minizip vs libzip | Build complexity | +| 1 | ZIP library | Resolved: libarchive | 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 | diff --git a/pj_marketplace/documentation/SPRINT_PROPOSAL.md b/pj_marketplace/documentation/SPRINT_PROPOSAL.md index 2f6a938..f7555a7 100644 --- a/pj_marketplace/documentation/SPRINT_PROPOSAL.md +++ b/pj_marketplace/documentation/SPRINT_PROPOSAL.md @@ -2,6 +2,8 @@ > **Target:** Integrated prototype by end of March / early April 2026 > **Owner:** Pablo (IBRobotics) +> **Status:** Historical planning document; see `ARCHITECTURE.md` and +> `REQUIREMENTS.md` for current implementation behavior. --- @@ -84,7 +86,7 @@ | 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-10 | Uninstall extension | ### Daily Breakdown @@ -95,7 +97,7 @@ | 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 | +| Wed | 11 Mar | DownloadManager + SHA256 + libarchive extraction | Installs dummy ZIP | ### Success Criteria Week 1 @@ -150,7 +152,7 @@ | 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 | +| Fri | 20 Mar | Package as ZIP with embedded plugin 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 | @@ -211,7 +213,7 @@ | 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-08 | Register from embedded DSO manifest | W1 | ⬜ | | F-09 | Detect updates | W3 | ⬜ | | F-10 | Uninstall extension | W1 | ⬜ | diff --git a/pj_marketplace/documentation/USER_MANUAL.md b/pj_marketplace/documentation/USER_MANUAL.md index 5487754..4009734 100644 --- a/pj_marketplace/documentation/USER_MANUAL.md +++ b/pj_marketplace/documentation/USER_MANUAL.md @@ -144,7 +144,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:** @@ -175,36 +175,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 @@ -253,12 +240,10 @@ If the marketplace is broken: ```bash # Linux/macOS rm -rf ~/.plotjuggler/extensions/ -rm ~/.plotjuggler/installed.json rm -rf ~/.plotjuggler/.cache/ # Windows rmdir /s %USERPROFILE%\.plotjuggler\extensions -del %USERPROFILE%\.plotjuggler\installed.json rmdir /s %USERPROFILE%\.plotjuggler\.cache ``` @@ -282,13 +267,12 @@ rmdir /s %USERPROFILE%\.plotjuggler\.cache ~/.plotjuggler/ ├── extensions/ # Installed extensions │ └── my-extension/ -│ ├── manifest.json │ └── libmy_plugin.so ├── .pending/ # Staged updates (Windows) -├── .backup/ # Backup of previous versions +│ └── my-extension/.pj_pending_install +├── .backup/ # Non-Windows update backups; automatic rollback is deferred ├── .cache/ # Registry cache │ └── registry.json -└── installed.json # Local state ``` ### 5.2 Registry URL @@ -367,7 +351,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..12c3a87 100644 --- a/pj_marketplace/documentation/diagrams/windows-staging.puml +++ b/pj_marketplace/documentation/diagrams/windows-staging.puml @@ -6,20 +6,22 @@ title Windows Staging Flow start :Download ZIP; :Extract to .pending/{id}/; -note right: Staging folder +:Load DSO manifest; +:Validate registry id/version; +:Write .pj_pending_install intent; :Notify "Restart required"; stop start :PlotJuggler restarts; +:Read .pj_pending_install intent; +:Validate staged DSO manifest; +if (Valid?) then (yes) :Move .pending/{id}/ to extensions/{id}/; -note right: Previous backup in\n.backup/{id}-{ver}/ -:Load plugin; -if (Load successful?) then (yes) :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..2fc70b7 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 @@ -78,7 +78,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 | @@ -181,7 +181,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 +297,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 +309,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 +359,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 +433,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 +447,18 @@ 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 +### 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 +Automatic rollback is deferred. On non-staged platforms an update keeps the +previous version in `.backup/`, but the marketplace does not currently restore +that backup automatically if a later plugin load fails. --- @@ -511,7 +482,6 @@ plotjuggler/extension-template/ ├── CMakeLists.txt ├── conanfile.py ├── pixi.toml ← Future alternative -├── manifest.json.in ├── conan_profiles/ │ ├── linux_static │ ├── windows_static @@ -607,7 +577,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) @@ -634,13 +604,15 @@ 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: +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: - Detects pending updates - - Backs up current version to `.backup/` + - Reads `.pj_pending_install` + - Revalidates the staged DSO against that registry intent - Moves new version from `.pending/` to `extensions/` - - Loads the plugin -5. If plugin fails to load, automatically restores from backup +7. If validation fails, the broken stage is removed and the active install is left untouched ### 11.3 Directory Structure @@ -650,10 +622,11 @@ The flow is: │ ├── ros2-streaming/ │ └── csv-loader/ ├── .pending/ ← Staging (Windows) -├── .backup/ ← Backups for rollback +│ └── plugin-id/.pj_pending_install +├── .backup/ ← Non-Windows update backups; automatic rollback deferred │ ├── ros2-streaming-1.2.2/ │ └── csv-loader-0.9.0/ -└── installed.json ← Local state +└── .cache/ ← Registry cache ``` --- @@ -817,7 +790,6 @@ marketplace/ │ │ ├── Extension.h │ │ ├── InstalledExtension.h │ │ ├── Registry.h -│ │ └── LocalState.h │ ├── core/ │ │ ├── RegistryManager.h/cpp │ │ ├── ExtensionManager.h/cpp @@ -825,13 +797,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 +818,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,7 +828,7 @@ 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 | @@ -905,8 +871,7 @@ marketplace/ - CMake + Qt6 project setup - Data structs (Extension, InstalledExtension) - MarketplaceWindow with QSplitter -- ExtensionListWidget with custom cards -- ExtensionDetailWidget with tabs +- MarketplaceWindow cards and ExtensionDetailDialog - Hardcoded mock data - Functional search and filter @@ -956,7 +921,7 @@ marketplace/ | # | Topic | Options | | --- | -------------------------- | --------------------------------- | -| 1 | ZIP library | QuaZip vs minizip vs libzip | +| 1 | ZIP library | Resolved: libarchive | | 2 | Markdown rendering | QTextBrowser vs plain text | | 3 | Metrics | Registry JSON vs GitHub API | | 4 | Icons | URL in registry vs bundled in ZIP | diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index 3a4e80f..7c933ad 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -26,7 +26,7 @@ class DownloadManager; // 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 +// - Discovers installed extensions by scanning plugin DSOs and reading their embedded manifest // // 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 @@ -45,10 +45,8 @@ class ExtensionManager : public QObject { // `extensions_dir` and `pending_dir` default to the standard user paths. // Pass QTemporaryDir paths in tests to get a clean, isolated state. explicit ExtensionManager( - DownloadManager* downloader, - const QString& extensions_dir = PlatformUtils::extensionsDir(), - const QString& pending_dir = PlatformUtils::pendingDir(), - QObject* parent = nullptr); + DownloadManager* downloader, const QString& extensions_dir = PlatformUtils::extensionsDir(), + const QString& pending_dir = PlatformUtils::pendingDir(), QObject* parent = nullptr); // Starts an async install of `ext` for the running platform. // Emits installStarted() synchronously before the download begins. @@ -61,11 +59,10 @@ class ExtensionManager : public QObject { // be removed (e.g. a DLL is still loaded on Windows — F-14 staging is deferred). 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. + // On Windows, downloads the replacement into .pending// and leaves the + // active DLL in place until applyPendingInstalls() runs on the next startup. + // On other platforms, moves the current version to + // ~/.plotjuggler/.backup/-/ and installs the registry version. void update(const Extension& ext); // Moves any staged extensions from .pending/ into extensions/ and registers them. @@ -78,17 +75,13 @@ class ExtensionManager : public QObject { // Should be called once at application startup. Safe to call on any platform. void applyPendingUninstalls(); - // Returns true if the extension is in the in-memory installed map AND its - // .so/.dll is still on disk. If the path has vanished (user deleted it - // outside the marketplace UI), self-evicts the stale entry and returns - // false. Subsequent reads then see the truth. + // Returns true if the extension is present in the latest DSO discovery cache. bool isInstalled(const QString& id) const; - // Walks every entry in the in-memory installed map and verifies the on-disk - // path still exists. Evicts stale records and emits extensionEvictedExternally - // for each. Called from the marketplace dialog's Refresh button and showEvent - // so the displayed state always matches disk on dialog open. - void reconcileInstalledWithDisk(); + // Rebuilds the installed cache from plugin DSOs on disk. Cheap enough for + // marketplace dialog open/refresh and keeps UI state aligned with external + // filesystem changes. + void refreshInstalledFromDisk(); // Returns true if the extension is staged in the pending directory and will // become active after the next restart (Windows update path). @@ -106,6 +99,12 @@ class ExtensionManager : public QObject { // Snapshot of the currently installed extensions, keyed by id. QMap installedExtensions() const; +#ifdef PJ_MARKETPLACE_TESTING + void testDoInstall(const Extension& ext, bool staging, bool allow_existing = false) { + doInstall(ext, staging, allow_existing); + } +#endif + signals: void installStarted(const QString& id); void installProgress(const QString& id, int percent); @@ -122,26 +121,12 @@ class ExtensionManager : public QObject { // via applyPendingUninstalls(). void uninstallPendingRestart(const QString& id); - // Emitted when isInstalled() or reconcileInstalledWithDisk() observes that an - // installed plugin's path no longer exists on disk and self-evicts the - // in-memory record. UI layers (and PJ4's ExtensionCatalogService) can use this - // to refresh views without an explicit marketplace reload trip. - void extensionEvictedExternally(const QString& id); - 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(); + void doInstall(const Extension& ext, bool staging, bool allow_existing = false); void disconnectDlConns(); - // Writes pj_meta.json (host-owned bookkeeping: id, version, install_date) - // into /. This is the persistent record that loadState() reads at - // startup; it replaces the plugin-author-supplied manifest.json sidecar - // for host-side bookkeeping. Plugin self-description (capabilities, - // file_extensions, name, etc.) still comes from the .so's vt_->manifest_json. - void writeInstalledMeta(const Extension& ext, const QString& dir); void schedulePendingUninstall(const QString& path); DownloadManager* downloader_ = nullptr; diff --git a/pj_marketplace/include/pj_marketplace/installed_extension.hpp b/pj_marketplace/include/pj_marketplace/installed_extension.hpp index 446ade5..f33c84d 100644 --- a/pj_marketplace/include/pj_marketplace/installed_extension.hpp +++ b/pj_marketplace/include/pj_marketplace/installed_extension.hpp @@ -11,7 +11,6 @@ struct InstalledExtension { 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 27e127a..01b6e1d 100644 --- a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp +++ b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp @@ -1,11 +1,13 @@ #pragma once #include -#include #include + #include "pj_marketplace/extension.hpp" -namespace Ui { class MarketplaceWindow; } +namespace Ui { +class MarketplaceWindow; +} namespace PJ { @@ -21,19 +23,19 @@ class MarketplaceWindow : public QDialog { // 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); + explicit MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& registry_url, QWidget* parent = nullptr); ~MarketplaceWindow() override; - bool installationsChanged() const { return installations_changed_; } + bool installationsChanged() const { + return installations_changed_; + } protected: bool eventFilter(QObject* obj, QEvent* event) override; - // Reconciles `installed_` against disk on every dialog open so phantom - // entries (`.so` removed externally between sessions) don't render as - // "Installed" badges. See ExtensionManager::reconcileInstalledWithDisk. + // Refreshes the installed DSO cache on every dialog open so external + // filesystem changes are reflected before cards are painted. void showEvent(QShowEvent* event) override; private slots: @@ -54,17 +56,16 @@ class MarketplaceWindow : public QDialog { void openDetail(const QString& ext_id); 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; }; } // namespace PJ diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index ae5acf5..5f1eef2 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -1,35 +1,167 @@ -#include "pj_marketplace/extension_manager.hpp" - #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"; + +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); +} + +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; + } + + intent.valid = true; + intent.id = QString::fromUtf8(lines[0].trimmed()); + intent.version = QString::fromUtf8(lines[1].trimmed()); + 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) +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(); + initComponents(); } void ExtensionManager::initComponents() { @@ -37,34 +169,31 @@ void ExtensionManager::initComponents() { downloader_ = new DownloadManager(this); } QDir().mkpath(extensions_dir_); - loadState(); + 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 (!pending_id_.isEmpty()) { emit installError(ext.id, QString("Install of \"%1\" is already in progress").arg(pending_id_)); emit installFinished(ext.id, false); return; } - if (isInstalled(ext.id)) { + if (!allow_existing && isInstalled(ext.id)) { emit installError(ext.id, QString("Extension \"%1\" is already installed").arg(ext.id)); emit installFinished(ext.id, false); 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)); @@ -73,9 +202,10 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { } 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. const QString dest_dir = staging ? pending_dir_ : extensions_dir_; + if (staging) { + QDir(pendingRoot(pending_dir_, ext.id)).removeRecursively(); + } pending_id_ = ext.id; emit installStarted(ext.id); @@ -88,8 +218,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,7 +230,6 @@ 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; @@ -114,29 +241,30 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { pending_id_.clear(); pending_op_id_ = -1; + const QString root = staging ? pendingRoot(pending_dir_, ext.id) : extRoot(extensions_dir_, ext.id); + const DirectoryDiscovery discovered = discoverExtensionDirectory(root); + const QString validation_error = validateRegistryIntent(discovered, ext.id, ext.version); + if (!validation_error.isEmpty()) { + QDir(root).removeRecursively(); + emit installError(finished_id, validation_error); + emit installFinished(finished_id, false); + return; + } + if (staging) { - // Persist host-owned metadata into the staged dir so applyPendingInstalls - // (next startup) can read id/version without parsing the plugin-author - // manifest.json sidecar. - writeInstalledMeta(ext, pending_dir_ + "/" + ext.id); + QString intent_error; + if (!writePendingInstallIntent(root, ext, &intent_error)) { + QDir(root).removeRecursively(); + emit installError(finished_id, intent_error); + emit installFinished(finished_id, false); + return; + } emit installPendingRestart(finished_id); - } else { - const QString ext_root = extensions_dir_ + "/" + ext.id; - - InstalledExtension record; - record.id = ext.id; - record.version = ext.version; // authoritative source: registry struct - record.install_date = QDateTime::currentDateTimeUtc(); - record.path = ext_root; - record.enabled = true; - - // Persist host-owned bookkeeping. loadState() at next startup reads this - // file (with a manifest.json fallback for legacy installs). - writeInstalledMeta(ext, ext_root); - - installed_[ext.id] = record; - emit installFinished(finished_id, true); + return; } + + installed_[ext.id] = discovered.record; + emit installFinished(finished_id, true); }); dl_failed_conn_ = connect(downloader_, &DownloadManager::failed, this, [this](int id, const QString& error) { @@ -150,9 +278,6 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { pending_id_.clear(); pending_op_id_ = -1; - // 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); }); @@ -168,10 +293,8 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { pending_op_id_ = -1; disk_space_checked_ = false; - // 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(); + QDir(extRoot(extensions_dir_, cancelled_id)).removeRecursively(); + QDir(pendingRoot(pending_dir_, cancelled_id)).removeRecursively(); const QString reason = cancel_reason_.isEmpty() ? "Installation was cancelled" : cancel_reason_; cancel_reason_.clear(); @@ -183,6 +306,8 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging) { } 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); @@ -193,8 +318,6 @@ 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); installed_.remove(extension_id); emit uninstallPendingRestart(extension_id); @@ -211,43 +334,29 @@ void ExtensionManager::uninstall(const QString& extension_id) { } void ExtensionManager::update(const Extension& ext) { - QString backup_path; + refreshInstalledFromDisk(); + + if (PlatformUtils::isWindows()) { + doInstall(ext, /*staging=*/true, /*allow_existing=*/true); + 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; - // 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 installError( + ext.id, QString("Could not back up \"%1\" — update aborted to prevent data loss").arg(current_path)); emit installFinished(ext.id, false); return; } - 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()); } @@ -258,51 +367,64 @@ void ExtensionManager::applyPendingInstalls() { } for (const QFileInfo& entry : pending.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - const QString src = entry.absoluteFilePath(); - // Directory name IS the id — install() always creates pending_dir_//, - // so we don't need to parse a sidecar to recover it. - const QString id = entry.fileName(); - if (id.isEmpty()) { + const QString staged_dir = entry.absoluteFilePath(); + const QString staged_name = entry.fileName(); + + auto failStagedInstall = [&](const QString& signal_id, const QString& message) { + qWarning( + "ExtensionManager: staged install '%s' failed validation: %s", qPrintable(staged_dir), qPrintable(message)); + QDir(staged_dir).removeRecursively(); + emit installError(signal_id, message); + emit installFinished(signal_id, false); + }; + + if (staged_name.isEmpty()) { + failStagedInstall(staged_name, "Staged install directory has no id"); continue; } - // Sanity: a real staged install always contains at least the plugin binary. - // A bare directory is a sign of a broken staging operation; skip it rather - // than promote a phantom installation that would later confuse loadState. - if (QDir(src).entryInfoList(QDir::Files | QDir::NoDotAndDotDot).isEmpty()) { + + const PendingInstallIntent intent = readPendingInstallIntent(staged_dir); + if (!intent.valid) { + failStagedInstall(staged_name, intent.error); continue; } - const QString dst = extensions_dir_ + "/" + id; - - // Version comes from the host-owned pj_meta.json that doInstall() wrote - // when staging completed. Fall back to manifest.json for legacy staged - // installs from before the sidecar removal. - QString version; - QFile pj_meta_file(src + "/pj_meta.json"); - if (pj_meta_file.open(QIODevice::ReadOnly)) { - version = QJsonDocument::fromJson(pj_meta_file.readAll()).object()["version"].toString(); - } else { - QFile legacy(src + "/" + kManifestFileName); - if (legacy.open(QIODevice::ReadOnly)) { - version = QJsonDocument::fromJson(legacy.readAll()).object()["version"].toString(); - } + 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; + } + + 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; } - // Remove any existing installation so the rename cannot fail on a non-empty target. - QDir(dst).removeRecursively(); + const QString dst = extRoot(extensions_dir_, intent.id); + if (QDir(dst).exists() && !QDir(dst).removeRecursively()) { + qWarning("ExtensionManager: failed to remove existing extension directory '%s'", qPrintable(dst)); + emit installError(intent.id, QString("Could not remove existing extension directory \"%1\"").arg(dst)); + emit installFinished(intent.id, false); + continue; + } + installed_.remove(intent.id); - if (!QDir().rename(src, dst)) { + if (!QDir().rename(staged_dir, dst)) { + qWarning( + "ExtensionManager: failed to promote staged install '%s' to '%s'", qPrintable(staged_dir), qPrintable(dst)); + emit installError(intent.id, QString("Could not promote staged install to \"%1\"").arg(dst)); + emit installFinished(intent.id, false); continue; } - InstalledExtension record; - record.id = id; - record.version = version; - record.install_date = QDateTime::currentDateTimeUtc(); + QFile::remove(pendingInstallIntentPath(dst)); + InstalledExtension record = discovered.record; record.path = dst; - record.enabled = true; - - installed_[id] = record; - emit installFinished(id, true); + record.install_date = QFileInfo(dst).lastModified(); + installed_[intent.id] = record; + emit installFinished(intent.id, true); } } @@ -312,7 +434,6 @@ void ExtensionManager::applyPendingUninstalls() { if (!QFile::exists(entry.absoluteFilePath() + "/" + kPendingUninstallMarker)) { continue; } - // Directory name IS the id — install() always creates extensions_dir_//. const QString id = entry.fileName(); if (QDir(entry.absoluteFilePath()).removeRecursively() && !id.isEmpty()) { installed_.remove(id); @@ -320,56 +441,19 @@ void ExtensionManager::applyPendingUninstalls() { } } - bool ExtensionManager::isInstalled(const QString& id) const { - auto it = installed_.find(id); - if (it == installed_.end()) { - return false; - } - if (QFileInfo::exists(it->path)) { - return true; - } - // The .so/.dll was removed externally. Evict the stale entry so subsequent - // reads see the truth and so a future install of the same id starts clean. - // const_cast is the price of a const-correct public API; every UI caller - // (populateCards) iterates per-paint and would otherwise observe the lie. - qWarning("ExtensionManager: evicting stale installed entry for '%s' — file vanished: %s", - qPrintable(id), qPrintable(it->path)); - auto* self = const_cast(this); - self->installed_.erase(it); - emit self->extensionEvictedExternally(id); - return false; -} - -void ExtensionManager::reconcileInstalledWithDisk() { - // Snapshot the keys first; we can't mutate `installed_` while iterating it. - QStringList stale_ids; - for (auto it = installed_.constBegin(); it != installed_.constEnd(); ++it) { - if (!QFileInfo::exists(it->path)) { - stale_ids << it.key(); - } - } - for (const QString& id : stale_ids) { - qWarning("ExtensionManager: reconcile evicting '%s' — file vanished: %s", - qPrintable(id), qPrintable(installed_[id].path)); - installed_.remove(id); - emit extensionEvictedExternally(id); - } + return installed_.contains(id); } bool ExtensionManager::hasPendingInstall(const QString& id) const { - // Directory name IS the id; if pending_dir_// exists, the install is staged. - const QString staged = pending_dir_ + "/" + id; - if (!QFileInfo::exists(staged)) { - return false; - } - emit const_cast(this)->installPendingRestart(id); - return true; + // Marker existence is enough for UI predicates; applyPendingInstalls() does + // the full validation (intent contents, DSO presence, registry-vs-embedded + // match) and tears down anything broken on next startup. + return QFile::exists(pendingInstallIntentPath(pendingRoot(pending_dir_, id))); } 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::hasUpdate(const Extension& ext) const { @@ -377,8 +461,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; @@ -404,87 +486,29 @@ void ExtensionManager::schedulePendingUninstall(const QString& path) { marker.open(QIODevice::WriteOnly); // content irrelevant; existence is the signal } -void ExtensionManager::writeInstalledMeta(const Extension& ext, const QString& dir) { - QJsonObject obj; - obj["id"] = ext.id; - obj["version"] = ext.version; - obj["install_date"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - - QFile file(dir + "/pj_meta.json"); - if (!file.open(QIODevice::WriteOnly)) { - qWarning("ExtensionManager: failed to write pj_meta.json under '%s'", qPrintable(dir)); - return; - } - file.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); -} - -// --------------------------------------------------------------------------- -// Private — state persistence -// --------------------------------------------------------------------------- - -void ExtensionManager::loadState() { +void ExtensionManager::refreshInstalledFromDisk() { + QMap discovered; const QDir dir(extensions_dir_); for (const QFileInfo& entry : dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { - const QString ext_root = entry.absoluteFilePath(); - - if (QFile::exists(ext_root + "/" + kPendingUninstallMarker)) { + const QString root = entry.absoluteFilePath(); + if (QFile::exists(root + "/" + kPendingUninstallMarker)) { continue; } - // Directory name IS the id (install always creates extensions_dir_//). - const QString id = entry.fileName(); - if (id.isEmpty()) { + const DirectoryDiscovery item = discoverExtensionDirectory(root); + if (!item.found_plugin) { + qWarning("ExtensionManager: ignoring extension directory '%s': %s", qPrintable(root), qPrintable(item.error)); continue; } - - // Version comes from the host-owned pj_meta.json. For installations made - // before the sidecar removal, fall back to the plugin-author manifest.json - // — that fallback can be deleted once all users have re-installed at least - // once under the new layout. - QString version; - QFile pj_meta_file(ext_root + "/pj_meta.json"); - if (pj_meta_file.open(QIODevice::ReadOnly)) { - version = QJsonDocument::fromJson(pj_meta_file.readAll()).object()["version"].toString(); - } else { - QFile legacy(ext_root + "/" + kManifestFileName); - if (legacy.open(QIODevice::ReadOnly)) { - version = QJsonDocument::fromJson(legacy.readAll()).object()["version"].toString(); - } + if (discovered.contains(item.record.id)) { + qWarning( + "ExtensionManager: duplicate embedded extension id '%s' in '%s'; keeping first", qPrintable(item.record.id), + qPrintable(root)); + continue; } - - InstalledExtension inst; - inst.id = id; - inst.version = version; - inst.install_date = entry.lastModified(); - inst.path = ext_root; - inst.enabled = true; - - installed_[id] = inst; + discovered[item.record.id] = item.record; } -} - -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()); - // } + installed_ = std::move(discovered); } } // 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/marketplace_window.cpp b/pj_marketplace/src/ui/marketplace_window.cpp index 2a12839..f43950c 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -1,27 +1,27 @@ #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 "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 = @@ -32,8 +32,7 @@ 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(), this); QSettings settings("PlotJuggler", "Marketplace"); const QString saved = settings.value("registry_url").toString(); registry_url_ = saved.isEmpty() ? registry_url : QUrl(saved); @@ -41,12 +40,12 @@ MarketplaceWindow::MarketplaceWindow(const QUrl& registry_url, QWidget* parent) ui_->setupUi(this); setupUi(); setupSignals(); + ext_mgr_->applyPendingUninstalls(); ext_mgr_->applyPendingInstalls(); 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, QWidget* parent) : QDialog(parent), ui_(new Ui::MarketplaceWindow) { registry_mgr_ = new RegistryManager(this); ext_mgr_ = ext_mgr; @@ -71,30 +70,26 @@ 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_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); } // ─── 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) { @@ -106,88 +101,92 @@ void MarketplaceWindow::setupSignals() { setStatus("Ready — " + QString::number(extensions_.size()) + " extensions loaded"); }); + connect(ext_mgr_, &ExtensionManager::installPendingRestart, this, [this](const QString& id) { + ui_->progress_bar_->setVisible(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); + 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) { - 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::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); + }); } // ─── 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 +215,14 @@ void MarketplaceWindow::populateCards() { f.setBold(true); name_lbl->setFont(f); + const bool has_update = ext_mgr_->hasUpdate(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 (has_update && installed.contains(ext.id)) { + version_text = installed[ext.id].version + " \u2192 " + ext.version; } auto* version_lbl = new QLabel(version_text, card); version_lbl->setStyleSheet("color: palette(text);"); @@ -228,8 +230,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 +238,16 @@ 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 (installed.contains(ext.id)) { auto* badge = new QPushButton("Installed", card); badge->setFixedWidth(90); badge->setEnabled(false); @@ -262,8 +262,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 +284,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 +292,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 +302,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,26 +319,33 @@ 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) { @@ -354,17 +355,17 @@ void MarketplaceWindow::setStatus(const QString& msg, bool is_error) { // ─── 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() { setStatus("Refreshing..."); - // Reconcile in-memory installed_ against disk first so the cards painted - // during the registry-fetch latency already show the correct state. If any - // entry was stale, mark installations changed so MainWindow's post-close - // catalog.reload() runs. const int before = ext_mgr_->installedExtensions().size(); - ext_mgr_->reconcileInstalledWithDisk(); + ext_mgr_->refreshInstalledFromDisk(); if (ext_mgr_->installedExtensions().size() != before) { installations_changed_ = true; } @@ -373,12 +374,9 @@ void MarketplaceWindow::onRefreshClicked() { } void MarketplaceWindow::showEvent(QShowEvent* event) { - // Self-heal stale "Installed" badges every time the dialog becomes visible. - // Cheap (one stat per installed entry) and prevents the user from ever - // seeing a phantom card without taking any explicit action. if (ext_mgr_ != nullptr) { const int before = ext_mgr_->installedExtensions().size(); - ext_mgr_->reconcileInstalledWithDisk(); + ext_mgr_->refreshInstalledFromDisk(); if (ext_mgr_->installedExtensions().size() != before) { installations_changed_ = true; populateCards(); @@ -426,11 +424,14 @@ 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; + } + if (ext_mgr_->hasUpdate(ext)) { ext_mgr_->update(ext); - else if (!ext_mgr_->isInstalled(ext.id)) + } else if (!ext_mgr_->isInstalled(ext.id)) { ext_mgr_->install(ext); + } return; } } @@ -442,17 +443,22 @@ void MarketplaceWindow::onUninstallButtonClicked(const QString& ext_id) { void MarketplaceWindow::onUpdateAllClicked() { 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::processInstallQueue() { - if (update_queue_.isEmpty()) return; + if (update_queue_.isEmpty()) { + return; + } ext_mgr_->update(update_queue_.takeFirst()); } diff --git a/pj_marketplace/tests/extension_manager_test.cpp b/pj_marketplace/tests/extension_manager_test.cpp index a8ac864..1006879 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,33 +124,77 @@ 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 only a placeholder binary. Plugin self-description (capabilities, -// file_extensions, name, etc.) is read from the .so's vt_->manifest_json at -// load time; the host-owned pj_meta.json bookkeeping file is written by -// ExtensionManager at install completion. There is no plugin-author sidecar. -QByteArray dummyPluginZip(const QString& ext_id, const QString& /*version*/ = "1.0.0") { +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())); +} + +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 + "/" + ext_id + ".plugin", "placeholder binary content"}, + {ext_id + "/" + pluginFileName(), plugin}, }); } -// Legacy ZIP shape that bakes manifest.json into the artifact. Used by the -// backward-compat test that exercises loadState's fallback for installations -// from before the sidecar removal. -QByteArray legacyPluginZipWithManifest(const QString& ext_id, const QString& version = "1.0.0") { - const QByteArray manifest = - QJsonDocument(QJsonObject{{"id", ext_id}, {"version", version}}).toJson(); +// 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") { + 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; @@ -186,8 +239,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); @@ -197,36 +250,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); @@ -236,7 +289,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); @@ -250,8 +303,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); @@ -263,7 +316,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()); } @@ -274,11 +327,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); @@ -287,7 +339,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()); } @@ -312,31 +364,98 @@ 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()); +} + // --------------------------------------------------------------------------- // [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()); } @@ -364,35 +483,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(); + + QTemporaryDir local_ext_dir(QDir(PlatformUtils::backupDir()).absoluteFilePath("../test_ext_XXXXXX")); + ASSERT_TRUE(local_ext_dir.isValid()); + + 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(mgr_, &ExtensionManager::installFinished); - mgr_->install(ext_v1); + 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")); @@ -401,32 +527,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 @@ -441,8 +565,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); @@ -453,7 +577,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); @@ -462,37 +586,36 @@ 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"; - 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); @@ -503,8 +626,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); @@ -515,26 +638,26 @@ 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)); } // 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); @@ -550,61 +673,122 @@ TEST_F(ExtensionManagerTest, HasUpdateReturnsFalseForOlderVersion) { // // 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 +// 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, ApplyPendingInstallsSkipsEmptyStagingDirectory) { +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")); +} + +// An entry in .pending/ 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 leave the directory empty to simulate a broken staging - // operation. Under the new sidecar-free layout the directory name would - // otherwise be taken as the id; the empty-dir guard prevents a phantom - // installation from being recorded. - 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()); } // applyPendingInstalls() is a no-op when the pending directory contains no sub-directories. @@ -616,21 +800,99 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsIsNoOpForEmptyDirectory) { // Multiple staged extensions in .pending/ 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(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); } // --------------------------------------------------------------------------- @@ -640,8 +902,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); @@ -652,25 +914,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 @@ -691,7 +983,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)); @@ -704,7 +996,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(); @@ -717,43 +1009,6 @@ TEST_F(ExtensionManagerTest, ApplyPendingUninstallsIsNoOpForEmptyDirectory) { mgr_->applyPendingUninstalls(); // must not crash } -// Backward compatibility: a pre-existing installation laid out by an older host -// version (manifest.json present, no pj_meta.json) must still be visible in -// installedExtensions() after a fresh ExtensionManager loads its state. -TEST_F(ExtensionManagerTest, LoadStateFallsBackToManifestJsonForLegacyInstalls) { - // Simulate a legacy install by extracting a legacy ZIP straight into the - // extensions directory under /, without ever calling install(). - QTemporaryDir legacy_extensions(QDir::tempPath() + "/legacy_ext_XXXXXX"); - ASSERT_TRUE(legacy_extensions.isValid()); - const QString id = "csv-loader"; - const QString version = "1.2.3"; - ASSERT_TRUE(QDir(legacy_extensions.path()).mkpath(id)); - const QByteArray manifest = - QJsonDocument(QJsonObject{{"id", id}, {"version", version}}).toJson(); - QFile mfile(legacy_extensions.path() + "/" + id + "/manifest.json"); - ASSERT_TRUE(mfile.open(QIODevice::WriteOnly)); - mfile.write(manifest); - mfile.close(); - // Touch the placeholder binary so isInstalled() filesystem check passes too - // (resilience-fix invariant: isInstalled stats the path). - QFile bin(legacy_extensions.path() + "/" + id + "/" + id + ".plugin"); - ASSERT_TRUE(bin.open(QIODevice::WriteOnly)); - bin.write("placeholder"); - bin.close(); - - // A fresh manager rooted on the legacy directory should reconstruct - // installed_ from manifest.json without help from pj_meta.json. - DownloadManager dl; - ExtensionManager mgr(&dl, legacy_extensions.path(), pending_dir_.path()); - EXPECT_TRUE(mgr.isInstalled(id)); - EXPECT_EQ(mgr.installedExtensions()[id].version, version); - - // Backward-compat helper coverage: the legacy ZIP shape we use elsewhere - // round-trips through the host as expected. - const QByteArray bytes = legacyPluginZipWithManifest(id, version); - EXPECT_FALSE(bytes.isEmpty()); -} - // --------------------------------------------------------------------------- // [8] Platform detection // --------------------------------------------------------------------------- @@ -762,8 +1017,7 @@ TEST_F(ExtensionManagerTest, LoadStateFallsBackToManifestJsonForLegacyInstalls) 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 @@ -788,7 +1042,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..bd849f2 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,16 @@ 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) + endif() # PJ_BUILD_TESTS # --------------------------------------------------------------------------- @@ -198,25 +227,22 @@ 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="$" +) 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) 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..319b684 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 @@ -236,7 +236,10 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { } // namespace PJ -/// Macro to export the vtable entry point for a plugin class. +/// Macro to export only the dialog vtable entry point for a plugin class. +/// +/// Use this when the dialog is co-resident with another plugin family that +/// already exports `pj_plugin_abi_version`. /// /// Emits two things: /// 1. The `PJ_get_dialog_vtable()` C symbol the host loader resolves @@ -245,7 +248,7 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { /// 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) \ +#define PJ_DIALOG_PLUGIN_VTABLE(ClassName) \ 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 { \ @@ -262,3 +265,8 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { return PJ_get_dialog_vtable(); \ } \ } + +/// Macro to export a standalone dialog plugin DSO. +#define PJ_DIALOG_PLUGIN(ClassName) \ + extern "C" PJ_DIALOG_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; \ + PJ_DIALOG_PLUGIN_VTABLE(ClassName) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index a006393..56b7d72 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -114,19 +114,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 +149,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 +271,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)` standalone, `PJ_DIALOG_PLUGIN_VTABLE(Class)` when co-resident | 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. diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 900da1c..2d202fe 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"})") ``` @@ -726,7 +726,7 @@ with no JSON serialization needed at runtime. │ getDialog() → borrowDialog(...) │ │ │ │ PJ_DATA_SOURCE_PLUGIN(MySource) │ → exports DataSource vtable -│ PJ_DIALOG_PLUGIN(MyDialog) │ → exports Dialog vtable +│ PJ_DIALOG_PLUGIN_VTABLE(MyDialog) │ → exports Dialog vtable └──────────────────────────────────────┘ ``` @@ -788,8 +788,8 @@ 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_DIALOG_PLUGIN(MyDialog) +PJ_DATA_SOURCE_PLUGIN(MySource, R"({"id":"my-source","name":"My Source","version":"1.0.0"})") +PJ_DIALOG_PLUGIN_VTABLE(MyDialog) ``` ### Host-side flow diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index 77207e6..cb9c6f8 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" @@ -551,9 +552,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_VTABLE(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..f3a71a9 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. @@ -271,7 +271,7 @@ needed. │ loadConfig(json) applies it │ │ │ │ PJ_MESSAGE_PARSER_PLUGIN(...) │ → exports parser vtable -│ PJ_DIALOG_PLUGIN(ProtoDialog) │ → exports dialog vtable +│ PJ_DIALOG_PLUGIN_VTABLE(ProtoDialog) │ → exports dialog vtable └──────────────────────────────────┘ ``` diff --git a/pj_plugins/docs/toolbox-guide.md b/pj_plugins/docs/toolbox-guide.md index f2ed23d..f63abb3 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -25,9 +25,9 @@ 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)` + `DialogPluginTyped` subclass and add `PJ_DIALOG_PLUGIN_VTABLE(YourDialog)` 5. Build as a shared library linking `pj_base` (+ `pj_dialog_sdk` if you have a dialog) @@ -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"})") ``` 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..7bdadc8 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) +PJ_DIALOG_PLUGIN_VTABLE(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..58e595b 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,24 @@ 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); +struct PluginDiagnostic { + std::filesystem::path path; + std::string message; +}; + +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/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..ecb2ad9 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,201 @@ 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); } - if (s == "message_parser") { - return PluginFamily::kMessageParser; +}; + +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()); + } + 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}; +} + +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); } -/// 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 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; + void* ctx = vt->create(); + if (ctx == nullptr) { + return unexpected(std::string("PJ_dialog_vtable_t::create returned null")); + } + 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)}; +} - // Required keys. Reject sidecars that are missing any of these. - if (!j.contains("name") || !j["name"].is_string()) { - return std::nullopt; +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("version") || !j["version"].is_string()) { - return std::nullopt; + + if (auto candidate = tryMessageParser(handle)) { + return *candidate; + } else { + errors.push_back("message_parser: " + candidate.error()); } - if (!j.contains("abi_major") || !j["abi_major"].is_number_integer()) { - return std::nullopt; + + if (auto candidate = tryToolbox(handle)) { + return *candidate; + } else { + errors.push_back("toolbox: " + candidate.error()); } - if (!j.contains("family") || !j["family"].is_string()) { - return std::nullopt; + + if (auto candidate = tryDialog(handle)) { + return *candidate; + } else { + errors.push_back("dialog: " + 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; + std::ostringstream out; + out << "no supported plugin vtable found"; + for (const auto& error : errors) { + out << "; " << error; } + return unexpected(out.str()); +} - // Optional fields. - if (j.contains("description") && j["description"].is_string()) { - d.description = j["description"].get(); +std::vector readStringArray(const nlohmann::json& j, std::string_view key) { + std::vector values; + const auto it = j.find(std::string(key)); + if (it == j.end() || !it->is_array()) { + return values; } - if (j.contains("category") && j["category"].is_string()) { - d.category = j["category"].get(); + for (const auto& value : *it) { + if (value.is_string()) { + values.push_back(value.get()); + } } - if (j.contains("encoding") && j["encoding"].is_string()) { - d.encoding = j["encoding"].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")); } - 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()); - } - } + + nlohmann::json j; + try { + j = nlohmann::json::parse(manifest_json); + } catch (const nlohmann::json::parse_error& 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")); } - if (j.contains("capabilities") && j["capabilities"].is_array()) { - for (const auto& c : j["capabilities"]) { - if (c.is_string()) { - d.capabilities.push_back(c.get()); - } + + 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(); + }; + + 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()); } - // 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; - } 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)); + d.id = *id; + d.name = *name; + d.version = *version; + d.description = j.value("description", ""); + d.category = j.value("category", ""); + d.file_extensions = readStringArray(j, "file_extensions"); + d.capabilities = readStringArray(j, "capabilities"); + + if (family == PluginFamily::kMessageParser) { + auto encoding = requiredString("encoding"); + if (!encoding) { + return unexpected(encoding.error()); } + d.encoding = *encoding; + } else if (j.contains("encoding") && j["encoding"].is_string()) { + d.encoding = j["encoding"].get(); } return d; @@ -144,7 +245,35 @@ 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()); @@ -153,31 +282,33 @@ Expected> scanPluginSidecars(const std::filesystem 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)) { + PluginScanResult result; + for (const auto& entry : std::filesystem::recursive_directory_iterator(directory, ec)) { if (ec) { + result.diagnostics.push_back({directory, "directory iteration failed: " + ec.message()}); 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())) { + const auto path = entry.path(); + if (!hasDsoSuffix(path)) { continue; } - if (auto d = decodeSidecar(path); d.has_value()) { - result.push_back(std::move(*d)); + auto descriptor = inspectPluginDso(path); + if (descriptor) { + result.plugins.push_back(std::move(*descriptor)); + } else { + result.diagnostics.push_back({path, descriptor.error()}); } } - // 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/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..63cca9b 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -1,14 +1,8 @@ -/** - * @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 @@ -30,105 +24,86 @@ 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"})"); - - auto result = scanPluginSidecars(dir_); - ASSERT_TRUE(result.has_value()); - ASSERT_EQ(result->size(), 1U); - EXPECT_EQ((*result)[0].name, "G"); +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"); } -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, 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); +} - 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, 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); } -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, 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, 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, "libvalid.so"); + std::ofstream(dir_ / "libbroken.so") << "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(), "libbroken.so"); } 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, "zz_plugin.so"); + copyPlugin(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, "aa_plugin.so"); + + 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(), "aa_plugin.so"); + EXPECT_EQ(result->plugins[1].dso_path.filename(), "zz_plugin.so"); } TEST_F(PluginCatalogTest, FamilyToStringRoundTrip) { @@ -141,30 +116,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 From ab302872542c9fe6a3b7aee8bc3d10b993c2e87f Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 07:42:30 +0200 Subject: [PATCH 06/12] feat: seed marketplace from loaded plugins --- pj_marketplace/CMakeLists.txt | 1 + pj_marketplace/documentation/ARCHITECTURE.md | 7 +- pj_marketplace/documentation/PLAN.md | 345 ------------------ pj_marketplace/documentation/REQUIREMENTS.md | 25 +- .../documentation/SPRINT_PROPOSAL.md | 298 --------------- pj_marketplace/documentation/TODO.md | 16 + pj_marketplace/documentation/USER_MANUAL.md | 15 +- .../diagrams/windows-staging.puml | 4 +- .../plotjuggler-marketplace-spec-v1.0.0-en.md | 90 +---- .../include/pj_marketplace/extension.hpp | 3 + .../pj_marketplace/extension_manager.hpp | 132 ++++--- .../pj_marketplace/installed_extension.hpp | 1 + .../pj_marketplace/marketplace_window.hpp | 56 ++- .../include/pj_marketplace/platform_utils.hpp | 3 +- pj_marketplace/src/core/ExtensionManager.cpp | 294 +++++++++++---- .../src/ui/extension_detail_dialog.cpp | 54 ++- pj_marketplace/src/ui/marketplace_window.cpp | 153 +++++++- pj_marketplace/src/ui/marketplace_window.ui | 20 +- ...ension_manager_check_plugin_management.cpp | 10 +- .../tests/extension_manager_test.cpp | 102 +++++- pj_plugins/CMakeLists.txt | 9 +- .../pj_plugins/host/plugin_catalog.hpp | 2 + pj_plugins/src/plugin_catalog.cpp | 58 ++- ...d_optional_manifest_data_source_plugin.cpp | 75 ++++ pj_plugins/tests/plugin_catalog_test.cpp | 36 +- pj_proto_app/CMakeLists.txt | 28 ++ pj_proto_app/src/main_window.cpp | 3 +- pj_proto_app/src/plugin_registry.cpp | 41 +++ pj_proto_app/src/plugin_registry.hpp | 13 + pj_proto_app/tests/plugin_registry_test.cpp | 30 ++ 30 files changed, 990 insertions(+), 934 deletions(-) delete mode 100644 pj_marketplace/documentation/PLAN.md delete mode 100644 pj_marketplace/documentation/SPRINT_PROPOSAL.md create mode 100644 pj_marketplace/documentation/TODO.md create mode 100644 pj_plugins/tests/invalid_optional_manifest_data_source_plugin.cpp create mode 100644 pj_proto_app/tests/plugin_registry_test.cpp diff --git a/pj_marketplace/CMakeLists.txt b/pj_marketplace/CMakeLists.txt index 4376e54..27f8385 100644 --- a/pj_marketplace/CMakeLists.txt +++ b/pj_marketplace/CMakeLists.txt @@ -90,6 +90,7 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) target_include_directories(pj_marketplace 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_marketplace PUBLIC nlohmann_json::nlohmann_json diff --git a/pj_marketplace/documentation/ARCHITECTURE.md b/pj_marketplace/documentation/ARCHITECTURE.md index 1292e96..0ab07d1 100644 --- a/pj_marketplace/documentation/ARCHITECTURE.md +++ b/pj_marketplace/documentation/ARCHITECTURE.md @@ -201,6 +201,7 @@ ExtensionManager(DownloadManager* downloader, - 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. +- 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. --- @@ -254,7 +255,7 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .pending/{id}/; +:Extract to .extension_windows_staging/{id}/; :Load DSO manifest; :Validate registry id/version; :Write .pj_pending_install intent; @@ -266,7 +267,7 @@ start :Read .pj_pending_install intent; :Validate staged DSO manifest; if (Valid?) then (yes) -:Move .pending/{id}/ to extensions/{id}/; +:Move .extension_windows_staging/{id}/ to extensions/{id}/; :Plugin active; else (no) :Remove broken stage; @@ -328,7 +329,7 @@ stop │ │ └── ros2_streaming.ui │ └── csv-loader/ │ └── libcsv_loader.so -├── .pending/ # Staging area (Windows) +├── .extension_windows_staging/ # Staging area (Windows) │ └── ros2-streaming/ # Ready to install on restart ├── .backup/ # Non-Windows update backups; automatic rollback deferred │ ├── ros2-streaming-1.2.2/ diff --git a/pj_marketplace/documentation/PLAN.md b/pj_marketplace/documentation/PLAN.md deleted file mode 100644 index 6ce2bd8..0000000 --- a/pj_marketplace/documentation/PLAN.md +++ /dev/null @@ -1,345 +0,0 @@ -# PlotJuggler Marketplace — Implementation Plan - -> **Version:** 1.0.0 -> **Last Updated:** 2026-03-05 -> **Status:** Historical planning document; not the current implementation contract -> **Deadline:** 31 March 2026 - -> Current implementation notes: installed state is discovered from embedded DSO -> manifests, ZIP extraction is handled by `DownloadManager` through libarchive, -> and automatic rollback remains deferred. - ---- - -## 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 from embedded DSO manifest | 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) -- [ ] Use DownloadManager/libarchive for ZIP extraction -- [ ] Create ExtensionManager — inject DownloadManager via constructor; installed state is rebuilt by scanning plugin DSOs -- [ ] 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 embedded plugin 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) -- [ ] Add embedded manifest to the plugin export -- [ ] 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 9f7c38d..f2dbea3 100644 --- a/pj_marketplace/documentation/REQUIREMENTS.md +++ b/pj_marketplace/documentation/REQUIREMENTS.md @@ -30,17 +30,18 @@ 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 | | | Confirmation | Confirmation dialog before uninstalling | | **Management** | Enable/Disable | Activate/deactivate extensions without uninstalling | -| | Rollback | Automatic restoration if a plugin fails to load | +| | 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; change triggers immediate refresh | | | Registry URL persistence | Last configured registry URL saved and restored between sessions | @@ -105,7 +106,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | 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 extension DSOs and reading each embedded plugin manifest | -| F-09 | Detect updates (local vs registry version) | User sees "Update available" badge when newer version exists | +| 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 @@ -269,7 +270,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 @@ -287,6 +288,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | 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 | 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 | --- @@ -417,18 +419,9 @@ Fields read from the embedded plugin manifest: --- -## 12. Pending Decisions - -| # | Topic | Options | Impact | -|---|-------|---------|--------| -| 1 | ZIP library | Resolved: libarchive | 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 f7555a7..0000000 --- a/pj_marketplace/documentation/SPRINT_PROPOSAL.md +++ /dev/null @@ -1,298 +0,0 @@ -# PlotJuggler Marketplace — Sprint Proposal - -> **Target:** Integrated prototype by end of March / early April 2026 -> **Owner:** Pablo (IBRobotics) -> **Status:** Historical planning document; see `ARCHITECTURE.md` and -> `REQUIREMENTS.md` for current implementation behavior. - ---- - -## 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 from embedded DSO manifest | -| 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 + libarchive extraction | 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 embedded plugin 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 from embedded DSO manifest | 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..838e35d --- /dev/null +++ b/pj_marketplace/documentation/TODO.md @@ -0,0 +1,16 @@ +# 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. +- Enable/disable without uninstalling. +- 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 4009734..080191f 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 @@ -89,7 +91,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 @@ -223,7 +225,8 @@ PJ_DATA_SOURCE_PLUGIN(MyPlugin, | "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 | ### 4.2 Log Locations @@ -268,7 +271,7 @@ rmdir /s %USERPROFILE%\.plotjuggler\.cache ├── extensions/ # Installed extensions │ └── my-extension/ │ └── libmy_plugin.so -├── .pending/ # Staged updates (Windows) +├── .extension_windows_staging/ # Staged updates (Windows) │ └── my-extension/.pj_pending_install ├── .backup/ # Non-Windows update backups; automatic rollback is deferred ├── .cache/ # Registry cache @@ -323,7 +326,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 diff --git a/pj_marketplace/documentation/diagrams/windows-staging.puml b/pj_marketplace/documentation/diagrams/windows-staging.puml index 12c3a87..f4db81b 100644 --- a/pj_marketplace/documentation/diagrams/windows-staging.puml +++ b/pj_marketplace/documentation/diagrams/windows-staging.puml @@ -5,7 +5,7 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .pending/{id}/; +:Extract to .extension_windows_staging/{id}/; :Load DSO manifest; :Validate registry id/version; :Write .pj_pending_install intent; @@ -17,7 +17,7 @@ start :Read .pj_pending_install intent; :Validate staged DSO manifest; if (Valid?) then (yes) -:Move .pending/{id}/ to extensions/{id}/; +:Move .extension_windows_staging/{id}/ to extensions/{id}/; :Plugin active; else (no) :Remove broken stage; 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 2fc70b7..a11a3b8 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) --- @@ -65,8 +64,8 @@ Development will begin with a standalone prototype to validate the concept, with | **Uninstallation** | Clean removal | Directory deletion + local state update | | | 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) | +| | 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 | @@ -452,6 +451,10 @@ package = "cmake --install build/release --prefix dist && cd dist && zip -r ../a 7. Plugin DSO is loaded and its embedded manifest is validated against the registry id/version 8. Installed cache is refreshed from discovery +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) @@ -603,7 +606,7 @@ 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/`) +2. New version downloads to a temporary folder (`.extension_windows_staging/`) 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" @@ -611,7 +614,7 @@ The flow is: - Detects pending updates - Reads `.pj_pending_install` - Revalidates the staged DSO against that registry intent - - Moves new version from `.pending/` to `extensions/` + - Moves new version from `.extension_windows_staging/` to `extensions/` 7. If validation fails, the broken stage is removed and the active install is left untouched ### 11.3 Directory Structure @@ -621,7 +624,7 @@ The flow is: ├── extensions/ ← Active plugins │ ├── ros2-streaming/ │ └── csv-loader/ -├── .pending/ ← Staging (Windows) +├── .extension_windows_staging/ ← Staging (Windows) │ └── plugin-id/.pj_pending_install ├── .backup/ ← Non-Windows update backups; automatic rollback deferred │ ├── ros2-streaming-1.2.2/ @@ -765,6 +768,7 @@ The detail panel includes: | Not installed | Install | | Installed, up-to-date | Disable, Uninstall | | Installed, update available | Update, Disable, Uninstall | +| Installed, local newer | Local newer, Uninstall | | Disabled | Enable, Uninstall | ### 12.5 Dialogs @@ -775,7 +779,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 | --- @@ -864,75 +868,13 @@ marketplace/ --- -## 16. Implementation Plan - -### Phase 1: Skeleton + Mock (Day 1-2) - -- CMake + Qt6 project setup -- Data structs (Extension, InstalledExtension) -- MarketplaceWindow with QSplitter -- MarketplaceWindow cards and ExtensionDetailDialog -- 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 | Resolved: libarchive | -| 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 7c933ad..3a6cff7 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -13,122 +15,131 @@ 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 plugin DSOs and reading their embedded manifest -// -// 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. explicit ExtensionManager( DownloadManager* downloader, const QString& extensions_dir = PlatformUtils::extensionsDir(), const QString& pending_dir = PlatformUtils::pendingDir(), 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); - // On Windows, downloads the replacement into .pending// and leaves the - // active DLL in place until applyPendingInstalls() runs on the next startup. - // On other platforms, moves the current version to - // ~/.plotjuggler/.backup/-/ and installs the registry version. + // 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 if the extension is present in the latest DSO discovery cache. + // Returns true when the latest disk scan found this extension id. bool isInstalled(const QString& id) const; - // Rebuilds the installed cache from plugin DSOs on disk. Cheap enough for - // marketplace dialog open/refresh and keeps UI state aligned with external - // filesystem changes. + // Rebuilds installed state by scanning extension directories for plugin DSOs. void refreshInstalledFromDisk(); - // Returns true if the extension is staged in the pending directory and will - // become active after the next restart (Windows update path). + // 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(); + #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(); + // 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(); + + // Writes the restart-cleanup marker into an installed extension directory. void 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); + + // 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_; @@ -143,6 +154,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 f33c84d..eb4c495 100644 --- a/pj_marketplace/include/pj_marketplace/installed_extension.hpp +++ b/pj_marketplace/include/pj_marketplace/installed_extension.hpp @@ -5,6 +5,7 @@ namespace PJ { +// Installed extension discovered from an embedded plugin manifest on disk. struct InstalledExtension { QString id; ///< Matches Extension::id from the registry QString version; diff --git a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp index 01b6e1d..9b37bc4 100644 --- a/pj_marketplace/include/pj_marketplace/marketplace_window.hpp +++ b/pj_marketplace/include/pj_marketplace/marketplace_window.hpp @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include "pj_marketplace/extension.hpp" +#include "pj_marketplace/installed_extension.hpp" namespace Ui { class MarketplaceWindow; @@ -15,45 +17,89 @@ 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. + // 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; + // 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 the installed DSO cache on every dialog open so external - // filesystem changes are reflected before cards are painted. + // 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; @@ -66,6 +112,8 @@ class MarketplaceWindow : public QDialog { QList filtered_; QList update_queue_; 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..0ab107f 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). + // ~/.plotjuggler/.extension_windows_staging/ - restart staging for Windows updates. static QString pendingDir(); // ~/.plotjuggler/.backup/ — pre-update backups (F-12, deferred to April+). diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 5f1eef2..80f8a9c 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ namespace { static constexpr const char* kPendingUninstallMarker = ".pj_pending_uninstall"; static constexpr const char* kPendingInstallIntent = ".pj_pending_install"; +static constexpr int kMaxDiagnostics = 50; QString extRoot(const QString& extensions_dir, const QString& id) { return QDir(extensions_dir).absoluteFilePath(id); @@ -43,6 +45,45 @@ 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())); @@ -182,32 +223,34 @@ void ExtensionManager::install(const Extension& ext) { } 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 (!allow_existing && isInstalled(ext.id)) { - emit installError(ext.id, QString("Extension \"%1\" is already installed").arg(ext.id)); - emit installFinished(ext.id, false); + emitInstallFailure(ext.id, QString("Extension \"%1\" is already installed").arg(ext.id)); return; } 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]; const QString dest_dir = staging ? pending_dir_ : extensions_dir_; - if (staging) { - QDir(pendingRoot(pending_dir_, ext.id)).removeRecursively(); - } + 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_ = @@ -230,42 +273,80 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ emit installProgress(pending_id_, percent); }); - 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; - const QString root = staging ? pendingRoot(pending_dir_, ext.id) : extRoot(extensions_dir_, ext.id); - const DirectoryDiscovery discovered = discoverExtensionDirectory(root); - const QString validation_error = validateRegistryIntent(discovered, ext.id, ext.version); - if (!validation_error.isEmpty()) { - QDir(root).removeRecursively(); - emit installError(finished_id, validation_error); - emit installFinished(finished_id, false); - return; - } + auto failAfterExtraction = [&](const QString& message) { + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + emitInstallFailure(finished_id, message); + }; - if (staging) { - QString intent_error; - if (!writePendingInstallIntent(root, ext, &intent_error)) { - QDir(root).removeRecursively(); - emit installError(finished_id, intent_error); - emit installFinished(finished_id, false); - return; - } - emit installPendingRestart(finished_id); - return; - } + if (const QString tx_error = validateTransactionContents(transaction_root, ext.id); !tx_error.isEmpty()) { + failAfterExtraction(tx_error); + return; + } - installed_[ext.id] = discovered.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; + } + + if (staging) { + QString intent_error; + if (!writePendingInstallIntent(root, ext, &intent_error)) { + failAfterExtraction(intent_error); + return; + } + + 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; + } + + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + pending_backup_path_.clear(); + emit installPendingRestart(finished_id); + return; + } + + 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; + } + + removeDirectoryIfSet(transaction_root); + pending_extract_dir_.clear(); + pending_backup_path_.clear(); + InstalledExtension record = discovered.record; + record.path = dst; + record.install_date = QFileInfo(dst).lastModified(); + installed_[ext.id] = record; + emit installFinished(finished_id, true); + }); dl_failed_conn_ = connect(downloader_, &DownloadManager::failed, this, [this](int id, const QString& error) { if (id != pending_op_id_) { @@ -278,8 +359,10 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ pending_id_.clear(); pending_op_id_ = -1; - emit installError(failed_id, error); - emit installFinished(failed_id, false); + const QString extract_dir = pending_extract_dir_; + pending_extract_dir_.clear(); + removeDirectoryIfSet(extract_dir); + emitInstallFailure(failed_id, error); }); dl_cancelled_conn_ = connect(downloader_, &DownloadManager::cancelled, this, [this](int id) { @@ -293,24 +376,23 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ pending_op_id_ = -1; disk_space_checked_ = false; - QDir(extRoot(extensions_dir_, cancelled_id)).removeRecursively(); - QDir(pendingRoot(pending_dir_, cancelled_id)).removeRecursively(); + const QString extract_dir = pending_extract_dir_; + pending_extract_dir_.clear(); + removeDirectoryIfSet(extract_dir); 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; } @@ -322,9 +404,8 @@ void ExtensionManager::uninstall(const QString& extension_id) { 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; } @@ -337,23 +418,43 @@ void ExtensionManager::update(const Extension& ext) { 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; + 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; + } + const QString candidate = PlatformUtils::backupDir() + "/" + ext.id + "-" + current_version; QDir().mkpath(PlatformUtils::backupDir()); if (!QDir().rename(current_path, candidate)) { - emit installError( + emitInstallFailure( ext.id, QString("Could not back up \"%1\" — update aborted to prevent data loss").arg(current_path)); - emit installFinished(ext.id, false); return; } + pending_backup_path_ = candidate; installed_.remove(ext.id); } @@ -366,16 +467,20 @@ void ExtensionManager::applyPendingInstalls() { return; } - for (const QFileInfo& entry : pending.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { + 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; + } auto failStagedInstall = [&](const QString& signal_id, const QString& message) { qWarning( "ExtensionManager: staged install '%s' failed validation: %s", qPrintable(staged_dir), qPrintable(message)); QDir(staged_dir).removeRecursively(); - emit installError(signal_id, message); - emit installFinished(signal_id, false); + emitInstallFailure(signal_id, message); }; if (staged_name.isEmpty()) { @@ -405,8 +510,7 @@ void ExtensionManager::applyPendingInstalls() { const QString dst = extRoot(extensions_dir_, intent.id); if (QDir(dst).exists() && !QDir(dst).removeRecursively()) { qWarning("ExtensionManager: failed to remove existing extension directory '%s'", qPrintable(dst)); - emit installError(intent.id, QString("Could not remove existing extension directory \"%1\"").arg(dst)); - emit installFinished(intent.id, false); + emitInstallFailure(intent.id, QString("Could not remove existing extension directory \"%1\"").arg(dst)); continue; } installed_.remove(intent.id); @@ -414,8 +518,7 @@ void ExtensionManager::applyPendingInstalls() { if (!QDir().rename(staged_dir, dst)) { qWarning( "ExtensionManager: failed to promote staged install '%s' to '%s'", qPrintable(staged_dir), qPrintable(dst)); - emit installError(intent.id, QString("Could not promote staged install to \"%1\"").arg(dst)); - emit installFinished(intent.id, false); + emitInstallFailure(intent.id, QString("Could not promote staged install to \"%1\"").arg(dst)); continue; } @@ -430,7 +533,7 @@ void ExtensionManager::applyPendingInstalls() { 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; } @@ -446,16 +549,32 @@ bool ExtensionManager::isInstalled(const QString& id) const { } bool ExtensionManager::hasPendingInstall(const QString& id) const { - // Marker existence is enough for UI predicates; applyPendingInstalls() does - // the full validation (intent contents, DSO presence, registry-vs-embedded - // match) and tears down anything broken on next startup. - return QFile::exists(pendingInstallIntentPath(pendingRoot(pending_dir_, id))); + if (!invalidExtensionIdReason(id).isEmpty()) { + return false; + } + const QString root = pendingRoot(pending_dir_, id); + const PendingInstallIntent intent = readPendingInstallIntent(root); + if (!intent.valid || intent.id != id) { + return false; + } + const DirectoryDiscovery discovered = discoverExtensionDirectory(root); + return validateRegistryIntent(discovered, intent.id, intent.version).isEmpty(); } bool ExtensionManager::hasPendingUninstall(const QString& id) const { 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 { if (!installed_.contains(ext.id)) { return false; @@ -470,6 +589,14 @@ QMap ExtensionManager::installedExtensions() const return installed_; } +QList ExtensionManager::diagnostics() const { + return diagnostics_; +} + +void ExtensionManager::clearDiagnostics() { + diagnostics_.clear(); +} + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -486,11 +613,40 @@ void ExtensionManager::schedulePendingUninstall(const QString& path) { marker.open(QIODevice::WriteOnly); // content irrelevant; existence is the signal } +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); +} + +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); +} + +void ExtensionManager::emitUninstallFailure(const QString& id, const QString& message) { + reportDiagnostic(id, message, true); + emit uninstallError(id, message); + emit uninstallFinished(id, false); +} + void ExtensionManager::refreshInstalledFromDisk() { QMap discovered; 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)) { const QString root = entry.absoluteFilePath(); + if (isTransactionDirectoryName(entry.fileName())) { + removeDirectoryIfSet(root); + continue; + } if (QFile::exists(root + "/" + kPendingUninstallMarker)) { continue; } @@ -511,4 +667,8 @@ void ExtensionManager::refreshInstalledFromDisk() { installed_ = std::move(discovered); } +void ExtensionManager::setInstalledExtensions(QMap installed) { + installed_ = std::move(installed); +} + } // 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 f43950c..cd08747 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,28 @@ 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); @@ -40,6 +63,8 @@ MarketplaceWindow::MarketplaceWindow(const QUrl& registry_url, QWidget* parent) ui_->setupUi(this); setupUi(); setupSignals(); + updateDiagnosticsButton(); + showLatestDiagnostic(); ext_mgr_->applyPendingUninstalls(); ext_mgr_->applyPendingInstalls(); registry_mgr_->fetchRegistry(registry_url_); @@ -56,6 +81,28 @@ MarketplaceWindow::MarketplaceWindow(ExtensionManager* ext_mgr, const QUrl& regi ui_->setupUi(this); setupUi(); setupSignals(); + updateDiagnosticsButton(); + showLatestDiagnostic(); + registry_mgr_->fetchRegistry(registry_url_); +} + +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); + + ui_->setupUi(this); + setupUi(); + setupSignals(); + ext_mgr_->setInstalledExtensions(installed); + updateDiagnosticsButton(); + showLatestDiagnostic(); registry_mgr_->fetchRegistry(registry_url_); } @@ -83,6 +130,7 @@ void MarketplaceWindow::setupUi() { 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 ─────────────────────────────────────────────────────────── @@ -103,6 +151,7 @@ void MarketplaceWindow::setupSignals() { 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(); @@ -110,6 +159,7 @@ void MarketplaceWindow::setupSignals() { 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)); }); @@ -142,6 +192,7 @@ void MarketplaceWindow::setupSignals() { } populateCards(); if (success) { + status_error_sticky_ = false; for (const auto& ext : extensions_) { if (ext.id == id) { setStatus("Installed " + ext.name + " v" + ext.version); @@ -161,6 +212,7 @@ void MarketplaceWindow::setupSignals() { 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_) { @@ -176,6 +228,15 @@ void MarketplaceWindow::setupSignals() { 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 ───────────────────────────────────────────────────────── @@ -216,13 +277,19 @@ void MarketplaceWindow::populateCards() { 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 (has_update && 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);"); @@ -247,6 +314,14 @@ void MarketplaceWindow::populateCards() { "QPushButton:hover { background:#f0b820; }"); connect(btn, &QPushButton::clicked, this, [this, ext_id]() { onActionButtonClicked(ext_id); }); btn_box->addWidget(btn); + } 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); @@ -349,10 +424,33 @@ void MarketplaceWindow::applyFilters() { } 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*/) { @@ -363,10 +461,11 @@ void MarketplaceWindow::onCategoryChanged(int /*index*/) { } void MarketplaceWindow::onRefreshClicked() { + clearStickyStatus(); setStatus("Refreshing..."); - const int before = ext_mgr_->installedExtensions().size(); + const auto before = ext_mgr_->installedExtensions(); ext_mgr_->refreshInstalledFromDisk(); - if (ext_mgr_->installedExtensions().size() != before) { + if (!installedStatesEqual(ext_mgr_->installedExtensions(), before)) { installations_changed_ = true; } populateCards(); @@ -375,12 +474,19 @@ void MarketplaceWindow::onRefreshClicked() { void MarketplaceWindow::showEvent(QShowEvent* event) { if (ext_mgr_ != nullptr) { - const int before = ext_mgr_->installedExtensions().size(); - ext_mgr_->refreshInstalledFromDisk(); - if (ext_mgr_->installedExtensions().size() != before) { - installations_changed_ = true; + 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); } @@ -415,6 +521,7 @@ void MarketplaceWindow::onSettingsClicked() { return; } + clearStickyStatus(); registry_url_ = new_url; QSettings("PlotJuggler", "Marketplace").setValue("registry_url", registry_url_.toString()); @@ -427,8 +534,11 @@ void MarketplaceWindow::onActionButtonClicked(const QString& ext_id) { if (ext.id != ext_id) { continue; } + clearStickyStatus(); if (ext_mgr_->hasUpdate(ext)) { ext_mgr_->update(ext); + } 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); } @@ -437,10 +547,12 @@ void MarketplaceWindow::onActionButtonClicked(const QString& ext_id) { } 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)) { @@ -455,6 +567,31 @@ void MarketplaceWindow::onUpdateAllClicked() { 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; 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..d0dbf82 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_windows_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 1006879..d715d60 100644 --- a/pj_marketplace/tests/extension_manager_test.cpp +++ b/pj_marketplace/tests/extension_manager_test.cpp @@ -167,6 +167,12 @@ bool copyFixturePlugin(const QString& dst_dir, const QString& fixture_id, const 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()) { @@ -431,6 +437,48 @@ TEST_F(ExtensionManagerTest, InstallRejectsExtensionDirectoryWithConflictingEmbe 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 // --------------------------------------------------------------------------- @@ -592,6 +640,9 @@ TEST_F(ExtensionManagerTest, UpdateKeepsBackupWhenInstallFails) { 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_dir).removeRecursively(); } @@ -654,6 +705,20 @@ TEST_F(ExtensionManagerTest, HasUpdateHandlesMultiSegmentVersionsCorrectly) { 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("mock-data-source", "2.0.0")); @@ -668,11 +733,34 @@ 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 +// 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 @@ -772,7 +860,7 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsStagedVersionMismatchAga EXPECT_FALSE(mgr_->isInstalled("mock-data-source")); } -// An entry in .pending/ that lacks the registry-intent marker is rejected and removed; +// 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"; @@ -789,6 +877,8 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsEmptyStagingDirectory) { 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")); } // applyPendingInstalls() is a no-op when the pending directory contains no sub-directories. @@ -798,7 +888,7 @@ 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{"mock-data-source", "mock-file-source"}) { const QString staged = pending_dir_.path() + "/" + id; @@ -887,6 +977,10 @@ TEST_F(ExtensionManagerTest, HasPendingInstallRequiresDsoAndRegistryIntent) { 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")); diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index bd849f2..1203be7 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -79,6 +79,11 @@ 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 # --------------------------------------------------------------------------- @@ -236,13 +241,15 @@ target_compile_definitions(plugin_catalog_test PRIVATE 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_plugin_catalog GTest::gtest_main ) add_dependencies(plugin_catalog_test mock_data_source_plugin mock_json_parser_plugin - mock_toolbox_plugin mock_dialog_plugin missing_id_data_source_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/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 58e595b..d13f35c 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -47,11 +47,13 @@ struct PluginDescriptor { std::vector capabilities; ///< optional capability tags }; +/// 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; diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index ecb2ad9..b150dc2 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -149,16 +149,20 @@ Expected findEmbeddedManifest(void* handle) { return unexpected(out.str()); } -std::vector readStringArray(const nlohmann::json& j, std::string_view key) { +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() || !it->is_array()) { + if (it == j.end()) { return values; } + if (!it->is_array()) { + return unexpected(std::string("plugin embedded manifest key must be an array of strings: ") + std::string(key)); + } for (const auto& value : *it) { - if (value.is_string()) { - values.push_back(value.get()); + 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()); } return values; } @@ -172,7 +176,7 @@ Expected decodeManifest( nlohmann::json j; try { j = nlohmann::json::parse(manifest_json); - } catch (const nlohmann::json::parse_error& e) { + } catch (const nlohmann::json::exception& e) { return unexpected(std::string("plugin embedded manifest is invalid JSON: ") + e.what()); } @@ -187,6 +191,16 @@ Expected decodeManifest( } 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; @@ -209,10 +223,28 @@ Expected decodeManifest( d.id = *id; d.name = *name; d.version = *version; - d.description = j.value("description", ""); - d.category = j.value("category", ""); - d.file_extensions = readStringArray(j, "file_extensions"); - d.capabilities = readStringArray(j, "capabilities"); + + 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()); + } + + d.description = *description; + d.category = *category; + d.file_extensions = *file_extensions; + d.capabilities = *capabilities; if (family == PluginFamily::kMessageParser) { auto encoding = requiredString("encoding"); @@ -220,8 +252,12 @@ Expected decodeManifest( return unexpected(encoding.error()); } d.encoding = *encoding; - } else if (j.contains("encoding") && j["encoding"].is_string()) { - d.encoding = j["encoding"].get(); + } else { + auto encoding = optionalString("encoding"); + if (!encoding) { + return unexpected(encoding.error()); + } + d.encoding = *encoding; } return d; 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/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index 63cca9b..4b47858 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -10,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 { @@ -82,9 +93,20 @@ TEST_F(PluginCatalogTest, MissingIdManifestIsRejected) { EXPECT_NE(descriptor.error().find("id"), std::string::npos); } +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, ScanContinuesAfterBrokenDso) { - copyPlugin(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, "libvalid.so"); - std::ofstream(dir_ / "libbroken.so") << "not a shared library"; + 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 = scanPluginDsos(dir_); @@ -92,18 +114,18 @@ TEST_F(PluginCatalogTest, ScanContinuesAfterBrokenDso) { 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(), "libbroken.so"); + EXPECT_EQ(result->diagnostics[0].path.filename(), pluginFileName("broken")); } TEST_F(PluginCatalogTest, ResultIsSortedByPath) { - copyPlugin(PJ_MOCK_TOOLBOX_PLUGIN_PATH, "zz_plugin.so"); - copyPlugin(PJ_MOCK_DATA_SOURCE_PLUGIN_PATH, "aa_plugin.so"); + 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(), "aa_plugin.so"); - EXPECT_EQ(result->plugins[1].dso_path.filename(), "zz_plugin.so"); + 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) { diff --git a/pj_proto_app/CMakeLists.txt b/pj_proto_app/CMakeLists.txt index fc64a1b..202f9d8 100644 --- a/pj_proto_app/CMakeLists.txt +++ b/pj_proto_app/CMakeLists.txt @@ -29,3 +29,31 @@ 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="$" + ) + target_link_libraries(proto_plugin_registry_test PRIVATE + pj_datastore + pj_data_source_host + pj_message_parser_host + pj_toolbox_host + pj_marketplace + GTest::gtest_main + nlohmann_json::nlohmann_json + Qt6::Core + ) + add_dependencies(proto_plugin_registry_test mock_data_source_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..7e7231b 100644 --- a/pj_proto_app/src/main_window.cpp +++ b/pj_proto_app/src/main_window.cpp @@ -715,7 +715,8 @@ 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()) { diff --git a/pj_proto_app/src/plugin_registry.cpp b/pj_proto_app/src/plugin_registry.cpp index 9305b8b..503bdaf 100644 --- a/pj_proto_app/src/plugin_registry.cpp +++ b/pj_proto_app/src/plugin_registry.cpp @@ -3,6 +3,7 @@ #include #include "pj_marketplace/platform_utils.hpp" +#include #include #include #include @@ -27,7 +28,9 @@ bool PluginRegistry::loadAndRegisterDataSource(const std::filesystem::path& so_p loaded.capabilities = handle.capabilities(); try { auto manifest = nlohmann::json::parse(handle.manifest()); + loaded.id = manifest.value("id", so_path.stem().string()); loaded.name = manifest.value("name", so_path.stem().string()); + loaded.version = manifest.value("version", so_path.stem().string()); if (manifest.contains("file_extensions")) { for (const auto& ext : manifest["file_extensions"]) { loaded.file_extensions.push_back(ext.get()); @@ -56,7 +59,9 @@ bool PluginRegistry::loadAndRegisterMessageParser(const std::filesystem::path& s auto handle = loaded.library.createHandle(); try { auto manifest = nlohmann::json::parse(handle.manifest()); + loaded.id = manifest.value("id", so_path.stem().string()); loaded.name = manifest.value("name", so_path.stem().string()); + loaded.version = manifest.value("version", 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()) { @@ -98,7 +103,9 @@ bool PluginRegistry::loadAndRegisterToolbox(const std::filesystem::path& so_path loaded.capabilities = handle.capabilities(); try { auto manifest = nlohmann::json::parse(handle.manifest()); + loaded.id = manifest.value("id", so_path.stem().string()); loaded.name = manifest.value("name", so_path.stem().string()); + loaded.version = manifest.value("version", so_path.stem().string()); } catch (...) { loaded.name = so_path.stem().string(); } @@ -350,4 +357,38 @@ std::string PluginRegistry::listAvailableEncodings() const { return json; } +QMap PluginRegistry::loadedExtensionsSnapshot() const { + QMap snapshot; + + 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; + } + + 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& ds : data_sources_) { + add_loaded(ds.id, ds.version, ds.path); + } + for (const auto& parser : message_parsers_) { + add_loaded(parser.id, parser.version, parser.path); + } + for (const auto& toolbox : toolbox_plugins_) { + 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..b484c20 100644 --- a/pj_proto_app/src/plugin_registry.hpp +++ b/pj_proto_app/src/plugin_registry.hpp @@ -6,6 +6,10 @@ #include #include +#include +#include + +#include "pj_marketplace/installed_extension.hpp" #include "pj_plugins/host/data_source_library.hpp" #include "pj_plugins/host/message_parser_library.hpp" #include "pj_plugins/host/toolbox_library.hpp" @@ -16,6 +20,8 @@ struct LoadedDataSource { PJ::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; @@ -25,6 +31,8 @@ struct LoadedMessageParser { PJ::MessageParserLibrary library; std::string path; std::string name; + std::string id; + std::string version; std::vector encodings; std::filesystem::file_time_type loaded_mtime; }; @@ -33,6 +41,8 @@ struct LoadedToolbox { PJ::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; }; @@ -62,6 +72,9 @@ class PluginRegistry { /// Get all loaded toolbox plugins. [[nodiscard]] const std::vector& allToolboxes() const { return toolbox_plugins_; } + /// Build a marketplace-style installed snapshot from loaded plugin manifests. + [[nodiscard]] QMap loadedExtensionsSnapshot() const; + private: /// Try to load a DataSource plugin and register it. Returns true on success. bool loadAndRegisterDataSource(const std::filesystem::path& so_path); 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..beabfa6 --- /dev/null +++ b/pj_proto_app/tests/plugin_registry_test.cpp @@ -0,0 +1,30 @@ +#include "plugin_registry.hpp" + +#include + +#include +#include +#include +#include + +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"); +} + +} // namespace proto From 063a6253d7ebd74192ea4d6763f5eb9034b637d2 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 08:37:41 +0200 Subject: [PATCH 07/12] fix(marketplace,proto_app): harden install lifecycle, add unified diagnostic sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-review follow-up. The bulk of the change is in three layers: Marketplace install/uninstall hardening (pj_marketplace): - Update-All queue advanced twice on every failure because emitInstallFailure fires both installError and installFinished and both UI handlers called processInstallQueue; the queue now advances exactly once per outcome. - Category combo data values now match the registry schema (data_streamer / parser); previously "data_stream" / "message_parser" silently filtered out every matching extension. - ExtensionManager::initComponents drains pending install/uninstall queues before computing the installed snapshot, so all three MarketplaceWindow ctors (and any host-managed mgr) get the same restart-recovery semantics. - schedulePendingUninstall now returns bool; uninstall() no longer mutates installed_ or emits uninstallPendingRestart if the marker write fails. - Failed staged installs that cannot be removed are renamed to .pj_quarantine__/ so the next start does not re-validate the same payload forever; quarantine entries are skipped by applyPendingInstalls. - applyPendingUninstalls reports a diagnostic when removeRecursively fails (was silently dropped, leading to invisible uninstall stalls). - Settings dialog rejects invalid registry URLs (non-http/https/file scheme) before persisting them to QSettings. - Pending-install intent file id/version are validated against safe-path / semver-regex rules to defend against tampered intent files. - Lambda-capture asymmetry fixed: failed/cancelled handlers now capture transaction_root by value like the finished handler. - Sticky startup error is cleared on successful registry fetch so progress messages aren't suppressed. - mkpath(extensions_dir_) failure is surfaced as a startup diagnostic. - doInstall now re-validates the DSO from its FINAL location after the rename, catching rpath/dep issues that hold in the staging area but break in extensions/. - Standalone CMake build synthesizes a pj_plugin_catalog target instead of inlining sources, eliminating a duplicate-symbol risk for downstream consumers that link both the standalone marketplace and pj_plugin_catalog. - Renamed .extension_windows_staging -> .extension_staging across code, tests, and all docs; the path is no longer Windows-specific now that the staging area is the validation gate on every OS. Plugin catalog (pj_plugins): - scanPluginDsos no longer terminates the whole scan on the first per-entry filesystem error; each unreadable subtree is reported as a diagnostic and iteration continues. pj_proto_app plugin loader: - loadAndRegister{DataSource,MessageParser,Toolbox} now narrow catch(...) to nlohmann::json::exception, log the actual error, and refuse to register plugins whose embedded manifest is missing required string fields. The previous "fabricate id from filename" fallback produced ids that could never match a registry entry, leaving plugins effectively undeletable from the marketplace UI. Unified diagnostic-propagation API (new): - pj_base/include/pj_base/diagnostic_sink.hpp — header-only PJ::Diagnostic / PJ::DiagnosticLevel / PJ::DiagnosticSink (std::function), zero-Qt, with a teeSink composition helper. - ExtensionManager forwards through an optional sink in addition to its existing diagnostics() ring buffer and diagnosticReported Qt signal — hosts can subscribe to one chronological stream covering both marketplace and non-marketplace events. - PluginRegistry takes an optional sink and routes 27+ std::cerr lines through it with appropriate levels; success messages are info-level and stay in the diagnostics dialog only, errors flash in the status bar. - pj_proto_app::QtDiagnosticBridge marshals every sink event onto the Qt thread via QueuedConnection (QPointer-guarded for outlive safety) and re-emits as a Qt signal. - MainWindow now has a QStatusBar plus a Diagnostics dialog (200-entry cap), wired to the bridge; MarketplaceWindow continues to use the marketplace-internal signal as before. Tests: 51/52 pass (1 pre-existing GTK leak in dialog_engine_test, unrelated). Two new capturing-sink assertions in proto_plugin_registry_test pin the GUI propagation contract. Docs updated: marketplace ARCHITECTURE / USER_MANUAL / spec / REQUIREMENTS, pj_plugins ARCHITECTURE.md (new "Host-side diagnostic propagation" section). Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/include/pj_base/diagnostic_sink.hpp | 48 ++++++ pj_marketplace/CMakeLists.txt | 32 ++-- pj_marketplace/documentation/ARCHITECTURE.md | 132 +++++++-------- pj_marketplace/documentation/REQUIREMENTS.md | 5 +- pj_marketplace/documentation/USER_MANUAL.md | 65 ++++---- .../diagrams/windows-staging.puml | 4 +- .../plotjuggler-marketplace-spec-v1.0.0-en.md | 30 ++-- .../pj_marketplace/extension_manager.hpp | 11 +- .../include/pj_marketplace/platform_utils.hpp | 2 +- pj_marketplace/src/core/ExtensionManager.cpp | 154 ++++++++++++++---- pj_marketplace/src/core/PlatformUtils.cpp | 2 +- pj_marketplace/src/ui/marketplace_window.cpp | 27 ++- ...ension_manager_check_plugin_management.cpp | 2 +- pj_plugins/docs/ARCHITECTURE.md | 22 +++ pj_plugins/src/plugin_catalog.cpp | 45 +++-- pj_proto_app/CMakeLists.txt | 1 + pj_proto_app/src/main_window.cpp | 118 +++++++++++--- pj_proto_app/src/main_window.hpp | 20 ++- pj_proto_app/src/plugin_registry.cpp | 150 ++++++++++------- pj_proto_app/src/plugin_registry.hpp | 10 +- pj_proto_app/src/qt_diagnostic_bridge.cpp | 33 ++++ pj_proto_app/src/qt_diagnostic_bridge.hpp | 29 ++++ pj_proto_app/tests/plugin_registry_test.cpp | 47 ++++++ 23 files changed, 719 insertions(+), 270 deletions(-) create mode 100644 pj_base/include/pj_base/diagnostic_sink.hpp create mode 100644 pj_proto_app/src/qt_diagnostic_bridge.cpp create mode 100644 pj_proto_app/src/qt_diagnostic_bridge.hpp 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_marketplace/CMakeLists.txt b/pj_marketplace/CMakeLists.txt index 27f8385..078c54a 100644 --- a/pj_marketplace/CMakeLists.txt +++ b/pj_marketplace/CMakeLists.txt @@ -80,22 +80,26 @@ 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) find_package(nlohmann_json REQUIRED) - target_sources(pj_marketplace PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/../pj_plugins/src/plugin_catalog.cpp - ) - target_include_directories(pj_marketplace 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_marketplace PUBLIC - nlohmann_json::nlohmann_json - ${CMAKE_DL_LIBS} - ) + 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 pj_plugin_catalog) endif() diff --git a/pj_marketplace/documentation/ARCHITECTURE.md b/pj_marketplace/documentation/ARCHITECTURE.md index 0ab07d1..0bff5c9 100644 --- a/pj_marketplace/documentation/ARCHITECTURE.md +++ b/pj_marketplace/documentation/ARCHITECTURE.md @@ -114,26 +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, staged promotion -│ │ ├── DownloadManager.h/cpp # HTTP download, checksum, libarchive extraction -│ │ └── PlatformUtils.h/cpp # OS detection, paths -│ ├── ui/ -│ │ ├── MarketplaceWindow.h/cpp # Main window/dialog -│ │ └── ExtensionDetailDialog.h/cpp # Detail dialog -└── 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 @@ -200,8 +205,23 @@ ExtensionManager(DownloadManager* downloader, - No `detectPlatform()` private method — delegated to `PlatformUtils::currentPlatform()` - 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. +- 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. --- @@ -209,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)
@@ -225,12 +254,12 @@ start :Download ZIP; :Verify SHA256; if (Checksum OK?) then (yes) - :Extract to extensions/; - :Load DSO manifest; - :Validate registry id/version; if (Is update?) then (yes) :Backup current; endif + :Extract to extensions/; + :Load DSO manifest; + :Validate registry id/version; :Register discovery cache; else (no) :Error: invalid checksum; @@ -255,7 +284,7 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .extension_windows_staging/{id}/; +:Extract to .extension_staging/{id}/; :Load DSO manifest; :Validate registry id/version; :Write .pj_pending_install intent; @@ -267,7 +296,7 @@ start :Read .pj_pending_install intent; :Validate staged DSO manifest; if (Valid?) then (yes) -:Move .extension_windows_staging/{id}/ to extensions/{id}/; +:Move .extension_staging/{id}/ to extensions/{id}/; :Plugin active; else (no) :Remove broken stage; @@ -321,21 +350,21 @@ 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/ │ │ ├── libros2_streaming.so │ │ └── ros2_streaming.ui │ └── csv-loader/ │ └── libcsv_loader.so -├── .extension_windows_staging/ # Staging area (Windows) -│ └── ros2-streaming/ # Ready to install on restart -├── .backup/ # Non-Windows update backups; automatic rollback deferred -│ ├── ros2-streaming-1.2.2/ -│ └── csv-loader-0.9.0/ -└── .cache/ # Registry cache - └── registry.json +├── .extension_staging/ # Staging area (Windows) +│ └── ros2-streaming/ # Ready to install on restart +└── .backup/ # Non-Windows update backups; automatic rollback deferred + ├── ros2-streaming-1.2.2/ + └── csv-loader-0.9.0/ ``` ### 5.2 Extension ZIP Structure @@ -404,41 +433,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) - -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(LibArchive REQUIRED) - -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/marketplace_window.cpp - src/ui/extension_detail_dialog.cpp - resources/marketplace.qrc -) - -target_link_libraries(pj_marketplace PRIVATE - Qt6::Widgets - Qt6::Network - LibArchive::LibArchive -) +The actual CMakeLists.txt is the source of truth — see `pj_marketplace/CMakeLists.txt`. Notable points: -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) diff --git a/pj_marketplace/documentation/REQUIREMENTS.md b/pj_marketplace/documentation/REQUIREMENTS.md index f2dbea3..24824b2 100644 --- a/pj_marketplace/documentation/REQUIREMENTS.md +++ b/pj_marketplace/documentation/REQUIREMENTS.md @@ -40,11 +40,12 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | | Automatic backup | Backup of previous version before updating | | **Uninstallation** | Clean removal | Directory deletion + local state update | | | Confirmation | Confirmation dialog before uninstalling | -| **Management** | Enable/Disable | Activate/deactivate extensions without uninstalling | +| **Management** | Enable/Disable | Activate/deactivate extensions without uninstalling (planned — see TODO.md) | | | 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; change triggers immediate refresh | +| | 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 | diff --git a/pj_marketplace/documentation/USER_MANUAL.md b/pj_marketplace/documentation/USER_MANUAL.md index 080191f..791e046 100644 --- a/pj_marketplace/documentation/USER_MANUAL.md +++ b/pj_marketplace/documentation/USER_MANUAL.md @@ -70,9 +70,7 @@ If PlotJuggler already has the plugin loaded at startup, the marketplace is seed - 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 @@ -104,14 +102,7 @@ If PlotJuggler already has the plugin loaded at startup, the marketplace is seed ### 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.* --- @@ -157,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:** @@ -217,6 +208,12 @@ PJ_DATA_SOURCE_PLUGIN(MyPlugin, ## 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 | @@ -227,6 +224,10 @@ PJ_DATA_SOURCE_PLUGIN(MyPlugin, | "Cannot update (Windows)" | DLL in use | Restart PlotJuggler | | "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 @@ -238,16 +239,19 @@ PJ_DATA_SOURCE_PLUGIN(MyPlugin, ### 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 -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 -rmdir /s %USERPROFILE%\.plotjuggler\.cache +rmdir /s %LOCALAPPDATA%\plotjuggler\extensions +rmdir /s %LOCALAPPDATA%\plotjuggler\.extension_staging ``` ### 4.4 Reporting Bugs @@ -266,26 +270,31 @@ 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/ │ └── libmy_plugin.so -├── .extension_windows_staging/ # Staged updates (Windows) +├── .extension_staging/ # Staged updates (Windows only) │ └── my-extension/.pj_pending_install -├── .backup/ # Non-Windows update backups; automatic rollback is deferred -├── .cache/ # Registry cache -│ └── registry.json +└── .backup/ # Non-Windows update backups; automatic rollback deferred ``` ### 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 diff --git a/pj_marketplace/documentation/diagrams/windows-staging.puml b/pj_marketplace/documentation/diagrams/windows-staging.puml index f4db81b..f445af8 100644 --- a/pj_marketplace/documentation/diagrams/windows-staging.puml +++ b/pj_marketplace/documentation/diagrams/windows-staging.puml @@ -5,7 +5,7 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .extension_windows_staging/{id}/; +:Extract to .extension_staging/{id}/; :Load DSO manifest; :Validate registry id/version; :Write .pj_pending_install intent; @@ -17,7 +17,7 @@ start :Read .pj_pending_install intent; :Validate staged DSO manifest; if (Valid?) then (yes) -:Move .extension_windows_staging/{id}/ to extensions/{id}/; +:Move .extension_staging/{id}/ to extensions/{id}/; :Plugin active; else (no) :Remove broken stage; 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 a11a3b8..21abb7c 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 @@ -131,7 +131,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. a plugin loader in the embedding app) see one unified, ordered diagnostic stream they can render in a status bar, dialog, or log file. Modules in pure C++ remain Qt-free; a Qt-aware bridge in the embedding app 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: @@ -606,30 +610,32 @@ 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 (`.extension_windows_staging/`) +2. New version downloads to a transaction folder under `.extension_staging/` 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: - - Detects pending updates - Reads `.pj_pending_install` - - Revalidates the staged DSO against that registry intent - - Moves new version from `.extension_windows_staging/` to `extensions/` -7. If validation fails, the broken stage is removed and the active install is left untouched + - 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/ -├── .extension_windows_staging/ ← Staging (Windows) +├── .extension_staging/ ← Staging (Windows) │ └── plugin-id/.pj_pending_install -├── .backup/ ← Non-Windows update backups; automatic rollback deferred -│ ├── ros2-streaming-1.2.2/ -│ └── csv-loader-0.9.0/ -└── .cache/ ← Registry cache +└── .backup/ ← Non-Windows update backups; automatic rollback deferred + ├── ros2-streaming-1.2.2/ + └── csv-loader-0.9.0/ ``` --- diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index 3a6cff7..acde973 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -7,6 +7,7 @@ #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" @@ -33,9 +34,12 @@ class ExtensionManager : public QObject { ExtensionManager(); // 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(), QObject* parent = nullptr); + const QString& pending_dir = PlatformUtils::pendingDir(), DiagnosticSink sink = {}, + QObject* parent = nullptr); // Starts an async install for the current platform. void install(const Extension& ext); @@ -129,7 +133,9 @@ class ExtensionManager : public QObject { void disconnectDlConns(); // Writes the restart-cleanup marker into an installed extension directory. - void schedulePendingUninstall(const QString& path); + // 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); @@ -143,6 +149,7 @@ class ExtensionManager : public QObject { DownloadManager* downloader_ = nullptr; QString extensions_dir_; QString pending_dir_; + DiagnosticSink sink_; QMap installed_; diff --git a/pj_marketplace/include/pj_marketplace/platform_utils.hpp b/pj_marketplace/include/pj_marketplace/platform_utils.hpp index 0ab107f..ecaa8ed 100644 --- a/pj_marketplace/include/pj_marketplace/platform_utils.hpp +++ b/pj_marketplace/include/pj_marketplace/platform_utils.hpp @@ -33,7 +33,7 @@ class PlatformUtils { // ~/.plotjuggler/extensions/ — active, loaded extensions. static QString extensionsDir(); - // ~/.plotjuggler/.extension_windows_staging/ - restart staging for Windows updates. + // /.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/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 80f8a9c..3e3bf6f 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ 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) { @@ -182,9 +184,24 @@ PendingInstallIntent readPendingInstallIntent(const QString& root) { 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 = QString::fromUtf8(lines[0].trimmed()); - intent.version = QString::fromUtf8(lines[1].trimmed()); + intent.id = id; + intent.version = version; return intent; } @@ -200,8 +217,13 @@ ExtensionManager::ExtensionManager() } 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) { + 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(); } @@ -209,7 +231,14 @@ void ExtensionManager::initComponents() { if (!downloader_) { downloader_ = new DownloadManager(this); } - QDir().mkpath(extensions_dir_); + 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(); } @@ -245,6 +274,13 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ } const Platform& artifact = ext.platforms[platform]; + // 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); @@ -338,34 +374,45 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ 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(); - InstalledExtension record = discovered.record; + InstalledExtension record = final_check.record; record.path = dst; record.install_date = QFileInfo(dst).lastModified(); installed_[ext.id] = record; emit installFinished(finished_id, true); }); - 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; + 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; + const QString failed_id = pending_id_; + pending_id_.clear(); + pending_op_id_ = -1; + pending_extract_dir_.clear(); - const QString extract_dir = pending_extract_dir_; - pending_extract_dir_.clear(); - removeDirectoryIfSet(extract_dir); - emitInstallFailure(failed_id, error); - }); + removeDirectoryIfSet(transaction_root); + emitInstallFailure(failed_id, error); + }); - dl_cancelled_conn_ = connect(downloader_, &DownloadManager::cancelled, this, [this](int id) { + dl_cancelled_conn_ = connect(downloader_, &DownloadManager::cancelled, this, [this, transaction_root](int id) { if (id != pending_op_id_) { return; } @@ -375,10 +422,9 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ pending_id_.clear(); pending_op_id_ = -1; disk_space_checked_ = false; - - const QString extract_dir = pending_extract_dir_; pending_extract_dir_.clear(); - removeDirectoryIfSet(extract_dir); + + removeDirectoryIfSet(transaction_root); const QString reason = cancel_reason_.isEmpty() ? "Installation was cancelled" : cancel_reason_; cancel_reason_.clear(); @@ -400,7 +446,12 @@ void ExtensionManager::uninstall(const QString& extension_id) { if (!QDir(dir_path).removeRecursively()) { if (PlatformUtils::isWindows()) { - 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 { @@ -458,7 +509,8 @@ void ExtensionManager::update(const Extension& ext) { installed_.remove(ext.id); } - doInstall(ext, PlatformUtils::isWindows()); + // The Windows branch returned earlier; here we always promote immediately. + doInstall(ext, /*staging=*/false, /*allow_existing=*/true); } void ExtensionManager::applyPendingInstalls() { @@ -475,12 +527,28 @@ void ExtensionManager::applyPendingInstalls() { removeDirectoryIfSet(staged_dir); continue; } + 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)); - QDir(staged_dir).removeRecursively(); - emitInstallFailure(signal_id, 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); }; if (staged_name.isEmpty()) { @@ -538,8 +606,18 @@ void ExtensionManager::applyPendingUninstalls() { continue; } const QString id = entry.fileName(); - if (QDir(entry.absoluteFilePath()).removeRecursively() && !id.isEmpty()) { - installed_.remove(id); + 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); } } } @@ -608,9 +686,12 @@ 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::reportDiagnostic(const QString& id, const QString& message, bool is_error) { @@ -619,6 +700,15 @@ void ExtensionManager::reportDiagnostic(const QString& id, const QString& messag 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(), + }); + } } void ExtensionManager::emitInstallFailure(const QString& id, const QString& message) { 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/ui/marketplace_window.cpp b/pj_marketplace/src/ui/marketplace_window.cpp index cd08747..6526416 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -55,7 +56,8 @@ 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); @@ -65,8 +67,7 @@ MarketplaceWindow::MarketplaceWindow(const QUrl& registry_url, QWidget* parent) setupSignals(); updateDiagnosticsButton(); showLatestDiagnostic(); - ext_mgr_->applyPendingUninstalls(); - ext_mgr_->applyPendingInstalls(); + // applyPendingUninstalls/applyPendingInstalls already ran in ExtensionManager::initComponents(). registry_mgr_->fetchRegistry(registry_url_); } @@ -119,8 +120,8 @@ void MarketplaceWindow::setupUi() { 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("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); @@ -144,6 +145,9 @@ void MarketplaceWindow::setupSignals() { 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"); @@ -207,7 +211,8 @@ void MarketplaceWindow::setupSignals() { connect(ext_mgr_, &ExtensionManager::installError, this, [this](const QString& /*id*/, const QString& error) { ui_->progress_bar_->setVisible(false); setStatus("Installation failed: " + error, true); - processInstallQueue(); + // 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) { @@ -516,7 +521,15 @@ 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; } diff --git a/pj_marketplace/tests/extension_manager_check_plugin_management.cpp b/pj_marketplace/tests/extension_manager_check_plugin_management.cpp index d0dbf82..6dd2efe 100644 --- a/pj_marketplace/tests/extension_manager_check_plugin_management.cpp +++ b/pj_marketplace/tests/extension_manager_check_plugin_management.cpp @@ -61,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) + "/.extension_windows_staging"; + 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(); diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 56b7d72..5732872 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -303,6 +303,28 @@ 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; the embedding app provides a thin Qt adapter (e.g. +`pj_proto_app::QtDiagnosticBridge`) 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/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index b150dc2..87a53aa 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -319,23 +319,36 @@ Expected scanPluginDsos(const std::filesystem::path& directory } PluginScanResult result; - for (const auto& entry : std::filesystem::recursive_directory_iterator(directory, ec)) { - if (ec) { - result.diagnostics.push_back({directory, "directory iteration failed: " + ec.message()}); - break; + // 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 (!entry.is_regular_file()) { - continue; - } - const auto path = entry.path(); - if (!hasDsoSuffix(path)) { - continue; - } - auto descriptor = inspectPluginDso(path); - if (descriptor) { - result.plugins.push_back(std::move(*descriptor)); - } else { - result.diagnostics.push_back({path, descriptor.error()}); + + 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(); } } diff --git a/pj_proto_app/CMakeLists.txt b/pj_proto_app/CMakeLists.txt index 202f9d8..30dba86 100644 --- a/pj_proto_app/CMakeLists.txt +++ b/pj_proto_app/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(pj_proto_app src/main.cpp src/main_window.cpp src/plugin_registry.cpp + src/qt_diagnostic_bridge.cpp src/data_source_session.cpp src/toolbox_session.cpp src/series_tree_model.cpp diff --git a/pj_proto_app/src/main_window.cpp b/pj_proto_app/src/main_window.cpp index 7e7231b..81ffc32 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 QtDiagnosticBridge(this); + connect(diag_bridge_, &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,21 +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"); - const auto loaded_snapshot = registry_.loadedExtensionsSnapshot(); + 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()) { @@ -770,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..b53ba93 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 @@ -16,6 +19,7 @@ #include "pj_datastore/colormap_registry.hpp" #include "pj_datastore/engine.hpp" #include "plugin_registry.hpp" +#include "qt_diagnostic_bridge.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_; + 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 503bdaf..29446a1 100644 --- a/pj_proto_app/src/plugin_registry.cpp +++ b/pj_proto_app/src/plugin_registry.cpp @@ -1,22 +1,30 @@ #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) {} +PluginRegistry::PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink) + : plugin_dir_(plugin_dir), sink_(std::move(sink)) {} + +void PluginRegistry::report(PJ::DiagnosticLevel level, const std::string& id, std::string message) const { + if (!sink_) { + return; + } + sink_(PJ::Diagnostic{level, "PluginRegistry", id, std::move(message), std::chrono::system_clock::now()}); +} 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"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "DataSourceLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); return false; } LoadedDataSource loaded; @@ -28,18 +36,27 @@ bool PluginRegistry::loadAndRegisterDataSource(const std::filesystem::path& so_p loaded.capabilities = handle.capabilities(); try { auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.id = manifest.value("id", so_path.stem().string()); - loaded.name = manifest.value("name", so_path.stem().string()); - loaded.version = manifest.value("version", so_path.stem().string()); + if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() + || !manifest.contains("version") || !manifest["version"].is_string() + || manifest["version"].get().empty()) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "DataSource " + so_path.string() + ": embedded manifest missing required string fields 'id' and/or 'version'"); + return false; + } + loaded.id = manifest["id"].get(); + loaded.name = manifest.value("name", loaded.id); + loaded.version = manifest["version"].get(); 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(); + } catch (const nlohmann::json::exception& e) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + std::string("DataSource ") + so_path.string() + ": manifest parse failed: " + e.what()); + return false; } - std::cerr << "Loaded DataSource: " << loaded.name << " from " << loaded.path << "\n"; + report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded DataSource " + loaded.name + " from " + loaded.path); data_sources_.push_back(std::move(loaded)); return true; } @@ -47,8 +64,8 @@ bool PluginRegistry::loadAndRegisterDataSource(const std::filesystem::path& so_p 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"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "MessageParserLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); return false; } LoadedMessageParser loaded; @@ -59,30 +76,36 @@ bool PluginRegistry::loadAndRegisterMessageParser(const std::filesystem::path& s auto handle = loaded.library.createHandle(); try { auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.id = manifest.value("id", so_path.stem().string()); - loaded.name = manifest.value("name", so_path.stem().string()); - loaded.version = manifest.value("version", 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()); - } - } + if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() + || !manifest.contains("version") || !manifest["version"].is_string() + || manifest["version"].get().empty()) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "MessageParser " + so_path.string() + + ": embedded manifest missing required string fields 'id' and/or 'version'"); + return false; + } + loaded.id = manifest["id"].get(); + loaded.name = manifest.value("name", loaded.id); + loaded.version = manifest["version"].get(); + if (manifest.contains("encoding")) { + const auto& enc = manifest["encoding"]; + if (!enc.is_array()) { + report(PJ::DiagnosticLevel::kError, loaded.id, + "MessageParser " + so_path.string() + ": 'encoding' must be an array of strings"); + return false; } - 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; + for (const auto& e : enc) { + if (e.is_string()) { + loaded.encodings.push_back(e.get()); + } } - }; - // Primary encoding field - if (manifest.contains("encoding")) { - push_encodings(manifest["encoding"]); } - } catch (...) { - loaded.name = so_path.stem().string(); + } catch (const nlohmann::json::exception& e) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + std::string("MessageParser ") + so_path.string() + ": manifest parse failed: " + e.what()); + return false; } - std::cerr << "Loaded MessageParser: " << loaded.name << " from " << loaded.path << "\n"; + report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded MessageParser " + loaded.name + " from " + loaded.path); message_parsers_.push_back(std::move(loaded)); return true; } @@ -90,8 +113,8 @@ bool PluginRegistry::loadAndRegisterMessageParser(const std::filesystem::path& s 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"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "ToolboxLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); return false; } LoadedToolbox loaded; @@ -103,13 +126,22 @@ bool PluginRegistry::loadAndRegisterToolbox(const std::filesystem::path& so_path loaded.capabilities = handle.capabilities(); try { auto manifest = nlohmann::json::parse(handle.manifest()); - loaded.id = manifest.value("id", so_path.stem().string()); - loaded.name = manifest.value("name", so_path.stem().string()); - loaded.version = manifest.value("version", so_path.stem().string()); - } catch (...) { - loaded.name = so_path.stem().string(); + if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() + || !manifest.contains("version") || !manifest["version"].is_string() + || manifest["version"].get().empty()) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "Toolbox " + so_path.string() + ": embedded manifest missing required string fields 'id' and/or 'version'"); + return false; + } + loaded.id = manifest["id"].get(); + loaded.name = manifest.value("name", loaded.id); + loaded.version = manifest["version"].get(); + } catch (const nlohmann::json::exception& e) { + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + std::string("Toolbox ") + so_path.string() + ": manifest parse failed: " + e.what()); + return false; } - std::cerr << "Loaded Toolbox: " << loaded.name << " from " << loaded.path << "\n"; + report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded Toolbox " + loaded.name + " from " + loaded.path); toolbox_plugins_.push_back(std::move(loaded)); return true; } @@ -118,28 +150,19 @@ 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"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, + "Plugin directory not found: " + plugin_dir_ + " (" + ec.message() + ")"); 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) { + if (!entry.is_regular_file() || entry.path().extension().string() != expected_ext) { continue; } ++matched; @@ -147,18 +170,19 @@ void PluginRegistry::scanDirectory() { 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"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Failed to load plugin: " + entry.path().string()); } } - std::cerr << "[scanDirectory] done. walked=" << walked << " matched=" << matched - << " (iter ec=" << ec.message() << ")\n"; + report(PJ::DiagnosticLevel::kInfo, /*id*/ {}, + "Plugin scan done: walked=" + std::to_string(walked) + " matched=" + std::to_string(matched) + + (ec ? std::string(" (iter ec=") + ec.message() + ")" : std::string{})); } void PluginRegistry::reload() { namespace fs = std::filesystem; if (!fs::is_directory(plugin_dir_)) { - std::cerr << "Plugin directory not found: " << plugin_dir_ << "\n"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Plugin directory not found: " + plugin_dir_); return; } @@ -178,21 +202,21 @@ void PluginRegistry::reload() { }; std::erase_if(data_sources_, [&](const LoadedDataSource& ds) { if (is_gone(ds.path)) { - std::cerr << "Unloaded DataSource (removed): " << ds.path << "\n"; + report(PJ::DiagnosticLevel::kInfo, ds.id, "Unloaded DataSource (removed from disk): " + ds.path); 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"; + report(PJ::DiagnosticLevel::kInfo, mp.id, "Unloaded MessageParser (removed from disk): " + mp.path); 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"; + report(PJ::DiagnosticLevel::kInfo, tb.id, "Unloaded Toolbox (removed from disk): " + tb.path); return true; } return false; @@ -209,7 +233,7 @@ void PluginRegistry::reload() { if (disk_mtime <= ds_it->loaded_mtime) { continue; } - std::cerr << "Reloading updated DataSource: " << path_str << "\n"; + report(PJ::DiagnosticLevel::kInfo, ds_it->id, "Reloading updated DataSource: " + path_str); data_sources_.erase(ds_it); } else { auto mp_it = std::find_if(message_parsers_.begin(), message_parsers_.end(), @@ -218,7 +242,7 @@ void PluginRegistry::reload() { if (disk_mtime <= mp_it->loaded_mtime) { continue; } - std::cerr << "Reloading updated MessageParser: " << path_str << "\n"; + report(PJ::DiagnosticLevel::kInfo, mp_it->id, "Reloading updated MessageParser: " + path_str); message_parsers_.erase(mp_it); } else { auto tb_it = std::find_if(toolbox_plugins_.begin(), toolbox_plugins_.end(), @@ -227,7 +251,7 @@ void PluginRegistry::reload() { if (disk_mtime <= tb_it->loaded_mtime) { continue; } - std::cerr << "Reloading updated Toolbox: " << path_str << "\n"; + report(PJ::DiagnosticLevel::kInfo, tb_it->id, "Reloading updated Toolbox: " + path_str); toolbox_plugins_.erase(tb_it); } } @@ -236,7 +260,7 @@ void PluginRegistry::reload() { if (!loadAndRegisterDataSource(so_path) && !loadAndRegisterMessageParser(so_path) && !loadAndRegisterToolbox(so_path)) { - std::cerr << "Failed to load plugin: " << path_str << "\n"; + report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Failed to load plugin: " + path_str); } } } diff --git a/pj_proto_app/src/plugin_registry.hpp b/pj_proto_app/src/plugin_registry.hpp index b484c20..3e1e5fb 100644 --- a/pj_proto_app/src/plugin_registry.hpp +++ b/pj_proto_app/src/plugin_registry.hpp @@ -9,6 +9,7 @@ #include #include +#include "pj_base/diagnostic_sink.hpp" #include "pj_marketplace/installed_extension.hpp" #include "pj_plugins/host/data_source_library.hpp" #include "pj_plugins/host/message_parser_library.hpp" @@ -49,7 +50,10 @@ struct LoadedToolbox { class PluginRegistry { public: - explicit PluginRegistry(std::string_view plugin_dir); + /// `sink` (optional) receives info/warning/error events about plugin load + /// lifecycle. If unset, events are silently discarded — useful for tests + /// that don't care. + explicit PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink = {}); void scanDirectory(); void reload(); @@ -85,7 +89,11 @@ class PluginRegistry { /// Try to load a Toolbox plugin and register it. Returns true on success. bool loadAndRegisterToolbox(const std::filesystem::path& so_path); + /// Forwards a one-shot diagnostic to sink_ if it is set. + void report(PJ::DiagnosticLevel level, const std::string& id, std::string message) const; + std::string plugin_dir_; + PJ::DiagnosticSink sink_; std::vector data_sources_; std::vector message_parsers_; std::vector toolbox_plugins_; diff --git a/pj_proto_app/src/qt_diagnostic_bridge.cpp b/pj_proto_app/src/qt_diagnostic_bridge.cpp new file mode 100644 index 0000000..94afc86 --- /dev/null +++ b/pj_proto_app/src/qt_diagnostic_bridge.cpp @@ -0,0 +1,33 @@ +#include "qt_diagnostic_bridge.hpp" + +#include +#include +#include + +namespace proto { + +QtDiagnosticBridge::QtDiagnosticBridge(QObject* parent) : QObject(parent) {} + +PJ::DiagnosticSink QtDiagnosticBridge::sink() { + // Capture a QPointer so a queued event firing after `this` is destroyed + // becomes a no-op instead of a use-after-free. + QPointer guard(this); + return [guard](const PJ::Diagnostic& d) { + if (!guard) { + return; + } + QMetaObject::invokeMethod( + guard.data(), + [guard, d]() { + if (!guard) { + return; + } + emit guard->diagnosticReported( + static_cast(d.level), QString::fromStdString(d.source), QString::fromStdString(d.id), + QString::fromStdString(d.message)); + }, + Qt::QueuedConnection); + }; +} + +} // namespace proto diff --git a/pj_proto_app/src/qt_diagnostic_bridge.hpp b/pj_proto_app/src/qt_diagnostic_bridge.hpp new file mode 100644 index 0000000..dca38f5 --- /dev/null +++ b/pj_proto_app/src/qt_diagnostic_bridge.hpp @@ -0,0 +1,29 @@ +#pragma once + +// QtDiagnosticBridge — adapter that exposes a thread-safe PJ::DiagnosticSink +// returning a Qt signal callers can connect to. Lets non-Qt modules surface +// diagnostics to a Qt GUI without depending on Qt themselves. + +#include +#include + +#include "pj_base/diagnostic_sink.hpp" + +namespace proto { + +class QtDiagnosticBridge : public QObject { + Q_OBJECT + + public: + explicit QtDiagnosticBridge(QObject* parent = nullptr); + + /// Returns a sink that marshals every event onto this object's Qt thread via + /// a queued connection, then emits diagnosticReported. Safe to call from any + /// thread; safe to outlive this bridge (the lambda holds a QPointer). + PJ::DiagnosticSink sink(); + + signals: + void diagnosticReported(int level, QString source, QString id, QString message); +}; + +} // namespace proto diff --git a/pj_proto_app/tests/plugin_registry_test.cpp b/pj_proto_app/tests/plugin_registry_test.cpp index beabfa6..6ea477f 100644 --- a/pj_proto_app/tests/plugin_registry_test.cpp +++ b/pj_proto_app/tests/plugin_registry_test.cpp @@ -6,6 +6,10 @@ #include #include #include +#include +#include + +#include "pj_base/diagnostic_sink.hpp" namespace proto { @@ -27,4 +31,47 @@ TEST(PluginRegistryTest, LoadedExtensionsSnapshotUsesLoadedManifestVersion) { EXPECT_EQ(snapshot["mock-data-source"].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 From c9d66e0e25c38785f7057633eb3b19bfc538f531 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 18:21:09 +0200 Subject: [PATCH 08/12] docs(audit) + sdk: unify PJ_DIALOG_PLUGIN, add manifest-sidecar tooling SDK / ABI: - Collapse PJ_DIALOG_PLUGIN_VTABLE into a single PJ_DIALOG_PLUGIN that works for both standalone and co-resident DSOs. The boot-level pj_plugin_abi_version export now lives at file scope in pj_base/plugin_abi_export.h with weak linkage (selectany on MSVC) so duplicate definitions across family macros in one DSO collapse to a single COMDAT entry. - pj_proto_app PluginRegistry: parser "encoding" manifest key is now required and accepts either a string or an array of strings; new test pins the string form through mock_json_parser_plugin. Build tooling: - New cmake/PjPluginManifest.cmake helper emits an inspection-only .pjmanifest.json sidecar next to plugin DSOs (augmenting the embedded manifest with abi_major + family). Runtime discovery ignores the file; it exists for packaging diagnostics and dev tools. Validates id/name/version at configure time. Documentation audit (13 files): - Add the now-required "id" key to manifest schema tables, examples, and protocol-header doc comments across data_source / message_parser / toolbox / dialog families. - pj_datastore USER_GUIDE: replace the removed appendArrowIpc bulk import section with the v4 Arrow C Data Interface equivalent (appendArrowStream + ArrowStreamHolder). - pj_plugins ARCHITECTURE.md + plugin_data_api.h: rewrite the boot-ABI symbol description to credit the shared header and weak linkage, not per-family macros. - pj_marketplace ARCHITECTURE / USER_MANUAL / spec: PlantUML install flow now shows the .pj_install__/ transaction directory and post-promotion DSO re-validation; .extension_staging/ no longer flagged Windows-only (used on all platforms as the validation gate); .pj_pending_install nesting corrected; transaction-folder location clarified per platform. - Drop a stale, never-existed test path reference from toolbox-guide; add a Manifest Schema section to the dialog-plugin-guide that other family guides already had. Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 2 + cmake/PjPluginManifest.cmake | 97 +++++++++++++++++++ .../include/pj_base/data_source_protocol.h | 2 + .../include/pj_base/message_parser_protocol.h | 2 + pj_base/include/pj_base/plugin_abi_export.h | 33 +++++++ pj_base/include/pj_base/plugin_data_api.h | 12 ++- .../pj_base/sdk/data_source_plugin_base.hpp | 3 +- .../sdk/message_parser_plugin_base.hpp | 3 +- .../pj_base/sdk/toolbox_plugin_base.hpp | 3 +- pj_base/include/pj_base/toolbox_protocol.h | 9 ++ pj_datastore/docs/USER_GUIDE.md | 13 ++- pj_marketplace/documentation/ARCHITECTURE.md | 28 ++++-- pj_marketplace/documentation/REQUIREMENTS.md | 30 ++++-- pj_marketplace/documentation/TODO.md | 1 - pj_marketplace/documentation/USER_MANUAL.md | 7 +- .../plotjuggler-marketplace-spec-v1.0.0-en.md | 27 +++--- .../pj_plugins/sdk/dialog_plugin_base.hpp | 26 ++--- pj_plugins/docs/ARCHITECTURE.md | 15 ++- pj_plugins/docs/data-source-guide.md | 6 +- pj_plugins/docs/dialog-plugin-guide.md | 20 +++- pj_plugins/docs/message-parser-guide.md | 4 +- pj_plugins/docs/toolbox-guide.md | 10 +- .../examples/mock_source_with_dialog.cpp | 2 +- pj_proto_app/CMakeLists.txt | 3 +- pj_proto_app/src/plugin_registry.cpp | 55 ++++++++--- pj_proto_app/tests/plugin_registry_test.cpp | 24 +++++ 26 files changed, 356 insertions(+), 81 deletions(-) create mode 100644 cmake/PjPluginManifest.cmake create mode 100644 pj_base/include/pj_base/plugin_abi_export.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 810be36..b145108 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,8 @@ 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(PjPluginManifest) # --------------------------------------------------------------------------- # Options diff --git a/cmake/PjPluginManifest.cmake b/cmake/PjPluginManifest.cmake new file mode 100644 index 0000000..d76e783 --- /dev/null +++ b/cmake/PjPluginManifest.cmake @@ -0,0 +1,97 @@ +# PjPluginManifest.cmake +# +# Helper that emits a human-readable plugin manifest sidecar JSON next to a +# plugin shared library. +# +# 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. +# +# 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 +# add_library(csv_source_plugin SHARED csv_source.cpp) +# pj_emit_plugin_manifest(csv_source_plugin +# FAMILY data_source +# MANIFEST_FILE ${CMAKE_CURRENT_SOURCE_DIR}/manifest.json +# ) +# +# Writes /.pjmanifest.json next to the DSO and installs it +# alongside the DSO. + +function(pj_emit_plugin_manifest TARGET) + set(_options) + set(_oneValueArgs FAMILY MANIFEST_FILE ABI_MAJOR) + set(_multiValueArgs) + cmake_parse_arguments(ARG "${_options}" "${_oneValueArgs}" "${_multiValueArgs}" ${ARGN}) + + if(NOT ARG_FAMILY) + message(FATAL_ERROR "pj_emit_plugin_manifest(${TARGET}): FAMILY is required") + endif() + + set(_valid_families data_source message_parser toolbox dialog) + list(FIND _valid_families "${ARG_FAMILY}" _family_idx) + if(_family_idx LESS 0) + message(FATAL_ERROR + "pj_emit_plugin_manifest(${TARGET}): FAMILY \"${ARG_FAMILY}\" is invalid. " + "Must be one of: ${_valid_families}") + endif() + + if(NOT ARG_MANIFEST_FILE) + set(ARG_MANIFEST_FILE "${CMAKE_CURRENT_SOURCE_DIR}/manifest.json") + endif() + if(NOT EXISTS "${ARG_MANIFEST_FILE}") + message(FATAL_ERROR + "pj_emit_plugin_manifest(${TARGET}): MANIFEST_FILE not found: ${ARG_MANIFEST_FILE}") + endif() + + if(NOT ARG_ABI_MAJOR) + # Matches PJ_ABI_VERSION in pj_base/plugin_data_api.h. Bump in lockstep. + set(ARG_ABI_MAJOR 4) + endif() + + # Track manifest edits so CMake reconfigures when the source changes. + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ARG_MANIFEST_FILE}") + + file(READ "${ARG_MANIFEST_FILE}" _src_json) + + 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}") + string(JSON _sidecar_json SET "${_sidecar_json}" "abi_major" "${ARG_ABI_MAJOR}") + string(JSON _sidecar_json SET "${_sidecar_json}" "family" "\"${ARG_FAMILY}\"") + + # Write to build tree. The file lives next to the DSO. + set(_sidecar_path "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}.pjmanifest.json") + file(WRITE "${_sidecar_path}" "${_sidecar_json}\n") + + add_custom_command( + TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_sidecar_path}" + "$/${TARGET}.pjmanifest.json" + COMMENT "Copying human-readable ${TARGET}.pjmanifest.json next to DSO" + VERBATIM + ) + + get_target_property(_type ${TARGET} TYPE) + if(_type STREQUAL "MODULE_LIBRARY" OR _type STREQUAL "SHARED_LIBRARY") + install(FILES "${_sidecar_path}" + DESTINATION "${CMAKE_INSTALL_LIBDIR}" + ) + endif() +endfunction() 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/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..53a6eb9 --- /dev/null +++ b/pj_base/include/pj_base/plugin_abi_export.h @@ -0,0 +1,33 @@ +#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. +#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 const 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_plugin_base.hpp b/pj_base/include/pj_base/sdk/data_source_plugin_base.hpp index 656f623..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" @@ -233,7 +234,7 @@ class DataSourcePluginBase { * @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 8b9f4a6..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" @@ -155,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 f1d6f47..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" @@ -233,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_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_marketplace/documentation/ARCHITECTURE.md b/pj_marketplace/documentation/ARCHITECTURE.md index 0bff5c9..9c11be0 100644 --- a/pj_marketplace/documentation/ARCHITECTURE.md +++ b/pj_marketplace/documentation/ARCHITECTURE.md @@ -257,10 +257,17 @@ if (Checksum OK?) then (yes) if (Is update?) then (yes) :Backup current; endif - :Extract to extensions/; + :Extract to .pj_install__/ (transaction dir); :Load DSO manifest; :Validate registry id/version; - :Register discovery cache; + :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 @@ -284,22 +291,24 @@ title Windows Staging Flow start :Download ZIP; -:Extract to .extension_staging/{id}/; +:Extract to .pj_install__/ (transaction dir under .extension_staging/); :Load DSO manifest; :Validate registry id/version; -:Write .pj_pending_install intent; +:Atomic rename to .extension_staging//; +:Write .extension_staging//.pj_pending_install intent; :Notify "Restart required"; stop start :PlotJuggler restarts; +:applyPendingInstalls() scans .extension_staging/; :Read .pj_pending_install intent; :Validate staged DSO manifest; if (Valid?) then (yes) -:Move .extension_staging/{id}/ to extensions/{id}/; + :Move .extension_staging// to extensions//; :Plugin active; else (no) - :Remove broken stage; + :Move to .pj_quarantine__/; :Notify install error; endif stop @@ -360,8 +369,11 @@ The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.l │ │ └── ros2_streaming.ui │ └── csv-loader/ │ └── libcsv_loader.so -├── .extension_staging/ # Staging area (Windows) -│ └── ros2-streaming/ # Ready to install on restart +├── .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/ # Non-Windows update backups; automatic rollback deferred ├── ros2-streaming-1.2.2/ └── csv-loader-0.9.0/ diff --git a/pj_marketplace/documentation/REQUIREMENTS.md b/pj_marketplace/documentation/REQUIREMENTS.md index 24824b2..6706df7 100644 --- a/pj_marketplace/documentation/REQUIREMENTS.md +++ b/pj_marketplace/documentation/REQUIREMENTS.md @@ -38,10 +38,9 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | | 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 (planned — see TODO.md) | -| | Backup diagnostics | Report retained backup paths when an update install fails | +| **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 | @@ -93,6 +92,22 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact --- +## 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. + +--- + ## 4. Functional Requirements ### 4.1 P0 — Minimum Viable Product @@ -120,7 +135,6 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact | F-12 | Backup previous version on updates | Old version saved before overwriting | | 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 | @@ -199,9 +213,9 @@ 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 Deferred) @@ -288,7 +302,7 @@ PlotJuggler has grown significantly, evolving from an internal tool to a de fact |----------|-------------------| | 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 | Reject install or staged promotion with diagnostics | +| 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 | --- @@ -299,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 diff --git a/pj_marketplace/documentation/TODO.md b/pj_marketplace/documentation/TODO.md index 838e35d..6208a42 100644 --- a/pj_marketplace/documentation/TODO.md +++ b/pj_marketplace/documentation/TODO.md @@ -5,7 +5,6 @@ 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. -- Enable/disable without uninstalling. - Local registry cache with TTL, if we decide to reintroduce caching. - macOS packaging and runtime validation. diff --git a/pj_marketplace/documentation/USER_MANUAL.md b/pj_marketplace/documentation/USER_MANUAL.md index 791e046..695dc99 100644 --- a/pj_marketplace/documentation/USER_MANUAL.md +++ b/pj_marketplace/documentation/USER_MANUAL.md @@ -285,8 +285,11 @@ Inside that root: ├── extensions/ # Active installed extensions │ └── my-extension/ │ └── libmy_plugin.so -├── .extension_staging/ # Staged updates (Windows only) -│ └── my-extension/.pj_pending_install +├── .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/ # Non-Windows update backups; automatic rollback deferred ``` 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 21abb7c..7b8152c 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 @@ -61,10 +61,9 @@ 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 | -| | Backup diagnostics | Report retained backup paths when an update install fails | +| **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 | @@ -109,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. | --- @@ -610,7 +609,7 @@ 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 transaction folder under `.extension_staging/` +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" @@ -631,8 +630,9 @@ The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.l ├── extensions/ ← Active plugins │ ├── ros2-streaming/ │ └── csv-loader/ -├── .extension_staging/ ← Staging (Windows) -│ └── plugin-id/.pj_pending_install +├── .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/ ← Non-Windows update backups; automatic rollback deferred ├── ros2-streaming-1.2.2/ └── csv-loader-0.9.0/ @@ -763,7 +763,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 @@ -772,10 +772,14 @@ The detail panel includes: | State | Actions | | --------------------------- | -------------------------- | | Not installed | Install | -| Installed, up-to-date | Disable, Uninstall | -| Installed, update available | Update, Disable, Uninstall | +| Installed, up-to-date | Uninstall | +| Installed, update available | Update, Uninstall | | Installed, local newer | Local newer, Uninstall | -| Disabled | Enable, 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 @@ -840,7 +844,6 @@ marketplace/ | F-12 | Backup previous version on updates | | 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 | 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 319b684..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,19 +237,23 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { } // namespace PJ -/// Macro to export only the dialog vtable entry point for a plugin class. +/// Macro to export a Dialog plugin from a DSO. /// -/// Use this when the dialog is co-resident with another plugin family that -/// already exports `pj_plugin_abi_version`. +/// 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 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 +/// 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_VTABLE(ClassName) \ +#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 { \ @@ -265,8 +270,3 @@ PJ_borrowed_dialog_t borrowDialog(DialogT& dialog) noexcept { return PJ_get_dialog_vtable(); \ } \ } - -/// Macro to export a standalone dialog plugin DSO. -#define PJ_DIALOG_PLUGIN(ClassName) \ - extern "C" PJ_DIALOG_EXPORT const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; \ - PJ_DIALOG_PLUGIN_VTABLE(ClassName) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 5732872..9078ebc 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 @@ -271,7 +278,7 @@ 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)` standalone, `PJ_DIALOG_PLUGIN_VTABLE(Class)` when co-resident | +| 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. diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 2d202fe..e83917c 100644 --- a/pj_plugins/docs/data-source-guide.md +++ b/pj_plugins/docs/data-source-guide.md @@ -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", @@ -726,7 +728,7 @@ with no JSON serialization needed at runtime. │ getDialog() → borrowDialog(...) │ │ │ │ PJ_DATA_SOURCE_PLUGIN(MySource) │ → exports DataSource vtable -│ PJ_DIALOG_PLUGIN_VTABLE(MyDialog) │ → exports Dialog vtable +│ PJ_DIALOG_PLUGIN(MyDialog) │ → exports Dialog vtable └──────────────────────────────────────┘ ``` @@ -789,7 +791,7 @@ class MySource : public PJ::StreamSourceBase { ```cpp PJ_DATA_SOURCE_PLUGIN(MySource, R"({"id":"my-source","name":"My Source","version":"1.0.0"})") -PJ_DIALOG_PLUGIN_VTABLE(MyDialog) +PJ_DIALOG_PLUGIN(MyDialog) ``` ### Host-side flow diff --git a/pj_plugins/docs/dialog-plugin-guide.md b/pj_plugins/docs/dialog-plugin-guide.md index cb9c6f8..4d19541 100644 --- a/pj_plugins/docs/dialog-plugin-guide.md +++ b/pj_plugins/docs/dialog-plugin-guide.md @@ -226,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: @@ -553,8 +569,8 @@ class MySource : public PJ::StreamSourceBase { }; PJ_DATA_SOURCE_PLUGIN(MySource, R"({"id":"my-source","name":"My Source","version":"1.0.0"})") -PJ_DIALOG_PLUGIN_VTABLE(MyDialog) // also specialises PJ::dialogVtableFor() - // so PJ::borrowDialog picks up the right vtable. +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 f3a71a9..ed80a73 100644 --- a/pj_plugins/docs/message-parser-guide.md +++ b/pj_plugins/docs/message-parser-guide.md @@ -271,7 +271,7 @@ needed. │ loadConfig(json) applies it │ │ │ │ PJ_MESSAGE_PARSER_PLUGIN(...) │ → exports parser vtable -│ PJ_DIALOG_PLUGIN_VTABLE(ProtoDialog) │ → exports dialog vtable +│ PJ_DIALOG_PLUGIN(ProtoDialog) │ → exports dialog vtable └──────────────────────────────────┘ ``` @@ -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 f63abb3..b09a635 100644 --- a/pj_plugins/docs/toolbox-guide.md +++ b/pj_plugins/docs/toolbox-guide.md @@ -27,7 +27,7 @@ editor, custom data transforms. acquiring services), `saveConfig()`, `loadConfig()`, `getDialog()` 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_VTABLE(YourDialog)` + `DialogPluginTyped` subclass and add `PJ_DIALOG_PLUGIN(YourDialog)` 5. Build as a shared library linking `pj_base` (+ `pj_dialog_sdk` if you have a dialog) @@ -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_source_with_dialog.cpp b/pj_plugins/examples/mock_source_with_dialog.cpp index 7bdadc8..23723d2 100644 --- a/pj_plugins/examples/mock_source_with_dialog.cpp +++ b/pj_plugins/examples/mock_source_with_dialog.cpp @@ -373,4 +373,4 @@ PJ_DATA_SOURCE_PLUGIN( 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_VTABLE(MockStreamerDialog) +PJ_DIALOG_PLUGIN(MockStreamerDialog) diff --git a/pj_proto_app/CMakeLists.txt b/pj_proto_app/CMakeLists.txt index 30dba86..821d6a6 100644 --- a/pj_proto_app/CMakeLists.txt +++ b/pj_proto_app/CMakeLists.txt @@ -41,6 +41,7 @@ if(PJ_BUILD_TESTS) ) 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 @@ -52,7 +53,7 @@ if(PJ_BUILD_TESTS) nlohmann_json::nlohmann_json Qt6::Core ) - add_dependencies(proto_plugin_registry_test mock_data_source_plugin) + 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 ) diff --git a/pj_proto_app/src/plugin_registry.cpp b/pj_proto_app/src/plugin_registry.cpp index 29446a1..42fe0b3 100644 --- a/pj_proto_app/src/plugin_registry.cpp +++ b/pj_proto_app/src/plugin_registry.cpp @@ -10,6 +10,40 @@ namespace proto { +namespace { + +bool appendManifestEncodings(const nlohmann::json& value, std::vector& encodings, std::string& error) { + auto append_string = [&](const nlohmann::json& item) -> bool { + if (!item.is_string() || item.get().empty()) { + error = "'encoding' must contain non-empty strings"; + return false; + } + encodings.push_back(item.get()); + return true; + }; + + if (value.is_string()) { + return append_string(value); + } + if (value.is_array()) { + for (const auto& item : value) { + if (!append_string(item)) { + return false; + } + } + if (encodings.empty()) { + error = "'encoding' array must not be empty"; + return false; + } + return true; + } + + error = "'encoding' must be a string or an array of strings"; + return false; +} + +} // namespace + PluginRegistry::PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink) : plugin_dir_(plugin_dir), sink_(std::move(sink)) {} @@ -87,18 +121,15 @@ bool PluginRegistry::loadAndRegisterMessageParser(const std::filesystem::path& s loaded.id = manifest["id"].get(); loaded.name = manifest.value("name", loaded.id); loaded.version = manifest["version"].get(); - if (manifest.contains("encoding")) { - const auto& enc = manifest["encoding"]; - if (!enc.is_array()) { - report(PJ::DiagnosticLevel::kError, loaded.id, - "MessageParser " + so_path.string() + ": 'encoding' must be an array of strings"); - return false; - } - for (const auto& e : enc) { - if (e.is_string()) { - loaded.encodings.push_back(e.get()); - } - } + if (!manifest.contains("encoding")) { + report(PJ::DiagnosticLevel::kError, loaded.id, + "MessageParser " + so_path.string() + ": embedded manifest missing required key 'encoding'"); + return false; + } + std::string encoding_error; + if (!appendManifestEncodings(manifest["encoding"], loaded.encodings, encoding_error)) { + report(PJ::DiagnosticLevel::kError, loaded.id, "MessageParser " + so_path.string() + ": " + encoding_error); + return false; } } catch (const nlohmann::json::exception& e) { report(PJ::DiagnosticLevel::kError, /*id*/ {}, diff --git a/pj_proto_app/tests/plugin_registry_test.cpp b/pj_proto_app/tests/plugin_registry_test.cpp index 6ea477f..b25c4f5 100644 --- a/pj_proto_app/tests/plugin_registry_test.cpp +++ b/pj_proto_app/tests/plugin_registry_test.cpp @@ -31,6 +31,30 @@ TEST(PluginRegistryTest, LoadedExtensionsSnapshotUsesLoadedManifestVersion) { 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. From ccc126efb6004b7a5f12384d02519c06834df729 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 19:32:36 +0200 Subject: [PATCH 09/12] fix(marketplace): back up previous version on Windows staged updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyPendingInstalls() previously called removeRecursively() on the existing extensions// directory before promoting the staged update, so a Windows update silently destroyed the prior install with no recovery path. Linux/macOS already moved the previous version to .backup/-/ synchronously inside update(); the Windows restart-time path lacked that symmetry. This change moves the existing dir to PlatformUtils::backupDir() before the rename, mirroring the Linux/macOS behavior. If the staged-dir promotion fails after the backup move, the marketplace attempts to roll the backup back into place; if that also fails the diagnostic surfaces both paths so the user can recover manually. Edge cases handled: - Existing extension's manifest unreadable -> backup gets a uuid suffix so we never silently overwrite an older recovery point. - Pre-existing backup at .backup/-/ (leftover from an earlier failed update) -> uuid-suffixed to keep both copies. - pending_backup_path_ cleared between iterations so it can't leak the previous extension's backup into a later failure diagnostic. Test: ApplyPendingInstallsBacksUpExistingExtensionBeforePromotion installs v1, manually stages v2 with intent file (the on-disk shape Windows leaves before restart), runs applyPendingInstalls(), asserts v2 is active AND v1 survives at .backup/mock-data-source-1.0.0/. Docs updated to drop the "Linux-only backup" framing in ARCHITECTURE, USER_MANUAL, and the spec — the mechanism now applies on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_marketplace/documentation/ARCHITECTURE.md | 17 ++++-- pj_marketplace/documentation/USER_MANUAL.md | 2 +- .../plotjuggler-marketplace-spec-v1.0.0-en.md | 17 ++++-- pj_marketplace/src/core/ExtensionManager.cpp | 57 +++++++++++++++++-- .../tests/extension_manager_test.cpp | 49 ++++++++++++++++ 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/pj_marketplace/documentation/ARCHITECTURE.md b/pj_marketplace/documentation/ARCHITECTURE.md index 9c11be0..b3d632a 100644 --- a/pj_marketplace/documentation/ARCHITECTURE.md +++ b/pj_marketplace/documentation/ARCHITECTURE.md @@ -318,9 +318,18 @@ stop ### 4.3 Rollback Flow -Automatic rollback is deferred. Non-Windows updates keep the previous version in -`.backup/`, but the marketplace does not currently restore it automatically if a -plugin later fails to load. +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) @@ -374,7 +383,7 @@ The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.l │ │ # use it as the post-promotion validation gate) │ └── ros2-streaming/ # Ready to install on restart (Windows) │ └── .pj_pending_install # Intent file (Windows-only) -└── .backup/ # Non-Windows update backups; automatic rollback deferred +└── .backup/ # Pre-update backups (all platforms); automatic rollback deferred — restore manually ├── ros2-streaming-1.2.2/ └── csv-loader-0.9.0/ ``` diff --git a/pj_marketplace/documentation/USER_MANUAL.md b/pj_marketplace/documentation/USER_MANUAL.md index 695dc99..f077bb2 100644 --- a/pj_marketplace/documentation/USER_MANUAL.md +++ b/pj_marketplace/documentation/USER_MANUAL.md @@ -290,7 +290,7 @@ Inside that root: │ │ # use it as the post-promotion validation gate) │ └── my-extension/ │ └── .pj_pending_install # Intent file (Windows-only restart-time apply) -└── .backup/ # Non-Windows update backups; automatic rollback deferred +└── .backup/ # Pre-update backups (all platforms); automatic rollback deferred — restore manually ``` ### 5.2 Registry URL 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 7b8152c..fc6e203 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 @@ -462,9 +462,18 @@ data only; the embedded manifest remains the authority for installed version rep ![Rollback Flow](diagrams/rollback-flow.png) -Automatic rollback is deferred. On non-staged platforms an update keeps the -previous version in `.backup/`, but the marketplace does not currently restore -that backup automatically if a later plugin load fails. +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. --- @@ -633,7 +642,7 @@ The root is `QStandardPaths::GenericDataLocation` + `/plotjuggler` (Linux: `~/.l ├── .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/ ← Non-Windows update backups; automatic rollback deferred +└── .backup/ ← Pre-update backups (all platforms); automatic rollback deferred — restore manually ├── ros2-streaming-1.2.2/ └── csv-loader-0.9.0/ ``` diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 3e3bf6f..32a2aa4 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -576,17 +576,61 @@ void ExtensionManager::applyPendingInstalls() { } const QString dst = extRoot(extensions_dir_, intent.id); - if (QDir(dst).exists() && !QDir(dst).removeRecursively()) { - qWarning("ExtensionManager: failed to remove existing extension directory '%s'", qPrintable(dst)); - emitInstallFailure(intent.id, QString("Could not remove existing extension directory \"%1\"").arg(dst)); - continue; + + // If an extension is already installed at `dst`, move it aside to the + // backup dir before promoting the staged version. This is the Windows + // counterpart to the synchronous backup that `update()` performs on + // Linux/macOS — without it, a Windows update would silently overwrite + // the previous version with no recovery path. + pending_backup_path_.clear(); + if (QDir(dst).exists()) { + const DirectoryDiscovery 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); + } + + 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); } - 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)); - emitInstallFailure(intent.id, QString("Could not promote staged install to \"%1\"").arg(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 (!pending_backup_path_.isEmpty()) { + if (QDir().rename(pending_backup_path_, dst)) { + message += " Previous version restored."; + pending_backup_path_.clear(); + // Re-register the restored install so isInstalled() reflects reality. + const DirectoryDiscovery restored = discoverExtensionDirectory(dst); + if (restored.found_plugin) { + InstalledExtension record = restored.record; + record.path = dst; + record.install_date = QFileInfo(dst).lastModified(); + installed_[intent.id] = record; + } + } + // If the rollback rename also failed, leave pending_backup_path_ set + // so emitInstallFailure surfaces "Previous version remains in backup". + } + emitInstallFailure(intent.id, message); continue; } @@ -595,6 +639,7 @@ void ExtensionManager::applyPendingInstalls() { record.path = dst; record.install_date = QFileInfo(dst).lastModified(); installed_[intent.id] = record; + pending_backup_path_.clear(); emit installFinished(intent.id, true); } } diff --git a/pj_marketplace/tests/extension_manager_test.cpp b/pj_marketplace/tests/extension_manager_test.cpp index d715d60..0d035c8 100644 --- a/pj_marketplace/tests/extension_manager_test.cpp +++ b/pj_marketplace/tests/extension_manager_test.cpp @@ -881,6 +881,55 @@ TEST_F(ExtensionManagerTest, ApplyPendingInstallsRejectsEmptyStagingDirectory) { 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. TEST_F(ExtensionManagerTest, ApplyPendingInstallsIsNoOpForEmptyDirectory) { QSignalSpy spy(mgr_, &ExtensionManager::installFinished); From 499f31e139a2f16fe94566988b222a36ac197ed2 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 19:39:01 +0200 Subject: [PATCH 10/12] cleanup --- .../pj_marketplace/extension_manager.hpp | 8 +++ pj_marketplace/src/core/ExtensionManager.cpp | 50 ++++++++----------- pj_marketplace/src/ui/marketplace_window.cpp | 2 +- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/pj_marketplace/include/pj_marketplace/extension_manager.hpp b/pj_marketplace/include/pj_marketplace/extension_manager.hpp index acde973..7196393 100644 --- a/pj_marketplace/include/pj_marketplace/extension_manager.hpp +++ b/pj_marketplace/include/pj_marketplace/extension_manager.hpp @@ -86,6 +86,9 @@ class ExtensionManager : public QObject { // 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) { @@ -143,6 +146,11 @@ class ExtensionManager : public QObject { // 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); diff --git a/pj_marketplace/src/core/ExtensionManager.cpp b/pj_marketplace/src/core/ExtensionManager.cpp index 32a2aa4..eb965ff 100644 --- a/pj_marketplace/src/core/ExtensionManager.cpp +++ b/pj_marketplace/src/core/ExtensionManager.cpp @@ -388,10 +388,7 @@ void ExtensionManager::doInstall(const Extension& ext, bool staging, bool allow_ removeDirectoryIfSet(transaction_root); pending_extract_dir_.clear(); pending_backup_path_.clear(); - InstalledExtension record = final_check.record; - record.path = dst; - record.install_date = QFileInfo(dst).lastModified(); - installed_[ext.id] = record; + registerInstalledExtension(ext.id, dst, final_check.record); emit installFinished(finished_id, true); }); @@ -577,14 +574,13 @@ void ExtensionManager::applyPendingInstalls() { const QString dst = extRoot(extensions_dir_, intent.id); - // If an extension is already installed at `dst`, move it aside to the - // backup dir before promoting the staged version. This is the Windows - // counterpart to the synchronous backup that `update()` performs on - // Linux/macOS — without it, a Windows update would silently overwrite - // the previous version with no recovery path. + // 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()) { - const DirectoryDiscovery existing = discoverExtensionDirectory(dst); + existing = discoverExtensionDirectory(dst); const QString version_tag = (existing.found_plugin && !existing.record.version.isEmpty()) ? existing.record.version : QString("unknown-") + QUuid::createUuid().toString(QUuid::Id128); @@ -613,32 +609,22 @@ void ExtensionManager::applyPendingInstalls() { "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 (!pending_backup_path_.isEmpty()) { - if (QDir().rename(pending_backup_path_, dst)) { - message += " Previous version restored."; - pending_backup_path_.clear(); - // Re-register the restored install so isInstalled() reflects reality. - const DirectoryDiscovery restored = discoverExtensionDirectory(dst); - if (restored.found_plugin) { - InstalledExtension record = restored.record; - record.path = dst; - record.install_date = QFileInfo(dst).lastModified(); - installed_[intent.id] = record; - } + // 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); } - // If the rollback rename also failed, leave pending_backup_path_ set - // so emitInstallFailure surfaces "Previous version remains in backup". } emitInstallFailure(intent.id, message); continue; } QFile::remove(pendingInstallIntentPath(dst)); - InstalledExtension record = discovered.record; - record.path = dst; - record.install_date = QFileInfo(dst).lastModified(); - installed_[intent.id] = record; + registerInstalledExtension(intent.id, dst, discovered.record); pending_backup_path_.clear(); emit installFinished(intent.id, true); } @@ -773,6 +759,12 @@ void ExtensionManager::emitUninstallFailure(const QString& id, const QString& me emit uninstallFinished(id, false); } +void ExtensionManager::registerInstalledExtension(const QString& id, const QString& dst, InstalledExtension record) { + record.path = dst; + record.install_date = QFileInfo(dst).lastModified(); + installed_[id] = record; +} + void ExtensionManager::refreshInstalledFromDisk() { QMap discovered; const QDir dir(extensions_dir_); diff --git a/pj_marketplace/src/ui/marketplace_window.cpp b/pj_marketplace/src/ui/marketplace_window.cpp index 6526416..8e2c635 100644 --- a/pj_marketplace/src/ui/marketplace_window.cpp +++ b/pj_marketplace/src/ui/marketplace_window.cpp @@ -506,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); From fd5b0435df772420bb18334353264d264a393f02 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 20:23:31 +0200 Subject: [PATCH 11/12] feat: share plugin runtime integration --- pj_marketplace/CMakeLists.txt | 2 + .../plotjuggler-marketplace-spec-v1.0.0-en.md | 2 +- .../pj_marketplace/qt_diagnostic_bridge.hpp | 30 ++ .../src/core/QtDiagnosticBridge.cpp | 31 ++ pj_plugins/CMakeLists.txt | 19 + pj_plugins/docs/ARCHITECTURE.md | 7 +- .../host/plugin_runtime_catalog.hpp | 142 ++++++ pj_plugins/src/plugin_catalog.cpp | 2 +- pj_plugins/src/plugin_runtime_catalog.cpp | 406 ++++++++++++++++++ pj_proto_app/CMakeLists.txt | 3 +- pj_proto_app/src/main_window.cpp | 4 +- pj_proto_app/src/main_window.hpp | 4 +- pj_proto_app/src/plugin_registry.cpp | 401 +---------------- pj_proto_app/src/plugin_registry.hpp | 86 ++-- pj_proto_app/src/qt_diagnostic_bridge.cpp | 33 -- pj_proto_app/src/qt_diagnostic_bridge.hpp | 29 -- 16 files changed, 680 insertions(+), 521 deletions(-) create mode 100644 pj_marketplace/include/pj_marketplace/qt_diagnostic_bridge.hpp create mode 100644 pj_marketplace/src/core/QtDiagnosticBridge.cpp create mode 100644 pj_plugins/include/pj_plugins/host/plugin_runtime_catalog.hpp create mode 100644 pj_plugins/src/plugin_runtime_catalog.cpp delete mode 100644 pj_proto_app/src/qt_diagnostic_bridge.cpp delete mode 100644 pj_proto_app/src/qt_diagnostic_bridge.hpp diff --git a/pj_marketplace/CMakeLists.txt b/pj_marketplace/CMakeLists.txt index 078c54a..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 ) 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 fc6e203..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 @@ -132,7 +132,7 @@ This architecture has an additional advantage: **any company can have their own ### 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. a plugin loader in the embedding app) see one unified, ordered diagnostic stream they can render in a status bar, dialog, or log file. Modules in pure C++ remain Qt-free; a Qt-aware bridge in the embedding app converts each event into a queued signal emission. +`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 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/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_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index 1203be7..e8f2d68 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -144,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) # --------------------------------------------------------------------------- diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 9078ebc..0e7ae63 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -324,10 +324,9 @@ 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; the embedding app provides a thin Qt adapter (e.g. -`pj_proto_app::QtDiagnosticBridge`) that marshals each event onto the Qt -event loop via `Qt::QueuedConnection` and emits a Qt signal the GUI can -connect to. +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. 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/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 87a53aa..0236bbd 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -312,7 +312,7 @@ Expected inspectPluginDso(const std::filesystem::path& dso_pat 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()); 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_proto_app/CMakeLists.txt b/pj_proto_app/CMakeLists.txt index 821d6a6..9c3d14d 100644 --- a/pj_proto_app/CMakeLists.txt +++ b/pj_proto_app/CMakeLists.txt @@ -7,7 +7,6 @@ add_executable(pj_proto_app src/main.cpp src/main_window.cpp src/plugin_registry.cpp - src/qt_diagnostic_bridge.cpp src/data_source_session.cpp src/toolbox_session.cpp src/series_tree_model.cpp @@ -23,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 @@ -48,6 +48,7 @@ if(PJ_BUILD_TESTS) pj_data_source_host pj_message_parser_host pj_toolbox_host + pj_plugin_runtime_catalog pj_marketplace GTest::gtest_main nlohmann_json::nlohmann_json diff --git a/pj_proto_app/src/main_window.cpp b/pj_proto_app/src/main_window.cpp index 81ffc32..c3f7ce2 100644 --- a/pj_proto_app/src/main_window.cpp +++ b/pj_proto_app/src/main_window.cpp @@ -126,8 +126,8 @@ MainWindow::MainWindow(const std::string& plugin_dir, QWidget* parent) : QMainWi // 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 QtDiagnosticBridge(this); - connect(diag_bridge_, &QtDiagnosticBridge::diagnosticReported, this, &MainWindow::onDiagnosticReported); + diag_bridge_ = new PJ::QtDiagnosticBridge(this); + connect(diag_bridge_, &PJ::QtDiagnosticBridge::diagnosticReported, this, &MainWindow::onDiagnosticReported); registry_ = std::make_unique(plugin_dir, diag_bridge_->sink()); diff --git a/pj_proto_app/src/main_window.hpp b/pj_proto_app/src/main_window.hpp index b53ba93..4dabf26 100644 --- a/pj_proto_app/src/main_window.hpp +++ b/pj_proto_app/src/main_window.hpp @@ -18,8 +18,8 @@ #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 "qt_diagnostic_bridge.hpp" #include "series_tree_model.hpp" #include "toolbox_session.hpp" @@ -68,7 +68,7 @@ class MainWindow : public QMainWindow { PJ::DataEngine engine_; PJ::ColorMapRegistry colormap_registry_; PJ::TimeDomainId default_td_id_ = 0; - QtDiagnosticBridge* diag_bridge_ = nullptr; + PJ::QtDiagnosticBridge* diag_bridge_ = nullptr; std::unique_ptr registry_; std::vector> sessions_; SeriesTreeModel tree_model_; diff --git a/pj_proto_app/src/plugin_registry.cpp b/pj_proto_app/src/plugin_registry.cpp index 42fe0b3..88f7a5e 100644 --- a/pj_proto_app/src/plugin_registry.cpp +++ b/pj_proto_app/src/plugin_registry.cpp @@ -1,415 +1,44 @@ #include "plugin_registry.hpp" -#include -#include - -#include "pj_marketplace/platform_utils.hpp" #include -#include -#include - -namespace proto { - -namespace { - -bool appendManifestEncodings(const nlohmann::json& value, std::vector& encodings, std::string& error) { - auto append_string = [&](const nlohmann::json& item) -> bool { - if (!item.is_string() || item.get().empty()) { - error = "'encoding' must contain non-empty strings"; - return false; - } - encodings.push_back(item.get()); - return true; - }; - - if (value.is_string()) { - return append_string(value); - } - if (value.is_array()) { - for (const auto& item : value) { - if (!append_string(item)) { - return false; - } - } - if (encodings.empty()) { - error = "'encoding' array must not be empty"; - return false; - } - return true; - } - error = "'encoding' must be a string or an array of strings"; - return false; -} +#include -} // namespace +namespace proto { PluginRegistry::PluginRegistry(std::string_view plugin_dir, PJ::DiagnosticSink sink) - : plugin_dir_(plugin_dir), sink_(std::move(sink)) {} - -void PluginRegistry::report(PJ::DiagnosticLevel level, const std::string& id, std::string message) const { - if (!sink_) { - return; - } - sink_(PJ::Diagnostic{level, "PluginRegistry", id, std::move(message), std::chrono::system_clock::now()}); -} - -bool PluginRegistry::loadAndRegisterDataSource(const std::filesystem::path& so_path) { - auto result = PJ::DataSourceLibrary::load(so_path.string()); - if (!result) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "DataSourceLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); - 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()); - if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() - || !manifest.contains("version") || !manifest["version"].is_string() - || manifest["version"].get().empty()) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "DataSource " + so_path.string() + ": embedded manifest missing required string fields 'id' and/or 'version'"); - return false; - } - loaded.id = manifest["id"].get(); - loaded.name = manifest.value("name", loaded.id); - loaded.version = manifest["version"].get(); - if (manifest.contains("file_extensions")) { - for (const auto& ext : manifest["file_extensions"]) { - loaded.file_extensions.push_back(ext.get()); - } - } - } catch (const nlohmann::json::exception& e) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - std::string("DataSource ") + so_path.string() + ": manifest parse failed: " + e.what()); - return false; - } - report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded DataSource " + loaded.name + " from " + loaded.path); - 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) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "MessageParserLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); - 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()); - if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() - || !manifest.contains("version") || !manifest["version"].is_string() - || manifest["version"].get().empty()) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "MessageParser " + so_path.string() + - ": embedded manifest missing required string fields 'id' and/or 'version'"); - return false; - } - loaded.id = manifest["id"].get(); - loaded.name = manifest.value("name", loaded.id); - loaded.version = manifest["version"].get(); - if (!manifest.contains("encoding")) { - report(PJ::DiagnosticLevel::kError, loaded.id, - "MessageParser " + so_path.string() + ": embedded manifest missing required key 'encoding'"); - return false; - } - std::string encoding_error; - if (!appendManifestEncodings(manifest["encoding"], loaded.encodings, encoding_error)) { - report(PJ::DiagnosticLevel::kError, loaded.id, "MessageParser " + so_path.string() + ": " + encoding_error); - return false; - } - } catch (const nlohmann::json::exception& e) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - std::string("MessageParser ") + so_path.string() + ": manifest parse failed: " + e.what()); - return false; - } - report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded MessageParser " + loaded.name + " from " + loaded.path); - 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) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "ToolboxLibrary::load failed for " + so_path.filename().string() + ": " + result.error()); - 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()); - if (!manifest.contains("id") || !manifest["id"].is_string() || manifest["id"].get().empty() - || !manifest.contains("version") || !manifest["version"].is_string() - || manifest["version"].get().empty()) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "Toolbox " + so_path.string() + ": embedded manifest missing required string fields 'id' and/or 'version'"); - return false; - } - loaded.id = manifest["id"].get(); - loaded.name = manifest.value("name", loaded.id); - loaded.version = manifest["version"].get(); - } catch (const nlohmann::json::exception& e) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - std::string("Toolbox ") + so_path.string() + ": manifest parse failed: " + e.what()); - return false; - } - report(PJ::DiagnosticLevel::kInfo, loaded.id, "Loaded Toolbox " + loaded.name + " from " + loaded.path); - toolbox_plugins_.push_back(std::move(loaded)); - return true; -} + : catalog_(std::filesystem::path(std::string(plugin_dir)), std::move(sink), "PluginRegistry") {} void PluginRegistry::scanDirectory() { - namespace fs = std::filesystem; - - std::error_code ec; - if (!fs::is_directory(plugin_dir_, ec)) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, - "Plugin directory not found: " + plugin_dir_ + " (" + ec.message() + ")"); - return; - } - - const std::string expected_ext = PJ::PlatformUtils::pluginExtension(); - - std::size_t walked = 0; - std::size_t matched = 0; - for (const auto& entry : fs::recursive_directory_iterator(plugin_dir_, ec)) { - ++walked; - if (!entry.is_regular_file() || entry.path().extension().string() != 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) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Failed to load plugin: " + entry.path().string()); - } - } - report(PJ::DiagnosticLevel::kInfo, /*id*/ {}, - "Plugin scan done: walked=" + std::to_string(walked) + " matched=" + std::to_string(matched) - + (ec ? std::string(" (iter ec=") + ec.message() + ")" : std::string{})); + catalog_.scanDirectory(); } void PluginRegistry::reload() { - namespace fs = std::filesystem; - - if (!fs::is_directory(plugin_dir_)) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Plugin directory not found: " + plugin_dir_); - 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)) { - report(PJ::DiagnosticLevel::kInfo, ds.id, "Unloaded DataSource (removed from disk): " + ds.path); - return true; - } - return false; - }); - std::erase_if(message_parsers_, [&](const LoadedMessageParser& mp) { - if (is_gone(mp.path)) { - report(PJ::DiagnosticLevel::kInfo, mp.id, "Unloaded MessageParser (removed from disk): " + mp.path); - return true; - } - return false; - }); - std::erase_if(toolbox_plugins_, [&](const LoadedToolbox& tb) { - if (is_gone(tb.path)) { - report(PJ::DiagnosticLevel::kInfo, tb.id, "Unloaded Toolbox (removed from disk): " + tb.path); - 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; - } - report(PJ::DiagnosticLevel::kInfo, ds_it->id, "Reloading updated DataSource: " + path_str); - 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; - } - report(PJ::DiagnosticLevel::kInfo, mp_it->id, "Reloading updated MessageParser: " + path_str); - 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; - } - report(PJ::DiagnosticLevel::kInfo, tb_it->id, "Reloading updated Toolbox: " + path_str); - toolbox_plugins_.erase(tb_it); - } - } - } - - if (!loadAndRegisterDataSource(so_path) && - !loadAndRegisterMessageParser(so_path) && - !loadAndRegisterToolbox(so_path)) { - report(PJ::DiagnosticLevel::kError, /*id*/ {}, "Failed to load plugin: " + path_str); - } - } + (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); - } - } - } - - // Build JSON array - std::string json = "["; - for (size_t i = 0; i < unique_encodings.size(); ++i) { - if (i > 0) { - json += ","; - } - json += "\"" + unique_encodings[i] + "\""; - } - json += "]"; - return json; + return catalog_.listAvailableEncodings(); } QMap PluginRegistry::loadedExtensionsSnapshot() const { @@ -433,13 +62,13 @@ QMap PluginRegistry::loadedExtensionsSnapshot() snapshot.insert(qid, record); }; - for (const auto& ds : data_sources_) { - add_loaded(ds.id, ds.version, ds.path); + for (const auto& source : catalog_.dataSources()) { + add_loaded(source.id, source.version, source.path); } - for (const auto& parser : message_parsers_) { + for (const auto& parser : catalog_.messageParsers()) { add_loaded(parser.id, parser.version, parser.path); } - for (const auto& toolbox : toolbox_plugins_) { + for (const auto& toolbox : catalog_.toolboxes()) { add_loaded(toolbox.id, toolbox.version, toolbox.path); } diff --git a/pj_proto_app/src/plugin_registry.hpp b/pj_proto_app/src/plugin_registry.hpp index 3e1e5fb..1151074 100644 --- a/pj_proto_app/src/plugin_registry.hpp +++ b/pj_proto_app/src/plugin_registry.hpp @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include #include @@ -11,92 +10,55 @@ #include "pj_base/diagnostic_sink.hpp" #include "pj_marketplace/installed_extension.hpp" -#include "pj_plugins/host/data_source_library.hpp" -#include "pj_plugins/host/message_parser_library.hpp" -#include "pj_plugins/host/toolbox_library.hpp" +#include "pj_plugins/host/plugin_runtime_catalog.hpp" namespace proto { -struct LoadedDataSource { - PJ::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; -}; - -struct LoadedMessageParser { - PJ::MessageParserLibrary library; - std::string path; - std::string name; - std::string id; - std::string version; - std::vector encodings; - std::filesystem::file_time_type loaded_mtime; -}; - -struct LoadedToolbox { - PJ::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; -}; +using LoadedDataSource = PJ::RuntimeDataSourcePlugin; +using LoadedMessageParser = PJ::RuntimeMessageParserPlugin; +using LoadedToolbox = PJ::RuntimeToolboxPlugin; +// Proto-app compatibility wrapper over PJ::PluginRuntimeCatalog. class PluginRegistry { public: - /// `sink` (optional) receives info/warning/error events about plugin load - /// lifecycle. If unset, events are silently discarded — useful for tests - /// that don't care. + // 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(); } - /// Build a marketplace-style installed snapshot from loaded plugin manifests. + // Builds a marketplace-style installed snapshot from loaded manifests. [[nodiscard]] QMap loadedExtensionsSnapshot() const; private: - /// Try to load a DataSource plugin and register it. Returns true on success. - bool loadAndRegisterDataSource(const std::filesystem::path& so_path); - - /// 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); - - /// Forwards a one-shot diagnostic to sink_ if it is set. - void report(PJ::DiagnosticLevel level, const std::string& id, std::string message) const; - - std::string plugin_dir_; - PJ::DiagnosticSink sink_; - std::vector data_sources_; - std::vector message_parsers_; - std::vector toolbox_plugins_; + PJ::PluginRuntimeCatalog catalog_; }; } // namespace proto diff --git a/pj_proto_app/src/qt_diagnostic_bridge.cpp b/pj_proto_app/src/qt_diagnostic_bridge.cpp deleted file mode 100644 index 94afc86..0000000 --- a/pj_proto_app/src/qt_diagnostic_bridge.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include "qt_diagnostic_bridge.hpp" - -#include -#include -#include - -namespace proto { - -QtDiagnosticBridge::QtDiagnosticBridge(QObject* parent) : QObject(parent) {} - -PJ::DiagnosticSink QtDiagnosticBridge::sink() { - // Capture a QPointer so a queued event firing after `this` is destroyed - // becomes a no-op instead of a use-after-free. - QPointer guard(this); - return [guard](const PJ::Diagnostic& d) { - if (!guard) { - return; - } - QMetaObject::invokeMethod( - guard.data(), - [guard, d]() { - if (!guard) { - return; - } - emit guard->diagnosticReported( - static_cast(d.level), QString::fromStdString(d.source), QString::fromStdString(d.id), - QString::fromStdString(d.message)); - }, - Qt::QueuedConnection); - }; -} - -} // namespace proto diff --git a/pj_proto_app/src/qt_diagnostic_bridge.hpp b/pj_proto_app/src/qt_diagnostic_bridge.hpp deleted file mode 100644 index dca38f5..0000000 --- a/pj_proto_app/src/qt_diagnostic_bridge.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -// QtDiagnosticBridge — adapter that exposes a thread-safe PJ::DiagnosticSink -// returning a Qt signal callers can connect to. Lets non-Qt modules surface -// diagnostics to a Qt GUI without depending on Qt themselves. - -#include -#include - -#include "pj_base/diagnostic_sink.hpp" - -namespace proto { - -class QtDiagnosticBridge : public QObject { - Q_OBJECT - - public: - explicit QtDiagnosticBridge(QObject* parent = nullptr); - - /// Returns a sink that marshals every event onto this object's Qt thread via - /// a queued connection, then emits diagnosticReported. Safe to call from any - /// thread; safe to outlive this bridge (the lambda holds a QPointer). - PJ::DiagnosticSink sink(); - - signals: - void diagnosticReported(int level, QString source, QString id, QString message); -}; - -} // namespace proto From fce4a45095dc679a5536e05f8afbda34aa11ea48 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 26 Apr 2026 20:49:51 +0200 Subject: [PATCH 12/12] fix(ci): unbreak Windows MSVC build and Linux extension_manager_test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated CI failures on PR #74, both fixed here. 1) Windows CI: pj_base/plugin_abi_export.h failed with error C2496: '__declspec(selectany)' can only be applied to data items with external linkage. Root cause: a namespace-scope const variable has internal linkage by default in C++. extern "C" only sets language linkage (name mangling), not internal/external linkage, so 'extern "C" const T x = val;' is still internal-linkage in C++ — and MSVC refuses selectany on internal-linkage data. GCC happily accepted it but the construct was technically broken there too (weak fold relies on external linkage). Fix: drop const (the symbol is host-read once via dlsym during ABI handshake; nothing inside any plugin DSO writes to it) and use the block form 'extern "C" { ... }' so GCC does not parse the declaration as extern-with-initializer and trip -Wextern-initializer. 2) Linux CI: 4 ExtensionManagerTest cases (UpdateReinstallsWithNewVersion, UpdateBacksUpOldVersionOnSuccess, UpdateKeepsBackupWhenInstallFails, ApplyPendingInstallsBacksUpExistingExtensionBeforePromotion) failed at ASSERT_TRUE(local_ext_dir.isValid()). Root cause: each test constructs a QTemporaryDir under PlatformUtils::configDir() (= ~/.local/share/plotjuggler/) so that QDir::rename() into backupDir() is a same-filesystem atomic move. QTemporaryDir refuses to create itself when the parent directory does not exist — exactly the state of a fresh CI runner. The tests passed locally only because that directory already existed from regular use. Fix: SetUp() now QDir().mkpath(PlatformUtils::configDir()) before constructing the fixture's temp dirs, so the precondition is explicit instead of inherited from host filesystem state. Verified: full ctest (52/52) passes both with the existing config dir and under a fresh XDG_DATA_HOME=\$(mktemp -d) simulating the CI runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- pj_base/include/pj_base/plugin_abi_export.h | 17 ++++++++++++++++- pj_marketplace/tests/extension_manager_test.cpp | 6 ++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/pj_base/include/pj_base/plugin_abi_export.h b/pj_base/include/pj_base/plugin_abi_export.h index 53a6eb9..59fdfea 100644 --- a/pj_base/include/pj_base/plugin_abi_export.h +++ b/pj_base/include/pj_base/plugin_abi_export.h @@ -15,6 +15,19 @@ // 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 @@ -22,7 +35,9 @@ __attribute__((visibility("default"))) __attribute__((weak)) __attribute__((used)) #endif -extern "C" PJ_PLUGIN_ABI_LINK const uint32_t pj_plugin_abi_version = PJ_ABI_VERSION; +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 diff --git a/pj_marketplace/tests/extension_manager_test.cpp b/pj_marketplace/tests/extension_manager_test.cpp index 0d035c8..095219b 100644 --- a/pj_marketplace/tests/extension_manager_test.cpp +++ b/pj_marketplace/tests/extension_manager_test.cpp @@ -220,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();