mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 15:03:49 +00:00
feat: plugins config UI
This commit is contained in:
@@ -86,6 +86,8 @@
|
||||
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
|
||||
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
|
||||
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
|
||||
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
|
||||
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
|
||||
@@ -138,6 +140,31 @@
|
||||
<div id="detailDescription" class="detail-description">No description available</div>
|
||||
</section>
|
||||
|
||||
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="configTab" data-panel="config" hidden>
|
||||
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
|
||||
<div id="configLayout" class="config-layout" hidden>
|
||||
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||
aria-label="Configurable capabilities"></div>
|
||||
<div class="config-view">
|
||||
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||
<div id="configEditor" class="config-editor" hidden>
|
||||
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||
<div class="config-editor-footer">
|
||||
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
|
||||
runs in an opaque origin: it cannot touch this page, and the only host surface
|
||||
it gets is the window.orca getConfig/saveConfig bridge injected into srcdoc. -->
|
||||
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
|
||||
aria-labelledby="changelogTab" data-panel="changelog" hidden>
|
||||
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">
|
||||
|
||||
@@ -21,6 +21,14 @@ let selectedPluginId = "";
|
||||
let contextPluginId = "";
|
||||
let activeDetailTab = "plugin-info";
|
||||
let selectedInstallAction = "explore";
|
||||
// Config tab: the capability whose config is currently shown, scoped to selectedPluginId. Name and
|
||||
// type together address the capability on the native side. Cleared whenever the plugin changes.
|
||||
let selectedCapabilityName = "";
|
||||
let selectedCapabilityType = "";
|
||||
// The plugin the capability selection above belongs to. Capability names are only unique within a
|
||||
// plugin, so two plugins can each expose e.g. a "main" script capability; without remembering the
|
||||
// owner, selecting the second plugin would keep the selection and show the first plugin's config.
|
||||
let configPluginId = "";
|
||||
|
||||
let pluginList = null;
|
||||
let ctxMenu = null;
|
||||
@@ -60,6 +68,14 @@ function OnInit() {
|
||||
pluginList?.addEventListener("contextmenu", OnPluginContextMenu);
|
||||
ctxMenu?.addEventListener("click", OnContextMenuClick);
|
||||
|
||||
document.getElementById("configSidebar")?.addEventListener("click", OnConfigSidebarClick);
|
||||
document.getElementById("configSaveBtn")?.addEventListener("click", SaveCapabilityConfig);
|
||||
document.getElementById("configText")?.addEventListener("input", ValidateConfigText);
|
||||
// The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
|
||||
// postMessage. Match on the frame's own contentWindow rather than the origin (which is "null"
|
||||
// for a sandboxed frame) and ignore anything else on the channel.
|
||||
window.addEventListener("message", OnCustomConfigMessage);
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".ctx"))
|
||||
HideContextMenu();
|
||||
@@ -227,6 +243,10 @@ function HandleStudio(value) {
|
||||
ApplyPlugins(payload.data || []);
|
||||
} else if (payload.command === "status_message") {
|
||||
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
|
||||
} else if (payload.command === "capability_config") {
|
||||
ApplyCapabilityConfig(payload);
|
||||
} else if (payload.command === "capability_config_saved") {
|
||||
ApplyCapabilityConfigSaved(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +805,335 @@ function RenderDetails() {
|
||||
ApplyDetailUpdateBadge(detailUpdateBadge, plugin);
|
||||
if (detailUpdateBtn)
|
||||
ApplyDetailUpdateButton(detailUpdateBtn, plugin);
|
||||
|
||||
RenderConfig(plugin);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config tab
|
||||
//
|
||||
// The sidebar lists the selected plugin's configurable capabilities; the right side shows either
|
||||
// the host's JSON editor or, when the capability ships one, its own HTML UI in a sandboxed frame.
|
||||
// Both edit the same stored config: the page holds no config state of its own, it renders what the
|
||||
// native side sends and sends back what the user saves.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every capability is configurable — it always gets at least the default JSON editor over its
|
||||
// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows
|
||||
// shown for a plugin that is not activated: those carry no capability name, so there is nothing to
|
||||
// address on the native side and nothing to configure yet.
|
||||
function GetConfigurableCapabilities(plugin) {
|
||||
return GetCapabilities(plugin).filter((capability) => String(capability?.name || ""));
|
||||
}
|
||||
|
||||
function RenderConfig(plugin) {
|
||||
const empty = document.getElementById("configEmpty");
|
||||
const layout = document.getElementById("configLayout");
|
||||
const sidebar = document.getElementById("configSidebar");
|
||||
if (!empty || !layout || !sidebar)
|
||||
return;
|
||||
|
||||
const capabilities = plugin ? GetConfigurableCapabilities(plugin) : [];
|
||||
const pluginKey = String(plugin?.plugin_key || "");
|
||||
|
||||
// A different plugin than the one the current selection belongs to: drop the selection outright
|
||||
// rather than trusting the name to mean the same thing here.
|
||||
if (pluginKey !== configPluginId) {
|
||||
configPluginId = pluginKey;
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
ClearCapabilityConfigView();
|
||||
}
|
||||
|
||||
if (!plugin || capabilities.length === 0) {
|
||||
// Capabilities are only materialized once the plugin is activated, so an inactive plugin has
|
||||
// nothing to configure yet — say that, rather than claiming it has no capabilities.
|
||||
empty.textContent = !plugin
|
||||
? "Select a plugin to configure its capabilities"
|
||||
: (IsPluginLoading(plugin)
|
||||
? "Loading the plugin…"
|
||||
: (GetStatus(plugin) === "Activated"
|
||||
? "This plugin exposes no capabilities"
|
||||
: "Activate this plugin to configure its capabilities"));
|
||||
empty.hidden = false;
|
||||
layout.hidden = true;
|
||||
sidebar.replaceChildren();
|
||||
ClearCapabilityConfigView();
|
||||
selectedCapabilityName = "";
|
||||
selectedCapabilityType = "";
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = true;
|
||||
layout.hidden = false;
|
||||
|
||||
// Keep the selection across a refresh when the capability is still there; otherwise fall back to
|
||||
// the first one, which is also the initial selection for a newly selected plugin.
|
||||
const stillPresent = capabilities.some((capability) =>
|
||||
capability.name === selectedCapabilityName && String(capability.type_key || "") === selectedCapabilityType);
|
||||
if (!stillPresent) {
|
||||
selectedCapabilityName = String(capabilities[0].name || "");
|
||||
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
}
|
||||
|
||||
sidebar.replaceChildren();
|
||||
for (const capability of capabilities) {
|
||||
const name = String(capability.name || "");
|
||||
const typeKey = String(capability.type_key || "");
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "config-cap";
|
||||
item.dataset.capabilityName = name;
|
||||
item.dataset.capabilityType = typeKey;
|
||||
item.setAttribute("role", "option");
|
||||
|
||||
const isSelected = name === selectedCapabilityName && typeKey === selectedCapabilityType;
|
||||
item.classList.toggle("selected", isSelected);
|
||||
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "config-cap-name";
|
||||
label.textContent = name;
|
||||
item.appendChild(label);
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "config-cap-type";
|
||||
type.textContent = String(capability.type || "");
|
||||
item.appendChild(type);
|
||||
|
||||
sidebar.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function OnConfigSidebarClick(event) {
|
||||
const item = event.target.closest(".config-cap");
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const name = String(item.dataset.capabilityName || "");
|
||||
const typeKey = String(item.dataset.capabilityType || "");
|
||||
if (!name || (name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||
return;
|
||||
|
||||
selectedCapabilityName = name;
|
||||
selectedCapabilityType = typeKey;
|
||||
|
||||
// Drop the outgoing capability's view immediately: the native reply is asynchronous and its
|
||||
// content must never appear under the newly selected capability.
|
||||
ClearCapabilityConfigView();
|
||||
RequestCapabilityConfig();
|
||||
RenderConfig(pluginsById.get(selectedPluginId));
|
||||
}
|
||||
|
||||
function RequestCapabilityConfig() {
|
||||
if (!selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
SendMessage("get_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType
|
||||
});
|
||||
}
|
||||
|
||||
// Empties both editors, so nothing from the previously selected capability can linger while the
|
||||
// next one is still in flight.
|
||||
function ClearCapabilityConfigView() {
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (text)
|
||||
text.value = "";
|
||||
if (error) {
|
||||
error.hidden = true;
|
||||
error.textContent = "";
|
||||
}
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// True when a native reply still matches what the user has selected. A reply for a capability the
|
||||
// user has already navigated away from is dropped rather than rendered into the current view.
|
||||
function IsCurrentCapability(payload) {
|
||||
return String(payload?.plugin_key || "") === selectedPluginId &&
|
||||
String(payload?.capability_name || "") === selectedCapabilityName;
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfig(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const editor = document.getElementById("configEditor");
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
const error = document.getElementById("configError");
|
||||
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
|
||||
const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
|
||||
const html = String(payload?.custom_html || "");
|
||||
|
||||
if (html) {
|
||||
// A capability with its own UI: hand it the config through the bridge, never the raw file.
|
||||
if (custom) {
|
||||
custom.hidden = false;
|
||||
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Default editor. The native side already reported why a custom UI is unavailable (if it was
|
||||
// meant to have one) in payload.error, and we fall back to editing the same config here.
|
||||
if (custom) {
|
||||
custom.hidden = true;
|
||||
custom.removeAttribute("srcdoc");
|
||||
}
|
||||
if (editor)
|
||||
editor.hidden = false;
|
||||
if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
function SetConfigValidation(message) {
|
||||
const node = document.getElementById("configValidation");
|
||||
const save = document.getElementById("configSaveBtn");
|
||||
if (node) {
|
||||
node.textContent = message;
|
||||
node.classList.toggle("invalid", message !== "");
|
||||
}
|
||||
// Invalid JSON can never be saved: the button is the only way to persist, and it is disabled
|
||||
// while the text does not parse. The native side re-validates regardless.
|
||||
if (save)
|
||||
save.disabled = message !== "";
|
||||
}
|
||||
|
||||
function ValidateConfigText() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text)
|
||||
return false;
|
||||
|
||||
try {
|
||||
JSON.parse(text.value);
|
||||
SetConfigValidation("");
|
||||
return true;
|
||||
} catch (err) {
|
||||
SetConfigValidation(String(err?.message || "Invalid JSON"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function SaveCapabilityConfig() {
|
||||
const text = document.getElementById("configText");
|
||||
if (!text || !selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
if (!ValidateConfigText())
|
||||
return;
|
||||
|
||||
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: text.value
|
||||
});
|
||||
}
|
||||
|
||||
function ApplyCapabilityConfigSaved(payload) {
|
||||
if (!IsCurrentCapability(payload))
|
||||
return;
|
||||
|
||||
const error = document.getElementById("configError");
|
||||
const message = String(payload?.error || "");
|
||||
if (error) {
|
||||
error.textContent = message;
|
||||
error.hidden = message === "";
|
||||
}
|
||||
if (payload?.ok !== true)
|
||||
return;
|
||||
|
||||
// Reload from what was actually persisted, so both editors show the stored state rather than
|
||||
// whatever was typed.
|
||||
const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
|
||||
const custom = document.getElementById("configCustom");
|
||||
const text = document.getElementById("configText");
|
||||
|
||||
if (custom && !custom.hidden && custom.contentWindow)
|
||||
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
|
||||
else if (text)
|
||||
text.value = JSON.stringify(config, null, 2);
|
||||
|
||||
SetConfigValidation("");
|
||||
}
|
||||
|
||||
// The whole host surface a custom config UI gets: read the config it was opened with, save a new
|
||||
// one, and be told when a save lands. Everything else about the dialog stays out of reach — the
|
||||
// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host.
|
||||
function BuildCustomConfigDocument(html, config) {
|
||||
// The config is inlined into a <script>, so a stored string containing "</script>" would
|
||||
// otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal
|
||||
// valid JSON while making that impossible.
|
||||
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
|
||||
const bridge = `<script>
|
||||
(function () {
|
||||
var handlers = [];
|
||||
var current = ${seed};
|
||||
window.orca = {
|
||||
getConfig: function () { return current; },
|
||||
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
|
||||
onConfig: function (cb) {
|
||||
if (typeof cb !== "function") return;
|
||||
handlers.push(cb);
|
||||
try { cb(current); } catch (e) {}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!event.data || event.data.__orca !== "config") return;
|
||||
current = event.data.config || {};
|
||||
handlers.forEach(function (handler) {
|
||||
try { handler(current); } catch (e) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
<\/script>`;
|
||||
return bridge + html;
|
||||
}
|
||||
|
||||
function OnCustomConfigMessage(event) {
|
||||
const custom = document.getElementById("configCustom");
|
||||
// Only the frame we created, and only while it is actually showing.
|
||||
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
|
||||
return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.__orca !== "save")
|
||||
return;
|
||||
if (!selectedPluginId || !selectedCapabilityName)
|
||||
return;
|
||||
|
||||
// The custom UI persists through the same native command as the JSON editor, so there is one
|
||||
// stored config and one code path that writes it.
|
||||
SendMessage("save_capability_config", {
|
||||
plugin_key: selectedPluginId,
|
||||
capability_name: selectedCapabilityName,
|
||||
capability_type: selectedCapabilityType,
|
||||
config: data.config === undefined ? {} : data.config
|
||||
});
|
||||
}
|
||||
|
||||
function RenderThumbnail(plugin) {
|
||||
|
||||
@@ -1084,6 +1084,158 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Config tab: fixed-width capability sidebar + the capability's configuration view. */
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.config-layout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.config-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-cap:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.config-cap.selected {
|
||||
background: var(--row-selected);
|
||||
border-color: var(--row-selected-outline);
|
||||
}
|
||||
|
||||
.config-cap-name {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-cap-type {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.config-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.config-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
/* common.css applies `user-select: none` to *, which in WebKit also stops a textarea taking a
|
||||
caret — the editor would be focusable but impossible to type into. Editable surfaces have to
|
||||
opt back in (same as include/xterm/xterm.css does for the terminal). */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.config-editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-validation {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-validation.invalid {
|
||||
color: var(--plugin-status-danger);
|
||||
}
|
||||
|
||||
.config-custom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.config-custom[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -606,6 +606,7 @@ set(SLIC3R_GUI_SOURCES
|
||||
plugin/PluginFsUtils.hpp
|
||||
plugin/PluginConfig.cpp
|
||||
plugin/PluginConfig.hpp
|
||||
plugin/PythonJsonUtils.hpp
|
||||
plugin/PluginCatalog.cpp
|
||||
plugin/PluginCatalog.hpp
|
||||
plugin/PluginLoader.cpp
|
||||
|
||||
@@ -68,6 +68,11 @@ struct PluginCapabilityView
|
||||
bool enabled = false;
|
||||
bool can_toggle = false;
|
||||
bool can_run = false;
|
||||
// Whether the capability supplies its own config UI, cached on the loaded capability at load
|
||||
// time. False for the descriptor-only rows shown for a plugin that is not loaded: its
|
||||
// capabilities have not been materialized yet, so there is nothing to configure until it is
|
||||
// activated. Every real capability is configurable; this only picks the editor.
|
||||
bool has_config_ui = false;
|
||||
};
|
||||
|
||||
struct PluginChangelogView
|
||||
@@ -206,12 +211,13 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
|
||||
nlohmann::json caps = nlohmann::json::array();
|
||||
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
|
||||
nlohmann::json c;
|
||||
c["name"] = capability.name;
|
||||
c["type"] = capability.type_label;
|
||||
c["type_key"] = capability.type_key;
|
||||
c["enabled"] = capability.enabled;
|
||||
c["can_toggle"] = capability.can_toggle;
|
||||
c["can_run"] = capability.can_run;
|
||||
c["name"] = capability.name;
|
||||
c["type"] = capability.type_label;
|
||||
c["type_key"] = capability.type_key;
|
||||
c["enabled"] = capability.enabled;
|
||||
c["can_toggle"] = capability.can_toggle;
|
||||
c["can_run"] = capability.can_run;
|
||||
c["has_config_ui"] = capability.has_config_ui;
|
||||
caps.push_back(std::move(c));
|
||||
}
|
||||
payload_item["capabilities"] = std::move(caps);
|
||||
@@ -355,7 +361,8 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
|
||||
for (const auto& cap : loader.get_loaded_plugin_capabilities(descriptor.plugin_key)) {
|
||||
if (cap) {
|
||||
item.capabilities.push_back({cap->name, plugin_capability_type_display_name(cap->type),
|
||||
plugin_capability_type_to_string(cap->type), cap->enabled, true, false});
|
||||
plugin_capability_type_to_string(cap->type), cap->enabled, true, false,
|
||||
cap->has_config_ui});
|
||||
if (cap->type == PluginCapabilityType::Script)
|
||||
item.has_script_capability = true;
|
||||
}
|
||||
@@ -518,6 +525,17 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
|
||||
open_plugin_hub();
|
||||
} else if (command == "set_plugin_sort") {
|
||||
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
|
||||
} else if (command == "get_capability_config") {
|
||||
send_capability_config(payload.value("plugin_key", ""),
|
||||
plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""));
|
||||
} else if (command == "save_capability_config") {
|
||||
// `config` is a JSON string from the default editor's textarea, or an already-structured
|
||||
// value from a capability's custom UI. Both land here; save_capability_config sorts it out.
|
||||
save_capability_config(payload.value("plugin_key", ""),
|
||||
plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||
payload.value("capability_name", ""),
|
||||
payload.contains("config") ? payload.at("config") : nlohmann::json::object());
|
||||
} else if (command == "set_plugin_install_action") {
|
||||
const std::string action = payload.value("action", "");
|
||||
if (action == "explore" || action == "install-local")
|
||||
@@ -896,6 +914,127 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
|
||||
return from_u8(plugin_key);
|
||||
}
|
||||
|
||||
// Replies to the web page with one capability's stored config, plus the custom HTML UI when the
|
||||
// capability provides one. Config is sent as a JSON value, not text: the default editor
|
||||
// pretty-prints it into its textarea, and a custom UI receives it as-is through window.orca.
|
||||
void PluginsDialog::send_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config";
|
||||
response["plugin_key"] = plugin_key;
|
||||
response["capability_name"] = capability_name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(type);
|
||||
response["config"] = nlohmann::json::object();
|
||||
response["custom_html"] = "";
|
||||
response["error"] = "";
|
||||
|
||||
// Scoped to (plugin_key, type, capability_name), so a stale request from a page that has not
|
||||
// caught up with a refresh cannot read a different plugin's config — it just misses.
|
||||
auto cap = get_capability(plugin_key, type, capability_name);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
|
||||
<< plugin_key << " capability_name=" << capability_name;
|
||||
response["error"] = into_u8(_L("This capability is no longer available."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
|
||||
|
||||
if (cap->has_config_ui) {
|
||||
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
|
||||
// custom UI: we report the failure and let the page fall back to the default JSON editor,
|
||||
// which edits the very same stored config.
|
||||
std::string html;
|
||||
std::string error;
|
||||
{
|
||||
wxBusyCursor busy;
|
||||
try {
|
||||
PythonGILState gil;
|
||||
html = cap->instance->get_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
error = ex.what();
|
||||
} catch (...) {
|
||||
error = "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name << " error=" << error;
|
||||
response["error"] = into_u8(format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||
from_u8(error)));
|
||||
} else if (html.empty()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name;
|
||||
response["error"] = into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
|
||||
} else {
|
||||
response["custom_html"] = html;
|
||||
}
|
||||
}
|
||||
|
||||
call_web_handler(response);
|
||||
}
|
||||
|
||||
// Persists one capability's config. `config` arrives either as text straight from the default
|
||||
// editor (validated here, so invalid JSON can never reach the file) or as a structured value from
|
||||
// a custom UI. Only cap_config is written; identity and version metadata stay host-managed.
|
||||
void PluginsDialog::save_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config)
|
||||
{
|
||||
nlohmann::json response;
|
||||
response["command"] = "capability_config_saved";
|
||||
response["plugin_key"] = plugin_key;
|
||||
response["capability_name"] = capability_name;
|
||||
response["capability_type"] = plugin_capability_type_to_string(type);
|
||||
response["ok"] = false;
|
||||
response["error"] = "";
|
||||
|
||||
auto cap = get_capability(plugin_key, type, capability_name);
|
||||
if (!cap) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
|
||||
<< plugin_key << " capability_name=" << capability_name;
|
||||
response["error"] = into_u8(_L("This capability is no longer available. Your changes were not saved."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
nlohmann::json parsed = config;
|
||||
if (config.is_string()) {
|
||||
// The page validates as the user types, but it is not the authority: re-parse here so a
|
||||
// malformed document is rejected before it can reach config.json.
|
||||
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||
if (parsed.is_discarded()) {
|
||||
response["error"] = into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, parsed)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name;
|
||||
response["error"] = into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
|
||||
call_web_handler(response);
|
||||
return;
|
||||
}
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << plugin_key
|
||||
<< " capability_name=" << capability_name;
|
||||
|
||||
// Echo the persisted value back so the editor reloads from what is actually stored rather
|
||||
// than from what the user typed.
|
||||
response["ok"] = true;
|
||||
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
|
||||
call_web_handler(response);
|
||||
|
||||
show_status(_L("Configuration saved."), "success");
|
||||
}
|
||||
|
||||
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
||||
{
|
||||
if (plugin_key.empty() || capability_name.empty()) {
|
||||
|
||||
@@ -67,6 +67,14 @@ private:
|
||||
bool install_plugin_package(const std::string& package_path);
|
||||
bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name);
|
||||
void run_script_plugin(const std::string& plugin_key, const std::string& capability_name);
|
||||
// Config tab. Both are scoped to (plugin_key, type, capability_name): a request naming a
|
||||
// capability that is gone or not configurable is refused rather than served from, or written
|
||||
// to, some other entry.
|
||||
void send_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name);
|
||||
void save_capability_config(const std::string& plugin_key,
|
||||
PluginCapabilityType type,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config);
|
||||
// Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"),
|
||||
// used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive.
|
||||
void show_status(const wxString& message, const char* level);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#include "PluginConfig.hpp"
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <boost/format.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
@@ -65,17 +63,15 @@ std::string running_plugin_version(const std::string& plugin_key)
|
||||
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
|
||||
}
|
||||
|
||||
// Identifies the capability whose Python method is currently on the stack. Both halves are
|
||||
// published by ScopedPluginAuditContext, which every C++ -> Python trampoline call opens.
|
||||
// A call arriving without them did not come through a capability, so it has no config to
|
||||
// address and we refuse it rather than reading or clobbering some other capability's entry.
|
||||
std::pair<std::string, std::string> calling_capability(const char* api_name)
|
||||
// The identity a capability is allowed to address: its own. PluginLoader stamps both halves
|
||||
// onto the instance when it materializes the capability, so the caller never supplies them
|
||||
// and cannot name another capability's entry. Empty means the instance was never materialized
|
||||
// (so it has no config to address) and we refuse rather than read or clobber a wrong entry.
|
||||
std::pair<std::string, std::string> capability_identity(const PluginCapabilityInterface& capability, const char* api_name)
|
||||
{
|
||||
const PluginAuditManager& audit = PluginAuditManager::instance();
|
||||
|
||||
std::pair<std::string, std::string> id{audit.current_plugin(), audit.current_capability()};
|
||||
std::pair<std::string, std::string> id{capability.audit_plugin_key(), capability.audit_capability_name()};
|
||||
if (id.first.empty() || id.second.empty())
|
||||
throw std::runtime_error(std::string(api_name) + "() must be called from a plugin capability method");
|
||||
throw std::runtime_error(std::string(api_name) + "() is only available on a capability loaded by the plugin host");
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -119,7 +115,7 @@ void PluginConfig::load()
|
||||
}
|
||||
}
|
||||
|
||||
void PluginConfig::save()
|
||||
bool PluginConfig::save()
|
||||
{
|
||||
const std::string path = plugin_config_file();
|
||||
|
||||
@@ -134,7 +130,7 @@ void PluginConfig::save()
|
||||
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
|
||||
if (ec) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot create the plugin directory: " << ec.message();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write to a PID-suffixed file and rename it into place, so a crash mid-write cannot
|
||||
@@ -147,15 +143,16 @@ void PluginConfig::save()
|
||||
file.close();
|
||||
if (file.fail()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path_pid << "; keeping the existing config";
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (const std::error_code rename_ec = rename_file(path_pid, path)) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to move " << path_pid << " onto " << path << ": " << rename_ec.message();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
m_dirty = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void PluginConfig::save_config(const std::string& plugin_key,
|
||||
@@ -164,20 +161,12 @@ void PluginConfig::save_config(const std::string& plugin_key,
|
||||
const nlohmann::json& config)
|
||||
{ save_config({plugin_key, capability_name, version, config}); }
|
||||
|
||||
bool PluginConfig::save_config_text(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
const std::string& version,
|
||||
const std::string& config)
|
||||
bool PluginConfig::store_capability_config(const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
const nlohmann::json& config)
|
||||
{
|
||||
nlohmann::json parsed = nlohmann::json::parse(config, nullptr, /* allow_exceptions */ false);
|
||||
if (parsed.is_discarded()) {
|
||||
BOOST_LOG_TRIVIAL(error) << "PluginConfig: capability '" << capability_name << "' of plugin '" << plugin_key
|
||||
<< "' supplied malformed JSON; config not saved";
|
||||
return false;
|
||||
}
|
||||
|
||||
save_config({plugin_key, capability_name, version, std::move(parsed)});
|
||||
return true;
|
||||
save_config({plugin_key, capability_name, running_plugin_version(plugin_key), config});
|
||||
return save();
|
||||
}
|
||||
|
||||
void PluginConfig::save_config(const BaseConfig& config)
|
||||
@@ -211,45 +200,27 @@ bool PluginConfig::dirty() const
|
||||
return m_dirty;
|
||||
}
|
||||
|
||||
void PluginConfig::RegisterBindings(pybind11::module_& m)
|
||||
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability)
|
||||
{
|
||||
namespace py = pybind11;
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config");
|
||||
|
||||
auto config_submodule = m.def_submodule("config", "Per-capability configuration storage");
|
||||
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability_name);
|
||||
// Never saved: hand back an empty object so a plugin can index the result unconditionally.
|
||||
return config.empty() ? nlohmann::json::object() : config.config;
|
||||
}
|
||||
|
||||
// Config crosses this boundary as a JSON string, not a dict: the host does not care about
|
||||
// the shape of cap_config, so it never builds Python objects out of it. Plugins hand the
|
||||
// string to json.loads/json.dumps themselves.
|
||||
config_submodule.def(
|
||||
"get_config",
|
||||
[]() -> py::object {
|
||||
const auto [plugin_key, capability] = calling_capability("get_config");
|
||||
std::string capability_get_config_version(const PluginCapabilityInterface& capability)
|
||||
{
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "get_config_version");
|
||||
|
||||
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability);
|
||||
if (config.empty())
|
||||
return py::none();
|
||||
return PluginManager::instance().get_config().get_config(plugin_key, capability_name).plugin_version;
|
||||
}
|
||||
|
||||
return py::str(config_to_entry(config).dump());
|
||||
},
|
||||
R"pbdoc(Return this capability's stored config as a JSON string, or None if it has never been saved.
|
||||
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config)
|
||||
{
|
||||
const auto [plugin_key, capability_name] = capability_identity(capability, "save_config");
|
||||
|
||||
The object mirrors the on-disk entry: "plugin_key", "capability", "plugin_version" and
|
||||
"cap_config". "plugin_version" is the version that last wrote the entry, so a plugin can
|
||||
compare it against its own and migrate "cap_config" before use.)pbdoc");
|
||||
|
||||
config_submodule.def(
|
||||
"save_config",
|
||||
[](const std::string& cap_config) {
|
||||
const auto [plugin_key, capability] = calling_capability("save_config");
|
||||
|
||||
return PluginManager::instance().get_config().save_config_text(plugin_key, capability, running_plugin_version(plugin_key),
|
||||
cap_config);
|
||||
},
|
||||
py::arg("cap_config"),
|
||||
R"pbdoc(Store this capability's config, given as a JSON string.
|
||||
|
||||
The plugin key, capability name and plugin version are supplied by the host. Returns False
|
||||
without storing anything if `cap_config` is not valid JSON.)pbdoc");
|
||||
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
|
||||
#define PLUGIN_CONFIG_DIR "config.json"
|
||||
|
||||
namespace pybind11 {
|
||||
class module_;
|
||||
}
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
class PluginCapabilityInterface;
|
||||
|
||||
/*
|
||||
Example config.json shape
|
||||
{
|
||||
@@ -86,16 +84,17 @@ public:
|
||||
void load();
|
||||
|
||||
// Rewrites config.json atomically. Clears the dirty flag only once the file is in place.
|
||||
void save();
|
||||
// False means the config on disk is unchanged.
|
||||
bool save();
|
||||
|
||||
void save_config(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const nlohmann::json& config);
|
||||
void save_config(const BaseConfig& config);
|
||||
|
||||
// Parses `config` as a JSON document, storing nothing and returning false if it is
|
||||
// malformed. Spelled differently from save_config() on purpose: nlohmann::json converts
|
||||
// implicitly from const char*, so a `save_config(..., "{}")` overload pair would be
|
||||
// ambiguous, and a raw string would silently store as a JSON string rather than an object.
|
||||
bool save_config_text(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const std::string& config);
|
||||
// Replaces one capability's cap_config and writes config.json straight away, stamping the
|
||||
// entry with the plugin version currently running. Every other entry is round-tripped
|
||||
// untouched, so saving one capability cannot disturb another's config.
|
||||
// The single mutation entry point for both the Plugins dialog and the Python binding.
|
||||
bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config);
|
||||
|
||||
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
|
||||
// no stored config.
|
||||
@@ -104,8 +103,6 @@ public:
|
||||
|
||||
bool dirty() const;
|
||||
|
||||
static void RegisterBindings(pybind11::module_& module);
|
||||
|
||||
private:
|
||||
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably.
|
||||
using CapabilityId = std::pair<std::string, std::string>;
|
||||
@@ -114,4 +111,19 @@ private:
|
||||
std::map<CapabilityId, BaseConfig> m_storage;
|
||||
bool m_dirty = false;
|
||||
};
|
||||
|
||||
// Host implementations behind the capability-level Python config API (bound onto every
|
||||
// capability class in PythonPluginBridge). The capability addresses only itself: the
|
||||
// (plugin_key, capability_name) pair is read off the instance the call arrived on, never
|
||||
// passed in from Python, so a capability cannot reach another capability's config.
|
||||
// Throw std::runtime_error (surfacing to Python as RuntimeError) on an unmaterialized instance.
|
||||
|
||||
// Only the user-editable cap_config. An empty object when nothing has been saved yet.
|
||||
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability);
|
||||
// The plugin version that last wrote the entry, so a plugin can migrate a stale cap_config.
|
||||
// Empty when the capability has no stored config.
|
||||
std::string capability_get_config_version(const PluginCapabilityInterface& capability);
|
||||
// Replaces cap_config and persists. Host-managed identity and version metadata are preserved.
|
||||
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config);
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
#include "PythonInterpreter.hpp" // PythonGILState
|
||||
#include "PythonJsonUtils.hpp" // json_to_py / py_to_json
|
||||
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/MainFrame.hpp>
|
||||
@@ -34,63 +35,6 @@ using json = nlohmann::json;
|
||||
namespace Slic3r {
|
||||
namespace {
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JSON <-> Python conversion (caller must hold the GIL).
|
||||
// --------------------------------------------------------------------------
|
||||
py::object json_to_py(const json& j)
|
||||
{
|
||||
switch (j.type()) {
|
||||
case json::value_t::null: return py::none();
|
||||
case json::value_t::boolean: return py::bool_(j.get<bool>());
|
||||
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
|
||||
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
|
||||
case json::value_t::number_float: return py::float_(j.get<double>());
|
||||
case json::value_t::string: return py::str(j.get<std::string>());
|
||||
case json::value_t::array: {
|
||||
py::list lst;
|
||||
for (const auto& e : j)
|
||||
lst.append(json_to_py(e));
|
||||
return lst;
|
||||
}
|
||||
case json::value_t::object: {
|
||||
py::dict d;
|
||||
for (auto it = j.begin(); it != j.end(); ++it)
|
||||
d[py::str(it.key())] = json_to_py(it.value());
|
||||
return d;
|
||||
}
|
||||
default: return py::none();
|
||||
}
|
||||
}
|
||||
|
||||
json py_to_json(const py::handle& o)
|
||||
{
|
||||
if (o.is_none())
|
||||
return json(nullptr);
|
||||
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
|
||||
return o.cast<bool>();
|
||||
if (py::isinstance<py::int_>(o))
|
||||
return o.cast<std::int64_t>();
|
||||
if (py::isinstance<py::float_>(o))
|
||||
return o.cast<double>();
|
||||
if (py::isinstance<py::str>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::bytes>(o))
|
||||
return o.cast<std::string>();
|
||||
if (py::isinstance<py::dict>(o)) {
|
||||
json obj = json::object();
|
||||
for (auto item : py::reinterpret_borrow<py::dict>(o))
|
||||
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
|
||||
return obj;
|
||||
}
|
||||
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
|
||||
json arr = json::array();
|
||||
for (auto e : o)
|
||||
arr.push_back(py_to_json(e));
|
||||
return arr;
|
||||
}
|
||||
return py::str(o).cast<std::string>(); // fallback: str()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// GIL-safe holder for a Python callable. A std::function that captured a bare
|
||||
// py::object could be destroyed on the main thread without the GIL (a dialog
|
||||
|
||||
@@ -974,6 +974,22 @@ void PluginLoader::load_plugin_impl(PluginCatalog& catalog, const std::string& p
|
||||
|
||||
loaded_cap->instance->set_audit_plugin_key(descriptor.plugin_key);
|
||||
loaded_cap->instance->set_audit_capability_name(loaded_cap->name);
|
||||
|
||||
// Cache the config-UI hook once, here, so the Plugins dialog can build the Config
|
||||
// tab without reaching into Python (and without the GIL). It is optional and
|
||||
// plugin-authored: a raising or non-bool override only costs this capability its
|
||||
// custom UI (it falls back to the host's JSON editor) — it must not fail the whole
|
||||
// plugin load, so it is caught locally rather than falling through to the
|
||||
// materialization handler below.
|
||||
try {
|
||||
loaded_cap->has_config_ui = loaded_cap->instance->has_config_ui();
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Plugin capability '" << loaded_cap->name << "' of plugin '" << loaded_cap->plugin_key
|
||||
<< "': has_config_ui() failed (" << ex.what() << "); falling back to the default JSON editor";
|
||||
loaded_cap->has_config_ui = false;
|
||||
}
|
||||
|
||||
capability_types.push_back(loaded_cap->type);
|
||||
loaded.capabilities.push_back(capability_id);
|
||||
capabilities.emplace_back(std::move(loaded_cap));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -122,6 +122,12 @@ bool PluginManager::initialize()
|
||||
|
||||
m_initialized = true;
|
||||
|
||||
// Bring every capability's stored config into memory. Deliberately unconditional and
|
||||
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
|
||||
// plugin that comes back later finds its settings intact. A missing or malformed file just
|
||||
// leaves the store empty (see PluginConfig::load), never blocking startup.
|
||||
m_config.load();
|
||||
|
||||
// Persist auto-load / capability state to each plugin's .install_state.json sidecar.
|
||||
// On load: write enabled=true plus current capability flags. On unload: flip enabled=false.
|
||||
// The on-unload callback is skipped during shutdown (run_on_unload_callbacks is gated by
|
||||
@@ -169,6 +175,12 @@ void PluginManager::shutdown()
|
||||
m_loader.unload_all_plugins();
|
||||
PythonPluginBridge::instance().clear_pending_captures();
|
||||
|
||||
// Every config write already goes to disk as it happens (store_capability_config), so this
|
||||
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
|
||||
// plugins above must never discard their stored config.
|
||||
if (m_config.dirty())
|
||||
m_config.save();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_initialized = false;
|
||||
|
||||
@@ -64,6 +64,32 @@ public:
|
||||
get_name);
|
||||
}
|
||||
|
||||
// Config UI hooks. Available on every capability type, so they live here rather than in
|
||||
// PyPluginInterfaceTrampoline. Audited like any other C++ -> Python call; a Python
|
||||
// exception is logged with its traceback and rethrown, and the caller (PluginLoader at
|
||||
// load time, PluginsDialog when opening the Config tab) decides the fallback.
|
||||
bool has_config_ui() const override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[] {},
|
||||
PYBIND11_OVERRIDE,
|
||||
bool,
|
||||
Base,
|
||||
has_config_ui);
|
||||
}
|
||||
|
||||
std::string get_config_ui() const override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[] {},
|
||||
PYBIND11_OVERRIDE,
|
||||
std::string,
|
||||
Base,
|
||||
get_config_ui);
|
||||
}
|
||||
|
||||
// All plugins may define their own on_load/unload functions.
|
||||
void on_load() override
|
||||
{
|
||||
|
||||
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
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include "PythonInterpreter.hpp"
|
||||
#include "PythonJsonUtils.hpp"
|
||||
#include "PluginConfig.hpp"
|
||||
#include "PluginHostApi.hpp"
|
||||
#include "PyPluginPackage.hpp"
|
||||
@@ -320,12 +321,45 @@ void bind_python_api(pybind11::module_& m)
|
||||
.def_static("skipped", &ExecutionResult::skipped, py::arg("message") = std::string())
|
||||
.def_static("failure", &ExecutionResult::failure, py::arg("status"), py::arg("message"), py::arg("data") = std::string());
|
||||
|
||||
// Config lives at the capability level, not as a global orca.* function: the host reads the
|
||||
// owning (plugin_key, capability) straight off the instance the call arrived on, so a
|
||||
// capability can only ever address its own config and never has to name itself.
|
||||
// Registered on the base, so every capability type (script/gcode/printer-agent) inherits it.
|
||||
py::class_<PluginCapabilityInterface, PyPluginInterfaceTrampoline, std::shared_ptr<PluginCapabilityInterface>>(m, "PythonPluginBase")
|
||||
.def(py::init<>())
|
||||
.def("get_name", &PluginCapabilityInterface::get_name)
|
||||
.def("get_type", &PluginCapabilityInterface::get_type)
|
||||
.def("on_load", &PluginCapabilityInterface::on_load)
|
||||
.def("on_unload", &PluginCapabilityInterface::on_unload);
|
||||
.def("on_unload", &PluginCapabilityInterface::on_unload)
|
||||
.def("has_config_ui", &PluginCapabilityInterface::has_config_ui,
|
||||
"Override to return True to replace the host's default JSON editor with your own HTML\n"
|
||||
"UI, returned by get_config_ui(). Every capability is configurable and appears in the\n"
|
||||
"Plugins dialog's Config tab regardless; this only chooses how its config is edited.")
|
||||
.def("get_config_ui", &PluginCapabilityInterface::get_config_ui,
|
||||
"Override to return the custom configuration UI as an HTML string. Only called when\n"
|
||||
"has_config_ui() is True; an empty result falls back to the default JSON editor.\n"
|
||||
"Inside the page, use window.orca.getConfig()/saveConfig() to reach this same config.")
|
||||
.def(
|
||||
"get_config",
|
||||
[](const PluginCapabilityInterface& self) {
|
||||
nlohmann::json config = capability_get_config(self);
|
||||
return json_to_py(config); // GIL held (binding body)
|
||||
},
|
||||
"Return this capability's stored config. An empty dict if it has never been saved.")
|
||||
.def(
|
||||
"get_config_version",
|
||||
[](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
|
||||
"Return the plugin version that last wrote this capability's config, so a newer\n"
|
||||
"release can spot a stale config and migrate it. Empty string if never saved.")
|
||||
.def(
|
||||
"save_config",
|
||||
[](const PluginCapabilityInterface& self, const py::object& config) {
|
||||
return capability_save_config(self, py_to_json(config)); // GIL held (binding body)
|
||||
},
|
||||
py::arg("config"),
|
||||
"Persist this capability's config, given as a JSON-compatible value (usually a dict).\n"
|
||||
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
|
||||
"the config file could not be written.");
|
||||
|
||||
// Expose the package marker base as orca.base. @orca.plugin later verifies that the
|
||||
// decorated class derives from this exact pybind-registered C++ type.
|
||||
@@ -340,7 +374,6 @@ void bind_python_api(pybind11::module_& m)
|
||||
PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes);
|
||||
ScriptPluginCapability::RegisterBindings(m, pluginTypes);
|
||||
PluginHostApi::RegisterBindings(m);
|
||||
PluginConfig::RegisterBindings(m);
|
||||
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
|
||||
|
||||
m.def(
|
||||
|
||||
@@ -103,8 +103,18 @@ public:
|
||||
|
||||
// Optional APIs
|
||||
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; }
|
||||
virtual bool has_config() const { return false; }
|
||||
virtual std::string embed_config_ui() const { return ""; }
|
||||
|
||||
// Every capability is configurable: it always appears in the Plugins dialog's Config
|
||||
// sidebar and always has the host's default JSON editor over its stored config. The only
|
||||
// question a capability answers is whether it supplies its own UI to edit that config
|
||||
// *instead of* the JSON editor.
|
||||
//
|
||||
// True when the capability ships a custom configuration UI. get_config_ui() is called
|
||||
// only when this returns true.
|
||||
virtual bool has_config_ui() const { return false; }
|
||||
// An HTML snippet for the custom configuration UI. An empty or throwing result is
|
||||
// treated as "no custom UI" and falls back to the default JSON editor.
|
||||
virtual std::string get_config_ui() const { return ""; }
|
||||
|
||||
virtual void on_load() {}
|
||||
virtual void on_unload() {}
|
||||
|
||||
Reference in New Issue
Block a user