feat: plugins config UI

This commit is contained in:
peachismomo
2026-07-12 16:20:39 +08:00
parent 18388ffb6d
commit a00fac9b72
16 changed files with 918 additions and 142 deletions

View File

@@ -1,13 +1,11 @@
#include "PluginConfig.hpp"
#include <pybind11/pybind11.h>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <stdexcept>
#include <utility>
@@ -65,17 +63,15 @@ std::string running_plugin_version(const std::string& plugin_key)
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
}
// Identifies the capability whose Python method is currently on the stack. Both halves are
// published by ScopedPluginAuditContext, which every C++ -> Python trampoline call opens.
// A call arriving without them did not come through a capability, so it has no config to
// address and we refuse it rather than reading or clobbering some other capability's entry.
std::pair<std::string, std::string> calling_capability(const char* api_name)
// The identity a capability is allowed to address: its own. PluginLoader stamps both halves
// onto the instance when it materializes the capability, so the caller never supplies them
// and cannot name another capability's entry. Empty means the instance was never materialized
// (so it has no config to address) and we refuse rather than read or clobber a wrong entry.
std::pair<std::string, std::string> capability_identity(const PluginCapabilityInterface& capability, const char* api_name)
{
const PluginAuditManager& audit = PluginAuditManager::instance();
std::pair<std::string, std::string> id{audit.current_plugin(), audit.current_capability()};
std::pair<std::string, std::string> id{capability.audit_plugin_key(), capability.audit_capability_name()};
if (id.first.empty() || id.second.empty())
throw std::runtime_error(std::string(api_name) + "() must be called from a plugin capability method");
throw std::runtime_error(std::string(api_name) + "() is only available on a capability loaded by the plugin host");
return id;
}
@@ -119,7 +115,7 @@ void PluginConfig::load()
}
}
void PluginConfig::save()
bool PluginConfig::save()
{
const std::string path = plugin_config_file();
@@ -134,7 +130,7 @@ void PluginConfig::save()
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
if (ec) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot create the plugin directory: " << ec.message();
return;
return false;
}
// Write to a PID-suffixed file and rename it into place, so a crash mid-write cannot
@@ -147,15 +143,16 @@ void PluginConfig::save()
file.close();
if (file.fail()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path_pid << "; keeping the existing config";
return;
return false;
}
if (const std::error_code rename_ec = rename_file(path_pid, path)) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to move " << path_pid << " onto " << path << ": " << rename_ec.message();
return;
return false;
}
m_dirty = false;
return true;
}
void PluginConfig::save_config(const std::string& plugin_key,
@@ -164,20 +161,12 @@ void PluginConfig::save_config(const std::string& plugin_key,
const nlohmann::json& config)
{ save_config({plugin_key, capability_name, version, config}); }
bool PluginConfig::save_config_text(const std::string& plugin_key,
const std::string& capability_name,
const std::string& version,
const std::string& config)
bool PluginConfig::store_capability_config(const std::string& plugin_key,
const std::string& capability_name,
const nlohmann::json& config)
{
nlohmann::json parsed = nlohmann::json::parse(config, nullptr, /* allow_exceptions */ false);
if (parsed.is_discarded()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: capability '" << capability_name << "' of plugin '" << plugin_key
<< "' supplied malformed JSON; config not saved";
return false;
}
save_config({plugin_key, capability_name, version, std::move(parsed)});
return true;
save_config({plugin_key, capability_name, running_plugin_version(plugin_key), config});
return save();
}
void PluginConfig::save_config(const BaseConfig& config)
@@ -211,45 +200,27 @@ bool PluginConfig::dirty() const
return m_dirty;
}
void PluginConfig::RegisterBindings(pybind11::module_& m)
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability)
{
namespace py = pybind11;
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config");
auto config_submodule = m.def_submodule("config", "Per-capability configuration storage");
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability_name);
// Never saved: hand back an empty object so a plugin can index the result unconditionally.
return config.empty() ? nlohmann::json::object() : config.config;
}
// Config crosses this boundary as a JSON string, not a dict: the host does not care about
// the shape of cap_config, so it never builds Python objects out of it. Plugins hand the
// string to json.loads/json.dumps themselves.
config_submodule.def(
"get_config",
[]() -> py::object {
const auto [plugin_key, capability] = calling_capability("get_config");
std::string capability_get_config_version(const PluginCapabilityInterface& capability)
{
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config_version");
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability);
if (config.empty())
return py::none();
return PluginManager::instance().get_config().get_config(plugin_key, capability_name).plugin_version;
}
return py::str(config_to_entry(config).dump());
},
R"pbdoc(Return this capability's stored config as a JSON string, or None if it has never been saved.
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config)
{
const auto [plugin_key, capability_name] = capability_identity(capability, "save_config");
The object mirrors the on-disk entry: "plugin_key", "capability", "plugin_version" and
"cap_config". "plugin_version" is the version that last wrote the entry, so a plugin can
compare it against its own and migrate "cap_config" before use.)pbdoc");
config_submodule.def(
"save_config",
[](const std::string& cap_config) {
const auto [plugin_key, capability] = calling_capability("save_config");
return PluginManager::instance().get_config().save_config_text(plugin_key, capability, running_plugin_version(plugin_key),
cap_config);
},
py::arg("cap_config"),
R"pbdoc(Store this capability's config, given as a JSON string.
The plugin key, capability name and plugin version are supplied by the host. Returns False
without storing anything if `cap_config` is not valid JSON.)pbdoc");
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
}
} // namespace Slic3r

