mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-09 19:17:36 +00:00
721 lines
29 KiB
C++
721 lines
29 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 "PluginFsUtils.hpp"
|
|
#include "PythonInterpreter.hpp"
|
|
#include "PythonPluginBridge.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
|
|
#include <boost/algorithm/string.hpp>
|
|
#include <boost/filesystem.hpp>
|
|
#include <boost/log/trivial.hpp>
|
|
#include <boost/process.hpp>
|
|
#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 = "Plugin package must be a .py or .whl file, got: " + 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)) {
|
|
error = "Failed to extract plugin .whl dependency " + it->path().string() + ": " + extract_error;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
std::string syspath_error;
|
|
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
|
|
error = "Failed to add .whl dependency to sys.path: " + 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)
|
|
{
|
|
error.clear();
|
|
out = Plugin{};
|
|
|
|
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 = "Python interpreter not initialized: " + interpreter.last_error();
|
|
return false;
|
|
}
|
|
|
|
if (!descriptor.is_metadata_valid()) {
|
|
error = "Plugin manifest is invalid: " + 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 = "Failed to install plugin dependencies: " + 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 = "Failed to load plugin module: " + 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, bridge_error);
|
|
if (!bridge_error.empty()) {
|
|
capabilities_found.clear();
|
|
error = "Plugin registration failed: " + bridge_error;
|
|
return false;
|
|
}
|
|
if (capabilities_found.empty()) {
|
|
error = "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) {
|
|
materialization_error = "Plugin declares duplicate capability '" + found.name + "' for type " +
|
|
plugin_capability_type_to_string(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);
|
|
}
|
|
|
|
capabilities.push_back(std::move(instance));
|
|
}
|
|
} catch (const std::exception& ex) {
|
|
materialization_error = std::string("Plugin capability materialization failed: ") + ex.what();
|
|
} catch (...) {
|
|
materialization_error = "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);
|
|
error = std::string("Plugin on_load failed: ") + ex.what();
|
|
return false;
|
|
} catch (...) {
|
|
teardown_capabilities(plugin.capabilities, lifecycle_count);
|
|
error = "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 = "Plugin installation 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());
|
|
|
|
const std::string ext = plugin_package_extension(source_path);
|
|
if (ext != ".py" && ext != ".whl")
|
|
return fail("Plugin package must be a .py or .whl file, got: " + 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("Cloud plugin UUID is not a valid identifier: " + 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)
|
|
return fail("Failed to create plugin directory " + 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)
|
|
return fail("Failed to backup existing plugin " + 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("Failed to create plugin directory " + 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);
|
|
}
|
|
return fail("Failed to copy plugin file to " + 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("Failed to write plugin install state: " + (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
|