feat: sidebar plugin config UI

This commit is contained in:
Ian Chua
2026-07-13 20:39:41 +08:00
parent 0c69b9982d
commit 5b3e1b921c
27 changed files with 2263 additions and 290 deletions

View 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>

View 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");
});

View 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);
}

View File

@@ -504,9 +504,8 @@ function HasMixedCapabilityState(plugin) {
String(capability?.type_key || "") String(capability?.type_key || "")
); );
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
const hasDisabled = 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) { function IsPluginLoading(plugin) {
@@ -826,12 +825,12 @@ function RenderDetails() {
// native side sends and sends back what the user saves. // 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 // The config sidebar's rows, built natively by PluginConfig::capabilities_payload and shared with
// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows // PluginsConfigDialog. Only loaded, addressable capabilities appear: the descriptor-only rows the
// shown for a plugin that is not activated: those carry no capability name, so there is nothing to // list tab shows for a plugin that is not activated carry no capability name, so there is nothing to
// address on the native side and nothing to configure yet. // configure and nothing to address on the native side.
function GetConfigurableCapabilities(plugin) { function GetConfigurableCapabilities(plugin) {
return GetCapabilities(plugin).filter((capability) => String(capability?.name || "")); return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
} }
function RenderConfig(plugin) { function RenderConfig(plugin) {

View File

@@ -2228,6 +2228,8 @@ public:
// Vector value, but edited as a single string. // Vector value, but edited as a single string.
one_string, one_string,
plugin_picker, 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. // Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.

View File

@@ -1194,6 +1194,7 @@ static std::vector<std::string> s_Preset_print_options{
"post_process", "post_process",
"slicing_pipeline_plugin", "slicing_pipeline_plugin",
"plugins", "plugins",
"plugin_preference_overrides",
"process_change_extrusion_role_gcode", "process_change_extrusion_role_gcode",
"min_length_factor", "min_length_factor",
"wall_maximum_resolution", "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", "filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature",
//BBS filament change length while the extruder color //BBS filament change length while the extruder color
"filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower", "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 { 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", "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", "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", "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 { static std::vector<std::string> s_Preset_sla_print_options {

View File

@@ -929,6 +929,17 @@ void PrintConfigDef::init_common_params()
def = this->add("preset_name", coString); def = this->add("preset_name", coString);
def->set_default_value(new ConfigOptionString()); 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() void PrintConfigDef::init_fff_params()

View File

@@ -116,6 +116,8 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginPickerDialog.hpp GUI/PluginPickerDialog.hpp
GUI/PluginsDialog.cpp GUI/PluginsDialog.cpp
GUI/PluginsDialog.hpp GUI/PluginsDialog.hpp
GUI/PluginsConfigDialog.cpp
GUI/PluginsConfigDialog.hpp
GUI/ProcessRunner.cpp GUI/ProcessRunner.cpp
GUI/ProcessRunner.hpp GUI/ProcessRunner.hpp
GUI/TerminalDialog.cpp GUI/TerminalDialog.cpp
@@ -613,8 +615,12 @@ set(SLIC3R_GUI_SOURCES
plugin/CloudPluginService.hpp plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp plugin/PluginFsUtils.cpp
plugin/PluginFsUtils.hpp plugin/PluginFsUtils.hpp
plugin/CapabilityConfigDocument.cpp
plugin/CapabilityConfigDocument.hpp
plugin/PluginConfig.cpp plugin/PluginConfig.cpp
plugin/PluginConfig.hpp plugin/PluginConfig.hpp
plugin/PresetPluginConfig.cpp
plugin/PresetPluginConfig.hpp
plugin/PythonJsonUtils.hpp plugin/PythonJsonUtils.hpp
plugin/PluginCatalog.cpp plugin/PluginCatalog.cpp
plugin/PluginCatalog.hpp plugin/PluginCatalog.hpp

View File

@@ -23,6 +23,8 @@
#include "OG_CustomCtrl.hpp" #include "OG_CustomCtrl.hpp"
#include "MsgDialog.hpp" #include "MsgDialog.hpp"
#include "BitmapComboBox.hpp" #include "BitmapComboBox.hpp"
#include "PluginsConfigDialog.hpp"
#include "Widgets/Button.hpp"
// BBS // BBS
#include "Notebook.hpp" #include "Notebook.hpp"
@@ -2308,6 +2310,136 @@ void PluginField::msw_rescale()
rebuild_ui(); 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() void ColourPicker::BUILD()
{ {
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width

View File

@@ -32,6 +32,10 @@
#define wxMSW false #define wxMSW false
#endif #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 { namespace Slic3r { namespace GUI {
class Field; class Field;
@@ -517,6 +521,47 @@ private:
std::function<std::string()> m_selector; 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 { class ColourPicker : public Field {
using Field::Field; using Field::Field;

View File

@@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
break; break;
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); 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_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: default:
switch (opt.type) { switch (opt.type) {
case coFloatOrPercent: 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. // assign function objects for callbacks, etc.
return field; return field;
} }

View 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

View 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

View File

@@ -4,6 +4,7 @@
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "I18N.hpp" #include "I18N.hpp"
#include "OrcaCloudServiceAgent.hpp" #include "OrcaCloudServiceAgent.hpp"
#include "slic3r/plugin/PluginConfig.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp" #include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp" #include "slic3r/plugin/PythonInterpreter.hpp"
@@ -16,11 +17,8 @@
#include <slic3r/GUI/format.hpp> #include <slic3r/GUI/format.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp> #include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/PluginLoader.hpp> #include <slic3r/plugin/PluginLoader.hpp>
#include <pybind11/embed.h>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <boost/log/trivial.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); 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(); nlohmann::json changelog = nlohmann::json::array();
for (const PluginChangelogView& entry : dialog_item.changelog) { for (const PluginChangelogView& entry : dialog_item.changelog) {
nlohmann::json c; nlohmann::json c;
@@ -918,153 +927,32 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
return from_u8(plugin_key); 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, void PluginsDialog::send_capability_config(const std::string& plugin_key,
PluginCapabilityType type, PluginCapabilityType type,
const std::string& capability_name) const std::string& capability_name)
{ {
nlohmann::json response; call_web_handler(PluginConfig::get_config_response({type, capability_name, plugin_key}));
response["command"] = "capability_config";
response["plugin_key"] = plugin_key;
response["capability_name"] = capability_name;
response["capability_type"] = plugin_capability_type_to_string(type);
response["config"] = nlohmann::json::object();
response["custom_html"] = "";
response["error"] = "";
// Scoped to (plugin_key, type, capability_name), so a stale request from a page that has not
// caught up with a refresh cannot read a different plugin's config — it just misses.
auto cap = get_capability(plugin_key, type, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
<< plugin_key << " capability_name=" << capability_name;
response["error"] = into_u8(_L("This capability is no longer available."));
call_web_handler(response);
return;
}
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
if (cap->has_config_ui) {
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
// custom UI: we report the failure and let the page fall back to the default JSON editor,
// which edits the very same stored config.
std::string html;
std::string error;
{
wxBusyCursor busy;
try {
PythonGILState gil;
html = cap->instance->get_config_ui();
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
error = "Unknown error";
}
}
if (!error.empty()) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << plugin_key
<< " capability_name=" << capability_name << " error=" << error;
response["error"] = into_u8(format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
from_u8(error)));
} else if (html.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
response["error"] = into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
} else {
response["custom_html"] = html;
}
}
call_web_handler(response);
} }
// Persists one capability's config. `config` arrives either as text straight from the default
// editor (validated here, so invalid JSON can never reach the file) or as a structured value from
// a custom UI. Only cap_config is written; identity and version metadata stay host-managed.
void PluginsDialog::save_capability_config(const std::string& plugin_key, void PluginsDialog::save_capability_config(const std::string& plugin_key,
PluginCapabilityType type, PluginCapabilityType type,
const std::string& capability_name, const std::string& capability_name,
const nlohmann::json& config) const nlohmann::json& config)
{ {
nlohmann::json response; const nlohmann::json response = PluginConfig::save_config_response({type, capability_name, plugin_key}, config);
response["command"] = "capability_config_saved";
response["plugin_key"] = plugin_key;
response["capability_name"] = capability_name;
response["capability_type"] = plugin_capability_type_to_string(type);
response["ok"] = false;
response["error"] = "";
auto cap = get_capability(plugin_key, type, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
<< plugin_key << " capability_name=" << capability_name;
response["error"] = into_u8(_L("This capability is no longer available. Your changes were not saved."));
call_web_handler(response);
return;
}
nlohmann::json parsed = config;
if (config.is_string()) {
// The page validates as the user types, but it is not the authority: re-parse here so a
// malformed document is rejected before it can reach config.json.
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
if (parsed.is_discarded()) {
response["error"] = into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
call_web_handler(response);
return;
}
}
if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, parsed)) {
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
response["error"] = into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
call_web_handler(response);
return;
}
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
// Echo the persisted value back so the editor reloads from what is actually stored rather
// than from what the user typed.
response["ok"] = true;
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
call_web_handler(response); 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, void PluginsDialog::restore_capability_config(const std::string& plugin_key,
PluginCapabilityType type, PluginCapabilityType type,
const std::string& capability_name) 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 // 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" const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
"This discards the settings currently saved for this capability."), "This discards the settings currently saved for this capability."),
from_u8(capability_name)), from_u8(capability_name)),
@@ -1072,49 +960,11 @@ void PluginsDialog::restore_capability_config(const std::string& plugin_key,
if (rc != wxYES) if (rc != wxYES)
return; return;
nlohmann::json defaults; const nlohmann::json response = PluginConfig::restore_config_response({type, capability_name, plugin_key});
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;
call_web_handler(response); 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) void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)

View File

@@ -3086,6 +3086,13 @@ void TabPrint::build()
option.opt.full_width = true; option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker"); 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); optgroup = page->new_optgroup(L("Notes"), "note", 0);
option = optgroup->get_option("notes"); option = optgroup->get_option("notes");
option.opt.full_width = true; option.opt.full_width = true;
@@ -4481,6 +4488,9 @@ void TabFilament::build()
option.opt.height = gcode_field_height;// 150; option.opt.height = gcode_field_height;// 150;
optgroup->append_single_option_line(option); 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 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 = 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"); 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("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction");
// optgroup->append_single_option_line("spaghetti_detector"); // 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("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"); 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 }; Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };

View 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

View 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

View File

@@ -4,54 +4,21 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.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/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <stdexcept> #include <stdexcept>
#include <utility>
#include <wx/utils.h>
namespace Slic3r { namespace Slic3r {
namespace { 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 // 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 // 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. // 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(); const std::string path = plugin_config_file();
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
m_storage.clear(); m_document = CapabilityConfigDocument();
m_dirty = false; m_dirty = false;
boost::system::error_code ec; boost::system::error_code ec;
@@ -99,20 +66,14 @@ void PluginConfig::load()
return; return;
} }
const auto entries = root.find(KEY_ENTRIES); const auto entries = root.find(CapabilityConfigDocument::KeyEntries);
if (entries == root.end() || !entries->is_array()) { 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; return;
} }
for (const auto& entry : *entries) { m_document = CapabilityConfigDocument::from_root_json(root);
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);
}
} }
bool PluginConfig::save() bool PluginConfig::save()
@@ -120,11 +81,10 @@ bool PluginConfig::save()
const std::string path = plugin_config_file(); const std::string path = plugin_config_file();
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
if (!m_dirty)
return true;
nlohmann::json root; const nlohmann::json root = m_document.root_json();
root[KEY_ENTRIES] = nlohmann::json::array();
for (const auto& [id, config] : m_storage)
root[KEY_ENTRIES].push_back(config_to_entry(config));
boost::system::error_code ec; boost::system::error_code ec;
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), 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); std::lock_guard<std::mutex> lock(m_mutex);
m_storage[{config.plugin_key, config.capability_name}] = config; m_dirty = m_document.upsert(CapabilityConfigEntry{{config.plugin_key, config.capability_name}, config.plugin_version, config.config}) || m_dirty;
m_dirty = true; }
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 BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_storage.find({plugin_key, capability_name}); const auto entry = m_document.find({plugin_key, capability_name});
return it != m_storage.end() ? it->second : BaseConfig(); 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 bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
{ {
std::lock_guard<std::mutex> lock(m_mutex); 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 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); 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 } // namespace Slic3r

