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

@@ -86,6 +86,8 @@
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
@@ -138,6 +140,31 @@
<div id="detailDescription" class="detail-description">No description available</div>
</section>
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
aria-labelledby="configTab" data-panel="config" hidden>
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
<div id="configLayout" class="config-layout" hidden>
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
aria-label="Configurable capabilities"></div>
<div class="config-view">
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
<div id="configEditor" class="config-editor" hidden>
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
<div class="config-editor-footer">
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
</div>
</div>
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
runs in an opaque origin: it cannot touch this page, and the only host surface
it gets is the window.orca getConfig/saveConfig bridge injected into srcdoc. -->
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
</div>
</div>
</section>
<section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
aria-labelledby="changelogTab" data-panel="changelog" hidden>
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">

View File

@@ -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 <script>, so a stored string containing "</script>" 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(/</g, "\\u003c");
const bridge = `<script>
(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) {

View File

@@ -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;

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