Merge branch 'feat/plugin-feature' into feature/speed-dial

Adapt the speed dial's ActionRegistry to the collapsed
get_plugin_capability(PluginCapabilityId) overload, and restore the
script success/skipped status message the dialog lost when its
PluginScriptRunner refactor was superseded by ActionRegistry.
This commit is contained in:
SoftFever
2026-07-17 19:32:44 +08:00
58 changed files with 4375 additions and 671 deletions
@@ -0,0 +1,53 @@
<!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">
<!-- The shared dialog sheets, theme.css last so its host-injected variables win. -->
<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="pagePresetName" class="page-title"></h1>
</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="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 and reaches the host only through the injected window.orca bridge. -->
<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="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
title="Discard this preset's override and use the global configuration again">
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>
@@ -0,0 +1,437 @@
// Capability rows the active preset uses, as PluginConfig::capabilities_payload emits them:
// {plugin_key, name, type, type_key, has_config_ui}.
let capabilities = [];
// The selected row's identity; plugin_key is part of it because this list spans plugins.
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 presetName = document.getElementById("pagePresetName");
if (presetName)
presetName.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 if the capability is still there, else select the first.
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;
// The native reply is async: clear now so the old config cannot appear under the new selection.
ClearCapabilityConfigView();
RequestCapabilityConfig();
RenderCapabilities();
}
// Config editor: the host's JSON editor, or the capability's own HTML UI in a sandboxed frame.
// Both edit the same stored config; the page renders what the native side sends.
// Replies are async: apply one only if it still matches the selected row (plugin_key included,
// since this list spans plugins), so a stale reply never lands under another capability.
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 and the footer, so nothing from the previous capability lingers while the
// next one is in flight.
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;
selectedHasPresetOverride = false;
selectedReadOnly = false;
SetConfigValidation("");
}
// A read-only capability cannot be saved, and there is nothing to restore until the preset overrides
// the global configuration.
function UpdateConfigActions(payload) {
selectedHasPresetOverride = payload?.has_preset_override === true;
selectedReadOnly = payload?.read_only === true;
const save = document.getElementById("configSaveBtn");
const restore = document.getElementById("configRestoreBtn");
if (save)
save.disabled = selectedReadOnly;
if (restore)
restore.disabled = selectedReadOnly || !selectedHasPresetOverride;
}
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 || "");
UpdateConfigActions(payload);
// The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
// save/restore controls it wants, and reaches the host through the window.orca bridge.
const footer = document.getElementById("configFooter");
if (footer)
footer.hidden = html !== "";
if (html) {
if (custom) {
custom.hidden = false;
custom.srcdoc = BuildCustomConfigDocument(html, config);
}
if (editor)
editor.hidden = true;
return;
}
// Default editor: any reason a custom UI is unavailable already arrived in payload.error.
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 is never saved: Save is the only way to persist. The native side re-validates.
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
});
}
// "Restore defaults" here drops the preset's override, so the capability falls back to the global
// configuration. The native side confirms, then re-sends the config that is now effective.
function RestoreCapabilityConfig() {
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 persisted, not from what 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, save one, drop the preset's
// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this
// bridge is its only channel.
function BuildCustomConfigDocument(html, config) {
// Inlined into a <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
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 }, "*"); },
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
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 || !selectedPluginKey || !selectedCapabilityName)
return;
if (data.__orca === "save") {
SendMessage("save_capability_config", {
plugin_key: selectedPluginKey,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: data.config === undefined ? {} : data.config
});
return;
}
if (data.__orca === "restore")
RestoreCapabilityConfig();
}
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 text = document.getElementById("configText");
if (text)
text.addEventListener("input", ValidateConfigText);
// The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage);
SendMessage("request_capabilities");
});
@@ -0,0 +1,251 @@
/* 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;
}
/* ---- 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-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);
}
@@ -81,12 +81,19 @@
<div id="pluginList" class="body thin-scroll"></div> <div id="pluginList" class="body thin-scroll"></div>
</section> </section>
<!-- Drag to redistribute the dialog's height between the list and the details pane; the hit
area is the whole strip, the visible divider is the line drawn inside it. -->
<div id="paneSplitter" class="pane-splitter" role="separator" aria-orientation="horizontal"
aria-label="Resize the plugin list" title="Drag to resize; double-click to reset"></div>
<section class="pane details-pane"> <section class="pane details-pane">
<div class="detail-tabs" role="tablist" aria-label="Plugin details"> <div class="detail-tabs" role="tablist" aria-label="Plugin details">
<button id="pluginInfoTab" class="detail-tab active" type="button" role="tab" <button id="pluginInfoTab" class="detail-tab active" type="button" role="tab"
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button> 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" <button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button> 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" <button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button> aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1" <button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
@@ -139,6 +146,39 @@
<div id="detailDescription" class="detail-description">No description available</div> <div id="detailDescription" class="detail-description">No description available</div>
</section> </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 and reaches the host only through the injected
window.orca bridge. -->
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
<!-- JSON-editor chrome only: a custom UI renders its own save/restore controls and
drives them through the window.orca bridge. -->
<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" <section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
aria-labelledby="changelogTab" data-panel="changelog" hidden> aria-labelledby="changelogTab" data-panel="changelog" hidden>
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog"> <table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">
+470 -23
View File
@@ -12,21 +12,35 @@ const pluginInstallActions = {
let expandedPluginIds = new Set(); let expandedPluginIds = new Set();
// why: transient per-search override on top of expandedPluginIds. A search // why: transient per-search override (id -> bool) on top of expandedPluginIds. A search auto-expands
// auto-expands rows whose capabilities match, display-only. This lets a // matching rows, so a triangle click during search must not touch the base expand state.
// triangle click during search collapse/reopen such a row without touching
// the base (id -> bool).
let searchExpandOverride = new Map(); let searchExpandOverride = new Map();
let selectedPluginId = ""; let selectedPluginId = "";
let contextPluginId = ""; let contextPluginId = "";
let activeDetailTab = "plugin-info"; let activeDetailTab = "plugin-info";
let selectedInstallAction = "explore"; let selectedInstallAction = "explore";
// Config tab: the capability whose config is shown. Name and type together address it natively.
let selectedCapabilityName = "";
let selectedCapabilityType = "";
// The plugin that selection belongs to. Capability names are only unique within a plugin, so
// without the owner, selecting another plugin could keep the selection and show the wrong config.
let configPluginId = "";
let pluginList = null; let pluginList = null;
let ctxMenu = null; let ctxMenu = null;
let exploreMenu = null; let exploreMenu = null;
let exploreMenuButton = null; let exploreMenuButton = null;
// Split pane. The ratio is the list's share of the content height, so it survives a dialog resize.
const SPLIT_STORAGE_KEY = "orca.plugins.split_ratio";
const SPLIT_DEFAULT_RATIO = 0.62;
const SPLIT_MIN_LIST_PX = 180;
const SPLIT_MIN_DETAILS_PX = 160;
let contentPane = null;
let paneSplitter = null;
let splitRatio = SPLIT_DEFAULT_RATIO;
function OnInit() { function OnInit() {
pluginList = document.getElementById("pluginList"); pluginList = document.getElementById("pluginList");
ctxMenu = document.getElementById("ctxMenu"); ctxMenu = document.getElementById("ctxMenu");
@@ -60,6 +74,23 @@ function OnInit() {
pluginList?.addEventListener("contextmenu", OnPluginContextMenu); pluginList?.addEventListener("contextmenu", OnPluginContextMenu);
ctxMenu?.addEventListener("click", OnContextMenuClick); ctxMenu?.addEventListener("click", OnContextMenuClick);
InitPaneSplitter();
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 cancels every key at the document level (returnValue=false) to block webview
// shortcuts, which also swallows typing; don't bubble to it, so the textarea stays editable.
// Same treatment as the search field (see plugin-search.js).
configText?.addEventListener("keydown", (event) => event.stopPropagation());
configText?.addEventListener("input", ValidateConfigText);
// The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage);
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!event.target.closest(".ctx")) if (!event.target.closest(".ctx"))
HideContextMenu(); HideContextMenu();
@@ -78,6 +109,87 @@ function OnInit() {
RequestPlugins(); RequestPlugins();
} }
function InitPaneSplitter() {
contentPane = document.querySelector(".content");
paneSplitter = document.getElementById("paneSplitter");
if (!contentPane || !paneSplitter)
return;
ApplySplitRatio(ReadStoredSplitRatio());
paneSplitter.addEventListener("pointerdown", OnSplitterPointerDown);
paneSplitter.addEventListener("dblclick", () => {
ApplySplitRatio(SPLIT_DEFAULT_RATIO);
StoreSplitRatio(splitRatio);
});
// A resized dialog changes what the ratio is a ratio *of*, so re-clamp it against the new height
// rather than letting a pane fall below its minimum.
window.addEventListener("resize", () => ApplySplitRatio(splitRatio));
}
// Clamped so neither pane drops below its minimum. When the dialog is too short to honor both, the
// panes just split what there is.
function ApplySplitRatio(ratio) {
const available = contentPane.clientHeight;
const sash = paneSplitter.offsetHeight;
let next = Number.isFinite(ratio) ? ratio : SPLIT_DEFAULT_RATIO;
if (available > 0) {
const min = SPLIT_MIN_LIST_PX / available;
const max = (available - sash - SPLIT_MIN_DETAILS_PX) / available;
next = min > max ? 0.5 : Math.min(Math.max(next, min), max);
}
splitRatio = next;
contentPane.style.setProperty("--plugin-list-height", `${(next * 100).toFixed(2)}%`);
}
function OnSplitterPointerDown(event) {
if (event.button !== 0)
return;
const bounds = contentPane.getBoundingClientRect();
// Offset of the grab point inside the strip, so the splitter does not jump under the cursor.
const grabOffset = event.clientY - paneSplitter.getBoundingClientRect().top;
const onMove = (moveEvent) => {
ApplySplitRatio((moveEvent.clientY - bounds.top - grabOffset) / bounds.height);
};
const onUp = (upEvent) => {
paneSplitter.releasePointerCapture(upEvent.pointerId);
paneSplitter.removeEventListener("pointermove", onMove);
paneSplitter.removeEventListener("pointerup", onUp);
paneSplitter.classList.remove("dragging");
document.body.classList.remove("pane-resizing");
StoreSplitRatio(splitRatio);
};
// Captured, so the drag keeps tracking once the pointer leaves the strip (which it does at once).
paneSplitter.setPointerCapture(event.pointerId);
paneSplitter.addEventListener("pointermove", onMove);
paneSplitter.addEventListener("pointerup", onUp);
paneSplitter.classList.add("dragging");
document.body.classList.add("pane-resizing");
event.preventDefault();
}
function ReadStoredSplitRatio() {
try {
const stored = Number.parseFloat(window.localStorage.getItem(SPLIT_STORAGE_KEY));
return Number.isFinite(stored) ? stored : SPLIT_DEFAULT_RATIO;
} catch (err) {
return SPLIT_DEFAULT_RATIO; // storage can be unavailable in the webview; the default still works
}
}
function StoreSplitRatio(ratio) {
try {
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(ratio));
} catch (err) {
// Persisting the position is a nicety, never a reason to break the drag.
}
}
function NormalizeInstallAction(action) { function NormalizeInstallAction(action) {
const normalized = String(action || ""); const normalized = String(action || "");
return pluginInstallActions[normalized] ? normalized : "explore"; return pluginInstallActions[normalized] ? normalized : "explore";
@@ -227,11 +339,14 @@ function HandleStudio(value) {
ApplyPlugins(payload.data || []); ApplyPlugins(payload.data || []);
} else if (payload.command === "status_message") { } else if (payload.command === "status_message") {
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info")); 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);
} }
} }
// Renders the latest plugin/capability operation result in the footer status bar. The result // Footer status bar for the latest operation result; the native side already localizes the text.
// persists until the next operation replaces it; the native side already localizes the text.
function ShowStatusMessage(message, level) { function ShowStatusMessage(message, level) {
const bar = document.getElementById("statusBar"); const bar = document.getElementById("statusBar");
const text = document.getElementById("statusText"); const text = document.getElementById("statusText");
@@ -305,7 +420,6 @@ function SyncPluginListHeaderGutter() {
} }
// why: paint matched-character ranges as <mark> without an innerHTML build // why: paint matched-character ranges as <mark> without an innerHTML build
// note: if no ranges -> return the plain text node
function ApplyHighlight(container, text, ranges) { function ApplyHighlight(container, text, ranges) {
if (!ranges || !ranges.length) { if (!ranges || !ranges.length) {
container.appendChild(document.createTextNode(text)); container.appendChild(document.createTextNode(text));
@@ -341,7 +455,7 @@ function RenderPlugins() {
} }
// why: stable filter over the existing C++ sort order - no scoring, no reorder. The empty query // why: stable filter over the existing C++ sort order - no scoring, no reorder. The empty query
// short-circuits (searching=false), leaving every existing render path untouched. // short-circuits, leaving every existing render path untouched.
const searching = typeof PluginSearchActive === "function" && PluginSearchActive(); const searching = typeof PluginSearchActive === "function" && PluginSearchActive();
let shown = 0; let shown = 0;
@@ -353,10 +467,8 @@ function RenderPlugins() {
continue; continue;
shown++; shown++;
// why: transient override wins. Otherwise while searching start collapsed and auto-expand only // why: the transient override wins; while searching, auto-expand only capability matches. The
// capability matches (the persistent expand state is ignored so unrelated caps don't clutter // base is never written while searching, so clearing it restores exactly what the user had.
// results); when not searching use the persistent state. The base is never written while
// searching, so clearing the search restores exactly what the user had.
const open = searchExpandOverride.has(pluginKey) const open = searchExpandOverride.has(pluginKey)
? searchExpandOverride.get(pluginKey) ? searchExpandOverride.get(pluginKey)
: (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey)); : (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey));
@@ -427,7 +539,6 @@ function GetLatestVersion(plugin) {
return String(plugin?.latest_version || plugin?.version || ""); return String(plugin?.latest_version || plugin?.version || "");
} }
// Primary version shown in the row: the installed version once installed, otherwise the latest available.
function GetDisplayVersion(plugin) { function GetDisplayVersion(plugin) {
const installed = GetInstalledVersion(plugin); const installed = GetInstalledVersion(plugin);
if (IsPluginInstalled(plugin) && installed) if (IsPluginInstalled(plugin) && installed)
@@ -476,9 +587,8 @@ function HasMixedCapabilityState(plugin) {
String(capability?.type_key || "") String(capability?.type_key || "")
); );
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true); const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
return hasEnabled && hasDisabled; return toggleableCapabilities.length > 0 && hasDisabled;
} }
function IsPluginLoading(plugin) { function IsPluginLoading(plugin) {
@@ -543,7 +653,6 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges =
const labelCell = document.createElement("span"); const labelCell = document.createElement("span");
labelCell.className = "label-cell"; labelCell.className = "label-cell";
// Add hyperlink
const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed"; const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed";
const pluginLabelText = plugin.label || plugin.name || plugin.plugin_id || ""; const pluginLabelText = plugin.label || plugin.name || plugin.plugin_id || "";
const canExpand = capabilityCount > 0; const canExpand = capabilityCount > 0;
@@ -785,6 +894,345 @@ function RenderDetails() {
ApplyDetailUpdateBadge(detailUpdateBadge, plugin); ApplyDetailUpdateBadge(detailUpdateBadge, plugin);
if (detailUpdateBtn) if (detailUpdateBtn)
ApplyDetailUpdateButton(detailUpdateBtn, plugin); ApplyDetailUpdateButton(detailUpdateBtn, plugin);
RenderConfig(plugin);
}
// Config tab: the sidebar lists the selected plugin's configurable capabilities; the view shows 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 renders what the native side sends.
// Rows built natively by PluginConfig::capabilities_payload. Only loaded, addressable capabilities
// appear: the descriptor-only rows of an inactive plugin carry no name, so nothing to address.
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: drop the selection rather than trusting the name to mean the same here.
if (pluginKey !== configPluginId) {
configPluginId = pluginKey;
selectedCapabilityName = "";
selectedCapabilityType = "";
ClearCapabilityConfigView();
}
if (!plugin || capabilities.length === 0) {
// Capabilities only exist once the plugin is activated: say that, rather than claiming it has
// none to configure.
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 if the capability is still there, else select the first.
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;
// The native reply is async: clear now so the old config cannot appear under the new selection.
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 and the footer, so nothing from the previous capability lingers while the
// next one is in flight.
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("");
}
// Replies are async: one for a capability the user has navigated away from is dropped, never
// 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 || "");
// The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
// save/restore controls it wants, and reaches the host through the window.orca bridge.
const footer = document.getElementById("configFooter");
if (footer)
footer.hidden = html !== "";
if (html) {
if (custom) {
custom.hidden = false;
custom.srcdoc = BuildCustomConfigDocument(html, config);
}
if (editor)
editor.hidden = true;
return;
}
// Default editor: any reason a custom UI is unavailable already arrived in payload.error.
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 is never saved: Save is the only way to persist. The native side re-validates.
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
});
}
// Writes the capability's own get_default_config() over whatever is stored — the host does not know
// what a plugin considers default. The native side confirms first, then replies as if it were a save.
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 persisted, not from what 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, save one, restore the plugin's
// defaults, and be told when either lands. The frame is sandboxed into an opaque origin, so this
// bridge is its only channel.
function BuildCustomConfigDocument(html, config) {
// Inlined into a <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
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 }, "*"); },
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
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 || !selectedPluginId || !selectedCapabilityName)
return;
if (data.__orca === "save") {
SendMessage("save_capability_config", {
plugin_key: selectedPluginId,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: data.config === undefined ? {} : data.config
});
return;
}
if (data.__orca === "restore")
RestoreCapabilityConfig();
} }
function RenderThumbnail(plugin) { function RenderThumbnail(plugin) {
@@ -809,8 +1257,8 @@ function RenderDescription(plugin) {
node.replaceChildren(); node.replaceChildren();
// Descriptions come only from the plugin's Python header (local/installed plugins). A cloud plugin // Descriptions come from the plugin's Python header; a cloud plugin that is not installed yet has
// that is not installed yet has no header, so show a link to view it on OrcaCloud instead. // no header, so link to OrcaCloud instead.
const description = String(plugin?.description || "").trim(); const description = String(plugin?.description || "").trim();
if (description && description !== "No description.") { if (description && description !== "No description.") {
node.textContent = description; node.textContent = description;
@@ -881,8 +1329,8 @@ function SetText(id, text) {
function ApplyDetailUpdateBadge(node, plugin) { function ApplyDetailUpdateBadge(node, plugin) {
node.className = "version-update-badge"; node.className = "version-update-badge";
// The "update_available" state is represented by the actionable Update button next to the // "update_available" is already the actionable Update button, so the badge only warns about
// version, so the detail panel only shows the passive badge for the unauthorized warning. // unauthorized.
const updateStatus = plugin ? GetUpdateStatus(plugin) : "normal"; const updateStatus = plugin ? GetUpdateStatus(plugin) : "normal";
if (updateStatus === "unauthorized") { if (updateStatus === "unauthorized") {
node.hidden = false; node.hidden = false;
@@ -982,9 +1430,8 @@ function OnPluginListClick(event) {
const pluginKey = String(block.dataset.pluginKey || ""); const pluginKey = String(block.dataset.pluginKey || "");
selectedPluginId = pluginKey; selectedPluginId = pluginKey;
// why: during a search the triangle writes to the transient override (read from the on-screen open // why: during a search the triangle writes to the transient override (read from the on-screen
// state), so an auto-expanded row collapses without touching the saved layout. With no search // open state), so an auto-expanded row collapses without touching the saved layout.
// active, toggle the persistent base exactly as before.
if (typeof PluginSearchActive === "function" && PluginSearchActive()) if (typeof PluginSearchActive === "function" && PluginSearchActive())
searchExpandOverride.set(pluginKey, !block.classList.contains("expanded")); searchExpandOverride.set(pluginKey, !block.classList.contains("expanded"));
else if (expandedPluginIds.has(pluginKey)) else if (expandedPluginIds.has(pluginKey))
+216 -3
View File
@@ -151,11 +151,13 @@ body {
margin-right: auto; margin-right: auto;
} }
/* Split pane: the list keeps --plugin-list-height of the dialog's content height (set by the
splitter drag, see InitPaneSplitter), the details pane takes what is left. Both panes shrink
before the layout overflows, so a short dialog degrades instead of clipping. */
.content { .content {
min-height: 0; min-height: 0;
display: grid; display: flex;
grid-template-rows: minmax(220px, 1fr) 280px; flex-direction: column;
gap: 8px;
} }
.pane { .pane {
@@ -166,17 +168,60 @@ body {
display: grid; display: grid;
} }
/* The pane minimums live in JS (SPLIT_MIN_LIST_PX / SPLIT_MIN_DETAILS_PX), which clamps the ratio
against the current height: as CSS min-heights they could not both be honored in a very short
dialog and the panes would overflow instead of shrinking. */
.plugin-list-pane { .plugin-list-pane {
flex: 0 1 auto;
height: var(--plugin-list-height, 62%);
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
} }
/* basis 0, so the details pane takes the space the list leaves instead of letting its own content
height push back and shrink the list below --plugin-list-height. */
.details-pane { .details-pane {
flex: 1 1 0;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
padding: 12px; padding: 12px;
box-sizing: border-box; box-sizing: border-box;
gap: 10px; gap: 10px;
} }
/* 9px of grab area for a 2px divider: the strip also supplies the gap between the panes. */
.pane-splitter {
flex: 0 0 auto;
position: relative;
height: 9px;
cursor: ns-resize;
touch-action: none;
}
.pane-splitter::after {
content: "";
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 2px;
transform: translateY(-50%);
border-radius: 1px;
background: transparent;
transition: background-color 0.12s ease;
}
.pane-splitter:hover::after {
background: var(--border-strong);
}
.pane-splitter.dragging::after {
background: var(--row-selected-outline);
}
/* Keep the resize cursor for the whole drag, wherever the pointer travels. */
body.pane-resizing {
cursor: ns-resize;
}
.hdr { .hdr {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
@@ -1084,6 +1129,174 @@ body {
display: none; 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 { .detail-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
+1
View File
@@ -68,6 +68,7 @@
--col-sep: #4a4a51; --col-sep: #4a4a51;
--row-hover: #2b3340; --row-hover: #2b3340;
--row-selected: #244945; --row-selected: #244945;
--plugin-link-text: #5eead4;
--plugin-status-danger: #ff7b72; --plugin-status-danger: #ff7b72;
--plugin-status-ok: #37c871; --plugin-status-ok: #37c871;
--plugin-status-warn: #f0b45a; --plugin-status-warn: #f0b45a;
+7 -9
View File
@@ -7,12 +7,6 @@
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "0.01" # version = "0.01"
# type = "slicing-pipeline" # type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# thickness_mm = "0.3"
# point_distance_mm = "0.8"
# fuzz_holes = "1"
# skip_first_layer = "1"
# /// # ///
"""Fuzzy Slices -- the fuzzy-skin effect applied at slice time. """Fuzzy Slices -- the fuzzy-skin effect applied at slice time.
@@ -44,6 +38,7 @@ Polygon.as_array()/set_points numpy path would be the faster route.
""" """
import math import math
import random import random
import json
import orca import orca
@@ -55,9 +50,9 @@ _DEFAULTS = {
} }
def _params(ctx): def _params(self):
try: try:
src = dict(ctx.params) src = json.loads(self.get_config())
except (AttributeError, TypeError): except (AttributeError, TypeError):
src = {} src = {}
out = {} out = {}
@@ -111,11 +106,14 @@ class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): def get_name(self):
return "Fuzzy Slices" return "Fuzzy Slices"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx): def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success() return orca.ExecutionResult.success()
p = _params(ctx) p = _params(self)
if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0: if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0:
return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do") return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do")
+14 -11
View File
@@ -7,9 +7,6 @@
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "0.01" # version = "0.01"
# type = "slicing-pipeline" # type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# stamp_text = "processed by the OrcaSlicer G-code Stamp plugin"
# /// # ///
"""G-code Stamp -- the post-processing half of the slicing-pipeline plugin. """G-code Stamp -- the post-processing half of the slicing-pipeline plugin.
@@ -19,7 +16,7 @@ exported G-code file -- NOT from Print::process(). So unlike the geometry steps
(posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and (posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and
ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code
file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and
ctx.output_name (the final file name). ctx.params and ctx.config_value() still work. ctx.output_name (the final file name). self.get_config() still works.
This sample inserts a single comment line near the top of the file. Because the same This sample inserts a single comment line near the top of the file. Because the same
capability class can also implement the geometry steps, one plugin can transform slices capability class can also implement the geometry steps, one plugin can transform slices
@@ -30,21 +27,27 @@ separate working copy), and its output is not reflected in the G-code preview --
viewer maps the pre-post-process file. viewer maps the pre-post-process file.
""" """
import orca import orca
import json
_DEFAULT_STAMP = "processed by the OrcaSlicer G-code Stamp plugin" _DEFAULTS = {
"stamp_text": "processed by the OrcaSlicer G-code Stamp plugin",
}
def _stamp_text(ctx): def _stamp_text(self):
try: try:
text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP) text = json.loads(self.get_config())["stamp_text"]
except (AttributeError, TypeError): except (AttributeError, TypeError, ValueError, KeyError):
text = _DEFAULT_STAMP text = _DEFAULTS["stamp_text"]
return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP return str(text).replace("\n", " ").strip() or _DEFAULTS["stamp_text"]
class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase): class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): def get_name(self):
return "G-code Stamp" return "G-code Stamp"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx): def execute(self, ctx):
# Only act at the post-process seam; at every geometry step this is a no-op. # Only act at the post-process seam; at every geometry step this is a no-op.
@@ -53,7 +56,7 @@ class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase):
if not ctx.gcode_path: if not ctx.gcode_path:
return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do") return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do")
comment = "; " + _stamp_text(ctx) + " (host=" + (ctx.host or "?") + ")\n" comment = "; " + _stamp_text(self) + " (host=" + (ctx.host or "?") + ")\n"
# Edit the exported G-code in place: keep the original first line first (some flavors # Edit the exported G-code in place: keep the original first line first (some flavors
# expect a specific leading line), then insert the stamp right after it. # expect a specific leading line), then insert the stamp right after it.
+20 -2
View File
@@ -25,21 +25,39 @@ A surface may split into several islands or vanish when shrunk; both are handled
No numpy required: the whole edit is expressed with the host geometry classes. No numpy required: the whole edit is expressed with the host geometry classes.
""" """
import json
import orca import orca
INSET_MM = 1.0 _DEFAULTS = {
"inset_mm": 1.0, # inward offset applied to every slice
}
def _inset_mm(self):
try:
return float(json.loads(self.get_config())["inset_mm"])
except (AttributeError, TypeError, ValueError, KeyError):
return _DEFAULTS["inset_mm"]
class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): def get_name(self):
return "Inset Every Slice" return "Inset Every Slice"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx): def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success() return orca.ExecutionResult.success()
inset_mm = _inset_mm(self)
if inset_mm <= 0.0:
return orca.ExecutionResult.success("Inset: zero inset, nothing to do")
# Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1))) inset_scaled = int(round(inset_mm / orca.slicing.unscale(1)))
regions_touched = 0 regions_touched = 0
for layer in ctx.object.layers(): for layer in ctx.object.layers():
+10 -14
View File
@@ -7,13 +7,6 @@
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "0.02" # version = "0.02"
# type = "slicing-pipeline" # type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# wobble_ampl_mm = "0.0"
# wobble_period_mm = "20.0"
# min_scale = "0.05"
# /// # ///
"""Twistify -- twist/taper/wobble any model at slice time. """Twistify -- twist/taper/wobble any model at slice time.
@@ -31,12 +24,12 @@ preview corkscrews and the print keeps correct walls/infill/flow.
Because we edit geometry in place, surface types are preserved automatically Because we edit geometry in place, surface types are preserved automatically
(no per-surface type carry needed), and no numpy is required -- (no per-surface type carry needed), and no numpy is required --
rotate/scale/translate are host methods. Parameters come from ctx.params (the rotate/scale/translate are host methods. Parameters come from self.get_config()
settings table above). The first object layer is untouched (z_rel = 0), so bed (see _params below), which the host seeds from get_default_config(). The first
adhesion is unaffected. object layer is untouched (z_rel = 0), so bed adhesion is unaffected.
""" """
import math import math
import json
import orca import orca
_DEFAULTS = { _DEFAULTS = {
@@ -48,9 +41,9 @@ _DEFAULTS = {
} }
def _params(ctx): def _params(self):
try: try:
src = dict(ctx.params) src = json.loads(self.get_config())
except (AttributeError, TypeError): except (AttributeError, TypeError):
src = {} src = {}
out = {} out = {}
@@ -79,12 +72,15 @@ def _layer_params(z_rel, mm_to_scaled, p):
class Twistify(orca.slicing.SlicingPipelineCapabilityBase): class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): def get_name(self):
return "Twistify" return "Twistify"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx): def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success() return orca.ExecutionResult.success()
p = _params(ctx) p = _params(self)
if _is_identity(p): if _is_identity(p):
return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do")
+2
View File
@@ -2256,6 +2256,8 @@ public:
// Vector value, but edited as a single string. // Vector value, but edited as a single string.
one_string, one_string,
plugin_picker, plugin_picker,
// Raw JSON string value, edited through a dialog behind a button rather than in the row.
plugin_config,
}; };
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map. // Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
+4 -1
View File
@@ -1203,6 +1203,7 @@ static std::vector<std::string> s_Preset_print_options{
"post_process", "post_process",
"slicing_pipeline_plugin", "slicing_pipeline_plugin",
"plugins", "plugins",
"plugin_config_overrides",
"process_change_extrusion_role_gcode", "process_change_extrusion_role_gcode",
"min_length_factor", "min_length_factor",
"wall_maximum_resolution", "wall_maximum_resolution",
@@ -1377,6 +1378,7 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
"filament_preheat_temperature_delta", "filament_retract_length_nc", "filament_preheat_temperature_delta", "filament_retract_length_nc",
"filament_change_length_nc", "filament_prime_volume_nc", "filament_change_length_nc", "filament_prime_volume_nc",
"long_retractions_when_ec", "retraction_distances_when_ec", "long_retractions_when_ec", "retraction_distances_when_ec",
"plugin_config_overrides",
//ams chamber //ams chamber
"filament_dev_ams_drying_ams_limitations", "filament_dev_ams_drying_temperature", "filament_dev_ams_drying_time", "filament_dev_ams_drying_heat_distortion_temperature", "filament_dev_ams_drying_ams_limitations", "filament_dev_ams_drying_temperature", "filament_dev_ams_drying_time", "filament_dev_ams_drying_heat_distortion_temperature",
"filament_dev_chamber_drying_bed_temperature", "filament_dev_chamber_drying_time", "filament_dev_chamber_drying_bed_temperature", "filament_dev_chamber_drying_time",
@@ -1427,7 +1429,8 @@ static std::vector<std::string> s_Preset_printer_options {
"machine_hotend_change_time", "machine_prepare_compensation_time", "machine_hotend_change_time", "machine_prepare_compensation_time",
// Fast-purge printer flag + device/firmware-facing per-variant extruder-change // Fast-purge printer flag + device/firmware-facing per-variant extruder-change
// deretraction speed (unconsumed by the slicer; carried by H2D/A2L/X2D/P2S machine profiles). // deretraction speed (unconsumed by the slicer; carried by H2D/A2L/X2D/P2S machine profiles).
"support_fast_purge_mode", "deretract_speed_extruder_change" "support_fast_purge_mode", "deretract_speed_extruder_change",
"plugin_config_overrides"
}; };
static std::vector<std::string> s_Preset_sla_print_options { static std::vector<std::string> s_Preset_sla_print_options {
+13 -3
View File
@@ -980,9 +980,8 @@ void PrintConfigDef::init_common_params()
def->tooltip = L("Select the network agent implementation for printer communication."); def->tooltip = L("Select the network agent implementation for printer communication.");
def->mode = comAdvanced; def->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli; def->cli = ConfigOptionDef::nocli;
// Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a // Plugin-backed (see ConfigOptionDef::is_plugin_backed), but edited through the Choice widget above
// plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its // rather than a plugin_picker field.
// "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed).
def->plugin_type = "printer-connection"; def->plugin_type = "printer-connection";
def->set_default_value(new ConfigOptionString("")); def->set_default_value(new ConfigOptionString(""));
@@ -1081,6 +1080,17 @@ void PrintConfigDef::init_common_params()
def = this->add("preset_name", coString); def = this->add("preset_name", coString);
def->set_default_value(new ConfigOptionString()); def->set_default_value(new ConfigOptionString());
} }
def = this->add("plugin_config_overrides", coString);
def->label = L("Capabilities");
def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global "
"Capabilities 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: GUIType::plugin_config renders a button that opens PluginsConfigDialog.
def->gui_type = ConfigOptionDef::GUIType::plugin_config;
def->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli;
def->set_default_value(new ConfigOptionString(""));
} }
void PrintConfigDef::init_fff_params() void PrintConfigDef::init_fff_params()
+4
View File
@@ -122,6 +122,8 @@ set(SLIC3R_GUI_SOURCES
GUI/SpeedDialDialog.hpp GUI/SpeedDialDialog.hpp
GUI/ActionRegistry.cpp GUI/ActionRegistry.cpp
GUI/ActionRegistry.hpp GUI/ActionRegistry.hpp
GUI/PluginsConfigDialog.cpp
GUI/PluginsConfigDialog.hpp
GUI/ProcessRunner.cpp GUI/ProcessRunner.cpp
GUI/ProcessRunner.hpp GUI/ProcessRunner.hpp
GUI/TerminalDialog.cpp GUI/TerminalDialog.cpp
@@ -621,6 +623,8 @@ set(SLIC3R_GUI_SOURCES
plugin/CloudPluginService.hpp plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp plugin/PluginFsUtils.cpp
plugin/PluginFsUtils.hpp plugin/PluginFsUtils.hpp
plugin/PluginConfig.cpp
plugin/PluginConfig.hpp
plugin/PluginLoader.cpp plugin/PluginLoader.cpp
plugin/PluginLoader.hpp plugin/PluginLoader.hpp
plugin/PluginDescriptor.hpp plugin/PluginDescriptor.hpp
+1 -1
View File
@@ -114,7 +114,7 @@ std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std:
if (!manager.is_plugin_loaded(plugin_key)) if (!manager.is_plugin_loaded(plugin_key))
return nullptr; return nullptr;
// only_enabled defaults true, so a disabled capability resolves to nullptr here. // only_enabled defaults true, so a disabled capability resolves to nullptr here.
if (!manager.get_plugin_capability(plugin_key, capability, PluginCapabilityType::Script)) if (!manager.get_plugin_capability({PluginCapabilityType::Script, capability, plugin_key}))
return nullptr; return nullptr;
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name); return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
} }
+122 -7
View File
@@ -23,6 +23,8 @@
#include "OG_CustomCtrl.hpp" #include "OG_CustomCtrl.hpp"
#include "MsgDialog.hpp" #include "MsgDialog.hpp"
#include "BitmapComboBox.hpp" #include "BitmapComboBox.hpp"
#include "PluginsConfigDialog.hpp"
#include "Widgets/Button.hpp"
// BBS // BBS
#include "Notebook.hpp" #include "Notebook.hpp"
@@ -1995,8 +1997,6 @@ void Choice::msw_rescale()
void PluginField::BUILD() void PluginField::BUILD()
{ {
// Wrap the dynamic rows in a single container panel so the field is a proper window-field
// (getWindow() != null). The panel owns m_main_sizer; all row controls are children of it.
auto* panel = new wxPanel(m_parent, wxID_ANY); auto* panel = new wxPanel(m_parent, wxID_ANY);
wxGetApp().UpdateDarkUI(panel); wxGetApp().UpdateDarkUI(panel);
window = panel; window = panel;
@@ -2004,7 +2004,6 @@ void PluginField::BUILD()
m_main_sizer = new wxBoxSizer(wxVERTICAL); m_main_sizer = new wxBoxSizer(wxVERTICAL);
panel->SetSizer(m_main_sizer); panel->SetSizer(m_main_sizer);
// Initialize with default values or empty
if (m_opt.type == coStrings) { if (m_opt.type == coStrings) {
const ConfigOptionStrings* vec = m_opt.get_default_value<ConfigOptionStrings>(); const ConfigOptionStrings* vec = m_opt.get_default_value<ConfigOptionStrings>();
if (vec != nullptr && !vec->values.empty()) { if (vec != nullptr && !vec->values.empty()) {
@@ -2095,13 +2094,11 @@ void PluginField::add_plugin_row(const wxString& value, bool is_last)
const auto button_size = wxSize(def_width_thinner() * m_em_unit, -1); const auto button_size = wxSize(def_width_thinner() * m_em_unit, -1);
auto row_sizer = new wxBoxSizer(wxHORIZONTAL); auto row_sizer = new wxBoxSizer(wxHORIZONTAL);
// Select button with search icon
ScalableButton* select_btn = new ScalableButton(window, wxID_ANY, "search", wxEmptyString, ScalableButton* select_btn = new ScalableButton(window, wxID_ANY, "search", wxEmptyString,
button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16);
wxGetApp().UpdateDarkUI(select_btn); wxGetApp().UpdateDarkUI(select_btn);
select_btn->SetToolTip(_L("Select plugin")); select_btn->SetToolTip(_L("Select plugin"));
// Display text control
wxTextCtrl* display = new wxTextCtrl(window, wxID_ANY, value, wxTextCtrl* display = new wxTextCtrl(window, wxID_ANY, value,
wxDefaultPosition, wxSize(def_width_wider() * m_em_unit, wxDefaultCoord), wxDefaultPosition, wxSize(def_width_wider() * m_em_unit, wxDefaultCoord),
wxTE_READONLY); wxTE_READONLY);
@@ -2117,7 +2114,6 @@ void PluginField::add_plugin_row(const wxString& value, bool is_last)
remove_btn->SetToolTip(_L("Remove plugin")); remove_btn->SetToolTip(_L("Remove plugin"));
} }
// Add button (only on last row)
ScalableButton* add_btn = nullptr; ScalableButton* add_btn = nullptr;
if (is_last && !m_opt.readonly) { if (is_last && !m_opt.readonly) {
add_btn = new ScalableButton(window, wxID_ANY, "param_add", wxEmptyString, add_btn = new ScalableButton(window, wxID_ANY, "param_add", wxEmptyString,
@@ -2238,7 +2234,6 @@ void PluginField::set_value(const boost::any& value, bool change_event)
{ {
m_disable_change_event = !change_event; m_disable_change_event = !change_event;
// Handle different input types
if (value.empty()) { if (value.empty()) {
m_values.clear(); m_values.clear();
} else if (value.type() == typeid(std::vector<std::string>)) { } else if (value.type() == typeid(std::vector<std::string>)) {
@@ -2309,6 +2304,126 @@ void PluginField::msw_rescale()
rebuild_ui(); rebuild_ui();
} }
namespace {
// The stored text as a document, or an empty array when it is absent or unparseable.
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()
{
m_button = new ::Button(m_parent, _L("Configure"));
// ButtonType::Parameter gives the button the same height as the parameter fields above it.
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;
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();
}
// Compare semantically: a round-trip through the serializer can reorder keys or drop whitespace,
// and that alone must not dirty the preset.
if (plugin_overrides_as_json(m_json) == plugin_overrides_as_json(edited))
return;
m_json = edited;
m_value = m_json;
update_button_label();
on_change_field();
}
void PluginConfigField::set_value(const boost::any& value, bool change_event)
{
m_disable_change_event = !change_event;
if (value.type() == typeid(wxString))
m_json = into_u8(boost::any_cast<wxString>(value));
else if (value.type() == typeid(std::string))
m_json = boost::any_cast<std::string>(value);
m_value = m_json;
update_button_label();
m_disable_change_event = false;
}
boost::any& PluginConfigField::get_value()
{
// std::string, not wxString: change_opt_value any_casts a coString to std::string.
m_value = m_json;
return m_value;
}
void PluginConfigField::enable()
{
if (m_button)
m_button->Enable();
}
void PluginConfigField::disable()
{
if (m_button)
m_button->Disable();
}
void PluginConfigField::msw_rescale()
{
Field::msw_rescale();
if (m_button == nullptr)
return;
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
m_button->SetMinSize(size);
}
void ColourPicker::BUILD() void ColourPicker::BUILD()
{ {
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width
+44 -3
View File
@@ -32,6 +32,9 @@
#define wxMSW false #define wxMSW false
#endif #endif
// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. It lives at global scope.
class Button;
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
class Field; class Field;
@@ -483,9 +486,8 @@ public:
void enable() override; void enable() override;
void disable() override; void disable() override;
// Window-field: the dynamic rows live inside a single container panel (assigned to the base // The rows live in one container panel (the base `window`), so the field exposes a window instead
// `window` in BUILD), so the field exposes one window instead of a bare sizer. This lets focus/ // of a bare sizer and focus, sizing and teardown apply to the whole field.
// scroll, full-width sizing and teardown operate on the whole field.
wxWindow* getWindow() override { return window; } wxWindow* getWindow() override { return window; }
void msw_rescale() override; void msw_rescale() override;
@@ -517,6 +519,45 @@ private:
std::function<std::string()> m_selector; std::function<std::string()> m_selector;
}; };
// A settings row whose value is a raw JSON document nobody types by hand: the button opens
// PluginsConfigDialog and the document it hands back becomes the field's value. The edit goes through
// the ordinary Field value/on_change_field path, so the row gets the same dirty state and revert arrow
// as any other setting — the dialog 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; set by the option group. 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;
// 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, collapsing the row.
wxWindow* getWindow() override { return window; }
void msw_rescale() override;
private:
void open_dialog();
void update_button_label();
wxWindow* window { nullptr }; // == m_button; the base class hands this to the option group
::Button* m_button { nullptr };
std::string m_json; // the option's raw text; "" when the preset overrides nothing
int m_preset_type { -1 };
};
class ColourPicker : public Field { class ColourPicker : public Field {
using Field::Field; using Field::Field;
+9 -3
View File
@@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
break; break;
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break; case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break; case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create<PluginConfigField>(this->ctrl_parent(), opt, id)); break;
default: default:
switch (opt.type) { switch (opt.type) {
case coFloatOrPercent: case coFloatOrPercent:
@@ -126,6 +127,12 @@ 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. Set
// here because fields are built lazily on activate() — too late for the Tab to reach in afterwards.
if (auto plugin_config_field = dynamic_cast<PluginConfigField*>(field.get()))
if (auto config_group = dynamic_cast<ConfigOptionsGroup*>(this))
plugin_config_field->set_preset_type(config_group->config_type());
// assign function objects for callbacks, etc. // assign function objects for callbacks, etc.
return field; return field;
} }
@@ -714,9 +721,8 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt)
if (selection.plugin_key.empty() || selection.name.empty()) if (selection.plugin_key.empty() || selection.name.empty())
return {}; return {};
// Validate that the selected capability's plugin is resolvable, but only the bare capability // Only the bare capability name is stored; the full "name;uuid;capability" reference is derived when
// name is stored in the option; the full "name;uuid;capability" reference is derived lazily // the preset is serialized (see ConfigBase::save_plugin_collection). Resolve here to reject bad picks.
// when the preset is serialized (see ConfigBase::save_plugin_collection).
Slic3r::PluginDescriptor descriptor; Slic3r::PluginDescriptor descriptor;
if (!manager.try_get_plugin_descriptor(selection.plugin_key, descriptor) || descriptor.name.empty()) if (!manager.try_get_plugin_descriptor(selection.plugin_key, descriptor) || descriptor.name.empty())
return {}; return {};
+221
View File
@@ -0,0 +1,221 @@
#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)
{
// On failure the document stays empty and every row goes read-only (see m_parse_error), 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() { m_alive->store(false, std::memory_order_release); }
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;
}
}
PluginCapabilityId 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;
// Defer command handling out of the webview script-message callback, exactly as PluginsDialog
// does: GTK and macOS deliver it synchronously inside the native webview callback, and window
// work on that stack is the crash class fixed in b779a7bfed/f2ccbfc8b5. remove_preset_override
// puts a modal message box on that stack, which is the same bug.
wxGetApp().CallAfter([this, alive = m_alive, payload]() {
if (alive->load(std::memory_order_acquire))
handle_web_command(payload);
});
}
void PluginsConfigDialog::handle_web_command(const nlohmann::json& payload)
{
const std::string command = payload.value("command", "");
if (command == "request_capabilities") {
send_capabilities();
return;
}
const PluginCapabilityId 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 == "remove_preset_override") {
// "Restore defaults" for a preset means holding no override at all: the capability falls back
// to the global configuration, not to the plugin's own get_default_config().
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 discards the preset's override and uses the global configuration."),
from_u8(id.name)),
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
if (rc != wxYES)
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["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 PluginCapabilityId& 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_plugin_capability(id, false);
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["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->config_ui_available()) {
try {
wxBusyCursor busy;
PythonGILState gil;
response["custom_html"] = cap->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 PluginCapabilityId& 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
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
#include <libslic3r/Preset.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <atomic>
#include <memory>
#include <string>
namespace Slic3r { namespace GUI {
// Lists the plugin capabilities the edited preset of `m_type` uses (see capabilities_in_use) and edits
// each one's config, falling back to the global config where the preset has no override.
//
// 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 owns the value and feeds it through the normal field/dirty pipeline.
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;
// Runs one web command on a clean main-loop stack; see on_script_message.
void handle_web_command(const nlohmann::json& payload);
const Preset* current_preset() const;
void send_capabilities();
void send_capability_config(const PluginCapabilityId& id);
void send_save_error(const PluginCapabilityId& id, const std::string& error);
void show_status(const wxString& message, const char* level);
PluginCapabilityId 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;
// Guards the deferred command handlers against the dialog being destroyed while one is queued.
std::shared_ptr<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true);
};
}} // namespace Slic3r::GUI
+117 -79
View File
@@ -4,6 +4,7 @@
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "I18N.hpp" #include "I18N.hpp"
#include "OrcaCloudServiceAgent.hpp" #include "OrcaCloudServiceAgent.hpp"
#include "slic3r/plugin/PluginConfig.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp" #include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PluginManager.hpp"
@@ -36,9 +37,9 @@
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
namespace { namespace {
const wxString kDeletePluginTitle = _L("Delete Plugin"); const wxString kDeletePluginTitle = _L("Delete Plugin");
const wxString kUnsubscribeTitle = _L("Unsubscribe"); const wxString kUnsubscribeTitle = _L("Unsubscribe");
const wxString kOverwritePluginTitle = _L("Overwrite Plugin"); const wxString kOverwritePluginTitle = _L("Overwrite Plugin");
std::string s_selected_plugin_install_action = "explore"; std::string s_selected_plugin_install_action = "explore";
struct PluginContextAction struct PluginContextAction
@@ -58,12 +59,13 @@ struct PluginAvailableActions
struct PluginCapabilityView struct PluginCapabilityView
{ {
std::string name; PluginCapabilityId id;
std::string type_label;
std::string type_key;
bool enabled = false; bool enabled = false;
bool can_toggle = false; bool can_toggle = false;
bool can_run = false; bool can_run = false;
// Whether the capability supplies its own config UI; every capability is configurable, this only
// picks the editor. False for descriptor-only rows: an unloaded plugin has no capabilities yet.
bool has_config_ui = false;
}; };
struct PluginChangelogView struct PluginChangelogView
@@ -76,7 +78,6 @@ struct PluginChangelogView
// View-model for one plugin row in the dialog // View-model for one plugin row in the dialog
struct PluginDialogItem struct PluginDialogItem
{ {
// Identity and display text
std::string plugin_key; std::string plugin_key;
std::string plugin_id; std::string plugin_id;
std::string display_name; std::string display_name;
@@ -85,7 +86,7 @@ struct PluginDialogItem
std::string version; std::string version;
std::string installed_version; std::string installed_version;
std::string latest_version; std::string latest_version;
std::string sort_version; // Version shown in the row (installed if installed, else latest); used by the Version sort. std::string sort_version; // Version shown in the row (installed if installed, else latest); used by the Version sort.
std::string type_label; std::string type_label;
std::string type_key; std::string type_key;
std::string sharing_token; std::string sharing_token;
@@ -93,16 +94,14 @@ struct PluginDialogItem
std::vector<std::string> type_labels; std::vector<std::string> type_labels;
std::vector<PluginChangelogView> changelog; std::vector<PluginChangelogView> changelog;
// Derived UI state
PluginSource source = PluginSource::Local; PluginSource source = PluginSource::Local;
PluginStatus status = PluginStatus::Inactive; PluginStatus status = PluginStatus::Inactive;
PluginUpdateStatus update_status = PluginUpdateStatus::Normal; PluginUpdateStatus update_status = PluginUpdateStatus::Normal;
std::string error_text; std::string error_text;
bool has_error = false; bool has_error = false;
bool is_loaded = false; bool is_loaded = false;
bool loading = false; bool loading = false;
// Installation and capability flags
bool is_cloud_plugin = false; bool is_cloud_plugin = false;
bool has_local_package = false; bool has_local_package = false;
bool unauthorized = false; bool unauthorized = false;
@@ -112,7 +111,6 @@ struct PluginDialogItem
// Runtime capabilities in registration order, or descriptor-only type rows when unloaded. // Runtime capabilities in registration order, or descriptor-only type rows when unloaded.
std::vector<PluginCapabilityView> capabilities; std::vector<PluginCapabilityView> capabilities;
// Row-level actions
PluginAvailableActions available_actions; PluginAvailableActions available_actions;
}; };
@@ -172,15 +170,13 @@ void refresh_plugin_metadata_blocking(bool fetch_cloud)
return; return;
for (const auto& uuid : not_found) for (const auto& uuid : not_found)
plater->get_notification_manager()->push_notification( plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel,
NotificationManager::NotificationLevel::RegularNotificationLevel, format(_L("Plugin %s is no longer available."), uuid));
format(_L("Plugin %s is no longer available."), uuid));
for (const auto& uuid : unauthorized) for (const auto& uuid : unauthorized)
plater->get_notification_manager()->push_notification( plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationType::CustomNotification, NotificationManager::NotificationLevel::RegularNotificationLevel,
NotificationManager::NotificationLevel::RegularNotificationLevel, format(_L("Plugin %s access is unauthorized."), uuid));
format(_L("Plugin %s access is unauthorized."), uuid));
}); });
} }
@@ -203,16 +199,26 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
nlohmann::json caps = nlohmann::json::array(); nlohmann::json caps = nlohmann::json::array();
for (const PluginCapabilityView& capability : dialog_item.capabilities) { for (const PluginCapabilityView& capability : dialog_item.capabilities) {
nlohmann::json c; nlohmann::json c;
c["name"] = capability.name; c["name"] = capability.id.name;
c["type"] = capability.type_label; c["type"] = plugin_capability_type_display_name(capability.id.type);
c["type_key"] = capability.type_key; c["type_key"] = plugin_capability_type_to_string(capability.id.type);
c["enabled"] = capability.enabled; c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle; c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run; c["can_run"] = capability.can_run;
c["has_config_ui"] = capability.has_config_ui;
caps.push_back(std::move(c)); caps.push_back(std::move(c));
} }
payload_item["capabilities"] = std::move(caps); payload_item["capabilities"] = std::move(caps);
// The Config tab's sidebar, built by the shared builder so it stays identical to PluginsConfigDialog's.
// Not the `capabilities` array above: that is the list tab's, with enable/run state and descriptor-only
// rows the config view has no use for.
std::vector<PluginCapabilityId> config_ids;
for (const PluginCapabilityView& capability : dialog_item.capabilities)
if (!capability.id.name.empty())
config_ids.push_back(capability.id);
payload_item["config_capabilities"] = PluginConfig::capabilities_payload(config_ids);
nlohmann::json changelog = nlohmann::json::array(); nlohmann::json changelog = nlohmann::json::array();
for (const PluginChangelogView& entry : dialog_item.changelog) { for (const PluginChangelogView& entry : dialog_item.changelog) {
nlohmann::json c; nlohmann::json c;
@@ -304,31 +310,28 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
PluginDialogItem item; PluginDialogItem item;
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
item.plugin_key = descriptor.plugin_key; item.plugin_key = descriptor.plugin_key;
item.display_name = descriptor.name; item.display_name = descriptor.name;
item.description = !descriptor.is_metadata_valid() && descriptor.has_error() ? item.description = !descriptor.is_metadata_valid() && descriptor.has_error() ?
descriptor.normalized_error() : descriptor.normalized_error() :
(descriptor.description.empty() ? "No description." : descriptor.description); (descriptor.description.empty() ? "No description." : descriptor.description);
item.author = descriptor.author; item.author = descriptor.author;
item.version = descriptor.version; item.version = descriptor.version;
// Installed version: the cloud merge overwrites `version` with the latest cloud version, so prefer // The cloud merge overwrites `version` with the latest cloud version, so prefer the preserved local
// the preserved local version, falling back to `version` for local-only / pre-merge descriptors. // one, falling back to `version` for local-only / pre-merge descriptors.
item.installed_version = descriptor.has_local_package() ? item.installed_version = descriptor.has_local_package() ?
(descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version) : (descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version) :
std::string{}; std::string{};
item.latest_version = descriptor.latest_available_version(); item.latest_version = descriptor.latest_available_version();
// why: sort by the same version the row displays (GetDisplayVersion in index.js) - installed when
// installed, otherwise latest - so the Version sort matches what the user sees.
item.sort_version = item.installed_version.empty() ? item.latest_version : item.installed_version; item.sort_version = item.installed_version.empty() ? item.latest_version : item.installed_version;
// A package that is not loaded has no capabilities, so its row contributes none and does not // A package that is not loaded has no capabilities, so its row contributes none and does not
// expand. // expand.
const std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities = const std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities =
manager.get_plugin_capabilities(descriptor.plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false); manager.get_plugin_capabilities(descriptor.plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
const PluginCapabilityType primary_type = capabilities.empty() ? PluginCapabilityType::Unknown : const PluginCapabilityType primary_type = capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type();
capabilities.front()->type(); item.type_label = plugin_capability_type_to_string(primary_type);
item.type_label = plugin_capability_type_to_string(primary_type); item.type_key = plugin_capability_type_to_string(primary_type);
item.type_key = plugin_capability_type_to_string(primary_type);
// "types" is the display-only compatibility list. Cloud plugins show the raw labels the // "types" is the display-only compatibility list. Cloud plugins show the raw labels the
// service returned (which may not map to real capability types); local plugins derive them // service returned (which may not map to real capability types); local plugins derive them
// from the capabilities actually loaded. // from the capabilities actually loaded.
@@ -356,8 +359,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.loading = manager.is_plugin_load_in_progress(descriptor.plugin_key); item.loading = manager.is_plugin_load_in_progress(descriptor.plugin_key);
item.has_script_capability = false; item.has_script_capability = false;
for (const auto& capability : capabilities) { for (const auto& capability : capabilities) {
item.capabilities.push_back({capability->name(), plugin_capability_type_display_name(capability->type()), item.capabilities.push_back({capability->identity(), capability->is_enabled(), true, false, capability->config_ui_available()});
plugin_capability_type_to_string(capability->type()), capability->is_enabled(), true, false});
if (capability->type() == PluginCapabilityType::Script) if (capability->type() == PluginCapabilityType::Script)
item.has_script_capability = true; item.has_script_capability = true;
} }
@@ -376,12 +378,12 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.available_actions = evaluate_action_policy(item); item.available_actions = evaluate_action_policy(item);
const bool has_enabled_script = std::any_of(item.capabilities.begin(), item.capabilities.end(), const bool has_enabled_script = std::any_of(item.capabilities.begin(), item.capabilities.end(),
[](const PluginCapabilityView& capability) { [](const PluginCapabilityView& capability) {
return capability.type_key == "script" && capability.enabled; return capability.id.type == PluginCapabilityType::Script && capability.enabled;
}); });
item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.is_loaded && item.can_run_script = descriptor.is_metadata_valid() && !descriptor.has_error() && item.has_script_capability && item.is_loaded &&
!item.loading && has_enabled_script; !item.loading && has_enabled_script;
for (PluginCapabilityView& capability : item.capabilities) { for (PluginCapabilityView& capability : item.capabilities) {
capability.can_run = item.can_run_script && capability.type_key == "script" && capability.enabled; capability.can_run = item.can_run_script && capability.id.type == PluginCapabilityType::Script && capability.enabled;
} }
return item; return item;
} }
@@ -479,8 +481,9 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
// Defer command handling out of the webview script-message callback: GTK and macOS // Defer command handling out of the webview script-message callback: GTK and macOS
// deliver it synchronously inside the native webview callback (see ui_create_window // deliver it synchronously inside the native webview callback (see ui_create_window
// in PluginHostUi.cpp), and window work on that stack is the crash class fixed in // in PluginHostUi.cpp), and window work on that stack is the crash class fixed in
// b779a7bfed/f2ccbfc8b5. Deferring at this single entry point keeps every command // b779a7bfed/f2ccbfc8b5. Deferring here keeps every command handler in THIS dialog off
// handler, current and future, off that stack by construction. // that stack; it is not a guarantee for other WebViewHostDialog subclasses, which each
// have to defer for themselves (PluginsConfigDialog does; others still do not).
wxGetApp().CallAfter([this, alive = m_alive, payload]() { wxGetApp().CallAfter([this, alive = m_alive, payload]() {
if (alive->load(std::memory_order_acquire)) if (alive->load(std::memory_order_acquire))
handle_web_command(payload); handle_web_command(payload);
@@ -515,6 +518,21 @@ void PluginsDialog::handle_web_command(const nlohmann::json& payload)
open_plugin_hub(); open_plugin_hub();
} else if (command == "set_plugin_sort") { } else if (command == "set_plugin_sort") {
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", "")); set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
} else if (command == "get_capability_config" || command == "save_capability_config" || command == "restore_capability_config") {
const PluginCapabilityId id{plugin_capability_type_from_string(payload.value("capability_type", "")),
payload.value("capability_name", ""), payload.value("plugin_key", "")};
if (command == "get_capability_config") {
send_capability_config(id);
return;
}
if (command == "restore_capability_config") {
restore_capability_config(id);
return;
}
// `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(id, payload.contains("config") ? payload.at("config") : nlohmann::json::object());
} else if (command == "set_plugin_install_action") { } else if (command == "set_plugin_install_action") {
const std::string action = payload.value("action", ""); const std::string action = payload.value("action", "");
if (action == "explore" || action == "install-local") if (action == "explore" || action == "install-local")
@@ -549,7 +567,6 @@ nlohmann::json PluginsDialog::build_plugins_payload() const
for (const PluginDescriptor& row : rows) for (const PluginDescriptor& row : rows)
items.push_back(build_plugin_dialog_item(row)); items.push_back(build_plugin_dialog_item(row));
// In-place sort
sort_plugin_items_for_dialog(items, m_plugin_sort_key, m_plugin_sort_order); sort_plugin_items_for_dialog(items, m_plugin_sort_key, m_plugin_sort_order);
for (const PluginDialogItem& item : items) for (const PluginDialogItem& item : items)
@@ -566,15 +583,6 @@ bool PluginsDialog::get_descriptor(const std::string& plugin_key, PluginDescript
return manager.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_invalid_package(); return manager.try_get_plugin_descriptor(plugin_key, descriptor) && descriptor.is_invalid_package();
} }
std::shared_ptr<PluginCapabilityInterface> PluginsDialog::get_capability(const std::string& plugin_key,
PluginCapabilityType type,
const std::string& capability_name) const
{
// only_enabled=false: this is an existence check used to gate both enabling and disabling a
// capability from the dialog, so a currently-disabled capability must still resolve.
return PluginManager::instance().get_plugin_capability(plugin_key, capability_name, type, /*only_enabled=*/false);
}
void PluginsDialog::refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud) void PluginsDialog::refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud)
{ {
run_with_dialog([fetch_cloud]() { refresh_plugin_metadata_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message); run_with_dialog([fetch_cloud]() { refresh_plugin_metadata_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message);
@@ -639,8 +647,7 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled)
} }
BOOST_LOG_TRIVIAL(info) << "Cloud plugin installed locally from Plugins dialog: " << plugin_key; BOOST_LOG_TRIVIAL(info) << "Cloud plugin installed locally from Plugins dialog: " << plugin_key;
// download_and_install_cloud_plugin updates the descriptor with the installed // download_and_install_cloud_plugin already updated the descriptor, so no rescan is needed here.
// local package state, so no rescan or cloud fetch is needed before loading.
if (!get_descriptor(plugin_key, row_data)) { if (!get_descriptor(plugin_key, row_data)) {
send_plugins(); send_plugins();
return; return;
@@ -678,8 +685,10 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled)
show_status(wxString::Format(_L("Activating \"%s\"..."), plugin_display_name(plugin_key)), "info"); show_status(wxString::Format(_L("Activating \"%s\"..."), plugin_display_name(plugin_key)), "info");
} }
void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, PluginCapabilityType type, void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key,
const std::string& capability_name, bool enabled) PluginCapabilityType type,
const std::string& capability_name,
bool enabled)
{ {
if (plugin_key.empty() || capability_name.empty() || type == PluginCapabilityType::Unknown) if (plugin_key.empty() || capability_name.empty() || type == PluginCapabilityType::Unknown)
return; return;
@@ -690,7 +699,7 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug
return; return;
} }
if (!get_capability(plugin_key, type, capability_name)) { if (!PluginManager::instance().get_plugin_capability({type, capability_name, plugin_key}, /*only_enabled=*/false)) {
BOOST_LOG_TRIVIAL(warning) << "Cannot toggle missing plugin capability: " << plugin_key << " | " << capability_name; BOOST_LOG_TRIVIAL(warning) << "Cannot toggle missing plugin capability: " << plugin_key << " | " << capability_name;
send_plugins(); send_plugins();
return; return;
@@ -699,11 +708,11 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
if (enabled) { if (enabled) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Enabling plugin capability: " << plugin_key << " | " << capability_name; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Enabling plugin capability: " << plugin_key << " | " << capability_name;
manager.set_capability_enabled(plugin_key, capability_name, true); manager.set_capability_enabled({type, capability_name, plugin_key}, true);
} else { } else {
// check if the capability is currently in use here. // check if the capability is currently in use here.
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Disabling plugin capability: " << plugin_key << " | " << capability_name; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Disabling plugin capability: " << plugin_key << " | " << capability_name;
manager.set_capability_enabled(plugin_key, capability_name, false); manager.set_capability_enabled({type, capability_name, plugin_key}, false);
} }
send_plugins(); send_plugins();
@@ -892,6 +901,35 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
return from_u8(plugin_key); return from_u8(plugin_key);
} }
void PluginsDialog::send_capability_config(const PluginCapabilityId& id) { call_web_handler(PluginConfig::get_config_response(id)); }
void PluginsDialog::save_capability_config(const PluginCapabilityId& id, const nlohmann::json& config)
{
const nlohmann::json response = PluginConfig::save_config_response(id, config);
call_web_handler(response);
if (response.value("ok", false))
show_status(_L("Configuration saved."), "success");
}
void PluginsDialog::restore_capability_config(const PluginCapabilityId& id)
{
// Destructive, so confirm first. The confirmation stays here rather than in PluginConfig: it needs
// a parent window.
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(id.name)),
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
if (rc != wxYES)
return;
const nlohmann::json response = PluginConfig::restore_config_response(id);
call_web_handler(response);
if (response.value("ok", false))
show_status(_L("Default configuration restored."), "success");
}
void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name) void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name)
{ {
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
@@ -909,8 +947,8 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
send_plugins(); send_plugins();
// The row now shows an "Error" status and the Diagnostics tab holds the full text, so surface // The row already shows "Error" and the Diagnostics tab holds the full text, so report in the
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override). // footer status bar instead of a modal box.
const wxString message = status_message.empty() ? from_u8(normalized_error) : status_message; const wxString message = status_message.empty() ? from_u8(normalized_error) : status_message;
show_status(message, "error"); show_status(message, "error");
}; };
@@ -936,6 +974,11 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
manager.clear_plugin_error(plugin_key); manager.clear_plugin_error(plugin_key);
send_plugins(); send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
} }
void PluginsDialog::update_plugin(const std::string& plugin_key) void PluginsDialog::update_plugin(const std::string& plugin_key)
@@ -948,15 +991,13 @@ void PluginsDialog::update_plugin(const std::string& plugin_key)
PluginDescriptor descriptor; PluginDescriptor descriptor;
const wxString name = get_descriptor(plugin_key, descriptor) ? from_u8(descriptor.name) : from_u8(plugin_key); const wxString name = get_descriptor(plugin_key, descriptor) ? from_u8(descriptor.name) : from_u8(plugin_key);
// update_cloud_plugin unloads the old plugin, deletes its local package, then downloads and // update_cloud_plugin unloads the old plugin, deletes its local package and reinstalls the latest
// reinstalls the latest version. Each of those steps already runs off the main thread in the // version; all of that is off-main-thread work, so run it on the worker behind a progress dialog.
// delete/install paths, so run the whole operation on the worker and pump a progress dialog.
std::string error; std::string error;
bool updated = false; bool updated = false;
try { try {
updated = run_with_dialog_wait( updated = run_with_dialog_wait([plugin_key, &error]() { return PluginManager::instance().update_cloud_plugin(plugin_key, error); },
[plugin_key, &error]() { return PluginManager::instance().update_cloud_plugin(plugin_key, error); }, _L("Updating plugin"), _L("Updating") + ": " + name);
_L("Updating plugin"), _L("Updating") + ": " + name);
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
error = ex.what(); error = ex.what();
} catch (...) { } catch (...) {
@@ -970,8 +1011,7 @@ void PluginsDialog::update_plugin(const std::string& plugin_key)
return; return;
} }
// update_cloud_plugin installs the package and updates the in-memory descriptor // update_cloud_plugin already updated the in-memory descriptor, so a UI refresh is enough here.
// (installed=true, update_available=false) on success.
send_plugins(); send_plugins();
show_status(wxString::Format(_L("Updated \"%s\"."), name), "success"); show_status(wxString::Format(_L("Updated \"%s\"."), name), "success");
} }
@@ -1099,8 +1139,7 @@ void PluginsDialog::reinstall_local_plugin(const std::string& plugin_key)
manager.load_plugin(plugin_key, false); manager.load_plugin(plugin_key, false);
std::string error; std::string error;
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || !manager.is_plugin_loaded(plugin_key))
!manager.is_plugin_loaded(plugin_key))
return {false, error.empty() ? "Plugin failed to load." : error}; return {false, error.empty() ? "Plugin failed to load." : error};
if (!was_loaded && !manager.unload_plugin(plugin_key)) if (!was_loaded && !manager.unload_plugin(plugin_key))
@@ -1132,7 +1171,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin)
return; return;
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
const bool was_loaded = PluginManager::instance().is_plugin_loaded(plugin_key); const bool was_loaded = PluginManager::instance().is_plugin_loaded(plugin_key);
std::string error; std::string error;
if (plugin.has_local_package()) { if (plugin.has_local_package()) {
@@ -1158,8 +1197,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin)
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
manager.load_plugin(plugin_key); manager.load_plugin(plugin_key);
std::string error; std::string error;
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || !manager.is_plugin_loaded(plugin_key))
!manager.is_plugin_loaded(plugin_key))
return {false, error.empty() ? "Plugin failed to load." : error}; return {false, error.empty() ? "Plugin failed to load." : error};
return {true, {}}; return {true, {}};
}, },
+8 -2
View File
@@ -29,6 +29,7 @@ class wxTimer;
namespace Slic3r { namespace Slic3r {
class PluginCapabilityInterface; class PluginCapabilityInterface;
struct PluginCapabilityId;
enum class PluginCapabilityType; enum class PluginCapabilityType;
namespace GUI { namespace GUI {
@@ -41,7 +42,7 @@ public:
const wxString& title = wxT(""), const wxString& title = wxT(""),
const wxPoint& pos = wxDefaultPosition, const wxPoint& pos = wxDefaultPosition,
const wxSize& size = wxDefaultSize, const wxSize& size = wxDefaultSize,
long style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX); long style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
~PluginsDialog(); ~PluginsDialog();
@@ -65,7 +66,6 @@ private:
nlohmann::json build_plugins_payload() const; nlohmann::json build_plugins_payload() const;
bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const; bool get_descriptor(const std::string& plugin_key, Slic3r::PluginDescriptor& descriptor) const;
std::shared_ptr<PluginCapabilityInterface> get_capability(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name) const;
void refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud); void refresh_plugin_metadata_async(const wxString& title, const wxString& message, bool fetch_cloud);
void refresh_plugins(); void refresh_plugins();
@@ -77,6 +77,12 @@ private:
bool install_plugin_package(const std::string& package_path); bool install_plugin_package(const std::string& package_path);
bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name); bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name);
void run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name); void run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name);
// Config tab. Both are scoped to the full capability ID: 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 PluginCapabilityId& id);
void save_capability_config(const PluginCapabilityId& id, const nlohmann::json& config);
void restore_capability_config(const PluginCapabilityId& id);
// Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"), // 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. // 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); void show_status(const wxString& message, const char* level);
-4
View File
@@ -270,10 +270,6 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
ctx.host = host; ctx.host = host;
ctx.output_name = output_name; ctx.output_name = output_name;
ctx.full_config = &config; // no live Print here; config_value() reads this ctx.full_config = &config; // no live Print here; config_value() reads this
// Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the
// capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp.
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.params = PluginManager::instance().get_plugin_settings(plugin_key);
ExecutionResult exec_result; ExecutionResult exec_result;
try { try {
+13 -3
View File
@@ -1793,9 +1793,8 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
return; return;
} }
// Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset // Keep this preset's "plugins" manifest in sync when a plugin picker changes, so full_config() and
// always carries resolved "name;uuid;capability" references that full_config() and save_to_json() // save_to_json() always find resolved "name;uuid;capability" references and rebuild it nowhere else.
// then pass downstream as-is -- no separate rebuild anywhere else.
if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key);
opt_def && opt_def->is_plugin_backed()) opt_def && opt_def->is_plugin_backed())
m_config->update_plugin_manifest(); m_config->update_plugin_manifest();
@@ -3123,6 +3122,11 @@ void TabPrint::build()
option.opt.full_width = true; option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker"); optgroup->append_single_option_line(option, "others_settings_plugin_picker");
// Its own group: the one above hides its labels, and this row needs its label — and the revert
// arrow beside it — to show. No label-width override either, as a 0 there means "no label column".
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
optgroup->append_single_option_line("plugin_config_overrides");
optgroup = page->new_optgroup(L("Notes"), "note", 0); optgroup = page->new_optgroup(L("Notes"), "note", 0);
option = optgroup->get_option("notes"); option = optgroup->get_option("notes");
option.opt.full_width = true; option.opt.full_width = true;
@@ -4524,6 +4528,9 @@ void TabFilament::build()
option.opt.height = gcode_field_height;// 150; option.opt.height = gcode_field_height;// 150;
optgroup->append_single_option_line(option); optgroup->append_single_option_line(option);
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
optgroup->append_single_option_line("plugin_config_overrides");
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower"); optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters"); optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
@@ -5030,6 +5037,9 @@ void TabPrinter::build_fff()
// optgroup->append_single_option_line("spaghetti_detector"); // optgroup->append_single_option_line("spaghetti_detector");
optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost"); optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
optgroup->append_single_option_line("plugin_config_overrides");
optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan"); optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan");
Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip }; Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };
line.label_path = "printer_basic_information_cooling_fan#fan-speed-up-time"; line.label_path = "printer_basic_information_cooling_fan#fan-speed-up-time";
+3 -1
View File
@@ -21,7 +21,9 @@ public:
const wxString& title = wxT(""), const wxString& title = wxT(""),
const wxPoint& pos = wxDefaultPosition, const wxPoint& pos = wxDefaultPosition,
const wxSize& size = wxDefaultSize, const wxSize& size = wxDefaultSize,
long style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX); // wxRESIZE_BORDER is required for a resizable frame on MSW/GTK; macOS derives
// one from wxMAXIMIZE_BOX alone, which is why these dialogs used to resize only there.
long style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
~WebViewHostDialog() override = default; ~WebViewHostDialog() override = default;
bool create_webview(const std::string& resource_path, bool create_webview(const std::string& resource_path,
+3 -3
View File
@@ -238,8 +238,8 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
{ {
PluginManager& plugin_manager = PluginManager::instance(); PluginManager& plugin_manager = PluginManager::instance();
auto cap = plugin_manager.get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::PrinterConnection, auto cap = plugin_manager.get_plugin_capability({PluginCapabilityType::PrinterConnection, capability_name, plugin_key},
/*only_enabled=*/false); /*only_enabled=*/false);
if (!cap) { if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Printer-agent capability '" << capability_name << "' not found for plugin '" << plugin_key << "'"; BOOST_LOG_TRIVIAL(warning) << "Printer-agent capability '" << capability_name << "' not found for plugin '" << plugin_key << "'";
return; return;
@@ -305,7 +305,7 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
// Re-resolve without holding the registry mutex to avoid lock inversion and reentrancy. // Re-resolve without holding the registry mutex to avoid lock inversion and reentrancy.
// only_enabled defaults to true here, so a capability that changed identity (reload) or was // only_enabled defaults to true here, so a capability that changed identity (reload) or was
// merely disabled both surface the same way: current_cap no longer equals cap. // merely disabled both surface the same way: current_cap no longer equals cap.
auto current_cap = plugin_manager.get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::PrinterConnection); auto current_cap = plugin_manager.get_plugin_capability({PluginCapabilityType::PrinterConnection, capability_name, plugin_key});
if (current_cap != cap) { if (current_cap != cap) {
BOOST_LOG_TRIVIAL(debug) << "Printer-agent capability '" << capability_name << "' from plugin '" << plugin_key BOOST_LOG_TRIVIAL(debug) << "Printer-agent capability '" << capability_name << "' from plugin '" << plugin_key
<< "' changed or was disabled during registration"; << "' changed or was disabled during registration";
+13 -10
View File
@@ -3094,6 +3094,17 @@ static bool cloud_media_is_image(const nlohmann::json& main_image)
return lowered(get_json_string_field(main_image, "media_type")) == "image"; return lowered(get_json_string_field(main_image, "media_type")) == "image";
} }
// creator_display_name degrades to the creator's username and then to their raw user id when the
// cloud cannot resolve a profile, so a bare id is dropped rather than shown: the author then falls
// back to the one the plugin's own manifest declares (see apply_plugin_metadata_fallbacks).
static std::string parse_cloud_author(const nlohmann::json& item)
{
std::string author = get_json_string_field(item, "creator_display_name");
if (author.empty() || is_uuid(author))
author = get_json_string_field(item, "creator_username");
return is_uuid(author) ? std::string() : author;
}
int OrcaCloudServiceAgent::fetch_subscribed_manifests_into_descriptors(std::vector<PluginDescriptor>& descriptors, int OrcaCloudServiceAgent::fetch_subscribed_manifests_into_descriptors(std::vector<PluginDescriptor>& descriptors,
std::vector<std::string>& not_found, std::vector<std::string>& not_found,
std::vector<std::string>& unauthorized) std::vector<std::string>& unauthorized)
@@ -3137,11 +3148,7 @@ int OrcaCloudServiceAgent::fetch_subscribed_manifests_into_descriptors(std::vect
descriptor.plugin_key = uuid; descriptor.plugin_key = uuid;
// Cloud API "description" is intentionally not parsed: descriptions come only from the // Cloud API "description" is intentionally not parsed: descriptions come only from the
// plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link. // plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link.
descriptor.author = get_json_string_field(item, "author"); descriptor.author = parse_cloud_author(item);
if (descriptor.author.empty())
descriptor.author = get_json_string_field(item, "creator_display_name");
if (descriptor.author.empty())
descriptor.author = get_json_string_field(item, "creator_username");
descriptor.version = get_json_string_field(item, "version"); descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version; descriptor.latest_version = descriptor.version;
// Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are // Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are
@@ -3239,11 +3246,7 @@ int OrcaCloudServiceAgent::fetch_mine_manifests_into_descriptors(std::vector<Plu
descriptor.plugin_key = uuid; descriptor.plugin_key = uuid;
// Cloud API "description" is intentionally not parsed: descriptions come only from the // Cloud API "description" is intentionally not parsed: descriptions come only from the
// plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link. // plugin's Python header once installed. Cloud-only rows show a "View on OrcaCloud" link.
descriptor.author = get_json_string_field(item, "author"); descriptor.author = parse_cloud_author(item);
if (descriptor.author.empty())
descriptor.author = get_json_string_field(item, "creator_display_name");
if (descriptor.author.empty())
descriptor.author = get_json_string_field(item, "creator_username");
descriptor.version = get_json_string_field(item, "version"); descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version; descriptor.latest_version = descriptor.version;
// Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are // Cloud "type" is cosmetic (see parse_cloud_display_types); real capability_types are
+13 -1
View File
@@ -77,17 +77,22 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
thread_local std::string PluginAuditManager::m_current_plugin_key = ""; 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 PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading;
thread_local std::vector<boost::filesystem::path> PluginAuditManager::m_scoped_allowed_roots; thread_local std::vector<boost::filesystem::path> PluginAuditManager::m_scoped_allowed_roots;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; 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_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_mode(PluginAuditManager::instance().audit_mode()) , m_previous_mode(PluginAuditManager::instance().audit_mode())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
{ {
PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::instance().set_audit_mode(mode); PluginAuditManager::instance().set_audit_mode(mode);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
} }
@@ -95,6 +100,7 @@ ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key
ScopedPluginAuditContext::~ScopedPluginAuditContext() ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); 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::instance().set_audit_mode(m_previous_mode);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
} }
@@ -115,6 +121,12 @@ std::string PluginAuditManager::current_plugin() const { return m_current_plugin
void PluginAuditManager::clear_current_plugin() { m_current_plugin_key.clear(); } 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 boost::filesystem::path& root) void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root)
{ {
if (root.empty()) if (root.empty())
+14 -1
View File
@@ -40,6 +40,14 @@ public:
std::string current_plugin() const; std::string current_plugin() const;
void clear_current_plugin(); 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 --- // --- allowed-roots registry ---
void add_global_allowed_root(const boost::filesystem::path& root); void add_global_allowed_root(const boost::filesystem::path& root);
void add_scoped_allowed_root(const boost::filesystem::path& root); void add_scoped_allowed_root(const boost::filesystem::path& root);
@@ -76,6 +84,7 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data); 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_plugin_key;
static thread_local std::string m_current_capability_name;
static thread_local AuditMode m_audit_mode; static thread_local AuditMode m_audit_mode;
static thread_local std::vector<boost::filesystem::path> m_scoped_allowed_roots; static thread_local std::vector<boost::filesystem::path> m_scoped_allowed_roots;
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
@@ -85,12 +94,15 @@ private:
std::vector<boost::filesystem::path> m_global_allowed_roots; std::vector<boost::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 class ScopedPluginAuditContext
{ {
public: public:
explicit ScopedPluginAuditContext( explicit ScopedPluginAuditContext(
const std::string& plugin_key, const std::string& plugin_key,
const std::string& capability_name = {},
PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading); PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading);
~ScopedPluginAuditContext(); ~ScopedPluginAuditContext();
@@ -100,6 +112,7 @@ public:
private: private:
std::string m_previous_id; std::string m_previous_id;
std::string m_previous_capability;
PluginAuditManager::AuditMode m_previous_mode; PluginAuditManager::AuditMode m_previous_mode;
std::vector<boost::filesystem::path> m_previous_scoped_roots; std::vector<boost::filesystem::path> m_previous_scoped_roots;
}; };
+649
View File
@@ -0,0 +1,649 @@
#include "PluginConfig.hpp"
#include <algorithm>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/PrintConfig.hpp>
#include <slic3r/GUI/GUI.hpp>
#include <slic3r/GUI/GUI_App.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/app.h>
#include <wx/utils.h>
namespace Slic3r {
namespace {
constexpr const char* KEY_PLUGIN = "plugin_key";
constexpr const char* KEY_CAPABILITY = "capability";
constexpr const char* KEY_TYPE = "capability_type";
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, PluginCapabilityId& id)
{
if (!entry.is_object())
return false;
id.plugin_key = string_field(entry, KEY_PLUGIN);
id.name = string_field(entry, KEY_CAPABILITY);
id.type = plugin_capability_type_from_string(string_field(entry, KEY_TYPE));
return !id.empty();
}
CapabilityConfigEntry decode_entry(const PluginCapabilityId& 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.config = cap_it != entry.end() ? *cap_it : nlohmann::json::object();
return result;
}
// PluginDescriptor::version is overwritten with the latest cloud version on a cloud merge, 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().try_get_valid_plugin_descriptor(plugin_key, descriptor))
return {};
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
}
// Null wherever the plugin host runs without the GUI app (the unit tests). wxGetApp() dereferences
// the app unconditionally, so ask wxWidgets instead.
const PresetBundle* active_preset_bundle()
{
const auto* app = dynamic_cast<const GUI::GUI_App*>(wxApp::GetInstance());
return app == nullptr ? nullptr : app->preset_bundle;
}
// 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: refuse rather than read or clobber a wrong entry.
PluginCapabilityId capability_identity(const PluginCapabilityInterface& capability, const char* api_name)
{
const PluginCapabilityId id = capability.identity();
if (id.empty())
throw std::runtime_error(std::string(api_name) + "() is only available on a capability loaded by the plugin host");
return id;
}
} // namespace
CapabilityConfigDocument CapabilityConfigDocument::from_entries(const nlohmann::json& entries)
{
CapabilityConfigDocument document;
if (!entries.is_array())
return document;
for (const nlohmann::json& entry : entries) {
PluginCapabilityId id;
if (is_recognized_entry(entry, id) || (!id.plugin_key.empty() && !id.name.empty()))
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 PluginCapabilityId& id) const
{
const auto it = m_entries.find(id);
if (it != m_entries.end())
return decode_entry(it->first, it->second);
// Legacy config.json entries have no capability type. Keep them addressable by the new
// typed API until that capability is saved again.
if (id.type != PluginCapabilityType::Unknown) {
const auto legacy = m_entries.find({PluginCapabilityType::Unknown, id.name, id.plugin_key});
if (legacy != m_entries.end())
return decode_entry(id, legacy->second);
}
return std::nullopt;
}
bool CapabilityConfigDocument::contains(const PluginCapabilityId& id) const
{
return find(id).has_value();
}
bool CapabilityConfigDocument::upsert(CapabilityConfigEntry entry)
{
if (entry.id.empty())
return false;
if (entry.id.type != PluginCapabilityType::Unknown)
m_entries.erase({PluginCapabilityType::Unknown, entry.id.name, entry.id.plugin_key});
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.name;
serialized[KEY_TYPE] = plugin_capability_type_to_string(entry.id.type);
serialized[KEY_VERSION] = entry.plugin_version;
serialized[KEY_CAP_CONFIG] = entry.config;
m_entries[entry.id] = std::move(serialized);
return true;
}
bool CapabilityConfigDocument::erase(const PluginCapabilityId& id)
{
bool erased = m_entries.erase(id) != 0;
if (id.type != PluginCapabilityType::Unknown)
erased = m_entries.erase({PluginCapabilityType::Unknown, id.name, id.plugin_key}) != 0 || erased;
return erased;
}
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;
}
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;
}
bool PluginConfig::store_capability_config(const PluginCapabilityId& id, const nlohmann::json& config)
{
if (id.empty())
return false;
save_config({id, running_plugin_version(id.plugin_key), config});
return save();
}
void PluginConfig::save_config(const CapabilityConfigEntry& config)
{
if (config.id.empty()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: refusing to store a config without a complete capability identity";
return;
}
std::lock_guard<std::mutex> lock(m_mutex);
m_dirty = m_document.upsert(config) || m_dirty;
}
bool PluginConfig::erase_capability_config(const PluginCapabilityId& id)
{
if (id.empty())
return false;
{
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_document.erase(id))
return true;
m_dirty = true;
}
return save();
}
std::optional<CapabilityConfigEntry> PluginConfig::get_config(const PluginCapabilityId& id) const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_document.find(id);
}
bool PluginConfig::has_config(const PluginCapabilityId& id) const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_document.contains(id);
}
bool PluginConfig::dirty() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_dirty;
}
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();
}
EffectiveCapabilityConfig PresetPluginConfigService::get_effective_config(const CapabilityConfigDocument& overrides,
const PluginCapabilityId& id) const
{
EffectiveCapabilityConfig result;
result.id = id;
result.running_plugin_version = running_plugin_version(id.plugin_key);
const auto base = PluginManager::instance().get_config().get_config(id);
result.has_base_config = base.has_value();
if (const auto entry = overrides.find(result.id)) {
result.has_preset_override = true;
result.config = entry->config;
result.stored_plugin_version = entry->plugin_version;
return result;
}
if (result.has_base_config) {
result.config = base->config;
result.stored_plugin_version = base->plugin_version;
}
return result;
}
MutationResult PresetPluginConfigService::set_preset_override(CapabilityConfigDocument& overrides,
const PluginCapabilityId& id,
const nlohmann::json& value) const
{
MutationResult result;
const std::string version = running_plugin_version(id.plugin_key);
// A no-op is a successful unchanged result: re-saving the displayed value must not mark the
// preset dirty.
const auto existing = overrides.find(id);
if (existing && existing->config == value && existing->plugin_version == version) {
result.ok = true;
result.effective = get_effective_config(overrides, id);
return result;
}
overrides.upsert({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 PluginCapabilityId& id) const
{
MutationResult result;
result.ok = true;
result.changed = overrides.erase(id);
result.effective = get_effective_config(overrides, id);
return result;
}
EffectiveCapabilityConfig active_capability_config(const PluginCapabilityId& id)
{
const PresetPluginConfigService service;
CapabilityConfigDocument overrides;
const PresetBundle* bundle = active_preset_bundle();
const Preset* preset = nullptr;
if (bundle != nullptr) {
const std::string type_key = plugin_capability_type_to_string(id.type);
for (const auto& [key, def] : print_config_def.options) {
if (def.plugin_type != type_key)
continue;
const auto& print_options = Preset::print_options();
if (std::find(print_options.begin(), print_options.end(), key) != print_options.end()) {
preset = &bundle->prints.get_edited_preset();
break;
}
const auto& printer_options = Preset::printer_options();
if (std::find(printer_options.begin(), printer_options.end(), key) != printer_options.end()) {
preset = &bundle->printers.get_edited_preset();
break;
}
}
}
if (preset != nullptr) {
std::string error;
if (!parse_plugin_overrides(plugin_overrides_of(*preset), overrides, error)) {
// Text we cannot read is not an override: log it and resolve against the base config.
BOOST_LOG_TRIVIAL(error) << "Preset \"" << preset->name << "\": " << error;
overrides = CapabilityConfigDocument();
}
}
return service.get_effective_config(overrides, id);
}
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability)
{
// Shares its resolution with the dialogs, so a capability reads back exactly the config its UI
// showed as effective. Stored in neither layer: an empty object, indexable unconditionally.
return active_capability_config(capability_identity(capability, "get_config")).config;
}
std::string capability_get_config_version(const PluginCapabilityInterface& capability)
{
// Must resolve through the same layer as get_config(), or a plugin would migrate one layer's
// config by another's version stamp.
return active_capability_config(capability_identity(capability, "get_config_version")).stored_plugin_version;
}
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config)
{
return PluginManager::instance().get_config().store_capability_config(capability_identity(capability, "save_config"), config);
}
nlohmann::json PluginConfig::capabilities_payload(const std::vector<PluginCapabilityId>& caps)
{
nlohmann::json payload = nlohmann::json::array();
for (const PluginCapabilityId& id : caps) {
// A capability unloaded since the list was built has nothing to configure.
const auto capability = PluginManager::instance().get_plugin_capability(id, false);
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->config_ui_available();
payload.push_back(std::move(entry));
}
return payload;
}
// 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 PluginCapabilityId& 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 misses rather than reading a different plugin's config.
const auto cap = PluginManager::instance().get_plugin_capability(id, false);
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;
}
if (const auto stored = PluginManager::instance().get_config().get_config(id))
response["config"] = stored->config;
if (cap->config_ui_available()) {
// A raising or empty get_config_ui() costs the capability only its custom UI: report the
// failure and let the page fall back to the default JSON editor over the same stored config.
std::string html;
std::string error;
{
wxBusyCursor busy;
try {
PythonGILState gil;
html = cap->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 PluginCapabilityId& 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_plugin_capability(id, false);
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 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, 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 back what was persisted, not what the user typed, so the editor reloads from the store.
response["ok"] = true;
if (const auto stored = PluginManager::instance().get_config().get_config(id))
response["config"] = stored->config;
return response;
}
// The host never invents the default: a capability that does not override get_default_config()
// restores an empty config, which is right for one that applies its own defaults on read.
nlohmann::json PluginConfig::restore_config_response(const PluginCapabilityId& 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_plugin_capability(id, false);
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->get_default_config();
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
error = "Unknown error";
}
}
// A raising hook leaves the stored config 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, 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;
response["ok"] = true;
if (const auto stored = PluginManager::instance().get_config().get_config(id))
response["config"] = stored->config;
return response;
}
} // namespace Slic3r
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
#define PLUGIN_CONFIG_DIR "config.json"
namespace Slic3r {
class Preset;
struct CapabilityConfigEntry
{
PluginCapabilityId id;
std::string plugin_version;
nlohmann::json 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 PluginCapabilityId& id) const;
bool contains(const PluginCapabilityId& id) const;
bool upsert(CapabilityConfigEntry entry);
bool erase(const PluginCapabilityId& id);
bool empty() const;
nlohmann::json serialize_entries() const;
nlohmann::json root_json() const;
private:
std::map<PluginCapabilityId, nlohmann::json> m_entries;
std::vector<nlohmann::json> m_opaque_entries;
};
inline constexpr const char* PLUGIN_OVERRIDES_OPTION_KEY = "plugin_config_overrides";
std::string plugin_overrides_of(const Preset& preset);
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error);
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document);
struct EffectiveCapabilityConfig
{
PluginCapabilityId id;
nlohmann::json config = nlohmann::json::object();
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;
};
class PresetPluginConfigService
{
public:
EffectiveCapabilityConfig get_effective_config(const CapabilityConfigDocument& overrides,
const PluginCapabilityId& id) const;
MutationResult set_preset_override(CapabilityConfigDocument& overrides,
const PluginCapabilityId& id,
const nlohmann::json& value) const;
MutationResult remove_preset_override(CapabilityConfigDocument& overrides,
const PluginCapabilityId& id) const;
};
EffectiveCapabilityConfig active_capability_config(const PluginCapabilityId& id);
class PluginConfig
{
public:
static const std::string plugin_config_file() { return (boost::filesystem::path(get_orca_plugins_dir()) / PLUGIN_CONFIG_DIR).string(); }
void load();
bool save();
void save_config(const CapabilityConfigEntry& config);
bool store_capability_config(const PluginCapabilityId& id, const nlohmann::json& config);
bool erase_capability_config(const PluginCapabilityId& id);
std::optional<CapabilityConfigEntry> get_config(const PluginCapabilityId& id) const;
bool has_config(const PluginCapabilityId& id) const;
bool dirty() const;
static nlohmann::json capabilities_payload(const std::vector<PluginCapabilityId>& caps);
static nlohmann::json get_config_response(const PluginCapabilityId& id);
static nlohmann::json save_config_response(const PluginCapabilityId& id, const nlohmann::json& config);
static nlohmann::json restore_config_response(const PluginCapabilityId& id);
private:
mutable std::mutex m_mutex;
CapabilityConfigDocument m_document;
bool m_dirty = false;
};
nlohmann::json capability_get_config(const PluginCapabilityInterface& capability);
std::string capability_get_config_version(const PluginCapabilityInterface& capability);
bool capability_save_config(const PluginCapabilityInterface& capability, const nlohmann::json& config);
} // namespace Slic3r
-7
View File
@@ -61,7 +61,6 @@ struct PluginDescriptor
std::string entry_path; // Full path to the installed plugin entry file std::string entry_path; // Full path to the installed plugin entry file
std::string entry_package; // Import package/module used for package-based loading std::string entry_package; // Import package/module used for package-based loading
std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata
std::map<std::string, std::string> settings; // [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params)
std::vector<PluginChangelog> changelog; // Cloud release changelog, sorted newest-first when available. std::vector<PluginChangelog> changelog; // Cloud release changelog, sorted newest-first when available.
std::string error; // Blocking error message. Non-empty means the plugin is in an error state. std::string error; // Blocking error message. Non-empty means the plugin is in an error state.
@@ -157,12 +156,6 @@ inline void apply_plugin_metadata_fallbacks(PluginDescriptor& target, const Plug
target.entry_package = fallback.entry_package; target.entry_package = fallback.entry_package;
if (target.dependencies.empty()) if (target.dependencies.empty())
target.dependencies = fallback.dependencies; target.dependencies = fallback.dependencies;
// [tool.orcaslicer.plugin.settings] lives only in the local package's PEP-723 header;
// cloud catalog records never carry it. Without this, every cloud-metadata merge wipes
// the parsed settings and plugins silently run on their built-in defaults (ctx.params
// arrives empty).
if (target.settings.empty())
target.settings = fallback.settings;
} }
// Sanitize a value for use as a filesystem name and as a local plugin_key: // Sanitize a value for use as a filesystem name and as a local plugin_key:
+13 -14
View File
@@ -31,10 +31,16 @@ namespace Slic3r {
const char* const INSTALL_STATE_FILE = ".install_state.json"; 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) std::string get_cloud_plugin_dir(const std::string& user_id)
{ {
namespace fs = boost::filesystem; 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) boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor)
@@ -48,8 +54,7 @@ boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescript
return {}; return {};
} }
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, const std::vector<std::string>& allowed_dirs)
const std::vector<std::string>& allowed_dirs)
{ {
boost::system::error_code ec; boost::system::error_code ec;
boost::filesystem::path resolved_root = boost::filesystem::weakly_canonical(candidate_root, ec); boost::filesystem::path resolved_root = boost::filesystem::weakly_canonical(candidate_root, ec);
@@ -103,9 +108,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
return true; return true;
} }
bool delete_plugin_root(const boost::filesystem::path& resolved_root, bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std::string& plugin_id, std::string& error)
const std::string& plugin_id,
std::string& error)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -432,7 +435,6 @@ bool parse_pep723_toml(const std::string& toml_content,
std::string& out_description, std::string& out_description,
std::string& out_author, std::string& out_author,
std::string& out_version, std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error) std::string& error)
{ {
out_deps.clear(); out_deps.clear();
@@ -441,7 +443,6 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear(); out_description.clear();
out_author.clear(); out_author.clear();
out_version.clear(); out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root; TomlSection section = TomlSection::Root;
@@ -466,7 +467,10 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed == "[tool.orcaslicer.plugin]") { if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin; section = TomlSection::OrcaPlugin;
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") { } else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
section = TomlSection::OrcaPluginSettings; // per-plugin params table // Legacy table, superseded by PluginConfig. Recognized so its keys are ignored
// rather than falling through to Root, where a stray dependencies/requires-python
// key inside it would be parsed as package metadata.
section = TomlSection::OrcaPluginSettings;
} else { } else {
section = TomlSection::Root; // Unknown section — skip. section = TomlSection::Root; // Unknown section — skip.
} }
@@ -519,10 +523,6 @@ bool parse_pep723_toml(const std::string& toml_content,
else if (key == "description") out_description = unquote_toml_string(val); else if (key == "description") out_description = unquote_toml_string(val);
else if (key == "author") out_author = unquote_toml_string(val); else if (key == "author") out_author = unquote_toml_string(val);
else if (key == "version") out_version = unquote_toml_string(val); else if (key == "version") out_version = unquote_toml_string(val);
} else if (section == TomlSection::OrcaPluginSettings) {
// collect every key as a string; the plugin parses (int/float/...) what it needs.
if (!key.empty())
out_settings[key] = unquote_toml_string(val);
} }
} }
@@ -920,7 +920,6 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD
pep_desc, pep_desc,
pep_author, pep_author,
pep_version, pep_version,
descriptor.settings,
pep723_error)) { pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error; error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false; return false;
+70
View File
@@ -2,8 +2,12 @@
#include "PluginDescriptor.hpp" #include "PluginDescriptor.hpp"
#include <nlohmann/json.hpp>
#include <pybind11/pybind11.h>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <cstdint>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -13,6 +17,70 @@ namespace Slic3r {
extern const char* const INSTALL_STATE_FILE; extern const char* const INSTALL_STATE_FILE;
// 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()
}
struct PluginInstallState { struct PluginInstallState {
std::string installed_from; // "local" | "cloud" std::string installed_from; // "local" | "cloud"
std::string installed_version; std::string installed_version;
@@ -26,6 +94,8 @@ struct PluginInstallState {
// Path: {data_dir}/orca_plugins/_subscribed/{user_id}/ // Path: {data_dir}/orca_plugins/_subscribed/{user_id}/
std::string get_cloud_plugin_dir(const std::string& 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); boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor);
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
-6
View File
@@ -70,9 +70,6 @@ void install_slicing_pipeline_hook()
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ExecutionResult r; ExecutionResult r;
try { try {
// Read manager state before acquiring the GIL so this path does not take
// m_mutex in the opposite order to plugin teardown.
const auto plugin_settings = PluginManager::instance().get_plugin_settings(plugin_key);
// GIL is acquired per capability (not once for the whole dispatch) so it // GIL is acquired per capability (not once for the whole dispatch) so it
// is released between capabilities. // is released between capabilities.
PythonGILState gil; PythonGILState gil;
@@ -87,9 +84,6 @@ void install_slicing_pipeline_hook()
ctx.step = step; ctx.step = step;
ctx.print = &print; ctx.print = &print;
ctx.object = object; ctx.object = object;
// hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params
// (same plugin_key the capability was resolved by, so it always matches).
ctx.params = plugin_settings;
r = cap->execute(ctx); r = cap->execute(ctx);
} catch (const CanceledException&) { } catch (const CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error throw; // cancellation must reach process(), never become a slicing error
+13
View File
@@ -347,6 +347,19 @@ bool load(const PluginDescriptor& descriptor,
instance->set_resolved_identity(found.name, type); instance->set_resolved_identity(found.name, type);
instance->set_enabled(enabled); instance->set_enabled(enabled);
// Cache has_config_ui() once, under this same GIL, so the GUI can pick the
// capability's custom UI vs. the host JSON editor without touching Python. It is
// optional and plugin-authored: a raising or non-bool override only costs this
// capability its custom UI, so it is caught locally rather than failing the load.
try {
instance->set_config_ui_available(instance->has_config_ui());
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': has_config_ui() failed (" << ex.what() << "); falling back to the default JSON editor";
instance->set_config_ui_available(false);
}
capabilities.push_back(std::move(instance)); capabilities.push_back(std::move(instance));
} }
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
+41 -26
View File
@@ -1,5 +1,6 @@
#include "PluginManager.hpp" #include "PluginManager.hpp"
#include <libslic3r/Utils.hpp>
#include <memory> #include <memory>
#include <pybind11/embed.h> #include <pybind11/embed.h>
@@ -19,6 +20,7 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <mutex> #include <mutex>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginLoader.hpp> #include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp> #include <slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp>
@@ -83,6 +85,12 @@ bool PluginManager::initialize()
m_initialized = true; 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). // Install the libslic3r hooks (capability resolver, slicing-pipeline dispatcher).
// Uninstalled in shutdown() before the interpreter finalizes. // Uninstalled in shutdown() before the interpreter finalizes.
plugin_hooks::install(); plugin_hooks::install();
@@ -142,6 +150,12 @@ void PluginManager::shutdown()
unload_all_plugins(); unload_all_plugins();
PythonPluginBridge::instance().clear_pending_captures(); 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();
// Drop the lifecycle subscriptions taken out during initialize(). Without this a second // Drop the lifecycle subscriptions taken out during initialize(). Without this a second
// initialize() in the same process re-subscribes the same callbacks on top of the old ones, // initialize() in the same process re-subscribes the same callbacks on top of the old ones,
// and every load would then write the install-state sidecar once per duplicate. // and every load would then write the install-state sidecar once per duplicate.
@@ -496,21 +510,18 @@ std::vector<std::shared_ptr<PluginCapabilityInterface>> PluginManager::get_plugi
return result; return result;
} }
std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(const std::string& plugin_key, std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(const PluginCapabilityId& id, bool only_enabled) const
const std::string& capability_name,
PluginCapabilityType type,
bool only_enabled) const
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
const Plugin* plugin = find_plugin_locked(plugin_key); const Plugin* plugin = find_plugin_locked(id.plugin_key);
if (plugin == nullptr) if (plugin == nullptr)
return nullptr; return nullptr;
for (const auto& capability : plugin->capabilities) { for (const auto& capability : plugin->capabilities) {
if (!capability || capability->name() != capability_name) if (!capability || capability->name() != id.name)
continue; continue;
if (type != PluginCapabilityType::Unknown && capability->type() != type) if (id.type != PluginCapabilityType::Unknown && capability->type() != id.type)
continue; continue;
if (only_enabled && !capability->is_enabled()) if (only_enabled && !capability->is_enabled())
continue; continue;
@@ -541,17 +552,6 @@ std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(
return nullptr; return nullptr;
} }
std::map<std::string, std::string> PluginManager::get_plugin_settings(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const Plugin* plugin = find_plugin_locked(plugin_key);
if (plugin == nullptr || !plugin->is_loaded())
return {};
return plugin->descriptor.settings;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────────────────────────────────────
bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const
@@ -736,7 +736,7 @@ void PluginManager::load_plugin(const std::string& plugin_key, bool skip_deps, s
for (const std::string& capability_name : capabilities_to_enable) { for (const std::string& capability_name : capabilities_to_enable) {
if (std::find(loaded_capability_names.begin(), loaded_capability_names.end(), capability_name) != if (std::find(loaded_capability_names.begin(), loaded_capability_names.end(), capability_name) !=
loaded_capability_names.end()) loaded_capability_names.end())
set_capability_enabled(plugin_id, capability_name, true); set_capability_enabled({PluginCapabilityType::Unknown, capability_name, plugin_id}, true);
} }
run_on_load_callbacks(plugin_id); run_on_load_callbacks(plugin_id);
@@ -844,6 +844,17 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de
return; return;
} }
for (const auto& cap : plugin.capabilities) {
auto config = cap->get_default_config();
if (config.empty())
continue;
const PluginCapabilityId id = cap->identity();
if (m_config.has_config(id))
continue;
m_config.save_config({id, plugin.descriptor.installed_version, config});
}
bool committed = false; bool committed = false;
bool cancelled = false; bool cancelled = false;
std::string registry_error; std::string registry_error;
@@ -993,8 +1004,7 @@ void PluginManager::unload_cloud_plugins()
} }
// ── Enable state ──────────────────────────────────────────────────────────────────────────── // ── Enable state ────────────────────────────────────────────────────────────────────────────
void PluginManager::set_capability_enabled(const PluginCapabilityId& id, bool enabled)
void PluginManager::set_capability_enabled(const std::string& plugin_key, const std::string& capability_name, bool enabled)
{ {
PluginCapabilityId changed; PluginCapabilityId changed;
bool did_change = false; bool did_change = false;
@@ -1002,15 +1012,18 @@ void PluginManager::set_capability_enabled(const std::string& plugin_key, const
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
Plugin* plugin = find_plugin_locked(plugin_key); Plugin* plugin = find_plugin_locked(id.plugin_key);
if (plugin == nullptr || !plugin->is_loaded()) if (plugin == nullptr || !plugin->is_loaded())
return; return;
for (const auto& capability : plugin->capabilities) { for (const auto& capability : plugin->capabilities) {
if (!capability || capability->name() != capability_name || capability->is_enabled() == enabled) if (!capability || capability->name() != id.name ||
(id.type != PluginCapabilityType::Unknown && capability->type() != id.type) ||
capability->is_enabled() == enabled)
continue; continue;
capability->set_enabled(enabled); capability->set_enabled(enabled);
changed = PluginCapabilityId{capability->type(), capability->name(), plugin_key}; changed = capability->identity();
did_change = true; did_change = true;
break; break;
} }
@@ -1019,7 +1032,7 @@ void PluginManager::set_capability_enabled(const std::string& plugin_key, const
if (!did_change) if (!did_change)
return; return;
write_loaded_plugin_install_state(plugin_key); write_loaded_plugin_install_state(id.plugin_key);
if (enabled) if (enabled)
run_on_capability_load_callbacks(changed); run_on_capability_load_callbacks(changed);
@@ -1029,6 +1042,8 @@ void PluginManager::set_capability_enabled(const std::string& plugin_key, const
void PluginManager::write_loaded_plugin_install_state(const std::string& plugin_key) void PluginManager::write_loaded_plugin_install_state(const std::string& plugin_key)
{ {
std::lock_guard<std::mutex> state_lock(m_install_state_mutex);
PluginDescriptor descriptor; PluginDescriptor descriptor;
std::vector<std::pair<std::string, bool>> capabilities; std::vector<std::pair<std::string, bool>> capabilities;
{ {
@@ -1958,7 +1973,7 @@ ExecutionResult PluginManager::run_script_capability(const std::string& plugin_k
return {}; return {};
} }
auto cap = get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::Script); auto cap = get_plugin_capability({PluginCapabilityType::Script, capability_name, plugin_key});
if (!cap) if (!cap)
return {}; return {};
+13 -18
View File
@@ -23,21 +23,12 @@
#include "PluginFsUtils.hpp" #include "PluginFsUtils.hpp"
#include "PluginDescriptor.hpp" #include "PluginDescriptor.hpp"
#include "PluginLoader.hpp" #include "PluginLoader.hpp"
#include "PluginConfig.hpp"
namespace Slic3r { namespace Slic3r {
class OrcaCloudServiceAgent; class OrcaCloudServiceAgent;
// Identity of a single capability, as published to lifecycle subscribers. Purely a message
// payload — capabilities are looked up by (plugin_key, name) linear scan, so unlike the registry
// key this replaces, it needs no hash and no equality.
struct PluginCapabilityId
{
PluginCapabilityType type = PluginCapabilityType::Unknown;
std::string name;
std::string plugin_key; // owning package
};
// One discovered plugin package: one .py/.whl file -> one descriptor + one Python module + // One discovered plugin package: one .py/.whl file -> one descriptor + one Python module +
// N materialized capabilities. // N materialized capabilities.
// //
@@ -126,6 +117,9 @@ public:
// Manually trigger a manifest-only rescan. Blocks until discovery is complete. // Manually trigger a manifest-only rescan. Blocks until discovery is complete.
void rescan_plugins(); void rescan_plugins();
PluginConfig& get_config() { return m_config; }
const PluginConfig& get_config() const { return m_config; }
bool is_discovery_complete() const; bool is_discovery_complete() const;
bool is_discovery_in_progress() const; bool is_discovery_in_progress() const;
std::string get_discovery_error() const; std::string get_discovery_error() const;
@@ -149,10 +143,9 @@ public:
const std::string& plugin_key = "", // "" => all plugins const std::string& plugin_key = "", // "" => all plugins
PluginCapabilityType type = PluginCapabilityType::Unknown, // Unknown => all types PluginCapabilityType type = PluginCapabilityType::Unknown, // Unknown => all types
bool only_enabled = true) const; bool only_enabled = true) const;
std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const std::string& plugin_key, std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const PluginCapabilityId& id, bool only_enabled = true) const;
const std::string& capability_name,
PluginCapabilityType type = PluginCapabilityType::Unknown, // Try to resolve from just capability name and type from presets.
bool only_enabled = true) const;
std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const std::string& capability_name, std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const std::string& capability_name,
PluginCapabilityType type = PluginCapabilityType::Unknown, PluginCapabilityType type = PluginCapabilityType::Unknown,
bool only_enabled = true) const; bool only_enabled = true) const;
@@ -169,10 +162,8 @@ public:
bool cancel_plugin_load(const std::string& plugin_key); bool cancel_plugin_load(const std::string& plugin_key);
std::string get_plugin_load_error(const std::string& plugin_key) const; std::string get_plugin_load_error(const std::string& plugin_key) const;
void set_capability_enabled(const std::string& plugin_key, const std::string& capability_name, bool enabled); void set_capability_enabled(const PluginCapabilityId& id, bool enabled);
// The plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown). This will be replaced once the config is merged in.
std::map<std::string, std::string> get_plugin_settings(const std::string& plugin_key) const;
// Sets the cloud user whose _subscribed/{user_id} directory is scanned and installed into. // Sets the cloud user whose _subscribed/{user_id} directory is scanned and installed into.
void set_cloud_user(const std::string& user_id); void set_cloud_user(const std::string& user_id);
@@ -264,6 +255,7 @@ private:
bool m_initialized = false; bool m_initialized = false;
CloudPluginService m_cloud_service; CloudPluginService m_cloud_service;
PluginConfig m_config;
// Leaf lock: code holding m_mutex must not call Python, acquire the GIL, invoke lifecycle // Leaf lock: code holding m_mutex must not call Python, acquire the GIL, invoke lifecycle
// callbacks, or re-enter the manager. Live plugin payloads are detached and torn down after // callbacks, or re-enter the manager. Live plugin payloads are detached and torn down after
@@ -283,6 +275,9 @@ private:
std::map<std::string, std::string> m_load_errors; std::map<std::string, std::string> m_load_errors;
mutable std::condition_variable m_load_cv; mutable std::condition_variable m_load_cv;
// Serialize sidecar snapshots so concurrent capability toggles cannot write stale state out of order.
mutable std::mutex m_install_state_mutex;
std::map<CallbackType, std::vector<PluginLifecycleCompleteFn>> m_callbacks; std::map<CallbackType, std::vector<PluginLifecycleCompleteFn>> m_callbacks;
std::map<CallbackType, std::vector<CapabilityLifecycleFn>> m_capability_callbacks; std::map<CallbackType, std::vector<CapabilityLifecycleFn>> m_capability_callbacks;
@@ -339,7 +334,7 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
// only_enabled = false so that "not loaded" and "loaded but disabled" stay distinguishable // only_enabled = false so that "not loaded" and "loaded but disabled" stay distinguishable
// and each keeps its own diagnostic. // and each keeps its own diagnostic.
auto cap = plugin_mgr.get_plugin_capability(plugin_key, cap_name, type, /*only_enabled=*/false); auto cap = plugin_mgr.get_plugin_capability({type, cap_name, plugin_key}, /*only_enabled=*/false);
if (!cap) { if (!cap) {
BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name << "' for plugin '" << plugin_key << "'; skipping"; BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name << "' for plugin '" << plugin_key << "'; skipping";
continue; continue;
+116 -27
View File
@@ -12,6 +12,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <libslic3r/Config.hpp> #include <libslic3r/Config.hpp>
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <vector> #include <vector>
#include <wx/utils.h> #include <wx/utils.h>
@@ -20,35 +21,39 @@
#include <map> #include <map>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <tuple>
#include <unordered_map> #include <unordered_map>
namespace Slic3r { namespace Slic3r {
namespace { namespace {
// Return the name of the tracked option in `preset` whose value references `ref`'s capability, or // The tracked option in `preset` whose value references `ref`'s capability, or "" when none does.
// an empty string when no active option uses it. The result doubles as the "Jump to" target and as // Doubles as the "Jump to" target and as the signal that the plugin is still required: a missing
// the signal that the plugin is still required: a missing plugin with no referencing option is // plugin with no referencing option is dropped from the set.
// considered resolved and dropped from the missing set. std::string find_option_for_capability(Preset::Type type,
std::string find_option_for_capability(Preset::Type type, const Preset& preset, const PluginCapabilityRef& ref) const Preset& preset,
const PluginCapabilityRef& ref,
PluginCapabilityType capability_type = PluginCapabilityType::Unknown)
{ {
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT) if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT)
return {}; return {};
// Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type), // Options opt in via ConfigOptionDef::is_plugin_backed, so scan the definition rather than keep a
// so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed // hardcoded per-type field list. A typed preset's config only holds keys for its own type.
// preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
const ConfigDef* def = preset.config.def(); const ConfigDef* def = preset.config.def();
if (def == nullptr) if (def == nullptr)
return {}; return {};
const std::string expected_type = plugin_capability_type_to_string(capability_type);
const auto matches_ref = [&ref](const std::string& value) { const auto matches_ref = [&ref](const std::string& value) {
return value == ref.capability_name; return value == ref.capability_name;
}; };
for (const std::string& field : preset.config.keys()) { for (const std::string& field : preset.config.keys()) {
const ConfigOptionDef* opt_def = def->get(field); const ConfigOptionDef* opt_def = def->get(field);
if (opt_def == nullptr || !opt_def->is_plugin_backed()) if (opt_def == nullptr || !opt_def->is_plugin_backed() ||
(capability_type != PluginCapabilityType::Unknown && opt_def->plugin_type != expected_type))
continue; continue;
const ConfigOption* option = preset.config.option(field); const ConfigOption* option = preset.config.option(field);
@@ -70,16 +75,19 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
// printer_agent stores AgentInfo::id, so a missing plugin cannot be reverse-mapped through // printer_agent stores AgentInfo::id, so a missing plugin cannot be reverse-mapped through
// the runtime registry. If no regular printer plugin field matched, assume printer_agent. // the runtime registry. If no regular printer plugin field matched, assume printer_agent.
if (type == Preset::Type::TYPE_PRINTER && preset.config.has("printer_agent")) if (type == Preset::Type::TYPE_PRINTER && preset.config.has("printer_agent")) {
const ConfigOptionDef* agent_def = def->get("printer_agent");
if (agent_def != nullptr && agent_def->is_plugin_backed() &&
(capability_type == PluginCapabilityType::Unknown || agent_def->plugin_type == expected_type))
return "printer_agent"; return "printer_agent";
}
return {}; return {};
} }
} // namespace } // namespace
// One missing-plugin set per tracked preset type, keyed by the full "name;uuid;capability" ref. // One set per tracked preset type, keyed by the full "name;uuid;capability" ref.
// Only TYPE_PRINT (process), TYPE_PRINTER (machine) and TYPE_FILAMENT are tracked.
static std::map<Preset::Type, std::unordered_map<std::string, MissingPlugin>> s_missing; static std::map<Preset::Type, std::unordered_map<std::string, MissingPlugin>> s_missing;
static std::mutex s_missing_mutex; static std::mutex s_missing_mutex;
// Installed-but-inactive capabilities (not loaded, or loaded-but-disabled); resolvable locally. // Installed-but-inactive capabilities (not loaded, or loaded-but-disabled); resolvable locally.
@@ -92,6 +100,95 @@ static bool is_tracked_type(Preset::Type type)
return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT; return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT;
} }
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset)
{
if (!is_tracked_type(type))
return {};
const auto* manifest = dynamic_cast<const ConfigOptionStrings*>(preset.config.option("plugins"));
if (manifest == nullptr)
return {};
std::vector<PluginCapabilityRef> refs;
for (const std::string& entry : manifest->values) {
const auto ref = parse_capability_ref(entry);
if (!ref)
continue;
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 not in the catalog, or whose capability is not loaded, is dropped: no instance means nothing
// to configure.
void collect_capabilities_in_use(Preset::Type type, const Preset& preset, std::vector<PluginCapabilityId>& out)
{
for (const PluginCapabilityRef& ref : referenced_capabilities(type, preset)) {
// Cloud plugins resolve by UUID, local plugins by plugin_key.
const std::string key = ref.uuid.empty() ? ref.name : ref.uuid;
if (key.empty())
continue;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(key, descriptor))
continue;
// The manifest ref does not carry a type, so require the live capability type to match the
// type declared by the option that references it.
for (const auto& capability : PluginManager::instance().get_plugin_capabilities(descriptor.plugin_key, PluginCapabilityType::Unknown, false))
if (capability && capability->type() != PluginCapabilityType::Unknown &&
capability->name() == ref.capability_name &&
!find_option_for_capability(type, preset, ref, capability->type()).empty())
out.push_back(capability->identity());
}
}
} // namespace
std::vector<PluginCapabilityId> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type)
{
if (!is_tracked_type(type))
return {};
std::vector<PluginCapabilityId> 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 {
// Each filament preset is tested against its own config; refresh_missing_plugins cannot do
// that, as 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 PluginCapabilityId& a, const PluginCapabilityId& 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<PluginCapabilityId> capabilities_in_use(Preset::Type type, const Preset& preset)
{
std::vector<PluginCapabilityId> result;
if (!is_tracked_type(type))
return result;
collect_capabilities_in_use(type, preset, result);
std::sort(result.begin(), result.end(), [](const PluginCapabilityId& a, const PluginCapabilityId& b) {
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
});
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
static std::string resolve_cloud_base_url() static std::string resolve_cloud_base_url()
{ {
std::string cloud_base_url = "https://cloud.orcaslicer.com"; std::string cloud_base_url = "https://cloud.orcaslicer.com";
@@ -111,9 +208,7 @@ std::string resolve_recovery_url(const PluginCapabilityRef& ref)
return resolve_cloud_base_url() + "/app/plugins/plugin-hub?search=" + Http::url_encode(ref.name); return resolve_cloud_base_url() + "/app/plugins/plugin-hub?search=" + Http::url_encode(ref.name);
} }
// Reports whether the loaded plugin currently exposes the referenced capability (in any enabled // {present, enabled}; {false, false} when the plugin is not loaded or does not provide the capability.
// state) and whether that capability is enabled. Returns {false, false} when the plugin is not
// loaded or does not provide the capability.
static std::pair<bool, bool> loaded_capability_state(const std::string& plugin_key, const PluginCapabilityRef& ref) static std::pair<bool, bool> loaded_capability_state(const std::string& plugin_key, const PluginCapabilityRef& ref)
{ {
PluginManager& mgr = PluginManager::instance(); PluginManager& mgr = PluginManager::instance();
@@ -123,7 +218,7 @@ static std::pair<bool, bool> loaded_capability_state(const std::string& plugin_k
if (!mgr.is_plugin_loaded(plugin_key)) if (!mgr.is_plugin_loaded(plugin_key))
return {false, false}; return {false, false};
const auto capability = mgr.get_plugin_capability(plugin_key, ref.capability_name, PluginCapabilityType::Unknown, const auto capability = mgr.get_plugin_capability({PluginCapabilityType::Unknown, ref.capability_name, plugin_key},
/*only_enabled=*/false); /*only_enabled=*/false);
if (!capability) if (!capability)
return {false, false}; return {false, false};
@@ -193,7 +288,7 @@ void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manif
continue; continue;
if (!installed) { if (!installed) {
// Not on disk — needs download/install (existing behavior). // Not on disk — needs download/install.
std::string recovery_url = ref->uuid.empty() ? resolve_recovery_url(*ref) : std::string(); std::string recovery_url = ref->uuid.empty() ? resolve_recovery_url(*ref) : std::string();
missing_set.emplace(entry, MissingPlugin{*ref, std::move(recovery_url), std::move(opt), type, PluginCapabilityType::Unknown}); missing_set.emplace(entry, MissingPlugin{*ref, std::move(recovery_url), std::move(opt), type, PluginCapabilityType::Unknown});
} else if (!loaded || cap_present) { } else if (!loaded || cap_present) {
@@ -287,7 +382,6 @@ static void report_install_failure(const std::string& message)
void resolve_missing_plugins(const std::vector<std::string>& refs, PluginInstallProgress progress) void resolve_missing_plugins(const std::vector<std::string>& refs, PluginInstallProgress progress)
{ {
// Collect the unique cloud UUIDs to install; local refs are handled via the browser flow.
std::vector<std::string> uuids; std::vector<std::string> uuids;
for (const std::string& r : refs) { for (const std::string& r : refs) {
const auto ref = parse_capability_ref(r); const auto ref = parse_capability_ref(r);
@@ -311,7 +405,6 @@ void resolve_missing_plugins(const std::vector<std::string>& refs, PluginInstall
const std::string& uuid = uuids[i]; const std::string& uuid = uuids[i];
// Use a friendly name for the progress message when the catalog already knows it.
std::string display_name = uuid; std::string display_name = uuid;
PluginDescriptor known; PluginDescriptor known;
if (mgr.try_get_plugin_descriptor(uuid, known) && !known.name.empty()) if (mgr.try_get_plugin_descriptor(uuid, known) && !known.name.empty())
@@ -345,8 +438,7 @@ void resolve_inactive_plugins(const std::vector<std::string>& refs)
{ {
PluginManager& mgr = PluginManager::instance(); PluginManager& mgr = PluginManager::instance();
// Group the requested capabilities by owning plugin so each plugin is loaded once with the full // Group by owning plugin so each plugin is loaded once with the full set to enable.
// set to enable.
std::map<std::string, std::vector<std::string>> by_plugin; std::map<std::string, std::vector<std::string>> by_plugin;
for (const std::string& r : refs) { for (const std::string& r : refs) {
const auto ref = parse_capability_ref(r); const auto ref = parse_capability_ref(r);
@@ -361,11 +453,9 @@ void resolve_inactive_plugins(const std::vector<std::string>& refs)
if (by_plugin.empty()) if (by_plugin.empty())
return; return;
// load_plugin loads+enables a not-loaded plugin (async) and enables the listed capabilities on an // The fresh-load path does NOT fire the capability-load callback the GUI uses to clear the
// already-loaded one. The fresh-load path does NOT fire the capability-load callback the GUI uses // notification, so wait for each load off the UI thread and re-validate once. That clears the
// to clear the notification, so wait for each load off the UI thread and then re-validate once — // inactive notification, or flips it to broken if the plugin does not provide the capability.
// mirroring the cloud-install flow. This clears the inactive notification, or flips it to broken
// if the loaded plugin turns out not to provide the capability.
std::vector<std::pair<std::string, std::vector<std::string>>> work(by_plugin.begin(), by_plugin.end()); std::vector<std::pair<std::string, std::vector<std::string>>> work(by_plugin.begin(), by_plugin.end());
std::thread([work = std::move(work)]() { std::thread([work = std::move(work)]() {
PluginManager& mgr = PluginManager::instance(); PluginManager& mgr = PluginManager::instance();
@@ -383,7 +473,6 @@ void resolve_inactive_plugins(const std::vector<std::string>& refs)
void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs) void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs)
{ {
// One missing plugin: deep-link a search for it. Multiple: just open the plugin hub.
if (local_refs.size() == 1) { if (local_refs.size() == 1) {
if (const auto ref = parse_capability_ref(local_refs.front())) { if (const auto ref = parse_capability_ref(local_refs.front())) {
wxLaunchDefaultBrowser(GUI::from_u8(resolve_recovery_url(*ref)), wxBROWSER_NEW_WINDOW); wxLaunchDefaultBrowser(GUI::from_u8(resolve_recovery_url(*ref)), wxBROWSER_NEW_WINDOW);
+27 -21
View File
@@ -4,7 +4,7 @@
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref #include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
#include <libslic3r/Preset.hpp> // Preset::Type #include <libslic3r/Preset.hpp> // Preset::Type
#include <libslic3r/PresetBundle.hpp> #include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType #include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityId, PluginCapabilityType
#include <cstddef> #include <cstddef>
#include <functional> #include <functional>
#include <string> #include <string>
@@ -23,10 +23,9 @@ struct MissingPlugin
PluginCapabilityType type{PluginCapabilityType::Unknown}; PluginCapabilityType type{PluginCapabilityType::Unknown};
}; };
// Rebuild the missing-plugin set owned by a single preset type from that preset's "plugins" // Rebuild one preset type's missing-plugin set from its "plugins" manifest, comparing each ref
// manifest, comparing each ref against the live plugin catalog and loaded/enabled capabilities. A // against the live catalog and loaded/enabled capabilities. A null/empty manifest clears the set.
// null/empty manifest clears the set for that type. Only TYPE_PRINT (process), TYPE_PRINTER // Only TYPE_PRINT, TYPE_PRINTER and TYPE_FILAMENT are tracked.
// (machine) and TYPE_FILAMENT are tracked; other types are ignored.
void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manifest, const Preset* preset = nullptr); void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manifest, const Preset* preset = nullptr);
void refresh_missing_plugins(const PresetBundle& preset_bundle); void refresh_missing_plugins(const PresetBundle& preset_bundle);
@@ -36,22 +35,16 @@ std::vector<MissingPlugin> get_missing_cloud_plugins();
std::vector<MissingPlugin> get_missing_local_plugins(); std::vector<MissingPlugin> get_missing_local_plugins();
bool has_missing_plugins(); bool has_missing_plugins();
// Installed-but-inactive capabilities: the plugin has a local package but the referenced capability // Installed-but-inactive: the plugin has a local package but is not loaded, or is loaded with the
// is not active because the plugin is not loaded, or it is loaded but the capability is disabled. // capability disabled. Resolved locally by loading and/or enabling — no download.
// Resolved locally by loading the plugin and/or enabling the capability — no download.
std::vector<MissingPlugin> get_inactive_plugins(); std::vector<MissingPlugin> get_inactive_plugins();
bool has_inactive_plugins(); bool has_inactive_plugins();
// Broken references: the plugin is installed AND loaded but does not provide the referenced // Broken references: the plugin is installed AND loaded but does not provide the referenced
// capability at all (renamed/removed/outdated plugin). Activation cannot fix these; surfaced as an // capability at all (renamed/removed/outdated plugin). Activation cannot fix these.
// informational notification pointing the user at OrcaCloud to update the plugin.
std::vector<MissingPlugin> get_broken_plugins(); std::vector<MissingPlugin> get_broken_plugins();
bool has_broken_plugins(); bool has_broken_plugins();
// Resolution actions invoked from the missing-plugin notifications:
// - cloud refs are subscribed/installed and loaded on a detached worker thread; failures are
// reported through a non-blocking notification. Non-cloud refs are ignored.
// Optional progress hook for the cloud install worker. All three callbacks fire on the worker // Optional progress hook for the cloud install worker. All three callbacks fire on the worker
// thread; implementations must only touch thread-safe state or marshal to the UI thread. // thread; implementations must only touch thread-safe state or marshal to the UI thread.
struct PluginInstallProgress struct PluginInstallProgress
@@ -64,23 +57,36 @@ struct PluginInstallProgress
std::function<void()> on_finished; std::function<void()> on_finished;
}; };
// Cloud refs only; local refs are handled via the browser flow. `progress` is optional — a // Subscribe, install and load the cloud refs on a detached worker; failures are reported through a
// default-constructed value preserves the previous silent behavior. // non-blocking notification. Local refs are ignored — they go through the browser flow below.
void resolve_missing_plugins(const std::vector<std::string>& refs, void resolve_missing_plugins(const std::vector<std::string>& refs,
PluginInstallProgress progress = {}); PluginInstallProgress progress = {});
// Activate inactive plugins: load each referenced plugin (passing the capabilities to enable) and/or // Load each referenced plugin and/or enable its disabled capabilities. Local only — no network. The
// enable already-loaded-but-disabled capabilities. Local only — no network. The loads run on a // loads run on a background worker that waits for them and then re-validates the plate, clearing the
// background worker that waits for them and then re-validates the plate, clearing the notification // notification (or reclassifying the ref as broken if the plugin does not provide the capability).
// (or reclassifying the ref as broken if the loaded plugin turns out not to provide the capability).
void resolve_inactive_plugins(const std::vector<std::string>& refs); void resolve_inactive_plugins(const std::vector<std::string>& refs);
// - local refs are opened on the OrcaCloud plugin hub (search when exactly one ref, hub otherwise). // Opens the OrcaCloud plugin hub (a search when there is exactly one ref, the hub otherwise).
void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs); void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs);
std::string create_full_ref(const PluginCapabilityRef& ref); std::string create_full_ref(const PluginCapabilityRef& ref);
std::string resolve_recovery_url(const PluginCapabilityRef& ref); std::string resolve_recovery_url(const PluginCapabilityRef& ref);
// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options
// (ConfigOptionDef::is_plugin_backed) currently references: a manifest entry nobody points at is not
// in use. Pure preset logic — the catalog and loader are not consulted. Empty for untracked types.
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset);
std::vector<PluginCapabilityId> capabilities_in_use(Preset::Type type, const Preset& preset);
// The referenced capabilities of the active preset(s) of `type` 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.
std::vector<PluginCapabilityId> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type);
bool check_capability_in_use(const std::string& capability_refs);
} // namespace Slic3r } // namespace Slic3r
#endif #endif
+66 -15
View File
@@ -3,20 +3,21 @@
#include <pybind11/embed.h> #include <pybind11/embed.h>
#include <boost/log/trivial.hpp>
#include <optional> #include <optional>
#include <stdexcept> #include <stdexcept>
#include "PythonPluginInterface.hpp" #include "PythonPluginInterface.hpp"
#include "PythonInterpreter.hpp" #include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginAuditManager.hpp" #include "PluginAuditManager.hpp"
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call // Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
// crosses through a trampoline method, so this single boundary is where we (1) log the // trampoline method, so this single boundary is where we (1) log the full Python traceback and
// full Python traceback (to sys.stderr -> session log) and rethrow the exception intact, // rethrow the exception intact, and (2) open the plugin's filesystem audit scope for the call.
// and (2) open the plugin's filesystem audit scope for the duration of the call. // We catch ONLY error_already_set (a Python-side raise); other pybind11_fail/runtime_error, such as
// We catch ONLY error_already_set (a Python-side raise); other pybind11_fail/runtime_error // a pure-virtual-missing failure, must keep their own path and are deliberately not caught here.
// like a pure-virtual-missing failure must keep their own path and are deliberately not
// caught here.
// Logs (and rethrows) a Python exception from a pybind11 override call, preserving the // Logs (and rethrows) a Python exception from a pybind11 override call, preserving the
// traceback. Internal helper shared by the public macros below and by trampolines that // traceback. Internal helper shared by the public macros below and by trampolines that
@@ -29,13 +30,13 @@
throw; \ throw; \
} }
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call // Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call, and publishes
// when this trampoline instance carries a non-empty audit plugin key. Declares a local // the calling capability's cached name so host APIs invoked from Python can tell which capability
// `_orca_audit_scope`. // they are serving. No-op without an audit plugin key. Declares a local `_orca_audit_scope`.
#define ORCA_PY_AUDIT_SCOPE(mode) \ #define ORCA_PY_AUDIT_SCOPE(mode) \
std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \ std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \
if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \ 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->name(), mode)
#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \ #define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \
do { \ do { \
@@ -55,13 +56,65 @@ template<class Base> class PyPluginCommonTrampoline : public Base
public: public:
using Base::Base; using Base::Base;
// get_name is required on all capabilities — Python subclass must implement it.
std::string get_name() const override std::string get_name() const override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name); ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name);
} }
// All plugins may define their own on_load/unload functions. // Config UI hooks. Available on every capability type, so they live here rather than in
// PyPluginInterfaceTrampoline. A Python exception is rethrown; the caller 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). Otherwise identical — same audit scope, same rethrow.
//
// The hook is optional, and "not implemented" must mean an EMPTY config: no override, or an
// override returning None or any non-object (`def get_default_config(self): pass` is the easy
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
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->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;
}
}
void on_load() override void on_load() override
{ {
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load); ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load);
@@ -83,8 +136,6 @@ class PyPluginInterfaceTrampoline : public PyPluginCommonTrampoline<PluginCapabi
public: public:
using PyPluginCommonTrampoline<PluginCapabilityInterface>::PyPluginCommonTrampoline; using PyPluginCommonTrampoline<PluginCapabilityInterface>::PyPluginCommonTrampoline;
// get_name is implemented in PyPluginCommonTrampoline (PYBIND11_OVERRIDE_PURE).
PluginCapabilityType get_type() const override PluginCapabilityType get_type() const override
{ {
ORCA_PY_OVERRIDE_AUDITED( ORCA_PY_OVERRIDE_AUDITED(
+57 -1
View File
@@ -1,8 +1,11 @@
#include "PythonPluginBridge.hpp" #include "PythonPluginBridge.hpp"
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <exception>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <string>
#include <unordered_map> #include <unordered_map>
#include <pybind11/embed.h> #include <pybind11/embed.h>
@@ -10,6 +13,8 @@
#include <pybind11/stl.h> #include <pybind11/stl.h>
#include "PythonInterpreter.hpp" #include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginConfig.hpp"
#include "host/PluginHost.hpp" #include "host/PluginHost.hpp"
#include "PyPluginPackage.hpp" #include "PyPluginPackage.hpp"
#include "PyPluginTrampoline.hpp" #include "PyPluginTrampoline.hpp"
@@ -347,12 +352,63 @@ void bind_python_api(pybind11::module_& m)
.def_static("skipped", &ExecutionResult::skipped, py::arg("message") = std::string()) .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()); .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") py::class_<PluginCapabilityInterface, PyPluginInterfaceTrampoline, std::shared_ptr<PluginCapabilityInterface>>(m, "PythonPluginBase")
.def(py::init<>()) .def(py::init<>())
.def("get_name", &PluginCapabilityInterface::get_name) .def("get_name", &PluginCapabilityInterface::get_name)
.def("get_type", &PluginCapabilityInterface::get_type) .def("get_type", &PluginCapabilityInterface::get_type)
.def("on_load", &PluginCapabilityInterface::on_load) .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 // Expose the package marker base as orca.base. @orca.plugin later verifies that the
// decorated class derives from this exact pybind-registered C++ type. // decorated class derives from this exact pybind-registered C++ type.
+47 -1
View File
@@ -7,12 +7,34 @@
#include <string_view> #include <string_view>
#include <utility> #include <utility>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h> #include <pybind11/embed.h>
namespace Slic3r { namespace Slic3r {
enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
struct PluginCapabilityId
{
PluginCapabilityType type = PluginCapabilityType::Unknown;
std::string name;
std::string plugin_key;
bool empty() const { return type == PluginCapabilityType::Unknown || name.empty() || plugin_key.empty(); }
friend bool operator==(const PluginCapabilityId& lhs, const PluginCapabilityId& rhs)
{
return lhs.type == rhs.type && lhs.name == rhs.name && lhs.plugin_key == rhs.plugin_key;
}
friend bool operator<(const PluginCapabilityId& lhs, const PluginCapabilityId& rhs)
{
if (lhs.plugin_key != rhs.plugin_key)
return lhs.plugin_key < rhs.plugin_key;
if (lhs.name != rhs.name)
return lhs.name < rhs.name;
return lhs.type < rhs.type;
}
};
inline std::string plugin_capability_type_to_string(PluginCapabilityType type) inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
{ {
switch (type) { switch (type) {
@@ -127,6 +149,20 @@ public:
virtual std::string get_name() const = 0; // required — overridden in Python virtual std::string get_name() const = 0; // required — overridden in Python
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override
// Every capability is configurable and always gets the host's default JSON editor over its
// stored config; this only says whether it supplies its own UI *instead of* that editor.
// get_config_ui() is called only when it 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 "Restore defaults" action writes back. Not overridden -> an empty object, which
// is right for a capability that keeps its stored config sparse and applies its own defaults on
// read. 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.
virtual nlohmann::json get_default_config() const { return nlohmann::json::object(); }
virtual void on_load() {} virtual void on_load() {}
virtual void on_unload() {} virtual void on_unload() {}
virtual void on_cancelled() {} virtual void on_cancelled() {}
@@ -137,9 +173,12 @@ public:
// exactly as long as the capability does, and are discarded with it on unload. Nothing about a // exactly as long as the capability does, and are discarded with it on unload. Nothing about a
// capability outlives the capability — the durable record is the .install_state.json sidecar. // capability outlives the capability — the durable record is the .install_state.json sidecar.
// Cached identity. Plain C++ reads, safe under any lock and after the interpreter is gone. // Cached identity. Plain C++ reads, safe under any lock and after the interpreter is gone. Also
// doubles as the audited capability name (paired with audit_plugin_key()) so host APIs invoked
// from Python can tell which capability they are serving.
const std::string& name() const { return m_name; } const std::string& name() const { return m_name; }
PluginCapabilityType type() const { return m_type; } PluginCapabilityType type() const { return m_type; }
PluginCapabilityId identity() const { return {m_type, m_name, m_audit_plugin_key}; }
void set_resolved_identity(std::string name, PluginCapabilityType type) void set_resolved_identity(std::string name, PluginCapabilityType type)
{ {
m_name = std::move(name); m_name = std::move(name);
@@ -152,6 +191,12 @@ public:
bool is_enabled() const { return m_enabled.load(std::memory_order_acquire); } bool is_enabled() const { return m_enabled.load(std::memory_order_acquire); }
void set_enabled(bool enabled) { m_enabled.store(enabled, std::memory_order_release); } void set_enabled(bool enabled) { m_enabled.store(enabled, std::memory_order_release); }
// Whether this capability supplies its own config UI (has_config_ui()), resolved once at
// materialization under the GIL and cached here. Plain C++ read so the GUI can decide between
// the capability's custom UI and the host's default JSON editor without touching Python.
bool config_ui_available() const { return m_config_ui_available; }
void set_config_ui_available(bool available) { m_config_ui_available = available; }
// The owning package (PluginDescriptor::plugin_key), the canonical runtime id. Also scopes // The owning package (PluginDescriptor::plugin_key), the canonical runtime id. Also scopes
// filesystem enforcement for trampoline calls. // filesystem enforcement for trampoline calls.
void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); } void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); }
@@ -165,6 +210,7 @@ private:
std::string m_name; std::string m_name;
PluginCapabilityType m_type = PluginCapabilityType::Unknown; PluginCapabilityType m_type = PluginCapabilityType::Unknown;
std::atomic<bool> m_enabled{true}; std::atomic<bool> m_enabled{true};
bool m_config_ui_available = false;
std::string m_audit_plugin_key; std::string m_audit_plugin_key;
mutable std::atomic<int> m_refs{0}; mutable std::atomic<int> m_refs{0};
+1 -57
View File
@@ -2,6 +2,7 @@
#include "slic3r/plugin/PluginAuditManager.hpp" #include "slic3r/plugin/PluginAuditManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState #include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
#include "slic3r/plugin/PluginFsUtils.hpp" // json_to_py / py_to_json
#include <slic3r/GUI/GUI_App.hpp> #include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp> #include <slic3r/GUI/MainFrame.hpp>
@@ -34,63 +35,6 @@ using json = nlohmann::json;
namespace Slic3r { namespace Slic3r {
namespace { 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 // 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 // py::object could be destroyed on the main thread without the GIL (a dialog
@@ -2,7 +2,6 @@
#include "SlicingPipelinePluginCapabilityTrampoline.hpp" #include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none #include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none
#include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR #include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include <pybind11/stl.h> // std::map<std::string,std::string> -> dict for ctx.params
namespace py = pybind11; namespace py = pybind11;
namespace Slic3r { namespace Slic3r {
@@ -31,7 +30,7 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
// ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin // ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin
// edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or // edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or
// upload each fire once, on separate working copies) and its output is not reflected in the G-code // upload each fire once, on separate working copies) and its output is not reflected in the G-code
// preview (the viewer maps the pre-post-process file). ctx.config_value()/ctx.params still work. // preview (the viewer maps the pre-post-process file). ctx.config_value() still works.
.value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess) .value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess)
.export_values(); .export_values();
@@ -48,9 +47,6 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext") py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version) .def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step) .def_readonly("step", &SlicingPipelineContext::step)
.def_readonly("params", &SlicingPipelineContext::params,
"read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values "
"(string->string). Parse the values you need, e.g. float(ctx.params['rate']).")
.def_readonly("gcode_path", &SlicingPipelineContext::gcode_path, .def_readonly("gcode_path", &SlicingPipelineContext::gcode_path,
"Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in " "Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in "
"place; empty at every other step.") "place; empty at every other step.")
@@ -18,10 +18,6 @@ struct SlicingPipelineContext {
SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice }; SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice };
Print* print { nullptr }; // present for in-pipeline steps; null at psGCodePostProcess Print* print { nullptr }; // present for in-pipeline steps; null at psGCodePostProcess
const PrintObject* object { nullptr }; // null for print-wide steps and psGCodePostProcess const PrintObject* object { nullptr }; // null for print-wide steps and psGCodePostProcess
// read-only per-plugin settings, populated by the dispatcher from the
// plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as
// ctx.params (dict of string->string).
std::map<std::string, std::string> params;
// Populated ONLY at Step.psGCodePostProcess (the GUI G-code export/post-process seam, // Populated ONLY at Step.psGCodePostProcess (the GUI G-code export/post-process seam,
// PostProcessor.cpp). gcode_path is the working G-code file on disk that the plugin edits // PostProcessor.cpp). gcode_path is the working G-code file on disk that the plugin edits
// in place; host is the target ("File", "OctoPrint", ...); output_name mirrors // in place; host is the target ("File", "OctoPrint", ...); output_name mirrors
+4 -1
View File
@@ -3,10 +3,13 @@ add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp ${_TEST_NAME}_tests_main.cpp
test_action_source.cpp test_action_source.cpp
test_plugin_host_api.cpp test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_install.cpp test_plugin_install.cpp
test_plugin_lifecycle.cpp test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_params.cpp test_slicing_pipeline_config.cpp
test_plugin_sort.cpp test_plugin_sort.cpp
test_plugin_cloud_metadata.cpp test_plugin_cloud_metadata.cpp
../fff_print/test_helpers.cpp ../fff_print/test_helpers.cpp
+39
View 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
@@ -0,0 +1,74 @@
#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.
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"});
}
@@ -0,0 +1,374 @@
#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()
{
// As in test_plugin_host_api.cpp: `orca` is 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 the way PluginLoader does: the audit identity is stamped on by the
// host, never supplied by the plugin, and it 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,
PluginCapabilityType type = PluginCapabilityType::Script)
{
// Import first: it brings the interpreter up, and any py:: object built before it 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_resolved_identity(capability_name, type);
}
return instance;
}
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
{
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
}
// The Python API writes through the PluginManager singleton, so that is where assertions read from.
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
// The Python config API speaks JSON text, not dicts; these helpers keep the tests 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>(); }
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
{
return {type, name, plugin_key};
}
} // 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: every capability has a config, so there is no hook to opt 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 only through the capability, never as a free orca.config.* function, so a
// capability cannot name — and 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() 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"}}));
const auto stored = host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
REQUIRE(stored);
CHECK(stored->id == capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
CHECK(stored->config == json{{"speed", 5}, {"name", "fast"}});
// Python reads back 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"}}));
// Refusing unparseable text must leave the previously stored config alone.
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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 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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a"))->config == json{{"value", 2}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"value", 3}});
// A shared name under one plugin must remain isolated when the capability type differs.
py::object importer = make_capability("IsoCapImporter", body, "plugin_a", "cap_a", PluginCapabilityType::Importer);
REQUIRE(py_save_config(importer, json{{"value", 4}}));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
host_config().load();
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
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: 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. There is no way to opt out.
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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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")
{
// Already "restore defaults" for a capability that keeps its stored config sparse and applies
// its own defaults on read.
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, and it must not store
// "cap_config": null.
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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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 rethrows; callers catch it and fall back to the default JSON editor.
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
// Catching it leaves the interpreter usable.
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; the host must survive the call.
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 or reject the value, and both are fine.
// What must hold is that the call is survivable — PluginLoader's guard turns a throw into
// "no custom UI".
try {
(void) iface->has_config_ui();
} catch (const std::exception&) {
}
// The capability is still usable afterwards.
CHECK(iface->get_name() == "cap_a");
CHECK(iface->get_config_ui().empty());
}
@@ -1,46 +1,27 @@
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp> #include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp> #include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp> #include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp> #include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp> #include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <fstream> #include <fstream>
#include <string> #include <string>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
using json = nlohmann::json;
namespace { namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards (same pattern as test_plugin_lifecycle.cpp).
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 plugins_dir() const { return dir / "orca_plugins"; }
};
// Shut both singletons down while boost::log is still alive; left to their static // Shut both singletons down while boost::log is still alive; left to their static
// destructors, shutdown()'s logging runs after boost::log tears down its thread-local // destructors, shutdown()'s logging runs after boost::log tears down its thread-local
// storage and crashes the process on exit (same reason ScopedPluginManager exists in // storage and crashes the process on exit (same reason ScopedPluginManager exists in
@@ -55,46 +36,46 @@ struct ScopedManagerShutdown
} }
}; };
// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist. const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# #
# [tool.orcaslicer.plugin] # [tool.orcaslicer.plugin]
# name = "Settings Cloud Plugin" # name = "Config Cloud Plugin"
# type = "slicing-pipeline" # type = "slicing-pipeline"
# version = "1.0" # version = "1.0"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# /// # ///
print('ok') print('ok')
)PY"; )PY";
} // namespace } // namespace
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud // Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only // cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the // that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and // Twistify running with its demo defaults instead of the configured values, 2026-07-17).
// plugins silently fall back to their built-in defaults (found via Twistify running with its // Configuration now lives in PluginConfig, keyed by the capability identity and kept off the
// demo defaults instead of the header values, 2026-07-17). // descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]") // config survives the same refresh path, while the descriptor fields the refresh owns do update.
//
// The capability need not exist for this to be meaningful: what is pinned is the architectural
// invariant that config never rides on the descriptor again. Anyone reintroducing it there, or
// adding a cloud-refresh path that prunes config, fails here.
TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]")
{ {
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
ScopedDataDir data_dir_guard("cloud-meta-settings"); ScopedDataDir data_dir_guard("cloud-meta-config");
// A locally-installed cloud plugin: package .py with a settings header, plus an // A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the
// install-state sidecar carrying the cloud identity. // cloud identity. Discovery derives plugin_key from the cloud UUID.
const std::string uuid = "11111111-2222-3333-4444-555555555555"; const std::string uuid = "11111111-2222-3333-4444-555555555555";
const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid; const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / uuid;
fs::create_directories(plugin_dir); fs::create_directories(plugin_dir);
{ {
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary); std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
out << SETTINGS_PLUGIN_SOURCE; out << CLOUD_PLUGIN_SOURCE;
} }
PluginDescriptor sidecar; PluginDescriptor sidecar;
sidecar.name = "Settings Cloud Plugin"; sidecar.name = "Config Cloud Plugin";
sidecar.installed_version = "1.0"; sidecar.installed_version = "1.0";
sidecar.cloud = CloudPluginState{uuid, true, false, false, false}; sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
REQUIRE(write_install_state(plugin_dir, sidecar)); REQUIRE(write_install_state(plugin_dir, sidecar));
@@ -109,25 +90,38 @@ TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[P
return {}; return {};
}; };
// Discovery parsed the header settings (premise). const PluginDescriptor discovered = find_by_uuid();
PluginDescriptor discovered = find_by_uuid(); REQUIRE(discovered.plugin_key == uuid);
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1); REQUIRE(discovered.version == "1.0");
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has // The user has configured the plugin's capability (premise).
// no settings, no local paths. const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "Twist", uuid};
const json configured{{"twist_deg_per_mm", 1.0}, {"taper_per_mm", 0.0}};
REQUIRE(manager.get_config().store_capability_config(id, configured));
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid and knows
// nothing about local config or local paths.
PluginDescriptor cloud_record; PluginDescriptor cloud_record;
cloud_record.name = "Settings Cloud Plugin"; cloud_record.name = "Config Cloud Plugin";
cloud_record.plugin_key = uuid; cloud_record.plugin_key = uuid;
cloud_record.version = "1.1"; cloud_record.version = "1.1";
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false}; cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
manager.update_cloud_metadata({cloud_record}); manager.update_cloud_metadata({cloud_record});
// Cloud metadata landed on the descriptor...
const PluginDescriptor refreshed = find_by_uuid(); const PluginDescriptor refreshed = find_by_uuid();
// Cloud metadata landed...
CHECK(refreshed.version == "1.1"); CHECK(refreshed.version == "1.1");
// ...and the locally-parsed settings survived the merge. CHECK(refreshed.plugin_key == uuid);
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1); CHECK(refreshed.installed_version == "1.0");
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
CHECK(refreshed.settings.count("taper_per_mm") == 1); // ...and the stored config is untouched, both in the live store...
const auto stored = manager.get_config().get_config(id);
REQUIRE(stored);
CHECK(stored->config == configured);
// ...and on disk, which is what the next run reads back.
PluginConfig reloaded;
reloaded.load();
REQUIRE(reloaded.has_config(id));
CHECK(reloaded.get_config(id)->config == configured);
} }
+264
View File
@@ -0,0 +1,264 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginManager.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 {
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
{
return {type, name, plugin_key};
}
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;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
// A capability nobody has configured yet reads as an empty record rather than throwing.
CHECK_FALSE(config.has_config(id));
CHECK_FALSE(config.get_config(id));
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
const auto stored = config.get_config(id);
REQUIRE(stored);
CHECK(stored->id == id);
CHECK(stored->config == json{{"speed", 5}});
CHECK(config.has_config(id));
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(id)->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;
const PluginCapabilityId a_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
const PluginCapabilityId a_b = capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a");
const PluginCapabilityId b_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b");
// The identity is the full (type, capability, plugin_key) tuple, so all three below are separate records.
REQUIRE(config.store_capability_config(a_a, json{{"value", 1}}));
REQUIRE(config.store_capability_config(a_b, json{{"value", 2}}));
REQUIRE(config.store_capability_config(b_a, json{{"value", 3}}));
REQUIRE(config.store_capability_config(a_a, json{{"value", 99}}));
CHECK(config.get_config(a_a)->config == json{{"value", 99}});
CHECK(config.get_config(a_b)->config == json{{"value", 2}});
CHECK(config.get_config(b_a)->config == json{{"value", 3}});
// The same holds on disk, not just in memory.
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(a_a)->config == json{{"value", 99}});
CHECK(reloaded.get_config(a_b)->config == json{{"value", 2}});
CHECK(reloaded.get_config(b_a)->config == json{{"value", 3}});
}
TEST_CASE("PluginConfig isolates same-name capabilities by type", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-type-isolation");
const PluginCapabilityId script = capability_id(PluginCapabilityType::Script, "shared", "plugin_a");
const PluginCapabilityId importer = capability_id(PluginCapabilityType::Importer, "shared", "plugin_a");
PluginConfig config;
REQUIRE(config.store_capability_config(script, json{{"value", "script"}}));
REQUIRE(config.store_capability_config(importer, json{{"value", "importer"}}));
CHECK(config.get_config(script)->config == json{{"value", "script"}});
CHECK(config.get_config(importer)->config == json{{"value", "importer"}});
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(script)->config == json{{"value", "script"}});
CHECK(reloaded.get_config(importer)->config == json{{"value", "importer"}});
}
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-schema");
PluginConfig config;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.store_capability_config(id, 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("capability_type") == "script");
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() == 5);
}
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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), json{{"token", "keep me"}}));
}
// config.json is deliberately not keyed to installed plugins: a record outlives its plugin and is
// still there on reinstall. Asserts no cleanup path silently drops it.
PluginConfig after_removal;
after_removal.load();
CHECK(after_removal.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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", "capability_type": "script", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"kept", true}});
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_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", "capability_type": "script", "plugin_version": "1.0.0"}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
REQUIRE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json::object());
}
SECTION("legacy entries without capability_type remain addressable")
{
ScopedDataDir data_dir_guard("plugin-config-notype");
write_config_file(R"({"config": [
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0", "cap_config": {"old": true}}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
const auto id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.has_config(id));
CHECK(config.get_config(id)->config == json{{"old", true}});
REQUIRE(config.store_capability_config(id, json{{"migrated", true}}));
const json root = read_config_file();
REQUIRE(root.at("config").size() == 1);
CHECK(root.at("config").front().at("capability_type") == "script");
CHECK(root.at("config").front().at("cap_config") == json{{"migrated", true}});
}
}
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-identity");
PluginConfig config;
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "cap_a", ""), "1.0.0", json::object()});
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "", "plugin_a"), "1.0.0", json::object()});
// Neither could ever be looked up again, so neither is kept.
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "")));
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "", "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;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.store_capability_config(id, nested));
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(id)->config == nested);
}
+3 -59
View File
@@ -5,6 +5,8 @@
#include <slic3r/plugin/PluginDescriptor.hpp> #include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp> #include <slic3r/plugin/PluginFsUtils.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <fstream> #include <fstream>
@@ -15,30 +17,6 @@ namespace fs = boost::filesystem;
namespace { 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::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
{ {
fs::create_directories(dir); fs::create_directories(dir);
@@ -142,38 +120,4 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
read_install_state(plugin_dir, scanned); read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0"); CHECK(scanned.installed_version == "1.2.0");
} }
TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("plugin-settings");
// A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin
// parses what it needs (ctx.params). This is the source Twistify reads its knobs from.
const std::string contents =
"# /// script\n"
"# requires-python = \">=3.12\"\n"
"#\n"
"# [tool.orcaslicer.plugin]\n"
"# name = \"Settings Plugin\"\n"
"# type = \"slicing-pipeline\"\n"
"#\n"
"# [tool.orcaslicer.plugin.settings]\n"
"# twist_deg_per_mm = \"1.5\"\n"
"# taper_per_mm = \"-0.004\"\n"
"# ///\n"
"print('ok')\n";
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
PluginDescriptor descriptor;
std::string error;
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
REQUIRE(installed);
CHECK(error.empty());
REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1);
CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5");
CHECK(descriptor.settings.at("taper_per_mm") == "-0.004");
// Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]).
CHECK(descriptor.settings.count("name") == 0);
}
+5 -7
View File
@@ -122,9 +122,7 @@ bool load_and_wait(PluginManager& manager,
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key, std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
const std::string& name) const std::string& name)
{ { return manager.get_plugin_capability({PluginCapabilityType::Unknown, name, plugin_key}, /*only_enabled=*/false); }
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
}
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key) std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
{ {
@@ -173,7 +171,7 @@ TEST_CASE("A discovered script plugin loads and materializes its capability", "[
CHECK(echo->is_enabled()); CHECK(echo->is_enabled());
CHECK(echo->audit_plugin_key() == "Echo_Plugin"); CHECK(echo->audit_plugin_key() == "Echo_Plugin");
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == echo); CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == echo);
manager.unload_plugin("Echo_Plugin"); manager.unload_plugin("Echo_Plugin");
} }
@@ -244,7 +242,7 @@ TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginL
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin")); CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty()); CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == nullptr); CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == nullptr);
// The package stays discovered, but nothing capability-shaped survives the unload. // The package stays discovered, but nothing capability-shaped survives the unload.
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin"); const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
@@ -403,7 +401,7 @@ TEST_CASE("Disabling a capability round-trips through the sidecar and survives a
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled()); REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
// Disabling writes the choice through to .install_state.json. // Disabling writes the choice through to .install_state.json.
manager.set_capability_enabled("Echo_Plugin", "Echo", false); manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled()); CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
PluginInstallState persisted; PluginInstallState persisted;
@@ -440,7 +438,7 @@ TEST_CASE("A capability disabled after load stays disabled when rediscovered and
std::string error; std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error)); REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
manager.set_capability_enabled("Echo_Plugin", "Echo", false); manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
REQUIRE(manager.unload_plugin("Echo_Plugin")); REQUIRE(manager.unload_plugin("Echo_Plugin"));
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the // Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
@@ -106,7 +106,7 @@ TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view
py::object slicing = orca.attr("slicing"); py::object slicing = orca.attr("slicing");
// Context surface: raw graph entry points + workflow accessors. // Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "print", "object", "params", "config_value", "cancelled", for (const char* name : { "print", "object", "config_value", "cancelled",
"orca_version", "step" }) "orca_version", "step" })
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name)); CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
@@ -0,0 +1,153 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "fff_print/test_helpers.hpp"
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
#include <fstream>
#include <string>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace fs = boost::filesystem;
using json = nlohmann::json;
// End-to-end coverage of a slicing-pipeline capability reading its own config: the loader seeds the
// store from the capability's get_default_config() hook, and the real dispatch
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) lets the plugin read back whatever
// the host has stored, through self.get_config(). A break anywhere in that chain makes plugins
// silently run on their built-in defaults, which is invisible to the plugin author (Twistify
// incident, 2026-07-17).
//
// Note this is deliberately NOT ctx.config_value(): that reads the slicer's print config, not the
// plugin's own config.
namespace {
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
const char* const CONFIG_PROBE_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Config Probe"
# description = "Echoes its own config back to the test"
# author = "OrcaSlicer"
# version = "1.0"
# type = "slicing-pipeline"
# ///
import json
import orca
class ConfigEcho(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "ConfigEcho"
def get_default_config(self):
return {"alpha": "1.25", "beta": "hello"}
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
try:
text = repr(sorted(json.loads(self.get_config()).items()))
except Exception as e: # what plugins' defaults-fallback code swallows silently
text = "config-error: " + repr(e)
orca._probe_config = text # read back by the test through pybind
return orca.ExecutionResult.success("config probed")
@orca.plugin
class ConfigProbePackage(orca.base):
def register_capabilities(self):
orca.register_capability(ConfigEcho)
)PY";
fs::path write_plugin(const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
} // namespace
TEST_CASE("slicing-pipeline dispatch delivers the stored config to self.get_config()", "[slicing_pipeline][PluginConfig][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("pipeline-config");
write_plugin("ConfigProbe", CONFIG_PROBE_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.get_config().load(); // reset the singleton's store against the empty temp data dir
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
manager.load_plugin("ConfigProbe", /*skip_deps=*/true, {});
REQUIRE(manager.wait_for_plugin_load("ConfigProbe", std::chrono::seconds(120), error));
INFO("load error: " << error);
REQUIRE(manager.is_plugin_loaded("ConfigProbe"));
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "ConfigEcho", "ConfigProbe"};
// Loading seeds the store from the capability's get_default_config() hook, so a plugin has a
// config before anyone has opened the Config tab.
const auto seeded = manager.get_config().get_config(id);
REQUIRE(seeded);
CHECK(seeded->config == json({{"alpha", "1.25"}, {"beta", "hello"}}));
// What editing the config in the Config tab does: the value the plugin must actually run on.
REQUIRE(manager.get_config().store_capability_config(id, json({{"alpha", "9.5"}, {"beta", "hello"}})));
// Slice with the capability selected, exactly as a preset would reference it.
Print print;
Model model;
auto config = DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ConfigEcho"}));
config.set_key_value("plugins", new ConfigOptionStrings({"ConfigProbe;;ConfigEcho"}));
init_print({cube(20)}, print, model, config);
print.process();
std::string observed = "<capability never executed>";
{
PythonGILState gil;
REQUIRE(static_cast<bool>(gil));
pybind11::module_ orca = pybind11::module_::import("orca");
if (pybind11::hasattr(orca, "_probe_config"))
observed = orca.attr("_probe_config").cast<std::string>();
}
INFO("config observed by Python: " << observed);
// The edited value arrived, not the seeded default: the host's store is what reaches the plugin.
CHECK(observed.find("'alpha', '9.5'") != std::string::npos);
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
CHECK(observed.find("1.25") == std::string::npos);
manager.unload_plugin("ConfigProbe");
}
@@ -1,160 +0,0 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "fff_print/test_helpers.hpp"
#include <boost/filesystem.hpp>
#include <pybind11/embed.h>
#include <fstream>
#include <string>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace fs = boost::filesystem;
// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses
// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin.
// A break anywhere in that chain makes plugins silently run on their built-in defaults,
// which is invisible to the plugin author (Twistify incident, 2026-07-17).
namespace {
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
// canonical(): the plugin audit canonicalizes its allowed roots, so a path through
// the macOS /var -> /private/var symlink would be rejected as "outside allowed root".
dir = fs::canonical(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 plugins_dir() const { return dir / "orca_plugins"; }
};
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
const char* const PARAM_PROBE_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Param Probe"
# description = "Echoes ctx.params back to the test"
# author = "OrcaSlicer"
# version = "1.0"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# alpha = "1.25"
# beta = "hello"
# ///
import orca
class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "ParamEcho"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
try:
text = repr(sorted(dict(ctx.params).items()))
except Exception as e: # what plugins' defaults-fallback code swallows silently
text = "params-error: " + repr(e)
orca._probe_params = text # read back by the test through pybind
return orca.ExecutionResult.success("params probed")
@orca.plugin
class ParamProbePackage(orca.base):
def register_capabilities(self):
orca.register_capability(ParamEcho)
)PY";
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
} // namespace
TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("pipeline-params");
write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
manager.load_plugin("ParamProbe", /*skip_deps=*/true, {});
REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error));
INFO("load error: " << error);
REQUIRE(manager.is_plugin_loaded("ParamProbe"));
// The manager serves the header settings for the key the dispatch resolves by.
const auto settings = manager.get_plugin_settings("ParamProbe");
REQUIRE(settings.count("alpha") == 1);
CHECK(settings.at("alpha") == "1.25");
// Slice with the capability selected, exactly as a preset would reference it.
Print print;
Model model;
auto config = DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"}));
config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"}));
init_print({cube(20)}, print, model, config);
print.process();
std::string observed = "<capability never executed>";
{
PythonGILState gil;
REQUIRE(static_cast<bool>(gil));
pybind11::module_ orca = pybind11::module_::import("orca");
if (pybind11::hasattr(orca, "_probe_params"))
observed = orca.attr("_probe_params").cast<std::string>();
}
INFO("ctx.params observed by Python: " << observed);
CHECK(observed.find("'alpha', '1.25'") != std::string::npos);
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
manager.unload_plugin("ParamProbe");
}