diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index ea081a23aa..ad1242e4f9 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -602,15 +602,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); @@ -654,7 +656,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; @@ -705,7 +707,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"); @@ -1233,7 +1235,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"); @@ -1370,6 +1372,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 23cf7a2648..c05d1839bc 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; @@ -246,6 +247,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; @@ -268,7 +270,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) @@ -281,11 +284,12 @@ 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) { @@ -294,7 +298,7 @@ PluginAvailableActions evaluate_action_policy(const PluginDialogItem& item) // Owned cloud plugins fall through to the local delete: it removes the installed package only. // Deleting a plugin from the cloud is a plugin hub operation and is never offered here. - if (is_cloud && !is_mine) { + if (is_cloud && !is_orphaned && !is_mine) { add_action("unsubscribe_plugin", "Unsubscribe", true, true); } else if (has_local) { add_action("delete_plugin", "Delete", true, true); @@ -302,11 +306,13 @@ PluginAvailableActions evaluate_action_policy(const PluginDialogItem& item) add_action("open_folder", "Show in folder", has_local); - if (is_cloud) { - add_action("reinstall_plugin", "Reinstall"); - } else { - add_action("reload_plugin", "Reload"); - add_action("clear_cache_reload_plugin", "Delete cache and reload"); + if (!is_orphaned) { + if (is_cloud) { + add_action("reinstall_plugin", "Reinstall"); + } else { + add_action("reload_plugin", "Reload"); + add_action("clear_cache_reload_plugin", "Delete cache and reload"); + } } return available_actions; @@ -360,6 +366,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 b7792e2e31..b0d2d214c3 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 @@ -106,6 +107,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 37d5651a52..021543105a 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -1422,7 +1422,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()) @@ -1437,9 +1438,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. @@ -1448,19 +1450,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 ec8b61dea9..c34b596ee0 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 @@ -48,6 +49,18 @@ const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script # version = "1.0" # /// print('ok') + +import orca +class stubscript(orca.script.ScriptPluginCapabilityBase): + def get_name(self): + return "stubscript" + def execute(self): + return orca.ExecutionResult.success("Stub orca script.") + +@orca.plugin +class stubpackage(orca.base): + def register_capabilities(self): + orca.register_capability(stubscript) )PY"; } // namespace @@ -152,4 +165,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); }