diff --git a/resources/web/dialog/PluginsConfigDialog/index.html b/resources/web/dialog/PluginsConfigDialog/index.html new file mode 100644 index 0000000000..04225986b2 --- /dev/null +++ b/resources/web/dialog/PluginsConfigDialog/index.html @@ -0,0 +1,64 @@ + + + + + + Plugin configuration + + + + + + + +
+ + +
This preset does not use any plugin capabilities
+ + + + +
+ + + diff --git a/resources/web/dialog/PluginsConfigDialog/index.js b/resources/web/dialog/PluginsConfigDialog/index.js new file mode 100644 index 0000000000..cd7032d029 --- /dev/null +++ b/resources/web/dialog/PluginsConfigDialog/index.js @@ -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 " would + // otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal + // valid JSON while making that impossible. + const seed = JSON.stringify(config).replace(/ +(function () { + var handlers = []; + var current = ${seed}; + window.orca = { + getConfig: function () { return current; }, + saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); }, + onConfig: function (cb) { + if (typeof cb !== "function") return; + handlers.push(cb); + try { cb(current); } catch (e) {} + } + }; + window.addEventListener("message", function (event) { + if (!event.data || event.data.__orca !== "config") return; + current = event.data.config || {}; + handlers.forEach(function (handler) { + try { handler(current); } catch (e) {} + }); + }); +})(); +<\/script>`; + return bridge + html; +} + +function OnCustomConfigMessage(event) { + const custom = document.getElementById("configCustom"); + // Only the frame we created, and only while it is actually showing. + if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow) + return; + + const data = event.data; + if (!data || data.__orca !== "save") + return; + if (!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"); +}); diff --git a/resources/web/dialog/PluginsConfigDialog/styles.css b/resources/web/dialog/PluginsConfigDialog/styles.css new file mode 100644 index 0000000000..7fe45a721d --- /dev/null +++ b/resources/web/dialog/PluginsConfigDialog/styles.css @@ -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); +} diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index d0ad2310a8..0d07757244 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -504,9 +504,8 @@ function HasMixedCapabilityState(plugin) { String(capability?.type_key || "") ); - const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true); const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true); - return hasEnabled && hasDisabled; + return toggleableCapabilities.length > 0 && hasDisabled; } function IsPluginLoading(plugin) { @@ -826,12 +825,12 @@ function RenderDetails() { // native side sends and sends back what the user saves. // --------------------------------------------------------------------------- -// Every capability is configurable — it always gets at least the default JSON editor over its -// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows -// shown for a plugin that is not activated: those carry no capability name, so there is nothing to -// address on the native side and nothing to configure yet. +// The config sidebar's rows, built natively by PluginConfig::capabilities_payload and shared with +// PluginsConfigDialog. Only loaded, addressable capabilities appear: the descriptor-only rows the +// list tab shows for a plugin that is not activated carry no capability name, so there is nothing to +// configure and nothing to address on the native side. function GetConfigurableCapabilities(plugin) { - return GetCapabilities(plugin).filter((capability) => String(capability?.name || "")); + return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : []; } function RenderConfig(plugin) { diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 0d776c7599..a8fe7ab988 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2228,6 +2228,8 @@ public: // Vector value, but edited as a single string. one_string, plugin_picker, + // Raw JSON string value, edited through a dialog behind a button rather than in the row. + plugin_config, }; // Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map. diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index be48fc2730..7c7b9cad64 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1194,6 +1194,7 @@ static std::vector s_Preset_print_options{ "post_process", "slicing_pipeline_plugin", "plugins", + "plugin_preference_overrides", "process_change_extrusion_role_gcode", "min_length_factor", "wall_maximum_resolution", @@ -1345,7 +1346,8 @@ static std::vector s_Preset_filament_options {/*"filament_colour", "filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature", //BBS filament change length while the extruder color "filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower", - "long_retractions_when_ec", "retraction_distances_when_ec" + "long_retractions_when_ec", "retraction_distances_when_ec", + "plugin_preference_overrides" }; static std::vector s_Preset_machine_limits_options { @@ -1384,7 +1386,8 @@ static std::vector s_Preset_printer_options { "cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower", "z_offset", "disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut", - "bed_temperature_formula", "nozzle_flush_dataset" + "bed_temperature_formula", "nozzle_flush_dataset", + "plugin_preference_overrides" }; static std::vector s_Preset_sla_print_options { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 7cd82a355c..8dc39f9115 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -929,6 +929,17 @@ void PrintConfigDef::init_common_params() def = this->add("preset_name", coString); def->set_default_value(new ConfigOptionString()); } + + def = this->add("plugin_preference_overrides", coString); + def->label = L("Plugin configuration"); + def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global " + "plugin configuration. Stored as a raw JSON array and edited through the dialog " + "behind the button, never typed in directly."); + // Never shown as a text field: plugin_config renders a button that opens PluginsConfigDialog. + def->gui_type = ConfigOptionDef::GUIType::plugin_config; + def->mode = comAdvanced; + def->cli = ConfigOptionDef::nocli; + def->set_default_value(new ConfigOptionString("")); } void PrintConfigDef::init_fff_params() diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 2c427e68a8..ede4077b43 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -116,6 +116,8 @@ set(SLIC3R_GUI_SOURCES GUI/PluginPickerDialog.hpp GUI/PluginsDialog.cpp GUI/PluginsDialog.hpp + GUI/PluginsConfigDialog.cpp + GUI/PluginsConfigDialog.hpp GUI/ProcessRunner.cpp GUI/ProcessRunner.hpp GUI/TerminalDialog.cpp @@ -613,8 +615,12 @@ set(SLIC3R_GUI_SOURCES plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp plugin/PluginFsUtils.hpp + plugin/CapabilityConfigDocument.cpp + plugin/CapabilityConfigDocument.hpp plugin/PluginConfig.cpp plugin/PluginConfig.hpp + plugin/PresetPluginConfig.cpp + plugin/PresetPluginConfig.hpp plugin/PythonJsonUtils.hpp plugin/PluginCatalog.cpp plugin/PluginCatalog.hpp diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 56173f259e..c0faaaeb83 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -23,6 +23,8 @@ #include "OG_CustomCtrl.hpp" #include "MsgDialog.hpp" #include "BitmapComboBox.hpp" +#include "PluginsConfigDialog.hpp" +#include "Widgets/Button.hpp" // BBS #include "Notebook.hpp" @@ -2308,6 +2310,136 @@ void PluginField::msw_rescale() rebuild_ui(); } +namespace { + +// The stored text as a document, or an empty array when it is absent or unparseable. Used to compare +// two versions of the value semantically: re-serializing an unchanged document can reorder keys or +// drop whitespace, and that alone must not be allowed to mark the preset dirty. +nlohmann::json plugin_overrides_as_json(const std::string& text) +{ + if (text.empty()) + return nlohmann::json::array(); + return nlohmann::json::parse(text, nullptr, /* allow_exceptions */ false); +} + +} // namespace + +void PluginConfigField::BUILD() +{ + // The button *is* the field's window (the ColourPicker idiom). Wrapping it in a panel would make + // this a container that OG_CustomCtrl sizes but never lays out, leaving the button unsized — and + // a zero-height row collapses its whole option group out of the page. + m_button = new ::Button(m_parent, _L("Configure")); + // ButtonType::Parameter is the style for a button sitting next to parameter boxes: it is what + // gives the button the same height as the fields above it, which a bare wxButton does not. + m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); + + wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight()); + if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit); + if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit); + m_button->SetMinSize(size); + m_button->SetSize(size); + + m_button->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { open_dialog(); }); + m_button->SetToolTip(get_tooltip_text(_L("Configure"))); + + window = m_button; + + if (const ConfigOptionString* def = m_opt.get_default_value()) + 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(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(value)); + else if (value.type() == typeid(std::string)) + m_json = boost::any_cast(value); + + m_value = m_json; + update_button_label(); + + m_disable_change_event = false; +} + +boost::any& PluginConfigField::get_value() +{ + // std::string, not wxString: change_opt_value any_casts a coString to std::string. + m_value = m_json; + return m_value; +} + +void PluginConfigField::enable() +{ + if (m_button) + m_button->Enable(); +} + +void PluginConfigField::disable() +{ + if (m_button) + m_button->Disable(); +} + +void PluginConfigField::msw_rescale() +{ + Field::msw_rescale(); + if (m_button == nullptr) + return; + + m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); + + wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight()); + if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit); + if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit); + m_button->SetMinSize(size); +} + void ColourPicker::BUILD() { auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index 37862c1923..448b492475 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -32,6 +32,10 @@ #define wxMSW false #endif +// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. Declared at global scope, +// which is where the widget lives. +class Button; + namespace Slic3r { namespace GUI { class Field; @@ -517,6 +521,47 @@ private: std::function m_selector; }; +// A settings row whose value is a raw JSON document that nobody should type by hand: the row shows a +// button, the button opens PluginsConfigDialog, and the document it hands back becomes the field's +// value. Routing the edit through the ordinary Field value/on_change_field path is what earns the row +// the same dirty state and revert arrow as every other setting — the dialog itself never touches the +// preset. +class PluginConfigField : public Field { + using Field::Field; +public: + PluginConfigField(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) {} + PluginConfigField(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field(parent, opt, id) {} + ~PluginConfigField() {} + + void BUILD() override; + + // Which preset's capabilities the dialog lists. Supplied by the option group, which already + // carries its Tab's type; an int for the same reason OptionsGroup::m_config_type is one — it + // keeps Preset.hpp out of this header. + void set_preset_type(int type) { m_preset_type = type; } + + void set_value(const boost::any& value, bool change_event = false) override; + boost::any& get_value() override; + + void enable() override; + void disable() override; + + // Window-field: the button is the whole field, so it is the window the option group sizes and + // positions (the ColourPicker idiom). A container panel would be sized but never laid out. + wxWindow* getWindow() override { return window; } + + void msw_rescale() override; + +private: + void open_dialog(); + void update_button_label(); + + wxWindow* window { nullptr }; // == m_button; the base class hands this to the option group + ::Button* m_button { nullptr }; + std::string m_json; // the option's raw text; "" when the preset overrides nothing + int m_preset_type { -1 }; +}; + class ColourPicker : public Field { using Field::Field; diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index a570a521e8..d8db505976 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co break; case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create(this->ctrl_parent(), opt, id)); break; case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create(this->ctrl_parent(), opt, id)); break; + case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create(this->ctrl_parent(), opt, id)); break; default: switch (opt.type) { case coFloatOrPercent: @@ -126,6 +127,13 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co }); } + // The dialog behind the button edits one preset's overrides, so it has to know which preset. The + // group already carries its Tab's type, and fields are built lazily on activate() — too late for + // the Tab to reach in and set it afterwards. + if (auto plugin_config_field = dynamic_cast(field.get())) + if (auto config_group = dynamic_cast(this)) + plugin_config_field->set_preset_type(config_group->config_type()); + // assign function objects for callbacks, etc. return field; } diff --git a/src/slic3r/GUI/PluginsConfigDialog.cpp b/src/slic3r/GUI/PluginsConfigDialog.cpp new file mode 100644 index 0000000000..4a539c7c55 --- /dev/null +++ b/src/slic3r/GUI/PluginsConfigDialog.cpp @@ -0,0 +1,238 @@ +#include "PluginsConfigDialog.hpp" + +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "format.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +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(), 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(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 diff --git a/src/slic3r/GUI/PluginsConfigDialog.hpp b/src/slic3r/GUI/PluginsConfigDialog.hpp new file mode 100644 index 0000000000..3c6e1c7448 --- /dev/null +++ b/src/slic3r/GUI/PluginsConfigDialog.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include + +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 diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 6af5d2ceba..f484411d41 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -4,6 +4,7 @@ #include "GUI_App.hpp" #include "I18N.hpp" #include "OrcaCloudServiceAgent.hpp" +#include "slic3r/plugin/PluginConfig.hpp" #include "slic3r/plugin/PluginFsUtils.hpp" #include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" @@ -16,11 +17,8 @@ #include #include -#include #include -#include - #include #include @@ -222,6 +220,17 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item) } payload_item["capabilities"] = std::move(caps); + // The Config tab's sidebar, built by the shared builder so it stays identical to + // PluginsConfigDialog's. Deliberately not the `capabilities` array above: that one is the list + // tab's, carrying enable/run state and descriptor-only rows for plugins that are not activated + // yet — neither of which the config view has any use for. + std::vector config_ids; + for (const PluginCapabilityView& capability : dialog_item.capabilities) + if (!capability.name.empty()) + config_ids.push_back(PluginCapabilityIdentifier{plugin_capability_type_from_string(capability.type_key), + capability.name, dialog_item.plugin_key}); + payload_item["config_capabilities"] = PluginConfig::capabilities_payload(config_ids); + nlohmann::json changelog = nlohmann::json::array(); for (const PluginChangelogView& entry : dialog_item.changelog) { nlohmann::json c; @@ -918,153 +927,32 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const return from_u8(plugin_key); } -// Replies to the web page with one capability's stored config, plus the custom HTML UI when the -// capability provides one. Config is sent as a JSON value, not text: the default editor -// pretty-prints it into its textarea, and a custom UI receives it as-is through window.orca. void PluginsDialog::send_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) { - nlohmann::json response; - response["command"] = "capability_config"; - response["plugin_key"] = plugin_key; - response["capability_name"] = capability_name; - response["capability_type"] = plugin_capability_type_to_string(type); - response["config"] = nlohmann::json::object(); - response["custom_html"] = ""; - response["error"] = ""; - - // Scoped to (plugin_key, type, capability_name), so a stale request from a page that has not - // caught up with a refresh cannot read a different plugin's config — it just misses. - auto cap = get_capability(plugin_key, type, capability_name); - if (!cap) { - BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key=" - << plugin_key << " capability_name=" << capability_name; - response["error"] = into_u8(_L("This capability is no longer available.")); - call_web_handler(response); - return; - } - - response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config; - - if (cap->has_config_ui) { - // Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its - // custom UI: we report the failure and let the page fall back to the default JSON editor, - // which edits the very same stored config. - std::string html; - std::string error; - { - wxBusyCursor busy; - try { - PythonGILState gil; - html = cap->instance->get_config_ui(); - } catch (const std::exception& ex) { - error = ex.what(); - } catch (...) { - error = "Unknown error"; - } - } - - if (!error.empty()) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << plugin_key - << " capability_name=" << capability_name << " error=" << error; - response["error"] = into_u8(format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."), - from_u8(error))); - } else if (html.empty()) { - BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - response["error"] = into_u8(_L("The plugin's configuration UI was empty. Showing the default editor.")); - } else { - response["custom_html"] = html; - } - } - - call_web_handler(response); + call_web_handler(PluginConfig::get_config_response({type, capability_name, plugin_key})); } -// Persists one capability's config. `config` arrives either as text straight from the default -// editor (validated here, so invalid JSON can never reach the file) or as a structured value from -// a custom UI. Only cap_config is written; identity and version metadata stay host-managed. void PluginsDialog::save_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name, const nlohmann::json& config) { - nlohmann::json response; - response["command"] = "capability_config_saved"; - response["plugin_key"] = plugin_key; - response["capability_name"] = capability_name; - response["capability_type"] = plugin_capability_type_to_string(type); - response["ok"] = false; - response["error"] = ""; - - auto cap = get_capability(plugin_key, type, capability_name); - if (!cap) { - BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key=" - << plugin_key << " capability_name=" << capability_name; - response["error"] = into_u8(_L("This capability is no longer available. Your changes were not saved.")); - call_web_handler(response); - return; - } - - nlohmann::json parsed = config; - if (config.is_string()) { - // The page validates as the user types, but it is not the authority: re-parse here so a - // malformed document is rejected before it can reach config.json. - parsed = nlohmann::json::parse(config.get(), nullptr, /* allow_exceptions */ false); - if (parsed.is_discarded()) { - response["error"] = into_u8(_L("The configuration is not valid JSON. Your changes were not saved.")); - call_web_handler(response); - return; - } - } - - if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, parsed)) { - BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - response["error"] = into_u8(_L("The configuration could not be written to disk. Your changes were not saved.")); - call_web_handler(response); - return; - } - - BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - - // Echo the persisted value back so the editor reloads from what is actually stored rather - // than from what the user typed. - response["ok"] = true; - response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config; + const nlohmann::json response = PluginConfig::save_config_response({type, capability_name, plugin_key}, config); call_web_handler(response); - show_status(_L("Configuration saved."), "success"); + if (response.value("ok", false)) + show_status(_L("Configuration saved."), "success"); } -// Overwrites one capability's stored config with the value its get_default_config() hands back. -// The host does not invent that value: a capability that does not override the hook restores an -// empty config, which is exactly right for one that applies its own defaults on read. void PluginsDialog::restore_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) { - nlohmann::json response; - response["command"] = "capability_config_saved"; - response["plugin_key"] = plugin_key; - response["capability_name"] = capability_name; - response["capability_type"] = plugin_capability_type_to_string(type); - response["ok"] = false; - response["error"] = ""; - - auto cap = get_capability(plugin_key, type, capability_name); - if (!cap) { - BOOST_LOG_TRIVIAL(warning) << "Refusing to restore config for a capability that is no longer loaded. plugin_key=" - << plugin_key << " capability_name=" << capability_name; - response["error"] = into_u8(_L("This capability is no longer available.")); - call_web_handler(response); - return; - } - // Discards whatever the user had stored, so confirm first — same as the other destructive - // actions in this dialog. + // actions in this dialog. The confirmation stays here rather than in PluginConfig: it needs a + // parent window, and it is the dialog's business to ask. const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n" "This discards the settings currently saved for this capability."), from_u8(capability_name)), @@ -1072,49 +960,11 @@ void PluginsDialog::restore_capability_config(const std::string& plugin_key, if (rc != wxYES) return; - nlohmann::json defaults; - std::string error; - { - wxBusyCursor busy; - try { - PythonGILState gil; - defaults = cap->instance->get_default_config(); - } catch (const std::exception& ex) { - error = ex.what(); - } catch (...) { - error = "Unknown error"; - } - } - - // A raising hook leaves the stored config exactly as it was: better to restore nothing than to - // wipe the user's settings on the strength of a broken plugin. - if (!error.empty()) { - BOOST_LOG_TRIVIAL(error) << "Plugin capability get_default_config() failed. plugin_key=" << plugin_key - << " capability_name=" << capability_name << " error=" << error; - response["error"] = into_u8(format_wxstr(_L("The plugin could not supply a default configuration (%1%). " - "Nothing was changed."), - from_u8(error))); - call_web_handler(response); - return; - } - - if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, defaults)) { - BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - response["error"] = into_u8(_L("The configuration could not be written to disk. Nothing was changed.")); - call_web_handler(response); - return; - } - - BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << plugin_key - << " capability_name=" << capability_name; - - // Reuses the saved reply, so both editors reload from what was actually persisted. - response["ok"] = true; - response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config; + const nlohmann::json response = PluginConfig::restore_config_response({type, capability_name, plugin_key}); call_web_handler(response); - show_status(_L("Default configuration restored."), "success"); + if (response.value("ok", false)) + show_status(_L("Default configuration restored."), "success"); } void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index b8e469ea92..6ff8cbbf55 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -3086,6 +3086,13 @@ void TabPrint::build() option.opt.full_width = true; optgroup->append_single_option_line(option, "others_settings_plugin_picker"); + // Its own group rather than the one above: that one hides its labels, and this row needs its + // label — and so the revert arrow beside it — to show. No label-width override either: a 0 + // there means "no label column", which is what the neighbouring full-width groups want and + // what would collapse this one. + optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); + optgroup->append_single_option_line("plugin_preference_overrides"); + optgroup = page->new_optgroup(L("Notes"), "note", 0); option = optgroup->get_option("notes"); option.opt.full_width = true; @@ -4481,6 +4488,9 @@ void TabFilament::build() option.opt.height = gcode_field_height;// 150; optgroup->append_single_option_line(option); + optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode"); + optgroup->append_single_option_line("plugin_preference_overrides"); + page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower"); optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters"); @@ -4923,6 +4933,7 @@ void TabPrinter::build_fff() optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction"); // optgroup->append_single_option_line("spaghetti_detector"); optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost"); + optgroup->append_single_option_line("plugin_preference_overrides"); optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan"); Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip }; diff --git a/src/slic3r/plugin/CapabilityConfigDocument.cpp b/src/slic3r/plugin/CapabilityConfigDocument.cpp new file mode 100644 index 0000000000..5114184445 --- /dev/null +++ b/src/slic3r/plugin/CapabilityConfigDocument.cpp @@ -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(); +} + +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 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 diff --git a/src/slic3r/plugin/CapabilityConfigDocument.hpp b/src/slic3r/plugin/CapabilityConfigDocument.hpp new file mode 100644 index 0000000000..ebdd4abbeb --- /dev/null +++ b/src/slic3r/plugin/CapabilityConfigDocument.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include + +#include +#include +#include +#include + +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 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 m_entries; + std::vector m_opaque_entries; +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginConfig.cpp b/src/slic3r/plugin/PluginConfig.cpp index a3baa7673c..33ad9fe67b 100644 --- a/src/slic3r/plugin/PluginConfig.cpp +++ b/src/slic3r/plugin/PluginConfig.cpp @@ -4,54 +4,21 @@ #include #include +#include +#include +#include +#include #include +#include #include #include -#include + +#include namespace Slic3r { namespace { -constexpr const char* KEY_ENTRIES = "config"; -constexpr const char* KEY_PLUGIN = "plugin_key"; -constexpr const char* KEY_CAPABILITY = "capability"; -constexpr const char* KEY_VERSION = "plugin_version"; -constexpr const char* KEY_CAP_CONFIG = "cap_config"; - -std::string string_field(const nlohmann::json& entry, const char* key) -{ - const auto it = entry.find(key); - return it != entry.end() && it->is_string() ? it->get() : std::string(); -} - -// Rejects entries missing an identity, which could never be looked up again. -bool entry_to_config(const nlohmann::json& entry, BaseConfig& out) -{ - if (!entry.is_object()) - return false; - - out.plugin_key = string_field(entry, KEY_PLUGIN); - out.capability_name = string_field(entry, KEY_CAPABILITY); - out.plugin_version = string_field(entry, KEY_VERSION); - if (out.empty()) - return false; - - const auto cap_config = entry.find(KEY_CAP_CONFIG); - out.config = cap_config != entry.end() ? *cap_config : nlohmann::json::object(); - return true; -} - -nlohmann::json config_to_entry(const BaseConfig& config) -{ - return nlohmann::json{ - {KEY_PLUGIN, config.plugin_key}, - {KEY_CAPABILITY, config.capability_name}, - {KEY_VERSION, config.plugin_version}, - {KEY_CAP_CONFIG, config.config}, - }; -} - // The version of the plugin package currently running. PluginDescriptor::version is // overwritten with the latest cloud version when a cloud merge happens, so it can name a // version that is not the one on disk; installed_version is what actually loaded. @@ -83,7 +50,7 @@ void PluginConfig::load() const std::string path = plugin_config_file(); std::lock_guard lock(m_mutex); - m_storage.clear(); + m_document = CapabilityConfigDocument(); m_dirty = false; boost::system::error_code ec; @@ -99,20 +66,14 @@ void PluginConfig::load() return; } - const auto entries = root.find(KEY_ENTRIES); + const auto entries = root.find(CapabilityConfigDocument::KeyEntries); if (entries == root.end() || !entries->is_array()) { - BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << KEY_ENTRIES << "\" array; starting with an empty config"; + BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << CapabilityConfigDocument::KeyEntries + << "\" array; starting with an empty config"; return; } - for (const auto& entry : *entries) { - BaseConfig config; - if (!entry_to_config(entry, config)) { - BOOST_LOG_TRIVIAL(warning) << "PluginConfig: skipping entry without a plugin key and capability name"; - continue; - } - m_storage[{config.plugin_key, config.capability_name}] = std::move(config); - } + m_document = CapabilityConfigDocument::from_root_json(root); } bool PluginConfig::save() @@ -120,11 +81,10 @@ bool PluginConfig::save() const std::string path = plugin_config_file(); std::lock_guard lock(m_mutex); + if (!m_dirty) + return true; - nlohmann::json root; - root[KEY_ENTRIES] = nlohmann::json::array(); - for (const auto& [id, config] : m_storage) - root[KEY_ENTRIES].push_back(config_to_entry(config)); + const nlohmann::json root = m_document.root_json(); boost::system::error_code ec; boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec); @@ -177,21 +137,37 @@ void PluginConfig::save_config(const BaseConfig& config) } std::lock_guard lock(m_mutex); - m_storage[{config.plugin_key, config.capability_name}] = config; - m_dirty = true; + m_dirty = m_document.upsert(CapabilityConfigEntry{{config.plugin_key, config.capability_name}, config.plugin_version, config.config}) || m_dirty; +} + +bool PluginConfig::erase_capability_config(const std::string& plugin_key, const std::string& capability_name) +{ + if (plugin_key.empty() || capability_name.empty()) + return false; + + { + std::lock_guard lock(m_mutex); + if (!m_document.erase({plugin_key, capability_name})) + return true; + m_dirty = true; + } + + return save(); } BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const { std::lock_guard lock(m_mutex); - const auto it = m_storage.find({plugin_key, capability_name}); - return it != m_storage.end() ? it->second : BaseConfig(); + const auto entry = m_document.find({plugin_key, capability_name}); + if (!entry) + return BaseConfig(); + return BaseConfig{entry->id.plugin_key, entry->id.capability, entry->plugin_version, entry->cap_config}; } bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const { std::lock_guard lock(m_mutex); - return m_storage.count({plugin_key, capability_name}) != 0; + return m_document.contains({plugin_key, capability_name}); } bool PluginConfig::dirty() const @@ -223,4 +199,194 @@ bool capability_save_config(const PluginCapabilityInterface& capability, const n return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config); } +nlohmann::json PluginConfig::capabilities_payload(const std::vector& 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(), 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 diff --git a/src/slic3r/plugin/PluginConfig.hpp b/src/slic3r/plugin/PluginConfig.hpp index 7181768b22..f2f9f111f5 100644 --- a/src/slic3r/plugin/PluginConfig.hpp +++ b/src/slic3r/plugin/PluginConfig.hpp @@ -3,11 +3,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #define PLUGIN_CONFIG_DIR "config.json" @@ -15,6 +18,12 @@ namespace Slic3r { class PluginCapabilityInterface; +enum class PluginConfigSource { + None, + Base, + Preset, +}; + /* Example config.json shape { @@ -95,6 +104,7 @@ public: // untouched, so saving one capability cannot disturb another's config. // The single mutation entry point for both the Plugins dialog and the Python binding. bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config); + bool erase_capability_config(const std::string& plugin_key, const std::string& capability_name); // Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has // no stored config. @@ -103,12 +113,35 @@ public: bool dirty() const; -private: - // (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably. - using CapabilityId = std::pair; + // ---- Webview-facing helpers, shared by PluginsDialog's Config tab and PluginsConfigDialog ---- + // + // These build the payloads both dialogs' config views speak, so the two pages stay in step and + // neither dialog owns the config protocol. They are static because a capability's config is + // addressed globally by (plugin_key, capability_name) through PluginManager's store, not through + // any one PluginConfig instance. + // + // The caller owns the UI: it confirms destructive restores and shows status toasts. These only + // touch the store, the loaded capability, and the payload. + // The config sidebar's rows: one entry per capability, in the order given. Capabilities that are + // no longer loaded are skipped — the sidebar only offers what can actually be configured. + static nlohmann::json capabilities_payload(const std::vector& caps); + + // One capability's stored config, plus its custom HTML UI when it provides one. + static nlohmann::json get_config_response(const PluginCapabilityIdentifier& id); + + // Persists one capability's config. `config` is either text straight from the default editor + // (re-parsed here, so malformed JSON can never reach config.json) or a structured value from a + // custom UI. + static nlohmann::json save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config); + + // Overwrites one capability's stored config with its get_default_config(). The caller must have + // confirmed with the user first — this does not ask. + static nlohmann::json restore_config_response(const PluginCapabilityIdentifier& id); + +private: mutable std::mutex m_mutex; - std::map m_storage; + CapabilityConfigDocument m_document; bool m_dirty = false; }; diff --git a/src/slic3r/plugin/PluginResolver.cpp b/src/slic3r/plugin/PluginResolver.cpp index d7808b6a5b..c8a42fc725 100644 --- a/src/slic3r/plugin/PluginResolver.cpp +++ b/src/slic3r/plugin/PluginResolver.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include namespace Slic3r { @@ -92,6 +94,96 @@ static bool is_tracked_type(Preset::Type type) return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT; } +std::vector referenced_capabilities(Preset::Type type, const Preset& preset) +{ + if (!is_tracked_type(type)) + return {}; + + const auto* manifest = dynamic_cast(preset.config.option("plugins")); + if (manifest == nullptr) + return {}; + + std::vector 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& 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 capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type) +{ + if (!is_tracked_type(type)) + return {}; + + std::vector 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 capabilities_in_use(Preset::Type type, const Preset& preset) +{ + std::vector result; + if (!is_tracked_type(type)) + return result; + + collect_capabilities_in_use(type, preset, result); + std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) { + return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type); + }); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + static std::string resolve_cloud_base_url() { std::string cloud_base_url = "https://cloud.orcaslicer.com"; diff --git a/src/slic3r/plugin/PluginResolver.hpp b/src/slic3r/plugin/PluginResolver.hpp index 11b1999a37..9a2e324162 100644 --- a/src/slic3r/plugin/PluginResolver.hpp +++ b/src/slic3r/plugin/PluginResolver.hpp @@ -4,6 +4,7 @@ #include // PluginCapabilityRef, parse_capability_ref #include // Preset::Type #include +#include // PluginCapabilityIdentifier #include // PluginCapabilityType #include #include @@ -81,6 +82,21 @@ void open_missing_plugins_on_cloud(const std::vector& local_refs); std::string create_full_ref(const PluginCapabilityRef& ref); std::string resolve_recovery_url(const PluginCapabilityRef& ref); +// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options +// (ConfigOptionDef::is_plugin_backed) currently references. A manifest entry nobody points at is not +// in use. Pure preset logic — the plugin catalog and loader are not consulted. Empty for untracked +// preset types. +std::vector referenced_capabilities(Preset::Type type, const Preset& preset); +std::vector 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 capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type); + bool check_capability_in_use(const std::string& capability_refs); } // namespace Slic3r diff --git a/src/slic3r/plugin/PresetPluginConfig.cpp b/src/slic3r/plugin/PresetPluginConfig.cpp new file mode 100644 index 0000000000..46e721790b --- /dev/null +++ b/src/slic3r/plugin/PresetPluginConfig.cpp @@ -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(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 diff --git a/src/slic3r/plugin/PresetPluginConfig.hpp b/src/slic3r/plugin/PresetPluginConfig.hpp new file mode 100644 index 0000000000..f714af4419 --- /dev/null +++ b/src/slic3r/plugin/PresetPluginConfig.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "CapabilityConfigDocument.hpp" +#include "PluginConfig.hpp" + +#include +#include + +#include + +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 diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 7d8d829383..156f392a8a 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -1,9 +1,11 @@ #include "PythonPluginBridge.hpp" #include +#include #include #include #include +#include #include #include @@ -343,33 +345,41 @@ void bind_python_api(pybind11::module_& m) "get_default_config", [](const PluginCapabilityInterface& self) { nlohmann::json config = self.get_default_config(); - return json_to_py(config); // GIL held (binding body) + return config.dump(); }, "Override to return the config that the Config tab's \"Restore defaults\" action writes\n" - "back. Optional: without it the action stores an empty dict, which already restores the\n" - "defaults of a capability that keeps its stored config sparse and applies its own\n" - "defaults on read. Override it to write an explicit starting config instead.") + "back, as a dict. Optional: without it the action stores an empty config, which already\n" + "restores the defaults of a capability that keeps its stored config sparse and applies\n" + "its own defaults on read. Override it to write an explicit starting config instead.\n" + "Calling it returns that config as a JSON string.") .def( "get_config", [](const PluginCapabilityInterface& self) { nlohmann::json config = capability_get_config(self); - return json_to_py(config); // GIL held (binding body) + return config.dump(); }, - "Return this capability's stored config. An empty dict if it has never been saved.") + "Return this capability's stored config as a JSON string — json.loads() it to use.\n" + "\"{}\" if it has never been saved, so the parsed result is always indexable.") .def( - "get_config_version", - [](const PluginCapabilityInterface& self) { return capability_get_config_version(self); }, + "get_config_version", [](const PluginCapabilityInterface& self) { return capability_get_config_version(self); }, "Return the plugin version that last wrote this capability's config, so a newer\n" "release can spot a stale config and migrate it. Empty string if never saved.") .def( "save_config", - [](const PluginCapabilityInterface& self, const py::object& config) { - return capability_save_config(self, py_to_json(config)); // GIL held (binding body) + [](const PluginCapabilityInterface& self, const std::string& config_str) { + nlohmann::json config = nlohmann::json::parse(config_str, nullptr, /* allow_exceptions */ false); + if (config.is_discarded()) { + // Refused rather than stored: the caller gets False, and the previously stored + // config is left alone. Logged because False alone does not say why. + BOOST_LOG_TRIVIAL(error) << "save_config: capability '" << self.get_name() << "' passed a config that is not valid JSON"; + return false; + } + return capability_save_config(self, config); }, py::arg("config"), - "Persist this capability's config, given as a JSON-compatible value (usually a dict).\n" + "Persist this capability's config, given as a JSON string (e.g. json.dumps(cfg)).\n" "The plugin key, capability name and version are supplied by the host. Returns False if\n" - "the config file could not be written."); + "the string is not valid JSON, or if the config file could not be written."); // Expose the package marker base as orca.base. @orca.plugin later verifies that the // decorated class derives from this exact pybind-registered C++ type. diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 685287be4e..86f41c1794 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_capability_config.cpp test_plugin_capability_identifier.cpp test_plugin_config.cpp + test_plugin_capabilities_in_use.cpp test_plugin_install.cpp test_slicing_pipeline_bindings.cpp test_plugin_sort.cpp diff --git a/tests/slic3rutils/test_plugin_capabilities_in_use.cpp b/tests/slic3rutils/test_plugin_capabilities_in_use.cpp new file mode 100644 index 0000000000..c6c219cf26 --- /dev/null +++ b/tests/slic3rutils/test_plugin_capabilities_in_use.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include +#include + +#include +#include +#include + +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& manifest, const std::vector& pipeline) +{ + Preset preset(Preset::TYPE_PRINT, "test-print"); + const std::unique_ptr defaults( + DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"})); + preset.config = *defaults; + preset.config.option("plugins")->values = manifest; + preset.config.option("slicing_pipeline_plugin")->values = pipeline; + return preset; +} + +std::vector capability_names(const std::vector& refs) +{ + std::vector 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{"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{"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{"CapA"}); +} diff --git a/tests/slic3rutils/test_plugin_capability_config.cpp b/tests/slic3rutils/test_plugin_capability_config.cpp index 06540257d8..59b879e191 100644 --- a/tests/slic3rutils/test_plugin_capability_config.cpp +++ b/tests/slic3rutils/test_plugin_capability_config.cpp @@ -73,6 +73,12 @@ std::shared_ptr as_interface(const py::object& instan // PluginManager singleton, so that is where the assertions read from. PluginConfig& host_config() { return PluginManager::instance().get_config(); } +// The Python config API speaks JSON text, not dicts: get_config()/get_default_config() hand back a +// JSON string and save_config() takes one. These helpers keep the tests written in terms of values. +json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast()); } + +bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast(); } + } // namespace TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") @@ -103,26 +109,23 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a"); - // Nothing stored yet: an empty dict, not None, so a plugin can index it unconditionally. + // Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads() + // and index it unconditionally. py::object initial = cap.attr("get_config")(); - REQUIRE(py::isinstance(initial)); - CHECK(py::len(initial) == 0); + REQUIRE(py::isinstance(initial)); + CHECK(json::parse(initial.cast()) == json::object()); CHECK(cap.attr("get_config_version")().cast().empty()); - py::dict value; - value["speed"] = 5; - value["name"] = "fast"; - REQUIRE(cap.attr("save_config")(value).cast()); + REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}})); // Persisted through PluginConfig, under this capability's identity only. const BaseConfig stored = host_config().get_config("plugin_a", "cap_a"); REQUIRE_FALSE(stored.empty()); CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}}); - // And read back through Python as a dict of exactly cap_config — no host metadata. - py::object reloaded = cap.attr("get_config")(); - REQUIRE(py::isinstance(reloaded)); - CHECK(py::len(reloaded) == 2); + // And read back through Python as exactly cap_config — no host metadata. + const json reloaded = py_get_config(cap); + CHECK(reloaded.size() == 2); CHECK(reloaded.contains("speed")); CHECK_FALSE(reloaded.contains("plugin_key")); CHECK_FALSE(reloaded.contains("capability")); @@ -130,6 +133,21 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl CHECK_FALSE(reloaded.contains("plugin_version")); } +TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]") +{ + ScopedDataDir data_dir_guard("plugin-config-py-badjson"); + host_config().load(); + + py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a"); + + REQUIRE(py_save_config(cap, json{{"keep", "me"}})); + + // The binding parses the string it is handed. Unparseable text is refused, and refusing it must + // leave the previously stored config alone rather than clobbering it with nothing. + CHECK_FALSE(cap.attr("save_config")("{not json").cast()); + CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"keep", "me"}}); +} + TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]") { ScopedDataDir data_dir_guard("plugin-config-py-isolation"); @@ -142,25 +160,19 @@ TEST_CASE("Saving one capability's config does not touch another's", "[PluginCon py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b"); py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a"); - py::dict one, two, three; - one["value"] = 1; - two["value"] = 2; - three["value"] = 3; - REQUIRE(a_cap1.attr("save_config")(one).cast()); - REQUIRE(a_cap2.attr("save_config")(two).cast()); - REQUIRE(b_cap1.attr("save_config")(three).cast()); + REQUIRE(py_save_config(a_cap1, json{{"value", 1}})); + REQUIRE(py_save_config(a_cap2, json{{"value", 2}})); + REQUIRE(py_save_config(b_cap1, json{{"value", 3}})); - py::dict updated; - updated["value"] = 99; - REQUIRE(a_cap1.attr("save_config")(updated).cast()); + REQUIRE(py_save_config(a_cap1, json{{"value", 99}})); CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"value", 99}}); CHECK(host_config().get_config("plugin_a", "cap_b").config == json{{"value", 2}}); CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}}); // Each capability still reads back its own value. - CHECK(a_cap2.attr("get_config")()["value"].cast() == 2); - CHECK(b_cap1.attr("get_config")()["value"].cast() == 3); + CHECK(py_get_config(a_cap2).at("value") == 2); + CHECK(py_get_config(b_cap1).at("value") == 3); } TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]") @@ -174,7 +186,7 @@ TEST_CASE("Config API refuses a capability the host never materialized", "[Plugi CHECK_THROWS(orphan.attr("get_config")()); CHECK_THROWS(orphan.attr("get_config_version")()); - CHECK_THROWS(orphan.attr("save_config")(py::dict())); + CHECK_THROWS(orphan.attr("save_config")(json::object().dump())); } TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]") @@ -206,9 +218,7 @@ TEST_CASE("A capability that omits the config UI hooks gets the default editor", CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor CHECK(iface->get_config_ui().empty()); - py::dict value; - value["speed"] = 5; - REQUIRE(bare.attr("save_config")(value).cast()); + REQUIRE(py_save_config(bare, json{{"speed", 5}})); CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 5}}); } @@ -279,10 +289,9 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a"); py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a"); - py::dict edited; - edited["speed"] = 99; - REQUIRE(target.attr("save_config")(edited).cast()); - REQUIRE(bystander.attr("save_config")(edited).cast()); + const json edited = json{{"speed", 99}}; + REQUIRE(py_save_config(target, edited)); + REQUIRE(py_save_config(bystander, edited)); // What PluginsDialog::restore_capability_config does: ask the capability, store the answer. auto iface = as_interface(target); @@ -303,9 +312,7 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P " def get_default_config(self): raise RuntimeError('boom')\n", "plugin_a", "cap_a"); - py::dict value; - value["keep"] = "me"; - REQUIRE(cap.attr("save_config")(value).cast()); + REQUIRE(py_save_config(cap, json{{"keep", "me"}})); auto iface = as_interface(cap); REQUIRE(iface);