# `ocs_plugin_api` Versioned, out-of-process plugin API for Open CAD Studio. This crate defines the contract between the host CAD application and third-party plugins. It is designed to stay small and dependency-free in its default configuration so that plugin crates and external tooling can depend on the manifest/ribbon surface cheaply. The runtime host surface is enabled by the `host` feature. ## Plugin Architecture OCS is scriptable and extensible through a versioned plugin API. The API supports three major protocol generations: **V2**, **V3**, and **V4**. Each generation keeps the previous ABI stable by appending new vtable entries and new enum variants at the end, so older plugins continue to load on newer hosts. | Version | Delivery model | Key capability | ABI / wire change | |---------|----------------|----------------|-------------------| | **V2** | Out-of-process runner | Synchronous commands + interactive point collection (`HostApi::start_interactive`) | Baseline | | **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended | | **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait | The host controls which generations are accepted at runtime through `OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `3` to disable V4). The current host advertises [`API_VERSION = 4`](src/manifest.rs) and supports plugins back to `API_VERSION_MIN_SUPPORTED = 2`. ### High-level layout ```mermaid flowchart LR subgraph HostProcess["Host process"] App[OpenCADStudio] PM[PluginManager] PP[PluginProcess] HA[HostApi impl] end subgraph RunnerProcess["Runner process"] RL[Runner loop] V4C[V4Client] Proxy[HostApi proxy] end subgraph Library["Plugin cdylib"] BP[BuiltinPlugin impl] end App --> PM PM --> PP PP -- local socket --> RL RL --> V4C V4C --> Proxy Proxy --> BP PP -. document view / shared memory .-> HA ``` A plugin is a Rust `cdylib` (or any language exposing the C symbols) that: 1. Exports `ocs_plugin_api_version()` — returns the API major the plugin was built against. 2. Exports `ocs_plugin_register()` — returns a `Box`. 3. Implements `BuiltinPlugin` (manifest, ribbon, dispatch, optional REPL execution, optional notifications). The host does **not** load the plugin into its own address space. It re-executes itself as `--ocs-runner ` and talks to the runner over a local socket. This isolates plugin crashes from the CAD process. ### Version handshake ```mermaid sequenceDiagram participant H as Host participant R as Runner process participant L as Plugin cdylib H->>R: spawn --ocs-runner path token R->>L: dlopen, ocs_plugin_register() L-->>R: Box R->>H: V3 or V4 handshake token H->>R: GetManifest R-->>H: PluginManifest { api_version } alt api_version accepted H->>R: Dispatch / ExecuteCode / InteractiveEvent ... else api_version rejected H->>R: Shutdown end ``` ### V2/V3 wire protocol V2 and V3 share the same request/response framing. The host sends a `HostToPlugin::Request(HostRequest::*)`; the runner executes it and returns a `PluginToHost::Response(HostResponse::*)`. Nested plugin-to-host requests (for example `add_entity`) are allowed inside a dispatch: the runner creates a temporary `HostApi` proxy, forwards the `PluginRequest`, awaits the host reply, and resumes the original call. ```mermaid sequenceDiagram participant H as Host participant R as V2/V3 Runner participant P as BuiltinPlugin H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) R->>P: dispatch(host, "LINE") P->>R: PluginToHost::Request(AddEntity(...)) R->>H: forward AddEntity request H-->>R: handle R-->>P: handle P-->>R: true R-->>H: PluginToHost::Response(Bool(true)) ``` ### V4 wire protocol V4 replaces the simple request/response pipe with a multiplexed local socket. The frame format supports: - Host → runner requests (`HostToPluginV4::Request { id, payload: HostRequest }`) - Runner → host responses (`PluginToHostV4::Response { id, payload: HostResponse }`) - Bi-directional, best-effort notifications (`NotificationEnvelope`) Because the socket is full-duplex, the host can push `HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a long command is still running. ```mermaid sequenceDiagram participant H as Host participant R as V4 Runner participant P as BuiltinPlugin H->>R: Request(id=7, ExecuteCode { code: "1+1", tab_index: 2 }) R->>P: start_execute_code(host, 7, "1+1", Editor, respond) P-->>R: true (async) Note over P: spawned on worker thread P->>R: Notification(Output { text: "calculating..." }) R->>H: forward Output P->>R: Response(id=7, CodeExecutionResult { success: true, output: "2" }) R->>H: delayed response ``` ### V4 asynchronous REPL execution V4 introduces `BuiltinPlugin::start_execute_code`. It is the plugin's entry point for REPL-style code evaluation. The signature receives the active tab's `HostApi` so the REPL session is tied to the document tab that invoked it, plus a `respond` callback that the plugin calls when evaluation finishes (possibly from a background thread). V4 also introduces `PluginRequestSender`: a thread-safe, `'static` interface that lets a plugin send `PluginRequest`s back to the host from any thread. This is useful when the plugin spawns a background worker (for example, the Python REPL's mutation thread) that needs to read the document snapshot or apply mutations without blocking the runner loop. Obtain it from `HostApi::plugin_request_sender()`; it is only available when the plugin is hosted out-of-process by a V4 runner. ```rust use ocs_plugin_api::host::{ BuiltinPlugin, CommandSource, ExecutionResult, HostApi, execute_code_guard, PluginRequestSender, }; use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; struct ReplPlugin; impl BuiltinPlugin for ReplPlugin { // ... manifest, ribbon, dispatch ... fn start_execute_code( &mut self, host: &mut dyn HostApi, _command_id: u64, code: &str, _source: CommandSource, respond: Box, ) -> bool { let tab = host.tab_index(); let code = code.to_string(); // A V4 host can provide a sender for background threads. let sender: Option> = host.plugin_request_sender().map(Arc::from); execute_code_guard(respond, move || { // Long-running evaluation happens here without blocking the runner loop. // If a sender is available, worker threads can use it to call back into // the host, e.g. to refresh the document snapshot. let result = evaluate(&code, tab, sender.as_deref()); ExecutionResult { success: result.is_ok(), output: result.ok(), error: result.err(), error_type: None, traceback: None, line_number: None, column_number: None, duration_ms: 0.0, } }); true } } ``` ```rust // Used from a background thread. fn refresh_snapshot(sender: &dyn PluginRequestSender) -> Result> { match sender.request(PluginRequest::OpenDocumentView)? { PluginResponse::DocumentView { path, version } => Ok(path), other => Err(format!("unexpected response: {other:?}").into()), } } ``` The host side calls it synchronously but with a long timeout: ```rust use ocs_plugin_api::host::CommandSource; use ocs_plugin_api::process::{PluginError, PluginProcess}; fn run_editor_snippet( plugin: &PluginProcess, host: &mut dyn HostApi, ) -> Result { let result = plugin.execute_code(host, 42, CommandSource::Editor, "1+1")?; Ok(result.output.unwrap_or_default()) } ``` `PluginProcess::execute_code` reads `host.tab_index()` and forwards it in the `ExecuteCode` request, so the plugin always knows which document tab the REPL session belongs to. ### Embedded build-time metadata `ocs_plugin_api` embeds two JSON blobs at build time: - **Type registry** — a stable, language-binding-friendly schema generated by tracing a curated allow-list of `acadrust` types with `serde-reflection`. - **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`, `api_version`, etc. Access them without pulling the `host` feature: ```rust let registry_json = ocs_plugin_api::get_embedded_type_registry_json(); let version_json = ocs_plugin_api::get_embedded_version_info_json(); ``` ### Minimal plugin example ```rust use ocs_plugin_api::export_plugin; use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginManifest}; use ocs_plugin_api::manifest::ApiVersion; use ocs_plugin_api::ribbon::{ CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef, }; static MANIFEST: PluginManifest = PluginManifest { id: "com.example.hello", name: "Hello Plugin", version: "0.1.0", description: "A minimal example plugin.", api_version: ApiVersion { major: 4 }, ribbon_order: 100, xdata_apps: &[], command_prefixes: &["HELLO"], }; struct HelloModule; impl CadModule for HelloModule { fn id(&self) -> &'static str { MANIFEST.id } fn title(&self) -> &'static str { "Hello" } fn ribbon_groups(&self) -> &[RibbonGroup] { static GROUPS: &[RibbonGroup] = &[RibbonGroup { title: "Example", tools: vec![RibbonItem::Tool(ToolDef { id: "HELLO", label: "Say Hello", icon: IconKind::Glyph("H"), event: ModuleEvent::Command("HELLO".to_string()), })], }]; GROUPS } } struct HelloPlugin; impl BuiltinPlugin for HelloPlugin { fn manifest(&self) -> &'static PluginManifest { &MANIFEST } fn ribbon(&self) -> Box { Box::new(HelloModule) } fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { if cmd == "HELLO" { host.push_info("Hello from the plugin!"); return true; } false } } export_plugin!(HelloPlugin); ``` ### Host-side loading example ```rust use std::path::Path; use std::sync::Arc; use ocs_plugin_api::process::{PluginError, PluginManager}; fn main() -> Result<(), PluginError> { let mut manager = PluginManager::new(); manager.set_notification_handler(Arc::new(|_id, _cmd, _notif| {})); let mut host_api = /* host-provided HostApi implementation */; let _plugin_id = manager.load( Path::new("./target/release/libhello_plugin.dll"), &mut host_api, )?; let result = manager.dispatch(&mut host_api, "HELLO", |_| false); if result.handled { println!("Plugin handled HELLO"); } Ok(()) } ``` ### V4 shared-memory document view example V4 plugins can request a zero-copy snapshot of the active document and mutate it from a background thread through `PluginRequestSender`. ```rust use std::sync::Arc; use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginRequestSender}; use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; use ocs_plugin_api::shm::DocumentViewDataV4; use ocs_plugin_api::shm::SharedDocumentReader; struct SnapshotWorker { reader: SharedDocumentReader, sender: Arc, } impl SnapshotWorker { fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result { use acadrust::entities::Point; use acadrust::EntityType; let mut p = Point::from_coords(x, y, z); p.common.layer = "0".to_string(); let entity = EntityType::Point(p); match self.sender.request(PluginRequest::AddEntity(entity))? { PluginResponse::Handle(h) => Ok(h.value()), other => Err(anyhow::anyhow!("unexpected add response: {other:?}")), } } } ``` In the `BuiltinPlugin` dispatch method: ```rust fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { if cmd != "SNAPSHOT_DEMO" { return false; } let Some(view) = host.document_view_v4(host.tab_id()) else { host.push_error("host does not support V4 document views"); return true; }; let Some(sender) = host.plugin_request_sender() else { host.push_error("host does not provide a V4 request sender"); return true; }; let reader = SharedDocumentReader::::open(Path::new(&view.path)) .expect("open snapshot"); let worker = SnapshotWorker { reader, sender: Arc::from(sender), }; std::thread::spawn(move || { let _ = worker.add_point(10.0, 20.0, 0.0); }); true } ``` ### V4 request proxy pattern When a plugin spawns a child process (for example a Python REPL) that needs to mutate the host document, the plugin can run a local TCP request proxy and pass the port to the child. The child connects to the proxy; the proxy forwards `PluginRequest` frames to the host's `PluginRequestSender` and returns the responses. ```rust use std::net::TcpListener; use ocs_plugin_api::ipc::proxy::run_request_proxy_with_shutdown; let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); let sender: Arc = Arc::from(host.plugin_request_sender().unwrap()); std::thread::spawn(move || { let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); run_request_proxy_with_shutdown(listener, sender, shutdown_rx).ok(); }); // Pass `port` to the child process via environment variable. ``` ### Further reading - Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) - Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) - REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) - Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md)