View File

@@ -11,12 +11,10 @@
#define PLUGIN_CONFIG_DIR "config.json"
namespace pybind11 {
class module_;
}
namespace Slic3r {
class PluginCapabilityInterface;
/*
Example config.json shape
{
@@ -86,16 +84,17 @@ public:
void load();
// Rewrites config.json atomically. Clears the dirty flag only once the file is in place.
void save();
// False means the config on disk is unchanged.
bool save();
void save_config(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const nlohmann::json& config);
void save_config(const BaseConfig& config);
// Parses `config` as a JSON document, storing nothing and returning false if it is
// malformed. Spelled differently from save_config() on purpose: nlohmann::json converts
// implicitly from const char*, so a `save_config(..., "{}")` overload pair would be
// ambiguous, and a raw string would silently store as a JSON string rather than an object.
bool save_config_text(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const std::string& config);
// Replaces one capability's cap_config and writes config.json straight away, stamping the
// entry with the plugin version currently running. Every other entry is round-tripped
// untouched, so saving one capability cannot disturb another's config.
// The single mutation entry point for both the Plugins dialog and the Python binding.
bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config);
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
// no stored config.
@@ -104,8 +103,6 @@ public:
bool dirty() const;
static void RegisterBindings(pybind11::module_& module);
private:
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably.
using CapabilityId = std::pair<std::string, std::string>;
@@ -114,4 +111,19 @@ private:
std::map<CapabilityId, BaseConfig> m_storage;
bool m_dirty = false;
};
// Host implementations behind the capability-level Python config API (bound onto every
// capability class in PythonPluginBridge). The capability addresses only itself: the
// (plugin_key, capability_name) pair is read off the instance the call arrived on, never
// passed in from Python, so a capability cannot reach another capability's config.
// Throw std::runtime_error (surfacing to Python as RuntimeError) on an unmaterialized instance.
// Only the user-editable cap_config. An empty object when nothing has been saved yet.
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability);
// The plugin version that last wrote the entry, so a plugin can migrate a stale cap_config.
// Empty when the capability has no stored config.
std::string capability_get_config_version(const PluginCapabilityInterface& capability);
// Replaces cap_config and persists. Host-managed identity and version metadata are preserved.
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config);
} // namespace Slic3r

View File

@@ -2,6 +2,7 @@
#include "PluginAuditManager.hpp"
#include "PythonInterpreter.hpp" // PythonGILState
#include "PythonJsonUtils.hpp" // json_to_py / py_to_json
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp>
@@ -34,63 +35,6 @@ using json = nlohmann::json;
namespace Slic3r {
namespace {
// --------------------------------------------------------------------------
// JSON <-> Python conversion (caller must hold the GIL).
// --------------------------------------------------------------------------
py::object json_to_py(const json& j)
{
switch (j.type()) {
case json::value_t::null: return py::none();
case json::value_t::boolean: return py::bool_(j.get<bool>());
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
case json::value_t::number_float: return py::float_(j.get<double>());
case json::value_t::string: return py::str(j.get<std::string>());
case json::value_t::array: {
py::list lst;
for (const auto& e : j)
lst.append(json_to_py(e));
return lst;
}
case json::value_t::object: {
py::dict d;
for (auto it = j.begin(); it != j.end(); ++it)
d[py::str(it.key())] = json_to_py(it.value());
return d;
}
default: return py::none();
}
}
json py_to_json(const py::handle& o)
{
if (o.is_none())
return json(nullptr);
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
return o.cast<bool>();
if (py::isinstance<py::int_>(o))
return o.cast<std::int64_t>();
if (py::isinstance<py::float_>(o))
return o.cast<double>();
if (py::isinstance<py::str>(o))
return o.cast<std::string>();
if (py::isinstance<py::bytes>(o))
return o.cast<std::string>();
if (py::isinstance<py::dict>(o)) {
json obj = json::object();
for (auto item : py::reinterpret_borrow<py::dict>(o))
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
return obj;
}
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
json arr = json::array();
for (auto e : o)
arr.push_back(py_to_json(e));
return arr;
}
return py::str(o).cast<std::string>(); // fallback: str()
}
// --------------------------------------------------------------------------
// GIL-safe holder for a Python callable. A std::function that captured a bare
// py::object could be destroyed on the main thread without the GIL (a dialog

View File

@@ -974,6 +974,22 @@ void PluginLoader::load_plugin_impl(PluginCatalog& catalog, const std::string& p
loaded_cap->instance->set_audit_plugin_key(descriptor.plugin_key);
loaded_cap->instance->set_audit_capability_name(loaded_cap->name);
// Cache the config-UI hook once, here, so the Plugins dialog can build the Config
// tab without reaching into Python (and without the GIL). It is optional and
// plugin-authored: a raising or non-bool override only costs this capability its
// custom UI (it falls back to the host's JSON editor) — it must not fail the whole
// plugin load, so it is caught locally rather than falling through to the
// materialization handler below.
try {
loaded_cap->has_config_ui = loaded_cap->instance->has_config_ui();
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << loaded_cap->name << "' of plugin '" << loaded_cap->plugin_key
<< "': has_config_ui() failed (" << ex.what() << "); falling back to the default JSON editor";
loaded_cap->has_config_ui = false;
}
capability_types.push_back(loaded_cap->type);
loaded.capabilities.push_back(capability_id);
capabilities.emplace_back(std::move(loaded_cap));

View File

@@ -32,6 +32,7 @@ struct LoadedPluginCapability
std::string name; // Cached from instance->get_name() at load time
std::string plugin_key; // Owning package
PluginCapabilityType type = PluginCapabilityType::Unknown; // cached from instance->get_type() at load (GUI reads this without the GIL)
bool has_config_ui = false; // cached from instance->has_config_ui() at load (GUI reads this without the GIL)
std::atomic<bool> enabled{true}; // logical enable/disable; disabled capabilities are skipped by consumers but stay loaded
};

View File

@@ -122,6 +122,12 @@ bool PluginManager::initialize()
m_initialized = true;
// Bring every capability's stored config into memory. Deliberately unconditional and
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
// plugin that comes back later finds its settings intact. A missing or malformed file just
// leaves the store empty (see PluginConfig::load), never blocking startup.
m_config.load();
// Persist auto-load / capability state to each plugin's .install_state.json sidecar.
// On load: write enabled=true plus current capability flags. On unload: flip enabled=false.
// The on-unload callback is skipped during shutdown (run_on_unload_callbacks is gated by
@@ -169,6 +175,12 @@ void PluginManager::shutdown()
m_loader.unload_all_plugins();
PythonPluginBridge::instance().clear_pending_captures();
// Every config write already goes to disk as it happens (store_capability_config), so this
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
// plugins above must never discard their stored config.
if (m_config.dirty())
m_config.save();
{
std::lock_guard<std::mutex> lock(m_mutex);
m_initialized = false;

View File

@@ -64,6 +64,32 @@ public:
get_name);
}
// Config UI hooks. Available on every capability type, so they live here rather than in
// PyPluginInterfaceTrampoline. Audited like any other C++ -> Python call; a Python
// exception is logged with its traceback and rethrown, and the caller (PluginLoader at
// load time, PluginsDialog when opening the Config tab) decides the fallback.
bool has_config_ui() const override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {},
PYBIND11_OVERRIDE,
bool,
Base,
has_config_ui);
}
std::string get_config_ui() const override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[] {},
PYBIND11_OVERRIDE,
std::string,
Base,
get_config_ui);
}
// All plugins may define their own on_load/unload functions.
void on_load() override
{

View File

@@ -0,0 +1,75 @@
#pragma once
#include <nlohmann/json.hpp>
#include <pybind11/pybind11.h>
#include <cstdint>
#include <string>
namespace Slic3r {
// JSON <-> Python conversion shared by the plugin bindings. The caller must hold the GIL.
// Plugin config and orca.host.ui payloads both cross the boundary as plain JSON-compatible
// values, so both go through these.
inline pybind11::object json_to_py(const nlohmann::json& j)
{
namespace py = pybind11;
using json = nlohmann::json;
switch (j.type()) {
case json::value_t::null: return py::none();
case json::value_t::boolean: return py::bool_(j.get<bool>());
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
case json::value_t::number_float: return py::float_(j.get<double>());
case json::value_t::string: return py::str(j.get<std::string>());
case json::value_t::array: {
py::list lst;
for (const auto& e : j)
lst.append(json_to_py(e));
return lst;
}
case json::value_t::object: {
py::dict d;
for (auto it = j.begin(); it != j.end(); ++it)
d[py::str(it.key())] = json_to_py(it.value());
return d;
}
default: return py::none();
}
}
inline nlohmann::json py_to_json(const pybind11::handle& o)
{
namespace py = pybind11;
using json = nlohmann::json;
if (o.is_none())
return json(nullptr);
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
return o.cast<bool>();
if (py::isinstance<py::int_>(o))
return o.cast<std::int64_t>();
if (py::isinstance<py::float_>(o))
return o.cast<double>();
if (py::isinstance<py::str>(o))
return o.cast<std::string>();
if (py::isinstance<py::bytes>(o))
return o.cast<std::string>();
if (py::isinstance<py::dict>(o)) {
json obj = json::object();
for (auto item : py::reinterpret_borrow<py::dict>(o))
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
return obj;
}
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
json arr = json::array();
for (auto e : o)
arr.push_back(py_to_json(e));
return arr;
}
return py::str(o).cast<std::string>(); // fallback: str()
}
} // namespace Slic3r

View File

@@ -11,6 +11,7 @@
#include <pybind11/stl.h>
#include "PythonInterpreter.hpp"
#include "PythonJsonUtils.hpp"
#include "PluginConfig.hpp"
#include "PluginHostApi.hpp"
#include "PyPluginPackage.hpp"
@@ -320,12 +321,45 @@ void bind_python_api(pybind11::module_& m)
.def_static("skipped", &ExecutionResult::skipped, py::arg("message") = std::string())
.def_static("failure", &ExecutionResult::failure, py::arg("status"), py::arg("message"), py::arg("data") = std::string());
// Config lives at the capability level, not as a global orca.* function: the host reads the
// owning (plugin_key, capability) straight off the instance the call arrived on, so a
// capability can only ever address its own config and never has to name itself.
// Registered on the base, so every capability type (script/gcode/printer-agent) inherits it.
py::class_<PluginCapabilityInterface, PyPluginInterfaceTrampoline, std::shared_ptr<PluginCapabilityInterface>>(m, "PythonPluginBase")
.def(py::init<>())
.def("get_name", &PluginCapabilityInterface::get_name)
.def("get_type", &PluginCapabilityInterface::get_type)
.def("on_load", &PluginCapabilityInterface::on_load)
.def("on_unload", &PluginCapabilityInterface::on_unload);
.def("on_unload", &PluginCapabilityInterface::on_unload)
.def("has_config_ui", &PluginCapabilityInterface::has_config_ui,
"Override to return True to replace the host's default JSON editor with your own HTML\n"
"UI, returned by get_config_ui(). Every capability is configurable and appears in the\n"
"Plugins dialog's Config tab regardless; this only chooses how its config is edited.")
.def("get_config_ui", &PluginCapabilityInterface::get_config_ui,
"Override to return the custom configuration UI as an HTML string. Only called when\n"
"has_config_ui() is True; an empty result falls back to the default JSON editor.\n"
"Inside the page, use window.orca.getConfig()/saveConfig() to reach this same config.")
.def(
"get_config",
[](const PluginCapabilityInterface& self) {
nlohmann::json config = capability_get_config(self);
return json_to_py(config); // GIL held (binding body)
},
"Return this capability's stored config. An empty dict if it has never been saved.")
.def(
"get_config_version",
[](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
"Return the plugin version that last wrote this capability's config, so a newer\n"
"release can spot a stale config and migrate it. Empty string if never saved.")
.def(
"save_config",
[](const PluginCapabilityInterface& self, const py::object& config) {
return capability_save_config(self, py_to_json(config)); // GIL held (binding body)
},
py::arg("config"),
"Persist this capability's config, given as a JSON-compatible value (usually a dict).\n"
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
"the config file could not be written.");
// Expose the package marker base as orca.base. @orca.plugin later verifies that the
// decorated class derives from this exact pybind-registered C++ type.
@@ -340,7 +374,6 @@ void bind_python_api(pybind11::module_& m)
PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes);
ScriptPluginCapability::RegisterBindings(m, pluginTypes);
PluginHostApi::RegisterBindings(m);
PluginConfig::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
m.def(

View File

@@ -103,8 +103,18 @@ public:
// Optional APIs
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; }
virtual bool has_config() const { return false; }
virtual std::string embed_config_ui() const { return ""; }
// Every capability is configurable: it always appears in the Plugins dialog's Config
// sidebar and always has the host's default JSON editor over its stored config. The only
// question a capability answers is whether it supplies its own UI to edit that config
// *instead of* the JSON editor.
//
// True when the capability ships a custom configuration UI. get_config_ui() is called
// only when this returns true.
virtual bool has_config_ui() const { return false; }
// An HTML snippet for the custom configuration UI. An empty or throwing result is
// treated as "no custom UI" and falls back to the default JSON editor.
virtual std::string get_config_ui() const { return ""; }
virtual void on_load() {}
virtual void on_unload() {}