diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index bca59a9a29..881cd38e21 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -86,6 +86,8 @@ aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info Description + Config Changelog No description available + + Select a plugin to configure its capabilities + + + + + + + + + + + + + + diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 238de8ec9a..4e9333a8d0 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -21,6 +21,14 @@ let selectedPluginId = ""; let contextPluginId = ""; let activeDetailTab = "plugin-info"; let selectedInstallAction = "explore"; +// Config tab: the capability whose config is currently shown, scoped to selectedPluginId. Name and +// type together address the capability on the native side. Cleared whenever the plugin changes. +let selectedCapabilityName = ""; +let selectedCapabilityType = ""; +// The plugin the capability selection above belongs to. Capability names are only unique within a +// plugin, so two plugins can each expose e.g. a "main" script capability; without remembering the +// owner, selecting the second plugin would keep the selection and show the first plugin's config. +let configPluginId = ""; let pluginList = null; let ctxMenu = null; @@ -60,6 +68,14 @@ function OnInit() { pluginList?.addEventListener("contextmenu", OnPluginContextMenu); ctxMenu?.addEventListener("click", OnContextMenuClick); + document.getElementById("configSidebar")?.addEventListener("click", OnConfigSidebarClick); + document.getElementById("configSaveBtn")?.addEventListener("click", SaveCapabilityConfig); + document.getElementById("configText")?.addEventListener("input", ValidateConfigText); + // The custom capability UI is sandboxed into an opaque origin, so it reaches us only through + // postMessage. Match on the frame's own contentWindow rather than the origin (which is "null" + // for a sandboxed frame) and ignore anything else on the channel. + window.addEventListener("message", OnCustomConfigMessage); + document.addEventListener("click", (event) => { if (!event.target.closest(".ctx")) HideContextMenu(); @@ -227,6 +243,10 @@ function HandleStudio(value) { ApplyPlugins(payload.data || []); } else if (payload.command === "status_message") { ShowStatusMessage(String(payload.message || ""), String(payload.level || "info")); + } else if (payload.command === "capability_config") { + ApplyCapabilityConfig(payload); + } else if (payload.command === "capability_config_saved") { + ApplyCapabilityConfigSaved(payload); } } @@ -785,6 +805,335 @@ function RenderDetails() { ApplyDetailUpdateBadge(detailUpdateBadge, plugin); if (detailUpdateBtn) ApplyDetailUpdateButton(detailUpdateBtn, plugin); + + RenderConfig(plugin); +} + +// --------------------------------------------------------------------------- +// Config tab +// +// The sidebar lists the selected plugin's configurable capabilities; the right side shows either +// the host's JSON editor or, when the capability ships one, its own HTML UI in a sandboxed frame. +// Both edit the same stored config: the page holds no config state of its own, it renders what the +// native side sends and sends back what the user saves. +// --------------------------------------------------------------------------- + +// Every capability is configurable — it always gets at least the default JSON editor over its +// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows +// shown for a plugin that is not activated: those carry no capability name, so there is nothing to +// address on the native side and nothing to configure yet. +function GetConfigurableCapabilities(plugin) { + return GetCapabilities(plugin).filter((capability) => String(capability?.name || "")); +} + +function RenderConfig(plugin) { + const empty = document.getElementById("configEmpty"); + const layout = document.getElementById("configLayout"); + const sidebar = document.getElementById("configSidebar"); + if (!empty || !layout || !sidebar) + return; + + const capabilities = plugin ? GetConfigurableCapabilities(plugin) : []; + const pluginKey = String(plugin?.plugin_key || ""); + + // A different plugin than the one the current selection belongs to: drop the selection outright + // rather than trusting the name to mean the same thing here. + if (pluginKey !== configPluginId) { + configPluginId = pluginKey; + selectedCapabilityName = ""; + selectedCapabilityType = ""; + ClearCapabilityConfigView(); + } + + if (!plugin || capabilities.length === 0) { + // Capabilities are only materialized once the plugin is activated, so an inactive plugin has + // nothing to configure yet — say that, rather than claiming it has no capabilities. + empty.textContent = !plugin + ? "Select a plugin to configure its capabilities" + : (IsPluginLoading(plugin) + ? "Loading the plugin…" + : (GetStatus(plugin) === "Activated" + ? "This plugin exposes no capabilities" + : "Activate this plugin to configure its capabilities")); + empty.hidden = false; + layout.hidden = true; + sidebar.replaceChildren(); + ClearCapabilityConfigView(); + selectedCapabilityName = ""; + selectedCapabilityType = ""; + return; + } + + empty.hidden = true; + layout.hidden = false; + + // Keep the selection across a refresh when the capability is still there; otherwise fall back to + // the first one, which is also the initial selection for a newly selected plugin. + const stillPresent = capabilities.some((capability) => + capability.name === selectedCapabilityName && String(capability.type_key || "") === selectedCapabilityType); + if (!stillPresent) { + selectedCapabilityName = String(capabilities[0].name || ""); + selectedCapabilityType = String(capabilities[0].type_key || ""); + ClearCapabilityConfigView(); + RequestCapabilityConfig(); + } + + sidebar.replaceChildren(); + for (const capability of capabilities) { + const name = String(capability.name || ""); + const typeKey = String(capability.type_key || ""); + const item = document.createElement("button"); + item.type = "button"; + item.className = "config-cap"; + item.dataset.capabilityName = name; + item.dataset.capabilityType = typeKey; + item.setAttribute("role", "option"); + + const isSelected = name === selectedCapabilityName && typeKey === selectedCapabilityType; + item.classList.toggle("selected", isSelected); + item.setAttribute("aria-selected", isSelected ? "true" : "false"); + + const label = document.createElement("span"); + label.className = "config-cap-name"; + label.textContent = name; + item.appendChild(label); + + const type = document.createElement("span"); + type.className = "config-cap-type"; + type.textContent = String(capability.type || ""); + item.appendChild(type); + + sidebar.appendChild(item); + } +} + +function OnConfigSidebarClick(event) { + const item = event.target.closest(".config-cap"); + if (!item) + return; + + const name = String(item.dataset.capabilityName || ""); + const typeKey = String(item.dataset.capabilityType || ""); + if (!name || (name === selectedCapabilityName && typeKey === selectedCapabilityType)) + return; + + selectedCapabilityName = name; + selectedCapabilityType = typeKey; + + // Drop the outgoing capability's view immediately: the native reply is asynchronous and its + // content must never appear under the newly selected capability. + ClearCapabilityConfigView(); + RequestCapabilityConfig(); + RenderConfig(pluginsById.get(selectedPluginId)); +} + +function RequestCapabilityConfig() { + if (!selectedPluginId || !selectedCapabilityName) + return; + + SendMessage("get_capability_config", { + plugin_key: selectedPluginId, + capability_name: selectedCapabilityName, + capability_type: selectedCapabilityType + }); +} + +// Empties both editors, so nothing from the previously selected capability can linger while the +// next one is still in flight. +function ClearCapabilityConfigView() { + const editor = document.getElementById("configEditor"); + const custom = document.getElementById("configCustom"); + const text = document.getElementById("configText"); + const error = document.getElementById("configError"); + + if (editor) + editor.hidden = true; + if (custom) { + custom.hidden = true; + custom.removeAttribute("srcdoc"); + } + if (text) + text.value = ""; + if (error) { + error.hidden = true; + error.textContent = ""; + } + SetConfigValidation(""); +} + +// True when a native reply still matches what the user has selected. A reply for a capability the +// user has already navigated away from is dropped rather than rendered into the current view. +function IsCurrentCapability(payload) { + return String(payload?.plugin_key || "") === selectedPluginId && + String(payload?.capability_name || "") === selectedCapabilityName; +} + +function ApplyCapabilityConfig(payload) { + if (!IsCurrentCapability(payload)) + return; + + const editor = document.getElementById("configEditor"); + const custom = document.getElementById("configCustom"); + const text = document.getElementById("configText"); + const error = document.getElementById("configError"); + + const message = String(payload?.error || ""); + if (error) { + error.textContent = message; + error.hidden = message === ""; + } + + const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {}; + const html = String(payload?.custom_html || ""); + + if (html) { + // A capability with its own UI: hand it the config through the bridge, never the raw file. + if (custom) { + custom.hidden = false; + custom.srcdoc = BuildCustomConfigDocument(html, config); + } + if (editor) + editor.hidden = true; + return; + } + + // Default editor. The native side already reported why a custom UI is unavailable (if it was + // meant to have one) in payload.error, and we fall back to editing the same config here. + if (custom) { + custom.hidden = true; + custom.removeAttribute("srcdoc"); + } + if (editor) + editor.hidden = false; + if (text) + text.value = JSON.stringify(config, null, 2); + SetConfigValidation(""); +} + +function SetConfigValidation(message) { + const node = document.getElementById("configValidation"); + const save = document.getElementById("configSaveBtn"); + if (node) { + node.textContent = message; + node.classList.toggle("invalid", message !== ""); + } + // Invalid JSON can never be saved: the button is the only way to persist, and it is disabled + // while the text does not parse. The native side re-validates regardless. + if (save) + save.disabled = message !== ""; +} + +function ValidateConfigText() { + const text = document.getElementById("configText"); + if (!text) + return false; + + try { + JSON.parse(text.value); + SetConfigValidation(""); + return true; + } catch (err) { + SetConfigValidation(String(err?.message || "Invalid JSON")); + return false; + } +} + +function SaveCapabilityConfig() { + const text = document.getElementById("configText"); + if (!text || !selectedPluginId || !selectedCapabilityName) + return; + if (!ValidateConfigText()) + return; + + // Sent as text on purpose: the native side is the authority on validity and parses it itself. + SendMessage("save_capability_config", { + plugin_key: selectedPluginId, + capability_name: selectedCapabilityName, + capability_type: selectedCapabilityType, + config: text.value + }); +} + +function ApplyCapabilityConfigSaved(payload) { + if (!IsCurrentCapability(payload)) + return; + + const error = document.getElementById("configError"); + const message = String(payload?.error || ""); + if (error) { + error.textContent = message; + error.hidden = message === ""; + } + if (payload?.ok !== true) + return; + + // Reload from what was actually persisted, so both editors show the stored state rather than + // whatever was typed. + const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {}; + const custom = document.getElementById("configCustom"); + const text = document.getElementById("configText"); + + if (custom && !custom.hidden && custom.contentWindow) + custom.contentWindow.postMessage({ __orca: "config", config: config }, "*"); + else if (text) + text.value = JSON.stringify(config, null, 2); + + SetConfigValidation(""); +} + +// The whole host surface a custom config UI gets: read the config it was opened with, save a new +// one, and be told when a save lands. Everything else about the dialog stays out of reach — the +// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host. +function BuildCustomConfigDocument(html, config) { + // The config is inlined into a " would + // otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal + // valid JSON while making that impossible. + const seed = JSON.stringify(config).replace(/ +(function () { + var handlers = []; + var current = ${seed}; + window.orca = { + getConfig: function () { return current; }, + saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); }, + onConfig: function (cb) { + if (typeof cb !== "function") return; + handlers.push(cb); + try { cb(current); } catch (e) {} + } + }; + window.addEventListener("message", function (event) { + if (!event.data || event.data.__orca !== "config") return; + current = event.data.config || {}; + handlers.forEach(function (handler) { + try { handler(current); } catch (e) {} + }); + }); +})(); +<\/script>`; + return bridge + html; +} + +function OnCustomConfigMessage(event) { + const custom = document.getElementById("configCustom"); + // Only the frame we created, and only while it is actually showing. + if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow) + return; + + const data = event.data; + if (!data || data.__orca !== "save") + return; + if (!selectedPluginId || !selectedCapabilityName) + return; + + // The custom UI persists through the same native command as the JSON editor, so there is one + // stored config and one code path that writes it. + SendMessage("save_capability_config", { + plugin_key: selectedPluginId, + capability_name: selectedCapabilityName, + capability_type: selectedCapabilityType, + config: data.config === undefined ? {} : data.config + }); } function RenderThumbnail(plugin) { diff --git a/resources/web/dialog/PluginsDialog/styles.css b/resources/web/dialog/PluginsDialog/styles.css index ef1c3b4d92..4253cc4a3b 100644 --- a/resources/web/dialog/PluginsDialog/styles.css +++ b/resources/web/dialog/PluginsDialog/styles.css @@ -1084,6 +1084,158 @@ body { display: none; } +/* Config tab: fixed-width capability sidebar + the capability's configuration view. */ +.config-layout { + display: grid; + grid-template-columns: 180px 1fr; + gap: 10px; + height: 100%; + min-height: 0; + padding: 6px; + box-sizing: border-box; +} + +.config-layout[hidden] { + display: none; +} + +.config-sidebar { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + overflow-y: auto; + padding-right: 4px; + border-right: 1px solid var(--border-soft); +} + +.config-cap { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.config-cap:hover { + background: var(--row-hover); +} + +.config-cap.selected { + background: var(--row-selected); + border-color: var(--row-selected-outline); +} + +.config-cap-name { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-cap-type { + color: var(--muted); + font-size: 11px; +} + +.config-view { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + min-width: 0; +} + +.config-error { + padding: 6px 8px; + border-radius: 4px; + background: var(--plugin-status-warn-bg); + color: var(--plugin-status-warn); + font-size: 12px; +} + +.config-error[hidden] { + display: none; +} + +.config-editor { + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-height: 0; +} + +.config-editor[hidden] { + display: none; +} + +.config-textarea { + flex: 1; + min-height: 0; + padding: 8px; + box-sizing: border-box; + /* common.css applies `user-select: none` to *, which in WebKit also stops a textarea taking a + caret — the editor would be focusable but impossible to type into. Editable surfaces have to + opt back in (same as include/xterm/xterm.css does for the terminal). */ + -webkit-user-select: text; + user-select: text; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + resize: none; + white-space: pre; + overflow: auto; +} + +.config-textarea:focus { + outline: none; + border-color: var(--main-color); +} + +.config-editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.config-validation { + color: var(--muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-validation.invalid { + color: var(--plugin-status-danger); +} + +.config-custom { + flex: 1; + min-height: 0; + width: 100%; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); +} + +.config-custom[hidden] { + display: none; +} + .detail-table { width: 100%; border-collapse: collapse; diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index a96a903f8c..956a9a27ca 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 070bc36a14..1675871007 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.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(), 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()) { diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 55885262a5..47f868f739 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -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); diff --git a/src/slic3r/plugin/PluginConfig.cpp b/src/slic3r/plugin/PluginConfig.cpp index ca76144138..a3baa7673c 100644 --- a/src/slic3r/plugin/PluginConfig.cpp +++ b/src/slic3r/plugin/PluginConfig.cpp @@ -1,13 +1,11 @@ #include "PluginConfig.hpp" -#include - #include #include #include -#include #include +#include #include #include @@ -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 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 capability_identity(const PluginCapabilityInterface& capability, const char* api_name) { - const PluginAuditManager& audit = PluginAuditManager::instance(); - - std::pair id{audit.current_plugin(), audit.current_capability()}; + std::pair 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 diff --git a/src/slic3r/plugin/PluginConfig.hpp b/src/slic3r/plugin/PluginConfig.hpp index a738af992c..7181768b22 100644 --- a/src/slic3r/plugin/PluginConfig.hpp +++ b/src/slic3r/plugin/PluginConfig.hpp @@ -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; @@ -114,4 +111,19 @@ private: std::map 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 diff --git a/src/slic3r/plugin/PluginHostUi.cpp b/src/slic3r/plugin/PluginHostUi.cpp index 7750b8d641..05978391b9 100644 --- a/src/slic3r/plugin/PluginHostUi.cpp +++ b/src/slic3r/plugin/PluginHostUi.cpp @@ -2,6 +2,7 @@ #include "PluginAuditManager.hpp" #include "PythonInterpreter.hpp" // PythonGILState +#include "PythonJsonUtils.hpp" // json_to_py / py_to_json #include #include @@ -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()); - case json::value_t::number_integer: return py::int_(j.get()); - case json::value_t::number_unsigned: return py::int_(j.get()); - case json::value_t::number_float: return py::float_(j.get()); - case json::value_t::string: return py::str(j.get()); - 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(o)) // bool before int (bool subclasses int in Python) - return o.cast(); - if (py::isinstance(o)) - return o.cast(); - if (py::isinstance(o)) - return o.cast(); - if (py::isinstance(o)) - return o.cast(); - if (py::isinstance(o)) - return o.cast(); - if (py::isinstance(o)) { - json obj = json::object(); - for (auto item : py::reinterpret_borrow(o)) - obj[py::str(item.first).cast()] = py_to_json(item.second); - return obj; - } - if (py::isinstance(o) || py::isinstance(o)) { - json arr = json::array(); - for (auto e : o) - arr.push_back(py_to_json(e)); - return arr; - } - return py::str(o).cast(); // 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 diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 8d99edf55f..062ac23743 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -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)); diff --git a/src/slic3r/plugin/PluginLoader.hpp b/src/slic3r/plugin/PluginLoader.hpp index e3903001e4..225b12d642 100644 --- a/src/slic3r/plugin/PluginLoader.hpp +++ b/src/slic3r/plugin/PluginLoader.hpp @@ -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 enabled{true}; // logical enable/disable; disabled capabilities are skipped by consumers but stay loaded }; diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 4b2ed56d35..c41607cf38 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -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 lock(m_mutex); m_initialized = false; diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index f5bdc902e6..1cb8a6f5ea 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -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 { diff --git a/src/slic3r/plugin/PythonJsonUtils.hpp b/src/slic3r/plugin/PythonJsonUtils.hpp new file mode 100644 index 0000000000..3caaf51041 --- /dev/null +++ b/src/slic3r/plugin/PythonJsonUtils.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +#include +#include + +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()); + case json::value_t::number_integer: return py::int_(j.get()); + case json::value_t::number_unsigned: return py::int_(j.get()); + case json::value_t::number_float: return py::float_(j.get()); + case json::value_t::string: return py::str(j.get()); + 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(o)) // bool before int (bool subclasses int in Python) + return o.cast(); + if (py::isinstance(o)) + return o.cast(); + if (py::isinstance(o)) + return o.cast(); + if (py::isinstance(o)) + return o.cast(); + if (py::isinstance(o)) + return o.cast(); + if (py::isinstance(o)) { + json obj = json::object(); + for (auto item : py::reinterpret_borrow(o)) + obj[py::str(item.first).cast()] = py_to_json(item.second); + return obj; + } + if (py::isinstance(o) || py::isinstance(o)) { + json arr = json::array(); + for (auto e : o) + arr.push_back(py_to_json(e)); + return arr; + } + return py::str(o).cast(); // fallback: str() +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 33e7177dcf..5480a3c4cf 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -11,6 +11,7 @@ #include #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_>(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( diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index 70670480c8..b2cf95ec89 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -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() {}