mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-06 10:31:12 +00:00
feat: Python Plugins
This commit is contained in:
475
docs/plugins/examples/host_ui_panel.py
Normal file
475
docs/plugins/examples/host_ui_panel.py
Normal file
@@ -0,0 +1,475 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["numpy"]
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Host Inspector Panel"
|
||||
# description = "A non-modal interactive panel that browses the whole orca.host read-only API."
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0.0"
|
||||
# ///
|
||||
"""Host Inspector — a worked sample for the interactive `orca.host.ui` API that
|
||||
also walks the entire `orca.host` read-only surface.
|
||||
|
||||
Run it from the Plugins dialog. It opens a NON-MODAL window (so OrcaSlicer stays
|
||||
usable) with three expandable rows — Plater, Presets, Model. Each row shows a one
|
||||
line summary; click its triangle to reveal the full detail for that section. The
|
||||
page talks to the plugin through the injected `window.orca` bridge:
|
||||
|
||||
page --orca.postMessage({command:'refresh'})--> plugin.on_message()
|
||||
page --orca.postMessage({command:'detail',section})--> plugin.on_message()
|
||||
plugin --win.post({command:'summary'|'detail', ...})--> page (orca.onMessage)
|
||||
|
||||
Detail is fetched lazily — the heavy Model walk (mesh geometry + numpy arrays)
|
||||
runs only when you expand the Model row. Click "Refresh" after changing the plate
|
||||
and the summaries update; any section left open re-fetches its detail to stay live.
|
||||
|
||||
numpy is declared as a dependency so the zero-copy mesh arrays and 4x4 matrices
|
||||
are available; the code still degrades gracefully if it is missing.
|
||||
"""
|
||||
|
||||
import orca
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
except Exception:
|
||||
np = None
|
||||
|
||||
|
||||
# Soft caps so a heavy scene cannot produce an unusably long detail block. When a
|
||||
# cap trims output we say so explicitly rather than truncating silently.
|
||||
MAX_OBJECTS = 25
|
||||
MAX_VOLUMES = 10
|
||||
MAX_INSTANCES = 10
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# small formatting helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def fmt_vec(v):
|
||||
"""Format an (x, y, z) tuple / 3-element sequence with mm precision."""
|
||||
return "(" + ", ".join(f"{float(c):.3f}" for c in v) + ")"
|
||||
|
||||
|
||||
def fmt_bbox(bb):
|
||||
"""Format a host.BoundingBox as min / max / size (mm)."""
|
||||
if not bb.defined:
|
||||
return "<undefined>"
|
||||
return f"min{fmt_vec(bb.min)} max{fmt_vec(bb.max)} size{fmt_vec(bb.size)}"
|
||||
|
||||
|
||||
def vol_type_name(volume):
|
||||
"""Readable name of a ModelVolumeType enum value."""
|
||||
t = volume.type()
|
||||
return getattr(t, "name", str(t))
|
||||
|
||||
|
||||
class Report:
|
||||
"""Accumulates indented key/value lines into one block of text."""
|
||||
|
||||
def __init__(self):
|
||||
self._lines = []
|
||||
|
||||
def line(self, text=""):
|
||||
self._lines.append(text)
|
||||
|
||||
def kv(self, indent, key, value, width=15):
|
||||
pad = " " * indent
|
||||
self._lines.append(f"{pad}{(key + ':'):<{width}} {value}")
|
||||
|
||||
def text(self):
|
||||
return "\n".join(self._lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# section builders — each guards itself so one failure doesn't sink the report
|
||||
# --------------------------------------------------------------------------- #
|
||||
def report_plater(r, plater):
|
||||
r.line("[Plater]")
|
||||
try:
|
||||
r.kv(2, "project dirty", plater.is_project_dirty())
|
||||
r.kv(2, "presets dirty", plater.is_presets_dirty())
|
||||
r.kv(2, "in snapshot", plater.inside_snapshot_capture())
|
||||
except Exception as exc:
|
||||
r.kv(2, "error", exc)
|
||||
r.line()
|
||||
|
||||
|
||||
def report_presets(r, bundle):
|
||||
r.line("[Presets]")
|
||||
try:
|
||||
pp = bundle.current_print_preset()
|
||||
r.kv(2, "process", f"{pp.name} (system={pp.is_system} dirty={pp.is_dirty})")
|
||||
|
||||
printer = bundle.current_printer_preset()
|
||||
r.kv(2, "printer", f"{printer.name} (system={printer.is_system})")
|
||||
|
||||
r.kv(2, "filaments", bundle.current_filament_preset_names())
|
||||
|
||||
# PresetCollection access: sizes + currently selected names.
|
||||
r.kv(2, "collections",
|
||||
f"prints={bundle.prints.size()} "
|
||||
f"printers={bundle.printers.size()} "
|
||||
f"filaments={bundle.filaments.size()}")
|
||||
|
||||
# full_config_value(): a few representative keys, only if present.
|
||||
keys = ("layer_height", "nozzle_diameter", "filament_type",
|
||||
"printer_model", "sparse_infill_density")
|
||||
samples = []
|
||||
for key in keys:
|
||||
value = bundle.full_config_value(key)
|
||||
if value is not None:
|
||||
samples.append(f"{key}={value}")
|
||||
if samples:
|
||||
r.kv(2, "config sample", " | ".join(samples))
|
||||
r.kv(2, "config keys", f"{len(bundle.full_config_keys())} total")
|
||||
except Exception as exc:
|
||||
r.kv(2, "error", exc)
|
||||
r.line()
|
||||
|
||||
|
||||
def report_mesh(r, indent, mesh, instance, volume):
|
||||
"""Detail a host.TriangleMesh: counts, samples, and (numpy) arrays."""
|
||||
r.kv(indent, "vertices", mesh.vertex_count())
|
||||
r.kv(indent, "triangles", mesh.triangle_count())
|
||||
r.kv(indent, "empty", mesh.is_empty())
|
||||
r.kv(indent, "manifold", mesh.is_manifold())
|
||||
r.kv(indent, "volume", f"{mesh.volume():.3f} mm^3")
|
||||
r.kv(indent, "bbox", fmt_bbox(mesh.bounding_box()))
|
||||
if not mesh.is_empty():
|
||||
# numpy-free element access (always available, bounds-checked).
|
||||
r.kv(indent, "vertex[0]", fmt_vec(mesh.vertex(0)))
|
||||
r.kv(indent, "triangle[0]", tuple(mesh.triangle(0)))
|
||||
|
||||
if np is None:
|
||||
r.kv(indent, "numpy", 'not installed (add dependencies=["numpy"])')
|
||||
return
|
||||
|
||||
try:
|
||||
V = np.asarray(mesh.vertices()) # (N, 3) float32, read-only, zero-copy
|
||||
T = np.asarray(mesh.triangles()) # (M, 3) int32, read-only, zero-copy
|
||||
N = np.asarray(mesh.face_normals()) # (M, 3) float32, computed copy
|
||||
r.kv(indent, "np vertices",
|
||||
f"shape={V.shape} dtype={V.dtype} "
|
||||
f"writeable={V.flags.writeable} zero_copy={V.base is not None}")
|
||||
r.kv(indent, "np triangles", f"shape={T.shape} dtype={T.dtype}")
|
||||
r.kv(indent, "np normals", f"shape={N.shape} dtype={N.dtype}")
|
||||
|
||||
# World-space bounding box for this volume under `instance`, using the
|
||||
# row-vector convention world = [V 1] @ (instance @ volume).T
|
||||
if instance is not None and V.size:
|
||||
M = instance.matrix() @ volume.matrix() # 4x4 float64
|
||||
homog = np.c_[V.astype(np.float64), np.ones(len(V))]
|
||||
world = (homog @ M.T)[:, :3]
|
||||
r.kv(indent, "world bbox",
|
||||
f"min{fmt_vec(world.min(0))} max{fmt_vec(world.max(0))}")
|
||||
except Exception as exc:
|
||||
r.kv(indent, "numpy", f"<error: {exc}>")
|
||||
|
||||
|
||||
def report_volume(r, index, volume, instance):
|
||||
r.line(f" Volume[{index}] '{volume.name}' type={vol_type_name(volume)}")
|
||||
try:
|
||||
r.kv(6, "roles",
|
||||
f"part={volume.is_model_part()} modifier={volume.is_modifier()} "
|
||||
f"negative={volume.is_negative_volume()} "
|
||||
f"support_enf={volume.is_support_enforcer()} "
|
||||
f"support_blk={volume.is_support_blocker()}")
|
||||
r.kv(6, "extruder_id", volume.extruder_id())
|
||||
r.kv(6, "offset", fmt_vec(volume.offset()))
|
||||
r.kv(6, "rotation", fmt_vec(volume.rotation()))
|
||||
r.kv(6, "scale", fmt_vec(volume.scaling_factor()))
|
||||
r.kv(6, "mirror", fmt_vec(volume.mirror()))
|
||||
r.kv(6, "facets", volume.facets_count())
|
||||
r.kv(6, "manifold", volume.is_manifold())
|
||||
r.kv(6, "mesh errors", volume.mesh_errors_count())
|
||||
r.kv(6, "painted",
|
||||
f"support={volume.is_fdm_support_painted()} "
|
||||
f"seam={volume.is_seam_painted()} "
|
||||
f"mm={volume.is_mm_painted()} "
|
||||
f"fuzzy={volume.is_fuzzy_skin_painted()}")
|
||||
r.kv(6, "config keys", len(volume.config_keys()))
|
||||
r.line(" mesh:")
|
||||
report_mesh(r, 8, volume.mesh(), instance, volume)
|
||||
except Exception as exc:
|
||||
r.kv(6, "error", exc)
|
||||
|
||||
|
||||
def report_instance(r, index, instance):
|
||||
r.line(f" Instance[{index}]")
|
||||
try:
|
||||
r.kv(6, "printable", instance.printable)
|
||||
r.kv(6, "is_printable", instance.is_printable())
|
||||
r.kv(6, "offset", fmt_vec(instance.offset()))
|
||||
r.kv(6, "rotation", fmt_vec(instance.rotation()))
|
||||
r.kv(6, "scale", fmt_vec(instance.scaling_factor()))
|
||||
r.kv(6, "mirror", fmt_vec(instance.mirror()))
|
||||
r.kv(6, "left_handed", instance.is_left_handed())
|
||||
r.kv(6, "world bbox", fmt_bbox(instance.bounding_box()))
|
||||
except Exception as exc:
|
||||
r.kv(6, "error", exc)
|
||||
|
||||
|
||||
def report_object(r, index, obj):
|
||||
r.line(f" Object[{index}] '{obj.name}'")
|
||||
try:
|
||||
r.kv(4, "input_file", obj.input_file or "<none>")
|
||||
r.kv(4, "module_name", obj.module_name or "<none>")
|
||||
r.kv(4, "printable", obj.printable)
|
||||
r.kv(4, "volumes", obj.volume_count())
|
||||
r.kv(4, "instances", obj.instance_count())
|
||||
r.kv(4, "facets", obj.facets_count())
|
||||
r.kv(4, "parts", obj.parts_count())
|
||||
r.kv(4, "materials", obj.materials_count())
|
||||
r.kv(4, "mesh errors", obj.mesh_errors_count())
|
||||
r.kv(4, "flags",
|
||||
f"multiparts={obj.is_multiparts()} cut={obj.is_cut()} "
|
||||
f"custom_layering={obj.has_custom_layering()}")
|
||||
r.kv(4, "painted",
|
||||
f"support={obj.is_fdm_support_painted()} "
|
||||
f"seam={obj.is_seam_painted()} "
|
||||
f"mm={obj.is_mm_painted()} "
|
||||
f"fuzzy={obj.is_fuzzy_skin_painted()}")
|
||||
r.kv(4, "z range", f"[{obj.min_z():.3f}, {obj.max_z():.3f}]")
|
||||
r.kv(4, "bbox", fmt_bbox(obj.bounding_box()))
|
||||
r.kv(4, "raw bbox", fmt_bbox(obj.raw_mesh_bounding_box()))
|
||||
|
||||
# The first instance is used as the frame for world-space mesh maths.
|
||||
instance0 = obj.instance(0) if obj.instance_count() else None
|
||||
|
||||
shown = min(obj.volume_count(), MAX_VOLUMES)
|
||||
for vi in range(shown):
|
||||
report_volume(r, vi, obj.volume(vi), instance0)
|
||||
if obj.volume_count() > shown:
|
||||
r.line(f" ... and {obj.volume_count() - shown} more volume(s)")
|
||||
|
||||
shown = min(obj.instance_count(), MAX_INSTANCES)
|
||||
for ii in range(shown):
|
||||
report_instance(r, ii, obj.instance(ii))
|
||||
if obj.instance_count() > shown:
|
||||
r.line(f" ... and {obj.instance_count() - shown} more instance(s)")
|
||||
except Exception as exc:
|
||||
r.kv(4, "error", exc)
|
||||
r.line()
|
||||
|
||||
|
||||
def report_model(r, model):
|
||||
r.line(f"[Model] id={model.id()}")
|
||||
try:
|
||||
r.kv(2, "objects", model.object_count())
|
||||
r.kv(2, "materials", model.material_count())
|
||||
r.kv(2, "current plate", model.current_plate_index())
|
||||
r.kv(2, "max_z", f"{model.max_z():.3f} mm")
|
||||
r.kv(2, "painted",
|
||||
f"support={model.is_fdm_support_painted()} "
|
||||
f"seam={model.is_seam_painted()} "
|
||||
f"mm={model.is_mm_painted()} "
|
||||
f"fuzzy={model.is_fuzzy_skin_painted()}")
|
||||
r.kv(2, "designer", repr(model.designer()))
|
||||
r.kv(2, "design_id", repr(model.design_id()))
|
||||
r.kv(2, "bbox exact", fmt_bbox(model.bounding_box()))
|
||||
r.kv(2, "bbox approx", fmt_bbox(model.bounding_box_approx()))
|
||||
except Exception as exc:
|
||||
r.kv(2, "error", exc)
|
||||
r.line()
|
||||
|
||||
count = model.object_count()
|
||||
if count == 0:
|
||||
r.line(" (no objects on the plate)")
|
||||
r.line()
|
||||
return
|
||||
|
||||
shown = min(count, MAX_OBJECTS)
|
||||
for oi in range(shown):
|
||||
report_object(r, oi, model.object(oi))
|
||||
if count > shown:
|
||||
r.line(f" ... and {count - shown} more object(s)")
|
||||
r.line()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# one-line summaries (collapsed row text) — pure formatters
|
||||
# --------------------------------------------------------------------------- #
|
||||
def summary_plater(plater):
|
||||
return f"project dirty: {'yes' if plater.is_project_dirty() else 'no'}"
|
||||
|
||||
|
||||
def summary_presets(bundle):
|
||||
return f"printer: {bundle.current_printer_preset().name}"
|
||||
|
||||
|
||||
def summary_model(model):
|
||||
return f"{model.object_count()} object(s) · max_z {model.max_z():.1f} mm"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the page — three expandable section rows, themed, self-contained
|
||||
# --------------------------------------------------------------------------- #
|
||||
#
|
||||
# No hardcoded colors: the host injects a theme matching OrcaSlicer's current
|
||||
# light/dark mode and exposes it as CSS variables (--orca-bg, --orca-fg,
|
||||
# --orca-muted, --orca-accent, --orca-border). The page only adds layout and
|
||||
# reuses those variables, so it follows the active theme automatically.
|
||||
PAGE = r"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
header { display:flex; gap:8px; align-items:center; padding:10px 14px;
|
||||
border-bottom:1px solid var(--orca-border); }
|
||||
header h1 { font-size:14px; margin:0; flex:1; }
|
||||
button.secondary { background:transparent; color:var(--orca-fg);
|
||||
border-color:var(--orca-border); }
|
||||
.section { border-bottom:1px solid var(--orca-border); }
|
||||
.row { display:flex; align-items:center; gap:8px; padding:8px 14px;
|
||||
cursor:pointer; user-select:none; }
|
||||
.row:hover { background:var(--orca-border); }
|
||||
.tri { width:1em; color:var(--orca-muted); }
|
||||
.label { font-weight:600; }
|
||||
.sum { color:var(--orca-muted); }
|
||||
pre.detail { margin:0; padding:8px 14px 14px 32px;
|
||||
font:12px ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
white-space:pre-wrap; word-break:break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Host Inspector</h1>
|
||||
<button onclick="refresh()">Refresh</button>
|
||||
<button class="secondary" onclick="orca.close()">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<div class="row" onclick="toggle('plater')">
|
||||
<span class="tri" id="tri-plater">▶</span><span class="label">Plater</span>
|
||||
<span class="sum" id="sum-plater">…</span>
|
||||
</div>
|
||||
<pre class="detail" id="det-plater" style="display:none"></pre>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="row" onclick="toggle('presets')">
|
||||
<span class="tri" id="tri-presets">▶</span><span class="label">Presets</span>
|
||||
<span class="sum" id="sum-presets">…</span>
|
||||
</div>
|
||||
<pre class="detail" id="det-presets" style="display:none"></pre>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="row" onclick="toggle('model')">
|
||||
<span class="tri" id="tri-model">▶</span><span class="label">Model</span>
|
||||
<span class="sum" id="sum-model">…</span>
|
||||
</div>
|
||||
<pre class="detail" id="det-model" style="display:none"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var KEYS = ['plater', 'presets', 'model'];
|
||||
var expanded = {}, loaded = {};
|
||||
|
||||
function toggle(key) {
|
||||
expanded[key] = !expanded[key];
|
||||
document.getElementById('tri-' + key).innerHTML = expanded[key] ? '▼' : '▶';
|
||||
var det = document.getElementById('det-' + key);
|
||||
det.style.display = expanded[key] ? 'block' : 'none';
|
||||
if (expanded[key] && !loaded[key]) requestDetail(key);
|
||||
}
|
||||
|
||||
function requestDetail(key) {
|
||||
document.getElementById('det-' + key).textContent = 'Loading…';
|
||||
orca.postMessage({ command: 'detail', section: key });
|
||||
}
|
||||
|
||||
function refresh() { orca.postMessage({ command: 'refresh' }); }
|
||||
|
||||
orca.onMessage(function (msg) {
|
||||
if (!msg) return;
|
||||
if (msg.command === 'summary') {
|
||||
var d = msg.data || {};
|
||||
KEYS.forEach(function (key) {
|
||||
document.getElementById('sum-' + key).textContent = d[key] || '';
|
||||
if (expanded[key]) { loaded[key] = false; requestDetail(key); } // keep open rows live
|
||||
});
|
||||
} else if (msg.command === 'detail') {
|
||||
var det = document.getElementById('det-' + msg.section);
|
||||
if (det) det.textContent = msg.text;
|
||||
loaded[msg.section] = true;
|
||||
}
|
||||
});
|
||||
|
||||
refresh(); // initial summaries; rows start collapsed
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the plugin
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HostInspectorPanel(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Host Inspector"
|
||||
|
||||
def execute(self):
|
||||
# Non-modal: returns immediately. The window is host-owned and lives on
|
||||
# after execute() returns; on_message keeps firing when the page posts.
|
||||
self.win = orca.host.ui.create_window(
|
||||
title="Host Inspector",
|
||||
html=PAGE,
|
||||
width=760,
|
||||
height=560,
|
||||
on_message=self.on_message,
|
||||
on_close=self.on_close,
|
||||
)
|
||||
return orca.ExecutionResult.success("Host Inspector opened.")
|
||||
|
||||
# Called on the UI thread when the page posts a message.
|
||||
def on_message(self, msg):
|
||||
msg = msg or {}
|
||||
command = msg.get("command")
|
||||
if command == "refresh":
|
||||
self.win.post({"command": "summary", "data": self.summaries()})
|
||||
elif command == "detail":
|
||||
section = msg.get("section", "")
|
||||
self.win.post({"command": "detail", "section": section,
|
||||
"text": self.detail(section)})
|
||||
|
||||
def on_close(self):
|
||||
print("Host Inspector closed")
|
||||
|
||||
@staticmethod
|
||||
def summaries():
|
||||
"""One-line summary per section; each guarded independently so one
|
||||
failure (or a not-ready host) shows only that row's error."""
|
||||
def safe(fn):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc:
|
||||
return f"<error: {exc}>"
|
||||
return {
|
||||
"plater": safe(lambda: summary_plater(orca.host.plater())),
|
||||
"presets": safe(lambda: summary_presets(orca.host.preset_bundle())),
|
||||
"model": safe(lambda: summary_model(orca.host.model())),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def detail(section):
|
||||
"""Full monospace report for one section, built on demand."""
|
||||
try:
|
||||
r = Report()
|
||||
if section == "plater":
|
||||
report_plater(r, orca.host.plater())
|
||||
elif section == "presets":
|
||||
report_presets(r, orca.host.preset_bundle())
|
||||
elif section == "model":
|
||||
report_model(r, orca.host.model())
|
||||
else:
|
||||
return f"<unknown section: {section}>"
|
||||
return r.text()
|
||||
except Exception as exc:
|
||||
return f"<error: {exc}>"
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class HostInspectorPlugin(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(HostInspectorPanel)
|
||||
141
docs/plugins/examples/multi_capability_skeleton.py
Normal file
141
docs/plugins/examples/multi_capability_skeleton.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = []
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Capability Skeleton"
|
||||
# description = "Starter template: one plugin package that registers several different capabilities."
|
||||
# author = "Your Name"
|
||||
# version = "1.0.0"
|
||||
# ///
|
||||
"""Multi-capability plugin skeleton.
|
||||
|
||||
Copy this file into its own folder under ``data_dir()/orca_plugins/<your-plugin>/``
|
||||
and adapt it. It shows the full shape of a plugin that offers more than one
|
||||
capability:
|
||||
|
||||
* ExampleScript - a ``script`` capability (runs from the Plugins dialog)
|
||||
* ExamplePostProcess - a ``post-processing`` capability (edits exported G-code)
|
||||
* ExamplePrinterAgent - a ``printer-connection`` capability (a network printer agent)
|
||||
|
||||
A plugin is a *package* (the ``@orca.plugin`` class at the bottom) that registers one
|
||||
or more *capabilities*. Each capability is an independent class with its own name and
|
||||
type. To make your own plugin: delete the capabilities you do not need, fill in the
|
||||
ones you keep, and register only those in ``register_capabilities``.
|
||||
|
||||
See ``plugin_development.md`` for the full reference, and ``host_ui_panel.py`` for a
|
||||
richer worked example built on the ``orca.host`` read-only API.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import orca
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capability 1 - a script capability
|
||||
# Runs on the main/UI thread via the Plugins dialog "Run" action. Keep
|
||||
# execute() fast: a slow call freezes the UI. Offload heavy work to your own
|
||||
# threading.Thread (which must not touch the model) and surface results through
|
||||
# an orca.host.ui window (see host_ui_panel.py).
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ExampleScript(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
# Display name; unique within this plugin; must not contain ';'.
|
||||
return "Example Script"
|
||||
|
||||
def on_load(self):
|
||||
# Optional. Runs once when the capability is loaded. Default: no-op.
|
||||
pass
|
||||
|
||||
def on_unload(self):
|
||||
# Optional. Runs once when the capability is unloaded. Default: no-op.
|
||||
pass
|
||||
|
||||
def execute(self):
|
||||
# TODO: your logic here.
|
||||
return orca.ExecutionResult.success("Example Script ran")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capability 2 - a post-processing (G-code) capability
|
||||
# Runs on a background slicing thread during G-code export. Receives a context
|
||||
# pointing at the temporary G-code file, which you may rewrite in place.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ExamplePostProcess(orca.gcode.GCodePluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Example Post-process"
|
||||
|
||||
def execute(self, ctx):
|
||||
# ctx.gcode_path - absolute path to the temp G-code being post-processed
|
||||
# ctx.output_name - the output file name
|
||||
# ctx.host - target host when exporting to a network printer
|
||||
# ctx.orca_version - OrcaSlicer version string
|
||||
# Writing into the folder of ctx.gcode_path is permitted by the audit hook;
|
||||
# writing elsewhere outside data_dir() is blocked.
|
||||
try:
|
||||
with open(ctx.gcode_path, "a", encoding="utf-8") as f:
|
||||
f.write(f"\n; processed by Example Post-process for {ctx.output_name}\n")
|
||||
except Exception as exc:
|
||||
return orca.ExecutionResult.failure(
|
||||
orca.PluginResult.RecoverableError,
|
||||
f"post-process failed: {exc}")
|
||||
return orca.ExecutionResult.success("G-code annotated")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capability 3 - a printer-connection (agent) capability
|
||||
# Registers a network printer agent on load. The host serialises each native
|
||||
# agent call into a single JSON request envelope
|
||||
# ({command, request_id, dev_id, payload}); you dispatch on request["command"]
|
||||
# and return a JSON response envelope string. Delete this whole class if your
|
||||
# plugin is not a printer agent.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ExamplePrinterAgent(orca.printer_agent.PrinterAgentBase):
|
||||
def get_name(self):
|
||||
return "Example Printer Agent"
|
||||
|
||||
def get_agent_info(self):
|
||||
return orca.printer_agent.AgentInfo(
|
||||
id="example-agent",
|
||||
name="Example Printer Agent",
|
||||
version="1.0.0",
|
||||
description="Skeleton printer agent.",
|
||||
)
|
||||
|
||||
def send_command(self, request_json):
|
||||
# Parse the request envelope and dispatch on its "command".
|
||||
try:
|
||||
request = json.loads(request_json or "{}")
|
||||
except json.JSONDecodeError:
|
||||
request = {}
|
||||
command = request.get("command", "")
|
||||
request_id = request.get("request_id", "")
|
||||
|
||||
# TODO: handle the commands your device supports and build a real response.
|
||||
return json.dumps({
|
||||
"request_id": request_id,
|
||||
"status": "error",
|
||||
"message": f"unhandled command: {command}",
|
||||
})
|
||||
|
||||
def send_command_with_progress(self, request_json, update_fn, cancel_fn):
|
||||
# Optional. Override only for long-running commands (uploads, prints).
|
||||
# update_fn(stage, percent, message) - push progress to the host UI.
|
||||
# cancel_fn() -> bool - poll it; abort if it returns True.
|
||||
# Default behaviour is to run as a plain send_command.
|
||||
return self.send_command(request_json)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The package - exactly one @orca.plugin class per file. register_capabilities()
|
||||
# declares which capabilities this plugin exposes. A capability you do not pass to
|
||||
# register_capability() is invisible to OrcaSlicer, even if its class is defined
|
||||
# above.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@orca.plugin
|
||||
class CapabilitySkeleton(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(ExampleScript)
|
||||
orca.register_capability(ExamplePostProcess)
|
||||
orca.register_capability(ExamplePrinterAgent)
|
||||
385
docs/plugins/plugin_audit_hook.md
Normal file
385
docs/plugins/plugin_audit_hook.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Plugin Audit Hook
|
||||
|
||||
OrcaSlicer's plugin system runs Python, which is extremely capable — it can read and
|
||||
write files, spawn processes, open sockets, and load native code. To keep plugins from
|
||||
reaching outside what they legitimately need, we install a **CPython audit hook** that
|
||||
inspects sensitive runtime operations performed by plugin code and blocks the ones that
|
||||
fall outside an allow‑list.
|
||||
|
||||
> **Scope of this version.** This is intentionally a *narrow, low‑risk first version* —
|
||||
> groundwork, not a complete sandbox. Today it enforces one thing: **file writes are
|
||||
> restricted to an allow‑list of directories** while a plugin is executing. Reads are
|
||||
> left permissive so Python can still import modules. Process/network/native‑code events
|
||||
> are *not* yet enforced. See [Limitations](#limitations) before relying on it as a
|
||||
> security boundary.
|
||||
|
||||
---
|
||||
|
||||
## What is a plugin audit hook?
|
||||
|
||||
CPython exposes an auditing API (PEP 578). Any interpreter‑wide hook registered with
|
||||
`PySys_AddAuditHook` is called *before* the runtime performs a sensitive operation — for
|
||||
example opening a file (`open`), spawning a subprocess (`subprocess.Popen`), or connecting
|
||||
a socket (`socket.connect`). The hook receives the event name and its arguments and may
|
||||
abort the operation by setting a Python exception and returning a non‑zero value.
|
||||
|
||||
We register exactly **one** such hook, once, from `PythonInterpreter::initialize()` via
|
||||
`PluginAuditManager::instance().install_hook()`. Everything else — *which* plugin is
|
||||
running, *what* mode it runs under, and *which* directories it may touch — is tracked by
|
||||
`PluginAuditManager`.
|
||||
|
||||
The hook itself is global to the interpreter, but it only enforces anything when a plugin
|
||||
**audit context** is active (see below). Non‑plugin Python code, and plugin loading before
|
||||
the context is set, pass through untouched.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
There are three moving parts. Keep them distinct — conflating them is the usual source of
|
||||
confusion.
|
||||
|
||||
### 1. Audit identity — *who* is running (set once, per instance)
|
||||
|
||||
Every plugin instance carries a C++‑only identity string, never exposed to Python:
|
||||
|
||||
```cpp
|
||||
// PythonPluginInterface.hpp
|
||||
class PluginCapabilityInterface {
|
||||
public:
|
||||
void set_audit_plugin_key(std::string key);
|
||||
const std::string& audit_plugin_key() const;
|
||||
private:
|
||||
std::string m_audit_plugin_key; // == PluginDescriptor::plugin_key
|
||||
};
|
||||
```
|
||||
|
||||
This is the canonical runtime ID, `PluginDescriptor::plugin_key`. It is stamped onto the
|
||||
instance by the loader **after** the plugin is captured and **before** `on_load()` runs:
|
||||
|
||||
- `PluginLoader::load_plugin_impl()` → `set_audit_plugin_key(descriptor.plugin_key)`
|
||||
- `PluginLoader::update_loaded_plugin_key()` → re‑stamps it if a key is migrated
|
||||
|
||||
Stamping the identity does **not** turn on enforcement — it only labels the object so that
|
||||
later calls know which plugin they belong to. This matters because printer‑agent plugins
|
||||
are later invoked through `IPrinterAgent` / `NetworkAgent`, where the original `plugin_key`
|
||||
is no longer available at the call site; the instance carries it instead.
|
||||
|
||||
### 2. Audit context — *how strict*, for the duration of one call (set per call)
|
||||
|
||||
The active plugin, mode, and scoped roots live in thread‑local state on
|
||||
`PluginAuditManager`. They are set and restored by an RAII guard,
|
||||
`ScopedPluginAuditContext`:
|
||||
|
||||
```cpp
|
||||
// constructor: remember previous state, then apply the new plugin/mode and clear scoped roots
|
||||
ScopedPluginAuditContext(const std::string& plugin_key,
|
||||
AuditMode mode = AuditMode::Loading);
|
||||
// destructor: restore the previous plugin/mode/scoped-roots
|
||||
```
|
||||
|
||||
A context is constructed at the **start of every C++ → Python trampoline call** and
|
||||
destroyed when that call returns or throws. So enforcement is *per call*: outside any
|
||||
trampoline call the mode is just its default and `current_plugin()` is empty, so the hook
|
||||
allows everything.
|
||||
|
||||
### 3. Audit modes — what "strict" means
|
||||
|
||||
```cpp
|
||||
enum class AuditMode {
|
||||
// Permissive reads, restricted writes. Python must be able to read stdlib
|
||||
// modules and the plugin file during import/on-load, so reads are allowed;
|
||||
// only writes outside the allowed roots are blocked.
|
||||
Loading,
|
||||
|
||||
// Restricted reads AND writes: every file path must resolve inside an
|
||||
// allowed root, or it is blocked.
|
||||
Enforcing,
|
||||
};
|
||||
```
|
||||
|
||||
The check that implements this is `PluginAuditManager::check_open(path, mode)`:
|
||||
|
||||
1. Empty path → allow.
|
||||
2. No active plugin (`current_plugin()` empty) → allow.
|
||||
3. `Loading` **and** the open is a read (`mode` has no `w`/`a`/`+`) → allow (early‑out).
|
||||
4. Otherwise the path must resolve inside a **scoped allowed root** or the **global
|
||||
allowed root**, else it is blocked.
|
||||
|
||||
So the only difference between the two modes is step 3: `Loading` lets reads through before
|
||||
the allow‑list check; `Enforcing` does not, so reads are subject to the same allow‑list as
|
||||
writes.
|
||||
|
||||
### Allowed roots
|
||||
|
||||
There are two tiers, checked in this order:
|
||||
|
||||
| Tier | Stored in | Lifetime | Set by |
|
||||
|---|---|---|---|
|
||||
| **Scoped** | thread‑local, cleared on every new context | one call | `add_scoped_allowed_root()` inside an `audit_setup` callback |
|
||||
| **Global** | shared, mutex‑guarded | process | `add_global_allowed_root()` in `install_hook()` |
|
||||
|
||||
In this version the global allow‑list contains **only `data_dir()`**. The executable
|
||||
directory and resources directory are deliberately *not* allowed — plugins must not write
|
||||
there. G‑code plugins additionally get the temp G‑code folder as a *scoped* root for the
|
||||
duration of their `execute()` call.
|
||||
|
||||
Path matching (`is_inside_allowed_root`) canonicalizes both paths with
|
||||
`weakly_canonical` (resolving symlinks without requiring existence) and does a
|
||||
component‑wise prefix match that rejects any `..` traversal.
|
||||
|
||||
### Putting it together — the flow of one call
|
||||
|
||||
```
|
||||
PluginLoader (once) set_audit_plugin_key(plugin_key) // identity stamped
|
||||
│
|
||||
▼
|
||||
C++ calls plugin->execute() ─► trampoline method
|
||||
│ ├─ ScopedPluginAuditContext ctor // mode + plugin set
|
||||
│ ├─ audit_setup() // e.g. add scoped roots
|
||||
│ └─ PYBIND11_OVERRIDE(_PURE) ──► Python runs
|
||||
│ │
|
||||
│ Python does open("/x", "w") ─┤
|
||||
│ ▼
|
||||
│ CPython raises "open" audit event
|
||||
│ │
|
||||
│ PluginAuditManager::audit_hook
|
||||
│ │
|
||||
│ check_open("/x","w") → blocked?
|
||||
│ └─ PyErr_SetString + return -1 ► PermissionError
|
||||
▼
|
||||
trampoline returns ─► ScopedPluginAuditContext dtor // previous state restored
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit hook development
|
||||
|
||||
The point of interest is **`PluginAuditManager.hpp` / `.cpp`** (the modes, the events, and
|
||||
the policy) and the trampoline macros in **`PyPluginTrampoline.hpp`** (how each plugin
|
||||
function opts into a mode).
|
||||
|
||||
### Handling events
|
||||
|
||||
Events are dispatched by name in `PluginAuditManager::audit_hook`. Return `0` to allow;
|
||||
set a Python exception and return non‑zero (we use `-1`) to block:
|
||||
|
||||
```cpp
|
||||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||
{
|
||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||||
std::string event_name(event ? event : "");
|
||||
|
||||
if (event_name == "open") {
|
||||
// CPython passes ("open", path, mode, flags)
|
||||
const char* path = nullptr; const char* mode = nullptr; int flags = 0;
|
||||
if (!PyArg_ParseTuple(args, "s|si", &path, &mode, &flags)) {
|
||||
PyErr_Clear();
|
||||
return 0; // couldn't parse — allow
|
||||
}
|
||||
if (!mgr->check_open(path ? path : "", mode ? mode : "r").allowed) {
|
||||
PyErr_SetString(PyExc_PermissionError,
|
||||
"Plugin attempted to access a blocked file path");
|
||||
return -1; // block
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// else if (event_name == "os.rename") { ... } // see below
|
||||
|
||||
return 0; // unhandled event — allow
|
||||
}
|
||||
```
|
||||
|
||||
To audit a new operation, add another `else if` branch. **Each event has its own argument
|
||||
tuple** — you cannot assume `(path, mode, flags)`. Look the event up in the official table
|
||||
and parse accordingly:
|
||||
|
||||
- `os.rename` → `(src, dst, src_dir_fd, dst_dir_fd)`
|
||||
- `os.remove` → `(path, dir_fd)`
|
||||
- `os.mkdir` → `(path, mode, dir_fd)`
|
||||
- `subprocess.Popen` → `(executable, args, cwd, env)`
|
||||
|
||||
The complete, version‑specific list of audit events and their arguments:
|
||||
**https://docs.python.org/3/library/audit_events.html**
|
||||
|
||||
For filesystem mutations you'll usually want to route the extracted path(s) through
|
||||
`check_open(path, "w")` (or a dedicated checker) so they share the same allow‑list logic.
|
||||
|
||||
### Defining the audit mode of a function
|
||||
|
||||
Every C++ → Python plugin call crosses a trampoline method, and those methods wrap the
|
||||
pybind11 override in the `ORCA_PY_OVERRIDE_AUDITED` macro
|
||||
(`PyPluginTrampoline.hpp`). The macro both (a) logs and rethrows Python exceptions at the
|
||||
single boundary and (b) opens the audit context. Its signature:
|
||||
|
||||
```cpp
|
||||
ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /* args... */)
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
|---|---|
|
||||
| `mode` | `AuditMode::Loading` or `AuditMode::Enforcing` for this call |
|
||||
| `audit_setup` | a callable (often `[] {}`) run *after* the context is constructed — use it to register scoped roots |
|
||||
| `override_macro` | `PYBIND11_OVERRIDE` or `PYBIND11_OVERRIDE_PURE` |
|
||||
| `ret, base, name, …` | the usual pybind11 override arguments |
|
||||
|
||||
When you add a new method to any trampoline, **you must choose its mode** based on what the
|
||||
function legitimately needs:
|
||||
|
||||
```cpp
|
||||
void on_load() override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading, // imports during load → reads allowed
|
||||
[] {}, // no extra setup
|
||||
PYBIND11_OVERRIDE,
|
||||
void, Base, on_load);
|
||||
}
|
||||
```
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- Use **`Loading`** for lifecycle/setup calls that may import modules (`on_load`,
|
||||
`on_unload`, `get_type`) or any call where you only care about restricting writes.
|
||||
- Use **`Enforcing`** for calls that should also be prevented from *reading* outside the
|
||||
allow‑list. Be aware this will block lazily‑imported stdlib/3rd‑party modules read from
|
||||
disk during the call, so only use it where the plugin is not expected to import at call
|
||||
time.
|
||||
|
||||
### Adding per‑call allowed roots (the `audit_setup` callback)
|
||||
|
||||
`ScopedPluginAuditContext`'s constructor **clears** the scoped roots, so any scoped root
|
||||
must be added *after* construction — which is exactly what `audit_setup` is for. The G‑code
|
||||
trampoline uses it to grant write access to the folder holding the current temp G‑code
|
||||
file:
|
||||
|
||||
```cpp
|
||||
ExecutionResult execute(const GCodePluginContext& ctx) override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||||
[&] { // runs only when a context is active
|
||||
if (!ctx.gcode_path.empty())
|
||||
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
|
||||
std::filesystem::path(ctx.gcode_path).parent_path());
|
||||
},
|
||||
PYBIND11_OVERRIDE_PURE,
|
||||
ExecutionResult, GCodePlugin, execute, ctx);
|
||||
}
|
||||
```
|
||||
|
||||
The callback runs only when the instance has a non‑empty audit key (i.e. a context was
|
||||
actually opened), so it's safe to assume enforcement is live inside it.
|
||||
|
||||
### Adding a global allowed root
|
||||
|
||||
If *every* plugin should be allowed a directory, add it in `install_hook()`:
|
||||
|
||||
```cpp
|
||||
void PluginAuditManager::install_hook()
|
||||
{
|
||||
PySys_AddAuditHook(audit_hook, this);
|
||||
add_global_allowed_root(data_dir()); // the only global root today
|
||||
// add_global_allowed_root(std::filesystem::temp_directory_path()); // e.g. to allow /tmp
|
||||
}
|
||||
```
|
||||
|
||||
Prefer scoped roots over global ones — a global root widens the boundary for *all* plugins
|
||||
and is process‑lifetime. Only add a global root when the access is genuinely universal.
|
||||
|
||||
### Identity wiring (rarely touched)
|
||||
|
||||
If you add a new way to load or re‑key plugin instances, make sure the new path also calls
|
||||
`set_audit_plugin_key()` — otherwise the instance has an empty key and **no context is ever
|
||||
opened**, so its calls run completely unaudited. The existing call sites are
|
||||
`PluginLoader::load_plugin_impl()` and `PluginLoader::update_loaded_plugin_key()`.
|
||||
|
||||
---
|
||||
|
||||
## Current policy at a glance
|
||||
|
||||
| Plugin call | Mode | Effective access |
|
||||
|---|---|---|
|
||||
| `on_load` / `on_unload` / `get_type` | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
| G‑code `execute()` | `Loading` | + write under the current temp G‑code folder |
|
||||
| Script `execute()` | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
| Printer‑agent methods | `Loading` | read anywhere; write only under `data_dir()` |
|
||||
|
||||
> Modes are chosen at each trampoline call site, so this table reflects the current source —
|
||||
> always check the actual `ORCA_PY_OVERRIDE_AUDITED(...)` call when in doubt.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
This version is deliberately minimal. Do **not** treat it as a hardened sandbox. Known gaps:
|
||||
|
||||
- **Only the `open` event is enforced.** `subprocess.Popen`, `os.system`, `socket.*`,
|
||||
`ctypes.*` and friends are *not* blocked. (The `Enforcing` enum comment describes an
|
||||
aspiration, not current behavior.)
|
||||
- **`os.open` slips through.** It raises the `open` event with `mode = None`, so the
|
||||
`"s|si"` parse fails and the call is allowed. Low‑level opens are currently unaudited.
|
||||
- **`open(path, "x")`** (exclusive create — a write) contains no `w`/`a`/`+`, so it is
|
||||
classified as a read and allowed under `Loading`.
|
||||
- **Non‑`open` filesystem mutations are unaudited.** `os.remove`, `os.rename`, `os.mkdir`,
|
||||
`shutil.*` raise their own events, which we don't yet handle — a plugin can delete or
|
||||
rename files outside `data_dir()` without tripping anything.
|
||||
- Enforcement is **per process / per thread** via thread‑locals; code that hops threads
|
||||
without re‑establishing a context runs unaudited.
|
||||
|
||||
Closing these gaps (especially the filesystem‑mutation events and `os.open` flags) is the
|
||||
natural next step for anyone hardening this into a real write‑sandbox.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
Enforcement only fires while a context is active, and the read/write distinction trips
|
||||
people up, so when something is unexpectedly blocked (or unexpectedly allowed), get the
|
||||
facts first.
|
||||
|
||||
**Temporary block log.** `check_open` logs each block just before returning, including the
|
||||
mode that was actually live:
|
||||
|
||||
```
|
||||
[AUDIT] block path=/tmp open_mode=w audit_mode=Loading plugin=local:.../Environment_Report_Script_
|
||||
```
|
||||
|
||||
Read it field by field:
|
||||
|
||||
- `open_mode=w` → it's a **write**. Under `Loading`, writes outside the allow‑list are
|
||||
*supposed* to be blocked. A blocked `open_mode=r` under `audit_mode=Loading` is
|
||||
impossible from current source — if you see it, your binary is stale (see below).
|
||||
- `audit_mode=` → tells you whether the live call site is `Loading` or `Enforcing`, which
|
||||
is the quickest way to confirm a trampoline change actually took effect.
|
||||
- `path=` → the resolved path that failed the allow‑list. Compare against `data_dir()`.
|
||||
|
||||
The permanent `report_violation` log (`[AUDIT BLOCKED] …`) fires on the same blocks and
|
||||
includes the plugin key, event name, path, and reason.
|
||||
|
||||
**Common pitfalls**
|
||||
|
||||
- **Read vs write.** `Loading` never blocks a read. If a "read" is blocked, it's actually a
|
||||
write (check `open_mode`), or the mode is `Enforcing`.
|
||||
- **Stale / incremental builds.** `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are
|
||||
included by many translation units. A header‑only change (e.g. flipping a trampoline's
|
||||
mode) may not propagate with an incremental build. If runtime behavior contradicts the
|
||||
source, do a clean rebuild of the affected targets. `PluginAuditManager.cpp` changes are
|
||||
a single‑TU recompile + relink.
|
||||
- **No context = no enforcement.** If a plugin's calls are never audited, check that its
|
||||
instance got `set_audit_plugin_key()` (non‑empty key) and that the method actually wraps
|
||||
through `ORCA_PY_OVERRIDE_AUDITED`.
|
||||
|
||||
---
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | modes, allowed roots, `audit_hook`, `check_open`, `ScopedPluginAuditContext` |
|
||||
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` macros (logging + audit context) |
|
||||
| `src/slic3r/plugin/PythonPluginInterface.hpp` | the per‑instance audit identity |
|
||||
| `src/slic3r/plugin/PluginLoader.cpp` | stamps the audit key at load / key migration |
|
||||
| `src/slic3r/plugin/pluginTypes/*/*Trampoline.hpp` | per‑plugin‑type methods and their chosen modes |
|
||||
| `src/slic3r/plugin/PythonInterpreter.cpp` | installs the hook once at interpreter init |
|
||||
1015
docs/plugins/plugin_development.md
Normal file
1015
docs/plugins/plugin_development.md
Normal file
File diff suppressed because it is too large
Load Diff
344
docs/plugins/plugin_system.md
Normal file
344
docs/plugins/plugin_system.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Plugin System Overview
|
||||
|
||||
OrcaSlicer can be extended at runtime with **Python plugins** that execute inside an
|
||||
embedded CPython interpreter — no recompilation, no patching the C++ core. This document is
|
||||
the **architectural overview**: what the pieces are, how they fit together, and the
|
||||
lifecycle of a plugin from discovery to teardown.
|
||||
|
||||
It is the map; the other two plugin docs are the detail:
|
||||
|
||||
- [`plugin_development.md`](plugin_development.md) — how to *write* a Python plugin and how to
|
||||
*add a new plugin type* in C++ (the authoring/extension guide).
|
||||
- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the CPython audit hook that constrains
|
||||
what plugin code may do (the security deep‑dive).
|
||||
|
||||
> **All paths below are under `src/slic3r/plugin/`** unless stated otherwise.
|
||||
|
||||
---
|
||||
|
||||
## What the system provides
|
||||
|
||||
- **Extensibility without rebuilding** — users drop a plugin into a folder (or subscribe to
|
||||
one from the cloud) and OrcaSlicer loads it.
|
||||
- **Capabilities, not single‑purpose plugins** — one plugin is a *package* that registers one
|
||||
or more **capabilities**, each a typed unit of functionality (e.g. `post-processing`,
|
||||
`script`, `printer-connection`). Each capability type has a fixed C++ entry point and is
|
||||
invoked at a specific place in the app; a plugin's "types" are simply the set of capability
|
||||
types it registers.
|
||||
- **Presets remember the plugins they use** — when a preset references a plugin capability,
|
||||
the full reference is stored in the preset and can be restored from OrcaCloud on another
|
||||
machine (see [Plugin references in presets](#plugin-references-in-presets)).
|
||||
- **A single, narrow API surface** — plugins see only the embedded `orca` module, not the
|
||||
slicer internals.
|
||||
- **A security boundary** — file access by plugin code is filtered by an audit hook with a
|
||||
write allow‑list (groundwork; see the audit doc for current scope).
|
||||
- **Isolation of failure** — a misbehaving plugin reports an error and is unloaded rather
|
||||
than taking down the app; tracebacks are persisted to a log file.
|
||||
|
||||
---
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
app startup ───► │ PluginManager (singleton orchestrator) │
|
||||
(GUI_App::OnInit) │ owns: CloudPluginService, PluginCatalog, │
|
||||
│ PluginLoader │
|
||||
└───────┬───────────────┬───────────────┬───────┘
|
||||
│ │ │
|
||||
discover (scan) │ install/ │ load/unload │
|
||||
▼ download ▼ ▼
|
||||
┌──────────────────┐ ┌───────────────┐ ┌────────────────────┐
|
||||
│ PluginCatalog │ │ CloudPlugin │ │ PluginLoader │
|
||||
│ manifest-only │ │ Service │ │ threaded loads, │
|
||||
│ inventory of │ │ (cloud fetch/ │ │ deps (uv), audit │
|
||||
│ PluginDescriptor│ │ download) │ │ key, capabilities │
|
||||
└──────────────────┘ └───────────────┘ └─────────┬──────────┘
|
||||
│ instantiates via
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Embedded CPython (PythonInterpreter, singleton) │
|
||||
│ PythonPluginBridge → `orca` module + @orca.plugin/register_capability + capture │
|
||||
│ PyPluginTrampoline → C++↔Python call boundary (traceback logging + audit scope) │
|
||||
│ PluginAuditManager → CPython audit hook (filesystem policy) │
|
||||
│ pluginTypes/* (gcode, script, printerAgent) → typed capability bases + tramps │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│ get_plugin_capability_* + dynamic_pointer_cast
|
||||
▼
|
||||
workflow call sites: PostProcessor (G-code post-processing) ·
|
||||
PluginsDialog "Run" (script) · NetworkAgentFactory (printer agent)
|
||||
```
|
||||
|
||||
Two broad layers:
|
||||
|
||||
- **Orchestration (C++, no Python):** `PluginManager`, `PluginCatalog`, `CloudPluginService`,
|
||||
`PluginLoader`, `PluginDescriptor`. These discover, install, and manage plugins as data.
|
||||
- **Execution (the C++↔Python bridge):** `PythonInterpreter`, `PythonPluginBridge`,
|
||||
`PyPluginTrampoline`, `PluginAuditManager`, and the per‑type bases under `pluginTypes/`.
|
||||
These turn a discovered plugin into a live object the app can call.
|
||||
|
||||
---
|
||||
|
||||
## Core components
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `PluginManager` | Top‑level **singleton orchestrator**. Owns the catalog, loader, and cloud service; exposes `initialize()`, `discover_plugins()`, install/update/delete, and `shutdown()`. |
|
||||
| `PluginCatalog` | **Manifest‑only inventory.** Scans the plugin directories, parses each plugin's metadata into a `PluginDescriptor`, and splits results into valid vs. invalid. Loads no Python. |
|
||||
| `CloudPluginService` | Thin wrapper over the cloud agent: fetch subscribed/owned plugin manifests, download a plugin payload, unsubscribe/delete. |
|
||||
| `PluginLoader` | **Load/unload lifecycle.** Installs dependencies (bundled `uv`), imports the module, instantiates the package and its capabilities, stamps their audit identity, runs `on_load()`, and keeps the live capability instances keyed by a `PluginCapabilityIdentifier`. Provides `get_plugin_capabilities_by_type()` / `get_plugin_capability_by_name()` and on‑load/unload + on‑capability‑load/unload callbacks. |
|
||||
| `PluginDescriptor` | The canonical record for one plugin: key, paths, capability/display types, version, changelog, dependencies, cloud overlay, and any error/validity state. |
|
||||
| `PythonInterpreter` | **Singleton RAII wrapper around embedded CPython.** Init/finalize, GIL handoff, `sys.path`, module loading, and installing the audit hook + stderr‑to‑log redirect. |
|
||||
| `PythonPluginBridge` | Defines the embedded **`orca` module**, the `@orca.plugin` decorator + `orca.base` package class + `register_capability` entry, and captures/instantiates the package and the capability classes it registers. |
|
||||
| `PyPluginTrampoline` | The pybind11 override base at the **C++↔Python boundary**: logs Python tracebacks and opens the per‑call audit scope. |
|
||||
| `pluginTypes/*` | Per‑type C++ capability bases + trampolines (`GCodePluginCapability`, `ScriptPluginCapability`, `PrinterAgentPluginCapability`) that define each type's entry method and dispatch. |
|
||||
| `PluginAuditManager` | **Singleton CPython audit hook**: filesystem policy (write allow‑list), scoped roots, `Loading`/`Enforcing` modes. See the audit doc. |
|
||||
|
||||
---
|
||||
|
||||
## Plugin packaging and discovery
|
||||
|
||||
A plugin is a folder under one of two roots, containing a single **entry file**:
|
||||
|
||||
| Root | Source |
|
||||
|---|---|
|
||||
| `data_dir()/orca_plugins/` | locally installed / side‑loaded |
|
||||
| `data_dir()/orca_plugins/_subscribed/<user_id>/` | cloud‑subscribed (per logged‑in user) |
|
||||
|
||||
The entry file is either a single **`.py`** (metadata in a PEP 723 comment block) or a
|
||||
**`.whl`** wheel (metadata from the wheel's `METADATA`). The **capabilities** the plugin
|
||||
registers determine which workflows can run it — there is no separate `type` declaration in the
|
||||
metadata. Metadata and packaging details are in
|
||||
[`plugin_development.md`](plugin_development.md).
|
||||
|
||||
**Discovery vs. loading are separate stages.** `PluginCatalog` *scans* directories and
|
||||
produces `PluginDescriptor`s — it parses manifests only and never executes plugin code. A
|
||||
*catalog entry* is just data; a *loaded plugin* is a live Python instance created later by
|
||||
`PluginLoader`. Cloud manifests are merged into the catalog as an overlay once a user is
|
||||
logged in.
|
||||
|
||||
---
|
||||
|
||||
## The plugin lifecycle
|
||||
|
||||
```
|
||||
1. App startup (GUI_App::OnInit, after network init)
|
||||
│
|
||||
2. PluginManager::initialize()
|
||||
│ └─ PythonInterpreter::initialize() (MAIN THREAD ONLY)
|
||||
│ ├─ start embedded CPython, set sys.path / python home
|
||||
│ ├─ install the audit hook (global allowed root = data_dir())
|
||||
│ ├─ tee sys.stderr → data_dir()/log/python_*.log
|
||||
│ └─ release the GIL (PyEval_SaveThread)
|
||||
│
|
||||
3. discover_plugins() ─► PluginCatalog scans local + cloud roots
|
||||
│ → PluginDescriptor list (valid / invalid)
|
||||
│ (cloud login later: fetch_plugins_from_cloud → catalog overlay)
|
||||
│
|
||||
4. PluginLoader::load_plugin() (worker thread, serialized)
|
||||
│ ├─ install dependencies via bundled `uv`; extract bundled .whl deps onto sys.path
|
||||
│ ├─ begin capture → import module (runs @orca.plugin, marking the package class)
|
||||
│ ├─ finalize capture → instantiate package, call register_capabilities(),
|
||||
│ │ then instantiate each registered capability and cache its get_name()
|
||||
│ ├─ set_audit_plugin_key(descriptor.plugin_key) // audit identity
|
||||
│ ├─ on_load() (under the GIL)
|
||||
│ └─ store the capabilities; fire on-load + on-capability-load callbacks
|
||||
│
|
||||
5. Use: a workflow call site resolves a capability (get_plugin_capability_by_name /
|
||||
│ get_plugin_capabilities_by_type) + dynamic_pointer_cast<TypeCapability>,
|
||||
│ builds the type's context, and calls the entry method (under the GIL).
|
||||
│ Each call crosses a trampoline that opens a ScopedPluginAuditContext.
|
||||
│
|
||||
6. Unload / shutdown: set_shutting_down → unload_plugin / unload_all_plugins
|
||||
(the instance's destructor runs on_unload() + Py_DECREF under the GIL)
|
||||
→ PythonInterpreter::shutdown()
|
||||
```
|
||||
|
||||
A few load‑time invariants worth knowing:
|
||||
|
||||
- **`set_audit_plugin_key()` is what arms enforcement.** Without it the instance has an empty
|
||||
key and its calls run unaudited. It is stamped at load and re‑stamped on key migration
|
||||
(`update_loaded_plugin_key`). See the audit doc.
|
||||
- A module must mark exactly one package class with `@orca.plugin` (a subclass of
|
||||
`orca.base`), and that class's `register_capabilities()` must register at least one valid
|
||||
capability via `orca.register_capability(...)`, or the load fails. Each capability must
|
||||
resolve `get_name()`, and `(type, name)` must be unique within the plugin.
|
||||
|
||||
---
|
||||
|
||||
## Execution model: how the app calls a plugin
|
||||
|
||||
Capabilities are reached **by type, not by name**. There is no per‑type instantiation
|
||||
registry: a capability's Python class subclasses a typed C++ base, the package registers it
|
||||
via `register_capability`, and each workflow call site narrows the stored capability instance
|
||||
(`PluginCapabilityInterface`) with `std::dynamic_pointer_cast<ConcreteType>`. If the cast
|
||||
succeeds, the capability is present and is invoked; if not (no such capability installed or
|
||||
enabled), the path is a no‑op — which is how the system guarantees that absent/disabled
|
||||
capabilities never change existing behavior.
|
||||
|
||||
| Capability type | Entry method | Invoked by |
|
||||
|---|---|---|
|
||||
| `post-processing` (G‑code) | `execute(ctx)` | `PostProcessor` during G‑code export, resolving the preset's plugin refs |
|
||||
| `script` | `execute()` | the **Plugins dialog → Run** action |
|
||||
| `printer-connection` | agent methods | `NetworkAgentFactory`, registered through a loader on‑capability‑load callback wired in `GUI_App` |
|
||||
|
||||
The on‑load / on‑unload **callbacks** (`PluginLoader::subscribe_on_load_callback` /
|
||||
`subscribe_on_unload_callback`) and the per‑capability variants
|
||||
(`subscribe_on_capability_load_callback` / `subscribe_on_capability_unload_callback`) are how
|
||||
subsystems react to plugins and capabilities appearing or disappearing — e.g. the
|
||||
printer‑agent layer registers/deregisters an agent for each `PrinterConnection` capability,
|
||||
and the Plugins dialog refreshes. Adding a new type and wiring a call site is covered in
|
||||
[`plugin_development.md`](plugin_development.md).
|
||||
|
||||
---
|
||||
|
||||
## Threading and the GIL
|
||||
|
||||
- **The interpreter is initialized on the main thread.** CPython is started once via
|
||||
`PythonInterpreter` (singleton). Initializing it off the main thread risks heap
|
||||
corruption, so `PluginManager::initialize()` does it eagerly and synchronously.
|
||||
- **After init the GIL is released** (`PyEval_SaveThread`) and reacquired at shutdown, so
|
||||
other threads may take it.
|
||||
- **Plugin loads run on worker threads**, serialized by a static mutex so module imports
|
||||
don't race. Discovery can also run on a background thread (`discover_plugins(async=true)`),
|
||||
though startup discovery is synchronous.
|
||||
- **Every touch of Python from a non‑main thread acquires the GIL** through the
|
||||
`PythonGILState` RAII guard (`PyGILState_Ensure` / `Release`) — load, execute, and the
|
||||
instance destructor (`on_unload` + `Py_DECREF`) all wrap in it.
|
||||
|
||||
---
|
||||
|
||||
## Cloud subscriptions
|
||||
|
||||
`CloudPluginService` wraps the cloud agent (`OrcaCloudServiceAgent`) and is gated on login.
|
||||
It fetches the manifests of subscribed/owned plugins, merges them into the catalog as an
|
||||
overlay, and downloads a plugin's payload (sniffing the file to tell a `.whl` from a `.py`)
|
||||
to a temporary file. `PluginManager` sets the loader's cloud user id, and `PluginLoader`
|
||||
installs the downloaded payload under `orca_plugins/_subscribed/<user_id>/`. Logging out
|
||||
unloads cloud plugins. The cloud auth token (`orca_refresh_token.sec`) is owned by the cloud
|
||||
agent, not by the plugin layer.
|
||||
|
||||
---
|
||||
|
||||
## Plugin references in presets
|
||||
|
||||
When a setting points at a plugin capability (for example `post_process_plugin`), the value
|
||||
the setting stores is just the capability's **name**. So that the reference survives being
|
||||
copied to another machine — where the plugin might not be installed — each preset also carries
|
||||
a `plugins` array that records the **full reference** for every capability it uses.
|
||||
|
||||
Each entry is a single string with three `;`‑separated fields:
|
||||
|
||||
```
|
||||
<plugin_name>;<cloud_uuid>;<capability_name>
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
"Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)",
|
||||
"master_plugin;;header-stamp"
|
||||
],
|
||||
"post_process_plugin": ["G-code Benchmark (.py)", "header-stamp"]
|
||||
}
|
||||
```
|
||||
|
||||
- The **`cloud_uuid`** is present for plugins subscribed from OrcaCloud and **empty** for
|
||||
local‑only plugins (note the adjacent `;;`). It is what lets OrcaSlicer offer to restore a
|
||||
missing plugin automatically.
|
||||
- Because `;` is the field separator, a **capability name may not contain `;`** (the loader
|
||||
rejects such a plugin), and plugin display names have any `;` replaced with `_`
|
||||
(`sanitize_plugin_name`).
|
||||
- The `plugins` array is an internal manifest (`coStrings`, `comDevelop` mode — not a
|
||||
user‑edited field). Fields that hold a capability name are flagged `support_plugin`; on
|
||||
save the array is **pruned** to only the references still used by such a field, so stale
|
||||
entries drop out.
|
||||
- Parsing/serialization lives in `Config.cpp` (`parse_capability_ref` →
|
||||
`PluginCapabilityRef{ name, capability_name, uuid }`); the `plugins` option is defined in
|
||||
`PrintConfig.cpp` and is a **process/print** preset setting. See
|
||||
`tests/libslic3r/test_config.cpp` and
|
||||
`tests/slic3rutils/test_plugin_capability_identifier.cpp`.
|
||||
|
||||
## Restoring missing plugins
|
||||
|
||||
When a slice is started (`Plater::reslice`), OrcaSlicer resolves the active preset's `plugins`
|
||||
array against the loaded catalog. Any reference that is not installed is **missing**, and a
|
||||
dialog appears before slicing continues. Missing references are split by whether they carry a
|
||||
cloud UUID:
|
||||
|
||||
- **Missing OrcaCloud plugins** (have a UUID) — the dialog offers **Install plugins**, which
|
||||
subscribes to, installs, loads, and enables each one so it is usable immediately, or
|
||||
**Continue without plugins**.
|
||||
- **Missing local plugins** (no UUID) — these cannot be fetched automatically, so the dialog
|
||||
offers **Open OrcaCloud** (a browser search for similarly named plugins on the OrcaCloud
|
||||
plugins explore page) or **Continue without plugins**.
|
||||
|
||||
Choosing *Continue without plugins* proceeds with the slice; the functionality those plugins
|
||||
would have provided is simply skipped.
|
||||
|
||||
## The Plugins dialog
|
||||
|
||||
The Plugins dialog (`PluginsDialog.cpp` + `resources/web/dialog/PluginsDialog/`) presents each
|
||||
installed plugin as an expandable row (Activate · Name · Version · Status). Expanding a plugin
|
||||
shows a **capability tree** — one row per registered capability with its own enable checkbox,
|
||||
type label, and (for runnable script capabilities) a **Run** button. The details pane is
|
||||
tabbed:
|
||||
|
||||
| Tab | Shows |
|
||||
|---|---|
|
||||
| **Plugin Info** | thumbnail, source, types, author, version (with an update badge) |
|
||||
| **Description** | the plugin's own description, taken from its Python/wheel metadata |
|
||||
| **Changelog** | version / date / changes table |
|
||||
| **Diagnostics** | load status and any error state |
|
||||
|
||||
Installing is done from a **Browse plugins** split dropdown that opens the OrcaCloud plugins
|
||||
hub, with an **Install local plugin** option for side‑loading a `.py` or `.whl` directly.
|
||||
Per‑plugin and per‑capability enablement is persisted in a per‑plugin `.install_state.json`
|
||||
sidecar (written by `PluginManager`).
|
||||
|
||||
---
|
||||
|
||||
## Security and observability
|
||||
|
||||
- **Security** — all C++→Python calls cross a trampoline that opens a per‑call audit context;
|
||||
the `PluginAuditManager` audit hook then filters sensitive operations (today: a filesystem
|
||||
write allow‑list rooted at `data_dir()`, plus scoped roots such as the current G‑code
|
||||
folder). This is groundwork, not a hardened sandbox — read
|
||||
[`plugin_audit_hook.md`](plugin_audit_hook.md) for exactly what is and isn't enforced.
|
||||
- **Observability** — Python `sys.stderr` (plugin tracebacks, including from
|
||||
plugin‑spawned threads) is teed to `data_dir()/log/python_*.log`; C++‑side
|
||||
load/discovery messages go to the main session log. How errors surface in the UI (message
|
||||
box vs. the plugin details area) is described in
|
||||
[`plugin_development.md`](plugin_development.md#how-errors-are-surfaced).
|
||||
|
||||
---
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`plugin_development.md`](plugin_development.md) — authoring Python plugins; adding a new
|
||||
C++ plugin type; testing and debugging.
|
||||
- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the audit hook: modes, allow‑list,
|
||||
extending the policy.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/slic3r/plugin/PluginManager.{hpp,cpp}` | top‑level orchestrator; startup `initialize()` / `discover_plugins()` / `shutdown()` |
|
||||
| `src/slic3r/plugin/PluginCatalog.{hpp,cpp}` | directory scan → `PluginDescriptor` inventory |
|
||||
| `src/slic3r/plugin/PluginLoader.{hpp,cpp}` | threaded load/unload, dependency install, capability registry, audit‑key stamping |
|
||||
| `src/slic3r/plugin/PluginDescriptor.hpp` | the per‑plugin record (types, changelog, `sanitize_plugin_name`) |
|
||||
| `src/slic3r/plugin/CloudPluginService.{hpp,cpp}` | cloud fetch / download / subscribe / unsubscribe |
|
||||
| `src/slic3r/plugin/PythonInterpreter.{hpp,cpp}` | embedded CPython, GIL handoff, audit‑hook + log install |
|
||||
| `src/slic3r/plugin/PythonPluginBridge.{hpp,cpp}` | the `orca` module, `@orca.plugin` / `register_capability`, package + capability capture |
|
||||
| `src/slic3r/plugin/PyPluginPackage.hpp` | the package base (`orca.base`) + `register_capabilities` |
|
||||
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | C++↔Python boundary macros (traceback logging + audit scope) |
|
||||
| `src/slic3r/plugin/pluginTypes/*` | per‑type capability bases + trampolines |
|
||||
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | the CPython audit hook and policy |
|
||||
| `src/libslic3r/Config.cpp` | `parse_capability_ref`, the `plugins` array (de)serialization |
|
||||
| `src/libslic3r/PrintConfig.cpp` | the `plugins` / `post_process_plugin` option definitions |
|
||||
| `src/slic3r/GUI/PostProcessor.cpp` | resolves preset plugin refs and runs G‑code capabilities |
|
||||
| `src/slic3r/GUI/PluginPickerDialog.{hpp,cpp}` | pick a capability as a setting value |
|
||||
| `src/slic3r/GUI/Plater.cpp` | the missing‑plugins resolution dialog on slice (`reslice`) |
|
||||
| `src/slic3r/GUI/GUI_App.cpp` | startup wiring (init, discovery, on‑load / on‑capability‑load callbacks) and shutdown |
|
||||
| `src/slic3r/GUI/PluginsDialog.cpp` | the Plugins dialog (capability tree, tabs, Run, Browse plugins) |
|
||||
Reference in New Issue
Block a user