diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index cd690ef8b1..183f29b25f 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -601,15 +601,17 @@ function SourceLabel(source) { return "Mine"; case "subscribed": return "Subscribed"; + case "orphaned": + return "Orphaned"; default: return "Local"; } } -// Shared Local/Subscribed/Mine pill, used both after the row name and in the info panel. +// Shared source pill, used both after the row name and in the info panel. function SourceBadge(source) { const normalized = String(source || "").toLowerCase(); - const variant = (normalized === "mine" || normalized === "subscribed") ? normalized : "local"; + const variant = (normalized === "mine" || normalized === "subscribed" || normalized === "orphaned") ? normalized : "local"; const badge = document.createElement("span"); badge.className = `plugin-source-badge source-${variant}`; badge.textContent = SourceLabel(source); @@ -653,7 +655,7 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges = const labelCell = document.createElement("span"); labelCell.className = "label-cell"; - const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed"; + const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed" || plugin.source === "orphaned"; const pluginLabelText = plugin.label || plugin.name || plugin.plugin_id || ""; const canExpand = capabilityCount > 0; @@ -704,7 +706,7 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges = function SourceCell(plugin) { const cell = document.createElement("span"); const normalized = String(plugin.source || "").toLowerCase(); - const variant = (normalized === "mine" || normalized === "subscribed") ? normalized : "local"; + const variant = (normalized === "mine" || normalized === "subscribed" || normalized === "orphaned") ? normalized : "local"; cell.className = `source-cell source-${variant}`; const sourceLabel = document.createElement("span"); @@ -1265,7 +1267,7 @@ function RenderDescription(plugin) { return; } - const isCloud = plugin && (plugin.source === "mine" || plugin.source === "subscribed"); + const isCloud = plugin && (plugin.source === "mine" || plugin.source === "subscribed" || plugin.source === "orphaned"); if (isCloud && String(plugin?.sharing_token || "")) { node.appendChild(document.createTextNode("View on OrcaCloud ")); const link = document.createElement("a"); @@ -1402,6 +1404,13 @@ function RenderDetailSummary(container, plugin) { message.textContent = errorText || StatusDescription(plugin); container.appendChild(message); + if (plugin.orphaned === true) { + const warning = document.createElement("div"); + warning.className = "detail-description detail-warning-text"; + warning.textContent = "Orphaned: This plugin is no longer subscribed or available in OrcaCloud. The local copy remains installed and can still be used."; + container.appendChild(warning); + } + const updateStatus = GetUpdateStatus(plugin); if (updateStatus === "update_available") { const note = document.createElement("div"); diff --git a/resources/web/dialog/PluginsDialog/styles.css b/resources/web/dialog/PluginsDialog/styles.css index b1c3caa055..3d8c18be9e 100644 --- a/resources/web/dialog/PluginsDialog/styles.css +++ b/resources/web/dialog/PluginsDialog/styles.css @@ -251,6 +251,11 @@ body.pane-resizing { font-weight: 600; } +.source-cell.source-orphaned { + color: var(--plugin-status-warn); + font-weight: 600; +} + .source-cell.source-local { color: var(--plugin-source-neutral-text); } @@ -648,6 +653,10 @@ body.pane-resizing { color: var(--plugin-status-danger); } +.detail-warning-text { + color: var(--plugin-status-warn); +} + .detail-status-chip { display: inline-flex; align-items: center; @@ -915,6 +924,11 @@ body.pane-resizing { color: var(--plugin-source-subscribed-text); } +.plugin-source-badge.source-orphaned { + background: var(--plugin-status-warn-bg); + color: var(--plugin-status-warn); +} + .plugin-cloud-link { color: var(--plugin-link-text); cursor: pointer; diff --git a/src/slic3r/GUI/PluginSource.hpp b/src/slic3r/GUI/PluginSource.hpp index ee1464c8df..8742153c9e 100644 --- a/src/slic3r/GUI/PluginSource.hpp +++ b/src/slic3r/GUI/PluginSource.hpp @@ -11,6 +11,7 @@ namespace Slic3r // IMPORTANT: ordinal order is the Plugins dialog Source sort priority. Mine, Subscribed, + Orphaned, Local }; @@ -20,6 +21,7 @@ namespace Slic3r { case PluginSource::Mine: return "mine"; case PluginSource::Subscribed: return "subscribed"; + case PluginSource::Orphaned: return "orphaned"; case PluginSource::Local: return "local"; } diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 3011c35ce3..a79b134b35 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -103,6 +103,7 @@ struct PluginDialogItem bool loading = false; bool is_cloud_plugin = false; + bool orphaned = false; bool has_local_package = false; bool unauthorized = false; bool has_script_capability = false; @@ -243,6 +244,7 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item) payload_item["sharing_token"] = dialog_item.sharing_token; payload_item["thumbnail_url"] = dialog_item.thumbnail_url; payload_item["installed"] = dialog_item.has_local_package; + payload_item["orphaned"] = dialog_item.orphaned; payload_item["installed_version"] = dialog_item.installed_version; payload_item["latest_version"] = dialog_item.latest_version; return payload_item; @@ -265,7 +267,8 @@ PluginSource derive_plugin_source(const PluginDescriptor& descriptor) const bool is_cloud = descriptor.is_cloud_plugin(); const bool is_mine = is_cloud && has_cloud_meta && descriptor.cloud->is_mine; - // Source is ownership/locality only; issue states never replace this badge. + if (is_cloud && has_cloud_meta && descriptor.cloud->orphaned) + return PluginSource::Orphaned; if (is_mine) return PluginSource::Mine; if (is_cloud) @@ -278,29 +281,33 @@ PluginAvailableActions evaluate_action_policy(const PluginDialogItem& item) PluginAvailableActions available_actions; const bool is_loading = item.status == PluginStatus::Loading; const bool is_cloud = item.is_cloud_plugin; + const bool is_orphaned = item.orphaned; const bool is_mine = item.source == PluginSource::Mine; const bool has_local = item.has_local_package; const bool authorized_for_install = !item.unauthorized; - available_actions.toggle_installs_cloud_plugin = is_cloud && !has_local && authorized_for_install; + available_actions.toggle_installs_cloud_plugin = is_cloud && !is_orphaned && !has_local && authorized_for_install; available_actions.can_toggle = !is_loading && (has_local || available_actions.toggle_installs_cloud_plugin); auto add_action = [&available_actions](const char* id, const char* label, bool enabled = true, bool danger = false) { available_actions.context_actions.push_back(PluginContextAction{id, label, enabled, danger}); }; - if (is_cloud) { + if (is_cloud && !is_orphaned) { if (is_mine) add_action("delete_mine_plugin", "Delete", true, true); else add_action("unsubscribe_plugin", "Unsubscribe", true, true); + } else if (is_orphaned && has_local) { + add_action("delete_plugin", "Delete", true, true); } else if (has_local) { add_action("delete_plugin", "Delete", true, true); } add_action("open_folder", "Show in folder", has_local); - add_action("reinstall_plugin", "Reinstall"); + if (!is_orphaned) + add_action("reinstall_plugin", "Reinstall"); return available_actions; } @@ -353,6 +360,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor) item.error_text = descriptor.normalized_error(); item.has_error = descriptor.has_error(); item.is_cloud_plugin = descriptor.is_cloud_plugin(); + item.orphaned = descriptor.cloud.has_value() && descriptor.cloud->orphaned; item.has_local_package = descriptor.has_local_package(); item.unauthorized = descriptor.is_unauthorized(); item.is_loaded = manager.is_plugin_loaded(descriptor.plugin_key); diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index 28c411b176..f98292f0fe 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -20,6 +20,7 @@ struct CloudPluginState bool update_available = false; // Cloud version > the local package version. bool unauthorized = false; // Cloud plugin is valid locally, but cannot receive cloud updates. bool is_mine = false; // Plugin was created (and uploaded) by the current user. + bool orphaned = false; // Cloud identity remains locally, but the plugin is no longer subscribed/available. }; enum class PluginUpdateStatus @@ -102,6 +103,8 @@ struct PluginDescriptor { if (!cloud.has_value()) return PluginUpdateStatus::Normal; + if (cloud->orphaned) + return PluginUpdateStatus::Normal; if (cloud->unauthorized) return PluginUpdateStatus::Unauthorized; if (cloud->update_available) diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index c586aafe99..0d66a03075 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -1420,7 +1420,8 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f std::vector cloud_list{}; std::vector not_found{}, unauthorized{}; - if (!m_cloud_service.fetch_manifests_into_descriptors(cloud_list, not_found, unauthorized)) { + const bool cloud_fetch_succeeded = m_cloud_service.fetch_manifests_into_descriptors(cloud_list, not_found, unauthorized); + if (!cloud_fetch_succeeded) { if (wxTheApp != nullptr) { GUI::wxGetApp().CallAfter([] { if (GUI::wxGetApp().is_closing()) @@ -1435,9 +1436,10 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f } } - update_cloud_metadata(cloud_list); + if (cloud_fetch_succeeded) + update_cloud_metadata(cloud_list); - { + if (cloud_fetch_succeeded) { std::lock_guard lock(m_mutex); // Clear the previous cloud verdicts before re-applying the fresh ones. @@ -1446,19 +1448,28 @@ void PluginManager::fetch_plugins_from_cloud(std::vector* out_not_f if (!entry.is_cloud_plugin()) continue; entry.set_unauthorized(false); + if (entry.cloud.has_value()) + entry.cloud->orphaned = false; if (entry.normalized_error() == CLOUD_PLUGIN_NOT_FOUND_ERROR) entry.clear_error(); } - for (const std::string& uuid : not_found) { - for (Plugin& plugin : m_plugins) { - PluginDescriptor& entry = plugin.descriptor; - if (!entry.is_cloud_plugin() || entry.cloud_uuid() != uuid) - continue; - if (!entry.has_local_package()) - entry.set_error(CLOUD_PLUGIN_NOT_FOUND_ERROR); - break; - } + // A successful subscriptions response may report missing UUIDs explicitly, or it may + // simply omit an unsubscribed plugin from `data`. Both cases leave a locally retained + // cloud package orphaned. Owned plugins are returned by the separate mine endpoint and + // must not be orphaned merely because they are not subscribed. + for (Plugin& plugin : m_plugins) { + PluginDescriptor& entry = plugin.descriptor; + if (!entry.is_cloud_plugin() || entry.cloud->is_mine) + continue; + + const bool explicitly_not_found = std::find(not_found.begin(), not_found.end(), entry.cloud_uuid()) != not_found.end(); + const bool returned_by_cloud = std::any_of(cloud_list.begin(), cloud_list.end(), [&entry](const PluginDescriptor& cloud_entry) { + return cloud_entry.cloud_uuid() == entry.cloud_uuid(); + }); + entry.cloud->orphaned = explicitly_not_found || !returned_by_cloud; + if (entry.cloud->orphaned) + entry.cloud->update_available = false; } for (const std::string& uuid : unauthorized) { diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp index 6f438541ff..b8f0a0fcd5 100644 --- a/tests/slic3rutils/test_plugin_cloud_metadata.cpp +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -129,4 +130,31 @@ TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginC reloaded.load(); REQUIRE(reloaded.has_config(id)); CHECK(reloaded.get_config(id)->config == configured); + + // A local package can remain after the cloud subscription disappears. The cloud identity is + // retained for diagnosis, but the orphaned state must suppress update availability until the + // plugin is returned by a later cloud refresh. + PluginDescriptor orphaned_record = cloud_record; + orphaned_record.cloud->orphaned = true; + orphaned_record.cloud->update_available = true; + manager.update_cloud_metadata({orphaned_record}); + + const PluginDescriptor orphaned = find_by_uuid(); + REQUIRE(orphaned.cloud.has_value()); + CHECK(orphaned.cloud->orphaned); + CHECK_FALSE(orphaned.has_error()); + CHECK(orphaned.get_update_status() == PluginUpdateStatus::Normal); + + // Orphaned is informational only: the local package must remain loadable and usable. + std::string load_error; + manager.load_plugin(uuid, /*skip_deps=*/true); + REQUIRE(manager.wait_for_plugin_load(uuid, std::chrono::seconds(120), load_error)); + INFO("load error: " << load_error); + CHECK(load_error.empty()); + CHECK(manager.is_plugin_loaded(uuid)); + CHECK(manager.unload_plugin(uuid)); + + // Seeing the plugin in a subsequent cloud response clears the orphaned marker. + manager.update_cloud_metadata({cloud_record}); + CHECK_FALSE(find_by_uuid().cloud->orphaned); }