Files
OrcaSlicer/src/slic3r/plugin/PluginLoader.cpp
T
Ian Bassi e821665e4b Add orca-translations Claude skill and PO tool (#16436)
* Add orca-translations Claude skill and PO tool

Add a .claude skill that guides translation passes over the localization/i18n gettext catalogs. It covers the workflow: run gettext, settle new glossary terms, translate in batches, verify, and report.

Includes translation-rules.md, which condenses the placeholder, line-break and glossary rules plus the guidance on review notes.

Also adds scripts/po_tool.py (standard library only). It lists pending entries with glossary hints, validates and applies translations, adding '# AI Translated' and '# Needs human review' comments, and rewrites only the entries it changes. It can also check catalogs, look up existing translations, suggest glossary candidates and update the wiki glossary TSV.

* Let po_tool apply translations by entry id

pending now gives each entry a short id, so a translation file can name
entries by id instead of repeating the English msgid. apply accepts either.

A "review": "" item clears an earlier review note when overwriting an
entry, and check gives each msgfmt run its own temporary output file so
parallel checks do not collide.

* Fill all empty translations

Regenerate the template with run_gettext --full and translate every empty
entry in the 22 catalogs (7,089 entries) with the orca-translations skill,
following the localization glossary, including the new belt printer,
IDEX/IQEX and plugin terms. Every new entry is marked "# AI Translated";
122 also carry "# Needs human review" with what to check.

* Show entry ids in po_tool lookup and search translations

lookup now prints each entry's id, so an entry found there can be
rewritten with apply --overwrite without repeating the English, and
--msgstr searches the translations instead of the msgids, which finds
every entry that still uses a term being changed.

SKILL.md describes how to change an established term: check the
catalog's usage first, update the glossary cell, then rewrite only the
entries where the term has that meaning.

* Fix mistranslated terms and align catalogs with the glossary

Correct labels that the glossary had copied, and the entries that used
them:
- ca: Raft was rendered as the Brim term; it is now "Rai". Exhaust fan,
  Monotonic line, Unretract, Slice, Lateral Honeycomb and PA Tower
  labels fixed.
- cs: Lightning infill is "Blesk", not "Osvětlení" (illumination).
- sv: Retraction is "Retraktion", not "Reduktion"; Unretract,
  Lagerhöjd, Delkylningsfläkt, Utsugsfläkt, Aligned Rectilinear and the
  Preset label fixed.
- de: Brim is "Rand" and Skirt is "Umrandung", as most entries already
  had them.
- lt: brim entries used the Skirt word "apvadas"; they now use "kraštas".
- es: Sparse infill is "relleno de baja densidad", the catalog majority.
- eu: nozzle wipe is "garbiketa", not purge.
- th: the project plate is "เพลต", distinct from the bed.
- uk: Quarter Cubic, Latin "PA" in PA Tower, and Belt purge tower.
- pl: Cross Hatch, Tri-hexagon, Volumetric speed and Raft labels.
- zh_TW: Brim stays "Brim", as the existing labels have it.
- tr: "Prime Tower" read "Prime Minister Tower".
- ko: the plugin "Capabilities" label read "performance".
- Build Volume labels fixed in tr, de, cs and eu.

All 242 rewritten entries are marked "# AI Translated".

* Make more UI text translatable and fix pluralization and context

Audit the sources for text that translators could not translate
correctly and fix it.

Mark text that reached users in English only: IDEX/IQEX role names,
plugin permission prompts and errors, 3MF/preset loading errors, G-code
placeholder parser errors, calibration and device messages, notification
texts, file dialog filters, and the config validation messages.

Replace sentences built from translated fragments, such as the prime
tower warnings, "left"/"right" nozzle names, and "Filament " + number,
with single msgids that have placeholders, so translators can reorder
and inflect them.

Use plural forms for texts with a count ("%1% mixed filaments",
"%d objects", "%1% triangles") instead of "(s)" and mismatched verbs.
Fix the swapped singular/plural pair in "The following presets inherit
this preset".

Add translation contexts where one English word has several meanings
(Subscribed/Local, Standard, Pause, View, Step, Model, Camera, Flow,
Filter, Others, Prepare, the Design tools). Show "Size" instead of
"Build Volume" in the Cut tool, which displays the object size.

Fix code that compared translated text with English (the Edit G-code
dialog group, the plugin registration failure check), and add the
bundled web pages' missing string-table entries. Use the single
character for degrees Celsius, say "G-code", and describe the head
filament map without internal jargon.

Install the libslic3r translate callback before the presets load, so
their load errors are translated, and list the 28 source files that had
marked strings but were never extracted into localization/i18n/list.txt.

* Suggest earlier translations and list fuzzy entries in po_tool pending

pending now attaches the closest earlier translation of a similar
English text (from obsolete or translated entries), so a reworded or
split string keeps its wording, and it lists fuzzy entries by default,
since users never see them. A fuzzy entry also carries its stale
translation as previous_translation.

* Fix wording of the non-boolean operands error

The logical operation error said "operators" where it means the
operands the operation was applied to.

* Translate the newly translatable strings in all languages

Regenerate the template after the translatability changes and fill every
new or changed entry in the 22 catalogs (about 12,800 entries): the
strings that became translatable, the plural forms, the entries split by
context, and the previously fuzzy singular entries that became plurals.

Every new entry is marked "# AI Translated", and about 360 also carry a
"# Needs human review" note saying what to check. Terms follow the
localization glossary. Earlier translations of reworded strings were
reused where the meaning was unchanged. Existing entries were not
changed, and obsolete entries are kept.

* Document the source translatability audit in the orca-translations skill

Add a reference describing how strings reach translators, what to look
for (untranslated text, spliced sentences, missing plurals, ambiguous
words), what not to translate, where a context cannot be carried, and
what to do after the edits, plus a scanner that lists untranslated
user-facing string literals.

* Document how to write translatable strings in AGENTS.md

Tell developers to keep each message complete in one msgid: use
placeholders instead of concatenating translated fragments, plural forms
for counts, and contexts for ambiguous words, and to list new source
files in localization/i18n/list.txt. Add a matching code review item.

* Translate the strings added since the rebase

Regenerate the template against the current main and translate the 26
new entries per language: the Fix Model commands and messages, and the
texture displacement image adjustment panel. Each new entry is marked
"# AI Translated"; existing entries are unchanged.

* tidy
2026-10-13 10:53:57 -03:00

752 lines
31 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 "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);
}
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