mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 15:03:49 +00:00
feat: sidebar plugin config UI
This commit is contained in:
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Plugin configuration</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<!-- Same three shared sheets every resources/web/dialog/* page links (see PluginsDialog/index.html):
|
||||
global.css for buttons/scrollbars, common.css for the base reset, theme.css last so its
|
||||
host-injected --orca-* contract wins and themes the .config-* rules ported into styles.css. -->
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page-header">
|
||||
<h1 id="pageTitle" class="page-title">Plugin configuration</h1>
|
||||
<p id="pageSubtitle" class="page-subtitle"></p>
|
||||
</header>
|
||||
|
||||
<div id="configEmpty" class="detail-empty">This preset does not use any plugin capabilities</div>
|
||||
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Capabilities used by this preset"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configMeta" class="config-meta" hidden>
|
||||
<span id="configSourceBadge" class="config-source-badge"></span>
|
||||
</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>
|
||||
<!-- 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 id="configFooter" class="config-view-footer" hidden>
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<div class="config-actions">
|
||||
<button id="configUseGlobalBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Remove the preset override and use the global configuration again">
|
||||
Use global
|
||||
</button>
|
||||
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Write the plugin defaults into this preset override">
|
||||
Restore defaults
|
||||
</button>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="statusBar" class="status-bar is-empty">
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
@@ -0,0 +1,499 @@
|
||||
// The capabilities the active preset uses, as sent by PluginsConfigDialog::send_capabilities.
|
||||
// Each row is {plugin_key, name, type, type_key, has_config_ui} — the shape
|
||||
// PluginConfig::capabilities_payload emits, shared with the Plugins dialog's Config tab.
|
||||
let capabilities = [];
|
||||
|
||||
// The selected row's full identity. plugin_key is part of it because this list spans plugins,
|
||||
// unlike the Plugins dialog's config tab where every row belongs to the one selected plugin.
|
||||
let selectedPluginKey = "";
|
||||
let selectedCapabilityName = "";
|
||||
let selectedCapabilityType = "";
|
||||
let selectedHasPresetOverride = false;
|
||||
let selectedReadOnly = false;
|
||||
|
||||
function SafeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function SendWXMessage(message) {
|
||||
if (window.wx && typeof window.wx.postMessage === "function")
|
||||
window.wx.postMessage(message);
|
||||
}
|
||||
|
||||
function SendMessage(command, payload = {}) {
|
||||
const message = {
|
||||
sequence_id: Math.round(Date.now() / 1000),
|
||||
command: command
|
||||
};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
message[key] = payload[key];
|
||||
});
|
||||
SendWXMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function HandleStudio(value) {
|
||||
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
|
||||
if (!payload || typeof payload !== "object")
|
||||
return;
|
||||
|
||||
if (payload.command === "list_capabilities") {
|
||||
ApplyCapabilities(payload);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowStatusMessage(message, level) {
|
||||
const bar = document.getElementById("statusBar");
|
||||
const text = document.getElementById("statusText");
|
||||
if (!bar || !text)
|
||||
return;
|
||||
|
||||
const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
|
||||
text.textContent = message;
|
||||
text.title = message;
|
||||
bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
|
||||
bar.classList.add(`level-${normalizedLevel}`);
|
||||
}
|
||||
|
||||
function ApplyCapabilities(payload) {
|
||||
capabilities = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
const title = document.getElementById("pageTitle");
|
||||
if (title)
|
||||
title.textContent = String(payload.title || "Plugin configuration");
|
||||
|
||||
const subtitle = document.getElementById("pageSubtitle");
|
||||
if (subtitle)
|
||||
subtitle.textContent = String(payload.preset_name || "");
|
||||
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
function IsSameCapability(capability) {
|
||||
return String(capability.plugin_key || "") === selectedPluginKey
|
||||
&& String(capability.name || "") === selectedCapabilityName
|
||||
&& String(capability.type_key || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RenderCapabilities() {
|
||||
const empty = document.getElementById("configEmpty");
|
||||
const layout = document.getElementById("configLayout");
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (!empty || !layout || !sidebar)
|
||||
return;
|
||||
|
||||
if (capabilities.length === 0) {
|
||||
empty.hidden = false;
|
||||
layout.hidden = true;
|
||||
sidebar.replaceChildren();
|
||||
ClearCapabilityConfigView();
|
||||
selectedPluginKey = "";
|
||||
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.
|
||||
if (!capabilities.some(IsSameCapability)) {
|
||||
selectedPluginKey = String(capabilities[0].plugin_key || "");
|
||||
selectedCapabilityName = String(capabilities[0].name || "");
|
||||
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
}
|
||||
|
||||
sidebar.replaceChildren();
|
||||
for (const capability of capabilities) {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "config-cap";
|
||||
item.dataset.pluginKey = String(capability.plugin_key || "");
|
||||
item.dataset.capabilityName = String(capability.name || "");
|
||||
item.dataset.capabilityType = String(capability.type_key || "");
|
||||
item.setAttribute("role", "option");
|
||||
|
||||
const isSelected = IsSameCapability(capability);
|
||||
item.classList.toggle("selected", isSelected);
|
||||
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "config-cap-name";
|
||||
label.textContent = String(capability.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 pluginKey = String(item.dataset.pluginKey || "");
|
||||
const name = String(item.dataset.capabilityName || "");
|
||||
const typeKey = String(item.dataset.capabilityType || "");
|
||||
if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||
return;
|
||||
|
||||
selectedPluginKey = pluginKey;
|
||||
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();
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config editor
|
||||
//
|
||||
// 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. Ported
|
||||
// from PluginsDialog/index.js, whose config tab lives inside a single selected plugin (it reads
|
||||
// selectedPluginId from a page-global); this list spans plugins, so every row carries its own
|
||||
// plugin_key and the page tracks selectedPluginKey instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A reply is only applied when it still matches the selected row. The native side is asynchronous,
|
||||
// and unlike the Plugins dialog this list spans plugins, so plugin_key is part of the match.
|
||||
function IsCurrentCapability(payload) {
|
||||
return String(payload?.plugin_key || "") === selectedPluginKey
|
||||
&& String(payload?.capability_name || "") === selectedCapabilityName
|
||||
&& String(payload?.capability_type || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("get_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
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. The footer goes with them: until a config has actually loaded there
|
||||
// is nothing to save or restore.
|
||||
function ClearCapabilityConfigView() {
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
const footer = document.getElementById("configFooter");
|
||||
const meta = document.getElementById("configMeta");
|
||||
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (text)
|
||||
text.value = "";
|
||||
if (error) {
|
||||
error.hidden = true;
|
||||
error.textContent = "";
|
||||
}
|
||||
if (footer)
|
||||
footer.hidden = true;
|
||||
if (meta)
|
||||
meta.hidden = true;
|
||||
selectedHasPresetOverride = false;
|
||||
selectedReadOnly = false;
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
function UpdateConfigMeta(payload) {
|
||||
selectedHasPresetOverride = payload?.has_preset_override === true;
|
||||
selectedReadOnly = payload?.read_only === true;
|
||||
|
||||
const meta = document.getElementById("configMeta");
|
||||
const badge = document.getElementById("configSourceBadge");
|
||||
const useGlobal = document.getElementById("configUseGlobalBtn");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
const restore = document.getElementById("configRestoreBtn");
|
||||
if (!meta || !badge || !useGlobal)
|
||||
return;
|
||||
|
||||
const source = String(payload?.source || "none");
|
||||
badge.textContent = source === "preset" ? "Preset override" :
|
||||
(source === "base" ? "Inherited from global configuration" : "No saved configuration");
|
||||
useGlobal.hidden = !selectedHasPresetOverride;
|
||||
useGlobal.disabled = selectedReadOnly;
|
||||
if (save)
|
||||
save.disabled = selectedReadOnly;
|
||||
if (restore)
|
||||
restore.disabled = selectedReadOnly;
|
||||
meta.hidden = false;
|
||||
}
|
||||
|
||||
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 && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||
const html = String(payload?.custom_html || "");
|
||||
UpdateConfigMeta(payload);
|
||||
|
||||
// Restore is host chrome and applies to either editor; Save and the validation message belong to
|
||||
// the JSON editor, since a custom UI saves through its own controls via the bridge.
|
||||
ShowConfigFooter(!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("");
|
||||
}
|
||||
|
||||
// Reveals the footer for the loaded capability. `withEditorControls` is false for a custom UI,
|
||||
// leaving Restore on its own.
|
||||
function ShowConfigFooter(withEditorControls) {
|
||||
const footer = document.getElementById("configFooter");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
const validation = document.getElementById("configValidation");
|
||||
|
||||
if (footer)
|
||||
footer.hidden = false;
|
||||
if (save)
|
||||
save.hidden = !withEditorControls;
|
||||
if (validation)
|
||||
validation.hidden = !withEditorControls;
|
||||
}
|
||||
|
||||
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 = selectedReadOnly || 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() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
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: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: text.value
|
||||
});
|
||||
}
|
||||
|
||||
// Asks the native side to write the capability's default config over whatever is stored. The
|
||||
// defaults come from the capability's get_default_config(), never from this page — the host does not
|
||||
// know what a given plugin considers default. The native side confirms before discarding anything,
|
||||
// and replies with the same "saved" payload, so both editors reload from what was persisted.
|
||||
function RestoreCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("restore_preset_defaults", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
function UseGlobalCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
|
||||
return;
|
||||
|
||||
SendMessage("remove_preset_override", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
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 && Object.prototype.hasOwnProperty.call(payload, "config") ? 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 (!selectedPluginKey || !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: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: data.config === undefined ? {} : data.config
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (sidebar)
|
||||
sidebar.addEventListener("click", OnConfigSidebarClick);
|
||||
|
||||
const saveBtn = document.getElementById("configSaveBtn");
|
||||
if (saveBtn)
|
||||
saveBtn.addEventListener("click", SaveCapabilityConfig);
|
||||
|
||||
const restoreBtn = document.getElementById("configRestoreBtn");
|
||||
if (restoreBtn)
|
||||
restoreBtn.addEventListener("click", RestoreCapabilityConfig);
|
||||
|
||||
const useGlobalBtn = document.getElementById("configUseGlobalBtn");
|
||||
if (useGlobalBtn)
|
||||
useGlobalBtn.addEventListener("click", UseGlobalCapabilityConfig);
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (text)
|
||||
text.addEventListener("input", ValidateConfigText);
|
||||
|
||||
// The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
|
||||
// postMessage. OnCustomConfigMessage matches on the frame's own contentWindow rather than the
|
||||
// origin (which is "null" for a sandboxed frame) and ignores anything else on the channel.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
|
||||
SendMessage("request_capabilities");
|
||||
});
|
||||
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
@@ -0,0 +1,278 @@
|
||||
/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
|
||||
the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
|
||||
...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
|
||||
the same three every resources/web/dialog/* page links). This file only carries what is unique
|
||||
to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
|
||||
config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
|
||||
*/
|
||||
|
||||
/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
|
||||
dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header { flex: 0 0 auto; }
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
|
||||
|
||||
.detail-empty {
|
||||
padding: 18px 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.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-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-meta[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.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 *, so without this the user could type into the
|
||||
editor but not select, drag or copy what they had typed. */
|
||||
-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-custom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-custom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-view-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-view-footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-actions > button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-validation {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-validation.invalid {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 6px 14px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-bar.level-success .status-text {
|
||||
color: var(--plugin-status-ok);
|
||||
}
|
||||
|
||||
.status-bar.level-error .status-text {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
.status-bar.level-warn .status-text {
|
||||
color: var(--plugin-status-warn);
|
||||
}
|
||||
@@ -504,9 +504,8 @@ function HasMixedCapabilityState(plugin) {
|
||||
String(capability?.type_key || "")
|
||||
);
|
||||
|
||||
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
|
||||
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
|
||||
return hasEnabled && hasDisabled;
|
||||
return toggleableCapabilities.length > 0 && hasDisabled;
|
||||
}
|
||||
|
||||
function IsPluginLoading(plugin) {
|
||||
@@ -826,12 +825,12 @@ function RenderDetails() {
|
||||
// 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.
|
||||
// The config sidebar's rows, built natively by PluginConfig::capabilities_payload and shared with
|
||||
// PluginsConfigDialog. Only loaded, addressable capabilities appear: the descriptor-only rows the
|
||||
// list tab shows for a plugin that is not activated carry no capability name, so there is nothing to
|
||||
// configure and nothing to address on the native side.
|
||||
function GetConfigurableCapabilities(plugin) {
|
||||
return GetCapabilities(plugin).filter((capability) => String(capability?.name || ""));
|
||||
return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
|
||||
}
|
||||
|
||||
function RenderConfig(plugin) {
|
||||
|
||||
@@ -2228,6 +2228,8 @@ public:
|
||||
// Vector value, but edited as a single string.
|
||||
one_string,
|
||||
plugin_picker,
|
||||
// Raw JSON string value, edited through a dialog behind a button rather than in the row.
|
||||
plugin_config,
|
||||
};
|
||||
|
||||
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
|
||||
|
||||
@@ -1194,6 +1194,7 @@ static std::vector<std::string> s_Preset_print_options{
|
||||
"post_process",
|
||||
"slicing_pipeline_plugin",
|
||||
"plugins",
|
||||
"plugin_preference_overrides",
|
||||
"process_change_extrusion_role_gcode",
|
||||
"min_length_factor",
|
||||
"wall_maximum_resolution",
|
||||
@@ -1345,7 +1346,8 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
|
||||
"filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature",
|
||||
//BBS filament change length while the extruder color
|
||||
"filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower",
|
||||
"long_retractions_when_ec", "retraction_distances_when_ec"
|
||||
"long_retractions_when_ec", "retraction_distances_when_ec",
|
||||
"plugin_preference_overrides"
|
||||
};
|
||||
|
||||
static std::vector<std::string> s_Preset_machine_limits_options {
|
||||
@@ -1384,7 +1386,8 @@ static std::vector<std::string> s_Preset_printer_options {
|
||||
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower",
|
||||
"z_offset",
|
||||
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
|
||||
"bed_temperature_formula", "nozzle_flush_dataset"
|
||||
"bed_temperature_formula", "nozzle_flush_dataset",
|
||||
"plugin_preference_overrides"
|
||||
};
|
||||
|
||||
static std::vector<std::string> s_Preset_sla_print_options {
|
||||
|
||||
@@ -929,6 +929,17 @@ void PrintConfigDef::init_common_params()
|
||||
def = this->add("preset_name", coString);
|
||||
def->set_default_value(new ConfigOptionString());
|
||||
}
|
||||
|
||||
def = this->add("plugin_preference_overrides", coString);
|
||||
def->label = L("Plugin configuration");
|
||||
def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global "
|
||||
"plugin configuration. Stored as a raw JSON array and edited through the dialog "
|
||||
"behind the button, never typed in directly.");
|
||||
// Never shown as a text field: plugin_config renders a button that opens PluginsConfigDialog.
|
||||
def->gui_type = ConfigOptionDef::GUIType::plugin_config;
|
||||
def->mode = comAdvanced;
|
||||
def->cli = ConfigOptionDef::nocli;
|
||||
def->set_default_value(new ConfigOptionString(""));
|
||||
}
|
||||
|
||||
void PrintConfigDef::init_fff_params()
|
||||
|
||||
@@ -116,6 +116,8 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/PluginPickerDialog.hpp
|
||||
GUI/PluginsDialog.cpp
|
||||
GUI/PluginsDialog.hpp
|
||||
GUI/PluginsConfigDialog.cpp
|
||||
GUI/PluginsConfigDialog.hpp
|
||||
GUI/ProcessRunner.cpp
|
||||
GUI/ProcessRunner.hpp
|
||||
GUI/TerminalDialog.cpp
|
||||
@@ -613,8 +615,12 @@ set(SLIC3R_GUI_SOURCES
|
||||
plugin/CloudPluginService.hpp
|
||||
plugin/PluginFsUtils.cpp
|
||||
plugin/PluginFsUtils.hpp
|
||||
plugin/CapabilityConfigDocument.cpp
|
||||
plugin/CapabilityConfigDocument.hpp
|
||||
plugin/PluginConfig.cpp
|
||||
plugin/PluginConfig.hpp
|
||||
plugin/PresetPluginConfig.cpp
|
||||
plugin/PresetPluginConfig.hpp
|
||||
plugin/PythonJsonUtils.hpp
|
||||
plugin/PluginCatalog.cpp
|
||||
plugin/PluginCatalog.hpp
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include "OG_CustomCtrl.hpp"
|
||||
#include "MsgDialog.hpp"
|
||||
#include "BitmapComboBox.hpp"
|
||||
#include "PluginsConfigDialog.hpp"
|
||||
#include "Widgets/Button.hpp"
|
||||
|
||||
// BBS
|
||||
#include "Notebook.hpp"
|
||||
@@ -2308,6 +2310,136 @@ void PluginField::msw_rescale()
|
||||
rebuild_ui();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// The stored text as a document, or an empty array when it is absent or unparseable. Used to compare
|
||||
// two versions of the value semantically: re-serializing an unchanged document can reorder keys or
|
||||
// drop whitespace, and that alone must not be allowed to mark the preset dirty.
|
||||
nlohmann::json plugin_overrides_as_json(const std::string& text)
|
||||
{
|
||||
if (text.empty())
|
||||
return nlohmann::json::array();
|
||||
return nlohmann::json::parse(text, nullptr, /* allow_exceptions */ false);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PluginConfigField::BUILD()
|
||||
{
|
||||
// The button *is* the field's window (the ColourPicker idiom). Wrapping it in a panel would make
|
||||
// this a container that OG_CustomCtrl sizes but never lays out, leaving the button unsized — and
|
||||
// a zero-height row collapses its whole option group out of the page.
|
||||
m_button = new ::Button(m_parent, _L("Configure"));
|
||||
// ButtonType::Parameter is the style for a button sitting next to parameter boxes: it is what
|
||||
// gives the button the same height as the fields above it, which a bare wxButton does not.
|
||||
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
|
||||
|
||||
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
|
||||
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
|
||||
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
|
||||
m_button->SetMinSize(size);
|
||||
m_button->SetSize(size);
|
||||
|
||||
m_button->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { open_dialog(); });
|
||||
m_button->SetToolTip(get_tooltip_text(_L("Configure")));
|
||||
|
||||
window = m_button;
|
||||
|
||||
if (const ConfigOptionString* def = m_opt.get_default_value<ConfigOptionString>())
|
||||
m_json = def->value;
|
||||
|
||||
update_button_label();
|
||||
m_value = m_json;
|
||||
}
|
||||
|
||||
void PluginConfigField::update_button_label()
|
||||
{
|
||||
if (m_button == nullptr)
|
||||
return;
|
||||
|
||||
const nlohmann::json entries = plugin_overrides_as_json(m_json);
|
||||
const size_t count = entries.is_array() ? entries.size() : 0;
|
||||
|
||||
// The count is the whole state this row can show: which capabilities they belong to, and what
|
||||
// they hold, is the dialog's job.
|
||||
m_button->SetLabel(count == 0 ? _L("Configure")
|
||||
: wxString::Format(_L("Configure (%d)"), int(count)));
|
||||
}
|
||||
|
||||
void PluginConfigField::open_dialog()
|
||||
{
|
||||
const Preset::Type type = static_cast<Preset::Type>(m_preset_type);
|
||||
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_FILAMENT && type != Preset::TYPE_PRINTER)
|
||||
return;
|
||||
|
||||
std::string edited;
|
||||
{
|
||||
PluginsConfigDialog dlg(m_button, type, m_json);
|
||||
dlg.ShowModal();
|
||||
edited = dlg.overrides_json();
|
||||
}
|
||||
|
||||
// Only a semantic change is a change: reopening the dialog and closing it must not dirty the
|
||||
// preset just because the document round-tripped through the serializer.
|
||||
if (plugin_overrides_as_json(m_json) == plugin_overrides_as_json(edited))
|
||||
return;
|
||||
|
||||
m_json = edited;
|
||||
m_value = m_json;
|
||||
update_button_label();
|
||||
// The one call that makes this behave like every other setting: it drives change_opt_value into
|
||||
// the preset config, Tab::update_dirty(), and the revert arrow.
|
||||
on_change_field();
|
||||
}
|
||||
|
||||
void PluginConfigField::set_value(const boost::any& value, bool change_event)
|
||||
{
|
||||
m_disable_change_event = !change_event;
|
||||
|
||||
if (value.type() == typeid(wxString))
|
||||
m_json = into_u8(boost::any_cast<wxString>(value));
|
||||
else if (value.type() == typeid(std::string))
|
||||
m_json = boost::any_cast<std::string>(value);
|
||||
|
||||
m_value = m_json;
|
||||
update_button_label();
|
||||
|
||||
m_disable_change_event = false;
|
||||
}
|
||||
|
||||
boost::any& PluginConfigField::get_value()
|
||||
{
|
||||
// std::string, not wxString: change_opt_value any_casts a coString to std::string.
|
||||
m_value = m_json;
|
||||
return m_value;
|
||||
}
|
||||
|
||||
void PluginConfigField::enable()
|
||||
{
|
||||
if (m_button)
|
||||
m_button->Enable();
|
||||
}
|
||||
|
||||
void PluginConfigField::disable()
|
||||
{
|
||||
if (m_button)
|
||||
m_button->Disable();
|
||||
}
|
||||
|
||||
void PluginConfigField::msw_rescale()
|
||||
{
|
||||
Field::msw_rescale();
|
||||
if (m_button == nullptr)
|
||||
return;
|
||||
|
||||
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
|
||||
|
||||
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
|
||||
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
|
||||
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
|
||||
m_button->SetMinSize(size);
|
||||
}
|
||||
|
||||
void ColourPicker::BUILD()
|
||||
{
|
||||
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
#define wxMSW false
|
||||
#endif
|
||||
|
||||
// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. Declared at global scope,
|
||||
// which is where the widget lives.
|
||||
class Button;
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
class Field;
|
||||
@@ -517,6 +521,47 @@ private:
|
||||
std::function<std::string()> m_selector;
|
||||
};
|
||||
|
||||
// A settings row whose value is a raw JSON document that nobody should type by hand: the row shows a
|
||||
// button, the button opens PluginsConfigDialog, and the document it hands back becomes the field's
|
||||
// value. Routing the edit through the ordinary Field value/on_change_field path is what earns the row
|
||||
// the same dirty state and revert arrow as every other setting — the dialog itself never touches the
|
||||
// preset.
|
||||
class PluginConfigField : public Field {
|
||||
using Field::Field;
|
||||
public:
|
||||
PluginConfigField(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) {}
|
||||
PluginConfigField(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field(parent, opt, id) {}
|
||||
~PluginConfigField() {}
|
||||
|
||||
void BUILD() override;
|
||||
|
||||
// Which preset's capabilities the dialog lists. Supplied by the option group, which already
|
||||
// carries its Tab's type; an int for the same reason OptionsGroup::m_config_type is one — it
|
||||
// keeps Preset.hpp out of this header.
|
||||
void set_preset_type(int type) { m_preset_type = type; }
|
||||
|
||||
void set_value(const boost::any& value, bool change_event = false) override;
|
||||
boost::any& get_value() override;
|
||||
|
||||
void enable() override;
|
||||
void disable() override;
|
||||
|
||||
// Window-field: the button is the whole field, so it is the window the option group sizes and
|
||||
// positions (the ColourPicker idiom). A container panel would be sized but never laid out.
|
||||
wxWindow* getWindow() override { return window; }
|
||||
|
||||
void msw_rescale() override;
|
||||
|
||||
private:
|
||||
void open_dialog();
|
||||
void update_button_label();
|
||||
|
||||
wxWindow* window { nullptr }; // == m_button; the base class hands this to the option group
|
||||
::Button* m_button { nullptr };
|
||||
std::string m_json; // the option's raw text; "" when the preset overrides nothing
|
||||
int m_preset_type { -1 };
|
||||
};
|
||||
|
||||
class ColourPicker : public Field {
|
||||
using Field::Field;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
||||
break;
|
||||
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
|
||||
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
|
||||
case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create<PluginConfigField>(this->ctrl_parent(), opt, id)); break;
|
||||
default:
|
||||
switch (opt.type) {
|
||||
case coFloatOrPercent:
|
||||
@@ -126,6 +127,13 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
||||
});
|
||||
}
|
||||
|
||||
// The dialog behind the button edits one preset's overrides, so it has to know which preset. The
|
||||
// group already carries its Tab's type, and fields are built lazily on activate() — too late for
|
||||
// the Tab to reach in and set it afterwards.
|
||||
if (auto plugin_config_field = dynamic_cast<PluginConfigField*>(field.get()))
|
||||
if (auto config_group = dynamic_cast<ConfigOptionsGroup*>(this))
|
||||
plugin_config_field->set_preset_type(config_group->config_type());
|
||||
|
||||
// assign function objects for callbacks, etc.
|
||||
return field;
|
||||
}
|
||||
|
||||
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
@@ -0,0 +1,238 @@
|
||||
#include "PluginsConfigDialog.hpp"
|
||||
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "format.hpp"
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
namespace {
|
||||
|
||||
wxString preset_type_title(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return _L("Process plugins");
|
||||
case Preset::TYPE_FILAMENT: return _L("Filament plugins");
|
||||
case Preset::TYPE_PRINTER: return _L("Printer plugins");
|
||||
default: return _L("Plugins");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PluginsConfigDialog::PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json)
|
||||
: WebViewHostDialog(parent, wxID_ANY, preset_type_title(type))
|
||||
, m_type(type)
|
||||
{
|
||||
// Unparseable text is kept, not replaced: parse_plugin_overrides leaves the document empty and
|
||||
// every row goes read-only, so a preset we cannot understand is never silently overwritten.
|
||||
if (!parse_plugin_overrides(overrides_json, m_overrides, m_parse_error))
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugins Config dialog: " << m_parse_error;
|
||||
|
||||
create_webview("web/dialog/PluginsConfigDialog/index.html", preset_type_title(type), wxSize(820, 660),
|
||||
wxSize(640, 520));
|
||||
}
|
||||
|
||||
PluginsConfigDialog::~PluginsConfigDialog() = default;
|
||||
|
||||
const Preset* PluginsConfigDialog::current_preset() const
|
||||
{
|
||||
const PresetBundle* bundle = wxGetApp().preset_bundle;
|
||||
if (bundle == nullptr)
|
||||
return nullptr;
|
||||
|
||||
switch (m_type) {
|
||||
case Preset::TYPE_PRINT: return &bundle->prints.get_edited_preset();
|
||||
case Preset::TYPE_PRINTER: return &bundle->printers.get_edited_preset();
|
||||
case Preset::TYPE_FILAMENT: return &bundle->filaments.get_edited_preset();
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
PluginCapabilityIdentifier PluginsConfigDialog::identifier_from(const nlohmann::json& payload) const
|
||||
{
|
||||
return {plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""),
|
||||
payload.value("plugin_key", "")};
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::on_script_message(const nlohmann::json& payload)
|
||||
{
|
||||
if (handle_common_script_command(payload))
|
||||
return;
|
||||
|
||||
const std::string command = payload.value("command", "");
|
||||
if (command == "request_capabilities") {
|
||||
send_capabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
const PluginCapabilityIdentifier id = identifier_from(payload);
|
||||
|
||||
if (command == "get_capability_config") {
|
||||
send_capability_config(id);
|
||||
} else if (command == "save_capability_config") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json value = payload.contains("config") ? payload.at("config") : nlohmann::json::object();
|
||||
if (value.is_string()) {
|
||||
value = nlohmann::json::parse(value.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||
if (value.is_discarded()) {
|
||||
send_save_error(id, into_u8(_L("The configuration is not valid JSON. Your changes were not saved.")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.set_preset_override(m_overrides, id, value);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Configuration updated. Save the preset to persist it."), "success");
|
||||
} else if (command == "restore_preset_defaults") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
||||
"This writes the plugin defaults into the current preset override."),
|
||||
from_u8(id.name)),
|
||||
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
|
||||
if (rc != wxYES)
|
||||
return;
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap)
|
||||
return;
|
||||
|
||||
nlohmann::json defaults;
|
||||
try {
|
||||
wxBusyCursor busy;
|
||||
PythonGILState gil;
|
||||
defaults = cap->instance->get_default_config();
|
||||
} catch (const std::exception& ex) {
|
||||
// A broken plugin must not be able to wipe the stored config: store nothing and say so.
|
||||
send_save_error(id, into_u8(GUI::format_wxstr(_L("The plugin failed to supply its default configuration (%1%)."),
|
||||
from_u8(ex.what()))));
|
||||
return;
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.set_preset_override(m_overrides, id, defaults);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Default configuration restored. Save the preset to persist it."), "success");
|
||||
} else if (command == "remove_preset_override") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.remove_preset_override(m_overrides, id);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Using global configuration. Save the preset to persist it."), "success");
|
||||
}
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_capabilities()
|
||||
{
|
||||
const Preset* preset = current_preset();
|
||||
if (preset == nullptr)
|
||||
return;
|
||||
|
||||
nlohmann::json response;
|
||||
response["command"] = "list_capabilities";
|
||||
response["preset_type"] = static_cast<int>(m_type);
|
||||
response["title"] = into_u8(preset_type_title(m_type));
|
||||
response["preset_name"] = preset->name;
|
||||
response["data"] = PluginConfig::capabilities_payload(capabilities_in_use(m_type, *preset));
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Prepared " << response["data"].size() << " capability rows for the Plugins Config dialog";
|
||||
call_web_handler(response);
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_capability_config(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
const Preset* preset = current_preset();
|
||||
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["config"] = nlohmann::json::object();
|
||||
response["custom_html"] = "";
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap || preset == nullptr) {
|
||||
response["error"] = into_u8(_L("This capability is no longer available."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const EffectiveCapabilityConfig effective = m_service.get_effective_config(m_overrides, id);
|
||||
response["config"] = effective.config;
|
||||
response["source"] = plugin_config_source_to_string(effective.source);
|
||||
response["has_preset_override"] = effective.has_preset_override;
|
||||
response["has_base_config"] = effective.has_base_config;
|
||||
response["stored_plugin_version"] = effective.stored_plugin_version;
|
||||
response["running_plugin_version"] = effective.running_plugin_version;
|
||||
response["read_only"] = !m_parse_error.empty();
|
||||
if (!m_parse_error.empty())
|
||||
response["error"] = m_parse_error;
|
||||
|
||||
if (cap->has_config_ui) {
|
||||
try {
|
||||
wxBusyCursor busy;
|
||||
PythonGILState gil;
|
||||
response["custom_html"] = cap->instance->get_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
response["error"] = into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||
from_u8(ex.what())));
|
||||
}
|
||||
}
|
||||
|
||||
call_web_handler(response);
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_save_error(const PluginCapabilityIdentifier& id, const std::string& error)
|
||||
{
|
||||
call_web_handler({{"command", "capability_config_saved"},
|
||||
{"plugin_key", id.plugin_key},
|
||||
{"capability_name", id.name},
|
||||
{"capability_type", plugin_capability_type_to_string(id.type)},
|
||||
{"ok", false},
|
||||
{"error", error}});
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::show_status(const wxString& message, const char* level)
|
||||
{
|
||||
nlohmann::json payload;
|
||||
payload["command"] = "status_message";
|
||||
payload["level"] = level;
|
||||
payload["message"] = into_u8(message);
|
||||
call_web_handler(payload);
|
||||
}
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <slic3r/plugin/PresetPluginConfig.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
// The config half of the Plugins dialog, scoped to one preset instead of one plugin: it lists the
|
||||
// capabilities the edited preset of `m_type` actually uses (see capabilities_in_use) and edits each
|
||||
// one's stored config, falling back to the global config where the preset has no override.
|
||||
//
|
||||
// It is a pure editor over a JSON document: it never writes to the preset and never writes to the
|
||||
// base config file. The caller seeds it with the preset's raw override text and reads the edited
|
||||
// text back from overrides_json(). PluginConfigField, which owns the value, then feeds that through
|
||||
// the normal field/dirty pipeline — which is what makes the revert arrow behave like any other
|
||||
// setting. The capability list and config payloads are shared with PluginsDialog's Config tab
|
||||
// through PluginConfig's statics.
|
||||
class PluginsConfigDialog : public WebViewHostDialog
|
||||
{
|
||||
public:
|
||||
PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json);
|
||||
~PluginsConfigDialog() override;
|
||||
|
||||
// The edited overrides as compact JSON text; "" once no override remains.
|
||||
std::string overrides_json() const { return serialize_plugin_overrides(m_overrides); }
|
||||
|
||||
private:
|
||||
void on_script_message(const nlohmann::json& payload) override;
|
||||
|
||||
const Preset* current_preset() const;
|
||||
void send_capabilities();
|
||||
void send_capability_config(const PluginCapabilityIdentifier& id);
|
||||
void send_save_error(const PluginCapabilityIdentifier& id, const std::string& error);
|
||||
void show_status(const wxString& message, const char* level);
|
||||
|
||||
PluginCapabilityIdentifier identifier_from(const nlohmann::json& payload) const;
|
||||
|
||||
Preset::Type m_type = Preset::TYPE_INVALID;
|
||||
PresetPluginConfigService m_service;
|
||||
// The working copy the dialog edits. Seeded from the preset's raw text, read back by the caller.
|
||||
CapabilityConfigDocument m_overrides;
|
||||
// Set when the preset's stored text could not be parsed: the rows are shown read-only rather
|
||||
// than silently replacing data we did not understand.
|
||||
std::string m_parse_error;
|
||||
};
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "OrcaCloudServiceAgent.hpp"
|
||||
#include "slic3r/plugin/PluginConfig.hpp"
|
||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||
#include "slic3r/plugin/PluginManager.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||
@@ -16,11 +17,8 @@
|
||||
#include <slic3r/GUI/format.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
@@ -222,6 +220,17 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
|
||||
}
|
||||
payload_item["capabilities"] = std::move(caps);
|
||||
|
||||
// The Config tab's sidebar, built by the shared builder so it stays identical to
|
||||
// PluginsConfigDialog's. Deliberately not the `capabilities` array above: that one is the list
|
||||
// tab's, carrying enable/run state and descriptor-only rows for plugins that are not activated
|
||||
// yet — neither of which the config view has any use for.
|
||||
std::vector<PluginCapabilityIdentifier> config_ids;
|
||||
for (const PluginCapabilityView& capability : dialog_item.capabilities)
|
||||
if (!capability.name.empty())
|
||||
config_ids.push_back(PluginCapabilityIdentifier{plugin_capability_type_from_string(capability.type_key),
|
||||
capability.name, dialog_item.plugin_key});
|
||||
payload_item["config_capabilities"] = PluginConfig::capabilities_payload(config_ids);
|
||||
|
||||
nlohmann::json changelog = nlohmann::json::array();
|
||||
for (const PluginChangelogView& entry : dialog_item.changelog) {
|
||||
nlohmann::json c;
|
||||
@@ -918,153 +927,32 @@ 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);
|
||||
call_web_handler(PluginConfig::get_config_response({type, capability_name, plugin_key}));
|
||||
}
|
||||
|
||||
// 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;
|
||||
const nlohmann::json response = PluginConfig::save_config_response({type, capability_name, plugin_key}, config);
|
||||
call_web_handler(response);
|
||||
|
||||
show_status(_L("Configuration saved."), "success");
|
||||
if (response.value("ok", false))
|
||||
show_status(_L("Configuration saved."), "success");
|
||||
}
|
||||
|
||||
// Overwrites one capability's stored config with the value its get_default_config() hands back.
|
||||
// The host does not invent that value: a capability that does not override the hook restores an
|
||||
// empty config, which is exactly right for one that applies its own defaults on read.
|
||||
void PluginsDialog::restore_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name)
|
||||
{
|
||||
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 restore 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."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Discards whatever the user had stored, so confirm first — same as the other destructive
|
||||
// actions in this dialog.
|
||||
// actions in this dialog. The confirmation stays here rather than in PluginConfig: it needs a
|
||||
// parent window, and it is the dialog's business to ask.
|
||||
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
||||
"This discards the settings currently saved for this capability."),
|
||||
from_u8(capability_name)),
|
||||
@@ -1072,49 +960,11 @@ void PluginsDialog::restore_capability_config(const std::string& plugin_key,
|
||||
if (rc != wxYES)
|
||||
return;
|
||||
|
||||
nlohmann::json defaults;
|
||||
std::string error;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
defaults = cap->instance->get_default_config();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
} catch (...) {
|
||||
error = "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
// A raising hook leaves the stored config exactly as it was: better to restore nothing than to
|
||||
// wipe the user's settings on the strength of a broken plugin.
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_default_config() failed. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name << " error=" << error;
|
||||
response["error"] = into_u8(format_wxstr(_L("The plugin could not supply a default configuration (%1%). "
|
||||
"Nothing was changed."),
|
||||
from_u8(error)));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, defaults)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name;
|
||||
response["error"] = into_u8(_L("The configuration could not be written to disk. Nothing was changed."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name;
|
||||
|
||||
// Reuses the saved reply, so both editors reload from what was actually persisted.
|
||||
response["ok"] = true;
|
||||
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
|
||||
const nlohmann::json response = PluginConfig::restore_config_response({type, capability_name, plugin_key});
|
||||
call_web_handler(response);
|
||||
|
||||
show_status(_L("Default configuration restored."), "success");
|
||||
if (response.value("ok", false))
|
||||
show_status(_L("Default configuration restored."), "success");
|
||||
}
|
||||
|
||||
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
||||
|
||||
@@ -3086,6 +3086,13 @@ void TabPrint::build()
|
||||
option.opt.full_width = true;
|
||||
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
|
||||
|
||||
// Its own group rather than the one above: that one hides its labels, and this row needs its
|
||||
// label — and so the revert arrow beside it — to show. No label-width override either: a 0
|
||||
// there means "no label column", which is what the neighbouring full-width groups want and
|
||||
// what would collapse this one.
|
||||
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
optgroup = page->new_optgroup(L("Notes"), "note", 0);
|
||||
option = optgroup->get_option("notes");
|
||||
option.opt.full_width = true;
|
||||
@@ -4481,6 +4488,9 @@ void TabFilament::build()
|
||||
option.opt.height = gcode_field_height;// 150;
|
||||
optgroup->append_single_option_line(option);
|
||||
|
||||
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
|
||||
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
|
||||
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
|
||||
@@ -4923,6 +4933,7 @@ void TabPrinter::build_fff()
|
||||
optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction");
|
||||
// optgroup->append_single_option_line("spaghetti_detector");
|
||||
optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan");
|
||||
Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };
|
||||
|
||||
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "CapabilityConfigDocument.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* KEY_PLUGIN = "plugin_key";
|
||||
constexpr const char* KEY_CAPABILITY = "capability";
|
||||
constexpr const char* KEY_VERSION = "plugin_version";
|
||||
constexpr const char* KEY_CAP_CONFIG = "cap_config";
|
||||
|
||||
std::string string_field(const nlohmann::json& entry, const char* key)
|
||||
{
|
||||
const auto it = entry.find(key);
|
||||
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
|
||||
}
|
||||
|
||||
bool is_recognized_entry(const nlohmann::json& entry, CapabilityConfigId& id)
|
||||
{
|
||||
if (!entry.is_object())
|
||||
return false;
|
||||
|
||||
id.plugin_key = string_field(entry, KEY_PLUGIN);
|
||||
id.capability = string_field(entry, KEY_CAPABILITY);
|
||||
return !id.plugin_key.empty() && !id.capability.empty();
|
||||
}
|
||||
|
||||
CapabilityConfigEntry decode_entry(const CapabilityConfigId& id, const nlohmann::json& entry)
|
||||
{
|
||||
CapabilityConfigEntry result;
|
||||
result.id = id;
|
||||
result.plugin_version = string_field(entry, KEY_VERSION);
|
||||
const auto cap_it = entry.find(KEY_CAP_CONFIG);
|
||||
result.cap_config = cap_it != entry.end() ? *cap_it : nlohmann::json::object();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CapabilityConfigDocument CapabilityConfigDocument::from_entries(const nlohmann::json& entries)
|
||||
{
|
||||
CapabilityConfigDocument document;
|
||||
if (!entries.is_array())
|
||||
return document;
|
||||
|
||||
for (const nlohmann::json& entry : entries) {
|
||||
CapabilityConfigId id;
|
||||
if (is_recognized_entry(entry, id))
|
||||
document.m_entries[id] = entry;
|
||||
else
|
||||
document.m_opaque_entries.push_back(entry);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
CapabilityConfigDocument CapabilityConfigDocument::from_root_json(const nlohmann::json& root)
|
||||
{
|
||||
const auto entries = root.find(KeyEntries);
|
||||
return entries != root.end() ? from_entries(*entries) : CapabilityConfigDocument();
|
||||
}
|
||||
|
||||
std::optional<CapabilityConfigEntry> CapabilityConfigDocument::find(const CapabilityConfigId& id) const
|
||||
{
|
||||
const auto it = m_entries.find(id);
|
||||
if (it == m_entries.end())
|
||||
return std::nullopt;
|
||||
return decode_entry(it->first, it->second);
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::contains(const CapabilityConfigId& id) const
|
||||
{
|
||||
return m_entries.find(id) != m_entries.end();
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::upsert(CapabilityConfigEntry entry)
|
||||
{
|
||||
if (entry.id.plugin_key.empty() || entry.id.capability.empty())
|
||||
return false;
|
||||
|
||||
nlohmann::json serialized = nlohmann::json::object();
|
||||
const auto existing = m_entries.find(entry.id);
|
||||
if (existing != m_entries.end() && existing->second.is_object())
|
||||
serialized = existing->second;
|
||||
|
||||
serialized[KEY_PLUGIN] = entry.id.plugin_key;
|
||||
serialized[KEY_CAPABILITY] = entry.id.capability;
|
||||
serialized[KEY_VERSION] = entry.plugin_version;
|
||||
serialized[KEY_CAP_CONFIG] = entry.cap_config;
|
||||
|
||||
m_entries[entry.id] = std::move(serialized);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::erase(const CapabilityConfigId& id)
|
||||
{
|
||||
return m_entries.erase(id) != 0;
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::empty() const
|
||||
{
|
||||
return m_entries.empty() && m_opaque_entries.empty();
|
||||
}
|
||||
|
||||
nlohmann::json CapabilityConfigDocument::serialize_entries() const
|
||||
{
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto& item : m_entries)
|
||||
result.push_back(item.second);
|
||||
for (const nlohmann::json& entry : m_opaque_entries)
|
||||
result.push_back(entry);
|
||||
return result;
|
||||
}
|
||||
|
||||
nlohmann::json CapabilityConfigDocument::root_json() const
|
||||
{
|
||||
nlohmann::json root = nlohmann::json::object();
|
||||
root[KeyEntries] = serialize_entries();
|
||||
return root;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
struct CapabilityConfigId
|
||||
{
|
||||
std::string plugin_key;
|
||||
std::string capability;
|
||||
|
||||
friend bool operator<(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||
{
|
||||
return lhs.plugin_key < rhs.plugin_key ||
|
||||
(lhs.plugin_key == rhs.plugin_key && lhs.capability < rhs.capability);
|
||||
}
|
||||
|
||||
friend bool operator==(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||
{
|
||||
return lhs.plugin_key == rhs.plugin_key && lhs.capability == rhs.capability;
|
||||
}
|
||||
};
|
||||
|
||||
struct CapabilityConfigEntry
|
||||
{
|
||||
CapabilityConfigId id;
|
||||
std::string plugin_version;
|
||||
nlohmann::json cap_config = nlohmann::json::object();
|
||||
};
|
||||
|
||||
class CapabilityConfigDocument
|
||||
{
|
||||
public:
|
||||
static constexpr const char* KeyEntries = "config";
|
||||
|
||||
static CapabilityConfigDocument from_root_json(const nlohmann::json& root);
|
||||
static CapabilityConfigDocument from_entries(const nlohmann::json& entries);
|
||||
|
||||
std::optional<CapabilityConfigEntry> find(const CapabilityConfigId& id) const;
|
||||
bool contains(const CapabilityConfigId& id) const;
|
||||
bool upsert(CapabilityConfigEntry entry);
|
||||
bool erase(const CapabilityConfigId& id);
|
||||
bool empty() const;
|
||||
nlohmann::json serialize_entries() const;
|
||||
nlohmann::json root_json() const;
|
||||
|
||||
private:
|
||||
std::map<CapabilityConfigId, nlohmann::json> m_entries;
|
||||
std::vector<nlohmann::json> m_opaque_entries;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -4,54 +4,21 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include <slic3r/GUI/GUI.hpp>
|
||||
#include <slic3r/GUI/I18N.hpp>
|
||||
#include <slic3r/GUI/format.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
#include <wx/utils.h>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* KEY_ENTRIES = "config";
|
||||
constexpr const char* KEY_PLUGIN = "plugin_key";
|
||||
constexpr const char* KEY_CAPABILITY = "capability";
|
||||
constexpr const char* KEY_VERSION = "plugin_version";
|
||||
constexpr const char* KEY_CAP_CONFIG = "cap_config";
|
||||
|
||||
std::string string_field(const nlohmann::json& entry, const char* key)
|
||||
{
|
||||
const auto it = entry.find(key);
|
||||
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
|
||||
}
|
||||
|
||||
// Rejects entries missing an identity, which could never be looked up again.
|
||||
bool entry_to_config(const nlohmann::json& entry, BaseConfig& out)
|
||||
{
|
||||
if (!entry.is_object())
|
||||
return false;
|
||||
|
||||
out.plugin_key = string_field(entry, KEY_PLUGIN);
|
||||
out.capability_name = string_field(entry, KEY_CAPABILITY);
|
||||
out.plugin_version = string_field(entry, KEY_VERSION);
|
||||
if (out.empty())
|
||||
return false;
|
||||
|
||||
const auto cap_config = entry.find(KEY_CAP_CONFIG);
|
||||
out.config = cap_config != entry.end() ? *cap_config : nlohmann::json::object();
|
||||
return true;
|
||||
}
|
||||
|
||||
nlohmann::json config_to_entry(const BaseConfig& config)
|
||||
{
|
||||
return nlohmann::json{
|
||||
{KEY_PLUGIN, config.plugin_key},
|
||||
{KEY_CAPABILITY, config.capability_name},
|
||||
{KEY_VERSION, config.plugin_version},
|
||||
{KEY_CAP_CONFIG, config.config},
|
||||
};
|
||||
}
|
||||
|
||||
// The version of the plugin package currently running. PluginDescriptor::version is
|
||||
// overwritten with the latest cloud version when a cloud merge happens, so it can name a
|
||||
// version that is not the one on disk; installed_version is what actually loaded.
|
||||
@@ -83,7 +50,7 @@ void PluginConfig::load()
|
||||
const std::string path = plugin_config_file();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_storage.clear();
|
||||
m_document = CapabilityConfigDocument();
|
||||
m_dirty = false;
|
||||
|
||||
boost::system::error_code ec;
|
||||
@@ -99,20 +66,14 @@ void PluginConfig::load()
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entries = root.find(KEY_ENTRIES);
|
||||
const auto entries = root.find(CapabilityConfigDocument::KeyEntries);
|
||||
if (entries == root.end() || !entries->is_array()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << KEY_ENTRIES << "\" array; starting with an empty config";
|
||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << CapabilityConfigDocument::KeyEntries
|
||||
<< "\" array; starting with an empty config";
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& entry : *entries) {
|
||||
BaseConfig config;
|
||||
if (!entry_to_config(entry, config)) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: skipping entry without a plugin key and capability name";
|
||||
continue;
|
||||
}
|
||||
m_storage[{config.plugin_key, config.capability_name}] = std::move(config);
|
||||
}
|
||||
m_document = CapabilityConfigDocument::from_root_json(root);
|
||||
}
|
||||
|
||||
bool PluginConfig::save()
|
||||
@@ -120,11 +81,10 @@ bool PluginConfig::save()
|
||||
const std::string path = plugin_config_file();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_dirty)
|
||||
return true;
|
||||
|
||||
nlohmann::json root;
|
||||
root[KEY_ENTRIES] = nlohmann::json::array();
|
||||
for (const auto& [id, config] : m_storage)
|
||||
root[KEY_ENTRIES].push_back(config_to_entry(config));
|
||||
const nlohmann::json root = m_document.root_json();
|
||||
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
|
||||
@@ -177,21 +137,37 @@ void PluginConfig::save_config(const BaseConfig& config)
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_storage[{config.plugin_key, config.capability_name}] = config;
|
||||
m_dirty = true;
|
||||
m_dirty = m_document.upsert(CapabilityConfigEntry{{config.plugin_key, config.capability_name}, config.plugin_version, config.config}) || m_dirty;
|
||||
}
|
||||
|
||||
bool PluginConfig::erase_capability_config(const std::string& plugin_key, const std::string& capability_name)
|
||||
{
|
||||
if (plugin_key.empty() || capability_name.empty())
|
||||
return false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_document.erase({plugin_key, capability_name}))
|
||||
return true;
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
return save();
|
||||
}
|
||||
|
||||
BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
const auto it = m_storage.find({plugin_key, capability_name});
|
||||
return it != m_storage.end() ? it->second : BaseConfig();
|
||||
const auto entry = m_document.find({plugin_key, capability_name});
|
||||
if (!entry)
|
||||
return BaseConfig();
|
||||
return BaseConfig{entry->id.plugin_key, entry->id.capability, entry->plugin_version, entry->cap_config};
|
||||
}
|
||||
|
||||
bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_storage.count({plugin_key, capability_name}) != 0;
|
||||
return m_document.contains({plugin_key, capability_name});
|
||||
}
|
||||
|
||||
bool PluginConfig::dirty() const
|
||||
@@ -223,4 +199,194 @@ bool capability_save_config(const PluginCapabilityInterface& capability, const n
|
||||
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
|
||||
}
|
||||
|
||||
nlohmann::json PluginConfig::capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps)
|
||||
{
|
||||
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||
|
||||
nlohmann::json payload = nlohmann::json::array();
|
||||
for (const PluginCapabilityIdentifier& id : caps) {
|
||||
// Read has_config_ui off the live capability rather than trusting the caller's copy: a
|
||||
// capability that has been unloaded since the list was built has nothing to configure.
|
||||
const auto capability = loader.get_plugin_capability_by_name(id);
|
||||
if (!capability)
|
||||
continue;
|
||||
|
||||
nlohmann::json entry;
|
||||
entry["plugin_key"] = id.plugin_key;
|
||||
entry["name"] = id.name;
|
||||
entry["type"] = plugin_capability_type_display_name(id.type);
|
||||
entry["type_key"] = plugin_capability_type_to_string(id.type);
|
||||
entry["has_config_ui"] = capability->has_config_ui;
|
||||
payload.push_back(std::move(entry));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Replies 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.
|
||||
nlohmann::json PluginConfig::get_config_response(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["config"] = nlohmann::json::object();
|
||||
response["custom_html"] = "";
|
||||
response["error"] = "";
|
||||
|
||||
// Scoped to the full identity, 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.
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||
return response;
|
||||
}
|
||||
|
||||
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.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=" << id.plugin_key
|
||||
<< " capability_name=" << id.name << " error=" << error;
|
||||
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||
GUI::from_u8(error)));
|
||||
} else if (html.empty()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
|
||||
} else {
|
||||
response["custom_html"] = html;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
nlohmann::json PluginConfig::save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config_saved";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["ok"] = false;
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
|
||||
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"] = GUI::into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, parsed)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << id.plugin_key << " capability_name=" << id.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(id.plugin_key, id.name).config;
|
||||
return response;
|
||||
}
|
||||
|
||||
// The host does not invent the default: a capability that does not override get_default_config()
|
||||
// restores an empty config, which is exactly right for one that applies its own defaults on read.
|
||||
nlohmann::json PluginConfig::restore_config_response(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config_saved";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["ok"] = false;
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Refusing to restore config for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||
return response;
|
||||
}
|
||||
|
||||
nlohmann::json defaults;
|
||||
std::string error;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
defaults = cap->instance->get_default_config();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
} catch (...) {
|
||||
error = "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
// A raising hook leaves the stored config exactly as it was: better to restore nothing than to
|
||||
// wipe the user's settings on the strength of a broken plugin.
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_default_config() failed. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name << " error=" << error;
|
||||
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin could not supply a default configuration (%1%). "
|
||||
"Nothing was changed."),
|
||||
GUI::from_u8(error)));
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, defaults)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Nothing was changed."));
|
||||
return response;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
|
||||
// Reuses the saved reply, so both editors reload from what was actually persisted.
|
||||
response["ok"] = true;
|
||||
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||
return response;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <slic3r/plugin/CapabilityConfigDocument.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#define PLUGIN_CONFIG_DIR "config.json"
|
||||
|
||||
@@ -15,6 +18,12 @@ namespace Slic3r {
|
||||
|
||||
class PluginCapabilityInterface;
|
||||
|
||||
enum class PluginConfigSource {
|
||||
None,
|
||||
Base,
|
||||
Preset,
|
||||
};
|
||||
|
||||
/*
|
||||
Example config.json shape
|
||||
{
|
||||
@@ -95,6 +104,7 @@ public:
|
||||
// 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);
|
||||
bool erase_capability_config(const std::string& plugin_key, const std::string& capability_name);
|
||||
|
||||
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
|
||||
// no stored config.
|
||||
@@ -103,12 +113,35 @@ public:
|
||||
|
||||
bool dirty() const;
|
||||
|
||||
private:
|
||||
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably.
|
||||
using CapabilityId = std::pair<std::string, std::string>;
|
||||
// ---- Webview-facing helpers, shared by PluginsDialog's Config tab and PluginsConfigDialog ----
|
||||
//
|
||||
// These build the payloads both dialogs' config views speak, so the two pages stay in step and
|
||||
// neither dialog owns the config protocol. They are static because a capability's config is
|
||||
// addressed globally by (plugin_key, capability_name) through PluginManager's store, not through
|
||||
// any one PluginConfig instance.
|
||||
//
|
||||
// The caller owns the UI: it confirms destructive restores and shows status toasts. These only
|
||||
// touch the store, the loaded capability, and the payload.
|
||||
|
||||
// The config sidebar's rows: one entry per capability, in the order given. Capabilities that are
|
||||
// no longer loaded are skipped — the sidebar only offers what can actually be configured.
|
||||
static nlohmann::json capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps);
|
||||
|
||||
// One capability's stored config, plus its custom HTML UI when it provides one.
|
||||
static nlohmann::json get_config_response(const PluginCapabilityIdentifier& id);
|
||||
|
||||
// Persists one capability's config. `config` is either text straight from the default editor
|
||||
// (re-parsed here, so malformed JSON can never reach config.json) or a structured value from a
|
||||
// custom UI.
|
||||
static nlohmann::json save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config);
|
||||
|
||||
// Overwrites one capability's stored config with its get_default_config(). The caller must have
|
||||
// confirmed with the user first — this does not ask.
|
||||
static nlohmann::json restore_config_response(const PluginCapabilityIdentifier& id);
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
std::map<CapabilityId, BaseConfig> m_storage;
|
||||
CapabilityConfigDocument m_document;
|
||||
bool m_dirty = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <libslic3r/Config.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <vector>
|
||||
#include <wx/utils.h>
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -92,6 +94,96 @@ static bool is_tracked_type(Preset::Type type)
|
||||
return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT;
|
||||
}
|
||||
|
||||
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset)
|
||||
{
|
||||
if (!is_tracked_type(type))
|
||||
return {};
|
||||
|
||||
const auto* manifest = dynamic_cast<const ConfigOptionStrings*>(preset.config.option("plugins"));
|
||||
if (manifest == nullptr)
|
||||
return {};
|
||||
|
||||
std::vector<PluginCapabilityRef> refs;
|
||||
for (const std::string& entry : manifest->values) {
|
||||
const auto ref = parse_capability_ref(entry);
|
||||
if (!ref)
|
||||
continue;
|
||||
// The same test the missing-plugin path uses: an entry no option points at is not in use.
|
||||
if (find_option_for_capability(type, preset, *ref).empty())
|
||||
continue;
|
||||
refs.push_back(*ref);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Resolve one preset's referenced capabilities to loaded capability identifiers, appending to `out`.
|
||||
// A ref whose plugin is not in the catalog, or whose capability is not currently loaded, is dropped:
|
||||
// there is no instance to ask for a config UI or defaults, so there is nothing to configure.
|
||||
void collect_capabilities_in_use(Preset::Type type, const Preset& preset, std::vector<PluginCapabilityIdentifier>& out)
|
||||
{
|
||||
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||
const PluginCatalog& catalog = PluginManager::instance().get_catalog();
|
||||
|
||||
for (const PluginCapabilityRef& ref : referenced_capabilities(type, preset)) {
|
||||
// Cloud plugins resolve by UUID, local plugins by plugin_key — the rule refresh_missing_plugins uses.
|
||||
const std::string key = ref.uuid.empty() ? ref.name : ref.uuid;
|
||||
if (key.empty())
|
||||
continue;
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
if (!catalog.try_get_plugin_descriptor(key, descriptor))
|
||||
continue;
|
||||
|
||||
// The loaded capability is also what supplies the type: the manifest ref does not carry one.
|
||||
for (const auto& capability : loader.get_loaded_plugin_capabilities(descriptor.plugin_key))
|
||||
if (capability && capability->name == ref.capability_name)
|
||||
out.push_back(PluginCapabilityIdentifier{capability->type, capability->name, capability->plugin_key});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type)
|
||||
{
|
||||
if (!is_tracked_type(type))
|
||||
return {};
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> result;
|
||||
if (type == Preset::TYPE_PRINT) {
|
||||
collect_capabilities_in_use(type, preset_bundle.prints.get_edited_preset(), result);
|
||||
} else if (type == Preset::TYPE_PRINTER) {
|
||||
collect_capabilities_in_use(type, preset_bundle.printers.get_edited_preset(), result);
|
||||
} else {
|
||||
// Filament: every selected filament preset, tested against its own config. (Note that
|
||||
// refresh_missing_plugins cannot do this — it unions the manifests and loses the preset.)
|
||||
for (const std::string& filament_name : preset_bundle.filament_presets)
|
||||
if (const Preset* filament = preset_bundle.filaments.find_preset(filament_name))
|
||||
collect_capabilities_in_use(type, *filament, result);
|
||||
}
|
||||
|
||||
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||
});
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset)
|
||||
{
|
||||
std::vector<PluginCapabilityIdentifier> result;
|
||||
if (!is_tracked_type(type))
|
||||
return result;
|
||||
|
||||
collect_capabilities_in_use(type, preset, result);
|
||||
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||
});
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string resolve_cloud_base_url()
|
||||
{
|
||||
std::string cloud_base_url = "https://cloud.orcaslicer.com";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
|
||||
#include <libslic3r/Preset.hpp> // Preset::Type
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp> // PluginCapabilityIdentifier
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
@@ -81,6 +82,21 @@ void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs);
|
||||
std::string create_full_ref(const PluginCapabilityRef& ref);
|
||||
std::string resolve_recovery_url(const PluginCapabilityRef& ref);
|
||||
|
||||
// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options
|
||||
// (ConfigOptionDef::is_plugin_backed) currently references. A manifest entry nobody points at is not
|
||||
// in use. Pure preset logic — the plugin catalog and loader are not consulted. Empty for untracked
|
||||
// preset types.
|
||||
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset);
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset);
|
||||
|
||||
// The capabilities the active preset(s) of `type` reference (see referenced_capabilities) and that
|
||||
// are loaded right now: the set that can actually be configured. Missing and broken refs are absent,
|
||||
// having no instance to ask for a config UI or defaults. A loaded-but-disabled capability IS listed —
|
||||
// it still has stored config worth editing, and disabling it is not a reason to hide that.
|
||||
// TYPE_FILAMENT unions every selected filament preset; a capability used by two extruders is listed
|
||||
// once. Deduped on the full identity, so two plugins exposing a same-named capability stay distinct.
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type);
|
||||
|
||||
bool check_capability_in_use(const std::string& capability_refs);
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "PresetPluginConfig.hpp"
|
||||
|
||||
#include "PluginManager.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
CapabilityConfigId make_id(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
return CapabilityConfigId{id.plugin_key, id.name};
|
||||
}
|
||||
|
||||
std::string running_plugin_version(const std::string& plugin_key)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().get_catalog().try_get_valid_plugin_descriptor(plugin_key, descriptor))
|
||||
return {};
|
||||
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string plugin_overrides_of(const Preset& preset)
|
||||
{
|
||||
const auto* opt = dynamic_cast<const ConfigOptionString*>(preset.config.option(PLUGIN_OVERRIDES_OPTION_KEY));
|
||||
return opt == nullptr ? std::string() : opt->value;
|
||||
}
|
||||
|
||||
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error)
|
||||
{
|
||||
document = CapabilityConfigDocument();
|
||||
error.clear();
|
||||
|
||||
if (raw.empty())
|
||||
return true;
|
||||
|
||||
const nlohmann::json parsed = nlohmann::json::parse(raw, nullptr, /* allow_exceptions */ false);
|
||||
if (parsed.is_discarded()) {
|
||||
error = "The preset stores invalid plugin capability configuration JSON.";
|
||||
return false;
|
||||
}
|
||||
if (!parsed.is_array()) {
|
||||
error = "The preset's plugin capability configuration is not an array and cannot be edited.";
|
||||
return false;
|
||||
}
|
||||
|
||||
document = CapabilityConfigDocument::from_entries(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document)
|
||||
{
|
||||
return document.empty() ? std::string() : document.serialize_entries().dump();
|
||||
}
|
||||
|
||||
std::string plugin_config_source_to_string(PluginConfigSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case PluginConfigSource::Preset: return "preset";
|
||||
case PluginConfigSource::Base: return "base";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
EffectiveCapabilityConfig PresetPluginConfigService::get_effective_config(const CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const
|
||||
{
|
||||
EffectiveCapabilityConfig result;
|
||||
result.id = make_id(id);
|
||||
result.running_plugin_version = running_plugin_version(id.plugin_key);
|
||||
|
||||
const BaseConfig base = PluginManager::instance().get_config().get_config(id.plugin_key, id.name);
|
||||
result.has_base_config = !base.empty();
|
||||
|
||||
if (const auto entry = overrides.find(result.id)) {
|
||||
result.has_preset_override = true;
|
||||
result.source = PluginConfigSource::Preset;
|
||||
result.config = entry->cap_config;
|
||||
result.stored_plugin_version = entry->plugin_version;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.has_base_config) {
|
||||
result.source = PluginConfigSource::Base;
|
||||
result.config = base.config;
|
||||
result.stored_plugin_version = base.plugin_version;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
MutationResult PresetPluginConfigService::set_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id,
|
||||
const nlohmann::json& value) const
|
||||
{
|
||||
MutationResult result;
|
||||
const CapabilityConfigId config_id = make_id(id);
|
||||
const std::string version = running_plugin_version(id.plugin_key);
|
||||
|
||||
// A no-op is a successful unchanged result, not a reason to rewrite the preset: re-saving the
|
||||
// displayed value must not be able to mark it dirty.
|
||||
const auto existing = overrides.find(config_id);
|
||||
if (existing && existing->cap_config == value && existing->plugin_version == version) {
|
||||
result.ok = true;
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
overrides.upsert({config_id, version, value});
|
||||
|
||||
result.ok = true;
|
||||
result.changed = true;
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
MutationResult PresetPluginConfigService::remove_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const
|
||||
{
|
||||
MutationResult result;
|
||||
result.ok = true;
|
||||
result.changed = overrides.erase(make_id(id));
|
||||
// Reads back as the base value now that the override is gone.
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "CapabilityConfigDocument.hpp"
|
||||
#include "PluginConfig.hpp"
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// A preset keeps its plugin capability overrides as one raw JSON string in this ordinary
|
||||
// ConfigOptionString, so the whole preset lifecycle — load, save, diff/dirty, inheritance, 3MF,
|
||||
// sync — carries it for free. The plugin layer is the only thing that gives that string meaning.
|
||||
inline constexpr const char* PLUGIN_OVERRIDES_OPTION_KEY = "plugin_preference_overrides";
|
||||
|
||||
// The preset's raw override text, or "" when it stores none.
|
||||
std::string plugin_overrides_of(const Preset& preset);
|
||||
|
||||
// An empty string is a valid, empty document. Returns false and fills `error` when the text is
|
||||
// present but is not a JSON array of entries; the caller then shows it and edits nothing.
|
||||
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error);
|
||||
|
||||
// The document as compact JSON text, and "" once it holds no entries. Empty text — rather than a
|
||||
// removed option — is what records "cleared here" against an inheriting parent that has overrides.
|
||||
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document);
|
||||
|
||||
struct EffectiveCapabilityConfig
|
||||
{
|
||||
CapabilityConfigId id;
|
||||
nlohmann::json config = nlohmann::json::object();
|
||||
PluginConfigSource source = PluginConfigSource::None;
|
||||
|
||||
bool has_preset_override = false;
|
||||
bool has_base_config = false;
|
||||
std::string stored_plugin_version;
|
||||
std::string running_plugin_version;
|
||||
};
|
||||
|
||||
struct MutationResult
|
||||
{
|
||||
bool ok = false;
|
||||
bool changed = false;
|
||||
std::string error;
|
||||
EffectiveCapabilityConfig effective;
|
||||
};
|
||||
|
||||
std::string plugin_config_source_to_string(PluginConfigSource source);
|
||||
|
||||
// Resolves a capability's effective config as `preset override -> base config -> none`, and mutates
|
||||
// the override layer.
|
||||
//
|
||||
// It works on a CapabilityConfigDocument the caller owns, never on a Preset and never on the base
|
||||
// config file. That is what keeps the two layers from writing to each other: PluginConfigField holds
|
||||
// the document, and feeds the edited text back through the normal field/dirty pipeline, so the
|
||||
// preset is written exactly the way every other setting is.
|
||||
class PresetPluginConfigService
|
||||
{
|
||||
public:
|
||||
EffectiveCapabilityConfig get_effective_config(const CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const;
|
||||
MutationResult set_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id,
|
||||
const nlohmann::json& value) const;
|
||||
MutationResult remove_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "PythonPluginBridge.hpp"
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
@@ -343,33 +345,41 @@ void bind_python_api(pybind11::module_& m)
|
||||
"get_default_config",
|
||||
[](const PluginCapabilityInterface& self) {
|
||||
nlohmann::json config = self.get_default_config();
|
||||
return json_to_py(config); // GIL held (binding body)
|
||||
return config.dump();
|
||||
},
|
||||
"Override to return the config that the Config tab's \"Restore defaults\" action writes\n"
|
||||
"back. Optional: without it the action stores an empty dict, which already restores the\n"
|
||||
"defaults of a capability that keeps its stored config sparse and applies its own\n"
|
||||
"defaults on read. Override it to write an explicit starting config instead.")
|
||||
"back, as a dict. Optional: without it the action stores an empty config, which already\n"
|
||||
"restores the defaults of a capability that keeps its stored config sparse and applies\n"
|
||||
"its own defaults on read. Override it to write an explicit starting config instead.\n"
|
||||
"Calling it returns that config as a JSON string.")
|
||||
.def(
|
||||
"get_config",
|
||||
[](const PluginCapabilityInterface& self) {
|
||||
nlohmann::json config = capability_get_config(self);
|
||||
return json_to_py(config); // GIL held (binding body)
|
||||
return config.dump();
|
||||
},
|
||||
"Return this capability's stored config. An empty dict if it has never been saved.")
|
||||
"Return this capability's stored config as a JSON string — json.loads() it to use.\n"
|
||||
"\"{}\" if it has never been saved, so the parsed result is always indexable.")
|
||||
.def(
|
||||
"get_config_version",
|
||||
[](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
|
||||
"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)
|
||||
[](const PluginCapabilityInterface& self, const std::string& config_str) {
|
||||
nlohmann::json config = nlohmann::json::parse(config_str, nullptr, /* allow_exceptions */ false);
|
||||
if (config.is_discarded()) {
|
||||
// Refused rather than stored: the caller gets False, and the previously stored
|
||||
// config is left alone. Logged because False alone does not say why.
|
||||
BOOST_LOG_TRIVIAL(error) << "save_config: capability '" << self.get_name() << "' passed a config that is not valid JSON";
|
||||
return false;
|
||||
}
|
||||
return capability_save_config(self, config);
|
||||
},
|
||||
py::arg("config"),
|
||||
"Persist this capability's config, given as a JSON-compatible value (usually a dict).\n"
|
||||
"Persist this capability's config, given as a JSON string (e.g. json.dumps(cfg)).\n"
|
||||
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
|
||||
"the config file could not be written.");
|
||||
"the string is not valid JSON, or if 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.
|
||||
|
||||
@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_capability_config.cpp
|
||||
test_plugin_capability_identifier.cpp
|
||||
test_plugin_config.cpp
|
||||
test_plugin_capabilities_in_use.cpp
|
||||
test_plugin_install.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_plugin_sort.cpp
|
||||
|
||||
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A print preset carrying a "plugins" manifest and the one plugin-backed print option
|
||||
// (slicing_pipeline_plugin, a coStrings vector).
|
||||
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
|
||||
{
|
||||
Preset preset(Preset::TYPE_PRINT, "test-print");
|
||||
const std::unique_ptr<DynamicPrintConfig> defaults(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
|
||||
preset.config = *defaults;
|
||||
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
|
||||
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
|
||||
return preset;
|
||||
}
|
||||
|
||||
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const PluginCapabilityRef& ref : refs)
|
||||
names.push_back(ref.capability_name);
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
|
||||
{
|
||||
// CapB is declared in the manifest but no option references it, so it is not in use.
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
|
||||
std::vector<std::string>{"CapA", "CapC"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({}, {"CapA"});
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
|
||||
{
|
||||
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
|
||||
preset.type = Preset::TYPE_SLA_PRINT;
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
|
||||
{
|
||||
// parse_capability_ref rejects entries that are not "name;uuid;capability".
|
||||
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
@@ -73,6 +73,12 @@ std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instan
|
||||
// PluginManager singleton, so that is where the assertions read from.
|
||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||
|
||||
// The Python config API speaks JSON text, not dicts: get_config()/get_default_config() hand back a
|
||||
// JSON string and save_config() takes one. These helpers keep the tests written in terms of values.
|
||||
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
|
||||
|
||||
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
@@ -103,26 +109,23 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl
|
||||
|
||||
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
// Nothing stored yet: an empty dict, not None, so a plugin can index it unconditionally.
|
||||
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads()
|
||||
// and index it unconditionally.
|
||||
py::object initial = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::dict>(initial));
|
||||
CHECK(py::len(initial) == 0);
|
||||
REQUIRE(py::isinstance<py::str>(initial));
|
||||
CHECK(json::parse(initial.cast<std::string>()) == json::object());
|
||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||
|
||||
py::dict value;
|
||||
value["speed"] = 5;
|
||||
value["name"] = "fast";
|
||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
||||
REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
|
||||
|
||||
// Persisted through PluginConfig, under this capability's identity only.
|
||||
const BaseConfig stored = host_config().get_config("plugin_a", "cap_a");
|
||||
REQUIRE_FALSE(stored.empty());
|
||||
CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}});
|
||||
|
||||
// And read back through Python as a dict of exactly cap_config — no host metadata.
|
||||
py::object reloaded = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::dict>(reloaded));
|
||||
CHECK(py::len(reloaded) == 2);
|
||||
// And read back through Python as exactly cap_config — no host metadata.
|
||||
const json reloaded = py_get_config(cap);
|
||||
CHECK(reloaded.size() == 2);
|
||||
CHECK(reloaded.contains("speed"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_key"));
|
||||
CHECK_FALSE(reloaded.contains("capability"));
|
||||
@@ -130,6 +133,21 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl
|
||||
CHECK_FALSE(reloaded.contains("plugin_version"));
|
||||
}
|
||||
|
||||
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
// The binding parses the string it is handed. Unparseable text is refused, and refusing it must
|
||||
// leave the previously stored config alone rather than clobbering it with nothing.
|
||||
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
||||
@@ -142,25 +160,19 @@ TEST_CASE("Saving one capability's config does not touch another's", "[PluginCon
|
||||
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
||||
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
||||
|
||||
py::dict one, two, three;
|
||||
one["value"] = 1;
|
||||
two["value"] = 2;
|
||||
three["value"] = 3;
|
||||
REQUIRE(a_cap1.attr("save_config")(one).cast<bool>());
|
||||
REQUIRE(a_cap2.attr("save_config")(two).cast<bool>());
|
||||
REQUIRE(b_cap1.attr("save_config")(three).cast<bool>());
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
|
||||
REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
|
||||
REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
|
||||
|
||||
py::dict updated;
|
||||
updated["value"] = 99;
|
||||
REQUIRE(a_cap1.attr("save_config")(updated).cast<bool>());
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
|
||||
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
|
||||
// Each capability still reads back its own value.
|
||||
CHECK(a_cap2.attr("get_config")()["value"].cast<int>() == 2);
|
||||
CHECK(b_cap1.attr("get_config")()["value"].cast<int>() == 3);
|
||||
CHECK(py_get_config(a_cap2).at("value") == 2);
|
||||
CHECK(py_get_config(b_cap1).at("value") == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
|
||||
@@ -174,7 +186,7 @@ TEST_CASE("Config API refuses a capability the host never materialized", "[Plugi
|
||||
|
||||
CHECK_THROWS(orphan.attr("get_config")());
|
||||
CHECK_THROWS(orphan.attr("get_config_version")());
|
||||
CHECK_THROWS(orphan.attr("save_config")(py::dict()));
|
||||
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
|
||||
}
|
||||
|
||||
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
||||
@@ -206,9 +218,7 @@ TEST_CASE("A capability that omits the config UI hooks gets the default editor",
|
||||
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
|
||||
py::dict value;
|
||||
value["speed"] = 5;
|
||||
REQUIRE(bare.attr("save_config")(value).cast<bool>());
|
||||
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
@@ -279,10 +289,9 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
py::dict edited;
|
||||
edited["speed"] = 99;
|
||||
REQUIRE(target.attr("save_config")(edited).cast<bool>());
|
||||
REQUIRE(bystander.attr("save_config")(edited).cast<bool>());
|
||||
const json edited = json{{"speed", 99}};
|
||||
REQUIRE(py_save_config(target, edited));
|
||||
REQUIRE(py_save_config(bystander, edited));
|
||||
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
@@ -303,9 +312,7 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
py::dict value;
|
||||
value["keep"] = "me";
|
||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
Reference in New Issue
Block a user