feat(plugin): lift host surface behind a HostApi trait (phase 1b)

Plugins targeted the host's concrete HostSession type. Add a HostApi
trait in ocs_plugin_api behind an optional `host` feature (the only thing
that pulls acadrust, so the core crate stays dependency-free). HostSession
implements it and BuiltinPlugin::dispatch now takes `&mut dyn HostApi`, so
an out-of-tree add-on compiles against the contract crate alone. Per-tab
plugin state is reached through object-safe plugin_state* helpers.

Completes the phase-1 host-surface extraction in the #100 epic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-17 11:31:05 +03:00
commit 5ccf5cfe36
12 changed files with 231 additions and 22 deletions

3
Cargo.lock generated
View file

@ -3626,6 +3626,9 @@ dependencies = [
[[package]] [[package]]
name = "ocs_plugin_api" name = "ocs_plugin_api"
version = "0.1.0" version = "0.1.0"
dependencies = [
"acadrust",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"

View file

@ -24,7 +24,7 @@ solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"]
[dependencies] [dependencies]
# Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types).
# Plugin authors target this crate's semver, not OpenCADStudio internals. # Plugin authors target this crate's semver, not OpenCADStudio internals.
ocs_plugin_api = { path = "crates/ocs_plugin_api" } ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
# vtkio (pulled in transitively via truck-meshalgo) depends on xz2 → lzma-sys, # vtkio (pulled in transitively via truck-meshalgo) depends on xz2 → lzma-sys,
# which by default links the system liblzma dynamically. On macOS that bakes a # which by default links the system liblzma dynamically. On macOS that bakes a
# Homebrew dylib path (/opt/homebrew/.../liblzma.5.dylib) into the binary, so # Homebrew dylib path (/opt/homebrew/.../liblzma.5.dylib) into the binary, so

View file

@ -6,3 +6,12 @@ description = "Stable, dependency-free contract for Open CAD Studio add-on packa
license = "GPL-3.0-only" license = "GPL-3.0-only"
[dependencies] [dependencies]
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { version = "0.3.4", optional = true }
[features]
# Enables the runtime host surface (`HostApi` trait). The OpenCADStudio binary
# turns this on; pure-data consumers leave it off.
host = ["dep:acadrust"]

View file

@ -0,0 +1,92 @@
//! Runtime host surface (`host` feature).
//!
//! [`HostApi`] is the `acadrust`-typed adapter a plugin uses at *dispatch* time
//! — document access, entity creation, XDATA, undo, and the command line. It is
//! the stable counterpart to the dependency-free manifest/ribbon contract: a
//! plugin's `dispatch` receives `&mut dyn HostApi` rather than the host's
//! concrete session type, so an out-of-tree add-on compiles against this crate
//! alone.
//!
//! Per-tab plugin state is keyed by `manifest.id`. The trait exposes it in an
//! object-safe `Any` form; use the [`plugin_state`], [`plugin_state_mut`] and
//! [`ensure_plugin_state`] helpers for the ergonomic typed access.
use std::any::Any;
use acadrust::xdata::ExtendedDataRecord;
use acadrust::{CadDocument, EntityType, Handle};
/// The plugin-facing runtime surface for one active document tab.
pub trait HostApi {
/// Index of the tab this session targets.
fn tab_index(&self) -> usize;
// ── Document ────────────────────────────────────────────────────────────
fn document(&self) -> &CadDocument;
fn document_mut(&mut self) -> &mut CadDocument;
/// Add an entity to the active document, returning its handle.
fn add_entity(&mut self, entity: EntityType) -> Handle;
/// Mark the scene geometry dirty so it is re-tessellated next frame.
fn bump_geometry(&mut self);
// ── XDATA ───────────────────────────────────────────────────────────────
/// Read the XDATA record for `app_name` on entity `handle`, if any.
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord>;
/// Attach `record` to entity `handle`, replacing any existing record for the
/// same application and registering the APPID. Returns `false` if the entity
/// does not exist.
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool;
/// Remove the XDATA record for `app_name` from entity `handle`. Returns
/// `true` if a record was removed.
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool;
// ── Undo / dirty ────────────────────────────────────────────────────────
fn push_undo(&mut self, label: &str);
fn set_dirty(&mut self);
// ── Command line ────────────────────────────────────────────────────────
fn push_info(&mut self, msg: &str);
fn push_output(&mut self, msg: &str);
fn push_error(&mut self, msg: &str);
// ── Per-tab plugin state (object-safe; use the typed helpers below) ──────
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>;
fn plugin_state_any_mut(&mut self, plugin_id: &str)
-> Option<&mut (dyn Any + Send + Sync)>;
/// Get the state for `plugin_id`, inserting `init()`'s result if absent.
fn ensure_plugin_state_any(
&mut self,
plugin_id: &'static str,
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync);
}
/// Typed read of per-tab plugin state stored under `plugin_id`.
pub fn plugin_state<'a, T: Any + Send + Sync>(
host: &'a dyn HostApi,
plugin_id: &str,
) -> Option<&'a T> {
host.plugin_state_any(plugin_id)?.downcast_ref::<T>()
}
/// Typed mutable access to per-tab plugin state stored under `plugin_id`.
pub fn plugin_state_mut<'a, T: Any + Send + Sync>(
host: &'a mut dyn HostApi,
plugin_id: &str,
) -> Option<&'a mut T> {
host.plugin_state_any_mut(plugin_id)?.downcast_mut::<T>()
}
/// Typed get-or-insert of per-tab plugin state stored under `plugin_id`.
pub fn ensure_plugin_state<'a, T: Any + Send + Sync>(
host: &'a mut dyn HostApi,
plugin_id: &'static str,
init: impl FnOnce() -> T,
) -> &'a mut T {
let mut init = Some(init);
let any = host.ensure_plugin_state_any(plugin_id, &mut || {
Box::new((init.take().expect("init called once"))())
});
any.downcast_mut::<T>()
.expect("plugin state type mismatch for plugin_id")
}