View File

@@ -3,11 +3,14 @@
#include <libslic3r/Utils.hpp> #include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <slic3r/plugin/CapabilityConfigDocument.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp> #include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <map> #include <map>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#define PLUGIN_CONFIG_DIR "config.json" #define PLUGIN_CONFIG_DIR "config.json"
@@ -15,6 +18,12 @@ namespace Slic3r {
class PluginCapabilityInterface; class PluginCapabilityInterface;
enum class PluginConfigSource {
None,
Base,
Preset,
};
/* /*
Example config.json shape Example config.json shape
{ {
@@ -95,6 +104,7 @@ public:
// untouched, so saving one capability cannot disturb another's config. // untouched, so saving one capability cannot disturb another's config.
// The single mutation entry point for both the Plugins dialog and the Python binding. // 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 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 // Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
// no stored config. // no stored config.
@@ -103,12 +113,35 @@ public:
bool dirty() const; bool dirty() const;
private: // ---- Webview-facing helpers, shared by PluginsDialog's Config tab and PluginsConfigDialog ----
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably. //
using CapabilityId = std::pair<std::string, std::string>; // 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; mutable std::mutex m_mutex;
std::map<CapabilityId, BaseConfig> m_storage; CapabilityConfigDocument m_document;
bool m_dirty = false; bool m_dirty = false;
}; };

View File

@@ -12,6 +12,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <libslic3r/Config.hpp> #include <libslic3r/Config.hpp>
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <vector> #include <vector>
#include <wx/utils.h> #include <wx/utils.h>
@@ -20,6 +21,7 @@
#include <map> #include <map>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <tuple>
#include <unordered_map> #include <unordered_map>
namespace Slic3r { 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; 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() static std::string resolve_cloud_base_url()
{ {
std::string cloud_base_url = "https://cloud.orcaslicer.com"; std::string cloud_base_url = "https://cloud.orcaslicer.com";

View File

@@ -4,6 +4,7 @@
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref #include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
#include <libslic3r/Preset.hpp> // Preset::Type #include <libslic3r/Preset.hpp> // Preset::Type
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PluginLoader.hpp> // PluginCapabilityIdentifier
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType #include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
#include <cstddef> #include <cstddef>
#include <functional> #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 create_full_ref(const PluginCapabilityRef& ref);
std::string resolve_recovery_url(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); bool check_capability_in_use(const std::string& capability_refs);
} // namespace Slic3r } // namespace Slic3r

View 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

View 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

View File

@@ -1,9 +1,11 @@
#include "PythonPluginBridge.hpp" #include "PythonPluginBridge.hpp"
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <exception>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <slic3r/plugin/PluginAuditManager.hpp> #include <slic3r/plugin/PluginAuditManager.hpp>
#include <string>
#include <unordered_map> #include <unordered_map>
#include <pybind11/embed.h> #include <pybind11/embed.h>
@@ -343,33 +345,41 @@ void bind_python_api(pybind11::module_& m)
"get_default_config", "get_default_config",
[](const PluginCapabilityInterface& self) { [](const PluginCapabilityInterface& self) {
nlohmann::json config = self.get_default_config(); 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" "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" "back, as a dict. Optional: without it the action stores an empty config, which already\n"
"defaults of a capability that keeps its stored config sparse and applies its own\n" "restores the defaults of a capability that keeps its stored config sparse and applies\n"
"defaults on read. Override it to write an explicit starting config instead.") "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( .def(
"get_config", "get_config",
[](const PluginCapabilityInterface& self) { [](const PluginCapabilityInterface& self) {
nlohmann::json config = capability_get_config(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( .def(
"get_config_version", "get_config_version", [](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
[](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
"Return the plugin version that last wrote this capability's config, so a newer\n" "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.") "release can spot a stale config and migrate it. Empty string if never saved.")
.def( .def(
"save_config", "save_config",
[](const PluginCapabilityInterface& self, const py::object& config) { [](const PluginCapabilityInterface& self, const std::string& config_str) {
return capability_save_config(self, py_to_json(config)); // GIL held (binding body) 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"), 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 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 // Expose the package marker base as orca.base. @orca.plugin later verifies that the
// decorated class derives from this exact pybind-registered C++ type. // decorated class derives from this exact pybind-registered C++ type.

View File

@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capability_config.cpp test_plugin_capability_config.cpp
test_plugin_capability_identifier.cpp test_plugin_capability_identifier.cpp
test_plugin_config.cpp test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_install.cpp test_plugin_install.cpp
test_slicing_pipeline_bindings.cpp test_slicing_pipeline_bindings.cpp
test_plugin_sort.cpp test_plugin_sort.cpp

View 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"});
}

View File

@@ -73,6 +73,12 @@ std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instan
// PluginManager singleton, so that is where the assertions read from. // PluginManager singleton, so that is where the assertions read from.
PluginConfig& host_config() { return PluginManager::instance().get_config(); } 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 } // namespace
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") 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"); 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")(); py::object initial = cap.attr("get_config")();
REQUIRE(py::isinstance<py::dict>(initial)); REQUIRE(py::isinstance<py::str>(initial));
CHECK(py::len(initial) == 0); CHECK(json::parse(initial.cast<std::string>()) == json::object());
CHECK(cap.attr("get_config_version")().cast<std::string>().empty()); CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
py::dict value; REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
value["speed"] = 5;
value["name"] = "fast";
REQUIRE(cap.attr("save_config")(value).cast<bool>());
// Persisted through PluginConfig, under this capability's identity only. // Persisted through PluginConfig, under this capability's identity only.
const BaseConfig stored = host_config().get_config("plugin_a", "cap_a"); const BaseConfig stored = host_config().get_config("plugin_a", "cap_a");
REQUIRE_FALSE(stored.empty()); REQUIRE_FALSE(stored.empty());
CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}}); CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}});
// And read back through Python as a dict of exactly cap_config — no host metadata. // And read back through Python as exactly cap_config — no host metadata.
py::object reloaded = cap.attr("get_config")(); const json reloaded = py_get_config(cap);
REQUIRE(py::isinstance<py::dict>(reloaded)); CHECK(reloaded.size() == 2);
CHECK(py::len(reloaded) == 2);
CHECK(reloaded.contains("speed")); CHECK(reloaded.contains("speed"));
CHECK_FALSE(reloaded.contains("plugin_key")); CHECK_FALSE(reloaded.contains("plugin_key"));
CHECK_FALSE(reloaded.contains("capability")); 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")); 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]") TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
{ {
ScopedDataDir data_dir_guard("plugin-config-py-isolation"); 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 a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a"); py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
py::dict one, two, three; REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
one["value"] = 1; REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
two["value"] = 2; REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
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>());
py::dict updated; REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
updated["value"] = 99;
REQUIRE(a_cap1.attr("save_config")(updated).cast<bool>());
CHECK(host_config().get_config("plugin_a", "cap_a").config == 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_a", "cap_b").config == json{{"value", 2}});
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}}); CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}});
// Each capability still reads back its own value. // Each capability still reads back its own value.
CHECK(a_cap2.attr("get_config")()["value"].cast<int>() == 2); CHECK(py_get_config(a_cap2).at("value") == 2);
CHECK(b_cap1.attr("get_config")()["value"].cast<int>() == 3); CHECK(py_get_config(b_cap1).at("value") == 3);
} }
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]") 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")());
CHECK_THROWS(orphan.attr("get_config_version")()); 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]") 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_FALSE(iface->has_config_ui()); // -> default JSON editor
CHECK(iface->get_config_ui().empty()); CHECK(iface->get_config_ui().empty());
py::dict value; REQUIRE(py_save_config(bare, json{{"speed", 5}}));
value["speed"] = 5;
REQUIRE(bare.attr("save_config")(value).cast<bool>());
CHECK(host_config().get_config("plugin_a", "cap_a").config == 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 target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a"); py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
py::dict edited; const json edited = json{{"speed", 99}};
edited["speed"] = 99; REQUIRE(py_save_config(target, edited));
REQUIRE(target.attr("save_config")(edited).cast<bool>()); REQUIRE(py_save_config(bystander, edited));
REQUIRE(bystander.attr("save_config")(edited).cast<bool>());
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer. // What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
auto iface = as_interface(target); 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", " def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a"); "plugin_a", "cap_a");
py::dict value; REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
value["keep"] = "me";
REQUIRE(cap.attr("save_config")(value).cast<bool>());
auto iface = as_interface(cap); auto iface = as_interface(cap);
REQUIRE(iface); REQUIRE(iface);