mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-14 04:40:20 +03:00
Compare commits
7 Commits
feat/plugi
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3e1b921c | ||
|
|
0c69b9982d | ||
|
|
2a746a3e61 | ||
|
|
25d7abf81b | ||
|
|
ce21a09cb1 | ||
|
|
a00fac9b72 | ||
|
|
18388ffb6d |
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Plugin configuration</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<!-- Same three shared sheets every resources/web/dialog/* page links (see PluginsDialog/index.html):
|
||||
global.css for buttons/scrollbars, common.css for the base reset, theme.css last so its
|
||||
host-injected --orca-* contract wins and themes the .config-* rules ported into styles.css. -->
|
||||
<link rel="stylesheet" type="text/css" href="../../include/global.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/common.css">
|
||||
<link rel="stylesheet" type="text/css" href="../css/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="page-header">
|
||||
<h1 id="pageTitle" class="page-title">Plugin configuration</h1>
|
||||
<p id="pageSubtitle" class="page-subtitle"></p>
|
||||
</header>
|
||||
|
||||
<div id="configEmpty" class="detail-empty">This preset does not use any plugin capabilities</div>
|
||||
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Capabilities used by this preset"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configMeta" class="config-meta" hidden>
|
||||
<span id="configSourceBadge" class="config-source-badge"></span>
|
||||
</div>
|
||||
<div id="configEditor" class="config-editor" hidden>
|
||||
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||
</div>
|
||||
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML runs in
|
||||
an opaque origin: it cannot touch this page, and the only host surface it gets is the
|
||||
window.orca getConfig/saveConfig bridge injected into srcdoc. -->
|
||||
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||
<div id="configFooter" class="config-view-footer" hidden>
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<div class="config-actions">
|
||||
<button id="configUseGlobalBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Remove the preset override and use the global configuration again">
|
||||
Use global
|
||||
</button>
|
||||
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Write the plugin defaults into this preset override">
|
||||
Restore defaults
|
||||
</button>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="statusBar" class="status-bar is-empty">
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
@@ -0,0 +1,499 @@
|
||||
// The capabilities the active preset uses, as sent by PluginsConfigDialog::send_capabilities.
|
||||
// Each row is {plugin_key, name, type, type_key, has_config_ui} — the shape
|
||||
// PluginConfig::capabilities_payload emits, shared with the Plugins dialog's Config tab.
|
||||
let capabilities = [];
|
||||
|
||||
// The selected row's full identity. plugin_key is part of it because this list spans plugins,
|
||||
// unlike the Plugins dialog's config tab where every row belongs to the one selected plugin.
|
||||
let selectedPluginKey = "";
|
||||
let selectedCapabilityName = "";
|
||||
let selectedCapabilityType = "";
|
||||
let selectedHasPresetOverride = false;
|
||||
let selectedReadOnly = false;
|
||||
|
||||
function SafeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function SendWXMessage(message) {
|
||||
if (window.wx && typeof window.wx.postMessage === "function")
|
||||
window.wx.postMessage(message);
|
||||
}
|
||||
|
||||
function SendMessage(command, payload = {}) {
|
||||
const message = {
|
||||
sequence_id: Math.round(Date.now() / 1000),
|
||||
command: command
|
||||
};
|
||||
Object.keys(payload).forEach((key) => {
|
||||
message[key] = payload[key];
|
||||
});
|
||||
SendWXMessage(JSON.stringify(message));
|
||||
}
|
||||
|
||||
function HandleStudio(value) {
|
||||
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
|
||||
if (!payload || typeof payload !== "object")
|
||||
return;
|
||||
|
||||
if (payload.command === "list_capabilities") {
|
||||
ApplyCapabilities(payload);
|
||||
} else if (payload.command === "status_message") {
|
||||
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
|
||||
} else if (payload.command === "capability_config") {
|
||||
ApplyCapabilityConfig(payload);
|
||||
} else if (payload.command === "capability_config_saved") {
|
||||
ApplyCapabilityConfigSaved(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function ShowStatusMessage(message, level) {
|
||||
const bar = document.getElementById("statusBar");
|
||||
const text = document.getElementById("statusText");
|
||||
if (!bar || !text)
|
||||
return;
|
||||
|
||||
const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
|
||||
text.textContent = message;
|
||||
text.title = message;
|
||||
bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
|
||||
bar.classList.add(`level-${normalizedLevel}`);
|
||||
}
|
||||
|
||||
function ApplyCapabilities(payload) {
|
||||
capabilities = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
const title = document.getElementById("pageTitle");
|
||||
if (title)
|
||||
title.textContent = String(payload.title || "Plugin configuration");
|
||||
|
||||
const subtitle = document.getElementById("pageSubtitle");
|
||||
if (subtitle)
|
||||
subtitle.textContent = String(payload.preset_name || "");
|
||||
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
function IsSameCapability(capability) {
|
||||
return String(capability.plugin_key || "") === selectedPluginKey
|
||||
&& String(capability.name || "") === selectedCapabilityName
|
||||
&& String(capability.type_key || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RenderCapabilities() {
|
||||
const empty = document.getElementById("configEmpty");
|
||||
const layout = document.getElementById("configLayout");
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (!empty || !layout || !sidebar)
|
||||
return;
|
||||
|
||||
if (capabilities.length === 0) {
|
||||
empty.hidden = false;
|
||||
layout.hidden = true;
|
||||
sidebar.replaceChildren();
|
||||
ClearCapabilityConfigView();
|
||||
selectedPluginKey = "";
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = true;
|
||||
layout.hidden = false;
|
||||
|
||||
// Keep the selection across a refresh when the capability is still there; otherwise fall back to
|
||||
// the first one, which is also the initial selection.
|
||||
if (!capabilities.some(IsSameCapability)) {
|
||||
selectedPluginKey = String(capabilities[0].plugin_key || "");
|
||||
selectedCapabilityName = String(capabilities[0].name || "");
|
||||
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
}
|
||||
|
||||
sidebar.replaceChildren();
|
||||
for (const capability of capabilities) {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "config-cap";
|
||||
item.dataset.pluginKey = String(capability.plugin_key || "");
|
||||
item.dataset.capabilityName = String(capability.name || "");
|
||||
item.dataset.capabilityType = String(capability.type_key || "");
|
||||
item.setAttribute("role", "option");
|
||||
|
||||
const isSelected = IsSameCapability(capability);
|
||||
item.classList.toggle("selected", isSelected);
|
||||
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "config-cap-name";
|
||||
label.textContent = String(capability.name || "");
|
||||
item.appendChild(label);
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "config-cap-type";
|
||||
type.textContent = String(capability.type || "");
|
||||
item.appendChild(type);
|
||||
|
||||
sidebar.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function OnConfigSidebarClick(event) {
|
||||
const item = event.target.closest(".config-cap");
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const pluginKey = String(item.dataset.pluginKey || "");
|
||||
const name = String(item.dataset.capabilityName || "");
|
||||
const typeKey = String(item.dataset.capabilityType || "");
|
||||
if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||
return;
|
||||
|
||||
selectedPluginKey = pluginKey;
|
||||
selectedCapabilityName = name;
|
||||
selectedCapabilityType = typeKey;
|
||||
|
||||
// Drop the outgoing capability's view immediately: the native reply is asynchronous and its
|
||||
// content must never appear under the newly selected capability.
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
RenderCapabilities();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config editor
|
||||
//
|
||||
// The right side shows either the host's JSON editor or, when the capability ships one, its own
|
||||
// HTML UI in a sandboxed frame. Both edit the same stored config: the page holds no config state
|
||||
// of its own, it renders what the native side sends and sends back what the user saves. Ported
|
||||
// from PluginsDialog/index.js, whose config tab lives inside a single selected plugin (it reads
|
||||
// selectedPluginId from a page-global); this list spans plugins, so every row carries its own
|
||||
// plugin_key and the page tracks selectedPluginKey instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A reply is only applied when it still matches the selected row. The native side is asynchronous,
|
||||
// and unlike the Plugins dialog this list spans plugins, so plugin_key is part of the match.
|
||||
function IsCurrentCapability(payload) {
|
||||
return String(payload?.plugin_key || "") === selectedPluginKey
|
||||
&& String(payload?.capability_name || "") === selectedCapabilityName
|
||||
&& String(payload?.capability_type || "") === selectedCapabilityType;
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("get_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
// Empties both editors, so nothing from the previously selected capability can linger while the
|
||||
// next one is still in flight. The footer goes with them: until a config has actually loaded there
|
||||
// is nothing to save or restore.
|
||||
function ClearCapabilityConfigView() {
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
const footer = document.getElementById("configFooter");
|
||||
const meta = document.getElementById("configMeta");
|
||||
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (text)
|
||||
text.value = "";
|
||||
if (error) {
|
||||
error.hidden = true;
|
||||
error.textContent = "";
|
||||
}
|
||||
if (footer)
|
||||
footer.hidden = true;
|
||||
if (meta)
|
||||
meta.hidden = true;
|
||||
selectedHasPresetOverride = false;
|
||||
selectedReadOnly = false;
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
function UpdateConfigMeta(payload) {
|
||||
selectedHasPresetOverride = payload?.has_preset_override === true;
|
||||
selectedReadOnly = payload?.read_only === true;
|
||||
|
||||
const meta = document.getElementById("configMeta");
|
||||
const badge = document.getElementById("configSourceBadge");
|
||||
const useGlobal = document.getElementById("configUseGlobalBtn");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
const restore = document.getElementById("configRestoreBtn");
|
||||
if (!meta || !badge || !useGlobal)
|
||||
return;
|
||||
|
||||
const source = String(payload?.source || "none");
|
||||
badge.textContent = source === "preset" ? "Preset override" :
|
||||
(source === "base" ? "Inherited from global configuration" : "No saved configuration");
|
||||
useGlobal.hidden = !selectedHasPresetOverride;
|
||||
useGlobal.disabled = selectedReadOnly;
|
||||
if (save)
|
||||
save.disabled = selectedReadOnly;
|
||||
if (restore)
|
||||
restore.disabled = selectedReadOnly;
|
||||
meta.hidden = false;
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfig(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
|
||||
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||
const html = String(payload?.custom_html || "");
|
||||
UpdateConfigMeta(payload);
|
||||
|
||||
// Restore is host chrome and applies to either editor; Save and the validation message belong to
|
||||
// the JSON editor, since a custom UI saves through its own controls via the bridge.
|
||||
ShowConfigFooter(!html);
|
||||
|
||||
if (html) {
|
||||
// A capability with its own UI: hand it the config through the bridge, never the raw file.
|
||||
if (custom) {
|
||||
custom.hidden = false;
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Default editor. The native side already reported why a custom UI is unavailable (if it was
|
||||
// meant to have one) in payload.error, and we fall back to editing the same config here.
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = false;
|
||||
if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// Reveals the footer for the loaded capability. `withEditorControls` is false for a custom UI,
|
||||
// leaving Restore on its own.
|
||||
function ShowConfigFooter(withEditorControls) {
|
||||
const footer = document.getElementById("configFooter");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
const validation = document.getElementById("configValidation");
|
||||
|
||||
if (footer)
|
||||
footer.hidden = false;
|
||||
if (save)
|
||||
save.hidden = !withEditorControls;
|
||||
if (validation)
|
||||
validation.hidden = !withEditorControls;
|
||||
}
|
||||
|
||||
function SetConfigValidation(message) {
|
||||
const node = document.getElementById("configValidation");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
if (node) {
|
||||
node.textContent = message;
|
||||
node.classList.toggle("invalid", message !== "");
|
||||
}
|
||||
// Invalid JSON can never be saved: the button is the only way to persist, and it is disabled
|
||||
// while the text does not parse. The native side re-validates regardless.
|
||||
if (save)
|
||||
save.disabled = selectedReadOnly || message !== "";
|
||||
}
|
||||
|
||||
function ValidateConfigText() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return false;
|
||||
|
||||
try {
|
||||
JSON.parse(text.value);
|
||||
SetConfigValidation("");
|
||||
return true;
|
||||
} catch (err) {
|
||||
SetConfigValidation(String(err?.message || "Invalid JSON"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SaveCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return;
|
||||
|
||||
if (!ValidateConfigText())
|
||||
return;
|
||||
|
||||
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: text.value
|
||||
});
|
||||
}
|
||||
|
||||
// Asks the native side to write the capability's default config over whatever is stored. The
|
||||
// defaults come from the capability's get_default_config(), never from this page — the host does not
|
||||
// know what a given plugin considers default. The native side confirms before discarding anything,
|
||||
// and replies with the same "saved" payload, so both editors reload from what was persisted.
|
||||
function RestoreCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("restore_preset_defaults", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
function UseGlobalCapabilityConfig() {
|
||||
if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
|
||||
return;
|
||||
|
||||
SendMessage("remove_preset_override", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfigSaved(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const error = document.getElementById("configError");
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
if (payload?.ok !== true)
|
||||
return;
|
||||
|
||||
// Reload from what was actually persisted, so both editors show the stored state rather than
|
||||
// whatever was typed.
|
||||
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
|
||||
if (custom && !custom.hidden && custom.contentWindow)
|
||||
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
|
||||
else if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config it was opened with, save a new
|
||||
// one, and be told when a save lands. Everything else about the dialog stays out of reach — the
|
||||
// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// The config is inlined into a <script>, so a stored string containing "</script>" would
|
||||
// otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal
|
||||
// valid JSON while making that impossible.
|
||||
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
|
||||
const bridge = `<script>
|
||||
(function () {
|
||||
var handlers = [];
|
||||
var current = ${seed};
|
||||
window.orca = {
|
||||
getConfig: function () { return current; },
|
||||
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
|
||||
onConfig: function (cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
handlers.push(cb);
|
||||
try { cb(current); } catch (e) {}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!event.data || event.data.__orca !== "config") return;
|
||||
current = event.data.config || {};
|
||||
handlers.forEach(function (handler) {
|
||||
try { handler(current); } catch (e) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
<\/script>`;
|
||||
return bridge + html;
|
||||
}
|
||||
|
||||
function OnCustomConfigMessage(event) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
|
||||
return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.__orca !== "save")
|
||||
return;
|
||||
if (!selectedPluginKey || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
// The custom UI persists through the same native command as the JSON editor, so there is one
|
||||
// stored config and one code path that writes it.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginKey,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: data.config === undefined ? {} : data.config
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (sidebar)
|
||||
sidebar.addEventListener("click", OnConfigSidebarClick);
|
||||
|
||||
const saveBtn = document.getElementById("configSaveBtn");
|
||||
if (saveBtn)
|
||||
saveBtn.addEventListener("click", SaveCapabilityConfig);
|
||||
|
||||
const restoreBtn = document.getElementById("configRestoreBtn");
|
||||
if (restoreBtn)
|
||||
restoreBtn.addEventListener("click", RestoreCapabilityConfig);
|
||||
|
||||
const useGlobalBtn = document.getElementById("configUseGlobalBtn");
|
||||
if (useGlobalBtn)
|
||||
useGlobalBtn.addEventListener("click", UseGlobalCapabilityConfig);
|
||||
|
||||
const text = document.getElementById("configText");
|
||||
if (text)
|
||||
text.addEventListener("input", ValidateConfigText);
|
||||
|
||||
// The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
|
||||
// postMessage. OnCustomConfigMessage matches on the frame's own contentWindow rather than the
|
||||
// origin (which is "null" for a sandboxed frame) and ignores anything else on the channel.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
|
||||
SendMessage("request_capabilities");
|
||||
});
|
||||
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
@@ -0,0 +1,278 @@
|
||||
/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
|
||||
the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
|
||||
...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
|
||||
the same three every resources/web/dialog/* page links). This file only carries what is unique
|
||||
to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
|
||||
config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
|
||||
*/
|
||||
|
||||
/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
|
||||
dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header { flex: 0 0 auto; }
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
|
||||
|
||||
.detail-empty {
|
||||
padding: 18px 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.config-layout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.config-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-cap:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.config-cap.selected {
|
||||
background: var(--row-selected);
|
||||
border-color: var(--row-selected-outline);
|
||||
}
|
||||
|
||||
.config-cap-name {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-cap-type {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-meta[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.config-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
/* common.css applies `user-select: none` to *, so without this the user could type into the
|
||||
editor but not select, drag or copy what they had typed. */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.config-custom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-custom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-view-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-view-footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-actions > button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-validation {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-validation.invalid {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 6px 14px;
|
||||
box-sizing: border-box;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-bar.level-success .status-text {
|
||||
color: var(--plugin-status-ok);
|
||||
}
|
||||
|
||||
.status-bar.level-error .status-text {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
.status-bar.level-warn .status-text {
|
||||
color: var(--plugin-status-warn);
|
||||
}
|
||||
@@ -86,6 +86,8 @@
|
||||
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
|
||||
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
|
||||
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
|
||||
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
|
||||
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
@@ -138,6 +140,42 @@
|
||||
<div id="detailDescription" class="detail-description">No description available</div>
|
||||
</section>
|
||||
|
||||
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="configTab" data-panel="config" hidden>
|
||||
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Configurable capabilities"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configEditor" class="config-editor" hidden>
|
||||
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||
</div>
|
||||
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
|
||||
runs in an opaque origin: it cannot touch this page, and the only host surface
|
||||
it gets is the window.orca getConfig/saveConfig bridge injected into srcdoc. -->
|
||||
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||
<!-- Host chrome for both editors, not just the JSON one: a capability with a custom
|
||||
UI needs Restore just as much, and keeping it here leaves it out of the plugin's
|
||||
HTML and off the JS bridge. Save and the validation message belong to the JSON
|
||||
editor alone (a custom UI saves through its own controls), so they are hidden
|
||||
when a custom UI is showing. -->
|
||||
<div id="configFooter" class="config-view-footer" hidden>
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<div class="config-actions">
|
||||
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||
title="Discard the settings saved for this capability and restore the plugin's defaults">
|
||||
Restore defaults
|
||||
</button>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="changelogTab" data-panel="changelog" hidden>
|
||||
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">
|
||||
|
||||
@@ -21,6 +21,14 @@ let selectedPluginId = "";
|
||||
let contextPluginId = "";
|
||||
let activeDetailTab = "plugin-info";
|
||||
let selectedInstallAction = "explore";
|
||||
// Config tab: the capability whose config is currently shown, scoped to selectedPluginId. Name and
|
||||
// type together address the capability on the native side. Cleared whenever the plugin changes.
|
||||
let selectedCapabilityName = "";
|
||||
let selectedCapabilityType = "";
|
||||
// The plugin the capability selection above belongs to. Capability names are only unique within a
|
||||
// plugin, so two plugins can each expose e.g. a "main" script capability; without remembering the
|
||||
// owner, selecting the second plugin would keep the selection and show the first plugin's config.
|
||||
let configPluginId = "";
|
||||
|
||||
let pluginList = null;
|
||||
let ctxMenu = null;
|
||||
@@ -60,6 +68,22 @@ function OnInit() {
|
||||
pluginList?.addEventListener("contextmenu", OnPluginContextMenu);
|
||||
ctxMenu?.addEventListener("click", OnContextMenuClick);
|
||||
|
||||
document.getElementById("configSidebar")?.addEventListener("click", OnConfigSidebarClick);
|
||||
document.getElementById("configSaveBtn")?.addEventListener("click", SaveCapabilityConfig);
|
||||
document.getElementById("configRestoreBtn")?.addEventListener("click", RestoreCapabilityConfig);
|
||||
|
||||
const configText = document.getElementById("configText");
|
||||
// why: common.js installs a document-level onkeydown that cancels the default action of every key
|
||||
// (returnValue=false) to block webview shortcuts; on the way up it also swallows typing. Stop
|
||||
// the editor's keydowns from bubbling to it so the textarea stays editable, leaving the global
|
||||
// guard intact. Same treatment as the search field (see plugin-search.js).
|
||||
configText?.addEventListener("keydown", (event) => event.stopPropagation());
|
||||
configText?.addEventListener("input", ValidateConfigText);
|
||||
// The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
|
||||
// postMessage. Match on the frame's own contentWindow rather than the origin (which is "null"
|
||||
// for a sandboxed frame) and ignore anything else on the channel.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".ctx"))
|
||||
HideContextMenu();
|
||||
@@ -227,6 +251,10 @@ function HandleStudio(value) {
|
||||
ApplyPlugins(payload.data || []);
|
||||
} else if (payload.command === "status_message") {
|
||||
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
|
||||
} else if (payload.command === "capability_config") {
|
||||
ApplyCapabilityConfig(payload);
|
||||
} else if (payload.command === "capability_config_saved") {
|
||||
ApplyCapabilityConfigSaved(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,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) {
|
||||
@@ -785,6 +812,373 @@ function RenderDetails() {
|
||||
ApplyDetailUpdateBadge(detailUpdateBadge, plugin);
|
||||
if (detailUpdateBtn)
|
||||
ApplyDetailUpdateButton(detailUpdateBtn, plugin);
|
||||
|
||||
RenderConfig(plugin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config tab
|
||||
//
|
||||
// The sidebar lists the selected plugin's configurable capabilities; the right side shows either
|
||||
// the host's JSON editor or, when the capability ships one, its own HTML UI in a sandboxed frame.
|
||||
// Both edit the same stored config: the page holds no config state of its own, it renders what the
|
||||
// native side sends and sends back what the user saves.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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 Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
|
||||
}
|
||||
|
||||
function RenderConfig(plugin) {
|
||||
const empty = document.getElementById("configEmpty");
|
||||
const layout = document.getElementById("configLayout");
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (!empty || !layout || !sidebar)
|
||||
return;
|
||||
|
||||
const capabilities = plugin ? GetConfigurableCapabilities(plugin) : [];
|
||||
const pluginKey = String(plugin?.plugin_key || "");
|
||||
|
||||
// A different plugin than the one the current selection belongs to: drop the selection outright
|
||||
// rather than trusting the name to mean the same thing here.
|
||||
if (pluginKey !== configPluginId) {
|
||||
configPluginId = pluginKey;
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
ClearCapabilityConfigView();
|
||||
}
|
||||
|
||||
if (!plugin || capabilities.length === 0) {
|
||||
// Capabilities are only materialized once the plugin is activated, so an inactive plugin has
|
||||
// nothing to configure yet — say that, rather than claiming it has no capabilities.
|
||||
empty.textContent = !plugin
|
||||
? "Select a plugin to configure its capabilities"
|
||||
: (IsPluginLoading(plugin)
|
||||
? "Loading the plugin…"
|
||||
: (GetStatus(plugin) === "Activated"
|
||||
? "This plugin exposes no capabilities"
|
||||
: "Activate this plugin to configure its capabilities"));
|
||||
empty.hidden = false;
|
||||
layout.hidden = true;
|
||||
sidebar.replaceChildren();
|
||||
ClearCapabilityConfigView();
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = true;
|
||||
layout.hidden = false;
|
||||
|
||||
// Keep the selection across a refresh when the capability is still there; otherwise fall back to
|
||||
// the first one, which is also the initial selection for a newly selected plugin.
|
||||
const stillPresent = capabilities.some((capability) =>
|
||||
capability.name === selectedCapabilityName && String(capability.type_key || "") === selectedCapabilityType);
|
||||
if (!stillPresent) {
|
||||
selectedCapabilityName = String(capabilities[0].name || "");
|
||||
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
}
|
||||
|
||||
sidebar.replaceChildren();
|
||||
for (const capability of capabilities) {
|
||||
const name = String(capability.name || "");
|
||||
const typeKey = String(capability.type_key || "");
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "config-cap";
|
||||
item.dataset.capabilityName = name;
|
||||
item.dataset.capabilityType = typeKey;
|
||||
item.setAttribute("role", "option");
|
||||
|
||||
const isSelected = name === selectedCapabilityName && typeKey === selectedCapabilityType;
|
||||
item.classList.toggle("selected", isSelected);
|
||||
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "config-cap-name";
|
||||
label.textContent = name;
|
||||
item.appendChild(label);
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "config-cap-type";
|
||||
type.textContent = String(capability.type || "");
|
||||
item.appendChild(type);
|
||||
|
||||
sidebar.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function OnConfigSidebarClick(event) {
|
||||
const item = event.target.closest(".config-cap");
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const name = String(item.dataset.capabilityName || "");
|
||||
const typeKey = String(item.dataset.capabilityType || "");
|
||||
if (!name || (name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||
return;
|
||||
|
||||
selectedCapabilityName = name;
|
||||
selectedCapabilityType = typeKey;
|
||||
|
||||
// Drop the outgoing capability's view immediately: the native reply is asynchronous and its
|
||||
// content must never appear under the newly selected capability.
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
RenderConfig(pluginsById.get(selectedPluginId));
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("get_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
// Empties both editors, so nothing from the previously selected capability can linger while the
|
||||
// next one is still in flight. 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");
|
||||
|
||||
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;
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// True when a native reply still matches what the user has selected. A reply for a capability the
|
||||
// user has already navigated away from is dropped rather than rendered into the current view.
|
||||
function IsCurrentCapability(payload) {
|
||||
return String(payload?.plugin_key || "") === selectedPluginId &&
|
||||
String(payload?.capability_name || "") === selectedCapabilityName;
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfig(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
|
||||
const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
|
||||
const html = String(payload?.custom_html || "");
|
||||
|
||||
// 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 = message !== "";
|
||||
}
|
||||
|
||||
function ValidateConfigText() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return false;
|
||||
|
||||
try {
|
||||
JSON.parse(text.value);
|
||||
SetConfigValidation("");
|
||||
return true;
|
||||
} catch (err) {
|
||||
SetConfigValidation(String(err?.message || "Invalid JSON"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SaveCapabilityConfig() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text || !selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
if (!ValidateConfigText())
|
||||
return;
|
||||
|
||||
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: text.value
|
||||
});
|
||||
}
|
||||
|
||||
// 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 (!selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("restore_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
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 && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
|
||||
if (custom && !custom.hidden && custom.contentWindow)
|
||||
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
|
||||
else if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config it was opened with, save a new
|
||||
// one, and be told when a save lands. Everything else about the dialog stays out of reach — the
|
||||
// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// The config is inlined into a <script>, so a stored string containing "</script>" would
|
||||
// otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal
|
||||
// valid JSON while making that impossible.
|
||||
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
|
||||
const bridge = `<script>
|
||||
(function () {
|
||||
var handlers = [];
|
||||
var current = ${seed};
|
||||
window.orca = {
|
||||
getConfig: function () { return current; },
|
||||
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
|
||||
onConfig: function (cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
handlers.push(cb);
|
||||
try { cb(current); } catch (e) {}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!event.data || event.data.__orca !== "config") return;
|
||||
current = event.data.config || {};
|
||||
handlers.forEach(function (handler) {
|
||||
try { handler(current); } catch (e) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
<\/script>`;
|
||||
return bridge + html;
|
||||
}
|
||||
|
||||
function OnCustomConfigMessage(event) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
|
||||
return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.__orca !== "save")
|
||||
return;
|
||||
if (!selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
// The custom UI persists through the same native command as the JSON editor, so there is one
|
||||
// stored config and one code path that writes it.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: data.config === undefined ? {} : data.config
|
||||
});
|
||||
}
|
||||
|
||||
function RenderThumbnail(plugin) {
|
||||
|
||||
@@ -1084,6 +1084,174 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Config tab: fixed-width capability sidebar + the capability's configuration view. */
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.config-layout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.config-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-cap:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.config-cap.selected {
|
||||
background: var(--row-selected);
|
||||
border-color: var(--row-selected-outline);
|
||||
}
|
||||
|
||||
.config-cap-name {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-cap-type {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.config-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
/* common.css applies `user-select: none` to *, so without this the user could type into the
|
||||
editor but not select, drag or copy what they had typed. (What blocks typing is the global
|
||||
onkeydown guard in common.js — see the keydown handler in index.js.) */
|
||||
-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-view-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-view-footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Restore sits immediately left of Save, both pinned right; the validation message takes the
|
||||
remaining space on the left. */
|
||||
.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);
|
||||
}
|
||||
|
||||
.config-custom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-custom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
--col-sep: #4a4a51;
|
||||
--row-hover: #2b3340;
|
||||
--row-selected: #244945;
|
||||
--plugin-link-text: #5eead4;
|
||||
--plugin-status-danger: #ff7b72;
|
||||
--plugin-status-ok: #37c871;
|
||||
--plugin-status-warn: #f0b45a;
|
||||
|
||||
@@ -2228,6 +2228,8 @@ public:
|
||||
// Vector value, but edited as a single string.
|
||||
one_string,
|
||||
plugin_picker,
|
||||
// Raw JSON string value, edited through a dialog behind a button rather than in the row.
|
||||
plugin_config,
|
||||
};
|
||||
|
||||
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
|
||||
|
||||
@@ -1194,6 +1194,7 @@ static std::vector<std::string> s_Preset_print_options{
|
||||
"post_process",
|
||||
"slicing_pipeline_plugin",
|
||||
"plugins",
|
||||
"plugin_preference_overrides",
|
||||
"process_change_extrusion_role_gcode",
|
||||
"min_length_factor",
|
||||
"wall_maximum_resolution",
|
||||
@@ -1345,7 +1346,8 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
|
||||
"filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature",
|
||||
//BBS filament change length while the extruder color
|
||||
"filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower",
|
||||
"long_retractions_when_ec", "retraction_distances_when_ec"
|
||||
"long_retractions_when_ec", "retraction_distances_when_ec",
|
||||
"plugin_preference_overrides"
|
||||
};
|
||||
|
||||
static std::vector<std::string> s_Preset_machine_limits_options {
|
||||
@@ -1384,7 +1386,8 @@ static std::vector<std::string> s_Preset_printer_options {
|
||||
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower",
|
||||
"z_offset",
|
||||
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
|
||||
"bed_temperature_formula", "nozzle_flush_dataset"
|
||||
"bed_temperature_formula", "nozzle_flush_dataset",
|
||||
"plugin_preference_overrides"
|
||||
};
|
||||
|
||||
static std::vector<std::string> s_Preset_sla_print_options {
|
||||
|
||||
@@ -929,6 +929,17 @@ void PrintConfigDef::init_common_params()
|
||||
def = this->add("preset_name", coString);
|
||||
def->set_default_value(new ConfigOptionString());
|
||||
}
|
||||
|
||||
def = this->add("plugin_preference_overrides", coString);
|
||||
def->label = L("Plugin configuration");
|
||||
def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global "
|
||||
"plugin configuration. Stored as a raw JSON array and edited through the dialog "
|
||||
"behind the button, never typed in directly.");
|
||||
// Never shown as a text field: plugin_config renders a button that opens PluginsConfigDialog.
|
||||
def->gui_type = ConfigOptionDef::GUIType::plugin_config;
|
||||
def->mode = comAdvanced;
|
||||
def->cli = ConfigOptionDef::nocli;
|
||||
def->set_default_value(new ConfigOptionString(""));
|
||||
}
|
||||
|
||||
void PrintConfigDef::init_fff_params()
|
||||
|
||||
@@ -116,6 +116,8 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/PluginPickerDialog.hpp
|
||||
GUI/PluginsDialog.cpp
|
||||
GUI/PluginsDialog.hpp
|
||||
GUI/PluginsConfigDialog.cpp
|
||||
GUI/PluginsConfigDialog.hpp
|
||||
GUI/ProcessRunner.cpp
|
||||
GUI/ProcessRunner.hpp
|
||||
GUI/TerminalDialog.cpp
|
||||
@@ -613,6 +615,13 @@ 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
|
||||
plugin/PluginLoader.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<ConfigOptionString>())
|
||||
m_json = def->value;
|
||||
|
||||
update_button_label();
|
||||
m_value = m_json;
|
||||
}
|
||||
|
||||
void PluginConfigField::update_button_label()
|
||||
{
|
||||
if (m_button == nullptr)
|
||||
return;
|
||||
|
||||
const nlohmann::json entries = plugin_overrides_as_json(m_json);
|
||||
const size_t count = entries.is_array() ? entries.size() : 0;
|
||||
|
||||
// The count is the whole state this row can show: which capabilities they belong to, and what
|
||||
// they hold, is the dialog's job.
|
||||
m_button->SetLabel(count == 0 ? _L("Configure")
|
||||
: wxString::Format(_L("Configure (%d)"), int(count)));
|
||||
}
|
||||
|
||||
void PluginConfigField::open_dialog()
|
||||
{
|
||||
const Preset::Type type = static_cast<Preset::Type>(m_preset_type);
|
||||
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_FILAMENT && type != Preset::TYPE_PRINTER)
|
||||
return;
|
||||
|
||||
std::string edited;
|
||||
{
|
||||
PluginsConfigDialog dlg(m_button, type, m_json);
|
||||
dlg.ShowModal();
|
||||
edited = dlg.overrides_json();
|
||||
}
|
||||
|
||||
// Only a semantic change is a change: reopening the dialog and closing it must not dirty the
|
||||
// preset just because the document round-tripped through the serializer.
|
||||
if (plugin_overrides_as_json(m_json) == plugin_overrides_as_json(edited))
|
||||
return;
|
||||
|
||||
m_json = edited;
|
||||
m_value = m_json;
|
||||
update_button_label();
|
||||
// The one call that makes this behave like every other setting: it drives change_opt_value into
|
||||
// the preset config, Tab::update_dirty(), and the revert arrow.
|
||||
on_change_field();
|
||||
}
|
||||
|
||||
void PluginConfigField::set_value(const boost::any& value, bool change_event)
|
||||
{
|
||||
m_disable_change_event = !change_event;
|
||||
|
||||
if (value.type() == typeid(wxString))
|
||||
m_json = into_u8(boost::any_cast<wxString>(value));
|
||||
else if (value.type() == typeid(std::string))
|
||||
m_json = boost::any_cast<std::string>(value);
|
||||
|
||||
m_value = m_json;
|
||||
update_button_label();
|
||||
|
||||
m_disable_change_event = false;
|
||||
}
|
||||
|
||||
boost::any& PluginConfigField::get_value()
|
||||
{
|
||||
// std::string, not wxString: change_opt_value any_casts a coString to std::string.
|
||||
m_value = m_json;
|
||||
return m_value;
|
||||
}
|
||||
|
||||
void PluginConfigField::enable()
|
||||
{
|
||||
if (m_button)
|
||||
m_button->Enable();
|
||||
}
|
||||
|
||||
void PluginConfigField::disable()
|
||||
{
|
||||
if (m_button)
|
||||
m_button->Disable();
|
||||
}
|
||||
|
||||
void PluginConfigField::msw_rescale()
|
||||
{
|
||||
Field::msw_rescale();
|
||||
if (m_button == nullptr)
|
||||
return;
|
||||
|
||||
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
|
||||
|
||||
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
|
||||
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
|
||||
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
|
||||
m_button->SetMinSize(size);
|
||||
}
|
||||
|
||||
void ColourPicker::BUILD()
|
||||
{
|
||||
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
#define wxMSW false
|
||||
#endif
|
||||
|
||||
// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. Declared at global scope,
|
||||
// which is where the widget lives.
|
||||
class Button;
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
class Field;
|
||||
@@ -517,6 +521,47 @@ private:
|
||||
std::function<std::string()> m_selector;
|
||||
};
|
||||
|
||||
// A settings row whose value is a raw JSON document that nobody should type by hand: the row shows a
|
||||
// button, the button opens PluginsConfigDialog, and the document it hands back becomes the field's
|
||||
// value. Routing the edit through the ordinary Field value/on_change_field path is what earns the row
|
||||
// the same dirty state and revert arrow as every other setting — the dialog itself never touches the
|
||||
// preset.
|
||||
class PluginConfigField : public Field {
|
||||
using Field::Field;
|
||||
public:
|
||||
PluginConfigField(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) {}
|
||||
PluginConfigField(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field(parent, opt, id) {}
|
||||
~PluginConfigField() {}
|
||||
|
||||
void BUILD() override;
|
||||
|
||||
// Which preset's capabilities the dialog lists. Supplied by the option group, which already
|
||||
// carries its Tab's type; an int for the same reason OptionsGroup::m_config_type is one — it
|
||||
// keeps Preset.hpp out of this header.
|
||||
void set_preset_type(int type) { m_preset_type = type; }
|
||||
|
||||
void set_value(const boost::any& value, bool change_event = false) override;
|
||||
boost::any& get_value() override;
|
||||
|
||||
void enable() override;
|
||||
void disable() override;
|
||||
|
||||
// Window-field: the button is the whole field, so it is the window the option group sizes and
|
||||
// positions (the ColourPicker idiom). A container panel would be sized but never laid out.
|
||||
wxWindow* getWindow() override { return window; }
|
||||
|
||||
void msw_rescale() override;
|
||||
|
||||
private:
|
||||
void open_dialog();
|
||||
void update_button_label();
|
||||
|
||||
wxWindow* window { nullptr }; // == m_button; the base class hands this to the option group
|
||||
::Button* m_button { nullptr };
|
||||
std::string m_json; // the option's raw text; "" when the preset overrides nothing
|
||||
int m_preset_type { -1 };
|
||||
};
|
||||
|
||||
class ColourPicker : public Field {
|
||||
using Field::Field;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
||||
break;
|
||||
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
|
||||
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
|
||||
case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create<PluginConfigField>(this->ctrl_parent(), opt, id)); break;
|
||||
default:
|
||||
switch (opt.type) {
|
||||
case coFloatOrPercent:
|
||||
@@ -126,6 +127,13 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
||||
});
|
||||
}
|
||||
|
||||
// The dialog behind the button edits one preset's overrides, so it has to know which preset. The
|
||||
// group already carries its Tab's type, and fields are built lazily on activate() — too late for
|
||||
// the Tab to reach in and set it afterwards.
|
||||
if (auto plugin_config_field = dynamic_cast<PluginConfigField*>(field.get()))
|
||||
if (auto config_group = dynamic_cast<ConfigOptionsGroup*>(this))
|
||||
plugin_config_field->set_preset_type(config_group->config_type());
|
||||
|
||||
// assign function objects for callbacks, etc.
|
||||
return field;
|
||||
}
|
||||
|
||||
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
@@ -0,0 +1,238 @@
|
||||
#include "PluginsConfigDialog.hpp"
|
||||
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "format.hpp"
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
namespace {
|
||||
|
||||
wxString preset_type_title(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return _L("Process plugins");
|
||||
case Preset::TYPE_FILAMENT: return _L("Filament plugins");
|
||||
case Preset::TYPE_PRINTER: return _L("Printer plugins");
|
||||
default: return _L("Plugins");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PluginsConfigDialog::PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json)
|
||||
: WebViewHostDialog(parent, wxID_ANY, preset_type_title(type))
|
||||
, m_type(type)
|
||||
{
|
||||
// Unparseable text is kept, not replaced: parse_plugin_overrides leaves the document empty and
|
||||
// every row goes read-only, so a preset we cannot understand is never silently overwritten.
|
||||
if (!parse_plugin_overrides(overrides_json, m_overrides, m_parse_error))
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugins Config dialog: " << m_parse_error;
|
||||
|
||||
create_webview("web/dialog/PluginsConfigDialog/index.html", preset_type_title(type), wxSize(820, 660),
|
||||
wxSize(640, 520));
|
||||
}
|
||||
|
||||
PluginsConfigDialog::~PluginsConfigDialog() = default;
|
||||
|
||||
const Preset* PluginsConfigDialog::current_preset() const
|
||||
{
|
||||
const PresetBundle* bundle = wxGetApp().preset_bundle;
|
||||
if (bundle == nullptr)
|
||||
return nullptr;
|
||||
|
||||
switch (m_type) {
|
||||
case Preset::TYPE_PRINT: return &bundle->prints.get_edited_preset();
|
||||
case Preset::TYPE_PRINTER: return &bundle->printers.get_edited_preset();
|
||||
case Preset::TYPE_FILAMENT: return &bundle->filaments.get_edited_preset();
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
PluginCapabilityIdentifier PluginsConfigDialog::identifier_from(const nlohmann::json& payload) const
|
||||
{
|
||||
return {plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""),
|
||||
payload.value("plugin_key", "")};
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::on_script_message(const nlohmann::json& payload)
|
||||
{
|
||||
if (handle_common_script_command(payload))
|
||||
return;
|
||||
|
||||
const std::string command = payload.value("command", "");
|
||||
if (command == "request_capabilities") {
|
||||
send_capabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
const PluginCapabilityIdentifier id = identifier_from(payload);
|
||||
|
||||
if (command == "get_capability_config") {
|
||||
send_capability_config(id);
|
||||
} else if (command == "save_capability_config") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json value = payload.contains("config") ? payload.at("config") : nlohmann::json::object();
|
||||
if (value.is_string()) {
|
||||
value = nlohmann::json::parse(value.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||
if (value.is_discarded()) {
|
||||
send_save_error(id, into_u8(_L("The configuration is not valid JSON. Your changes were not saved.")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.set_preset_override(m_overrides, id, value);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Configuration updated. Save the preset to persist it."), "success");
|
||||
} else if (command == "restore_preset_defaults") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
||||
"This writes the plugin defaults into the current preset override."),
|
||||
from_u8(id.name)),
|
||||
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
|
||||
if (rc != wxYES)
|
||||
return;
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap)
|
||||
return;
|
||||
|
||||
nlohmann::json defaults;
|
||||
try {
|
||||
wxBusyCursor busy;
|
||||
PythonGILState gil;
|
||||
defaults = cap->instance->get_default_config();
|
||||
} catch (const std::exception& ex) {
|
||||
// A broken plugin must not be able to wipe the stored config: store nothing and say so.
|
||||
send_save_error(id, into_u8(GUI::format_wxstr(_L("The plugin failed to supply its default configuration (%1%)."),
|
||||
from_u8(ex.what()))));
|
||||
return;
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.set_preset_override(m_overrides, id, defaults);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Default configuration restored. Save the preset to persist it."), "success");
|
||||
} else if (command == "remove_preset_override") {
|
||||
if (!m_parse_error.empty()) {
|
||||
send_save_error(id, m_parse_error);
|
||||
return;
|
||||
}
|
||||
|
||||
const MutationResult result = m_service.remove_preset_override(m_overrides, id);
|
||||
if (!result.ok) {
|
||||
send_save_error(id, result.error);
|
||||
return;
|
||||
}
|
||||
send_capability_config(id);
|
||||
show_status(_L("Using global configuration. Save the preset to persist it."), "success");
|
||||
}
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_capabilities()
|
||||
{
|
||||
const Preset* preset = current_preset();
|
||||
if (preset == nullptr)
|
||||
return;
|
||||
|
||||
nlohmann::json response;
|
||||
response["command"] = "list_capabilities";
|
||||
response["preset_type"] = static_cast<int>(m_type);
|
||||
response["title"] = into_u8(preset_type_title(m_type));
|
||||
response["preset_name"] = preset->name;
|
||||
response["data"] = PluginConfig::capabilities_payload(capabilities_in_use(m_type, *preset));
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Prepared " << response["data"].size() << " capability rows for the Plugins Config dialog";
|
||||
call_web_handler(response);
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_capability_config(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
const Preset* preset = current_preset();
|
||||
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["config"] = nlohmann::json::object();
|
||||
response["custom_html"] = "";
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap || preset == nullptr) {
|
||||
response["error"] = into_u8(_L("This capability is no longer available."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const EffectiveCapabilityConfig effective = m_service.get_effective_config(m_overrides, id);
|
||||
response["config"] = effective.config;
|
||||
response["source"] = plugin_config_source_to_string(effective.source);
|
||||
response["has_preset_override"] = effective.has_preset_override;
|
||||
response["has_base_config"] = effective.has_base_config;
|
||||
response["stored_plugin_version"] = effective.stored_plugin_version;
|
||||
response["running_plugin_version"] = effective.running_plugin_version;
|
||||
response["read_only"] = !m_parse_error.empty();
|
||||
if (!m_parse_error.empty())
|
||||
response["error"] = m_parse_error;
|
||||
|
||||
if (cap->has_config_ui) {
|
||||
try {
|
||||
wxBusyCursor busy;
|
||||
PythonGILState gil;
|
||||
response["custom_html"] = cap->instance->get_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
response["error"] = into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||
from_u8(ex.what())));
|
||||
}
|
||||
}
|
||||
|
||||
call_web_handler(response);
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::send_save_error(const PluginCapabilityIdentifier& id, const std::string& error)
|
||||
{
|
||||
call_web_handler({{"command", "capability_config_saved"},
|
||||
{"plugin_key", id.plugin_key},
|
||||
{"capability_name", id.name},
|
||||
{"capability_type", plugin_capability_type_to_string(id.type)},
|
||||
{"ok", false},
|
||||
{"error", error}});
|
||||
}
|
||||
|
||||
void PluginsConfigDialog::show_status(const wxString& message, const char* level)
|
||||
{
|
||||
nlohmann::json payload;
|
||||
payload["command"] = "status_message";
|
||||
payload["level"] = level;
|
||||
payload["message"] = into_u8(message);
|
||||
call_web_handler(payload);
|
||||
}
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <slic3r/plugin/PresetPluginConfig.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
// The config half of the Plugins dialog, scoped to one preset instead of one plugin: it lists the
|
||||
// capabilities the edited preset of `m_type` actually uses (see capabilities_in_use) and edits each
|
||||
// one's stored config, falling back to the global config where the preset has no override.
|
||||
//
|
||||
// It is a pure editor over a JSON document: it never writes to the preset and never writes to the
|
||||
// base config file. The caller seeds it with the preset's raw override text and reads the edited
|
||||
// text back from overrides_json(). PluginConfigField, which owns the value, then feeds that through
|
||||
// the normal field/dirty pipeline — which is what makes the revert arrow behave like any other
|
||||
// setting. The capability list and config payloads are shared with PluginsDialog's Config tab
|
||||
// through PluginConfig's statics.
|
||||
class PluginsConfigDialog : public WebViewHostDialog
|
||||
{
|
||||
public:
|
||||
PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json);
|
||||
~PluginsConfigDialog() override;
|
||||
|
||||
// The edited overrides as compact JSON text; "" once no override remains.
|
||||
std::string overrides_json() const { return serialize_plugin_overrides(m_overrides); }
|
||||
|
||||
private:
|
||||
void on_script_message(const nlohmann::json& payload) override;
|
||||
|
||||
const Preset* current_preset() const;
|
||||
void send_capabilities();
|
||||
void send_capability_config(const PluginCapabilityIdentifier& id);
|
||||
void send_save_error(const PluginCapabilityIdentifier& id, const std::string& error);
|
||||
void show_status(const wxString& message, const char* level);
|
||||
|
||||
PluginCapabilityIdentifier identifier_from(const nlohmann::json& payload) const;
|
||||
|
||||
Preset::Type m_type = Preset::TYPE_INVALID;
|
||||
PresetPluginConfigService m_service;
|
||||
// The working copy the dialog edits. Seeded from the preset's raw text, read back by the caller.
|
||||
CapabilityConfigDocument m_overrides;
|
||||
// Set when the preset's stored text could not be parsed: the rows are shown read-only rather
|
||||
// than silently replacing data we did not understand.
|
||||
std::string m_parse_error;
|
||||
};
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "GUI_App.hpp"
|
||||
#include "I18N.hpp"
|
||||
#include "OrcaCloudServiceAgent.hpp"
|
||||
#include "slic3r/plugin/PluginConfig.hpp"
|
||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||
#include "slic3r/plugin/PluginManager.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||
@@ -16,11 +17,8 @@
|
||||
#include <slic3r/GUI/format.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
@@ -68,6 +66,11 @@ struct PluginCapabilityView
|
||||
bool enabled = false;
|
||||
bool can_toggle = false;
|
||||
bool can_run = false;
|
||||
// Whether the capability supplies its own config UI, cached on the loaded capability at load
|
||||
// time. False for the descriptor-only rows shown for a plugin that is not loaded: its
|
||||
// capabilities have not been materialized yet, so there is nothing to configure until it is
|
||||
// activated. Every real capability is configurable; this only picks the editor.
|
||||
bool has_config_ui = false;
|
||||
};
|
||||
|
||||
struct PluginChangelogView
|
||||
@@ -206,16 +209,28 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
|
||||
nlohmann::json caps = nlohmann::json::array();
|
||||
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
|
||||
nlohmann::json c;
|
||||
c["name"] = capability.name;
|
||||
c["type"] = capability.type_label;
|
||||
c["type_key"] = capability.type_key;
|
||||
c["enabled"] = capability.enabled;
|
||||
c["can_toggle"] = capability.can_toggle;
|
||||
c["can_run"] = capability.can_run;
|
||||
c["name"] = capability.name;
|
||||
c["type"] = capability.type_label;
|
||||
c["type_key"] = capability.type_key;
|
||||
c["enabled"] = capability.enabled;
|
||||
c["can_toggle"] = capability.can_toggle;
|
||||
c["can_run"] = capability.can_run;
|
||||
c["has_config_ui"] = capability.has_config_ui;
|
||||
caps.push_back(std::move(c));
|
||||
}
|
||||
payload_item["capabilities"] = std::move(caps);
|
||||
|
||||
// The Config tab's sidebar, built by the shared builder so it stays identical to
|
||||
// PluginsConfigDialog's. Deliberately not the `capabilities` array above: that one is the list
|
||||
// tab's, carrying enable/run state and descriptor-only rows for plugins that are not activated
|
||||
// yet — neither of which the config view has any use for.
|
||||
std::vector<PluginCapabilityIdentifier> config_ids;
|
||||
for (const PluginCapabilityView& capability : dialog_item.capabilities)
|
||||
if (!capability.name.empty())
|
||||
config_ids.push_back(PluginCapabilityIdentifier{plugin_capability_type_from_string(capability.type_key),
|
||||
capability.name, dialog_item.plugin_key});
|
||||
payload_item["config_capabilities"] = PluginConfig::capabilities_payload(config_ids);
|
||||
|
||||
nlohmann::json changelog = nlohmann::json::array();
|
||||
for (const PluginChangelogView& entry : dialog_item.changelog) {
|
||||
nlohmann::json c;
|
||||
@@ -355,7 +370,8 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
|
||||
for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) {
|
||||
if (cap) {
|
||||
item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type),
|
||||
plugin_capability_type_to_string(cap->type), cap->enabled, true, false});
|
||||
plugin_capability_type_to_string(cap->type), cap->enabled, true, false,
|
||||
cap->has_config_ui});
|
||||
if (cap->type == PluginCapabilityType::Script)
|
||||
item.has_script_capability = true;
|
||||
}
|
||||
@@ -518,6 +534,21 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
|
||||
open_plugin_hub();
|
||||
} else if (command == "set_plugin_sort") {
|
||||
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
|
||||
} else if (command == "get_capability_config") {
|
||||
send_capability_config(payload.value("plugin_key", ""),
|
||||
plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""));
|
||||
} else if (command == "save_capability_config") {
|
||||
// `config` is a JSON string from the default editor's textarea, or an already-structured
|
||||
// value from a capability's custom UI. Both land here; save_capability_config sorts it out.
|
||||
save_capability_config(payload.value("plugin_key", ""),
|
||||
plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""),
|
||||
payload.contains("config") ? payload.at("config") : nlohmann::json::object());
|
||||
} else if (command == "restore_capability_config") {
|
||||
restore_capability_config(payload.value("plugin_key", ""),
|
||||
plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""));
|
||||
} else if (command == "set_plugin_install_action") {
|
||||
const std::string action = payload.value("action", "");
|
||||
if (action == "explore" || action == "install-local")
|
||||
@@ -896,6 +927,46 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
|
||||
return from_u8(plugin_key);
|
||||
}
|
||||
|
||||
void PluginsDialog::send_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name)
|
||||
{
|
||||
call_web_handler(PluginConfig::get_config_response({type, capability_name, plugin_key}));
|
||||
}
|
||||
|
||||
void PluginsDialog::save_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config)
|
||||
{
|
||||
const nlohmann::json response = PluginConfig::save_config_response({type, capability_name, plugin_key}, config);
|
||||
call_web_handler(response);
|
||||
|
||||
if (response.value("ok", false))
|
||||
show_status(_L("Configuration saved."), "success");
|
||||
}
|
||||
|
||||
void PluginsDialog::restore_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name)
|
||||
{
|
||||
// Discards whatever the user had stored, so confirm first — same as the other destructive
|
||||
// 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)),
|
||||
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
|
||||
if (rc != wxYES)
|
||||
return;
|
||||
|
||||
const nlohmann::json response = PluginConfig::restore_config_response({type, capability_name, plugin_key});
|
||||
call_web_handler(response);
|
||||
|
||||
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)
|
||||
{
|
||||
if (plugin_key.empty() || capability_name.empty()) {
|
||||
|
||||
@@ -67,6 +67,15 @@ private:
|
||||
bool install_plugin_package(const std::string& package_path);
|
||||
bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name);
|
||||
void run_script_plugin(const std::string& plugin_key, const std::string& capability_name);
|
||||
// Config tab. Both are scoped to (plugin_key, type, capability_name): a request naming a
|
||||
// capability that is gone or not configurable is refused rather than served from, or written
|
||||
// to, some other entry.
|
||||
void send_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name);
|
||||
void save_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config);
|
||||
void restore_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name);
|
||||
// Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"),
|
||||
// used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive.
|
||||
void show_status(const wxString& message, const char* level);
|
||||
|
||||
@@ -3086,6 +3086,13 @@ void TabPrint::build()
|
||||
option.opt.full_width = true;
|
||||
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
|
||||
|
||||
// Its own group rather than the one above: that one hides its labels, and this row needs its
|
||||
// label — and so the revert arrow beside it — to show. No label-width override either: a 0
|
||||
// there means "no label column", which is what the neighbouring full-width groups want and
|
||||
// what would collapse this one.
|
||||
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
optgroup = page->new_optgroup(L("Notes"), "note", 0);
|
||||
option = optgroup->get_option("notes");
|
||||
option.opt.full_width = true;
|
||||
@@ -4481,6 +4488,9 @@ void TabFilament::build()
|
||||
option.opt.height = gcode_field_height;// 150;
|
||||
optgroup->append_single_option_line(option);
|
||||
|
||||
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
|
||||
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
|
||||
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
|
||||
@@ -4923,6 +4933,7 @@ void TabPrinter::build_fff()
|
||||
optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction");
|
||||
// optgroup->append_single_option_line("spaghetti_detector");
|
||||
optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
|
||||
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||
|
||||
optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan");
|
||||
Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };
|
||||
|
||||
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "CapabilityConfigDocument.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* KEY_PLUGIN = "plugin_key";
|
||||
constexpr const char* KEY_CAPABILITY = "capability";
|
||||
constexpr const char* KEY_VERSION = "plugin_version";
|
||||
constexpr const char* KEY_CAP_CONFIG = "cap_config";
|
||||
|
||||
std::string string_field(const nlohmann::json& entry, const char* key)
|
||||
{
|
||||
const auto it = entry.find(key);
|
||||
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
|
||||
}
|
||||
|
||||
bool is_recognized_entry(const nlohmann::json& entry, CapabilityConfigId& id)
|
||||
{
|
||||
if (!entry.is_object())
|
||||
return false;
|
||||
|
||||
id.plugin_key = string_field(entry, KEY_PLUGIN);
|
||||
id.capability = string_field(entry, KEY_CAPABILITY);
|
||||
return !id.plugin_key.empty() && !id.capability.empty();
|
||||
}
|
||||
|
||||
CapabilityConfigEntry decode_entry(const CapabilityConfigId& id, const nlohmann::json& entry)
|
||||
{
|
||||
CapabilityConfigEntry result;
|
||||
result.id = id;
|
||||
result.plugin_version = string_field(entry, KEY_VERSION);
|
||||
const auto cap_it = entry.find(KEY_CAP_CONFIG);
|
||||
result.cap_config = cap_it != entry.end() ? *cap_it : nlohmann::json::object();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CapabilityConfigDocument CapabilityConfigDocument::from_entries(const nlohmann::json& entries)
|
||||
{
|
||||
CapabilityConfigDocument document;
|
||||
if (!entries.is_array())
|
||||
return document;
|
||||
|
||||
for (const nlohmann::json& entry : entries) {
|
||||
CapabilityConfigId id;
|
||||
if (is_recognized_entry(entry, id))
|
||||
document.m_entries[id] = entry;
|
||||
else
|
||||
document.m_opaque_entries.push_back(entry);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
CapabilityConfigDocument CapabilityConfigDocument::from_root_json(const nlohmann::json& root)
|
||||
{
|
||||
const auto entries = root.find(KeyEntries);
|
||||
return entries != root.end() ? from_entries(*entries) : CapabilityConfigDocument();
|
||||
}
|
||||
|
||||
std::optional<CapabilityConfigEntry> CapabilityConfigDocument::find(const CapabilityConfigId& id) const
|
||||
{
|
||||
const auto it = m_entries.find(id);
|
||||
if (it == m_entries.end())
|
||||
return std::nullopt;
|
||||
return decode_entry(it->first, it->second);
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::contains(const CapabilityConfigId& id) const
|
||||
{
|
||||
return m_entries.find(id) != m_entries.end();
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::upsert(CapabilityConfigEntry entry)
|
||||
{
|
||||
if (entry.id.plugin_key.empty() || entry.id.capability.empty())
|
||||
return false;
|
||||
|
||||
nlohmann::json serialized = nlohmann::json::object();
|
||||
const auto existing = m_entries.find(entry.id);
|
||||
if (existing != m_entries.end() && existing->second.is_object())
|
||||
serialized = existing->second;
|
||||
|
||||
serialized[KEY_PLUGIN] = entry.id.plugin_key;
|
||||
serialized[KEY_CAPABILITY] = entry.id.capability;
|
||||
serialized[KEY_VERSION] = entry.plugin_version;
|
||||
serialized[KEY_CAP_CONFIG] = entry.cap_config;
|
||||
|
||||
m_entries[entry.id] = std::move(serialized);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::erase(const CapabilityConfigId& id)
|
||||
{
|
||||
return m_entries.erase(id) != 0;
|
||||
}
|
||||
|
||||
bool CapabilityConfigDocument::empty() const
|
||||
{
|
||||
return m_entries.empty() && m_opaque_entries.empty();
|
||||
}
|
||||
|
||||
nlohmann::json CapabilityConfigDocument::serialize_entries() const
|
||||
{
|
||||
nlohmann::json result = nlohmann::json::array();
|
||||
for (const auto& item : m_entries)
|
||||
result.push_back(item.second);
|
||||
for (const nlohmann::json& entry : m_opaque_entries)
|
||||
result.push_back(entry);
|
||||
return result;
|
||||
}
|
||||
|
||||
nlohmann::json CapabilityConfigDocument::root_json() const
|
||||
{
|
||||
nlohmann::json root = nlohmann::json::object();
|
||||
root[KeyEntries] = serialize_entries();
|
||||
return root;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
struct CapabilityConfigId
|
||||
{
|
||||
std::string plugin_key;
|
||||
std::string capability;
|
||||
|
||||
friend bool operator<(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||
{
|
||||
return lhs.plugin_key < rhs.plugin_key ||
|
||||
(lhs.plugin_key == rhs.plugin_key && lhs.capability < rhs.capability);
|
||||
}
|
||||
|
||||
friend bool operator==(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||
{
|
||||
return lhs.plugin_key == rhs.plugin_key && lhs.capability == rhs.capability;
|
||||
}
|
||||
};
|
||||
|
||||
struct CapabilityConfigEntry
|
||||
{
|
||||
CapabilityConfigId id;
|
||||
std::string plugin_version;
|
||||
nlohmann::json cap_config = nlohmann::json::object();
|
||||
};
|
||||
|
||||
class CapabilityConfigDocument
|
||||
{
|
||||
public:
|
||||
static constexpr const char* KeyEntries = "config";
|
||||
|
||||
static CapabilityConfigDocument from_root_json(const nlohmann::json& root);
|
||||
static CapabilityConfigDocument from_entries(const nlohmann::json& entries);
|
||||
|
||||
std::optional<CapabilityConfigEntry> find(const CapabilityConfigId& id) const;
|
||||
bool contains(const CapabilityConfigId& id) const;
|
||||
bool upsert(CapabilityConfigEntry entry);
|
||||
bool erase(const CapabilityConfigId& id);
|
||||
bool empty() const;
|
||||
nlohmann::json serialize_entries() const;
|
||||
nlohmann::json root_json() const;
|
||||
|
||||
private:
|
||||
std::map<CapabilityConfigId, nlohmann::json> m_entries;
|
||||
std::vector<nlohmann::json> m_opaque_entries;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -68,17 +68,22 @@ bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::f
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
||||
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
||||
thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading;
|
||||
thread_local std::vector<std::filesystem::path> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local bool PluginAuditManager::m_has_last_violation = false;
|
||||
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
||||
|
||||
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, PluginAuditManager::AuditMode mode)
|
||||
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
PluginAuditManager::AuditMode mode)
|
||||
: m_previous_id(PluginAuditManager::instance().current_plugin())
|
||||
, m_previous_capability(PluginAuditManager::instance().current_capability())
|
||||
, m_previous_mode(PluginAuditManager::instance().audit_mode())
|
||||
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
|
||||
{
|
||||
PluginAuditManager::instance().set_current_plugin(plugin_key);
|
||||
PluginAuditManager::instance().set_current_capability(capability_name);
|
||||
PluginAuditManager::instance().set_audit_mode(mode);
|
||||
PluginAuditManager::m_scoped_allowed_roots.clear();
|
||||
}
|
||||
@@ -86,6 +91,7 @@ ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key
|
||||
ScopedPluginAuditContext::~ScopedPluginAuditContext()
|
||||
{
|
||||
PluginAuditManager::instance().set_current_plugin(m_previous_id);
|
||||
PluginAuditManager::instance().set_current_capability(m_previous_capability);
|
||||
PluginAuditManager::instance().set_audit_mode(m_previous_mode);
|
||||
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
|
||||
}
|
||||
@@ -106,6 +112,12 @@ std::string PluginAuditManager::current_plugin() const { return m_current_plugin
|
||||
|
||||
void PluginAuditManager::clear_current_plugin() { m_current_plugin_key.clear(); }
|
||||
|
||||
void PluginAuditManager::set_current_capability(const std::string& capability_name) { m_current_capability_name = capability_name; }
|
||||
|
||||
std::string PluginAuditManager::current_capability() const { return m_current_capability_name; }
|
||||
|
||||
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
|
||||
|
||||
void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& root)
|
||||
{
|
||||
if (root.empty())
|
||||
|
||||
@@ -39,6 +39,14 @@ public:
|
||||
std::string current_plugin() const;
|
||||
void clear_current_plugin();
|
||||
|
||||
// --- current-capability context (thread_local) ---
|
||||
// The capability whose method is currently executing, within the current plugin. Empty
|
||||
// while a plugin-wide call runs, and during capture (get_name/get_type), where the
|
||||
// capability has no cached name yet.
|
||||
void set_current_capability(const std::string& capability_name);
|
||||
std::string current_capability() const;
|
||||
void clear_current_capability();
|
||||
|
||||
// --- allowed-roots registry ---
|
||||
void add_global_allowed_root(const std::filesystem::path& root);
|
||||
void add_scoped_allowed_root(const std::filesystem::path& root);
|
||||
@@ -75,6 +83,7 @@ private:
|
||||
static int audit_hook(const char* event, PyObject* args, void* user_data);
|
||||
|
||||
static thread_local std::string m_current_plugin_key;
|
||||
static thread_local std::string m_current_capability_name;
|
||||
static thread_local AuditMode m_audit_mode;
|
||||
static thread_local std::vector<std::filesystem::path> m_scoped_allowed_roots;
|
||||
static thread_local bool m_has_last_violation;
|
||||
@@ -84,12 +93,15 @@ private:
|
||||
std::vector<std::filesystem::path> m_global_allowed_roots;
|
||||
};
|
||||
|
||||
// RAII guard that sets the current plugin key and restores the previous one.
|
||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
|
||||
// single capability.
|
||||
class ScopedPluginAuditContext
|
||||
{
|
||||
public:
|
||||
explicit ScopedPluginAuditContext(
|
||||
const std::string& plugin_key,
|
||||
const std::string& capability_name = {},
|
||||
PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading);
|
||||
|
||||
~ScopedPluginAuditContext();
|
||||
@@ -99,6 +111,7 @@ public:
|
||||
|
||||
private:
|
||||
std::string m_previous_id;
|
||||
std::string m_previous_capability;
|
||||
PluginAuditManager::AuditMode m_previous_mode;
|
||||
std::vector<std::filesystem::path> m_previous_scoped_roots;
|
||||
};
|
||||
|
||||
@@ -279,7 +279,7 @@ std::vector<std::string> PluginCatalog::get_plugin_directories() const
|
||||
};
|
||||
|
||||
// Local plugins: {data_dir}/orca_plugins/
|
||||
add_or_create_dir(fs::path(data_dir()) / "orca_plugins");
|
||||
add_or_create_dir(get_orca_plugins_dir());
|
||||
|
||||
// Cloud plugins: {data_dir}/orca_plugins/_subscribed/{user_id}/
|
||||
if (!cloud_plugin_dir_name.empty())
|
||||
|
||||
392
src/slic3r/plugin/PluginConfig.cpp
Normal file
392
src/slic3r/plugin/PluginConfig.cpp
Normal file
@@ -0,0 +1,392 @@
|
||||
#include "PluginConfig.hpp"
|
||||
|
||||
#include <boost/format.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include <slic3r/GUI/GUI.hpp>
|
||||
#include <slic3r/GUI/I18N.hpp>
|
||||
#include <slic3r/GUI/format.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <wx/utils.h>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
// The identity a capability is allowed to address: its own. PluginLoader stamps both halves
|
||||
// onto the instance when it materializes the capability, so the caller never supplies them
|
||||
// and cannot name another capability's entry. Empty means the instance was never materialized
|
||||
// (so it has no config to address) and we refuse rather than read or clobber a wrong entry.
|
||||
std::pair<std::string, std::string> capability_identity(const PluginCapabilityInterface& capability, const char* api_name)
|
||||
{
|
||||
std::pair<std::string, std::string> id{capability.audit_plugin_key(), capability.audit_capability_name()};
|
||||
if (id.first.empty() || id.second.empty())
|
||||
throw std::runtime_error(std::string(api_name) + "() is only available on a capability loaded by the plugin host");
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void PluginConfig::load()
|
||||
{
|
||||
const std::string path = plugin_config_file();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_document = CapabilityConfigDocument();
|
||||
m_dirty = false;
|
||||
|
||||
boost::system::error_code ec;
|
||||
if (!boost::filesystem::exists(path, ec))
|
||||
return;
|
||||
|
||||
nlohmann::json root;
|
||||
try {
|
||||
boost::nowide::ifstream ifs(path.c_str());
|
||||
ifs >> root;
|
||||
} catch (const std::exception& err) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot read " << path << ": " << err.what() << "; starting with an empty config";
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entries = root.find(CapabilityConfigDocument::KeyEntries);
|
||||
if (entries == root.end() || !entries->is_array()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << CapabilityConfigDocument::KeyEntries
|
||||
<< "\" array; starting with an empty config";
|
||||
return;
|
||||
}
|
||||
|
||||
m_document = CapabilityConfigDocument::from_root_json(root);
|
||||
}
|
||||
|
||||
bool PluginConfig::save()
|
||||
{
|
||||
const std::string path = plugin_config_file();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_dirty)
|
||||
return true;
|
||||
|
||||
const nlohmann::json root = m_document.root_json();
|
||||
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
|
||||
if (ec) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot create the plugin directory: " << ec.message();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write to a PID-suffixed file and rename it into place, so a crash mid-write cannot
|
||||
// truncate an existing config. Same approach as AppConfig::save().
|
||||
const std::string path_pid = (boost::format("%1%.%2%") % path % get_current_pid()).str();
|
||||
|
||||
boost::nowide::ofstream file;
|
||||
file.open(path_pid, std::ios::out | std::ios::trunc);
|
||||
file << root.dump(1, '\t') << std::endl;
|
||||
file.close();
|
||||
if (file.fail()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path_pid << "; keeping the existing config";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (const std::error_code rename_ec = rename_file(path_pid, path)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to move " << path_pid << " onto " << path << ": " << rename_ec.message();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_dirty = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PluginConfig::save_config(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
const std::string& version,
|
||||
const nlohmann::json& config)
|
||||
{ save_config({plugin_key, capability_name, version, config}); }
|
||||
|
||||
bool PluginConfig::store_capability_config(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config)
|
||||
{
|
||||
save_config({plugin_key, capability_name, running_plugin_version(plugin_key), config});
|
||||
return save();
|
||||
}
|
||||
|
||||
void PluginConfig::save_config(const BaseConfig& config)
|
||||
{
|
||||
if (config.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: refusing to store a config without a plugin key and capability name";
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_dirty = m_document.upsert(CapabilityConfigEntry{{config.plugin_key, config.capability_name}, config.plugin_version, config.config}) || m_dirty;
|
||||
}
|
||||
|
||||
bool PluginConfig::erase_capability_config(const std::string& plugin_key, const std::string& capability_name)
|
||||
{
|
||||
if (plugin_key.empty() || capability_name.empty())
|
||||
return false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (!m_document.erase({plugin_key, capability_name}))
|
||||
return true;
|
||||
m_dirty = true;
|
||||
}
|
||||
|
||||
return save();
|
||||
}
|
||||
|
||||
BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
const auto entry = m_document.find({plugin_key, capability_name});
|
||||
if (!entry)
|
||||
return BaseConfig();
|
||||
return BaseConfig{entry->id.plugin_key, entry->id.capability, entry->plugin_version, entry->cap_config};
|
||||
}
|
||||
|
||||
bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_document.contains({plugin_key, capability_name});
|
||||
}
|
||||
|
||||
bool PluginConfig::dirty() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_dirty;
|
||||
}
|
||||
|
||||
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability)
|
||||
{
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config");
|
||||
|
||||
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability_name);
|
||||
// Never saved: hand back an empty object so a plugin can index the result unconditionally.
|
||||
return config.empty() ? nlohmann::json::object() : config.config;
|
||||
}
|
||||
|
||||
std::string capability_get_config_version(const PluginCapabilityInterface& capability)
|
||||
{
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config_version");
|
||||
|
||||
return PluginManager::instance().get_config().get_config(plugin_key, capability_name).plugin_version;
|
||||
}
|
||||
|
||||
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config)
|
||||
{
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "save_config");
|
||||
|
||||
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
|
||||
}
|
||||
|
||||
nlohmann::json PluginConfig::capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps)
|
||||
{
|
||||
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||
|
||||
nlohmann::json payload = nlohmann::json::array();
|
||||
for (const PluginCapabilityIdentifier& id : caps) {
|
||||
// Read has_config_ui off the live capability rather than trusting the caller's copy: a
|
||||
// capability that has been unloaded since the list was built has nothing to configure.
|
||||
const auto capability = loader.get_plugin_capability_by_name(id);
|
||||
if (!capability)
|
||||
continue;
|
||||
|
||||
nlohmann::json entry;
|
||||
entry["plugin_key"] = id.plugin_key;
|
||||
entry["name"] = id.name;
|
||||
entry["type"] = plugin_capability_type_display_name(id.type);
|
||||
entry["type_key"] = plugin_capability_type_to_string(id.type);
|
||||
entry["has_config_ui"] = capability->has_config_ui;
|
||||
payload.push_back(std::move(entry));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Replies with one capability's stored config, plus the custom HTML UI when the capability provides
|
||||
// one. Config is sent as a JSON value, not text: the default editor pretty-prints it into its
|
||||
// textarea, and a custom UI receives it as-is through window.orca.
|
||||
nlohmann::json PluginConfig::get_config_response(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["config"] = nlohmann::json::object();
|
||||
response["custom_html"] = "";
|
||||
response["error"] = "";
|
||||
|
||||
// Scoped to the full identity, so a stale request from a page that has not caught up with a
|
||||
// refresh cannot read a different plugin's config — it just misses.
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||
return response;
|
||||
}
|
||||
|
||||
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||
|
||||
if (cap->has_config_ui) {
|
||||
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
|
||||
// custom UI: we report the failure and let the page fall back to the default JSON editor,
|
||||
// which edits the very same stored config.
|
||||
std::string html;
|
||||
std::string error;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
html = cap->instance->get_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
} catch (...) {
|
||||
error = "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name << " error=" << error;
|
||||
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||
GUI::from_u8(error)));
|
||||
} else if (html.empty()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
|
||||
} else {
|
||||
response["custom_html"] = html;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
nlohmann::json PluginConfig::save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config_saved";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["ok"] = false;
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
|
||||
nlohmann::json parsed = config;
|
||||
if (config.is_string()) {
|
||||
// The page validates as the user types, but it is not the authority: re-parse here so a
|
||||
// malformed document is rejected before it can reach config.json.
|
||||
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||
if (parsed.is_discarded()) {
|
||||
response["error"] = GUI::into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, parsed)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
|
||||
return response;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << id.plugin_key << " capability_name=" << id.name;
|
||||
|
||||
// Echo the persisted value back so the editor reloads from what is actually stored rather than
|
||||
// from what the user typed.
|
||||
response["ok"] = true;
|
||||
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||
return response;
|
||||
}
|
||||
|
||||
// The host does not invent the default: a capability that does not override get_default_config()
|
||||
// restores an empty config, which is exactly right for one that applies its own defaults on read.
|
||||
nlohmann::json PluginConfig::restore_config_response(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config_saved";
|
||||
response["plugin_key"] = id.plugin_key;
|
||||
response["capability_name"] = id.name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||
response["ok"] = false;
|
||||
response["error"] = "";
|
||||
|
||||
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Refusing to restore config for a capability that is no longer loaded. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||
return response;
|
||||
}
|
||||
|
||||
nlohmann::json defaults;
|
||||
std::string error;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
defaults = cap->instance->get_default_config();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
} catch (...) {
|
||||
error = "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
// A raising hook leaves the stored config exactly as it was: better to restore nothing than to
|
||||
// wipe the user's settings on the strength of a broken plugin.
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_default_config() failed. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name << " error=" << error;
|
||||
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin could not supply a default configuration (%1%). "
|
||||
"Nothing was changed."),
|
||||
GUI::from_u8(error)));
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, defaults)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key="
|
||||
<< id.plugin_key << " capability_name=" << id.name;
|
||||
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Nothing was changed."));
|
||||
return response;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << id.plugin_key
|
||||
<< " capability_name=" << id.name;
|
||||
|
||||
// Reuses the saved reply, so both editors reload from what was actually persisted.
|
||||
response["ok"] = true;
|
||||
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||
return response;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
162
src/slic3r/plugin/PluginConfig.hpp
Normal file
162
src/slic3r/plugin/PluginConfig.hpp
Normal file
@@ -0,0 +1,162 @@
|
||||
#pragma once
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <slic3r/plugin/CapabilityConfigDocument.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#define PLUGIN_CONFIG_DIR "config.json"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
class PluginCapabilityInterface;
|
||||
|
||||
enum class PluginConfigSource {
|
||||
None,
|
||||
Base,
|
||||
Preset,
|
||||
};
|
||||
|
||||
/*
|
||||
Example config.json shape
|
||||
{
|
||||
"config": [
|
||||
{
|
||||
"plugin_key": "some_name",
|
||||
"capability": "capability_name",
|
||||
"plugin_version": "1.0.0",
|
||||
"cap_config": {
|
||||
"some": "plugin",
|
||||
"capability": "specific",
|
||||
"stuff": "here"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin_key": "some_name",
|
||||
"capability": "capability_name",
|
||||
"plugin_version": "1.0.0",
|
||||
"cap_config": {
|
||||
"some": "plugin",
|
||||
"capability": "specific",
|
||||
"stuff": "here"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin_key": "some_name",
|
||||
"capability": "capability_name",
|
||||
"plugin_version": "1.0.0",
|
||||
"cap_config": {
|
||||
"some": "plugin",
|
||||
"capability": "specific",
|
||||
"stuff": "here"
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
struct BaseConfig {
|
||||
std::string plugin_key;
|
||||
std::string capability_name;
|
||||
std::string plugin_version;
|
||||
|
||||
nlohmann::json config;
|
||||
|
||||
// True for the default-constructed instance returned by get_config() on a miss.
|
||||
bool empty() const { return plugin_key.empty() || capability_name.empty(); }
|
||||
};
|
||||
|
||||
// Consolidated store for every plugin capability's configuration, persisted as a single
|
||||
// config.json alongside the installed plugins. The shape of `cap_config` belongs to the
|
||||
// plugin; this class only round-trips it.
|
||||
//
|
||||
// A capability is identified by (plugin_key, capability_name). `plugin_version` is metadata
|
||||
// recording which version last wrote the entry, letting an upgraded plugin spot a stale
|
||||
// config and migrate it. Version is deliberately not part of the identity, so upgrading a
|
||||
// plugin does not silently reset the user's settings.
|
||||
//
|
||||
// Plugin code runs on worker threads, so every entry point is mutex-guarded.
|
||||
class PluginConfig
|
||||
{
|
||||
public:
|
||||
static const std::string plugin_config_file() { return (boost::filesystem::path(get_orca_plugins_dir()) / PLUGIN_CONFIG_DIR).string(); }
|
||||
|
||||
// Replaces the in-memory store with what is on disk. A missing or malformed file leaves
|
||||
// the store empty rather than throwing: a bad plugin config must not block startup.
|
||||
void load();
|
||||
|
||||
// Rewrites config.json atomically. Clears the dirty flag only once the file is in place.
|
||||
// False means the config on disk is unchanged.
|
||||
bool save();
|
||||
|
||||
void save_config(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const nlohmann::json& config);
|
||||
void save_config(const BaseConfig& config);
|
||||
|
||||
// Replaces one capability's cap_config and writes config.json straight away, stamping the
|
||||
// entry with the plugin version currently running. Every other entry is round-tripped
|
||||
// untouched, so saving one capability cannot disturb another's config.
|
||||
// The single mutation entry point for both the Plugins dialog and the Python binding.
|
||||
bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config);
|
||||
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.
|
||||
BaseConfig get_config(const std::string& plugin_key, const std::string& capability_name) const;
|
||||
bool has_config(const std::string& plugin_key, const std::string& capability_name) const;
|
||||
|
||||
bool dirty() const;
|
||||
|
||||
// ---- Webview-facing helpers, shared by PluginsDialog's Config tab and PluginsConfigDialog ----
|
||||
//
|
||||
// These build the payloads both dialogs' config views speak, so the two pages stay in step and
|
||||
// neither dialog owns the config protocol. They are static because a capability's config is
|
||||
// addressed globally by (plugin_key, capability_name) through PluginManager's store, not through
|
||||
// any one PluginConfig instance.
|
||||
//
|
||||
// The caller owns the UI: it confirms destructive restores and shows status toasts. These only
|
||||
// touch the store, the loaded capability, and the payload.
|
||||
|
||||
// The config sidebar's rows: one entry per capability, in the order given. Capabilities that are
|
||||
// no longer loaded are skipped — the sidebar only offers what can actually be configured.
|
||||
static nlohmann::json capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps);
|
||||
|
||||
// One capability's stored config, plus its custom HTML UI when it provides one.
|
||||
static nlohmann::json get_config_response(const PluginCapabilityIdentifier& id);
|
||||
|
||||
// Persists one capability's config. `config` is either text straight from the default editor
|
||||
// (re-parsed here, so malformed JSON can never reach config.json) or a structured value from a
|
||||
// custom UI.
|
||||
static nlohmann::json save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config);
|
||||
|
||||
// Overwrites one capability's stored config with its get_default_config(). The caller must have
|
||||
// confirmed with the user first — this does not ask.
|
||||
static nlohmann::json restore_config_response(const PluginCapabilityIdentifier& id);
|
||||
|
||||
private:
|
||||
mutable std::mutex m_mutex;
|
||||
CapabilityConfigDocument m_document;
|
||||
bool m_dirty = false;
|
||||
};
|
||||
|
||||
// Host implementations behind the capability-level Python config API (bound onto every
|
||||
// capability class in PythonPluginBridge). The capability addresses only itself: the
|
||||
// (plugin_key, capability_name) pair is read off the instance the call arrived on, never
|
||||
// passed in from Python, so a capability cannot reach another capability's config.
|
||||
// Throw std::runtime_error (surfacing to Python as RuntimeError) on an unmaterialized instance.
|
||||
|
||||
// Only the user-editable cap_config. An empty object when nothing has been saved yet.
|
||||
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability);
|
||||
// The plugin version that last wrote the entry, so a plugin can migrate a stale cap_config.
|
||||
// Empty when the capability has no stored config.
|
||||
std::string capability_get_config_version(const PluginCapabilityInterface& capability);
|
||||
// Replaces cap_config and persists. Host-managed identity and version metadata are preserved.
|
||||
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config);
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -13,10 +13,16 @@ namespace Slic3r {
|
||||
|
||||
const char* const INSTALL_STATE_FILE = ".install_state.json";
|
||||
|
||||
std::string get_orca_plugins_dir()
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
return (fs::path(data_dir()) / "orca_plugins").string();
|
||||
}
|
||||
|
||||
std::string get_cloud_plugin_dir(const std::string& user_id)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
return (fs::path(data_dir()) / "orca_plugins" / PLUGIN_SUBSCRIBED_DIR / user_id).string();
|
||||
return (fs::path(get_orca_plugins_dir()) / PLUGIN_SUBSCRIBED_DIR / user_id).string();
|
||||
}
|
||||
|
||||
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor)
|
||||
@@ -30,8 +36,7 @@ boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescript
|
||||
return {};
|
||||
}
|
||||
|
||||
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
|
||||
const std::vector<std::string>& allowed_dirs)
|
||||
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, const std::vector<std::string>& allowed_dirs)
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::path resolved_root = boost::filesystem::weakly_canonical(candidate_root, ec);
|
||||
@@ -44,8 +49,7 @@ bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
|
||||
return false;
|
||||
|
||||
for (const auto& allowed_dir : allowed_dirs) {
|
||||
if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()),
|
||||
std::filesystem::path(allowed_dir)))
|
||||
if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()), std::filesystem::path(allowed_dir)))
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -85,9 +89,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool delete_plugin_root(const boost::filesystem::path& resolved_root,
|
||||
const std::string& plugin_id,
|
||||
std::string& error)
|
||||
bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std::string& plugin_id, std::string& error)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ extern const char* const INSTALL_STATE_FILE;
|
||||
// Path: {data_dir}/orca_plugins/_subscribed/{user_id}/
|
||||
std::string get_cloud_plugin_dir(const std::string& user_id);
|
||||
|
||||
std::string get_orca_plugins_dir();
|
||||
|
||||
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor);
|
||||
|
||||
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
|
||||
|
||||
@@ -59,7 +59,7 @@ std::string plugin_package_extension(const boost::filesystem::path& path)
|
||||
|
||||
boost::filesystem::path local_plugin_root()
|
||||
{
|
||||
return boost::filesystem::path(data_dir()) / "orca_plugins";
|
||||
return boost::filesystem::path(get_orca_plugins_dir());
|
||||
}
|
||||
|
||||
boost::filesystem::path local_plugin_install_dir(const boost::filesystem::path& source_path)
|
||||
@@ -979,6 +979,23 @@ void PluginLoader::load_plugin_impl(PluginCatalog& catalog, const std::string& p
|
||||
}
|
||||
|
||||
loaded_cap->instance->set_audit_plugin_key(descriptor.plugin_key);
|
||||
loaded_cap->instance->set_audit_capability_name(loaded_cap->name);
|
||||
|
||||
// Cache the config-UI hook once, here, so the Plugins dialog can build the Config
|
||||
// tab without reaching into Python (and without the GIL). It is optional and
|
||||
// plugin-authored: a raising or non-bool override only costs this capability its
|
||||
// custom UI (it falls back to the host's JSON editor) — it must not fail the whole
|
||||
// plugin load, so it is caught locally rather than falling through to the
|
||||
// materialization handler below.
|
||||
try {
|
||||
loaded_cap->has_config_ui = loaded_cap->instance->has_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Plugin capability '" << loaded_cap->name << "' of plugin '" << loaded_cap->plugin_key
|
||||
<< "': has_config_ui() failed (" << ex.what() << "); falling back to the default JSON editor";
|
||||
loaded_cap->has_config_ui = false;
|
||||
}
|
||||
|
||||
capability_types.push_back(loaded_cap->type);
|
||||
loaded.capabilities.push_back(capability_id);
|
||||
capabilities.emplace_back(std::move(loaded_cap));
|
||||
|
||||
@@ -32,6 +32,7 @@ struct LoadedPluginCapability
|
||||
std::string name; // Cached from instance->get_name() at load time
|
||||
std::string plugin_key; // Owning package
|
||||
PluginCapabilityType type = PluginCapabilityType::Unknown; // cached from instance->get_type() at load (GUI reads this without the GIL)
|
||||
bool has_config_ui = false; // cached from instance->has_config_ui() at load (GUI reads this without the GIL)
|
||||
std::atomic<bool> enabled{true}; // logical enable/disable; disabled capabilities are skipped by consumers but stay loaded
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "PluginManager.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include "PythonPluginBridge.hpp"
|
||||
@@ -121,6 +123,12 @@ bool PluginManager::initialize()
|
||||
|
||||
m_initialized = true;
|
||||
|
||||
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
||||
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
||||
// leaves the store empty (see PluginConfig::load), never blocking startup.
|
||||
m_config.load();
|
||||
|
||||
// Install the libslic3r hooks (capability resolver, slicing-pipeline
|
||||
// dispatcher). Uninstalled in shutdown() before the interpreter finalizes.
|
||||
plugin_hooks::install();
|
||||
@@ -176,6 +184,12 @@ void PluginManager::shutdown()
|
||||
m_loader.unload_all_plugins();
|
||||
PythonPluginBridge::instance().clear_pending_captures();
|
||||
|
||||
// Every config write already goes to disk as it happens (store_capability_config), so this
|
||||
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||
// plugins above must never discard their stored config.
|
||||
if (m_config.dirty())
|
||||
m_config.save();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_initialized = false;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "PluginCatalog.hpp"
|
||||
#include "PluginLoader.hpp"
|
||||
#include "PluginDescriptor.hpp"
|
||||
#include "PluginConfig.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
@@ -58,6 +59,8 @@ public:
|
||||
const PluginCatalog& get_catalog() const { return m_catalog; }
|
||||
PluginLoader& get_loader() { return m_loader; }
|
||||
const PluginLoader& get_loader() const { return m_loader; }
|
||||
PluginConfig& get_config() { return m_config; }
|
||||
const PluginConfig& get_config() const { return m_config; }
|
||||
|
||||
void set_cloud_agent(std::shared_ptr<OrcaCloudServiceAgent> agent) { m_cloud_service.set_cloud_agent(std::move(agent)); }
|
||||
|
||||
@@ -88,6 +91,7 @@ private:
|
||||
CloudPluginService m_cloud_service;
|
||||
PluginCatalog m_catalog;
|
||||
PluginLoader m_loader;
|
||||
PluginConfig m_config;
|
||||
|
||||
mutable std::mutex m_mutex;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <libslic3r/Config.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <vector>
|
||||
#include <wx/utils.h>
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -92,6 +94,96 @@ static bool is_tracked_type(Preset::Type type)
|
||||
return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT;
|
||||
}
|
||||
|
||||
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset)
|
||||
{
|
||||
if (!is_tracked_type(type))
|
||||
return {};
|
||||
|
||||
const auto* manifest = dynamic_cast<const ConfigOptionStrings*>(preset.config.option("plugins"));
|
||||
if (manifest == nullptr)
|
||||
return {};
|
||||
|
||||
std::vector<PluginCapabilityRef> refs;
|
||||
for (const std::string& entry : manifest->values) {
|
||||
const auto ref = parse_capability_ref(entry);
|
||||
if (!ref)
|
||||
continue;
|
||||
// The same test the missing-plugin path uses: an entry no option points at is not in use.
|
||||
if (find_option_for_capability(type, preset, *ref).empty())
|
||||
continue;
|
||||
refs.push_back(*ref);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Resolve one preset's referenced capabilities to loaded capability identifiers, appending to `out`.
|
||||
// A ref whose plugin is not in the catalog, or whose capability is not currently loaded, is dropped:
|
||||
// there is no instance to ask for a config UI or defaults, so there is nothing to configure.
|
||||
void collect_capabilities_in_use(Preset::Type type, const Preset& preset, std::vector<PluginCapabilityIdentifier>& out)
|
||||
{
|
||||
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||
const PluginCatalog& catalog = PluginManager::instance().get_catalog();
|
||||
|
||||
for (const PluginCapabilityRef& ref : referenced_capabilities(type, preset)) {
|
||||
// Cloud plugins resolve by UUID, local plugins by plugin_key — the rule refresh_missing_plugins uses.
|
||||
const std::string key = ref.uuid.empty() ? ref.name : ref.uuid;
|
||||
if (key.empty())
|
||||
continue;
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
if (!catalog.try_get_plugin_descriptor(key, descriptor))
|
||||
continue;
|
||||
|
||||
// The loaded capability is also what supplies the type: the manifest ref does not carry one.
|
||||
for (const auto& capability : loader.get_loaded_plugin_capabilities(descriptor.plugin_key))
|
||||
if (capability && capability->name == ref.capability_name)
|
||||
out.push_back(PluginCapabilityIdentifier{capability->type, capability->name, capability->plugin_key});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type)
|
||||
{
|
||||
if (!is_tracked_type(type))
|
||||
return {};
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> result;
|
||||
if (type == Preset::TYPE_PRINT) {
|
||||
collect_capabilities_in_use(type, preset_bundle.prints.get_edited_preset(), result);
|
||||
} else if (type == Preset::TYPE_PRINTER) {
|
||||
collect_capabilities_in_use(type, preset_bundle.printers.get_edited_preset(), result);
|
||||
} else {
|
||||
// Filament: every selected filament preset, tested against its own config. (Note that
|
||||
// refresh_missing_plugins cannot do this — it unions the manifests and loses the preset.)
|
||||
for (const std::string& filament_name : preset_bundle.filament_presets)
|
||||
if (const Preset* filament = preset_bundle.filaments.find_preset(filament_name))
|
||||
collect_capabilities_in_use(type, *filament, result);
|
||||
}
|
||||
|
||||
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||
});
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset)
|
||||
{
|
||||
std::vector<PluginCapabilityIdentifier> result;
|
||||
if (!is_tracked_type(type))
|
||||
return result;
|
||||
|
||||
collect_capabilities_in_use(type, preset, result);
|
||||
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||
});
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string resolve_cloud_base_url()
|
||||
{
|
||||
std::string cloud_base_url = "https://cloud.orcaslicer.com";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
|
||||
#include <libslic3r/Preset.hpp> // Preset::Type
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp> // PluginCapabilityIdentifier
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
@@ -81,6 +82,21 @@ void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs);
|
||||
std::string create_full_ref(const PluginCapabilityRef& ref);
|
||||
std::string resolve_recovery_url(const PluginCapabilityRef& ref);
|
||||
|
||||
// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options
|
||||
// (ConfigOptionDef::is_plugin_backed) currently references. A manifest entry nobody points at is not
|
||||
// in use. Pure preset logic — the plugin catalog and loader are not consulted. Empty for untracked
|
||||
// preset types.
|
||||
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset);
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset);
|
||||
|
||||
// The capabilities the active preset(s) of `type` reference (see referenced_capabilities) and that
|
||||
// are loaded right now: the set that can actually be configured. Missing and broken refs are absent,
|
||||
// having no instance to ask for a config UI or defaults. A loaded-but-disabled capability IS listed —
|
||||
// it still has stored config worth editing, and disabling it is not a reason to hide that.
|
||||
// TYPE_FILAMENT unions every selected filament preset; a capability used by two extruders is listed
|
||||
// once. Deduped on the full identity, so two plugins exposing a same-named capability stay distinct.
|
||||
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type);
|
||||
|
||||
bool check_capability_in_use(const std::string& capability_refs);
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "PresetPluginConfig.hpp"
|
||||
|
||||
#include "PluginManager.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
namespace {
|
||||
|
||||
CapabilityConfigId make_id(const PluginCapabilityIdentifier& id)
|
||||
{
|
||||
return CapabilityConfigId{id.plugin_key, id.name};
|
||||
}
|
||||
|
||||
std::string running_plugin_version(const std::string& plugin_key)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().get_catalog().try_get_valid_plugin_descriptor(plugin_key, descriptor))
|
||||
return {};
|
||||
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string plugin_overrides_of(const Preset& preset)
|
||||
{
|
||||
const auto* opt = dynamic_cast<const ConfigOptionString*>(preset.config.option(PLUGIN_OVERRIDES_OPTION_KEY));
|
||||
return opt == nullptr ? std::string() : opt->value;
|
||||
}
|
||||
|
||||
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error)
|
||||
{
|
||||
document = CapabilityConfigDocument();
|
||||
error.clear();
|
||||
|
||||
if (raw.empty())
|
||||
return true;
|
||||
|
||||
const nlohmann::json parsed = nlohmann::json::parse(raw, nullptr, /* allow_exceptions */ false);
|
||||
if (parsed.is_discarded()) {
|
||||
error = "The preset stores invalid plugin capability configuration JSON.";
|
||||
return false;
|
||||
}
|
||||
if (!parsed.is_array()) {
|
||||
error = "The preset's plugin capability configuration is not an array and cannot be edited.";
|
||||
return false;
|
||||
}
|
||||
|
||||
document = CapabilityConfigDocument::from_entries(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document)
|
||||
{
|
||||
return document.empty() ? std::string() : document.serialize_entries().dump();
|
||||
}
|
||||
|
||||
std::string plugin_config_source_to_string(PluginConfigSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case PluginConfigSource::Preset: return "preset";
|
||||
case PluginConfigSource::Base: return "base";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
EffectiveCapabilityConfig PresetPluginConfigService::get_effective_config(const CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const
|
||||
{
|
||||
EffectiveCapabilityConfig result;
|
||||
result.id = make_id(id);
|
||||
result.running_plugin_version = running_plugin_version(id.plugin_key);
|
||||
|
||||
const BaseConfig base = PluginManager::instance().get_config().get_config(id.plugin_key, id.name);
|
||||
result.has_base_config = !base.empty();
|
||||
|
||||
if (const auto entry = overrides.find(result.id)) {
|
||||
result.has_preset_override = true;
|
||||
result.source = PluginConfigSource::Preset;
|
||||
result.config = entry->cap_config;
|
||||
result.stored_plugin_version = entry->plugin_version;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.has_base_config) {
|
||||
result.source = PluginConfigSource::Base;
|
||||
result.config = base.config;
|
||||
result.stored_plugin_version = base.plugin_version;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
MutationResult PresetPluginConfigService::set_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id,
|
||||
const nlohmann::json& value) const
|
||||
{
|
||||
MutationResult result;
|
||||
const CapabilityConfigId config_id = make_id(id);
|
||||
const std::string version = running_plugin_version(id.plugin_key);
|
||||
|
||||
// A no-op is a successful unchanged result, not a reason to rewrite the preset: re-saving the
|
||||
// displayed value must not be able to mark it dirty.
|
||||
const auto existing = overrides.find(config_id);
|
||||
if (existing && existing->cap_config == value && existing->plugin_version == version) {
|
||||
result.ok = true;
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
overrides.upsert({config_id, version, value});
|
||||
|
||||
result.ok = true;
|
||||
result.changed = true;
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
MutationResult PresetPluginConfigService::remove_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const
|
||||
{
|
||||
MutationResult result;
|
||||
result.ok = true;
|
||||
result.changed = overrides.erase(make_id(id));
|
||||
// Reads back as the base value now that the override is gone.
|
||||
result.effective = get_effective_config(overrides, id);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "CapabilityConfigDocument.hpp"
|
||||
#include "PluginConfig.hpp"
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// A preset keeps its plugin capability overrides as one raw JSON string in this ordinary
|
||||
// ConfigOptionString, so the whole preset lifecycle — load, save, diff/dirty, inheritance, 3MF,
|
||||
// sync — carries it for free. The plugin layer is the only thing that gives that string meaning.
|
||||
inline constexpr const char* PLUGIN_OVERRIDES_OPTION_KEY = "plugin_preference_overrides";
|
||||
|
||||
// The preset's raw override text, or "" when it stores none.
|
||||
std::string plugin_overrides_of(const Preset& preset);
|
||||
|
||||
// An empty string is a valid, empty document. Returns false and fills `error` when the text is
|
||||
// present but is not a JSON array of entries; the caller then shows it and edits nothing.
|
||||
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error);
|
||||
|
||||
// The document as compact JSON text, and "" once it holds no entries. Empty text — rather than a
|
||||
// removed option — is what records "cleared here" against an inheriting parent that has overrides.
|
||||
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document);
|
||||
|
||||
struct EffectiveCapabilityConfig
|
||||
{
|
||||
CapabilityConfigId id;
|
||||
nlohmann::json config = nlohmann::json::object();
|
||||
PluginConfigSource source = PluginConfigSource::None;
|
||||
|
||||
bool has_preset_override = false;
|
||||
bool has_base_config = false;
|
||||
std::string stored_plugin_version;
|
||||
std::string running_plugin_version;
|
||||
};
|
||||
|
||||
struct MutationResult
|
||||
{
|
||||
bool ok = false;
|
||||
bool changed = false;
|
||||
std::string error;
|
||||
EffectiveCapabilityConfig effective;
|
||||
};
|
||||
|
||||
std::string plugin_config_source_to_string(PluginConfigSource source);
|
||||
|
||||
// Resolves a capability's effective config as `preset override -> base config -> none`, and mutates
|
||||
// the override layer.
|
||||
//
|
||||
// It works on a CapabilityConfigDocument the caller owns, never on a Preset and never on the base
|
||||
// config file. That is what keeps the two layers from writing to each other: PluginConfigField holds
|
||||
// the document, and feeds the edited text back through the normal field/dirty pipeline, so the
|
||||
// preset is written exactly the way every other setting is.
|
||||
class PresetPluginConfigService
|
||||
{
|
||||
public:
|
||||
EffectiveCapabilityConfig get_effective_config(const CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const;
|
||||
MutationResult set_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id,
|
||||
const nlohmann::json& value) const;
|
||||
MutationResult remove_preset_override(CapabilityConfigDocument& overrides,
|
||||
const PluginCapabilityIdentifier& id) const;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "PythonPluginInterface.hpp"
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonJsonUtils.hpp"
|
||||
#include "PluginAuditManager.hpp"
|
||||
|
||||
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call
|
||||
@@ -29,13 +32,14 @@
|
||||
}
|
||||
|
||||
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call
|
||||
// when this trampoline instance carries a non-empty audit plugin key. Declares a local
|
||||
// `_orca_audit_scope`.
|
||||
// when this trampoline instance carries a non-empty audit plugin key. Also publishes the
|
||||
// calling capability's name, so host APIs invoked from Python can tell which capability
|
||||
// they are serving. Declares a local `_orca_audit_scope`.
|
||||
#define ORCA_PY_AUDIT_SCOPE(mode) \
|
||||
std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \
|
||||
if (const std::string& _orca_audit_key = this->audit_plugin_key(); \
|
||||
!_orca_audit_key.empty()) \
|
||||
_orca_audit_scope.emplace(_orca_audit_key, mode)
|
||||
_orca_audit_scope.emplace(_orca_audit_key, this->audit_capability_name(), mode)
|
||||
|
||||
#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \
|
||||
do { \
|
||||
@@ -63,6 +67,66 @@ public:
|
||||
get_name);
|
||||
}
|
||||
|
||||
// Config UI hooks. Available on every capability type, so they live here rather than in
|
||||
// PyPluginInterfaceTrampoline. Audited like any other C++ -> Python call; a Python
|
||||
// exception is logged with its traceback and rethrown, and the caller (PluginLoader at
|
||||
// load time, PluginsDialog when opening the Config tab) decides the fallback.
|
||||
bool has_config_ui() const override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[] {},
|
||||
PYBIND11_OVERRIDE,
|
||||
bool,
|
||||
Base,
|
||||
has_config_ui);
|
||||
}
|
||||
|
||||
std::string get_config_ui() const override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[] {},
|
||||
PYBIND11_OVERRIDE,
|
||||
std::string,
|
||||
Base,
|
||||
get_config_ui);
|
||||
}
|
||||
|
||||
// Hand-rolled rather than PYBIND11_OVERRIDE: the macro casts the Python result to the return
|
||||
// type, and nlohmann::json has no pybind caster (config crosses this boundary through the
|
||||
// explicit py_to_json/json_to_py helpers instead). Otherwise identical — same audit scope, and
|
||||
// a Python exception is logged with its traceback and rethrown for the caller to handle.
|
||||
//
|
||||
// The hook is optional, and "not implemented" must mean an EMPTY config, never a null or a
|
||||
// stray scalar landing in cap_config. Two ways to not implement it, both resolved here:
|
||||
// - no override at all -> the base's empty object
|
||||
// - an override that returns None, or any -> likewise. `def get_default_config(self): pass`
|
||||
// non-object (a list, a string, a number) is the easy mistake, and it must not be able to
|
||||
// write `"cap_config": null` to config.json.
|
||||
nlohmann::json get_default_config() const override
|
||||
{
|
||||
ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading);
|
||||
try {
|
||||
pybind11::gil_scoped_acquire gil;
|
||||
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
|
||||
if (!override)
|
||||
return Base::get_default_config();
|
||||
|
||||
nlohmann::json config = ::Slic3r::py_to_json(override());
|
||||
if (!config.is_object()) {
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Plugin capability '" << this->audit_capability_name() << "' of plugin '" << this->audit_plugin_key()
|
||||
<< "': get_default_config() returned " << config.type_name() << ", not an object; restoring an empty config";
|
||||
return Base::get_default_config();
|
||||
}
|
||||
return config;
|
||||
} catch (pybind11::error_already_set& err) {
|
||||
::Slic3r::log_python_exception_keep(err);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// All plugins may define their own on_load/unload functions.
|
||||
void on_load() override
|
||||
{
|
||||
|
||||
75
src/slic3r/plugin/PythonJsonUtils.hpp
Normal file
75
src/slic3r/plugin/PythonJsonUtils.hpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// JSON <-> Python conversion shared by the plugin bindings. The caller must hold the GIL.
|
||||
// Plugin config and orca.host.ui payloads both cross the boundary as plain JSON-compatible
|
||||
// values, so both go through these.
|
||||
|
||||
inline pybind11::object json_to_py(const nlohmann::json& j)
|
||||
{
|
||||
namespace py = pybind11;
|
||||
using json = nlohmann::json;
|
||||
|
||||
switch (j.type()) {
|
||||
case json::value_t::null: return py::none();
|
||||
case json::value_t::boolean: return py::bool_(j.get<bool>());
|
||||
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
|
||||
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
|
||||
case json::value_t::number_float: return py::float_(j.get<double>());
|
||||
case json::value_t::string: return py::str(j.get<std::string>());
|
||||
case json::value_t::array: {
|
||||
py::list lst;
|
||||
for (const auto& e : j)
|
||||
lst.append(json_to_py(e));
|
||||
return lst;
|
||||
}
|
||||
case json::value_t::object: {
|
||||
py::dict d;
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
d[py::str(it.key())] = json_to_py(it.value());
|
||||
return d;
|
||||
}
|
||||
default: return py::none();
|
||||
}
|
||||
}
|
||||
|
||||
inline nlohmann::json py_to_json(const pybind11::handle& o)
|
||||
{
|
||||
namespace py = pybind11;
|
||||
using json = nlohmann::json;
|
||||
|
||||
if (o.is_none())
|
||||
return json(nullptr);
|
||||
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
|
||||
return o.cast<bool>();
|
||||
if (py::isinstance<py::int_>(o))
|
||||
return o.cast<std::int64_t>();
|
||||
if (py::isinstance<py::float_>(o))
|
||||
return o.cast<double>();
|
||||
if (py::isinstance<py::str>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::bytes>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::dict>(o)) {
|
||||
json obj = json::object();
|
||||
for (auto item : py::reinterpret_borrow<py::dict>(o))
|
||||
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
|
||||
return obj;
|
||||
}
|
||||
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
|
||||
json arr = json::array();
|
||||
for (auto e : o)
|
||||
arr.push_back(py_to_json(e));
|
||||
return arr;
|
||||
}
|
||||
return py::str(o).cast<std::string>(); // fallback: str()
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -1,8 +1,11 @@
|
||||
#include "PythonPluginBridge.hpp"
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <exception>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
@@ -10,6 +13,8 @@
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonJsonUtils.hpp"
|
||||
#include "PluginConfig.hpp"
|
||||
#include "host/PluginHost.hpp"
|
||||
#include "PyPluginPackage.hpp"
|
||||
#include "PyPluginTrampoline.hpp"
|
||||
@@ -318,12 +323,63 @@ void bind_python_api(pybind11::module_& m)
|
||||
.def_static("skipped", &ExecutionResult::skipped, py::arg("message") = std::string())
|
||||
.def_static("failure", &ExecutionResult::failure, py::arg("status"), py::arg("message"), py::arg("data") = std::string());
|
||||
|
||||
// Config lives at the capability level, not as a global orca.* function: the host reads the
|
||||
// owning (plugin_key, capability) straight off the instance the call arrived on, so a
|
||||
// capability can only ever address its own config and never has to name itself.
|
||||
// Registered on the base, so every capability type (script/gcode/printer-agent) inherits it.
|
||||
py::class_<PluginCapabilityInterface, PyPluginInterfaceTrampoline, std::shared_ptr<PluginCapabilityInterface>>(m, "PythonPluginBase")
|
||||
.def(py::init<>())
|
||||
.def("get_name", &PluginCapabilityInterface::get_name)
|
||||
.def("get_type", &PluginCapabilityInterface::get_type)
|
||||
.def("on_load", &PluginCapabilityInterface::on_load)
|
||||
.def("on_unload", &PluginCapabilityInterface::on_unload);
|
||||
.def("on_unload", &PluginCapabilityInterface::on_unload)
|
||||
.def("has_config_ui", &PluginCapabilityInterface::has_config_ui,
|
||||
"Override to return True to replace the host's default JSON editor with your own HTML\n"
|
||||
"UI, returned by get_config_ui(). Every capability is configurable and appears in the\n"
|
||||
"Plugins dialog's Config tab regardless; this only chooses how its config is edited.")
|
||||
.def("get_config_ui", &PluginCapabilityInterface::get_config_ui,
|
||||
"Override to return the custom configuration UI as an HTML string. Only called when\n"
|
||||
"has_config_ui() is True; an empty result falls back to the default JSON editor.\n"
|
||||
"Inside the page, use window.orca.getConfig()/saveConfig() to reach this same config.")
|
||||
.def(
|
||||
"get_default_config",
|
||||
[](const PluginCapabilityInterface& self) {
|
||||
nlohmann::json config = self.get_default_config();
|
||||
return config.dump();
|
||||
},
|
||||
"Override to return the config that the Config tab's \"Restore defaults\" action writes\n"
|
||||
"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 config.dump();
|
||||
},
|
||||
"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); },
|
||||
"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 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 string (e.g. json.dumps(cfg)).\n"
|
||||
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
|
||||
"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.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
namespace Slic3r {
|
||||
@@ -98,8 +99,33 @@ class PluginCapabilityInterface
|
||||
public:
|
||||
virtual ~PluginCapabilityInterface() = default;
|
||||
|
||||
virtual std::string get_name() const = 0; // required — overridden in Python
|
||||
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override
|
||||
// Required APIs
|
||||
virtual std::string get_name() const = 0;
|
||||
|
||||
// Optional APIs
|
||||
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; }
|
||||
|
||||
// Every capability is configurable: it always appears in the Plugins dialog's Config
|
||||
// sidebar and always has the host's default JSON editor over its stored config. The only
|
||||
// question a capability answers is whether it supplies its own UI to edit that config
|
||||
// *instead of* the JSON editor.
|
||||
//
|
||||
// True when the capability ships a custom configuration UI. get_config_ui() is called
|
||||
// only when this returns true.
|
||||
virtual bool has_config_ui() const { return false; }
|
||||
// An HTML snippet for the custom configuration UI. An empty or throwing result is
|
||||
// treated as "no custom UI" and falls back to the default JSON editor.
|
||||
virtual std::string get_config_ui() const { return ""; }
|
||||
|
||||
// The config the Config tab's "Restore defaults" action writes back. Optional.
|
||||
//
|
||||
// Not overridden -> an empty object, which is the right answer for a capability that keeps
|
||||
// its stored config sparse and applies its own defaults on read: clearing the overrides
|
||||
// *is* restoring the defaults, and it keeps a later release free to change them.
|
||||
// Override it to write an explicit starting config instead (e.g. to seed a form UI with
|
||||
// every field present). The host neither invents nor validates this value; it only stores
|
||||
// whatever comes back, so a throwing override leaves the stored config untouched.
|
||||
virtual nlohmann::json get_default_config() const { return nlohmann::json::object(); }
|
||||
|
||||
virtual void on_load() {}
|
||||
virtual void on_unload() {}
|
||||
@@ -110,8 +136,16 @@ public:
|
||||
void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); }
|
||||
const std::string& audit_plugin_key() const { return m_audit_plugin_key; }
|
||||
|
||||
// The cached get_name() captured at load, paired with the audit plugin key to identify
|
||||
// which capability a trampoline call belongs to. Cached rather than read live: get_name()
|
||||
// is itself a trampoline call, so calling it from inside a trampoline would recurse.
|
||||
// Empty until PluginLoader materializes the capability.
|
||||
void set_audit_capability_name(std::string name) { m_audit_capability_name = std::move(name); }
|
||||
const std::string& audit_capability_name() const { return m_audit_capability_name; }
|
||||
|
||||
private:
|
||||
std::string m_audit_plugin_key;
|
||||
std::string m_audit_capability_name;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "slic3r/plugin/PluginAuditManager.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
|
||||
#include "slic3r/plugin/PythonJsonUtils.hpp" // json_to_py / py_to_json
|
||||
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/MainFrame.hpp>
|
||||
@@ -34,63 +35,6 @@ using json = nlohmann::json;
|
||||
namespace Slic3r {
|
||||
namespace {
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JSON <-> Python conversion (caller must hold the GIL).
|
||||
// --------------------------------------------------------------------------
|
||||
py::object json_to_py(const json& j)
|
||||
{
|
||||
switch (j.type()) {
|
||||
case json::value_t::null: return py::none();
|
||||
case json::value_t::boolean: return py::bool_(j.get<bool>());
|
||||
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
|
||||
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
|
||||
case json::value_t::number_float: return py::float_(j.get<double>());
|
||||
case json::value_t::string: return py::str(j.get<std::string>());
|
||||
case json::value_t::array: {
|
||||
py::list lst;
|
||||
for (const auto& e : j)
|
||||
lst.append(json_to_py(e));
|
||||
return lst;
|
||||
}
|
||||
case json::value_t::object: {
|
||||
py::dict d;
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
d[py::str(it.key())] = json_to_py(it.value());
|
||||
return d;
|
||||
}
|
||||
default: return py::none();
|
||||
}
|
||||
}
|
||||
|
||||
json py_to_json(const py::handle& o)
|
||||
{
|
||||
if (o.is_none())
|
||||
return json(nullptr);
|
||||
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
|
||||
return o.cast<bool>();
|
||||
if (py::isinstance<py::int_>(o))
|
||||
return o.cast<std::int64_t>();
|
||||
if (py::isinstance<py::float_>(o))
|
||||
return o.cast<double>();
|
||||
if (py::isinstance<py::str>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::bytes>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::dict>(o)) {
|
||||
json obj = json::object();
|
||||
for (auto item : py::reinterpret_borrow<py::dict>(o))
|
||||
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
|
||||
return obj;
|
||||
}
|
||||
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
|
||||
json arr = json::array();
|
||||
for (auto e : o)
|
||||
arr.push_back(py_to_json(e));
|
||||
return arr;
|
||||
}
|
||||
return py::str(o).cast<std::string>(); // fallback: str()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GIL-safe holder for a Python callable. A std::function that captured a bare
|
||||
// py::object could be destroyed on the main thread without the GIL (a dialog
|
||||
|
||||
@@ -2,7 +2,10 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests_main.cpp
|
||||
test_plugin_host_api.cpp
|
||||
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
|
||||
|
||||
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
// restore the previous value afterwards, so code under test writes into a
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
boost::filesystem::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
ScopedDataDir(const ScopedDataDir&) = delete;
|
||||
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A print preset carrying a "plugins" manifest and the one plugin-backed print option
|
||||
// (slicing_pipeline_plugin, a coStrings vector).
|
||||
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
|
||||
{
|
||||
Preset preset(Preset::TYPE_PRINT, "test-print");
|
||||
const std::unique_ptr<DynamicPrintConfig> defaults(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
|
||||
preset.config = *defaults;
|
||||
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
|
||||
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
|
||||
return preset;
|
||||
}
|
||||
|
||||
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const PluginCapabilityRef& ref : refs)
|
||||
names.push_back(ref.capability_name);
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
|
||||
{
|
||||
// CapB is declared in the manifest but no option references it, so it is not in use.
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
|
||||
std::vector<std::string>{"CapA", "CapC"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({}, {"CapA"});
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
|
||||
{
|
||||
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
|
||||
preset.type = Preset::TYPE_SLA_PRINT;
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
|
||||
{
|
||||
// parse_capability_ref rejects entries that are not "name;uuid;capability".
|
||||
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
368
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
368
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
@@ -0,0 +1,368 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace Slic3r;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
void ensure_python_initialized()
|
||||
{
|
||||
// Same rationale as test_plugin_host_api.cpp: `orca` is an embedded module compiled into this
|
||||
// binary, so a bare interpreter is enough and does not need the bundled Python home.
|
||||
if (!Py_IsInitialized()) {
|
||||
static py::scoped_interpreter interpreter;
|
||||
(void) interpreter;
|
||||
}
|
||||
}
|
||||
|
||||
py::module_ import_orca_module()
|
||||
{
|
||||
ensure_python_initialized();
|
||||
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||
return py::module_::import("orca");
|
||||
}
|
||||
|
||||
// Builds a Python capability from `body` and materializes it the way PluginLoader does: the audit
|
||||
// identity is stamped on by the host, never supplied by the plugin, and it is what scopes every
|
||||
// config call to this one capability.
|
||||
py::object make_capability(const std::string& class_name,
|
||||
const std::string& body,
|
||||
const std::string& plugin_key,
|
||||
const std::string& capability_name)
|
||||
{
|
||||
// Import first: it is what brings the interpreter up, and constructing any py:: object
|
||||
// beforehand would touch a Python that does not exist yet.
|
||||
py::module_ orca = import_orca_module();
|
||||
|
||||
py::dict globals;
|
||||
globals["orca"] = orca;
|
||||
|
||||
py::exec("class " + class_name + "(orca.PythonPluginBase):\n" + body, globals);
|
||||
py::object instance = globals[class_name.c_str()]();
|
||||
|
||||
if (!plugin_key.empty()) {
|
||||
auto iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
iface->set_audit_plugin_key(plugin_key);
|
||||
iface->set_audit_capability_name(capability_name);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
|
||||
{
|
||||
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
}
|
||||
|
||||
// The config the Python API actually writes to: capability_save_config persists through the
|
||||
// PluginManager singleton, so that is where the assertions read from.
|
||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||
|
||||
// The Python config API speaks JSON text, not dicts: get_config()/get_default_config() hand back a
|
||||
// JSON string and save_config() takes one. These helpers keep the tests written in terms of values.
|
||||
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
|
||||
|
||||
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
{
|
||||
py::module_ orca = import_orca_module();
|
||||
REQUIRE(py::hasattr(orca, "PythonPluginBase"));
|
||||
|
||||
py::object base = orca.attr("PythonPluginBase");
|
||||
// Host-provided (the capability calls these). Every capability has a config, so these are
|
||||
// always available — there is no hook to opt in or out of being configurable.
|
||||
CHECK(py::hasattr(base, "get_config"));
|
||||
CHECK(py::hasattr(base, "save_config"));
|
||||
CHECK(py::hasattr(base, "get_config_version"));
|
||||
// Plugin-provided (the host calls these). All optional.
|
||||
CHECK(py::hasattr(base, "has_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_default_config"));
|
||||
|
||||
// Config is reached through the capability, never as a free orca.config.* function, so a
|
||||
// capability cannot name — and therefore cannot touch — a config that is not its own.
|
||||
CHECK_FALSE(py::hasattr(orca, "config"));
|
||||
}
|
||||
|
||||
TEST_CASE("get_config returns only cap_config and save_config persists it", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-roundtrip");
|
||||
host_config().load(); // reset the singleton's in-memory store against the empty temp dir
|
||||
|
||||
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads()
|
||||
// and index it unconditionally.
|
||||
py::object initial = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::str>(initial));
|
||||
CHECK(json::parse(initial.cast<std::string>()) == json::object());
|
||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||
|
||||
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 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"));
|
||||
CHECK_FALSE(reloaded.contains("cap_config"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_version"));
|
||||
}
|
||||
|
||||
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
// The binding parses the string it is handed. Unparseable text is refused, and refusing it must
|
||||
// leave the previously stored config alone rather than clobbering it with nothing.
|
||||
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
||||
host_config().load();
|
||||
|
||||
const std::string body = " def get_name(self): return 'cap'\n";
|
||||
// Same capability name under two different plugins, plus a second capability of plugin_a:
|
||||
// each addresses only the entry matching its own stamped identity.
|
||||
py::object a_cap1 = make_capability("IsoCapA1", body, "plugin_a", "cap_a");
|
||||
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
||||
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
||||
|
||||
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}}));
|
||||
|
||||
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(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]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-unowned");
|
||||
host_config().load();
|
||||
|
||||
// No audit identity: the instance was never loaded by the host, so it has no config to address.
|
||||
// Refused rather than served from, or written to, some arbitrary entry.
|
||||
py::object orphan = make_capability("OrphanCap", " def get_name(self): return 'cap'\n", "", "");
|
||||
|
||||
CHECK_THROWS(orphan.attr("get_config")());
|
||||
CHECK_THROWS(orphan.attr("get_config_version")());
|
||||
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
|
||||
}
|
||||
|
||||
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
||||
{
|
||||
py::object cap = make_capability("CustomUiCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): return '<p>hello</p>'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->has_config_ui());
|
||||
CHECK(iface->get_config_ui() == "<p>hello</p>");
|
||||
}
|
||||
|
||||
TEST_CASE("A capability that omits the config UI hooks gets the default editor", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-bare");
|
||||
host_config().load();
|
||||
|
||||
// Both hooks are optional and only choose the editor. A capability that overrides neither is
|
||||
// still configurable — it just gets the host's JSON editor — so it stays in the Config sidebar
|
||||
// and its config API keeps working. There is no way for a capability to opt out of having one.
|
||||
py::object bare = make_capability("BareCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
|
||||
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("get_default_config supplies the value Restore defaults writes back", "[PluginConfig][Python]")
|
||||
{
|
||||
SECTION("not overridden -> an empty config")
|
||||
{
|
||||
// Which is already "restore defaults" for a capability that keeps its stored config sparse
|
||||
// and applies its own defaults on read: clearing the overrides restores them.
|
||||
py::object bare = make_capability("NoDefaultsCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
|
||||
SECTION("overridden -> exactly what the plugin returns")
|
||||
{
|
||||
py::object cap = make_capability("DefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self):\n"
|
||||
" return {'speed': 5, 'nested': {'on': True}, 'items': [1, 2]}\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
// Round-trips through py_to_json untouched: the host does not reshape or validate it.
|
||||
CHECK(iface->get_default_config() == json{{"speed", 5}, {"nested", {{"on", true}}}, {"items", {1, 2}}});
|
||||
}
|
||||
|
||||
SECTION("overridden but returns None -> an empty config, never a null")
|
||||
{
|
||||
// `def get_default_config(self): pass` is the easy mistake. It must not be able to store
|
||||
// "cap_config": null — an unimplemented hook means an empty config, however it is spelled.
|
||||
py::object cap = make_capability("NoneDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): pass\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
const json restored = iface->get_default_config();
|
||||
CHECK(restored == json::object());
|
||||
CHECK_FALSE(restored.is_null());
|
||||
}
|
||||
|
||||
SECTION("overridden but returns a non-object -> an empty config")
|
||||
{
|
||||
py::object cap = make_capability("ScalarDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): return [1, 2, 3]\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||
host_config().load();
|
||||
|
||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
||||
" def get_default_config(self): return {'speed': 1}\n";
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
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);
|
||||
REQUIRE(host_config().store_capability_config("plugin_a", "cap_a", iface->get_default_config()));
|
||||
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 1}});
|
||||
// The same capability name under another plugin keeps its edited value.
|
||||
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"speed", 99}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("RaisingDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
||||
|
||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||
{
|
||||
py::object cap = make_capability("RaisingCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// The trampoline logs the traceback and rethrows; callers (PluginLoader when caching the flag,
|
||||
// PluginsDialog when opening the Config tab) catch it and fall back to the default JSON editor
|
||||
// instead of crashing.
|
||||
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
|
||||
|
||||
// Catching it leaves the interpreter usable — the host is still able to talk to the capability.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
}
|
||||
|
||||
TEST_CASE("A config UI hook returning the wrong type does not crash the host", "[PluginConfig][Python]")
|
||||
{
|
||||
// has_config_ui() is plugin-authored, so it can return anything. Whatever pybind makes of a
|
||||
// non-bool, the host must survive the call: it either converts or throws, never crashes.
|
||||
py::object cap = make_capability("BadTypeCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return 'not a bool'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// Deliberately not REQUIRE_THROWS: pybind may coerce the value or reject it, and both are
|
||||
// acceptable. What must hold is that the call is survivable — a throw is what PluginLoader's
|
||||
// guard turns into "no custom UI".
|
||||
try {
|
||||
(void) iface->has_config_ui();
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
|
||||
// The capability is still usable afterwards: the bad hook cost it nothing but its own answer.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
}
|
||||
216
tests/slic3rutils/test_plugin_config.cpp
Normal file
216
tests/slic3rutils/test_plugin_config.cpp
Normal file
@@ -0,0 +1,216 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
json read_config_file()
|
||||
{
|
||||
boost::nowide::ifstream ifs(PluginConfig::plugin_config_file().c_str());
|
||||
json root;
|
||||
ifs >> root;
|
||||
return root;
|
||||
}
|
||||
|
||||
void write_config_file(const std::string& contents)
|
||||
{
|
||||
const fs::path path(PluginConfig::plugin_config_file());
|
||||
fs::create_directories(path.parent_path());
|
||||
boost::nowide::ofstream ofs(path.string().c_str(), std::ios::out | std::ios::trunc);
|
||||
ofs << contents;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("PluginConfig creates, reads back and persists a capability config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-roundtrip");
|
||||
|
||||
PluginConfig config;
|
||||
|
||||
// A capability nobody has configured yet reads as an empty record rather than throwing.
|
||||
CHECK_FALSE(config.has_config("plugin_a", "cap_a"));
|
||||
CHECK(config.get_config("plugin_a", "cap_a").empty());
|
||||
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"speed", 5}}));
|
||||
|
||||
const BaseConfig stored = config.get_config("plugin_a", "cap_a");
|
||||
REQUIRE_FALSE(stored.empty());
|
||||
CHECK(stored.plugin_key == "plugin_a");
|
||||
CHECK(stored.capability_name == "cap_a");
|
||||
CHECK(stored.config == json{{"speed", 5}});
|
||||
CHECK(config.has_config("plugin_a", "cap_a"));
|
||||
|
||||
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig updates only the target capability's cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-isolation");
|
||||
|
||||
PluginConfig config;
|
||||
// Two capabilities in one plugin, plus a same-named capability in a different plugin: the
|
||||
// identity is the (plugin_key, capability) pair, so all three are separate records.
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"value", 1}}));
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_b", json{{"value", 2}}));
|
||||
REQUIRE(config.store_capability_config("plugin_b", "cap_a", json{{"value", 3}}));
|
||||
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"value", 99}}));
|
||||
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(config.get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(config.get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
|
||||
// The same holds on disk, not just in memory.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(reloaded.get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-schema");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"speed", 5}}));
|
||||
|
||||
// Locks the field names: an existing config.json must keep loading after any future change.
|
||||
const json root = read_config_file();
|
||||
REQUIRE(root.contains("config"));
|
||||
REQUIRE(root.at("config").is_array());
|
||||
REQUIRE(root.at("config").size() == 1);
|
||||
|
||||
const json& entry = root.at("config").front();
|
||||
CHECK(entry.at("plugin_key") == "plugin_a");
|
||||
CHECK(entry.at("capability") == "cap_a");
|
||||
CHECK(entry.at("cap_config") == json{{"speed", 5}});
|
||||
CHECK(entry.contains("plugin_version"));
|
||||
// Only cap_config is user data; the rest of the record is host-managed.
|
||||
CHECK(entry.size() == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig keeps a capability's config after its plugin goes away", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-retention");
|
||||
|
||||
{
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"token", "keep me"}}));
|
||||
}
|
||||
|
||||
// Nothing here installs, uninstalls or unsubscribes a plugin: config.json is deliberately not
|
||||
// keyed to installed plugins, so a record outlives the plugin and is still there when the user
|
||||
// reinstalls or resubscribes. This asserts no cleanup path silently drops it.
|
||||
PluginConfig after_removal;
|
||||
after_removal.load();
|
||||
CHECK(after_removal.get_config("plugin_a", "cap_a").config == json{{"token", "keep me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig treats a missing config file as an empty store", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-missing");
|
||||
|
||||
REQUIRE_FALSE(fs::exists(PluginConfig::plugin_config_file()));
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config("plugin_a", "cap_a"));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig survives a malformed config file", "[PluginConfig]")
|
||||
{
|
||||
SECTION("not JSON at all")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-garbage");
|
||||
write_config_file("this is not json {{{");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load()); // a bad config must not block startup
|
||||
CHECK_FALSE(config.has_config("plugin_a", "cap_a"));
|
||||
}
|
||||
|
||||
SECTION("valid JSON without the entries array")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-noarray");
|
||||
write_config_file(R"({"config": {"not": "an array"}})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config("plugin_a", "cap_a"));
|
||||
}
|
||||
|
||||
SECTION("entries without an identity are skipped, the rest still load")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-partial");
|
||||
write_config_file(R"({"config": [
|
||||
{"cap_config": {"orphan": true}},
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json{{"kept", true}});
|
||||
CHECK(config.get_config("plugin_a", "cap_a").plugin_version == "1.0.0");
|
||||
}
|
||||
|
||||
SECTION("an entry with no cap_config reads as an empty object")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-nocap");
|
||||
write_config_file(R"({"config": [
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0"}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
REQUIRE(config.has_config("plugin_a", "cap_a"));
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json::object());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-identity");
|
||||
|
||||
PluginConfig config;
|
||||
config.save_config(BaseConfig{"", "cap_a", "1.0.0", json::object()});
|
||||
config.save_config(BaseConfig{"plugin_a", "", "1.0.0", json::object()});
|
||||
|
||||
// Neither could ever be looked up again, so neither is kept.
|
||||
CHECK_FALSE(config.has_config("", "cap_a"));
|
||||
CHECK_FALSE(config.has_config("plugin_a", ""));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig preserves unknown keys inside cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-unknown");
|
||||
|
||||
// The host never interprets cap_config, so a nested/odd shape must round-trip untouched.
|
||||
const json nested = json{{"nested", {{"deep", json::array({1, 2, 3})}}}, {"flag", false}, {"name", "x"}};
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", nested));
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").config == nested);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <fstream>
|
||||
@@ -15,30 +17,6 @@ namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
// restore the previous value afterwards, so install_plugin() writes into a
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
};
|
||||
|
||||
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
|
||||
{
|
||||
fs::create_directories(dir);
|
||||
|
||||
Reference in New Issue
Block a user