View file

@ -19,6 +19,10 @@
pub mod manifest; pub mod manifest;
pub mod ribbon; pub mod ribbon;
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
#[cfg(feature = "host")]
pub mod host;
pub use manifest::{ApiVersion, PluginManifest, API_VERSION}; pub use manifest::{ApiVersion, PluginManifest, API_VERSION};
pub use ribbon::{ pub use ribbon::{
CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef,

View file

@ -150,7 +150,7 @@ pub struct PluginManifest {
pub trait BuiltinPlugin: Send + Sync { pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest; fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>; fn ribbon(&self) -> Box<dyn CadModule>;
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
} }
``` ```
@ -183,14 +183,20 @@ Plugins use `HostSession`, not `OpenCADStudio`:
| Command line | `push_info`, `push_output`, `push_error`, `set_active_command` | | Command line | `push_info`, `push_output`, `push_error`, `set_active_command` |
| Undo / dirty | `push_undo`, `set_dirty` | | Undo / dirty | `push_undo`, `set_dirty` |
**Status:** The dependency-free half of the contract — `PluginManifest` / **Status:** The whole contract now lives in the standalone, semver-versioned
`ApiVersion` (manifest) and `CadModule` + the ribbon types (`ToolDef`, [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate:
`RibbonGroup`, …) — now lives in the standalone, semver-versioned - **Dependency-free core**`PluginManifest` / `ApiVersion` (manifest) and
[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) crate. The host re-exports it `CadModule` + ribbon types (`ToolDef`, `RibbonGroup`, …). Engine crates and
(`crate::plugin::manifest`, `crate::modules`) so in-tree paths are unchanged. external tooling depend on this cheaply.
The `acadrust`-typed runtime surface in the table above (`document_mut`, - **`host` feature** — the `acadrust`-typed `HostApi` trait (the runtime surface
`add_entity`, `set_active_command`, …) stays in the host binary for now; lifting in the table above). `HostSession` in the binary implements it; a plugin's
it behind a `HostApi` trait in the same crate is the remaining phase-1b step. `dispatch` receives `&mut dyn HostApi`, so an out-of-tree add-on compiles
against this crate alone. Per-tab plugin state is reached through the
object-safe `plugin_state*` helpers. `set_active_command` (interactive
acquisition) stays host-side for now — see Command routing.
The host re-exports both (`crate::plugin::manifest`, `crate::modules`,
`crate::plugin::host::HostApi`) so in-tree paths are unchanged.
### Command routing ### Command routing
@ -255,7 +261,7 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with
- [x] Per-tab `plugin_state` - [x] Per-tab `plugin_state`
- [x] Storm Sewer off `commands.rs` monolith - [x] Storm Sewer off `commands.rs` monolith
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`) - [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
- [~] Extract `ocs_plugin_api` crate — manifest + ribbon/`CadModule` done; `acadrust`-typed host surface pending - [x] Extract `ocs_plugin_api` crate — dependency-free manifest + ribbon/`CadModule`, plus the `acadrust`-typed `HostApi` trait behind the optional `host` feature; `dispatch` takes `&mut dyn HostApi`
- [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button - [x] Plugin manager UI (list installed, versions) — `PLUGINS` / `PLUGINMANAGER` command, or the Start-page "Plugins" button
- [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`) - [x] Enable/disable plugins from the manager — a disabled plugin drops its ribbon tab and command dispatch; persisted in `settings.txt` (`disabled_plugins=`)
- [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `"<command> <path>"` back to the plugin with original case preserved (bypasses the command-line upper-casing) - [x] `ModuleEvent::PluginFileDialog` — a plugin tool requests a native file picker; the host opens it and dispatches `"<command> <path>"` back to the plugin with original case preserved (bypasses the command-line upper-casing)

View file

@ -1,8 +1,8 @@
use crate::plugin::host::HostSession; use crate::plugin::host::HostApi;
use super::manifest::PLUGIN_ID; use super::manifest::PLUGIN_ID;
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool {
let _ = (host, PLUGIN_ID); let _ = (host, PLUGIN_ID);
match cmd { match cmd {
"MP_HELLO" => { "MP_HELLO" => {

View file

@ -1,4 +1,4 @@
use crate::plugin::host::{BuiltinPlugin, HostSession}; use crate::plugin::host::{BuiltinPlugin, HostApi};
use crate::plugin::manifest::PluginManifest; use crate::plugin::manifest::PluginManifest;
use super::dispatch; use super::dispatch;
@ -15,7 +15,7 @@ impl BuiltinPlugin for MyPlugin {
Box::new(super::MyPluginModule) Box::new(super::MyPluginModule)
} }
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
dispatch::handle(host, cmd) dispatch::handle(host, cmd)
} }
} }

View file

@ -5,6 +5,7 @@ use std::any::{Any, TypeId};
use acadrust::tables::AppId; use acadrust::tables::AppId;
use acadrust::xdata::ExtendedDataRecord; use acadrust::xdata::ExtendedDataRecord;
use acadrust::{CadDocument, EntityType, Handle}; use acadrust::{CadDocument, EntityType, Handle};
use ocs_plugin_api::host::HostApi;
use super::OpenCADStudio; use super::OpenCADStudio;
use crate::command::CadCommand; use crate::command::CadCommand;
@ -167,6 +168,79 @@ impl<'a> HostSession<'a> {
} }
} }
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
/// inherent `HostSession` method of the same name (inherent methods take
/// resolution priority, so this is plain delegation, not recursion). The
/// per-tab plugin-state accessors expose the raw `Any` box; the typed
/// `ocs_plugin_api::host::plugin_state*` helpers wrap them.
impl HostApi for HostSession<'_> {
fn tab_index(&self) -> usize {
self.tab_index()
}
fn document(&self) -> &CadDocument {
self.document()
}
fn document_mut(&mut self) -> &mut CadDocument {
self.document_mut()
}
fn add_entity(&mut self, entity: EntityType) -> Handle {
self.add_entity(entity)
}
fn bump_geometry(&mut self) {
self.bump_geometry()
}
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.read_record(handle, app_name)
}
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
self.write_record(handle, record)
}
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
self.remove_record(handle, app_name)
}
fn push_undo(&mut self, label: &str) {
self.push_undo(label)
}
fn set_dirty(&mut self) {
self.set_dirty()
}
fn push_info(&mut self, msg: &str) {
self.push_info(msg)
}
fn push_output(&mut self, msg: &str) {
self.push_output(msg)
}
fn push_error(&mut self, msg: &str) {
self.push_error(msg)
}
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
self.app.tabs[self.tab]
.plugin_state
.get(plugin_id)
.map(|b| b.as_ref())
}
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)
.map(|b| b.as_mut())
}
fn ensure_plugin_state_any(
&mut self,
plugin_id: &'static str,
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync) {
self.app.tabs[self.tab]
.plugin_state
.entry(plugin_id)
.or_insert_with(|| init())
.as_mut()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -202,4 +276,20 @@ mod tests {
assert!(host.read_record(h, "DEMO_SURVEY").is_none()); assert!(host.read_record(h, "DEMO_SURVEY").is_none());
assert!(!host.remove_record(h, "DEMO_SURVEY")); assert!(!host.remove_record(h, "DEMO_SURVEY"));
} }
#[test]
fn plugin_state_round_trips_through_hostapi_trait() {
use ocs_plugin_api::host::{self, HostApi};
let mut app = OpenCADStudio::new_for_test();
let mut session = HostSession::new(&mut app, 0);
let host: &mut dyn HostApi = &mut session;
// Absent before first use.
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);
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
assert_eq!(*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(), 100);
}
} }

