From d0003534a0fdd3320fd13df7c1936353ce6b78b2 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:17:36 +0200 Subject: [PATCH] Fix 5: Allow backward-compatibility and add plugin-template-v2 --- crates/ocs_plugin_api/src/lib.rs | 4 +- crates/ocs_plugin_api/src/manifest.rs | 7 + crates/ocs_plugin_api/src/runner.rs | 2 +- crates/plugin-template-api2/Cargo.toml | 13 ++ crates/plugin-template-api2/src/lib.rs | 76 ++++++++++ docs/plugin-template-v2/Cargo.toml | 13 ++ docs/plugin-template-v2/DESIGN.md | 78 ++++++++++ docs/plugin-template-v2/src/lib.rs | 175 ++++++++++++++++++++++ src/app/plugin_host.rs | 192 +++++++++++++++++++++---- src/plugin/external.rs | 50 ++++--- src/plugin/marketplace.rs | 14 +- src/ui/ribbon/widgets.rs | 4 +- 12 files changed, 568 insertions(+), 60 deletions(-) create mode 100644 crates/plugin-template-api2/Cargo.toml create mode 100644 crates/plugin-template-api2/src/lib.rs create mode 100644 docs/plugin-template-v2/Cargo.toml create mode 100644 docs/plugin-template-v2/DESIGN.md create mode 100644 docs/plugin-template-v2/src/lib.rs diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 8b06bfd2..3d394209 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -41,7 +41,9 @@ pub mod shm; #[cfg(feature = "host")] pub mod runner; -pub use manifest::{ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED}; +pub use manifest::{ + host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED, +}; pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; #[cfg(feature = "host")] diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index d6990113..efcc0403 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -28,6 +28,13 @@ impl ApiVersion { } } +/// True when a plugin built against `plugin_major` can be loaded by this host. +/// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to +/// `API_VERSION`. +pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { + plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= API_VERSION +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 10396f90..30bbcaad 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -152,7 +152,7 @@ unsafe fn load_plugin(path: &Path) -> Result, Box crate::API_VERSION { + if !crate::host_accepts_plugin_version(v) { return Err(format!( "API version {v} is incompatible (host supports {}-{})", crate::API_VERSION_MIN_SUPPORTED, diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml new file mode 100644 index 00000000..e9f6cfb1 --- /dev/null +++ b/crates/plugin-template-api2/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "plugin-template-api2" +version = "0.1.0" +edition = "2021" +description = "API v2 compatibility fixture for the out-of-process plugin path." +license = "GPL-3.0-only" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } +acadrust = "0.3.4" diff --git a/crates/plugin-template-api2/src/lib.rs b/crates/plugin-template-api2/src/lib.rs new file mode 100644 index 00000000..e2c4e0d2 --- /dev/null +++ b/crates/plugin-template-api2/src/lib.rs @@ -0,0 +1,76 @@ +//! API v2 compatibility fixture. +//! +//! Mimics an old plugin compiled against `ocs_plugin_api` v2: it reports API +//! major 2 from `ocs_plugin_api_version()` and implements only the v2 surface +//! (`HostApi` methods up to `start_interactive`). The current host must still +//! be able to load and dispatch it. + +use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +static MANIFEST: PluginManifest = PluginManifest { + id: "opencad.my_plugin", + name: "My Plugin", + version: env!("CARGO_PKG_VERSION"), + description: "API v2 fixture plugin.", + api_version: ApiVersion { major: 2 }, + ribbon_order: 60, + xdata_apps: &[], + command_prefixes: &["MP_"], +}; + +struct MyModule; + +impl CadModule for MyModule { + fn id(&self) -> &'static str { + "my_plugin" + } + fn title(&self) -> &'static str { + "My Plugin" + } + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Tools", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "MP_HELLO", + label: "Hello", + icon: IconKind::Glyph("*"), + event: ModuleEvent::Command("MP_HELLO".to_string()), + })], + }] + } +} + +struct MyPlugin; + +impl BuiltinPlugin for MyPlugin { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(MyModule) + } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + match cmd { + "MP_HELLO" => { + host.push_info("Hello from API v2 plugin"); + true + } + _ => false, + } + } +} + +// Custom C-ABI export that reports API v2, emulating an older build of +// `ocs_plugin_api::export_plugin!`. +#[no_mangle] +pub extern "C" fn ocs_plugin_api_version() -> u32 { + 2 +} + +#[no_mangle] +pub extern "C" fn ocs_plugin_register() -> *mut Box { + let plugin: Box = Box::new(MyPlugin); + Box::into_raw(Box::new(plugin)) +} diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml new file mode 100644 index 00000000..f826d178 --- /dev/null +++ b/docs/plugin-template-v2/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "plugin-template-v2" +version = "0.1.0" +edition = "2021" +description = "Example OpenCAD Studio plugin demonstrating the shared-memory DocumentReader API." +license = "GPL-3.0-only" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } +acadrust = "0.3.4" diff --git a/docs/plugin-template-v2/DESIGN.md b/docs/plugin-template-v2/DESIGN.md new file mode 100644 index 00000000..8a30078c --- /dev/null +++ b/docs/plugin-template-v2/DESIGN.md @@ -0,0 +1,78 @@ +# `plugin-template-v2` — Shared-memory `DocumentReader` example + +This crate is a runnable plugin template that demonstrates the V2 host/plugin +contract: + +- **Zero-copy reads** through [`ocs_plugin_api::host::DocumentReader`]. +- **Validated writes** through the existing `HostApi` RPCs (`add_entity`, + `write_record`). +- **Read→write round-trips**: read an entity handle from the shared document + view and attach XDATA to it. + +It lives under `docs/` (not `crates/`) because it is documentation/example code +rather than a runtime dependency of the host. + +--- + +## Commands + +| Command | Purpose | +|---|---| +| `COUNT_SURVEY_POINTS` | Count point entities on the `SURVEY` layer via `document_reader()`. | +| `ADD_SURVEY_POINT` | Add a new point entity on the `SURVEY` layer via `host.add_entity()`. | +| `MARK_FIRST_SURVEY_POINT` | Read the first `SURVEY` point and mark it with an XDATA record. | +| `COUNT_MARKED_SURVEY_POINTS` | Count `SURVEY` points that carry the `SURVEYMARK` XDATA record. | + +--- + +## Design notes + +### Reads are zero-copy; writes are RPCs + +`document_reader()` returns a read-only view backed by host-owned shared memory +(for out-of-process plugins) or by `&CadDocument` (for in-process plugins). The +plugin iterates entities without copying the model. + +All mutations still cross the validated RPC boundary. This preserves the +host's crash-safety boundary: a buggy plugin cannot corrupt the host document +because it never holds a mutable reference to host memory. + +### Entity handles bridge reads and writes + +Each [`ReaderEntity`](ocs_plugin_api::host::ReaderEntity) exposes the entity +`handle`. A plugin can therefore: + +1. Find an entity of interest with `document_reader().for_each_entity(...)`. +2. Use that handle with `host.write_record(handle, record)` to attach XDATA. +3. Read the record back later with `host.read_record(handle, app_name)`. + +This is the primary read→write round-trip pattern the template demonstrates. + +### Ribbon integration + +The plugin registers one ribbon module (`SurveyTools`) with one group +(`Survey`). Each tool emits a `ModuleEvent::Command(...)` carrying the command +name. The host routes the command to `BuiltinPlugin::dispatch`. + +--- + +## Building and running + +From the workspace root: + +```bash +cargo build -p plugin-template-v2 +``` + +The resulting cdylib plus `plugin.toml` can be installed into the host's +plugins folder (see `docs/plugin-architecture.md`). + +--- + +## Compatibility + +The template is built against the current `ocs_plugin_api` and declares +`ApiVersion::CURRENT`. Older plugins compiled against API v2 continue to load +on an API v3 host because new `HostApi` methods are appended at the end of the +trait and the host accepts plugin majors from `API_VERSION_MIN_SUPPORTED` up to +`API_VERSION`. diff --git a/docs/plugin-template-v2/src/lib.rs b/docs/plugin-template-v2/src/lib.rs new file mode 100644 index 00000000..702f39c9 --- /dev/null +++ b/docs/plugin-template-v2/src/lib.rs @@ -0,0 +1,175 @@ +//! OpenCAD Studio plugin template v2. +//! +//! Demonstrates read/write round-trips between the plugin and the host using +//! the zero-copy `DocumentReader` API and the validated `HostApi` RPCs. + +use acadrust::entities::Point; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; +use acadrust::{EntityType, Handle}; +use ocs_plugin_api::export_plugin; +use ocs_plugin_api::host::{BuiltinPlugin, HostApi, ReaderEntityKind}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +static MANIFEST: PluginManifest = PluginManifest { + id: "com.example.plugin-template-v2", + name: "Plugin Template v2", + version: env!("CARGO_PKG_VERSION"), + description: "Demonstrates read/write object round-trips with the host.", + api_version: ApiVersion::CURRENT, + ribbon_order: 100, + xdata_apps: &["SURVEYMARK"], + command_prefixes: &[ + "COUNT_SURVEY_POINTS", + "ADD_SURVEY_POINT", + "MARK_FIRST_SURVEY_POINT", + "COUNT_MARKED_SURVEY_POINTS", + ], +}; + +struct PluginTemplateV2; + +impl BuiltinPlugin for PluginTemplateV2 { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + + fn ribbon(&self) -> Box { + Box::new(TemplateModule) + } + + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + match cmd { + "COUNT_SURVEY_POINTS" => { + let count = count_survey_points(host); + host.push_info(&format!("SURVEY points: {count}")); + true + } + "ADD_SURVEY_POINT" => { + let handle = add_survey_point(host); + host.push_info(&format!("Added SURVEY point {handle}")); + true + } + "MARK_FIRST_SURVEY_POINT" => { + if let Some(handle) = first_survey_point_handle(host) { + mark_point(host, handle); + } else { + host.push_info("No SURVEY point to mark"); + } + true + } + "COUNT_MARKED_SURVEY_POINTS" => { + let count = count_marked_survey_points(host); + host.push_info(&format!("Marked SURVEY points: {count}")); + true + } + _ => false, + } + } +} + +/// Read: count point entities on the SURVEY layer via the zero-copy reader. +fn count_survey_points(host: &mut dyn HostApi) -> usize { + let reader = host.document_reader(); + let mut count = 0usize; + reader.for_each_entity(&mut |e| { + if e.kind == ReaderEntityKind::Point && e.layer_name.eq_ignore_ascii_case("SURVEY") { + count += 1; + } + }); + count +} + +/// Write: add a new point entity on the SURVEY layer through a validated RPC. +fn add_survey_point(host: &mut dyn HostApi) -> Handle { + let mut point = Point::from_coords(0.0, 0.0, 0.0); + point.common.layer = "SURVEY".to_string(); + host.add_entity(EntityType::Point(point)) +} + +/// Read: locate the first SURVEY point and return its handle. +fn first_survey_point_handle(host: &mut dyn HostApi) -> Option { + let reader = host.document_reader(); + let mut handle = None; + reader.for_each_entity(&mut |e| { + if handle.is_none() + && e.kind == ReaderEntityKind::Point + && e.layer_name.eq_ignore_ascii_case("SURVEY") + { + handle = Some(e.handle); + } + }); + handle +} + +/// Write: attach an XDATA record to the entity, registering the APPID. +fn mark_point(host: &mut dyn HostApi, handle: Handle) { + let mut record = ExtendedDataRecord::new("SURVEYMARK"); + record.add_value(XDataValue::Integer32(1)); + if host.write_record(handle, record) { + host.push_info(&format!("Marked SURVEY point {handle}")); + } else { + host.push_error(&format!("Failed to mark SURVEY point {handle}")); + } +} + +/// Read+write round-trip: count SURVEY points that have the SURVEYMARK XDATA. +fn count_marked_survey_points(host: &mut dyn HostApi) -> usize { + let reader = host.document_reader(); + let mut handles = Vec::new(); + reader.for_each_entity(&mut |e| { + if e.kind == ReaderEntityKind::Point && e.layer_name.eq_ignore_ascii_case("SURVEY") { + handles.push(e.handle); + } + }); + handles + .into_iter() + .filter(|h| host.read_record(*h, "SURVEYMARK").is_some()) + .count() +} + +struct TemplateModule; + +impl CadModule for TemplateModule { + fn id(&self) -> &'static str { + MANIFEST.id + } + + fn title(&self) -> &'static str { + "Template v2" + } + + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Survey", + tools: vec![ + RibbonItem::LargeTool(ToolDef { + id: "COUNT_SURVEY_POINTS", + label: "Count", + icon: IconKind::Glyph("C"), + event: ModuleEvent::Command("COUNT_SURVEY_POINTS".to_string()), + }), + RibbonItem::LargeTool(ToolDef { + id: "ADD_SURVEY_POINT", + label: "Add Point", + icon: IconKind::Glyph("+"), + event: ModuleEvent::Command("ADD_SURVEY_POINT".to_string()), + }), + RibbonItem::LargeTool(ToolDef { + id: "MARK_FIRST_SURVEY_POINT", + label: "Mark First", + icon: IconKind::Glyph("M"), + event: ModuleEvent::Command("MARK_FIRST_SURVEY_POINT".to_string()), + }), + RibbonItem::LargeTool(ToolDef { + id: "COUNT_MARKED_SURVEY_POINTS", + label: "Marked", + icon: IconKind::Glyph("*"), + event: ModuleEvent::Command("COUNT_MARKED_SURVEY_POINTS".to_string()), + }), + ], + }] + } +} + +export_plugin!(PluginTemplateV2); diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 7dfca010..202a7781 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -13,11 +13,16 @@ use super::OpenCADStudio; pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, + doc_store: Option, } impl<'a> HostSession<'a> { pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self { - Self { app, tab } + Self { + app, + tab, + doc_store: None, + } } pub fn tab_index(&self) -> usize { @@ -32,8 +37,36 @@ impl<'a> HostSession<'a> { &mut self.app.tabs[self.tab].scene.document } + pub fn document_view(&mut self) -> Option { + use ocs_plugin_api::shm::DocumentSnapshotStore; + if self.doc_store.is_none() { + let mut store = DocumentSnapshotStore::new(self.tab, 8 * 1024 * 1024).ok()?; + store.publish(self.document()).ok()?; + self.doc_store = Some(store); + } + let store = self.doc_store.as_ref()?; + Some(ocs_plugin_api::shm::DocumentViewInfo { + path: store.path().to_string_lossy().to_string(), + version: store.version(), + }) + } + + fn publish_document_view(&mut self) { + let doc = &self.app.tabs[self.tab].scene.document; + if let Some(store) = self.doc_store.as_mut() { + if let Err(e) = store.publish(doc) { + eprintln!( + "[host] failed to publish document view for tab {}: {e}", + self.tab + ); + } + } + } + pub fn add_entity(&mut self, entity: EntityType) -> Handle { - self.app.tabs[self.tab].scene.add_entity(entity) + let handle = self.app.tabs[self.tab].scene.add_entity(entity); + self.publish_document_view(); + handle } pub fn bump_geometry(&mut self) { @@ -77,6 +110,7 @@ impl<'a> HostSession<'a> { xd.add_record(r); } xd.add_record(record); + self.publish_document_view(); true } @@ -100,6 +134,7 @@ impl<'a> HostSession<'a> { for r in kept { xd.add_record(r); } + self.publish_document_view(); true } @@ -131,7 +166,6 @@ impl<'a> HostSession<'a> { pub fn push_error(&mut self, msg: &str) { self.app.command_line.push_error(msg); } - } /// The stable contract a plugin's `dispatch` sees. Each method forwards to the @@ -179,10 +213,7 @@ impl HostApi for HostSession<'_> { fn push_error(&mut self, msg: &str) { self.push_error(msg) } - fn start_interactive( - &mut self, - command: Box, - ) { + fn start_interactive(&mut self, command: Box) { self.app.tabs[self.tab].active_cmd = Some(Box::new(PluginInteractiveAdapter { inner: command })); } @@ -192,10 +223,7 @@ impl HostApi for HostSession<'_> { .get(plugin_id) .map(|b| b.as_ref()) } - fn plugin_state_any_mut( - &mut self, - plugin_id: &str, - ) -> Option<&mut (dyn Any + Send + Sync)> { + fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { self.app.tabs[self.tab] .plugin_state .get_mut(plugin_id) @@ -212,6 +240,12 @@ impl HostApi for HostSession<'_> { .or_insert_with(|| init()) .as_mut() } + fn document_reader(&self) -> Box { + Box::new(ocs_plugin_api::host::CadDocumentReader(self.document())) + } + fn document_view(&mut self) -> Option { + self.document_view() + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -343,9 +377,7 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { } } -fn plugin_step_to_result( - step: ocs_plugin_api::host::CommandStep, -) -> crate::command::CmdResult { +fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult { use crate::command::CmdResult; use ocs_plugin_api::host::CommandStep; match step { @@ -362,6 +394,7 @@ mod tests { use crate::app::OpenCADStudio; use acadrust::entities::Point; use acadrust::xdata::XDataValue; + use ocs_plugin_api::host::DocumentReader; #[test] fn xdata_record_round_trips_and_registers_appid() { @@ -403,9 +436,15 @@ mod tests { assert!(host::plugin_state::(&*host, "opencad.demo").is_none()); // Insert via ensure, then mutate. *host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1; - assert_eq!(*host::plugin_state::(&*host, "opencad.demo").unwrap(), 8); + assert_eq!( + *host::plugin_state::(&*host, "opencad.demo").unwrap(), + 8 + ); *host::plugin_state_mut::(host, "opencad.demo").unwrap() = 100; - assert_eq!(*host::plugin_state::(&*host, "opencad.demo").unwrap(), 100); + assert_eq!( + *host::plugin_state::(&*host, "opencad.demo").unwrap(), + 100 + ); } /// A plugin command: second point commits a Point and ends. @@ -444,7 +483,10 @@ mod tests { let _ = app.apply_cmd_result(r); } assert_eq!(app.tabs[0].scene.document.entities().count(), 1); - assert!(app.tabs[0].active_cmd.is_none(), "command should have ended"); + assert!( + app.tabs[0].active_cmd.is_none(), + "command should have ended" + ); } /// A plugin command that picks an existing object, then marks it. @@ -464,9 +506,8 @@ mod tests { _handle: acadrust::Handle, pt: [f64; 3], ) -> ocs_plugin_api::host::CommandStep { - let p = acadrust::entities::Point::at(acadrust::types::Vector3::new( - pt[0], pt[1], pt[2], - )); + let p = + acadrust::entities::Point::at(acadrust::types::Vector3::new(pt[0], pt[1], pt[2])); ocs_plugin_api::host::CommandStep::CommitAndEnd(acadrust::EntityType::Point(p)) } } @@ -477,18 +518,14 @@ mod tests { app.tabs[0].is_start = false; let target = { let mut host = HostSession::new(&mut app, 0); - let h = host.add_entity(acadrust::EntityType::Point( - acadrust::entities::Point::at(acadrust::types::Vector3::new(3.0, 4.0, 0.0)), - )); + let h = host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at( + acadrust::types::Vector3::new(3.0, 4.0, 0.0), + ))); host.start_interactive(Box::new(PickThenMark)); h }; // The command requested an entity pick, not a free point. - assert!(app.tabs[0] - .active_cmd - .as_ref() - .unwrap() - .needs_entity_pick()); + assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick()); let r = app.tabs[0] .active_cmd .as_mut() @@ -498,4 +535,101 @@ mod tests { // Original point + the mark the command committed. assert_eq!(app.tabs[0].scene.document.entities().count(), 2); } -} \ No newline at end of file + + #[test] + fn host_document_reader_sees_entities() { + use ocs_plugin_api::host::ReaderEntityKind; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at( + acadrust::types::Vector3::new(7.0, 8.0, 0.0), + ))); + let reader = host.document_reader(); + assert_eq!(reader.entity_count(), 1); + let mut kinds = Vec::new(); + reader.for_each_entity(&mut |e| kinds.push(e.kind)); + assert_eq!(kinds, vec![ReaderEntityKind::Point]); + } + + #[test] + fn host_document_view_publish_and_read_shared() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let info = host.document_view().unwrap(); + let reader = + ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path)) + .unwrap(); + assert_eq!(reader.entity_count(), 0); + + host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at( + acadrust::types::Vector3::new(1.0, 2.0, 0.0), + ))); + + assert_eq!(reader.entity_count(), 1); + } + + /// Read an entity handle from the live document, write XDATA for that + /// handle, read it back, and remove it. + #[test] + fn document_reader_to_xdata_roundtrip() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 7.0, 8.0, 0.0, + )))); + + { + let reader = host.document_reader(); + assert_eq!(reader.entity_count(), 1); + let mut handles = Vec::new(); + reader.for_each_entity(&mut |e| handles.push(e.handle)); + assert_eq!(handles, vec![h]); + } + + let mut rec = ExtendedDataRecord::new("ROUNDTRIP"); + rec.add_value(XDataValue::String("from-reader".to_string())); + assert!(host.write_record(h, rec)); + + let got = host.read_record(h, "ROUNDTRIP").expect("record missing"); + assert_eq!(got.values.len(), 1); + assert!(matches!(got.values[0], XDataValue::String(ref s) if s == "from-reader")); + + assert!(host.remove_record(h, "ROUNDTRIP")); + assert!(host.read_record(h, "ROUNDTRIP").is_none()); + } + + /// Publish a shared document view, read the entity handle from shared + /// memory, then write and read-back XDATA through the normal HostApi RPCs. + #[test] + fn shared_document_view_read_then_write_xdata_roundtrip() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let info = host.document_view().unwrap(); + let reader = + ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path)) + .unwrap(); + + let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 1.0, 2.0, 0.0, + )))); + assert_eq!(reader.entity_count(), 1); + + let mut handles = Vec::new(); + reader.for_each_entity(&mut |e| handles.push(e.handle)); + assert_eq!(handles, vec![h]); + + let mut rec = ExtendedDataRecord::new("SHM_ROUNDTRIP"); + rec.add_value(XDataValue::Integer32(123)); + assert!(host.write_record(h, rec)); + + let got = host + .read_record(h, "SHM_ROUNDTRIP") + .expect("record missing"); + assert_eq!(got.values.len(), 1); + assert!(matches!(got.values[0], XDataValue::Integer32(123))); + } +} diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 7198beb2..245c8891 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -8,9 +8,9 @@ //! Layout (mirrors the spec in `docs/plugin-architecture.md`): //! ```text //! /OpenCADStudio/plugins/ -//! opencad.storm_sewer/ +//! / //! plugin.toml -//! +//! .so | .dll | .dylib> //! ``` use std::path::PathBuf; @@ -40,9 +40,9 @@ pub struct ExternalPlugin { } impl ExternalPlugin { - /// True when the package's API version matches the host ABI major. + /// True when the package's API version is supported by this host. pub fn api_compatible(&self) -> bool { - self.api_version == ocs_plugin_api::API_VERSION + ocs_plugin_api::host_accepts_plugin_version(self.api_version) } /// True when the package can be loaded today: compatible API *and* a native @@ -85,7 +85,9 @@ pub fn plugins_dir() -> Option { /// session (the library is resident); the removal takes effect on next start. #[cfg(not(target_arch = "wasm32"))] pub fn uninstall(id: &str) -> Result<(), String> { - let dir = plugins_dir().ok_or("cannot locate the plugins folder")?.join(id); + let dir = plugins_dir() + .ok_or("cannot locate the plugins folder")? + .join(id); if dir.is_dir() { std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; } @@ -254,7 +256,10 @@ mod loader { continue; } let Some(path) = lib_file(&d.dir) else { - out.push((d.id.clone(), Err("no native library in package".to_string()))); + out.push(( + d.id.clone(), + Err("no native library in package".to_string()), + )); continue; }; let mut host = crate::app::plugin_host::HostSession::new(app, 0); @@ -309,27 +314,24 @@ mod tests { use super::*; #[test] - fn parses_documented_keys() { + fn api_v2_plugin_from_template_is_compatible() { let toml = r#" [plugin] -id = "opencad.storm_sewer" -name = "Storm Sewer" -version = "0.2.0" -description = "Gravity storm-drain design" +id = "opencad.my_plugin" +name = "My Plugin" +version = "0.1.0" +description = "Template plugin" [opencad] -api_version = 1 -ribbon_order = 50 -command_prefixes = ["SS_", "STORM_"] +api_version = 2 +ribbon_order = 60 +command_prefixes = ["MP_"] +xdata_apps = ["MYPLUGIN_RECORD"] "#; let p = parse_plugin_toml(toml).expect("parsed"); - assert_eq!(p.id, "opencad.storm_sewer"); - assert_eq!(p.name, "Storm Sewer"); - assert_eq!(p.version, "0.2.0"); - assert_eq!(p.api_version, 1); - assert_eq!(p.ribbon_order, 50); - assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]); - assert!(!p.api_compatible()); + assert_eq!(p.api_version, 2); + assert!(p.command_prefixes.contains(&"MP_".to_string())); + assert!(p.api_compatible(), "API v2 plugins must run on API v3 host"); } #[test] @@ -361,7 +363,11 @@ command_prefixes = ["SS_", "STORM_"] std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .unwrap_or_else(|| std::env::current_exe().unwrap().into_os_string()), ); - assert!(host_exe.exists(), "host exe not found: {}", host_exe.display()); + assert!( + host_exe.exists(), + "host exe not found: {}", + host_exe.display() + ); std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe); let mut app = crate::app::OpenCADStudio::new_for_test(); diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index cf71ac53..62c1474c 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -57,7 +57,11 @@ impl Release { self.assets .iter() .find(|a| a.name.ends_with(&suffix)) - .or_else(|| self.assets.iter().find(|a| a.name.ends_with(&format!(".{ext}")))) + .or_else(|| { + self.assets + .iter() + .find(|a| a.name.ends_with(&format!(".{ext}"))) + }) } fn toml_asset(&self) -> Option<&Asset> { @@ -176,12 +180,12 @@ pub fn install(release: &Release) -> Result { let toml = release.toml_asset().ok_or("release has no plugin.toml")?; let toml_text = download_string(&toml.url)?; - let manifest = - external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?; - if manifest.api_version != ocs_plugin_api::API_VERSION { + let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?; + if !ocs_plugin_api::host_accepts_plugin_version(manifest.api_version) { return Err(format!( - "API version {} is incompatible (host is {})", + "API version {} is incompatible (host supports {}-{})", manifest.api_version, + ocs_plugin_api::API_VERSION_MIN_SUPPORTED, ocs_plugin_api::API_VERSION )); } diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 0efe8c31..806a64cc 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -324,7 +324,7 @@ pub(super) fn render_small<'a>( let active = active_tool.as_deref() == Some(id) || items .iter() - .any(|(cmd, _, _)| active_tool.as_deref() == Some(cmd)); + .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); let dd_open = open_dd.as_deref() == Some(id); let last = last_cmd.get(id).copied().unwrap_or(default); let cur_icon = last_cmd @@ -464,7 +464,7 @@ pub(super) fn render_large<'a>( let active = active_tool.as_deref() == Some(id) || items .iter() - .any(|(cmd, _, _)| active_tool.as_deref() == Some(cmd)); + .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); let dd_open = open_dd.as_deref() == Some(id); let last = last_cmd.get(id).copied().unwrap_or(default); let cur_icon = last_cmd