Feat/plugin config (#14746)

# Description

This PR introduces persistent, capability-scoped configuration for plugins. Users can configure plugins through the Plugins dialog instead of manually editing the plugin’s Python source.

Configuration exists at two levels:

- **Global** — one configuration per capability, shared by every preset. Edited in the Plugins dialog’s **Config** tab.
- **Per preset** — an optional override stored on a process or printer preset, edited from that preset’s **Plugin Preferences** group. A preset that overrides a capability configures the slices it drives; presets that do not simply use the global configuration.

Plugin authors can use the built-in JSON editor or provide a custom HTML settings interface. Both levels use the same editor and the same stored shape.

## New Plugin Configuration APIs

The following APIs are available to all plugin capability types.

### Python APIs

- `get_config() -> str` — Returns a raw JSON string of the capability’s effective configuration: the active preset’s override if it has one, otherwise the global configuration, otherwise `{}`.
- `save_config(config) -> bool` — Persists JSON-compatible configuration for the capability and returns whether the write succeeded. Always writes the **global** configuration (see [Preset overrides](#preset-overrides)).
- `get_config_version() -> str` — Returns the plugin version that last saved the configuration `get_config()` returned, from that same level, or `""` if it has never been saved.
- `has_config_ui() -> bool` — Override and return `True` to use a custom configuration interface instead of the built-in JSON editor.
- `get_config_ui() -> str` — Returns the HTML used to render the custom configuration interface.
- `get_default_config() -> str` — Returns a raw JSON string of the configuration applied by **Restore defaults** in the Plugins dialog. The default implementation returns `{}`.

### Custom UI JavaScript APIs

Custom configuration interfaces receive a sandboxed `window.orca` bridge:

- `window.orca.getConfig()` — Returns the current capability configuration.
- `window.orca.saveConfig(config)` — Requests that the host persist the supplied configuration.
- `window.orca.onConfig(callback)` — Immediately invokes the callback with the current configuration and invokes it again after successful saves or restores.

`saveConfig()` is asynchronous and does not return a Promise. Custom interfaces should use `onConfig()` to observe the successfully persisted state.

## Plugins Dialog

Every activated capability appears in the Plugins dialog’s **Config** tab, where its global configuration is edited.

The editor shown for a capability is selected as follows:

- If `has_config_ui()` returns `True` and `get_config_ui()` returns valid, non-empty HTML, the dialog renders the custom interface.
- Otherwise, the dialog renders the built-in JSON editor.
- If a custom interface cannot be loaded, the dialog reports the error and falls back to the JSON editor.
- The **Restore defaults** action replaces the stored configuration with the value returned by `get_default_config()`.

Custom interfaces run in a sandboxed iframe and can access configuration only through the provided `window.orca` bridge.

## Preset overrides

Process and printer presets gain a **Plugin Preferences → Capabilities** setting (Advanced mode). Its **Configure** button opens a dialog listing the capabilities that preset actually uses — the ones its `plugins` manifest declares *and* one of its plugin-backed options points at — and edits each one’s configuration for that preset alone. The button shows the number of overrides the preset carries.

That dialog offers two actions:

- **Save** — stores the edited configuration as this preset’s override.
- **Restore defaults** — discards the preset’s override, so the capability falls back to the global configuration. A preset holding no override *is* a preset at its defaults.

### How a running capability reads its configuration

`get_config()` resolves in this order:

1. The active preset’s override for this capability, if it has one.
2. The global configuration in `config.json`.
3. `{}`.

Which preset is consulted follows from the capability’s type. A plugin-backed option declares the capability type it accepts (`ConfigOptionDef::plugin_type`) and belongs to exactly one preset type, so `slicing-pipeline` capabilities are configured by the process preset and `printer-connection` capabilities by the printer preset. Nothing is hardcoded: declaring `plugin_type` on a new option is all it takes to place a new capability type on that map.

`get_config_version()` reports the version stamp from whichever level supplied the configuration, so a plugin migrating a stale config is never handed one level’s data with another level’s version.

`save_config()` from Python always writes the global configuration, never a preset — presets are the user’s to edit, and a plugin saving from a worker thread cannot mark one dirty. A capability whose active preset overrides it will therefore keep reading that override back rather than what it saved.

### Storage

A preset’s overrides live in an ordinary string setting on the preset (`plugin_preference_overrides`), holding a JSON array of entries keyed by plugin and capability. Because it is an ordinary setting, the whole preset lifecycle carries it for free: the dirty marker, the revert arrow, inheritance, project (3MF) round-tripping, and preset sync all behave exactly as they do for every other setting. The dialog is a pure editor over that text — it never writes to the preset itself and never writes to the global config file.

## Configuration Storage

All global plugin configuration is stored in a shared file:

`data_dir()/orca_plugins/config.json`

Configuration entries are isolated by plugin and capability. The host also records the plugin version that last wrote each entry.

The configuration file is intentionally stored outside individual plugin directories. This allows settings to survive:

- Plugin upgrades and reloads
- Local plugin deletion and reinstallation
- Cloud plugin unsubscribe and resubscribe operations

Reinstalling or resubscribing to the same plugin restores access to its previously saved configuration.

## Known limitations

**Filament capabilities cannot be overridden per preset.** There is no single active filament preset — one is selected per extruder — and `get_config()` does not say which extruder the capability is running for, so a filament override could only be applied by guessing. Rather than hand a plugin another extruder's settings, filament capabilities read the global configuration.

Nothing reaches this today: no filament option declares a `plugin_type`, so no capability type maps to the filament preset. Lifting it means pushing the extruder onto the plugin call context the Python trampoline already maintains and resolving the preset from that, with the extruder optional — whole slicing steps (`posSlice`, `psGCodePostProcess`) span every extruder and have no current filament.

# Tests

`tests/slic3rutils` covers the capability config store, the Python config API, the preset override layer, the capability-type → preset-type mapping, and which capabilities a preset counts as in use.

# Screenshots/Recordings/Graphs

Custom UI
<img width="855" height="703" alt="image" src="https://github.com/user-attachments/assets/745ecb7d-9e20-4c39-b857-5aa730a27142" />

Default JSON text editor
<img width="855" height="703" alt="image" src="https://github.com/user-attachments/assets/18b7b89c-6f77-4960-a9b2-964e71f74fc3" />

Process Sidebar
<img width="717" height="360" alt="image" src="https://github.com/user-attachments/assets/8fac3e66-c06a-44e4-ad4b-4cc6c003bb2b" />

Filament dialog
<img width="1090" height="832" alt="image" src="https://github.com/user-attachments/assets/ff6a4cbe-11c0-4d04-9ecb-9a717bdeb3f4" />

Printer settings dialog
<img width="1090" height="832" alt="image" src="https://github.com/user-attachments/assets/c616afb0-4eb2-40c2-92c0-7f5edc50b4e6" />

Dialog opened from preset settings
<img width="860" height="725" alt="image" src="https://github.com/user-attachments/assets/069408a8-e94b-47e0-8e16-a81e0d58b4d4" />

# Example plugin with custom UI used in screenshot
[custom_ui_screenshot_demo.py](https://github.com/user-attachments/files/29995212/custom_ui_screenshot_demo.py)

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever
2026-07-17 18:45:51 +08:00
committed by GitHub
57 changed files with 4370 additions and 674 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);
}
@@ -80,12 +80,19 @@
<div id="pluginList" class="body thin-scroll"></div>
</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">
<div class="detail-tabs" role="tablist" aria-label="Plugin details">
<button id="pluginInfoTab" class="detail-tab active" type="button" role="tab"
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
@@ -138,6 +145,39 @@
<div id="detailDescription" class="detail-description">No description available</div>
</section>
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
aria-labelledby="configTab" data-panel="config" hidden>
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
<div id="configLayout" class="config-layout" hidden>
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
aria-label="Configurable capabilities"></div>
<div class="config-view">
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
<div id="configEditor" class="config-editor" hidden>
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
</div>
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
runs in an opaque origin 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"
aria-labelledby="changelogTab" data-panel="changelog" hidden>
<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();
// why: transient per-search override on top of expandedPluginIds. A search
// auto-expands rows whose capabilities match, display-only. This lets a
// triangle click during search collapse/reopen such a row without touching
// the base (id -> bool).
// why: transient per-search override (id -> bool) on top of expandedPluginIds. A search auto-expands
// matching rows, so a triangle click during search must not touch the base expand state.
let searchExpandOverride = new Map();
let selectedPluginId = "";
let contextPluginId = "";
let activeDetailTab = "plugin-info";
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 ctxMenu = null;
let exploreMenu = 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() {
pluginList = document.getElementById("pluginList");
ctxMenu = document.getElementById("ctxMenu");
@@ -60,6 +74,23 @@ function OnInit() {
pluginList?.addEventListener("contextmenu", OnPluginContextMenu);
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) => {
if (!event.target.closest(".ctx"))
HideContextMenu();
@@ -78,6 +109,87 @@ function OnInit() {
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) {
const normalized = String(action || "");
return pluginInstallActions[normalized] ? normalized : "explore";
@@ -227,11 +339,14 @@ function HandleStudio(value) {
ApplyPlugins(payload.data || []);
} else if (payload.command === "status_message") {
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
} else if (payload.command === "capability_config") {
ApplyCapabilityConfig(payload);
} else if (payload.command === "capability_config_saved") {
ApplyCapabilityConfigSaved(payload);
}
}
// Renders the latest plugin/capability operation result in the footer status bar. The result
// persists until the next operation replaces it; the native side already localizes the text.
// Footer status bar for the latest operation result; the native side already localizes the text.
function ShowStatusMessage(message, level) {
const bar = document.getElementById("statusBar");
const text = document.getElementById("statusText");
@@ -305,7 +420,6 @@ function SyncPluginListHeaderGutter() {
}
// 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) {
if (!ranges || !ranges.length) {
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
// short-circuits (searching=false), leaving every existing render path untouched.
// short-circuits, leaving every existing render path untouched.
const searching = typeof PluginSearchActive === "function" && PluginSearchActive();
let shown = 0;
@@ -353,10 +467,8 @@ function RenderPlugins() {
continue;
shown++;
// why: transient override wins. Otherwise while searching start collapsed and auto-expand only
// capability matches (the persistent expand state is ignored so unrelated caps don't clutter
// 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.
// why: the transient override wins; while searching, auto-expand only capability matches. The
// base is never written while searching, so clearing it restores exactly what the user had.
const open = searchExpandOverride.has(pluginKey)
? searchExpandOverride.get(pluginKey)
: (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey));
@@ -427,7 +539,6 @@ function GetLatestVersion(plugin) {
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) {
const installed = GetInstalledVersion(plugin);
if (IsPluginInstalled(plugin) && installed)
@@ -476,9 +587,8 @@ function HasMixedCapabilityState(plugin) {
String(capability?.type_key || "")
);
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
return hasEnabled && hasDisabled;
return toggleableCapabilities.length > 0 && hasDisabled;
}
function IsPluginLoading(plugin) {
@@ -543,7 +653,6 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges =
const labelCell = document.createElement("span");
labelCell.className = "label-cell";
// Add hyperlink
const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed";
const pluginLabelText = plugin.label || plugin.name || plugin.plugin_id || "";
const canExpand = capabilityCount > 0;
@@ -785,6 +894,345 @@ function RenderDetails() {
ApplyDetailUpdateBadge(detailUpdateBadge, plugin);
if (detailUpdateBtn)
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) {
@@ -809,8 +1257,8 @@ function RenderDescription(plugin) {
node.replaceChildren();
// Descriptions come only from the plugin's Python header (local/installed plugins). A cloud plugin
// that is not installed yet has no header, so show a link to view it on OrcaCloud instead.
// Descriptions come from the plugin's Python header; a cloud plugin that is not installed yet has
// no header, so link to OrcaCloud instead.
const description = String(plugin?.description || "").trim();
if (description && description !== "No description.") {
node.textContent = description;
@@ -881,8 +1329,8 @@ function SetText(id, text) {
function ApplyDetailUpdateBadge(node, plugin) {
node.className = "version-update-badge";
// The "update_available" state is represented by the actionable Update button next to the
// version, so the detail panel only shows the passive badge for the unauthorized warning.
// "update_available" is already the actionable Update button, so the badge only warns about
// unauthorized.
const updateStatus = plugin ? GetUpdateStatus(plugin) : "normal";
if (updateStatus === "unauthorized") {
node.hidden = false;
@@ -982,9 +1430,8 @@ function OnPluginListClick(event) {
const pluginKey = String(block.dataset.pluginKey || "");
selectedPluginId = pluginKey;
// why: during a search the triangle writes to the transient override (read from the on-screen open
// state), so an auto-expanded row collapses without touching the saved layout. With no search
// active, toggle the persistent base exactly as before.
// why: during a search the triangle writes to the transient override (read from the on-screen
// open state), so an auto-expanded row collapses without touching the saved layout.
if (typeof PluginSearchActive === "function" && PluginSearchActive())
searchExpandOverride.set(pluginKey, !block.classList.contains("expanded"));
else if (expandedPluginIds.has(pluginKey))
+216 -3
View File
@@ -151,11 +151,13 @@ body {
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 {
min-height: 0;
display: grid;
grid-template-rows: minmax(220px, 1fr) 280px;
gap: 8px;
display: flex;
flex-direction: column;
}
.pane {
@@ -166,17 +168,60 @@ body {
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 {
flex: 0 1 auto;
height: var(--plugin-list-height, 62%);
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 {
flex: 1 1 0;
grid-template-rows: auto minmax(0, 1fr);
padding: 12px;
box-sizing: border-box;
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 {
font-weight: 600;
font-size: 12px;
@@ -1084,6 +1129,174 @@ body {
display: none;
}
/* Config tab: fixed-width capability sidebar + the capability's configuration view. */
.config-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
height: 100%;
min-height: 0;
padding: 6px;
box-sizing: border-box;
}
.config-layout[hidden] {
display: none;
}
.config-sidebar {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
border-right: 1px solid var(--border-soft);
}
.config-cap {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.config-cap:hover {
background: var(--row-hover);
}
.config-cap.selected {
background: var(--row-selected);
border-color: var(--row-selected-outline);
}
.config-cap-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-cap-type {
color: var(--muted);
font-size: 11px;
}
.config-view {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
min-width: 0;
}
.config-error {
padding: 6px 8px;
border-radius: 4px;
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
font-size: 12px;
}
.config-error[hidden] {
display: none;
}
.config-editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.config-editor[hidden] {
display: none;
}
.config-textarea {
flex: 1;
min-height: 0;
padding: 8px;
box-sizing: border-box;
/* common.css applies `user-select: none` to *, so without this the user could type into the
editor but not select, drag or copy what they had typed. (What blocks typing is the global
onkeydown guard in common.js — see the keydown handler in index.js.) */
-webkit-user-select: text;
user-select: text;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
resize: none;
white-space: pre;
overflow: auto;
}
.config-textarea:focus {
outline: none;
border-color: var(--main-color);
}
.config-view-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.config-view-footer[hidden] {
display: none;
}
/* Restore sits immediately left of Save, both pinned right; the validation message takes the
remaining space on the left. */
.config-actions {
display: flex;
align-items: center;
gap: 8px;
}
.config-actions > button[hidden] {
display: none;
}
.config-validation {
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-validation.invalid {
color: var(--plugin-status-danger);
}
.config-custom {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.config-custom[hidden] {
display: none;
}
.detail-table {
width: 100%;
border-collapse: collapse;
+1
View File
@@ -62,6 +62,7 @@
--col-sep: #4a4a51;
--row-hover: #2b3340;
--row-selected: #244945;
--plugin-link-text: #5eead4;
--plugin-status-danger: #ff7b72;
--plugin-status-ok: #37c871;
--plugin-status-warn: #f0b45a;
+7 -9
View File
@@ -7,12 +7,6 @@
# author = "OrcaSlicer"
# version = "0.01"
# 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.
@@ -44,6 +38,7 @@ Polygon.as_array()/set_points numpy path would be the faster route.
"""
import math
import random
import json
import orca
@@ -55,9 +50,9 @@ _DEFAULTS = {
}
def _params(ctx):
def _params(self):
try:
src = dict(ctx.params)
src = json.loads(self.get_config())
except (AttributeError, TypeError):
src = {}
out = {}
@@ -111,11 +106,14 @@ class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Fuzzy Slices"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
p = _params(ctx)
p = _params(self)
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")
+14 -11
View File
@@ -7,9 +7,6 @@
# author = "OrcaSlicer"
# version = "0.01"
# 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.
@@ -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
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
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
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.
"""
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:
text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP)
except (AttributeError, TypeError):
text = _DEFAULT_STAMP
return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP
text = json.loads(self.get_config())["stamp_text"]
except (AttributeError, TypeError, ValueError, KeyError):
text = _DEFAULTS["stamp_text"]
return str(text).replace("\n", " ").strip() or _DEFAULTS["stamp_text"]
class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "G-code Stamp"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx):
# 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:
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
# 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.
"""
import json
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):
def get_name(self):
return "Inset Every Slice"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
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).
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
inset_scaled = int(round(inset_mm / orca.slicing.unscale(1)))
regions_touched = 0
for layer in ctx.object.layers():
+10 -14
View File
@@ -7,13 +7,6 @@
# author = "OrcaSlicer"
# version = "0.02"
# 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.
@@ -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
(no per-surface type carry needed), and no numpy is required --
rotate/scale/translate are host methods. Parameters come from ctx.params (the
settings table above). The first object layer is untouched (z_rel = 0), so bed
adhesion is unaffected.
rotate/scale/translate are host methods. Parameters come from self.get_config()
(see _params below), which the host seeds from get_default_config(). The first
object layer is untouched (z_rel = 0), so bed adhesion is unaffected.
"""
import math
import json
import orca
_DEFAULTS = {
@@ -48,9 +41,9 @@ _DEFAULTS = {
}
def _params(ctx):
def _params(self):
try:
src = dict(ctx.params)
src = json.loads(self.get_config())
except (AttributeError, TypeError):
src = {}
out = {}
@@ -79,12 +72,15 @@ def _layer_params(z_rel, mm_to_scaled, p):
class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Twistify"
def get_default_config(self):
return _DEFAULTS
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
p = _params(ctx)
p = _params(self)
if _is_identity(p):
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.
one_string,
plugin_picker,
// Raw JSON string value, edited through a dialog behind a button rather than in the row.
plugin_config,
};
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
+4 -1
View File
@@ -1203,6 +1203,7 @@ static std::vector<std::string> s_Preset_print_options{
"post_process",
"slicing_pipeline_plugin",
"plugins",
"plugin_config_overrides",
"process_change_extrusion_role_gcode",
"min_length_factor",
"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_change_length_nc", "filament_prime_volume_nc",
"long_retractions_when_ec", "retraction_distances_when_ec",
"plugin_config_overrides",
//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_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",
// 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).
"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 {
+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->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli;
// Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a
// plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its
// "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed).
// Plugin-backed (see ConfigOptionDef::is_plugin_backed), but edited through the Choice widget above
// rather than a plugin_picker field.
def->plugin_type = "printer-connection";
def->set_default_value(new ConfigOptionString(""));
@@ -1081,6 +1080,17 @@ void PrintConfigDef::init_common_params()
def = this->add("preset_name", coString);
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()
+4
View File
@@ -118,6 +118,8 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginPickerDialog.hpp
GUI/PluginsDialog.cpp
GUI/PluginsDialog.hpp
GUI/PluginsConfigDialog.cpp
GUI/PluginsConfigDialog.hpp
GUI/ProcessRunner.cpp
GUI/ProcessRunner.hpp
GUI/TerminalDialog.cpp
@@ -617,6 +619,8 @@ set(SLIC3R_GUI_SOURCES
plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp
plugin/PluginFsUtils.hpp
plugin/PluginConfig.cpp
plugin/PluginConfig.hpp
plugin/PluginLoader.cpp
plugin/PluginLoader.hpp
plugin/PluginDescriptor.hpp
+122 -7
View File
@@ -23,6 +23,8 @@
#include "OG_CustomCtrl.hpp"
#include "MsgDialog.hpp"
#include "BitmapComboBox.hpp"
#include "PluginsConfigDialog.hpp"
#include "Widgets/Button.hpp"
// BBS
#include "Notebook.hpp"
@@ -1995,8 +1997,6 @@ void Choice::msw_rescale()
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);
wxGetApp().UpdateDarkUI(panel);
window = panel;
@@ -2004,7 +2004,6 @@ void PluginField::BUILD()
m_main_sizer = new wxBoxSizer(wxVERTICAL);
panel->SetSizer(m_main_sizer);
// Initialize with default values or empty
if (m_opt.type == coStrings) {
const ConfigOptionStrings* vec = m_opt.get_default_value<ConfigOptionStrings>();
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);
auto row_sizer = new wxBoxSizer(wxHORIZONTAL);
// Select button with search icon
ScalableButton* select_btn = new ScalableButton(window, wxID_ANY, "search", wxEmptyString,
button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16);
wxGetApp().UpdateDarkUI(select_btn);
select_btn->SetToolTip(_L("Select plugin"));
// Display text control
wxTextCtrl* display = new wxTextCtrl(window, wxID_ANY, value,
wxDefaultPosition, wxSize(def_width_wider() * m_em_unit, wxDefaultCoord),
wxTE_READONLY);
@@ -2117,7 +2114,6 @@ void PluginField::add_plugin_row(const wxString& value, bool is_last)
remove_btn->SetToolTip(_L("Remove plugin"));
}
// Add button (only on last row)
ScalableButton* add_btn = nullptr;
if (is_last && !m_opt.readonly) {
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;
// Handle different input types
if (value.empty()) {
m_values.clear();
} else if (value.type() == typeid(std::vector<std::string>)) {
@@ -2309,6 +2304,126 @@ void PluginField::msw_rescale()
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()
{
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
#endif
// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. It lives at global scope.
class Button;
namespace Slic3r { namespace GUI {
class Field;
@@ -483,9 +486,8 @@ public:
void enable() override;
void disable() override;
// Window-field: the dynamic rows live inside a single container panel (assigned to the base
// `window` in BUILD), so the field exposes one window instead of a bare sizer. This lets focus/
// scroll, full-width sizing and teardown operate on the whole field.
// The rows live in one container panel (the base `window`), so the field exposes a window instead
// of a bare sizer and focus, sizing and teardown apply to the whole field.
wxWindow* getWindow() override { return window; }
void msw_rescale() override;
@@ -517,6 +519,45 @@ private:
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 {
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;
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create<PluginConfigField>(this->ctrl_parent(), opt, id)); break;
default:
switch (opt.type) {
case coFloatOrPercent:
@@ -126,6 +127,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.
return field;
}
@@ -714,9 +721,8 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt)
if (selection.plugin_key.empty() || selection.name.empty())
return {};
// Validate that the selected capability's plugin is resolvable, but only the bare capability
// name is stored in the option; the full "name;uuid;capability" reference is derived lazily
// when the preset is serialized (see ConfigBase::save_plugin_collection).
// Only the bare capability name is stored; the full "name;uuid;capability" reference is derived when
// the preset is serialized (see ConfigBase::save_plugin_collection). Resolve here to reject bad picks.
Slic3r::PluginDescriptor descriptor;
if (!manager.try_get_plugin_descriptor(selection.plugin_key, descriptor) || descriptor.name.empty())
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
+113 -83
View File
@@ -4,6 +4,7 @@
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "slic3r/plugin/PluginConfig.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
@@ -16,11 +17,8 @@
#include <slic3r/GUI/format.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <pybind11/embed.h>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
@@ -41,9 +39,9 @@
namespace Slic3r { namespace GUI {
namespace {
const wxString kDeletePluginTitle = _L("Delete Plugin");
const wxString kUnsubscribeTitle = _L("Unsubscribe");
const wxString kOverwritePluginTitle = _L("Overwrite Plugin");
const wxString kDeletePluginTitle = _L("Delete Plugin");
const wxString kUnsubscribeTitle = _L("Unsubscribe");
const wxString kOverwritePluginTitle = _L("Overwrite Plugin");
std::string s_selected_plugin_install_action = "explore";
struct PluginContextAction
@@ -63,12 +61,13 @@ struct PluginAvailableActions
struct PluginCapabilityView
{
std::string name;
std::string type_label;
std::string type_key;
PluginCapabilityId id;
bool enabled = false;
bool can_toggle = 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
@@ -81,7 +80,6 @@ struct PluginChangelogView
// View-model for one plugin row in the dialog
struct PluginDialogItem
{
// Identity and display text
std::string plugin_key;
std::string plugin_id;
std::string display_name;
@@ -90,7 +88,7 @@ struct PluginDialogItem
std::string version;
std::string installed_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_key;
std::string sharing_token;
@@ -98,16 +96,14 @@ struct PluginDialogItem
std::vector<std::string> type_labels;
std::vector<PluginChangelogView> changelog;
// Derived UI state
PluginSource source = PluginSource::Local;
PluginStatus status = PluginStatus::Inactive;
PluginUpdateStatus update_status = PluginUpdateStatus::Normal;
std::string error_text;
bool has_error = false;
bool is_loaded = false;
bool is_loaded = false;
bool loading = false;
// Installation and capability flags
bool is_cloud_plugin = false;
bool has_local_package = false;
bool unauthorized = false;
@@ -117,7 +113,6 @@ struct PluginDialogItem
// Runtime capabilities in registration order, or descriptor-only type rows when unloaded.
std::vector<PluginCapabilityView> capabilities;
// Row-level actions
PluginAvailableActions available_actions;
};
@@ -177,15 +172,13 @@ void refresh_plugin_metadata_blocking(bool fetch_cloud)
return;
for (const auto& uuid : not_found)
plater->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s is no longer available."), uuid));
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s is no longer available."), uuid));
for (const auto& uuid : unauthorized)
plater->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s access is unauthorized."), uuid));
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s access is unauthorized."), uuid));
});
}
@@ -208,16 +201,26 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
nlohmann::json caps = nlohmann::json::array();
for (const PluginCapabilityView& capability : dialog_item.capabilities) {
nlohmann::json c;
c["name"] = capability.name;
c["type"] = capability.type_label;
c["type_key"] = capability.type_key;
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
c["name"] = capability.id.name;
c["type"] = plugin_capability_type_display_name(capability.id.type);
c["type_key"] = plugin_capability_type_to_string(capability.id.type);
c["enabled"] = capability.enabled;
c["can_toggle"] = capability.can_toggle;
c["can_run"] = capability.can_run;
c["has_config_ui"] = capability.has_config_ui;
caps.push_back(std::move(c));
}
payload_item["capabilities"] = std::move(caps);
// The Config tab's sidebar, built by the shared builder so it stays identical to PluginsConfigDialog's.
// 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();
for (const PluginChangelogView& entry : dialog_item.changelog) {
nlohmann::json c;
@@ -309,31 +312,28 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
PluginDialogItem item;
PluginManager& manager = PluginManager::instance();
item.plugin_key = descriptor.plugin_key;
item.display_name = descriptor.name;
item.description = !descriptor.is_metadata_valid() && descriptor.has_error() ?
descriptor.normalized_error() :
(descriptor.description.empty() ? "No description." : descriptor.description);
item.author = descriptor.author;
item.version = descriptor.version;
// Installed version: the cloud merge overwrites `version` with the latest cloud version, so prefer
// the preserved local version, falling back to `version` for local-only / pre-merge descriptors.
item.plugin_key = descriptor.plugin_key;
item.display_name = descriptor.name;
item.description = !descriptor.is_metadata_valid() && descriptor.has_error() ?
descriptor.normalized_error() :
(descriptor.description.empty() ? "No description." : descriptor.description);
item.author = descriptor.author;
item.version = descriptor.version;
// The cloud merge overwrites `version` with the latest cloud version, so prefer the preserved local
// one, falling back to `version` for local-only / pre-merge descriptors.
item.installed_version = descriptor.has_local_package() ?
(descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version) :
std::string{};
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;
// A package that is not loaded has no capabilities, so its row contributes none and does not
// expand.
const std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities =
manager.get_plugin_capabilities(descriptor.plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
const PluginCapabilityType primary_type = capabilities.empty() ? PluginCapabilityType::Unknown :
capabilities.front()->type();
item.type_label = plugin_capability_type_to_string(primary_type);
item.type_key = plugin_capability_type_to_string(primary_type);
const PluginCapabilityType primary_type = capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type();
item.type_label = 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
// service returned (which may not map to real capability types); local plugins derive them
// from the capabilities actually loaded.
@@ -361,8 +361,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.loading = manager.is_plugin_load_in_progress(descriptor.plugin_key);
item.has_script_capability = false;
for (const auto& capability : capabilities) {
item.capabilities.push_back({capability->name(), plugin_capability_type_display_name(capability->type()),
plugin_capability_type_to_string(capability->type()), capability->is_enabled(), true, false});
item.capabilities.push_back({capability->identity(), capability->is_enabled(), true, false, capability->config_ui_available()});
if (capability->type() == PluginCapabilityType::Script)
item.has_script_capability = true;
}
@@ -381,12 +380,12 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.available_actions = evaluate_action_policy(item);
const bool has_enabled_script = std::any_of(item.capabilities.begin(), item.capabilities.end(),
[](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.loading && has_enabled_script;
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;
}
@@ -484,8 +483,9 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload)
// 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
// 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
// handler, current and future, off that stack by construction.
// b779a7bfed/f2ccbfc8b5. Deferring here keeps every command handler in THIS dialog off
// 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]() {
if (alive->load(std::memory_order_acquire))
handle_web_command(payload);
@@ -520,6 +520,21 @@ void PluginsDialog::handle_web_command(const nlohmann::json& payload)
open_plugin_hub();
} else if (command == "set_plugin_sort") {
set_plugin_sort(payload.value("sort_key", ""), payload.value("sort_order", ""));
} else if (command == "get_capability_config" || 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") {
const std::string action = payload.value("action", "");
if (action == "explore" || action == "install-local")
@@ -554,7 +569,6 @@ nlohmann::json PluginsDialog::build_plugins_payload() const
for (const PluginDescriptor& row : rows)
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);
for (const PluginDialogItem& item : items)
@@ -571,15 +585,6 @@ bool PluginsDialog::get_descriptor(const std::string& plugin_key, PluginDescript
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)
{
run_with_dialog([fetch_cloud]() { refresh_plugin_metadata_blocking(fetch_cloud); }, [this]() { send_plugins(); }, title, message);
@@ -644,8 +649,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;
// download_and_install_cloud_plugin updates the descriptor with the installed
// local package state, so no rescan or cloud fetch is needed before loading.
// download_and_install_cloud_plugin already updated the descriptor, so no rescan is needed here.
if (!get_descriptor(plugin_key, row_data)) {
send_plugins();
return;
@@ -683,8 +687,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");
}
void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, PluginCapabilityType type,
const std::string& capability_name, bool enabled)
void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key,
PluginCapabilityType type,
const std::string& capability_name,
bool enabled)
{
if (plugin_key.empty() || capability_name.empty() || type == PluginCapabilityType::Unknown)
return;
@@ -695,7 +701,7 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug
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;
send_plugins();
return;
@@ -704,11 +710,11 @@ void PluginsDialog::toggle_plugin_capability(const std::string& plugin_key, Plug
PluginManager& manager = PluginManager::instance();
if (enabled) {
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 {
// check if the capability is currently in use here.
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();
@@ -897,6 +903,35 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
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)
{
PluginManager& manager = PluginManager::instance();
@@ -914,8 +949,8 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
send_plugins();
// The row now shows an "Error" status and the Diagnostics tab holds the full text, so surface
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override).
// The row already shows "Error" and the Diagnostics tab holds the full text, so report in the
// footer status bar instead of a modal box.
const wxString message = status_message.empty() ? from_u8(normalized_error) : status_message;
show_status(message, "error");
};
@@ -942,7 +977,7 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
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");
@@ -958,15 +993,13 @@ void PluginsDialog::update_plugin(const std::string& plugin_key)
PluginDescriptor descriptor;
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
// reinstalls the latest version. Each of those steps already runs off the main thread in the
// delete/install paths, so run the whole operation on the worker and pump a progress dialog.
// update_cloud_plugin unloads the old plugin, deletes its local package and reinstalls the latest
// version; all of that is off-main-thread work, so run it on the worker behind a progress dialog.
std::string error;
bool updated = false;
try {
updated = run_with_dialog_wait(
[plugin_key, &error]() { return PluginManager::instance().update_cloud_plugin(plugin_key, error); },
_L("Updating plugin"), _L("Updating") + ": " + name);
updated = run_with_dialog_wait([plugin_key, &error]() { return PluginManager::instance().update_cloud_plugin(plugin_key, error); },
_L("Updating plugin"), _L("Updating") + ": " + name);
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
@@ -980,8 +1013,7 @@ void PluginsDialog::update_plugin(const std::string& plugin_key)
return;
}
// update_cloud_plugin installs the package and updates the in-memory descriptor
// (installed=true, update_available=false) on success.
// update_cloud_plugin already updated the in-memory descriptor, so a UI refresh is enough here.
send_plugins();
show_status(wxString::Format(_L("Updated \"%s\"."), name), "success");
}
@@ -1109,8 +1141,7 @@ void PluginsDialog::reinstall_local_plugin(const std::string& plugin_key)
manager.load_plugin(plugin_key, false);
std::string error;
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) ||
!manager.is_plugin_loaded(plugin_key))
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || !manager.is_plugin_loaded(plugin_key))
return {false, error.empty() ? "Plugin failed to load." : error};
if (!was_loaded && !manager.unload_plugin(plugin_key))
@@ -1142,7 +1173,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin)
return;
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;
if (plugin.has_local_package()) {
@@ -1168,8 +1199,7 @@ void PluginsDialog::reinstall_cloud_plugin(const PluginDescriptor& plugin)
PluginManager& manager = PluginManager::instance();
manager.load_plugin(plugin_key);
std::string error;
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) ||
!manager.is_plugin_loaded(plugin_key))
if (!manager.wait_for_plugin_load(plugin_key, std::chrono::minutes(5), error) || !manager.is_plugin_loaded(plugin_key))
return {false, error.empty() ? "Plugin failed to load." : error};
return {true, {}};
},
+8 -2
View File
@@ -29,6 +29,7 @@ class wxTimer;
namespace Slic3r {
class PluginCapabilityInterface;
struct PluginCapabilityId;
enum class PluginCapabilityType;
namespace GUI {
@@ -41,7 +42,7 @@ public:
const wxString& title = wxT(""),
const wxPoint& pos = wxDefaultPosition,
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();
@@ -65,7 +66,6 @@ private:
nlohmann::json build_plugins_payload() 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_plugins();
@@ -77,6 +77,12 @@ private:
bool install_plugin_package(const std::string& package_path);
bool install_cloud_plugin(const std::string& uuid, const std::string& version, const wxString& name);
void run_script_plugin_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"),
// 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);
-4
View File
@@ -270,10 +270,6 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
ctx.host = host;
ctx.output_name = output_name;
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;
try {
+13 -3
View File
@@ -1793,9 +1793,8 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
return;
}
// Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset
// always carries resolved "name;uuid;capability" references that full_config() and save_to_json()
// then pass downstream as-is -- no separate rebuild anywhere else.
// Keep this preset's "plugins" manifest in sync when a plugin picker changes, so full_config() and
// save_to_json() always find resolved "name;uuid;capability" references and rebuild it nowhere else.
if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key);
opt_def && opt_def->is_plugin_backed())
m_config->update_plugin_manifest();
@@ -3123,6 +3122,11 @@ void TabPrint::build()
option.opt.full_width = true;
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);
option = optgroup->get_option("notes");
option.opt.full_width = true;
@@ -4524,6 +4528,9 @@ void TabFilament::build()
option.opt.height = gcode_field_height;// 150;
optgroup->append_single_option_line(option);
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
optgroup->append_single_option_line("plugin_config_overrides");
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
@@ -5030,6 +5037,9 @@ void TabPrinter::build_fff()
// optgroup->append_single_option_line("spaghetti_detector");
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");
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";
+3 -1
View File
@@ -21,7 +21,9 @@ public:
const wxString& title = wxT(""),
const wxPoint& pos = wxDefaultPosition,
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;
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();
auto cap = plugin_manager.get_plugin_capability(plugin_key, capability_name, PluginCapabilityType::PrinterConnection,
/*only_enabled=*/false);
auto cap = plugin_manager.get_plugin_capability({PluginCapabilityType::PrinterConnection, capability_name, plugin_key},
/*only_enabled=*/false);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Printer-agent capability '" << capability_name << "' not found for plugin '" << plugin_key << "'";
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.
// 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.
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) {
BOOST_LOG_TRIVIAL(debug) << "Printer-agent capability '" << capability_name << "' from plugin '" << plugin_key
<< "' 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";
}
// 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,
std::vector<std::string>& not_found,
std::vector<std::string>& unauthorized)
@@ -3137,11 +3148,7 @@ int OrcaCloudServiceAgent::fetch_subscribed_manifests_into_descriptors(std::vect
descriptor.plugin_key = uuid;
// 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.
descriptor.author = get_json_string_field(item, "author");
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.author = parse_cloud_author(item);
descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version;
// 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;
// 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.
descriptor.author = get_json_string_field(item, "author");
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.author = parse_cloud_author(item);
descriptor.version = get_json_string_field(item, "version");
descriptor.latest_version = descriptor.version;
// 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_capability_name = "";
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 bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, PluginAuditManager::AuditMode mode)
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name,
PluginAuditManager::AuditMode mode)
: m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_mode(PluginAuditManager::instance().audit_mode())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
{
PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::instance().set_audit_mode(mode);
PluginAuditManager::m_scoped_allowed_roots.clear();
}
@@ -95,6 +100,7 @@ ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key
ScopedPluginAuditContext::~ScopedPluginAuditContext()
{
PluginAuditManager::instance().set_current_plugin(m_previous_id);
PluginAuditManager::instance().set_current_capability(m_previous_capability);
PluginAuditManager::instance().set_audit_mode(m_previous_mode);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
}
@@ -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::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)
{
if (root.empty())
+14 -1
View File
@@ -40,6 +40,14 @@ public:
std::string current_plugin() const;
void clear_current_plugin();
// --- current-capability context (thread_local) ---
// The capability whose method is currently executing, within the current plugin. Empty
// while a plugin-wide call runs, and during capture (get_name/get_type), where the
// capability has no cached name yet.
void set_current_capability(const std::string& capability_name);
std::string current_capability() const;
void clear_current_capability();
// --- allowed-roots registry ---
void add_global_allowed_root(const 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 thread_local std::string m_current_plugin_key;
static thread_local std::string m_current_capability_name;
static thread_local AuditMode m_audit_mode;
static thread_local std::vector<boost::filesystem::path> m_scoped_allowed_roots;
static thread_local bool m_has_last_violation;
@@ -85,12 +94,15 @@ private:
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
{
public:
explicit ScopedPluginAuditContext(
const std::string& plugin_key,
const std::string& capability_name = {},
PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading);
~ScopedPluginAuditContext();
@@ -100,6 +112,7 @@ public:
private:
std::string m_previous_id;
std::string m_previous_capability;
PluginAuditManager::AuditMode m_previous_mode;
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_package; // Import package/module used for package-based loading
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::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;
if (target.dependencies.empty())
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:
+13 -14
View File
@@ -31,10 +31,16 @@ namespace Slic3r {
const char* const INSTALL_STATE_FILE = ".install_state.json";
std::string get_orca_plugins_dir()
{
namespace fs = boost::filesystem;
return (fs::path(data_dir()) / "orca_plugins").string();
}
std::string get_cloud_plugin_dir(const std::string& user_id)
{
namespace fs = boost::filesystem;
return (fs::path(data_dir()) / "orca_plugins" / PLUGIN_SUBSCRIBED_DIR / user_id).string();
return (fs::path(get_orca_plugins_dir()) / PLUGIN_SUBSCRIBED_DIR / user_id).string();
}
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor)
@@ -48,8 +54,7 @@ boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescript
return {};
}
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
const std::vector<std::string>& allowed_dirs)
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, const std::vector<std::string>& allowed_dirs)
{
boost::system::error_code ec;
boost::filesystem::path resolved_root = boost::filesystem::weakly_canonical(candidate_root, ec);
@@ -103,9 +108,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
return true;
}
bool delete_plugin_root(const boost::filesystem::path& resolved_root,
const std::string& plugin_id,
std::string& error)
bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std::string& plugin_id, std::string& error)
{
namespace fs = boost::filesystem;
@@ -432,7 +435,6 @@ bool parse_pep723_toml(const std::string& toml_content,
std::string& out_description,
std::string& out_author,
std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error)
{
out_deps.clear();
@@ -441,7 +443,6 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear();
out_author.clear();
out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root;
@@ -466,7 +467,10 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin;
} 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 {
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 == "author") out_author = 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_author,
pep_version,
descriptor.settings,
pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false;
+70
View File
@@ -2,8 +2,12 @@
#include "PluginDescriptor.hpp"
#include <nlohmann/json.hpp>
#include <pybind11/pybind11.h>
#include <boost/filesystem/path.hpp>
#include <cstdint>
#include <string>
#include <vector>
@@ -13,6 +17,70 @@ namespace Slic3r {
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 {
std::string installed_from; // "local" | "cloud"
std::string installed_version;
@@ -26,6 +94,8 @@ struct PluginInstallState {
// Path: {data_dir}/orca_plugins/_subscribed/{user_id}/
std::string get_cloud_plugin_dir(const std::string& user_id);
std::string get_orca_plugins_dir();
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor);
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
-6
View File
@@ -70,9 +70,6 @@ void install_slicing_pipeline_hook()
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ExecutionResult r;
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
// is released between capabilities.
PythonGILState gil;
@@ -87,9 +84,6 @@ void install_slicing_pipeline_hook()
ctx.step = step;
ctx.print = &print;
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);
} catch (const CanceledException&) {
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_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));
}
} catch (const std::exception& ex) {
+41 -26
View File
@@ -1,5 +1,6 @@
#include "PluginManager.hpp"
#include <libslic3r/Utils.hpp>
#include <memory>
#include <pybind11/embed.h>
@@ -19,6 +20,7 @@
#include <algorithm>
#include <chrono>
#include <mutex>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp>
@@ -83,6 +85,12 @@ bool PluginManager::initialize()
m_initialized = true;
}
// Bring every capability's stored config into memory. Deliberately unconditional and
// independent of which plugins are installed: an entry outlives uninstall/unsubscribe, so a
// plugin that comes back later finds its settings intact. A missing or malformed file just
// leaves the store empty (see PluginConfig::load), never blocking startup.
m_config.load();
// Install the libslic3r hooks (capability resolver, slicing-pipeline dispatcher).
// Uninstalled in shutdown() before the interpreter finalizes.
plugin_hooks::install();
@@ -142,6 +150,12 @@ void PluginManager::shutdown()
unload_all_plugins();
PythonPluginBridge::instance().clear_pending_captures();
// Every config write already goes to disk as it happens (store_capability_config), so this
// only catches an in-memory-only mutation. Note we flush rather than clear: unloading the
// plugins above must never discard their stored config.
if (m_config.dirty())
m_config.save();
// 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,
// 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;
}
std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(const std::string& plugin_key,
const std::string& capability_name,
PluginCapabilityType type,
bool only_enabled) const
std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(const PluginCapabilityId& id, bool only_enabled) const
{
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)
return nullptr;
for (const auto& capability : plugin->capabilities) {
if (!capability || capability->name() != capability_name)
if (!capability || capability->name() != id.name)
continue;
if (type != PluginCapabilityType::Unknown && capability->type() != type)
if (id.type != PluginCapabilityType::Unknown && capability->type() != id.type)
continue;
if (only_enabled && !capability->is_enabled())
continue;
@@ -541,17 +552,6 @@ std::shared_ptr<PluginCapabilityInterface> PluginManager::get_plugin_capability(
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 ───────────────────────────────────────────────────────────────────────────────
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) {
if (std::find(loaded_capability_names.begin(), loaded_capability_names.end(), capability_name) !=
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);
@@ -844,6 +844,17 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de
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 cancelled = false;
std::string registry_error;
@@ -993,8 +1004,7 @@ void PluginManager::unload_cloud_plugins()
}
// ── Enable state ────────────────────────────────────────────────────────────────────────────
void PluginManager::set_capability_enabled(const std::string& plugin_key, const std::string& capability_name, bool enabled)
void PluginManager::set_capability_enabled(const PluginCapabilityId& id, bool enabled)
{
PluginCapabilityId changed;
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);
Plugin* plugin = find_plugin_locked(plugin_key);
Plugin* plugin = find_plugin_locked(id.plugin_key);
if (plugin == nullptr || !plugin->is_loaded())
return;
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;
capability->set_enabled(enabled);
changed = PluginCapabilityId{capability->type(), capability->name(), plugin_key};
changed = capability->identity();
did_change = true;
break;
}
@@ -1019,7 +1032,7 @@ void PluginManager::set_capability_enabled(const std::string& plugin_key, const
if (!did_change)
return;
write_loaded_plugin_install_state(plugin_key);
write_loaded_plugin_install_state(id.plugin_key);
if (enabled)
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)
{
std::lock_guard<std::mutex> state_lock(m_install_state_mutex);
PluginDescriptor descriptor;
std::vector<std::pair<std::string, bool>> capabilities;
{
@@ -1931,7 +1946,7 @@ ExecutionResult PluginManager::run_script_capability(const std::string& plugin_k
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)
return {};
+13 -18
View File
@@ -23,21 +23,12 @@
#include "PluginFsUtils.hpp"
#include "PluginDescriptor.hpp"
#include "PluginLoader.hpp"
#include "PluginConfig.hpp"
namespace Slic3r {
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 +
// N materialized capabilities.
//
@@ -126,6 +117,9 @@ public:
// Manually trigger a manifest-only rescan. Blocks until discovery is complete.
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_in_progress() const;
std::string get_discovery_error() const;
@@ -149,10 +143,9 @@ public:
const std::string& plugin_key = "", // "" => all plugins
PluginCapabilityType type = PluginCapabilityType::Unknown, // Unknown => all types
bool only_enabled = true) const;
std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const std::string& plugin_key,
const std::string& capability_name,
PluginCapabilityType type = PluginCapabilityType::Unknown,
bool only_enabled = true) const;
std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const PluginCapabilityId& id, bool only_enabled = true) const;
// Try to resolve from just capability name and type from presets.
std::shared_ptr<PluginCapabilityInterface> get_plugin_capability(const std::string& capability_name,
PluginCapabilityType type = PluginCapabilityType::Unknown,
bool only_enabled = true) const;
@@ -169,10 +162,8 @@ public:
bool cancel_plugin_load(const std::string& plugin_key);
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.
void set_cloud_user(const std::string& user_id);
@@ -264,6 +255,7 @@ private:
bool m_initialized = false;
CloudPluginService m_cloud_service;
PluginConfig m_config;
// 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
@@ -283,6 +275,9 @@ private:
std::map<std::string, std::string> m_load_errors;
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<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
// 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) {
BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name << "' for plugin '" << plugin_key << "'; skipping";
continue;
+116 -27
View File
@@ -12,6 +12,7 @@
#include <boost/log/trivial.hpp>
#include <libslic3r/Config.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <vector>
#include <wx/utils.h>
@@ -20,35 +21,39 @@
#include <map>
#include <mutex>
#include <thread>
#include <tuple>
#include <unordered_map>
namespace Slic3r {
namespace {
// Return the name of the tracked option in `preset` whose value references `ref`'s capability, or
// an empty string when no active option uses it. The result doubles as the "Jump to" target and as
// the signal that the plugin is still required: a missing plugin with no referencing option is
// considered resolved and dropped from the missing set.
std::string find_option_for_capability(Preset::Type type, const Preset& preset, const PluginCapabilityRef& ref)
// The tracked option in `preset` whose value references `ref`'s capability, or "" when none does.
// Doubles as the "Jump to" target and as the signal that the plugin is still required: a missing
// plugin with no referencing option is dropped from the set.
std::string find_option_for_capability(Preset::Type type,
const Preset& preset,
const PluginCapabilityRef& ref,
PluginCapabilityType capability_type = PluginCapabilityType::Unknown)
{
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT)
return {};
// Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type),
// so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed
// preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
// Options opt in via ConfigOptionDef::is_plugin_backed, so scan the definition rather than keep a
// hardcoded per-type field list. A typed preset's config only holds keys for its own type.
const ConfigDef* def = preset.config.def();
if (def == nullptr)
return {};
const std::string expected_type = plugin_capability_type_to_string(capability_type);
const auto matches_ref = [&ref](const std::string& value) {
return value == ref.capability_name;
};
for (const std::string& field : preset.config.keys()) {
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;
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
// 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 {};
}
} // namespace
// One missing-plugin 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.
// One set per tracked preset type, keyed by the full "name;uuid;capability" ref.
static std::map<Preset::Type, std::unordered_map<std::string, MissingPlugin>> s_missing;
static std::mutex s_missing_mutex;
// 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;
}
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()
{
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);
}
// Reports whether the loaded plugin currently exposes the referenced capability (in any enabled
// state) and whether that capability is enabled. Returns {false, false} when the plugin is not
// loaded or does not provide the capability.
// {present, enabled}; {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)
{
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))
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);
if (!capability)
return {false, false};
@@ -193,7 +288,7 @@ void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manif
continue;
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();
missing_set.emplace(entry, MissingPlugin{*ref, std::move(recovery_url), std::move(opt), type, PluginCapabilityType::Unknown});
} 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)
{
// Collect the unique cloud UUIDs to install; local refs are handled via the browser flow.
std::vector<std::string> uuids;
for (const std::string& r : refs) {
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];
// Use a friendly name for the progress message when the catalog already knows it.
std::string display_name = uuid;
PluginDescriptor known;
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();
// Group the requested capabilities by owning plugin so each plugin is loaded once with the full
// set to enable.
// Group by owning plugin so each plugin is loaded once with the full set to enable.
std::map<std::string, std::vector<std::string>> by_plugin;
for (const std::string& r : refs) {
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())
return;
// load_plugin loads+enables a not-loaded plugin (async) and enables the listed capabilities on an
// already-loaded one. The fresh-load path does NOT fire the capability-load callback the GUI uses
// to clear the notification, so wait for each load off the UI thread and then re-validate once —
// 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.
// The fresh-load path does NOT fire the capability-load callback the GUI uses to clear the
// notification, so wait for each load off the UI thread and re-validate once. That clears the
// inactive notification, or flips it to broken if the plugin does not provide the capability.
std::vector<std::pair<std::string, std::vector<std::string>>> work(by_plugin.begin(), by_plugin.end());
std::thread([work = std::move(work)]() {
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)
{
// One missing plugin: deep-link a search for it. Multiple: just open the plugin hub.
if (local_refs.size() == 1) {
if (const auto ref = parse_capability_ref(local_refs.front())) {
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/Preset.hpp> // Preset::Type
#include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityId, PluginCapabilityType
#include <cstddef>
#include <functional>
#include <string>
@@ -23,10 +23,9 @@ struct MissingPlugin
PluginCapabilityType type{PluginCapabilityType::Unknown};
};
// Rebuild the missing-plugin set owned by a single preset type from that preset's "plugins"
// manifest, comparing each ref against the live plugin catalog and loaded/enabled capabilities. A
// null/empty manifest clears the set for that type. Only TYPE_PRINT (process), TYPE_PRINTER
// (machine) and TYPE_FILAMENT are tracked; other types are ignored.
// Rebuild one preset type's missing-plugin set from its "plugins" manifest, comparing each ref
// against the live catalog and loaded/enabled capabilities. A null/empty manifest clears the set.
// Only TYPE_PRINT, TYPE_PRINTER and TYPE_FILAMENT are tracked.
void refresh_missing_plugins(Preset::Type type, const ConfigOptionStrings* manifest, const Preset* preset = nullptr);
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();
bool has_missing_plugins();
// Installed-but-inactive capabilities: the plugin has a local package but the referenced capability
// is not active because the plugin is not loaded, or it is loaded but the capability is disabled.
// Resolved locally by loading the plugin and/or enabling the capability — no download.
// Installed-but-inactive: the plugin has a local package but is not loaded, or is loaded with the
// capability disabled. Resolved locally by loading and/or enabling — no download.
std::vector<MissingPlugin> get_inactive_plugins();
bool has_inactive_plugins();
// 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
// informational notification pointing the user at OrcaCloud to update the plugin.
// capability at all (renamed/removed/outdated plugin). Activation cannot fix these.
std::vector<MissingPlugin> get_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
// thread; implementations must only touch thread-safe state or marshal to the UI thread.
struct PluginInstallProgress
@@ -64,23 +57,36 @@ struct PluginInstallProgress
std::function<void()> on_finished;
};
// Cloud refs only; local refs are handled via the browser flow. `progress` is optional — a
// default-constructed value preserves the previous silent behavior.
// Subscribe, install and load the cloud refs on a detached worker; failures are reported through a
// non-blocking notification. Local refs are ignored — they go through the browser flow below.
void resolve_missing_plugins(const std::vector<std::string>& refs,
PluginInstallProgress progress = {});
// Activate inactive plugins: load each referenced plugin (passing the capabilities to enable) and/or
// enable already-loaded-but-disabled capabilities. Local only — no network. The loads run on a
// background worker that waits for them and then re-validates the plate, clearing the notification
// (or reclassifying the ref as broken if the loaded plugin turns out not to provide the capability).
// Load each referenced plugin and/or enable its disabled capabilities. Local only — no network. The
// loads run on a background worker that waits for them and then re-validates the plate, clearing the
// notification (or reclassifying the ref as broken if the plugin does not provide the capability).
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);
std::string create_full_ref(const PluginCapabilityRef& ref);
std::string resolve_recovery_url(const PluginCapabilityRef& ref);
// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options
// (ConfigOptionDef::is_plugin_backed) currently references: a manifest entry nobody points at is not
// in use. Pure preset logic — the 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
#endif
+66 -15
View File
@@ -3,20 +3,21 @@
#include <pybind11/embed.h>
#include <boost/log/trivial.hpp>
#include <optional>
#include <stdexcept>
#include "PythonPluginInterface.hpp"
#include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginAuditManager.hpp"
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call
// crosses through a trampoline method, so this single boundary is where we (1) log the
// full Python traceback (to sys.stderr -> session log) and rethrow the exception intact,
// 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
// like a pure-virtual-missing failure must keep their own path and are deliberately not
// caught here.
// Trampoline variants of pybind11's override macros. Every C++->Python plugin call crosses a
// trampoline method, so this single boundary is where we (1) log the full Python traceback and
// rethrow the exception intact, and (2) open the plugin's filesystem audit scope for the call.
// We catch ONLY error_already_set (a Python-side raise); other pybind11_fail/runtime_error, such as
// 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
// traceback. Internal helper shared by the public macros below and by trampolines that
@@ -29,13 +30,13 @@
throw; \
}
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call
// when this trampoline instance carries a non-empty audit plugin key. Declares a local
// `_orca_audit_scope`.
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call, and publishes
// the calling capability's cached name so host APIs invoked from Python can tell which capability
// they are serving. No-op without an audit plugin key. Declares a local `_orca_audit_scope`.
#define ORCA_PY_AUDIT_SCOPE(mode) \
std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \
if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \
_orca_audit_scope.emplace(_orca_audit_key, mode)
_orca_audit_scope.emplace(_orca_audit_key, this->name(), mode)
#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \
do { \
@@ -55,13 +56,65 @@ template<class Base> class PyPluginCommonTrampoline : public Base
public:
using Base::Base;
// get_name is required on all capabilities — Python subclass must implement it.
std::string get_name() const override
{
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
{
ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load);
@@ -83,8 +136,6 @@ class PyPluginInterfaceTrampoline : public PyPluginCommonTrampoline<PluginCapabi
public:
using PyPluginCommonTrampoline<PluginCapabilityInterface>::PyPluginCommonTrampoline;
// get_name is implemented in PyPluginCommonTrampoline (PYBIND11_OVERRIDE_PURE).
PluginCapabilityType get_type() const override
{
ORCA_PY_OVERRIDE_AUDITED(
+57 -1
View File
@@ -1,8 +1,11 @@
#include "PythonPluginBridge.hpp"
#include <boost/log/trivial.hpp>
#include <exception>
#include <memory>
#include <mutex>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <string>
#include <unordered_map>
#include <pybind11/embed.h>
@@ -10,6 +13,8 @@
#include <pybind11/stl.h>
#include "PythonInterpreter.hpp"
#include "PluginFsUtils.hpp"
#include "PluginConfig.hpp"
#include "host/PluginHost.hpp"
#include "PyPluginPackage.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("failure", &ExecutionResult::failure, py::arg("status"), py::arg("message"), py::arg("data") = std::string());
// Config lives at the capability level, not as a global orca.* function: the host reads the
// owning (plugin_key, capability) straight off the instance the call arrived on, so a
// capability can only ever address its own config and never has to name itself.
// Registered on the base, so every capability type (script/gcode/printer-agent) inherits it.
py::class_<PluginCapabilityInterface, PyPluginInterfaceTrampoline, std::shared_ptr<PluginCapabilityInterface>>(m, "PythonPluginBase")
.def(py::init<>())
.def("get_name", &PluginCapabilityInterface::get_name)
.def("get_type", &PluginCapabilityInterface::get_type)
.def("on_load", &PluginCapabilityInterface::on_load)
.def("on_unload", &PluginCapabilityInterface::on_unload);
.def("on_unload", &PluginCapabilityInterface::on_unload)
.def("has_config_ui", &PluginCapabilityInterface::has_config_ui,
"Override to return True to replace the host's default JSON editor with your own HTML\n"
"UI, returned by get_config_ui(). Every capability is configurable and appears in the\n"
"Plugins dialog's Config tab regardless; this only chooses how its config is edited.")
.def("get_config_ui", &PluginCapabilityInterface::get_config_ui,
"Override to return the custom configuration UI as an HTML string. Only called when\n"
"has_config_ui() is True; an empty result falls back to the default JSON editor.\n"
"Inside the page, use window.orca.getConfig()/saveConfig() to reach this same config.")
.def(
"get_default_config",
[](const PluginCapabilityInterface& self) {
nlohmann::json config = self.get_default_config();
return config.dump();
},
"Override to return the config that the Config tab's \"Restore defaults\" action writes\n"
"back, as a dict. Optional: without it the action stores an empty config, which already\n"
"restores the defaults of a capability that keeps its stored config sparse and applies\n"
"its own defaults on read. Override it to write an explicit starting config instead.\n"
"Calling it returns that config as a JSON string.")
.def(
"get_config",
[](const PluginCapabilityInterface& self) {
nlohmann::json config = capability_get_config(self);
return config.dump();
},
"Return this capability's stored config as a JSON string — json.loads() it to use.\n"
"\"{}\" if it has never been saved, so the parsed result is always indexable.")
.def(
"get_config_version", [](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
"Return the plugin version that last wrote this capability's config, so a newer\n"
"release can spot a stale config and migrate it. Empty string if never saved.")
.def(
"save_config",
[](const PluginCapabilityInterface& self, const std::string& config_str) {
nlohmann::json config = nlohmann::json::parse(config_str, nullptr, /* allow_exceptions */ false);
if (config.is_discarded()) {
// Refused rather than stored: the caller gets False, and the previously stored
// config is left alone. Logged because False alone does not say why.
BOOST_LOG_TRIVIAL(error) << "save_config: capability '" << self.get_name() << "' passed a config that is not valid JSON";
return false;
}
return capability_save_config(self, config);
},
py::arg("config"),
"Persist this capability's config, given as a JSON string (e.g. json.dumps(cfg)).\n"
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
"the string is not valid JSON, or if the config file could not be written.");
// Expose the package marker base as orca.base. @orca.plugin later verifies that the
// decorated class derives from this exact pybind-registered C++ type.
+47 -1
View File
@@ -7,12 +7,34 @@
#include <string_view>
#include <utility>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
namespace Slic3r {
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)
{
switch (type) {
@@ -127,6 +149,20 @@ public:
virtual std::string get_name() const = 0; // required — overridden in Python
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_unload() {}
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
// 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; }
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)
{
m_name = std::move(name);
@@ -152,6 +191,12 @@ public:
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); }
// 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
// filesystem enforcement for trampoline calls.
void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); }
@@ -165,6 +210,7 @@ private:
std::string m_name;
PluginCapabilityType m_type = PluginCapabilityType::Unknown;
std::atomic<bool> m_enabled{true};
bool m_config_ui_available = false;
std::string m_audit_plugin_key;
mutable std::atomic<int> m_refs{0};
+1 -57
View File
@@ -2,6 +2,7 @@
#include "slic3r/plugin/PluginAuditManager.hpp"
#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/MainFrame.hpp>
@@ -34,63 +35,6 @@ using json = nlohmann::json;
namespace Slic3r {
namespace {
// --------------------------------------------------------------------------
// JSON <-> Python conversion (caller must hold the GIL).
// --------------------------------------------------------------------------
py::object json_to_py(const json& j)
{
switch (j.type()) {
case json::value_t::null: return py::none();
case json::value_t::boolean: return py::bool_(j.get<bool>());
case json::value_t::number_integer: return py::int_(j.get<std::int64_t>());
case json::value_t::number_unsigned: return py::int_(j.get<std::uint64_t>());
case json::value_t::number_float: return py::float_(j.get<double>());
case json::value_t::string: return py::str(j.get<std::string>());
case json::value_t::array: {
py::list lst;
for (const auto& e : j)
lst.append(json_to_py(e));
return lst;
}
case json::value_t::object: {
py::dict d;
for (auto it = j.begin(); it != j.end(); ++it)
d[py::str(it.key())] = json_to_py(it.value());
return d;
}
default: return py::none();
}
}
json py_to_json(const py::handle& o)
{
if (o.is_none())
return json(nullptr);
if (py::isinstance<py::bool_>(o)) // bool before int (bool subclasses int in Python)
return o.cast<bool>();
if (py::isinstance<py::int_>(o))
return o.cast<std::int64_t>();
if (py::isinstance<py::float_>(o))
return o.cast<double>();
if (py::isinstance<py::str>(o))
return o.cast<std::string>();
if (py::isinstance<py::bytes>(o))
return o.cast<std::string>();
if (py::isinstance<py::dict>(o)) {
json obj = json::object();
for (auto item : py::reinterpret_borrow<py::dict>(o))
obj[py::str(item.first).cast<std::string>()] = py_to_json(item.second);
return obj;
}
if (py::isinstance<py::list>(o) || py::isinstance<py::tuple>(o)) {
json arr = json::array();
for (auto e : o)
arr.push_back(py_to_json(e));
return arr;
}
return py::str(o).cast<std::string>(); // fallback: str()
}
// --------------------------------------------------------------------------
// GIL-safe holder for a Python callable. A std::function that captured a bare
// py::object could be destroyed on the main thread without the GIL (a dialog
@@ -2,7 +2,6 @@
#include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none
#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 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
// 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
// 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)
.export_values();
@@ -48,9 +47,6 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.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,
"Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in "
"place; empty at every other step.")
@@ -18,10 +18,6 @@ struct SlicingPipelineContext {
SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice };
Print* print { nullptr }; // present for in-pipeline steps; null at 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,
// 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
+4 -1
View File
@@ -2,10 +2,13 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_params.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
test_plugin_cloud_metadata.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 <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
using json = nlohmann::json;
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
// 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
@@ -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 SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Settings Cloud Plugin"
# name = "Config Cloud Plugin"
# type = "slicing-pipeline"
# version = "1.0"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# ///
print('ok')
)PY";
} // namespace
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and
// plugins silently fall back to their built-in defaults (found via Twistify running with its
// demo defaults instead of the header values, 2026-07-17).
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]")
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
// Twistify running with its demo defaults instead of the configured values, 2026-07-17).
// Configuration now lives in PluginConfig, keyed by the capability identity and kept off the
// descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored
// 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
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
// install-state sidecar carrying the cloud identity.
// A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the
// cloud identity. Discovery derives plugin_key from the cloud UUID.
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);
{
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
out << SETTINGS_PLUGIN_SOURCE;
out << CLOUD_PLUGIN_SOURCE;
}
PluginDescriptor sidecar;
sidecar.name = "Settings Cloud Plugin";
sidecar.name = "Config Cloud Plugin";
sidecar.installed_version = "1.0";
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
REQUIRE(write_install_state(plugin_dir, sidecar));
@@ -109,25 +90,38 @@ TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[P
return {};
};
// Discovery parsed the header settings (premise).
PluginDescriptor discovered = find_by_uuid();
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1);
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
const PluginDescriptor discovered = find_by_uuid();
REQUIRE(discovered.plugin_key == uuid);
REQUIRE(discovered.version == "1.0");
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has
// no settings, no local paths.
// The user has configured the plugin's capability (premise).
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;
cloud_record.name = "Settings Cloud Plugin";
cloud_record.name = "Config Cloud Plugin";
cloud_record.plugin_key = uuid;
cloud_record.version = "1.1";
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
manager.update_cloud_metadata({cloud_record});
// Cloud metadata landed on the descriptor...
const PluginDescriptor refreshed = find_by_uuid();
// Cloud metadata landed...
CHECK(refreshed.version == "1.1");
// ...and the locally-parsed settings survived the merge.
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1);
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
CHECK(refreshed.settings.count("taper_per_mm") == 1);
CHECK(refreshed.plugin_key == uuid);
CHECK(refreshed.installed_version == "1.0");
// ...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/PluginFsUtils.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <fstream>
@@ -15,30 +17,6 @@ namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and
// restore the previous value afterwards, so install_plugin() writes into a
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
};
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
{
fs::create_directories(dir);
@@ -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
read_install_state(plugin_dir, scanned);
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,
const std::string& name)
{
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
}
{ return manager.get_plugin_capability({PluginCapabilityType::Unknown, name, plugin_key}, /*only_enabled=*/false); }
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->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");
}
@@ -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(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.
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());
// 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());
PluginInstallState persisted;
@@ -440,7 +438,7 @@ TEST_CASE("A capability disabled after load stays disabled when rediscovered and
std::string 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"));
// 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");
// 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" })
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");
}