Fix 5: Allow backward-compatibility and add plugin-template-v2

This commit is contained in:
Sebastian authored and Hakan Seven committed 2026-06-25 00:02:17 +03:00
1 parent 2b898d6f22
commit d0003534a0
12 files changed
+568 -60

No files matched your search

+163 -29
View File
@@ -13,11 +13,16 @@ use super::OpenCADStudio;
pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
doc_store: Option<ocs_plugin_api::shm::DocumentSnapshotStore>,
}
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<ocs_plugin_api::shm::DocumentViewInfo> {
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<dyn ocs_plugin_api::host::InteractiveCommand>,
) {
fn start_interactive(&mut self, command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {
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<dyn ocs_plugin_api::host::DocumentReader + '_> {
Box::new(ocs_plugin_api::host::CadDocumentReader(self.document()))
}
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
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::<u32>(&*host, "opencad.demo").is_none());
// Insert via ensure, then mutate.
*host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1;
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 8);
assert_eq!(
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
8
);
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 100);
assert_eq!(
*host::plugin_state::<u32>(&*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);
}
}
#[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)));
}
}
+28 -22
View File
@@ -8,9 +8,9 @@
//! Layout (mirrors the spec in `docs/plugin-architecture.md`):
//! ```text
//! <config>/OpenCADStudio/plugins/
//! opencad.storm_sewer/
//! <plugin-id>/
//! plugin.toml
//! <libopencad_storm_sewer.so | .dll | .dylib>
//! <lib<name>.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<PathBuf> {
/// 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();
+9 -5
View File
@@ -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<String, String> {
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
));
}
+2 -2
View File
@@ -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