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

@@ -606,6 +606,7 @@ set(SLIC3R_GUI_SOURCES
plugin/PluginFsUtils.hpp
plugin/PluginConfig.cpp
plugin/PluginConfig.hpp
plugin/PythonJsonUtils.hpp
plugin/PluginCatalog.cpp
plugin/PluginCatalog.hpp
plugin/PluginLoader.cpp

View File

@@ -68,6 +68,11 @@ struct PluginCapabilityView
bool enabled = false;
bool can_toggle = false;
bool can_run = false;
// Whether the capability supplies its own config UI, cached on the loaded capability at load
// time. False for the descriptor-only rows shown for a plugin that is not loaded: its
// capabilities have not been materialized yet, so there is nothing to configure until it is
// activated. Every real capability is configurable; this only picks the editor.
bool has_config_ui = false;
};
struct PluginChangelogView
@@ -206,12 +211,13 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
nlohmann::json caps = nlohmann::json::array();
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
nlohmann::json c;
c["name"] = capability.name;
c["type"] = capability.type_label;
c["type_key"] = capability.type_key;
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
c["name"] = capability.name;
c["type"] = capability.type_label;
c["type_key"] = capability.type_key;
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
c["has_config_ui"] = capability.has_config_ui;
caps.push_back(std::move(c));
}
payload_item["capabilities"] = std::move(caps);
@@ -355,7 +361,8 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) {
if (cap) {
item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type),
plugin_capability_type_to_string(cap->type), cap->enabled, true, false});
plugin_capability_type_to_string(cap->type), cap->enabled, true, false,
cap->has_config_ui});
if (cap->type == PluginCapabilityType::Script)
item.has_script_capability = true;
}
@@ -518,6 +525,17 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
open_plugin_hub();
} else if (command == "set_plugin_sort") {
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
} else if (command == "get_capability_config") {
send_capability_config(payload.value("plugin_key", ""),
plugin_capability_type_from_string(payload.value("capability_type", "")),
payload.value("capability_name", ""));
} else if (command == "save_capability_config") {
// `config` is a JSON string from the default editor's textarea, or an already-structured
// value from a capability's custom UI. Both land here; save_capability_config sorts it out.
save_capability_config(payload.value("plugin_key", ""),
plugin_capability_type_from_string(payload.value("capability_type", "")),
payload.value("capability_name", ""),
payload.contains("config") ? payload.at("config") : nlohmann::json::object());
} else if (command == "set_plugin_install_action") {
const std::string action = payload.value("action", "");
if (action == "explore" || action == "install-local")
@@ -896,6 +914,127 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
return from_u8(plugin_key);
}
// Replies to the web page with one capability's stored config, plus the custom HTML UI when the
// capability provides one. Config is sent as a JSON value, not text: the default editor
// pretty-prints it into its textarea, and a custom UI receives it as-is through window.orca.
void PluginsDialog::send_capability_config(const std::string& plugin_key,
PluginCapabilityType type,
const std::string& capability_name)
{
nlohmann::json response;
response["command"] = "capability_config";
response["plugin_key"] = plugin_key;
response["capability_name"] = capability_name;
response["capability_type"] = plugin_capability_type_to_string(type);
response["config"] = nlohmann::json::object();
response["custom_html"] = "";
response["error"] = "";
// Scoped to (plugin_key, type, capability_name), so a stale request from a page that has not
// caught up with a refresh cannot read a different plugin's config — it just misses.
auto cap = get_capability(plugin_key, type, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
<< plugin_key << " capability_name=" << capability_name;
response["error"] = into_u8(_L("This capability is no longer available."));
call_web_handler(response);
return;
}
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
if (cap->has_config_ui) {
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
// custom UI: we report the failure and let the page fall back to the default JSON editor,
// which edits the very same stored config.
std::string html;
std::string error;
{
wxBusyCursor busy;
try {
PythonGILState gil;
html = cap->instance->get_config_ui();
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
error = "Unknown error";
}
}
if (!error.empty()) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << plugin_key
<< " capability_name=" << capability_name << " error=" << error;
response["error"] = into_u8(format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
from_u8(error)));
} else if (html.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
response["error"] = into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
} else {
response["custom_html"] = html;
}
}
call_web_handler(response);
}
// Persists one capability's config. `config` arrives either as text straight from the default
// editor (validated here, so invalid JSON can never reach the file) or as a structured value from
// a custom UI. Only cap_config is written; identity and version metadata stay host-managed.
void PluginsDialog::save_capability_config(const std::string& plugin_key,
PluginCapabilityType type,
const std::string& capability_name,
const nlohmann::json& config)
{
nlohmann::json response;
response["command"] = "capability_config_saved";
response["plugin_key"] = plugin_key;
response["capability_name"] = capability_name;
response["capability_type"] = plugin_capability_type_to_string(type);
response["ok"] = false;
response["error"] = "";
auto cap = get_capability(plugin_key, type, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
<< plugin_key << " capability_name=" << capability_name;
response["error"] = into_u8(_L("This capability is no longer available. Your changes were not saved."));
call_web_handler(response);
return;
}
nlohmann::json parsed = config;
if (config.is_string()) {
// The page validates as the user types, but it is not the authority: re-parse here so a
// malformed document is rejected before it can reach config.json.
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
if (parsed.is_discarded()) {
response["error"] = into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
call_web_handler(response);
return;
}
}
if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, parsed)) {
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
response["error"] = into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
call_web_handler(response);
return;
}
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
// Echo the persisted value back so the editor reloads from what is actually stored rather
// than from what the user typed.
response["ok"] = true;
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
call_web_handler(response);
show_status(_L("Configuration saved."), "success");
}
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
{
if (plugin_key.empty() || capability_name.empty()) {

View File

@@ -67,6 +67,14 @@ private:
bool install_plugin_package(const std::string& package_path);
bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name);
void run_script_plugin(const std::string& plugin_key, const std::string& capability_name);
// Config tab. Both are scoped to (plugin_key, type, capability_name): a request naming a
// capability that is gone or not configurable is refused rather than served from, or written
// to, some other entry.
void send_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name);
void save_capability_config(const std::string& plugin_key,
PluginCapabilityType type,
const std::string& capability_name,
const nlohmann::json& config);
// Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"),
// used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive.
void show_status(const wxString& message, const char* level);

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() {}