View file

@ -1,6 +1,6 @@
use crate::plugin::host::HostSession; use crate::plugin::host::HostApi;
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { pub fn handle(host: &mut dyn HostApi, cmd: &str) -> bool {
// "DP_IMPORT <path>" arrives from ModuleEvent::PluginFileDialog with the // "DP_IMPORT <path>" arrives from ModuleEvent::PluginFileDialog with the
// path in its original case (the command line is bypassed). // path in its original case (the command line is bypassed).
if let Some(path) = cmd.strip_prefix("DP_IMPORT ") { if let Some(path) = cmd.strip_prefix("DP_IMPORT ") {

View file

@ -1,4 +1,4 @@
use crate::plugin::host::{BuiltinPlugin, HostSession}; use crate::plugin::host::{BuiltinPlugin, HostApi};
use crate::plugin::manifest::PluginManifest; use crate::plugin::manifest::PluginManifest;
use super::dispatch; use super::dispatch;
@ -15,7 +15,7 @@ impl BuiltinPlugin for DemoPlugin {
Box::new(super::DemoPluginModule) Box::new(super::DemoPluginModule)
} }
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
dispatch::handle(host, cmd) dispatch::handle(host, cmd)
} }
} }

View file

@ -1,6 +1,9 @@
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access). // Plugin traits — HostSession lives in `app::plugin_host` (same-crate field
// access) and implements the stable `HostApi` contract plugins target.
pub(crate) use crate::app::plugin_host::HostSession; pub(crate) use crate::app::plugin_host::HostSession;
/// The stable runtime surface a plugin's `dispatch` receives.
pub use ocs_plugin_api::host::HostApi;
use crate::modules::CadModule; use crate::modules::CadModule;
@ -9,9 +12,11 @@ use super::manifest::PluginManifest;
/// Add-on package entry point (phase 1: in-tree, in-process). /// Add-on package entry point (phase 1: in-tree, in-process).
/// ///
/// One `PluginRegistration` per package — ribbon tab, manifest, and command /// One `PluginRegistration` per package — ribbon tab, manifest, and command
/// dispatch are owned here. See `docs/plugin-architecture.md`. /// dispatch are owned here. `dispatch` receives `&mut dyn HostApi` (the stable
/// `ocs_plugin_api` contract), not the host's concrete session type. See
/// `docs/plugin-architecture.md`.
pub trait BuiltinPlugin: Send + Sync { pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest; fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>; fn ribbon(&self) -> Box<dyn CadModule>;
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
} }