Files
OrcaSlicer/src/slic3r/plugin/PluginLoader.cpp
T
Lam Wei Lun 86ab598899 Add a ResourceTexture plugin capability for height-map texture packs
A fifth Python plugin capability type, ResourceTexture, contributes
height-map textures to the Texture Displacement picker. A pack returns a
directory of images; the host validates it once at load and lists the
images under a section of their own.

- New capability type ResourceTexture, type string "resource-texture",
  exposed to plugin authors as
  orca.resource_texture.ResourceTexturePluginCapabilityBase with one
  required method, get_texture_dir().
- The directory is resolved once on the load worker, under the GIL, and
  cached as a plain string: the picker runs inside the ImGui frame, where
  a trampoline call would block on the GIL behind a load/unload worker, or
  throw past EndPopup.
- Validation resolves the directory and requires containment in the plugin
  root, re-checked per file so a symlink inside the pack cannot resolve
  outside it. Containment is the only security control here and it guards
  exfiltration: layer bytes are embedded in every saved .3mf, so a pack
  that could return ~/.ssh/id_rsa would leak it through any shared project.
- New host registry PluginResourceTextures, filled from both the plugin
  and the capability lifecycle callbacks plus a startup sweep. It holds
  strings only and never a capability reference, so nothing outlives
  Py_Finalize.
- The picker lists pack textures read-only, one collapsible section per
  pack, in a fixed-size scrolling popup whose height follows the window. A
  generation counter invalidates the cached listing, and each gizmo's
  cached thumbnails, when the set of packs changes.
- Tests in tests/slic3rutils/test_plugin_resource_texture.cpp cover the
  validator (parent-directory and absolute escapes, an escaping symlink,
  the extension whitelist, the 64-file cap), the listing and its
  load/disable/re-enable/unload lifecycle, hostile get_texture_dir()
  overrides, decode parity with hand-picked files, and a .3mf round trip
  with the pack uninstalled.

No persistence work is needed: a layer carries its own image bytes and 3MF
writes them into the archive, so uninstalling a pack leaves existing
projects intact.

Behaviour change worth noting for review: the picker popup is now a fixed
size that scrolls, rather than sizing itself to its content, and it gains
one collapsible section per pack. That sizing applies to every user, not
only those with packs installed.
2026-10-14 17:15:39 +08:00

784 lines
33 KiB
C++

