#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 #include #include #include #ifdef _WIN32 #include #endif #include #include #include #include #include #include #include #include #include #include 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>& 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& 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& plugin_paths, std::vector& 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& capabilities_to_enable, const std::function& 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 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> seen_capabilities; std::vector> 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 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& 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 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