mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-20 17:32:26 +00:00
feat: plugins config UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
75
src/slic3r/plugin/PythonJsonUtils.hpp
Normal file
75
src/slic3r/plugin/PythonJsonUtils.hpp
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
Reference in New Issue
Block a user