feat(plugin-api): entity-class display declarations on the manifest (API v10)

Plugins can now declare the entity classes they identify via XDATA, and the
host resolves them to display-only type labels while the declaring plugin is
loaded.

- Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot,
  the same additive pattern as `on_load` in v5) returning a &'static
  `EntityClasses { app_id, class_prefix, labels }`. The static
  `PluginManifest` struct is deliberately untouched: older cdylibs share it
  by pointer and its layout is frozen ABI since v1, so adding a field would
  read past their allocation.
- `OwnedEntityClasses` travels on the registered manifest as
  `OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests
  serialized before the field keep decoding (older runners tolerate it
  both ways).
- The runner folds the declaration into the GetManifest registration only
  when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports
  >= 10 — an older binary's vtable predates the slot, so it is never called;
  its entities simply keep their native type names.
- `PluginManager::entity_class_declarations` exposes the live set, filtered
  to alive, non-disabled plugins in load order. The registered manifests are
  the only source and nothing is cached: not installed, disabled,
  cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a
  dead process all contribute nothing.
- Embedded version info `api_version` follows the bump to 10;
  `API_VERSION_MIN_SUPPORTED` stays 3.

Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is
contained and reported), manager filtering (disabled / dead / undeclaring
skipped; declarations disappear once the process dies — verified with a real
killed child), bincode registration round-trip, and older-manifest serde
default.
This commit is contained in:
Sebastian committed 2026-10-15 22:39:09 +02:00
1 parent 859070403c
commit 60eb7b086e
9 files changed
+502 -13

No files matched your search

+158 -7
View File
@@ -29,7 +29,7 @@ use crate::ipc::protocol::{
};
use crate::ipc::transport::{recv, send};
use crate::ipc::v4::client::{RunnerFrame, V4Client};
use crate::ribbon::owned::OwnedRibbonGroup;
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
/// Entry point for the plugin runner child process.
///
@@ -59,7 +59,7 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
if version >= 5 {
invoke_on_load(&mut *plugin, &client, &interactive)?;
}
run_v4(&mut *plugin, &interactive, client)
run_v4(&mut *plugin, &interactive, client, version)
} else {
let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host");
@@ -106,6 +106,7 @@ fn run_v4(
plugin: &mut dyn BuiltinPlugin,
interactive: &InteractiveRegistry,
client: V4Client,
api_version: u32,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Drain any host notifications and dispatch them to the plugin's
@@ -119,9 +120,14 @@ fn run_v4(
match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) {
Ok(RunnerFrame::Request { id, payload }) => {
if let Some(resp) =
handle_host_request_v4(&mut *plugin, interactive, &client, id, payload)
{
if let Some(resp) = handle_host_request_v4(
&mut *plugin,
interactive,
&client,
id,
api_version,
payload,
) {
client.send_response(id, resp)?;
}
}
@@ -277,12 +283,37 @@ fn handle_host_request_v4(
interactive: &InteractiveRegistry,
client: &V4Client,
id: u64,
api_version: u32,
req: HostRequest,
) -> Option<HostResponse> {
match req {
HostRequest::GetManifest => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
Ok(m) => Some(HostResponse::Manifest(m.into())),
Ok(m) => {
let mut manifest = OwnedPluginManifest::from(m);
// API v10: fold the plugin's entity-class declarations into
// the registered manifest. The call is guarded by the ABI
// version the cdylib was actually built against (the
// exported symbol, not the manifest's claim): an older
// cdylib's vtable predates the slot, so touching it would
// be undefined behavior. Its entities simply keep their
// native type names.
if api_version >= 10 {
let classes =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.entity_classes()
}));
manifest.entity_classes = match classes {
Ok(classes) => classes.map(OwnedEntityClasses::from),
Err(_) => {
return Some(HostResponse::Error(
"plugin entity_classes() panicked".to_string(),
))
}
};
}
Some(HostResponse::Manifest(manifest))
}
Err(_) => Some(HostResponse::Error(
"plugin manifest() panicked".to_string(),
)),
@@ -568,7 +599,7 @@ mod tests {
code: "1+1".to_string(),
tab_index: 0,
};
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req);
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, 4, req);
match resp {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("does not support code execution"));
@@ -577,4 +608,124 @@ mod tests {
}
drop(host_stream);
}
static V10_MANIFEST: PluginManifest = PluginManifest {
id: "test.classed",
name: "Test Classed",
version: "0.1.0",
description: "test",
api_version: ApiVersion { major: 10 },
ribbon_order: 0,
xdata_apps: &["CVIFC"],
command_prefixes: &[],
};
static TEST_ENTITY_CLASSES: crate::manifest::EntityClasses = crate::manifest::EntityClasses {
app_id: "CVIFC",
class_prefix: "CVIFC-CLASS:",
labels: &[
("LINE", "Linear Element"),
("CIRCULARARC", "Circular Arc Element"),
],
};
struct ClassedPlugin;
impl BuiltinPlugin for ClassedPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
Some(&TEST_ENTITY_CLASSES)
}
}
fn manifest_response_of(
plugin: &mut dyn BuiltinPlugin,
api_version: u32,
) -> Option<HostResponse> {
let (host_stream, runner_stream) = connect_pair();
let client = V4Client::from_stream_for_test(runner_stream);
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
let resp = handle_host_request_v4(
plugin,
&interactive,
&client,
99,
api_version,
HostRequest::GetManifest,
);
drop(host_stream);
resp
}
#[test]
fn get_manifest_folds_entity_classes_for_v10_plugin() {
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Manifest(manifest)) => {
let classes = manifest.entity_classes.expect("declaration folded in");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
assert_eq!(
classes.labels.get("CIRCULARARC").map(String::as_str),
Some("Circular Arc Element")
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_leaves_entity_classes_default_below_v10() {
// A cdylib whose exported ABI symbol predates v10 must not have the
// trait slot called, even if the in-process plugin object happens to
// implement it: the declaration stays `None` (native display types).
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 9) {
Some(HostResponse::Manifest(manifest)) => {
assert!(
manifest.entity_classes.is_none(),
"entity classes must not be folded in for pre-v10 plugins"
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_reports_entity_classes_panic() {
struct PanickingClassesPlugin;
impl BuiltinPlugin for PanickingClassesPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
panic!("boom")
}
}
let mut plugin = PanickingClassesPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("entity_classes() panicked"), "got: {msg}");
}
other => panic!("expected error response, got {other:?}"),
}
}
}