diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 6d0b66ec01..312c4f6731 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -1198,7 +1198,11 @@ std::string GUI_App::get_plugin_url(std::string name, std::string country_code) curr_version = get_latest_network_version(); } - std::string using_version = curr_version.substr(0, 9) + "00"; + // The cloud endpoint is series-keyed and serves the series' newest build: AA.BB.CC.00. + // Build it from the series so an 8-char series string (02.08.01) works too. + std::string using_version = use_legacy_network_plugin() + ? curr_version.substr(0, 9) + "00" + : network_plugin_series(curr_version) + ".00"; if (name == "cameratools") using_version = curr_version.substr(0, 6) + "00.00"; url += (boost::format("?slicer/%1%/cloud=%2%") % name % using_version).str(); @@ -1256,14 +1260,13 @@ int GUI_App::download_plugin(std::string name, std::string package_name, Install // get_url std::string url = get_plugin_url(name, app_config->get_country_code()); std::string download_url; - std::string online_version; Slic3r::Http http_url = Slic3r::Http::get(url); BOOST_LOG_TRIVIAL(info) << "[download_plugin]: check the plugin from " << url; http_url.timeout_connect(TIMEOUT_CONNECT) .timeout_max(TIMEOUT_RESPONSE) .header("X-BBL-OS-Type", os_type) .on_complete( - [&download_url, &online_version](std::string body, unsigned status) { + [&download_url](std::string body, unsigned status) { try { json j = json::parse(body); std::string message = j["message"].get(); @@ -1296,7 +1299,6 @@ int GUI_App::download_plugin(std::string name, std::string package_name, Install } BOOST_LOG_TRIVIAL(info) << "[download_plugin 1]: get type " << type << ", version " << version.to_string() << ", url " << url; download_url = url; - online_version = version_str; } } } @@ -1387,25 +1389,10 @@ int GUI_App::download_plugin(std::string name, std::string package_name, Install }); http.perform_sync(); - // The cloud endpoint serves the newest build of the requested series, which may be - // newer than the configured version. Adopt the actual downloaded version so that - // install_plugin() names the library after what it really contains - otherwise the - // post-load config sync (on_init_network) points at a file name that does not exist - // and the plugin is "lost" on the following launch. Legacy mode is left untouched: - // is_legacy_version() matches exactly and drives the legacy struct layout. - if (result >= 0 && name == "plugins" && !online_version.empty() && !use_legacy_network_plugin()) { - std::string configured = app_config->get_network_plugin_version(); - if (configured.empty()) - configured = get_latest_network_version(); - if (configured != online_version - && configured.size() >= 8 && online_version.size() >= 8 - && configured.compare(0, 8, online_version, 0, 8) == 0) { - BOOST_LOG_TRIVIAL(info) << "[download_plugin] server returned " << online_version - << " for configured " << configured << ", adopting it"; - app_config->set_network_plugin_version(online_version); - } - } - + // No version adoption: the stored identity is the AA.BB.CC series, so install_plugin() names + // the library after the configured series regardless of which build the series-keyed endpoint + // served (02.08.01.53). The series config never diverges from the file name, so there is + // nothing to adopt. j["result"] = result < 0 ? "failed" : "success"; j["error_msg"] = err_msg; return result; @@ -1770,6 +1757,53 @@ bool GUI_App::wait_for_network_idle(int timeout_ms) return false; } +void GUI_App::migrate_network_plugin_config() +{ + if (!app_config) + return; + + const std::string cfg = app_config->get_network_plugin_version(); + if (!is_series_managed_version(cfg) || !is_supported_network_version(cfg)) + return; // empty / legacy / custom-named / unsupported old series -> nothing to migrate here + // (an unsupported old config is handled by the fallback in on_init_network) + + const std::string series = network_plugin_series(cfg); + if (cfg != series) { + app_config->set_network_plugin_version(series); + app_config->save(); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": network_plugin_version " << cfg << " -> " << series; + } + + // Consolidate the on-disk files onto the series name. Runs at startup before the plug-in is + // loaded, so the rename is safe even on Windows (nothing holds the file open yet). If it is + // skipped or fails, resolve_library_path() still loads the specific build for this series. + std::string newest; + for (const auto& v : BBLNetworkPlugin::scan_plugin_versions()) + if (is_series_managed_version(v) && network_plugin_series(v) == series && (newest.empty() || v > newest)) + newest = v; + if (newest.empty()) + return; + + boost::system::error_code ec; + const boost::filesystem::path series_path(BBLNetworkPlugin::get_versioned_library_path(series)); + if (newest != series) { + // Make the newest same-series build the series file. Safe here (pre-load); if the rename + // fails, resolve_library_path() still loads the specific build for this series. + boost::filesystem::remove(series_path, ec); + boost::filesystem::rename(BBLNetworkPlugin::get_versioned_library_path(newest), series_path, ec); + if (ec) + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": rename " << newest << " -> " << series + << " failed (" << ec.message() << "), loader will resolve it"; + } + + // Tidy strictly-older same-series managed builds (best-effort; never touch custom/legacy). + for (const auto& v : BBLNetworkPlugin::scan_plugin_versions()) { + if (v == series || !is_series_managed_version(v) || network_plugin_series(v) != series) + continue; + boost::filesystem::remove(BBLNetworkPlugin::get_versioned_library_path(v), ec); + } +} + bool GUI_App::hot_reload_network_plugin() { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": starting hot reload"; @@ -3590,6 +3624,9 @@ bool GUI_App::on_init_network(bool try_backup) auto should_load_networking_plugin = app_config->get_bool("installed_networking"); + // Normalize an older full-version identity to the AA.BB.CC series before it drives loading. + migrate_network_plugin_config(); + std::string config_version = app_config->get_network_plugin_version(); if (should_load_networking_plugin) { @@ -3627,12 +3664,18 @@ bool GUI_App::on_init_network(bool try_backup) std::string loaded_version = Slic3r::NetworkAgent::get_version(); if (app_config && !loaded_version.empty() && loaded_version != "00.00.00.00") { + // Self-heal only when a genuinely different series loaded than configured (e.g. the + // configured build was unavailable and a fallback loaded). Within a series the + // loaded build (02.08.01.53) differs from the series config (02.08.01) by design, so + // compare series, not the raw string, and store the managed series form - a custom + // config (02.08.01_custom) keeps its own name. std::string config_version = app_config->get_network_plugin_version(); - std::string config_base = extract_base_version(config_version); - if (config_base != loaded_version) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": syncing config version from " << config_version << " to loaded " - << loaded_version; - app_config->set_network_plugin_version(loaded_version); + std::string loaded_series = network_plugin_series(loaded_version); + if (network_plugin_series(config_version) != loaded_series) { + std::string synced = is_series_managed_version(loaded_version) ? loaded_series : loaded_version; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": syncing config version from " << config_version + << " to loaded " << loaded_version << " (stored as " << synced << ")"; + app_config->set_network_plugin_version(synced); app_config->save(); } } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 186693dc4b..c90e58887e 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -763,6 +763,10 @@ public: void check_config_updates_from_updater() { check_updates(false); } void show_network_plugin_download_dialog(bool is_update = false); + // One-time normalization of an older full-version identity (config 02.08.01.53 + file + // ..._02.08.01.53.dylib) to the AA.BB.CC series form, with no re-download. Runs at startup + // before the plug-in is loaded. + void migrate_network_plugin_config(); bool hot_reload_network_plugin(); bool install_network_plugin_from_ota(bool& had_cache); std::string get_latest_network_version() const; diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 18fab4938c..2b5787c760 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -1273,55 +1273,79 @@ wxBoxSizer *PreferencesDialog::create_item_network_plugin_version(wxString title m_sizer->Add(m_network_version_combo, 0, wxALIGN_CENTER); m_network_version_combo->GetDropDown().Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& e) { + e.Skip(); // order-independent flag read after this handler returns; every path just returns int selection = e.GetSelection(); - if (selection >= 0 && selection < (int)m_available_versions.size()) { - const auto& selected_ver = m_available_versions[selection]; - std::string new_version = selected_ver.version; - std::string old_version = app_config->get_network_plugin_version(); - if (old_version.empty()) { - old_version = get_latest_network_version(); - } + if (selection < 0 || selection >= (int) m_available_versions.size()) + return; - app_config->set_network_plugin_version(new_version); - app_config->save(); + const auto& selected_ver = m_available_versions[selection]; + const std::string new_version = selected_ver.version; + std::string old_version = app_config->get_network_plugin_version(); + if (old_version.empty()) + old_version = get_latest_network_version(); - if (new_version != old_version) { - BOOST_LOG_TRIVIAL(info) << "Network plugin version changed from " << old_version << " to " << new_version; - - if (!selected_ver.warning.empty()) { - MessageDialog warn_dlg(this, wxString::FromUTF8(selected_ver.warning), _L("Warning"), wxOK | wxCANCEL | wxICON_WARNING); - if (warn_dlg.ShowModal() != wxID_OK) { - app_config->set_network_plugin_version(old_version); - app_config->save(); - e.Skip(); - return; - } + // Move the combo back to the row for `version`, so the UI never shows a build other + // than the one actually configured/loaded (e.g. after a declined or refused switch). + auto reselect = [this](const std::string& version) { + for (size_t i = 0; i < m_available_versions.size(); ++i) + if (m_available_versions[i].version == version) { + m_network_version_combo->SetSelection((int) i); + break; } + }; - // Check if the selected version already exists on disk - if (Slic3r::NetworkAgent::versioned_library_exists(new_version)) { - BOOST_LOG_TRIVIAL(info) << "Version " << new_version << " already exists on disk, triggering hot reload"; - if (wxGetApp().hot_reload_network_plugin()) { - MessageDialog dlg(this, _L("Network plug-in switched successfully."), _L("Success"), wxOK | wxICON_INFORMATION); - dlg.ShowModal(); - } else { - MessageDialog dlg(this, _L("Failed to load network plug-in. Please restart the application."), _L("Restart Required"), wxOK | wxICON_WARNING); - dlg.ShowModal(); - } - } else { - wxString msg = wxString::Format( - _L("You've selected network plug-in version %s.\n\nWould you like to download and install this version now?\n\nNote: The application may need to restart after installation."), - wxString::FromUTF8(new_version)); + if (new_version == old_version) + return; - MessageDialog dlg(this, msg, _L("Download Network Plug-in"), wxYES_NO | wxICON_QUESTION); - if (dlg.ShowModal() == wxID_YES) { - DownloadProgressDialog progress_dlg(_L("Downloading Network Plug-in")); - progress_dlg.ShowModal(); - } - } + BOOST_LOG_TRIVIAL(info) << "Network plugin version selection changed from " << old_version << " to " << new_version; + + if (!selected_ver.warning.empty()) { + MessageDialog warn_dlg(this, wxString::FromUTF8(selected_ver.warning), _L("Warning"), wxOK | wxCANCEL | wxICON_WARNING); + if (warn_dlg.ShowModal() != wxID_OK) { + reselect(old_version); + return; } } - e.Skip(); + + // A build already present on disk loads directly with a hot reload - on any platform + // and across series (legacy <-> modern). Only claim success once the build that + // actually loaded is the one that was requested. + if (Slic3r::NetworkAgent::versioned_library_exists(new_version)) { + app_config->set_network_plugin_version(new_version); + app_config->save(); + BOOST_LOG_TRIVIAL(info) << "Version " << new_version << " already exists on disk, triggering hot reload"; + // Claim success only once the series that actually loaded is the one requested - the + // loaded plug-in reports its full build (02.08.01.53) while the requested identity is + // the series (02.08.01), so compare series, not the raw string. + if (wxGetApp().hot_reload_network_plugin() && + network_plugin_series(Slic3r::NetworkAgent::get_version()) == network_plugin_series(new_version)) { + MessageDialog dlg(this, _L("Network plug-in switched successfully."), _L("Success"), wxOK | wxICON_INFORMATION); + dlg.ShowModal(); + } else { + MessageDialog dlg(this, _L("Failed to load network plug-in. Please restart the application."), _L("Restart Required"), wxOK | wxICON_WARNING); + dlg.ShowModal(); + reselect(app_config->get_network_plugin_version()); + } + return; + } + + // Not on disk: offer to download it. The endpoint is series-keyed and serves that series' + // newest build. (A same-series custom build is only ever listed when its file is on disk, + // so it takes the hot-reload branch above; the only not-on-disk selectable is a series or + // legacy entry that genuinely needs fetching.) + wxString msg = wxString::Format( + _L("You've selected network plug-in version %s.\n\nWould you like to download and install this version now?\n\nNote: The application may need to restart after installation."), + wxString::FromUTF8(new_version)); + MessageDialog dlg(this, msg, _L("Download Network Plug-in"), wxYES_NO | wxICON_QUESTION); + if (dlg.ShowModal() == wxID_YES) { + app_config->set_network_plugin_version(new_version); + app_config->save(); + DownloadProgressDialog progress_dlg(_L("Downloading Network Plug-in")); + progress_dlg.ShowModal(); + reselect(app_config->get_network_plugin_version()); + } else { + reselect(old_version); + } }); auto reload_btn = new Button(m_parent, wxEmptyString, "refresh", 0, 16); diff --git a/src/slic3r/Utils/BBLNetworkPlugin.cpp b/src/slic3r/Utils/BBLNetworkPlugin.cpp index 630ec98086..b0dd277a79 100644 --- a/src/slic3r/Utils/BBLNetworkPlugin.cpp +++ b/src/slic3r/Utils/BBLNetworkPlugin.cpp @@ -100,17 +100,18 @@ int BBLNetworkPlugin::initialize(bool using_backup, const std::string& version) } } - // Load versioned library + // Load versioned library. In the normal plugins folder a bare series (02.08.01) resolves to + // whatever same-series build is actually on disk (see resolve_library_path); the backup + // folder keeps the exact versioned name. #if defined(_MSC_VER) || defined(_WIN32) - library = plugin_folder.string() + "\\" + std::string(BAMBU_NETWORK_LIBRARY) + "_" + version + ".dll"; + std::string versioned_name = std::string(BAMBU_NETWORK_LIBRARY) + "_" + version + ".dll"; +#elif defined(__WXMAC__) + std::string versioned_name = std::string("lib") + std::string(BAMBU_NETWORK_LIBRARY) + "_" + version + ".dylib"; #else - #if defined(__WXMAC__) - std::string lib_ext = ".dylib"; - #else - std::string lib_ext = ".so"; - #endif - library = plugin_folder.string() + "/" + std::string("lib") + std::string(BAMBU_NETWORK_LIBRARY) + "_" + version + lib_ext; + std::string versioned_name = std::string("lib") + std::string(BAMBU_NETWORK_LIBRARY) + "_" + version + ".so"; #endif + library = using_backup ? (plugin_folder / versioned_name).string() + : resolve_library_path(version); #if defined(_MSC_VER) || defined(_WIN32) wchar_t lib_wstr[256]; @@ -382,12 +383,33 @@ std::string BBLNetworkPlugin::get_versioned_library_path(const std::string& vers #endif } +std::string BBLNetworkPlugin::resolve_library_path(const std::string& version) +{ + std::string exact = get_versioned_library_path(version); + if (boost::filesystem::exists(exact)) + return exact; + + // A bare series (02.08.01) is physically present only as a specific build (02.08.01.53) when + // the startup file-rename was skipped or failed. Resolve to the newest same-series build + // actually on disk. Custom and legacy names are exact and never resolved. + if (is_series_managed_version(version)) { + const std::string series = network_plugin_series(version); + std::string best; + for (const auto& v : scan_plugin_versions()) + if (is_series_managed_version(v) && network_plugin_series(v) == series && (best.empty() || v > best)) + best = v; + if (!best.empty()) + return get_versioned_library_path(best); + } + + return exact; // nonexistent -> caller downloads +} + bool BBLNetworkPlugin::versioned_library_exists(const std::string& version) { if (version.empty()) return false; - std::string path = get_versioned_library_path(version); - if (boost::filesystem::exists(path)) return true; + if (boost::filesystem::exists(resolve_library_path(version))) return true; if (is_legacy_version(version)) { return legacy_library_exists(); @@ -786,43 +808,30 @@ std::vector get_all_available_versions() std::vector get_all_available_versions(const std::string& loaded_version) { std::vector result; - std::set known_base_versions; std::set all_known_versions; for (size_t i = 0; i < AVAILABLE_NETWORK_VERSIONS_COUNT; ++i) { result.push_back(NetworkLibraryVersionInfo::from_static(AVAILABLE_NETWORK_VERSIONS[i])); - known_base_versions.insert(AVAILABLE_NETWORK_VERSIONS[i].version); all_known_versions.insert(AVAILABLE_NETWORK_VERSIONS[i].version); } std::vector discovered = BBLNetworkPlugin::scan_plugin_versions(); - // Surface discovered builds that are not whitelisted outright: - // - suffixed dev builds (02.08.01.52-custom) attach to the exact whitelisted base - // version they were built from, so they render nested under it; - // - unsuffixed builds (typically installed by the OTA plug-in update) are accepted - // when is_supported_network_version() recognises their release series - the plugin - // ABI is stable within an AA.BB.CC series and the OTA sync only ever accepts - // same-series updates, so they load with the whitelisted struct layout. That gate - // never matches the legacy entry, which would otherwise be loaded with the modern - // layout and break. - // Only the static whitelist anchors a dev build, never another discovered one, so - // known_base_versions must stay separate from all_known_versions here. + // A managed build (pure dotted-numeric AA.BB.CC[.DD]) is represented by its series entry + // above - the OTA-installed 02.08.01.53 and a bare 02.08.01 both collapse into the single + // 02.08.01 row. Only a custom-named build a user dropped in (02.08.01_custom, ..-dev) earns + // its own row, and only when its series is one this build can actually load. The part past + // the series is stored as the "suffix" so the entry sorts and renders nested under it. for (const auto& version : discovered) { if (all_known_versions.count(version) > 0) continue; - - const std::string suffix = extract_suffix(version); - if (suffix.empty()) { - if (!is_supported_network_version(version)) - continue; - result.push_back(NetworkLibraryVersionInfo::from_discovered(version, version, "")); - } else { - const std::string base = extract_base_version(version); - if (known_base_versions.count(base) == 0) - continue; - result.push_back(NetworkLibraryVersionInfo::from_discovered(version, base, suffix)); - } + if (is_series_managed_version(version)) + continue; + if (!is_supported_network_version(version)) + continue; + const std::string series = network_plugin_series(version); + const std::string sfx = version.size() > series.size() ? version.substr(series.size()) : version; + result.push_back(NetworkLibraryVersionInfo::from_discovered(version, series, sfx)); all_known_versions.insert(version); } @@ -835,8 +844,15 @@ std::vector get_all_available_versions(const std::str return a.suffix < b.suffix; }); + const std::string loaded_series = network_plugin_series(loaded_version); for (auto& info : result) { - info.is_loaded = !loaded_version.empty() && info.version == loaded_version; + // A managed (series) entry matches when a managed build of the same series is loaded - + // the loaded plug-in reports its full build (02.08.01.53) but the row is the series. + // Custom and legacy entries match their exact reported version. + info.is_loaded = !loaded_version.empty() && + (info.version == loaded_version || + (is_series_managed_version(info.version) && is_series_managed_version(loaded_version) && + network_plugin_series(info.version) == loaded_series)); info.is_latest = false; } diff --git a/src/slic3r/Utils/BBLNetworkPlugin.hpp b/src/slic3r/Utils/BBLNetworkPlugin.hpp index 7ca989c129..ed94b23f85 100644 --- a/src/slic3r/Utils/BBLNetworkPlugin.hpp +++ b/src/slic3r/Utils/BBLNetworkPlugin.hpp @@ -260,6 +260,9 @@ public: static std::string get_libpath_in_current_directory(const std::string& library_name); static std::string get_versioned_library_path(const std::string& version); + // Path to load for a stored version: the exact file if present, else - for a bare series + // whose file the startup rename did not produce - the newest same-series build on disk. + static std::string resolve_library_path(const std::string& version); static bool versioned_library_exists(const std::string& version); static bool legacy_library_exists(); static void remove_legacy_library(); diff --git a/src/slic3r/Utils/bambu_networking.hpp b/src/slic3r/Utils/bambu_networking.hpp index 8e25db169e..1093392423 100644 --- a/src/slic3r/Utils/bambu_networking.hpp +++ b/src/slic3r/Utils/bambu_networking.hpp @@ -364,7 +364,7 @@ struct NetworkLibraryVersion { // a dedicated shim for the legacy build. Older 02.0x series expect different layouts // and must not be loaded - see is_supported_network_version(). static const NetworkLibraryVersion AVAILABLE_NETWORK_VERSIONS[] = { - {"02.08.01.52", "02.08.01.52", nullptr, true, nullptr}, + {"02.08.01", "02.08.01", nullptr, true, nullptr}, {BAMBU_NETWORK_AGENT_VERSION_LEGACY, BAMBU_NETWORK_AGENT_VERSION_LEGACY " (legacy)", nullptr, false, nullptr}, }; @@ -441,6 +441,25 @@ inline std::string extract_suffix(const std::string& full_version) { return (pos == std::string::npos) ? "" : full_version.substr(pos + 1); } +// The AA.BB.CC series of a modern version string - the plug-in's stored identity. The 4th +// component is only which build of the series happens to be installed and is read live from +// the loaded plug-in for display. Legacy keeps its exact string (the shim matches exactly). +inline std::string network_plugin_series(const std::string& version) { + if (version.empty() || version == BAMBU_NETWORK_AGENT_VERSION_LEGACY) + return version; + return version.size() >= 8 ? version.substr(0, 8) : version; +} + +// True when the version is a pure dotted-numeric build (AA.BB.CC or AA.BB.CC.DD) whose identity +// collapses to its series - the managed/OTA build. Legacy and any custom-named build +// (02.08.01_custom, 02.08.01.52-dev) are NOT managed: they are genuinely distinct files kept +// under their own name and never folded into the series entry. +inline bool is_series_managed_version(const std::string& version) { + if (version.empty() || version == BAMBU_NETWORK_AGENT_VERSION_LEGACY) + return false; + return version.find_first_not_of("0123456789.") == std::string::npos; +} + // Selectable versions, newest first. Marks the entry matching the plug-in currently // loaded in this session. std::vector get_all_available_versions(); diff --git a/tests/slic3rutils/test_network_versions.cpp b/tests/slic3rutils/test_network_versions.cpp index a26725fc4c..af8a639025 100644 --- a/tests/slic3rutils/test_network_versions.cpp +++ b/tests/slic3rutils/test_network_versions.cpp @@ -61,76 +61,105 @@ int count_version(const std::vector& versions, const } // namespace -TEST_CASE_METHOD(PluginFolderFixture, "Same-series OTA plugin versions are surfaced", "[NetworkVersions]") +TEST_CASE("Series and managed classification", "[NetworkVersions]") { - add_plugin("02.08.01.55"); // same series as the whitelisted latest -> listed - add_plugin("02.09.00.10"); // unknown series -> not listed - add_plugin("02.08.01.52-custom"); // suffixed build of a whitelisted base -> listed (existing behavior) - add_plugin("02.03.00.62"); // series no longer whitelisted -> not listed + // The AA.BB.CC series is the stored identity of every modern build. + CHECK(network_plugin_series("02.08.01.53") == "02.08.01"); + CHECK(network_plugin_series("02.08.01") == "02.08.01"); // idempotent + CHECK(network_plugin_series("02.08.01_custom") == "02.08.01"); + CHECK(network_plugin_series("02.08.01.52-dev") == "02.08.01"); + CHECK(network_plugin_series(BAMBU_NETWORK_AGENT_VERSION_LEGACY) == BAMBU_NETWORK_AGENT_VERSION_LEGACY); + CHECK(network_plugin_series("").empty()); + + // Only pure dotted-numeric builds collapse into their series entry; legacy and any + // custom-named build keep their own identity. + CHECK(is_series_managed_version("02.08.01")); + CHECK(is_series_managed_version("02.08.01.53")); + CHECK_FALSE(is_series_managed_version("02.08.01_custom")); + CHECK_FALSE(is_series_managed_version("02.08.01.52-dev")); + CHECK_FALSE(is_series_managed_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY)); + CHECK_FALSE(is_series_managed_version("")); +} + +TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; customs are surfaced", "[NetworkVersions]") +{ + add_plugin("02.08.01.55"); // managed, same series -> folded into the 02.08.01 row + add_plugin("02.09.00.10"); // managed, unknown series -> not listed + add_plugin("02.03.00.62"); // managed, series no longer whitelisted -> not listed + add_plugin("02.08.01_custom"); // custom, whitelisted series -> listed under it + add_plugin("02.08.01.52-dev"); // custom (dash-suffixed), whitelisted series -> listed auto versions = get_all_available_versions(); - REQUIRE(count_version(versions, "02.08.01.55") == 1); + // The specific managed build never gets its own row - the series represents it. + REQUIRE(count_version(versions, "02.08.01.55") == 0); + REQUIRE(count_version(versions, "02.08.01") == 1); REQUIRE(count_version(versions, "02.09.00.10") == 0); - REQUIRE(count_version(versions, "02.08.01.52-custom") == 1); REQUIRE(count_version(versions, "02.03.00.62") == 0); + // Custom-named builds are distinct files kept under their own name. + REQUIRE(count_version(versions, "02.08.01_custom") == 1); + REQUIRE(count_version(versions, "02.08.01.52-dev") == 1); - // Newest first, regardless of whether a version came from the whitelist or from - // disk: the OTA build outranks the older whitelist entry it was discovered under. - REQUIRE(versions[0].version == "02.08.01.55"); - REQUIRE(versions[1].version == "02.08.01.52"); - // Suffixed dev builds stay nested directly under the base version they build on. - REQUIRE(versions[2].version == "02.08.01.52-custom"); - REQUIRE(versions[2].base_version == "02.08.01.52"); - // The legacy series is the oldest, so it sorts last. + // Newest series first, its customs nested under it (suffix sort: "" < ".52-dev" < "_custom"), + // legacy last. + REQUIRE(versions[0].version == "02.08.01"); + REQUIRE(versions[1].version == "02.08.01.52-dev"); + REQUIRE(versions[2].version == "02.08.01_custom"); REQUIRE(versions.back().version == BAMBU_NETWORK_AGENT_VERSION_LEGACY); - const auto& ota = versions[0]; - REQUIRE(ota.is_discovered); - REQUIRE(ota.suffix.empty()); + // Customs sort/render nested under their series (non-empty suffix, base = the series). + REQUIRE(versions[1].base_version == "02.08.01"); + REQUIRE_FALSE(versions[1].suffix.empty()); + REQUIRE(versions[2].base_version == "02.08.01"); + REQUIRE_FALSE(versions[2].suffix.empty()); - // "(Latest)" is dynamic: the OTA build is the highest listed version, so it takes - // the label from the static whitelist entry. - REQUIRE(ota.is_latest); + // "(Latest)" is the series row, never a nested custom build. + REQUIRE(versions[0].suffix.empty()); + REQUIRE(versions[0].is_latest); REQUIRE_FALSE(versions[1].is_latest); + REQUIRE_FALSE(versions[2].is_latest); - // The static default used for download and update-check decisions is unchanged. - REQUIRE(std::string(get_latest_network_version()) == "02.08.01.52"); + // The stored default that drives download and update-check decisions is now the series. + REQUIRE(std::string(get_latest_network_version()) == "02.08.01"); } -TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded build is marked installed", "[NetworkVersions]") +TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installed", "[NetworkVersions]") { - // Switching versions leaves the previous library on disk, so presence on disk is - // not what "(installed)" reports - the build actually loaded in this session is. add_plugin("02.08.01.55"); - add_plugin("02.08.01.52"); + add_plugin("02.08.01_custom"); - auto versions = get_all_available_versions("02.08.01.52"); - - int marked = 0; - for (const auto& info : versions) { - if (info.is_loaded) { - ++marked; - REQUIRE(info.version == "02.08.01.52"); - } + // The loaded plug-in reports its full build (02.08.01.55); the series row is what gets marked. + { + auto versions = get_all_available_versions("02.08.01.55"); + int marked = 0; + for (const auto& info : versions) + if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.08.01"); } + REQUIRE(marked == 1); } - REQUIRE(marked == 1); - // Nothing loaded (plug-in disabled or failed to load) marks nothing, even though - // both libraries are on disk. + // A loaded custom build matches its own row, never the bare series. + { + auto versions = get_all_available_versions("02.08.01_custom"); + int marked = 0; + for (const auto& info : versions) + if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.08.01_custom"); } + REQUIRE(marked == 1); + } + + // Nothing loaded marks nothing, even though libraries are on disk. for (const auto& info : get_all_available_versions("")) REQUIRE_FALSE(info.is_loaded); } TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]") { - // Exact whitelist entries. + // The whitelisted series, its builds, and custom-named builds of that series. + REQUIRE(is_supported_network_version("02.08.01")); REQUIRE(is_supported_network_version("02.08.01.52")); - REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY)); - - // Same-series OTA builds and suffixed dev builds of the whitelisted latest. REQUIRE(is_supported_network_version("02.08.01.55")); - REQUIRE(is_supported_network_version("02.08.01.52-custom")); + REQUIRE(is_supported_network_version("02.08.01_custom")); + REQUIRE(is_supported_network_version("02.08.01.52-dev")); + REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY)); // Series whitelisted by previous Orca releases - their ABI no longer matches. REQUIRE_FALSE(is_supported_network_version("02.03.00.62")); @@ -159,10 +188,10 @@ TEST_CASE_METHOD(PluginFolderFixture, "Legacy series never adopts discovered bui REQUIRE(count_version(versions, legacy_sibling) == 0); REQUIRE(count_version(versions, legacy) == 1); - // With nothing else on disk, the highest whitelisted version holds "(Latest)" - // even though its library is not installed. + // With nothing else on disk, the series holds "(Latest)" even though its library is + // not installed. for (const auto& info : versions) { - if (info.version == "02.08.01.52") { + if (info.version == "02.08.01") { REQUIRE(info.is_latest); REQUIRE_FALSE(info.is_loaded); }