#include "PluginLoader.hpp"
// plugin_loader fills and tears down a Plugin, so it needs the complete type. The
// declaration lives with the registry that owns it; the dependency is .cpp-only, so the
// service header stays free of the manager.
#include "PluginManager.hpp"
#include "PluginConfig.hpp"
#include "PluginFsUtils.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/algorithm/string/join.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/directory.hpp>
#include <boost/filesystem/file_status.hpp>
#include <boost/log/trivial.hpp>
#include <boost/process.hpp>
#include "slic3r/plugin/PluginDescriptor.hpp"
#include <optional>
#include <vector>
#include <memory>
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include <cstddef>
#include <functional>
#include <boost/process/pipe.hpp>
#include <boost/process/args.hpp>
#include <boost/process/io.hpp>
#include <system_error>
#ifdef _WIN32
#include <boost/process/windows.hpp>
#endif
#include <algorithm>
#include <chrono>
#include <exception>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace Slic3r::plugin_loader {
namespace {
boost::filesystem::path canonical_or_absolute(const boost::filesystem::path& path)
{
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::path resolved = fs::weakly_canonical(path, ec);
if (!ec && !resolved.empty())
return resolved;
ec.clear();
resolved = fs::absolute(path, ec);
if (!ec && !resolved.empty())
return resolved;
return path;
}
std::string plugin_package_extension(const boost::filesystem::path& path)
{
std::string ext = path.extension().string();
boost::algorithm::to_lower(ext);
return ext;
}
boost::filesystem::path local_plugin_root()
{
return boost::filesystem::path(data_dir()) / "orca_plugins";
}
boost::filesystem::path local_plugin_install_dir(const boost::filesystem::path& source_path)
{
// Folder name follows the plugin filename and is the install/backup conflict unit;
// plugin_key is the file stem of the same file.
return local_plugin_root() / filesystem_safe_escape(source_path.filename().string());
}
void assign_local_plugin_key(PluginDescriptor& plugin_descriptor, const boost::filesystem::path& entry_file)
{
plugin_descriptor.plugin_key = make_local_plugin_key(entry_file.stem().string());
}
bool read_local_plugin_package_metadata(const boost::filesystem::path& source_path,
PluginDescriptor& plugin_descriptor,
std::string& error)
{
error.clear();
const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl") {
error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
return false;
}
bool ok = false;
if (ext == ".whl")
ok = read_wheel_plugin_metadata(source_path, plugin_descriptor, error);
else
ok = read_python_plugin_metadata(source_path, plugin_descriptor, error);
if (!ok)
return false;
plugin_descriptor.cloud = std::nullopt;
return true;
}
// on_unload() the first `lifecycle_count` capabilities — the ones that actually got on_load() —
// then drop them all.
//
void teardown_capabilities(std::vector<std::shared_ptr<PluginCapabilityInterface>>& capabilities, std::size_t lifecycle_count)
{
if (capabilities.empty())
return;
if (!PythonInterpreter::instance().is_initialized()) {
capabilities.clear();
return;
}
PythonGILState gil;
if (!gil) {
capabilities.clear();
return;
}
lifecycle_count = std::min(lifecycle_count, capabilities.size());
for (std::size_t index = 0; index < lifecycle_count; ++index) {
const auto& capability = capabilities[index];
if (!capability)
continue;
try {
capability->on_unload();
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Plugin on_unload failed: " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Plugin on_unload failed";
}
}
capabilities.clear();
}
// Add every .whl sitting next to the plugin entry file to sys.path, extracting it first.
bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor,
std::vector<std::string>& plugin_paths,
std::string& error)
{
namespace fs = boost::filesystem;
const fs::path entry_path(descriptor.entry_path);
const fs::path plugin_dir = entry_path.has_extension() ? entry_path.parent_path() : entry_path;
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir))
return true;
PythonInterpreter& interpreter = PythonInterpreter::instance();
for (fs::directory_iterator it(plugin_dir); it != fs::directory_iterator(); ++it) {
if (!fs::is_regular_file(it->status()) || it->path().extension() != ".whl")
continue;
// Skip the entry file itself — it is loaded by its own path.
if (it->path() == entry_path)
continue;
const fs::path dep_dir = plugin_dir / "__whl_extracted__" / it->path().stem().string();
if (!fs::exists(dep_dir)) {
std::string extract_error;
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
// TRN %1% is the dependency file, %2% the error detail.
error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
return false;
}
}
std::string syspath_error;
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
return false;
}
plugin_paths.push_back(dep_dir.string());
}
return true;
}
PyObject* import_plugin_module(const PluginDescriptor& descriptor,
std::vector<std::string>& plugin_paths,
std::vector<std::string>& plugin_modules,
std::string& error)
{
PythonInterpreter& interpreter = PythonInterpreter::instance();
if (descriptor.entry_package.empty())
return interpreter.load_module_from_file(descriptor.entry_path, error, &plugin_paths, &plugin_modules);
if (boost::filesystem::path(descriptor.entry_path).extension() == ".whl")
return interpreter.load_module_from_whl(
descriptor.entry_path, descriptor.entry_package, error, &plugin_paths, &plugin_modules);
return interpreter.load_module_from_directory(
descriptor.entry_path, descriptor.entry_package, error, &plugin_paths, &plugin_modules);
}
std::string plugin_module_name(const PluginDescriptor& descriptor)
{
if (!descriptor.entry_package.empty())
return descriptor.entry_package;
return boost::filesystem::path(descriptor.entry_path).stem().string();
}
} // namespace
void unload(Plugin& plugin)
{
// Dropping the capabilities drops their state with them; the user's enable choices survive in
// the .install_state.json sidecar, which the next load seeds from.
teardown_capabilities(plugin.capabilities, plugin.capabilities.size());
plugin.release_module();
}
bool load(const PluginDescriptor& descriptor,
bool skip_deps,
const std::vector<std::string>& capabilities_to_enable,
const std::function<std::string(const Plugin&)>& registry_precheck,
Plugin& out,
std::string& error,
bool* registration_failed)
{
error.clear();
out = Plugin{};
if (registration_failed != nullptr)
*registration_failed = false;
BOOST_LOG_TRIVIAL(info) << "[plugin_loader::load] START plugin=" << descriptor.plugin_key
<< " thread=" << std::this_thread::get_id();
PythonInterpreter& interpreter = PythonInterpreter::instance();
if (!interpreter.is_initialized()) {
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error());
return false;
}
if (!descriptor.is_metadata_valid()) {
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
if (descriptor.has_error())
error += " - " + descriptor.normalized_error();
return false;
}
// Serialize the entire load sequence — only one thread may run it at a time. This prevents
// sys.path / sys.modules races when two plugins share the same entry-point filename (e.g.
// plugin.py), and keeps the caller's check-then-commit of the registry free of an interleaved
// load (which could otherwise force a wasted on_load/on_unload rollback). install_packages()
// has a 120s timeout so this cannot block indefinitely.
static std::mutex load_serializer;
std::lock_guard<std::mutex> load_lock(load_serializer);
Plugin plugin;
plugin.descriptor = descriptor;
plugin.module_name = plugin_module_name(descriptor);
if (!skip_deps) {
std::string pkg_install_error;
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
return false;
}
}
PythonPluginBridge& bridge = PythonPluginBridge::instance();
bridge.begin_plugin_capture(descriptor.entry_path);
std::string wheel_error;
if (!add_wheel_dependencies_to_sys_path(descriptor, plugin.plugin_sys_paths, wheel_error)) {
bridge.cancel_plugin_capture(descriptor.entry_path);
error = std::move(wheel_error);
return false;
}
std::string load_error;
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
if (module == nullptr) {
bridge.cancel_plugin_capture(descriptor.entry_path);
error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error);
return false;
}
// From here on the module reference is owned by `plugin`: every failure path below returns and
// lets ~Plugin release it.
plugin.module = module;
// finalize_plugin_capture runs the module's @orca.plugin package class register_capabilities()
// (while the active plugin key is set), then instantiates each registered capability and caches
// its get_name(). Returns one entry per capability.
std::string bridge_error;
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
if (!bridge_error.empty()) {
capabilities_found.clear();
error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error);
if (registration_failed != nullptr)
*registration_failed = true;
return false;
}
if (capabilities_found.empty()) {
error = _u8L("Plugin module did not register any capabilities");
return false;
}
plugin.descriptor.clear_error();
// The user's per-capability enable choices live in the sidecar — the only durable record, since
// a capability has no state (and no existence) while the package is not loaded.
PluginInstallState install_state;
const bool have_install_state = !descriptor.plugin_root.empty() &&
read_install_state(boost::filesystem::path(descriptor.plugin_root), install_state);
// get_name()/get_type() are read exactly once — here, under the GIL this block already holds —
// and cached on the capability, which is the only place capability state lives.
std::unordered_map<PluginCapabilityType, std::unordered_set<std::string>> seen_capabilities;
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities;
capabilities.reserve(capabilities_found.size());
std::string materialization_error;
{
PythonGILState gil;
if (!gil) {
materialization_error = "Python interpreter is shutting down";
} else {
try {
for (auto& found : capabilities_found) {
if (!found.instance) {
materialization_error = "Plugin capability instance is null";
break;
}
std::shared_ptr<PluginCapabilityInterface> instance = std::move(found.instance);
const PluginCapabilityType type = instance->get_type();
// An empty request restores the sidecar state (or enables capabilities by
// default). An explicit request overrides the sidecar for that capability;
// all other capabilities retain their persisted state.
const bool explicitly_requested =
std::find(capabilities_to_enable.begin(), capabilities_to_enable.end(), found.name) !=
capabilities_to_enable.end();
bool enabled = capabilities_to_enable.empty() || explicitly_requested;
if (have_install_state && !explicitly_requested) {
for (const auto& [cap_name, cap_enabled] : install_state.capabilities) {
if (cap_name == found.name) {
enabled = cap_enabled;
break;
}
}
}
if (!seen_capabilities[type].insert(found.name).second) {
// TRN %1% is the capability name, %2% the capability type (e.g. Script).
materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"),
found.name, PluginConfig::capability_type_label(type));
break;
}
instance->set_audit_plugin_key(descriptor.plugin_key);
instance->set_resolved_identity(found.name, type);
instance->set_enabled(enabled);
// Cache has_config_ui() once, under this same GIL, so the GUI can pick the
// capability's custom UI vs. the host JSON editor without touching Python. It is
// optional and plugin-authored: a raising or non-bool override only costs this
// capability its custom UI, so it is caught locally rather than failing the load.
try {
instance->set_config_ui_available(instance->has_config_ui());
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': has_config_ui() failed (" << ex.what() << "); falling back to the default JSON editor";
instance->set_config_ui_available(false);
}
// ResourceTexture capabilities resolve their texture directory here, under the
// GIL this block already holds, and cache the validated result. A resolution
// fault only costs this capability its textures, so it is caught locally like
// has_config_ui() above rather than failing the load.
if (auto* resource_texture = dynamic_cast<ResourceTexturePluginCapability*>(instance.get())) {
try {
const std::string texture_dir = resource_texture->get_texture_dir();
if (texture_dir.empty()) {
resource_texture->set_texture_dir({});
} else {
std::string texture_error;
collect_resource_texture_files(texture_dir, descriptor.plugin_root, texture_error);
if (texture_error.empty()) {
resource_texture->set_texture_dir(texture_dir);
} else {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': texture directory rejected (" << texture_error
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': get_texture_dir() failed (" << ex.what()
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
capabilities.push_back(std::move(instance));
}
} catch (const std::exception& ex) {
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what());
} catch (...) {
materialization_error = _u8L("Plugin capability materialization failed");
}
}
if (!materialization_error.empty()) {
capabilities.clear();
capabilities_found.clear();
}
}
if (!materialization_error.empty()) {
error = std::move(materialization_error);
return false;
}
capabilities_found.clear();
plugin.capabilities = std::move(capabilities);
// Let the caller reject the load against its registry BEFORE on_load() runs, so a duplicate
// package or a capability-name collision costs no on_load/on_unload cycle.
if (registry_precheck) {
std::string precheck_error = registry_precheck(plugin);
if (!precheck_error.empty()) {
teardown_capabilities(plugin.capabilities, 0);
error = std::move(precheck_error);
return false;
}
}
// lifecycle_count is incremented BEFORE on_load(), so a capability whose on_load() throws
// still gets its on_unload().
std::size_t lifecycle_count = 0;
try {
PythonGILState gil;
if (!gil)
throw std::runtime_error("Python interpreter is shutting down");
for (const auto& capability : plugin.capabilities) {
++lifecycle_count;
capability->on_load();
}
} catch (const std::exception& ex) {
teardown_capabilities(plugin.capabilities, lifecycle_count);
// TRN on_load is the name of the plugin's startup function; keep it untranslated.
error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what());
return false;
} catch (...) {
teardown_capabilities(plugin.capabilities, lifecycle_count);
error = _u8L("Plugin on_load failed");
return false;
}
out = std::move(plugin);
BOOST_LOG_TRIVIAL(info) << "[plugin_loader::load] SUCCESS plugin=" << descriptor.plugin_key
<< " thread=" << std::this_thread::get_id();
return true;
}
bool install_packages(const std::vector<std::string>& pkgs, std::string& error)
{
if (pkgs.empty())
return true;
const std::string uv_path = PythonInterpreter::bundled_uv_path();
if (uv_path.empty()) {
error = "Bundled uv executable not found. Python package installation is unavailable.";
return false;
}
const std::string python_executable = PythonInterpreter::bundled_python_executable();
if (python_executable.empty()) {
error = "Bundled Python executable not found. Python package installation is unavailable.";
return false;
}
const std::string target_dir = PythonInterpreter::shared_packages_dir();
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::create_directories(target_dir, ec);
if (ec) {
error = "Failed to create package target directory: " + ec.message();
return false;
}
std::vector<std::string> args = {"pip", "install", "--python", python_executable, "--no-python-downloads", "--target", target_dir};
args.insert(args.end(), pkgs.begin(), pkgs.end());
BOOST_LOG_TRIVIAL(info) << "Installing Python packages via uv for " << python_executable << ": "
<< boost::algorithm::join(pkgs, ", ");
try {
namespace process = boost::process;
process::ipstream std_err;
process::child child(uv_path, process::args(args),
#ifdef _WIN32
// uv.exe (and the python.exe it spawns) are console-subsystem programs.
// OrcaSlicer is a GUI app with no console of its own, so without this flag
// Windows allocates a fresh console window for the child that flashes on
// screen during startup plugin loading. Matches ProcessRunner/MediaPlayCtrl.
process::windows::create_no_window,
#endif
process::std_err > std_err);
std::string err_output;
std::thread stderr_reader([&std_err, &err_output]() {
std::string line;
while (std::getline(std_err, line)) {
err_output += line + '\n';
}
});
// Wait up to 120 seconds for package install to complete.
//
// NOTE: we poll child.running() instead of calling child.wait_for().
// On macOS (which lacks sigtimedwait) boost.process v1 implements the
// timed waits with a sigwait()-based fallback that deadlocks inside a
// multi-threaded process: SIGCHLD is delivered to an arbitrary thread
// and consumed by an async handler, so the worker thread's sigwait()
// never returns and the timeout never fires — the plugin load hangs
// forever (the "Loading" status never clears). child.running() uses
// waitpid(WNOHANG), which behaves correctly on every platform.
// (boost.process v2, in Boost >= 1.86, implements timed waits via Asio
// and is unaffected — revisit this when the bundled Boost is upgraded.)
constexpr auto kInstallTimeout = std::chrono::seconds(120);
const auto deadline = std::chrono::steady_clock::now() + kInstallTimeout;
std::error_code run_ec;
while (child.running(run_ec)) {
if (run_ec) {
stderr_reader.join();
error = "Failed to query uv process status: " + run_ec.message();
BOOST_LOG_TRIVIAL(error) << error;
return false;
}
if (std::chrono::steady_clock::now() >= deadline) {
std::error_code term_ec;
child.terminate(term_ec);
child.wait(term_ec);
stderr_reader.join();
error = "uv pip install timed out after 120s";
BOOST_LOG_TRIVIAL(error) << error;
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
stderr_reader.join();
const int exit_code = child.exit_code();
if (exit_code != 0) {
if (!err_output.empty())
error = "uv pip install failed (exit code " + std::to_string(exit_code) + "): " + err_output;
else
error = "uv pip install failed with exit code " + std::to_string(exit_code);
BOOST_LOG_TRIVIAL(error) << error;
return false;
}
BOOST_LOG_TRIVIAL(info) << "Python packages installed successfully";
return true;
} catch (const std::exception& ex) {
error = std::string("Failed to run uv: ") + ex.what();
BOOST_LOG_TRIVIAL(error) << error;
return false;
}
}
bool inspect_local_plugin_package(const boost::filesystem::path& filepath,
PluginDescriptor& plugin_descriptor,
bool& existing_installation,
std::string& error)
{
namespace fs = boost::filesystem;
error.clear();
plugin_descriptor = PluginDescriptor{};
existing_installation = false;
auto fail = [&error](const std::string& message) {
error = "Plugin package inspection failed: " + message;
return false;
};
boost::system::error_code ec;
const fs::path source_path = canonical_or_absolute(filepath);
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
return fail("Plugin package is not a file: " + filepath.string());
std::string metadata_error;
if (!read_local_plugin_package_metadata(source_path, plugin_descriptor, metadata_error))
return fail(metadata_error);
const fs::path final_dir = local_plugin_install_dir(source_path);
assign_local_plugin_key(plugin_descriptor, source_path);
plugin_descriptor.plugin_root = final_dir.string();
ec.clear();
const fs::file_status final_status = fs::status(final_dir, ec);
if (ec && final_status.type() != fs::file_not_found)
return fail("Failed to check existing plugin " + final_dir.string() + ": " + ec.message());
existing_installation = final_status.type() != fs::file_not_found && final_status.type() != fs::status_error;
return true;
}
bool install_plugin(const boost::filesystem::path& filepath, const std::string& cloud_user_id, std::string& error)
{
PluginDescriptor descriptor{};
return install_plugin(filepath, cloud_user_id, descriptor, error);
}
bool install_plugin(const boost::filesystem::path& filepath,
const std::string& cloud_user_id,
PluginDescriptor& plugin_descriptor,
std::string& error)
{
namespace fs = boost::filesystem;
const std::string cloud_uuid = plugin_descriptor.cloud_uuid();
const bool is_cloud_install = !cloud_uuid.empty();
fs::path backup_dir;
bool backup_created = false;
auto fail = [&error](const std::string& message) {
error = GUI::format(_u8L("Plugin installation failed: %1%"), message);
return false;
};
boost::system::error_code ec;
const fs::path source_path = canonical_or_absolute(filepath);
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string()));
const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl")
return fail(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
// The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid).
// It comes verbatim from the server's catalog JSON, so reject anything that could escape the
// per-user plugin root (traversal, leading dots, separators) before touching the filesystem.
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
return fail(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) :
local_plugin_root();
fs::create_directories(plugin_root, ec);
if (ec)
// TRN %1% is a folder path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
std::string meta_error;
if (is_cloud_install) {
PluginDescriptor package_metadata;
if (!read_local_plugin_package_metadata(source_path, package_metadata, meta_error))
return fail(meta_error);
apply_plugin_metadata_fallbacks(plugin_descriptor, package_metadata);
plugin_descriptor.dependencies = std::move(package_metadata.dependencies);
if (plugin_descriptor.plugin_key.empty())
plugin_descriptor.plugin_key = cloud_uuid;
if (!plugin_descriptor.cloud.has_value())
plugin_descriptor.cloud = CloudPluginState{cloud_uuid, true, false, false, false};
else
plugin_descriptor.cloud->installed = true;
} else {
if (!read_local_plugin_package_metadata(source_path, plugin_descriptor, meta_error))
return fail(meta_error);
// A side-loaded .py with no (or incomplete) PEP 723 block parses as success but has no
// usable identity: an empty name collides in local_plugin_install_dir() (every nameless
// plugin maps to orca_plugins/"path"), so it installs but never loads. Wheels are exempt:
// read_wheel_plugin_metadata() already requires a Name and may legitimately leave the type
// Unknown.
if (ext == ".py" && plugin_descriptor.name.empty())
return fail("Side-loaded .py plugin is missing required PEP 723 metadata: 'name' is required");
}
const fs::path final_dir = is_cloud_install ? plugin_root / cloud_uuid : local_plugin_install_dir(source_path);
const fs::path dest_file = final_dir / source_path.filename();
// Local key is the plugin file stem; cloud key is the cloud UUID.
if (is_cloud_install)
plugin_descriptor.plugin_key = cloud_uuid;
else
assign_local_plugin_key(plugin_descriptor, source_path);
// Backup existing installation if present.
if (fs::exists(final_dir, ec)) {
backup_dir = plugin_root / (final_dir.filename().string() + ".backup-" + fs::unique_path("%%%%-%%%%-%%%%").string());
fs::rename(final_dir, backup_dir, ec);
if (ec)
// TRN %1% is a folder path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
backup_created = true;
}
// Create the plugin directory and copy the file.
fs::create_directories(final_dir, ec);
if (ec) {
if (backup_created) {
boost::system::error_code restore_ec;
fs::rename(backup_dir, final_dir, restore_ec);
}
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
}
fs::copy_file(source_path, dest_file, ec);
if (ec) {
if (backup_created) {
boost::system::error_code restore_ec;
fs::remove_all(final_dir, restore_ec);
fs::rename(backup_dir, final_dir, restore_ec);
}
// TRN %1% is a file path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
}
// Update entry_path to the installed location.
plugin_descriptor.plugin_root = final_dir.string();
plugin_descriptor.entry_path = dest_file.string();
plugin_descriptor.set_metadata_valid(true);
plugin_descriptor.clear_error();
// Write install state sidecar before removing the backup.
{
PluginDescriptor installed_entry = plugin_descriptor;
if (is_cloud_install)
installed_entry.cloud = CloudPluginState{cloud_uuid, true, false, false, plugin_descriptor.cloud->is_mine};
if (!write_install_state(final_dir, installed_entry)) {
// Roll back: remove new files and restore backup.
boost::system::error_code rollback_ec;
fs::remove_all(final_dir, rollback_ec);
if (backup_created)
fs::rename(backup_dir, final_dir, rollback_ec);
return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
}
}
if (backup_created) {
boost::system::error_code remove_ec;
fs::remove_all(backup_dir, remove_ec);
if (remove_ec)
BOOST_LOG_TRIVIAL(warning) << "Failed to remove plugin backup " << backup_dir.string() << ": " << remove_ec.message();
}
BOOST_LOG_TRIVIAL(info) << "Installed plugin " << plugin_descriptor.name << " to " << final_dir.string();
if (is_cloud_install) {
boost::system::error_code remove_ec;
fs::remove(source_path, remove_ec);
}
return true;
}
} // namespace Slic3r::plugin_loader