diff --git a/Cargo.toml b/Cargo.toml index 6f4b7c4e..8a14631d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,8 +83,6 @@ acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } rayon = "1" open = "5" ureq = { version = "3", default-features = false, features = ["rustls"] } -# Runtime loading of external plugin cdylibs (phase 2, desktop only). -libloading = "0.8" # Parse the GitHub Releases API response for the plugin marketplace. serde_json = "1" diff --git a/build.rs b/build.rs index f24a0a61..611a726a 100644 --- a/build.rs +++ b/build.rs @@ -101,9 +101,10 @@ fn main() { out.push_str("\t]\n}\n"); - let out_path = mods_dir.join("registry.rs"); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + let out_path = Path::new(&out_dir).join("modules_registry.rs"); let current = fs::read_to_string(&out_path).unwrap_or_default(); if current != out { - fs::write(&out_path, &out).expect("failed to write registry.rs"); + fs::write(&out_path, &out).expect("failed to write modules_registry.rs"); } } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 535912cd..588b5697 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -9,9 +9,17 @@ license = "GPL-3.0-only" # 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 } +acadrust = { version = "0.3.4", optional = true, features = ["serde"] } + +# Runtime IPC and serialization (host feature only). +interprocess = { version = "2", optional = true } +serde = { version = "1", features = ["derive"], optional = true } +bincode = { version = "1", optional = true } +thiserror = { version = "1", optional = true } +libloading = { version = "0.8", 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"] +# Enables the runtime host surface (`HostApi` trait) and the out-of-process +# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers +# leave it off. +host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] diff --git a/crates/ocs_plugin_api/REPORT.md b/crates/ocs_plugin_api/REPORT.md new file mode 100644 index 00000000..1509fd1c --- /dev/null +++ b/crates/ocs_plugin_api/REPORT.md @@ -0,0 +1,193 @@ +# Out-of-Process Plugin Architecture — `ocs_plugin_api` + +**Status:** Design proposal +**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate) + +--- + +## 1. Problem + +The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC. + +--- + +## 2. Design + +`ocs_plugin_api` becomes a dual-use library: + +- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`). +- **Host side:** runtime that spawns plugin processes and handles their IPC requests. + +Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner `) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time. + +--- + +## 3. Constraints + +| Constraint | Handling | +|---|---| +| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. | +| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. | +| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. | +| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. | + +--- + +## 4. Architecture + +```text +Host process Plugin process +┌─────────────────┐ local socket ┌─────────────────┐ +│ HostSession │◄───────────────►│ HostApi proxy │ +│ (document, UI) │ bincode frames │ (sends RPCs) │ +└─────────────────┘ └─────────────────┘ + ▲ │ + │ ▼ + PluginManager cdylib loaded + (spawn / kill / by host in + supervise) runner mode +``` + +### 4.1 IPC protocol + +All messages are length-framed and serialized with `bincode`. + +**Host → plugin:** + +- `GetManifest`, `GetRibbon` +- `Dispatch { cmd: String }` +- `InteractiveEvent { command_id, event }` +- `Shutdown` + +**Plugin → host:** + +- `PushInfo` / `PushOutput` / `PushError` +- `AddEntity(SerializedEntity)` → `Handle` +- `BumpGeometry`, `PushUndo`, `SetDirty` +- `ReadRecord`, `WriteRecord`, `RemoveRecord` +- `StartInteractive`, `PollInteractive` +- `DocumentSnapshot` → `SerializedDocument` + +`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived. + +### 4.2 Plugin-side runtime + +`OpenCADStudio --ocs-plugin-runner `: + +1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`. +2. Connects to the host socket and answers `GetManifest` / `GetRibbon`. +3. Runs a request loop: dispatch commands, forward interactive events. + +`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes. + +### 4.3 Host-side runtime + +`ocs_plugin_api::process` provides: + +- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection. +- `PluginManager` — spawn all discovered plugins, supervise, kill. +- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler. + +The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused. + +### 4.4 Ribbon + +Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code. + +### 4.5 Failure handling + +| Failure | Behavior | +|---|---| +| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. | +| Plugin panics | Caught inside the runner; an error response is returned to the host. | +| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. | + +--- + +## 5. Host Integration Points + +1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`. +2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch. +3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls. + +No changes to `docs/plugin-template` or any other plugin. + +--- + +## 6. Crate Changes + +### 6.1 New files inside `crates/ocs_plugin_api` + +```text +src/ + ipc/ + protocol.rs # HostRequest / PluginRequest / PluginResponse + transport.rs # framed read/write over local_socket + client.rs # plugin-side IpcClient + PluginHostApi + server.rs # host-side serve_plugin_connection + process.rs # PluginProcess / PluginManager + runner.rs # plugin runner logic invoked by host in runner mode +``` + +### 6.2 Dependencies + +Add under the existing `host` feature: + +```toml +[dependencies] +interprocess = { version = "2", optional = true } +serde = { version = "1", features = ["derive"], optional = true } +bincode = { version = "1", optional = true } +thiserror = { version = "1", optional = true } +libloading = { version = "0.8", optional = true } + +[features] +host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] +``` + +--- + +## 7. API Version + +Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`. + +--- + +## 8. Implementation Plan + +1. Add dependencies to `Cargo.toml`. +2. Implement framed transport and protocol messages. +3. Implement runner logic in `runner.rs`. +4. Implement `PluginHostApi` proxy. +5. Implement host-side server and `PluginManager`. +6. Add owned ribbon conversions. +7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`. +8. Bump `API_VERSION` and update `docs/plugin-architecture.md`. + +--- + +## 9. Testing + +- **Unit:** protocol round-trip, ribbon conversion, proxy request emission. +- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives. +- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`. + +--- + +## 10. Compliance with `AGENT.md` + +| Requirement | Status | +|---|---| +| `ocs_plugin_api` is a library, not a plugin | Yes | +| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. | +| Separate processes + failure management | Yes | +| Platform-independent `interprocess` IPC | Yes | +| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. | +| No new crate | Yes | +| Memory / process isolation | Yes | + +--- + +## 11. Summary + +`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced. diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 9b6ef0c7..b7de1fca 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -61,6 +61,8 @@ pub trait InteractiveCommand: Send { } /// The outcome of an [`InteractiveCommand`] step. +#[derive(Debug)] +#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] pub enum CommandStep { /// Need another point; keep the command active. NeedPoint, diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs new file mode 100644 index 00000000..0e94fb63 --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -0,0 +1,373 @@ +//! Plugin-side IPC client and `HostApi` proxy. + +use std::any::Any; +use std::cell::{Cell, OnceCell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; + +use acadrust::xdata::ExtendedDataRecord; +use acadrust::{CadDocument, EntityType, Handle}; +use interprocess::local_socket::traits::Stream as StreamTrait; +use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; + +use crate::host::{HostApi, InteractiveCommand}; +use crate::ipc::protocol::{ + HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, +}; +use crate::ipc::transport::{recv, send}; + +/// Shared registry of active interactive commands, keyed by host-assigned id. +pub type InteractiveRegistry = Rc>>>; + +/// Plugin-side connection to the host. +#[derive(Clone)] +pub struct IpcClient { + stream: Rc>, +} + +impl IpcClient { + pub fn connect(name: &str) -> std::io::Result { + let name = name + .to_ns_name::() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + let stream = StreamTrait::connect(name)?; + Ok(Self::from_stream(stream)) + } + + pub(crate) fn from_stream(stream: Stream) -> Self { + Self { + stream: Rc::new(RefCell::new(stream)), + } + } + + pub fn stream_ref(&self) -> std::cell::RefMut<'_, Stream> { + self.stream.borrow_mut() + } + + /// Send a plugin request and wait for the matching response. Any nested + /// host requests that arrive while we are waiting are treated as errors. + pub fn request( + &self, + req: PluginRequest, + ) -> Result { + send(&mut self.stream.borrow_mut(), &PluginToHost::Request(req))?; + loop { + match recv::(&mut self.stream.borrow_mut())? { + HostToPlugin::Response(resp) => return Ok(resp), + HostToPlugin::Request(host_req) => { + let resp = HostResponse::Error(format!( + "unexpected nested host request: {host_req:?}" + )); + send(&mut self.stream.borrow_mut(), &PluginToHost::Response(resp))?; + } + } + } + } +} + +/// `HostApi` implementation used inside the plugin process. Every host-mutating +/// method is an RPC; `document()` / `document_mut()` return a local cached copy. +pub struct PluginHostApi { + client: IpcClient, + tab_index: usize, + document_cache: OnceCell, + interactive: InteractiveRegistry, + next_command_id: Cell, + /// Cache XDATA records so repeated reads for the same (handle, app) return + /// stable references without leaking on every call. Each distinct record is + /// leaked once per plugin dispatch/interactive session. + record_cache: RefCell>, +} + +impl PluginHostApi { + pub fn new(client: IpcClient, tab_index: usize, interactive: InteractiveRegistry) -> Self { + Self { + client, + tab_index, + document_cache: OnceCell::new(), + interactive, + next_command_id: Cell::new(1), + record_cache: RefCell::new(HashMap::new()), + } + } + + fn fetch_document(&self) -> CadDocument { + match self.client.request(PluginRequest::DocumentSnapshot) { + Ok(PluginResponse::Document(doc)) => doc, + Ok(other) => { + eprintln!("[plugin] unexpected DocumentSnapshot response: {other:?}"); + CadDocument::default() + } + Err(e) => { + eprintln!("[plugin] failed to fetch document snapshot: {e}"); + CadDocument::default() + } + } + } +} + +impl HostApi for PluginHostApi { + fn tab_index(&self) -> usize { + self.tab_index + } + + fn document(&self) -> &CadDocument { + self.document_cache.get_or_init(|| self.fetch_document()) + } + + fn document_mut(&mut self) -> &mut CadDocument { + if self.document_cache.get().is_none() { + let doc = self.fetch_document(); + let _ = self.document_cache.set(doc); + } + self.document_cache.get_mut().expect("document initialized") + } + + fn add_entity(&mut self, entity: EntityType) -> Handle { + match self.client.request(PluginRequest::AddEntity(entity)) { + Ok(PluginResponse::Handle(h)) => h, + Ok(other) => { + eprintln!("[plugin] unexpected AddEntity response: {other:?}"); + Handle::default() + } + Err(e) => { + eprintln!("[plugin] AddEntity failed: {e}"); + Handle::default() + } + } + } + + fn bump_geometry(&mut self) { + let _ = self.client.request(PluginRequest::BumpGeometry); + } + + fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> { + let key = (handle, app_name.to_string()); + { + let cache = self.record_cache.borrow(); + if let Some(&r) = cache.get(&key) { + return Some(r); + } + } + match self.client.request(PluginRequest::ReadRecord { + handle, + app_name: app_name.to_string(), + }) { + Ok(PluginResponse::Record(rec)) => rec.map(|r| { + // Leak once per distinct (handle, app_name) and reuse the + // reference for the lifetime of this PluginHostApi. + let leaked: &'static ExtendedDataRecord = Box::leak(Box::new(r)); + self.record_cache.borrow_mut().insert(key, leaked); + leaked + }), + Ok(other) => { + eprintln!("[plugin] unexpected ReadRecord response: {other:?}"); + None + } + Err(e) => { + eprintln!("[plugin] ReadRecord failed: {e}"); + None + } + } + } + + fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool { + let app = record.application_name.clone(); + match self + .client + .request(PluginRequest::WriteRecord { handle, record }) + { + Ok(PluginResponse::Bool(b)) => { + if b { + self.record_cache + .borrow_mut() + .remove(&(handle, app)); + } + b + } + Ok(other) => { + eprintln!("[plugin] unexpected WriteRecord response: {other:?}"); + false + } + Err(e) => { + eprintln!("[plugin] WriteRecord failed: {e}"); + false + } + } + } + + fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool { + match self.client.request(PluginRequest::RemoveRecord { + handle, + app_name: app_name.to_string(), + }) { + Ok(PluginResponse::Bool(b)) => { + if b { + self.record_cache + .borrow_mut() + .remove(&(handle, app_name.to_string())); + } + b + } + Ok(other) => { + eprintln!("[plugin] unexpected RemoveRecord response: {other:?}"); + false + } + Err(e) => { + eprintln!("[plugin] RemoveRecord failed: {e}"); + false + } + } + } + + fn push_undo(&mut self, label: &str) { + if let Err(e) = self + .client + .request(PluginRequest::PushUndo { label: label.to_string() }) + { + eprintln!("[plugin] push_undo failed: {e}"); + } + } + + fn set_dirty(&mut self) { + if let Err(e) = self.client.request(PluginRequest::SetDirty) { + eprintln!("[plugin] set_dirty failed: {e}"); + } + } + + fn push_info(&mut self, msg: &str) { + if let Err(e) = self.client.request(PluginRequest::PushInfo(msg.to_string())) { + eprintln!("[plugin] push_info failed: {e}"); + } + } + + fn push_output(&mut self, msg: &str) { + if let Err(e) = self.client.request(PluginRequest::PushOutput(msg.to_string())) { + eprintln!("[plugin] push_output failed: {e}"); + } + } + + fn push_error(&mut self, msg: &str) { + if let Err(e) = self.client.request(PluginRequest::PushError(msg.to_string())) { + eprintln!("[plugin] push_error failed: {e}"); + } + } + + fn start_interactive(&mut self, command: Box) { + let id = self.next_command_id.get(); + self.next_command_id.set(id + 1); + self.interactive.borrow_mut().insert(id, command); + if let Err(e) = self + .client + .request(PluginRequest::StartInteractive { command_id: id }) + { + eprintln!("[plugin] start_interactive failed: {e}"); + } + } + + fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { + // Per-tab plugin state stored in the host cannot cross the process + // boundary because `dyn Any` is not serializable. Plugins should keep + // their own state inside the plugin process. + None + } + + fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { + None + } + + fn ensure_plugin_state_any( + &mut self, + _plugin_id: &'static str, + _init: &mut dyn FnMut() -> Box, + ) -> &mut (dyn Any + Send + Sync) { + // Same limitation as `plugin_state_any`. This would need a serializable + // state contract to work across processes. + panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate") + } +} + +#[cfg(all(test, feature = "host"))] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + + use acadrust::{EntityType, Handle}; + use acadrust::entities::Point; + use interprocess::local_socket::{ + traits::{Listener, Stream as StreamTrait}, + GenericNamespaced, ListenerOptions, Stream, ToNsName, + }; + + use crate::host::HostApi; + use crate::ipc::client::{IpcClient, PluginHostApi}; + use crate::ipc::protocol::{HostToPlugin, PluginRequest, PluginResponse, PluginToHost}; + use crate::ipc::transport::{recv, send}; + + fn unique_socket_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("ocs_plugin_client_test_{}_{}", std::process::id(), n) + } + + fn make_client() -> (PluginHostApi, Stream) { + let name = unique_socket_name(); + let name_ref = name + .clone() + .to_ns_name::() + .expect("valid name"); + let listener = ListenerOptions::new() + .name(name_ref) + .create_sync() + .expect("listener"); + let client_name = name.clone(); + let client_thread = thread::spawn(move || { + StreamTrait::connect(client_name.to_ns_name::().unwrap()) + .expect("connect") + }); + let server = listener.accept().expect("accept"); + let client_stream = client_thread.join().expect("client thread"); + let client = IpcClient::from_stream(server); + let api = PluginHostApi::new( + client, + 0, + std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())), + ); + (api, client_stream) + } + + #[test] + fn push_info_emits_request() { + let (mut api, mut peer) = make_client(); + let peer_handle = thread::spawn(move || { + let msg = recv::(&mut peer).unwrap(); + match msg { + PluginToHost::Request(PluginRequest::PushInfo(s)) => assert_eq!(s, "hello host"), + other => panic!("unexpected: {other:?}"), + } + send(&mut peer, &HostToPlugin::Response(PluginResponse::Ok)).unwrap(); + }); + api.push_info("hello host"); + peer_handle.join().unwrap(); + } + + #[test] + fn add_entity_awaits_handle_response() { + let (mut api, mut peer) = make_client(); + let peer_handle = thread::spawn(move || { + let msg = recv::(&mut peer).unwrap(); + match msg { + PluginToHost::Request(PluginRequest::AddEntity(_)) => {} + other => panic!("unexpected: {other:?}"), + } + send( + &mut peer, + &HostToPlugin::Response(PluginResponse::Handle(Handle::new(42))), + ) + .unwrap(); + }); + let handle = api.add_entity(EntityType::Point(Point::new())); + peer_handle.join().unwrap(); + assert_eq!(handle, Handle::new(42)); + } +} diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs new file mode 100644 index 00000000..e35cc7a9 --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -0,0 +1,113 @@ +//! Inter-process communication layer for out-of-process plugins. +//! +//! Built only with the `host` feature because it needs `acadrust`-typed +//! messages and the plugin runner binary. + +#[cfg(feature = "host")] +pub mod client; +#[cfg(feature = "host")] +pub mod protocol; +#[cfg(feature = "host")] +pub mod server; +#[cfg(feature = "host")] +pub mod transport; + +#[cfg(all(test, feature = "host"))] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::thread; + + use interprocess::local_socket::{ + traits::{Listener, Stream as StreamTrait}, + GenericNamespaced, ListenerOptions, Stream, ToNsName, + }; + + use crate::ipc::protocol::{HostRequest, HostResponse, HostToPlugin, PluginToHost}; + use crate::ipc::transport::{recv, send}; + + fn unique_socket_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("ocs_plugin_test_{}_{}", std::process::id(), n) + } + + fn connect_pair() -> (Stream, Stream) { + let name = unique_socket_name(); + let name_ref = name + .clone() + .to_ns_name::() + .expect("valid namespaced name"); + let listener = ListenerOptions::new() + .name(name_ref) + .create_sync() + .expect("create listener"); + let client = thread::spawn(move || { + StreamTrait::connect(name.to_ns_name::().unwrap()) + .expect("connect") + }); + let server = listener.accept().expect("accept"); + let client = client.join().expect("client thread"); + (server, client) + } + + #[test] + fn transport_round_trips_host_request() { + let (mut a, mut b) = connect_pair(); + let req = HostRequest::Dispatch { + cmd: "LINE".to_string(), + }; + send(&mut a, &HostToPlugin::Request(req)).unwrap(); + let got = recv::(&mut b).unwrap(); + match got { + HostToPlugin::Request(HostRequest::Dispatch { cmd }) => assert_eq!(cmd, "LINE"), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn transport_round_trips_plugin_request() { + let (mut a, mut b) = connect_pair(); + let req = PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo( + "hello".to_string(), + )); + send(&mut a, &req).unwrap(); + let got = recv::(&mut b).unwrap(); + match got { + PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo(msg)) => { + assert_eq!(msg, "hello") + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn transport_rejects_oversized_message() { + let (mut a, _b) = connect_pair(); + // A Vec larger than MAX_MESSAGE_SIZE should be rejected on send. + let huge = vec![0u8; 65 * 1024 * 1024]; + let err = send(&mut a, &huge).unwrap_err(); + assert!(format!("{err}").contains("too large")); + } + + #[test] + fn protocol_host_response_serde_roundtrip() { + let resp = HostResponse::Text("pick a point".to_string()); + let bytes = bincode::serialize(&resp).unwrap(); + let got: HostResponse = bincode::deserialize(&bytes).unwrap(); + match got { + HostResponse::Text(s) => assert_eq!(s, "pick a point"), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn protocol_plugin_to_host_serde_roundtrip() { + let msg = PluginToHost::Response(HostResponse::Bool(true)); + let bytes = bincode::serialize(&msg).unwrap(); + let got: PluginToHost = bincode::deserialize(&bytes).unwrap(); + match got { + PluginToHost::Response(HostResponse::Bool(b)) => assert!(b), + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs new file mode 100644 index 00000000..ea1c0a30 --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -0,0 +1,112 @@ +//! Request/response envelopes exchanged between the host and a plugin process. +//! +//! A single bidirectional socket is used. Each side sends either a request +//! (expecting a response) or a response (to a previous request). This lets the +//! host handle plugin RPCs inline while it waits for the result of a host→plugin +//! request such as `Dispatch`, avoiding the need for two sockets or threads. + +use serde::{Deserialize, Serialize}; + +use crate::host::CommandStep; +use crate::manifest::ApiVersion; +use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; + +pub use acadrust::{CadDocument, EntityType, Handle}; +pub use acadrust::xdata::ExtendedDataRecord; + +/// Events the host forwards to an active plugin `InteractiveCommand`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InteractiveEvent { + Point([f64; 3]), + Enter, + ObjectPick { handle: Handle, pt: [f64; 3] }, +} + +/// Requests the host sends to the plugin runner. +#[derive(Debug, Serialize, Deserialize)] +pub enum HostRequest { + GetManifest, + GetRibbon, + Dispatch { cmd: String }, + InteractiveEvent { command_id: u64, event: InteractiveEvent }, + GetPrompt { command_id: u64 }, + NeedsEntityPick { command_id: u64 }, + Shutdown, +} + +/// Responses the plugin runner sends back for `HostRequest`. +#[derive(Debug, Serialize, Deserialize)] +pub enum HostResponse { + Bool(bool), + CommandStep(CommandStep), + Text(String), + Ribbon(Vec), + Manifest(OwnedPluginManifest), + Error(String), +} + +/// Requests the plugin runner sends to the host. +#[derive(Debug, Serialize, Deserialize)] +pub enum PluginRequest { + PushInfo(String), + PushOutput(String), + PushError(String), + AddEntity(EntityType), + BumpGeometry, + ReadRecord { handle: Handle, app_name: String }, + WriteRecord { handle: Handle, record: ExtendedDataRecord }, + RemoveRecord { handle: Handle, app_name: String }, + PushUndo { label: String }, + SetDirty, + StartInteractive { command_id: u64 }, + DocumentSnapshot, +} + +/// Responses the host sends back for `PluginRequest`. +#[derive(Debug, Serialize, Deserialize)] +pub enum PluginResponse { + Ok, + Bool(bool), + Handle(Handle), + Record(Option), + Document(CadDocument), + Error(String), +} + +/// Messages sent from the host to the plugin runner. +#[derive(Debug, Serialize, Deserialize)] +pub enum HostToPlugin { + Request(HostRequest), + Response(PluginResponse), +} + +/// Messages sent from the plugin runner to the host. +#[derive(Debug, Serialize, Deserialize)] +pub enum PluginToHost { + Request(PluginRequest), + Response(HostResponse), +} + +/// Convenience helper for manifest serialization. +impl From<&'static crate::manifest::PluginManifest> for OwnedPluginManifest { + fn from(m: &'static crate::manifest::PluginManifest) -> Self { + Self { + id: m.id.to_string(), + name: m.name.to_string(), + version: m.version.to_string(), + description: m.description.to_string(), + api_version: m.api_version.major, + ribbon_order: m.ribbon_order, + xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(), + command_prefixes: m.command_prefixes.iter().map(|s| s.to_string()).collect(), + } + } +} + +impl OwnedPluginManifest { + pub fn api_version(&self) -> ApiVersion { + ApiVersion { + major: self.api_version, + } + } +} diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs new file mode 100644 index 00000000..9262da01 --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -0,0 +1,58 @@ +//! Host-side IPC request handler. + +use crate::host::HostApi; +use crate::ipc::protocol::{PluginRequest, PluginResponse}; + +/// Apply one plugin request to the host's `HostApi` implementation. +/// +/// `on_start_interactive` is called when the plugin starts an interactive +/// command; the host should install an adapter that sends +/// `HostRequest::InteractiveEvent` back to the plugin process. +pub fn handle_plugin_request( + host: &mut dyn HostApi, + req: PluginRequest, + on_start_interactive: &mut dyn FnMut(u64), +) -> PluginResponse { + use PluginRequest::*; + match req { + PushInfo(msg) => { + host.push_info(&msg); + PluginResponse::Ok + } + PushOutput(msg) => { + host.push_output(&msg); + PluginResponse::Ok + } + PushError(msg) => { + host.push_error(&msg); + PluginResponse::Ok + } + AddEntity(entity) => PluginResponse::Handle(host.add_entity(entity)), + BumpGeometry => { + host.bump_geometry(); + PluginResponse::Ok + } + ReadRecord { handle, app_name } => { + PluginResponse::Record(host.read_record(handle, &app_name).cloned()) + } + WriteRecord { handle, record } => { + PluginResponse::Bool(host.write_record(handle, record)) + } + RemoveRecord { handle, app_name } => { + PluginResponse::Bool(host.remove_record(handle, &app_name)) + } + PushUndo { label } => { + host.push_undo(&label); + PluginResponse::Ok + } + SetDirty => { + host.set_dirty(); + PluginResponse::Ok + } + StartInteractive { command_id } => { + on_start_interactive(command_id); + PluginResponse::Ok + } + DocumentSnapshot => PluginResponse::Document(host.document().clone()), + } +} diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs new file mode 100644 index 00000000..b2009f0c --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -0,0 +1,52 @@ +//! Length-framed transport over `interprocess::local_socket` streams. + +use std::io::{Read, Write}; + +use interprocess::local_socket::Stream; +use serde::{de::DeserializeOwned, Serialize}; + +/// Maximum serialized message size accepted over the wire (64 MiB). Prevents +/// a malicious or buggy peer from exhausting host/runner memory. +const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024; + +/// Errors that can occur during transport. +#[derive(Debug, thiserror::Error)] +pub enum TransportError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("serialization error: {0}")] + Encode(#[from] bincode::Error), + #[error("empty message")] + Empty, + #[error("message too large: {0} bytes")] + TooLarge(usize), +} + +/// Send a length-framed serialized message. +pub fn send(stream: &mut Stream, msg: &T) -> Result<(), TransportError> { + let bytes = bincode::serialize(msg)?; + if bytes.len() > MAX_MESSAGE_SIZE { + return Err(TransportError::TooLarge(bytes.len())); + } + let len = bytes.len() as u64; + stream.write_all(&len.to_le_bytes())?; + stream.write_all(&bytes)?; + stream.flush()?; + Ok(()) +} + +/// Receive a length-framed serialized message. +pub fn recv(stream: &mut Stream) -> Result { + let mut len_buf = [0u8; 8]; + stream.read_exact(&mut len_buf)?; + let len = u64::from_le_bytes(len_buf) as usize; + if len == 0 { + return Err(TransportError::Empty); + } + if len > MAX_MESSAGE_SIZE { + return Err(TransportError::TooLarge(len)); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf)?; + Ok(bincode::deserialize(&buf)?) +} diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index cff3749d..b9ec1281 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -23,7 +23,24 @@ pub mod ribbon; #[cfg(feature = "host")] pub mod host; +/// Out-of-process plugin runtime — only built with the `host` feature. +#[cfg(feature = "host")] +pub mod ipc; + +/// Process management for out-of-process plugins — only built with the `host` +/// feature. +#[cfg(feature = "host")] +pub mod process; + +/// Plugin runner implementation used by the host when it spawns itself in +/// runner mode — only built with the `host` feature. +#[cfg(feature = "host")] +pub mod runner; + pub use manifest::{ApiVersion, PluginManifest, API_VERSION}; pub use ribbon::{ CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, }; + +#[cfg(feature = "host")] +pub use process::{PluginError, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 5612fa10..4d0bda36 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,9 +1,10 @@ //! Plugin identity and capability declaration. /// Host plugin API version. Bump when the host runtime surface breaks -/// compatibility. v2 added `HostApi::start_interactive` (the -/// `InteractiveCommand` hook) — a vtable change, so v1 binaries are refused. -pub const API_VERSION: u32 = 2; +/// compatibility. v2 added `HostApi::start_interactive`. v3 changes +/// `document()` / `document_mut()` to local cached copies for out-of-process +/// plugins. +pub const API_VERSION: u32 = 3; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ApiVersion { diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs new file mode 100644 index 00000000..007fcdd8 --- /dev/null +++ b/crates/ocs_plugin_api/src/process.rs @@ -0,0 +1,315 @@ +//! Process management for out-of-process plugins. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::sync::Mutex; +use std::time::Duration; + +use interprocess::local_socket::traits::Listener; +use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToNsName}; + +use crate::host::{CommandStep, HostApi}; +use crate::ipc::protocol::{ + HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, +}; +use crate::ipc::server::handle_plugin_request; +use crate::ipc::transport::{recv, send}; +use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup as OwnedRibbonGroupAlias}; + +/// Maximum time to wait for the plugin runner to connect back to the host. +const SPAWN_TIMEOUT: Duration = Duration::from_secs(10); + +fn spawn_timeout() -> Duration { + std::env::var("OCS_PLUGIN_SPAWN_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(SPAWN_TIMEOUT) +} + +#[derive(Debug, thiserror::Error)] +pub enum PluginError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("transport error: {0}")] + Transport(#[from] crate::ipc::transport::TransportError), + #[error("plugin runner error: {0}")] + Runner(String), + #[error("spawn timeout: runner did not connect within {0:?}")] + SpawnTimeout(Duration), + #[error("runner exited before connecting")] + RunnerExited, + #[error("unexpected response: {0:?}")] + UnexpectedResponse(HostResponse), +} + +/// One spawned plugin process. +pub struct PluginProcess { + stream: Mutex, + child: Mutex, + id: String, + manifest: OwnedPluginManifest, + ribbon: Vec, +} + +impl PluginProcess { + /// Spawn the plugin cdylib in a separate process and connect to it. + pub fn spawn( + cdylib_path: &Path, + host: &mut dyn HostApi, + ) -> Result { + let socket_name = generate_socket_name(); + let socket_name_ref: interprocess::local_socket::Name = socket_name + .clone() + .to_ns_name::() + .expect("valid namespaced name"); + let runner_path = runner_executable()?; + eprintln!("[plugin] spawning runner {} for {}", runner_path.display(), cdylib_path.display()); + + // Create the listener before spawning so the runner can connect immediately. + let listener = ListenerOptions::new() + .name(socket_name_ref) + .create_sync()?; + + let mut child = Command::new(&runner_path) + .arg("--ocs-plugin-runner") + .arg(&socket_name) + .arg(cdylib_path) + .spawn()?; + + // Accept the runner connection with a timeout so a hung/crashed runner + // does not block the host indefinitely. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(listener.accept()); + }); + let stream = match rx.recv_timeout(spawn_timeout()) { + Ok(Ok(stream)) => { + eprintln!("[plugin] runner connected"); + Mutex::new(stream) + } + Ok(Err(e)) => return Err(e.into()), + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = child.kill(); + return Err(PluginError::SpawnTimeout(spawn_timeout())); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = child.kill(); + return Err(PluginError::RunnerExited); + } + }; + + // The runner first answers GetManifest and GetRibbon so the host can + // build the UI without keeping the plugin object alive. + let no_op = &mut |_| {}; + let manifest = match call(&stream, host, HostRequest::GetManifest, no_op)? { + HostResponse::Manifest(m) => m, + other => return Err(PluginError::UnexpectedResponse(other)), + }; + let ribbon = match call(&stream, host, HostRequest::GetRibbon, no_op)? { + HostResponse::Ribbon(r) => r, + other => return Err(PluginError::UnexpectedResponse(other)), + }; + + let id = manifest.id.clone(); + Ok(Self { + stream, + child: Mutex::new(child), + id, + manifest, + ribbon, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn manifest(&self) -> &OwnedPluginManifest { + &self.manifest + } + + pub fn ribbon(&self) -> &[OwnedRibbonGroupAlias] { + &self.ribbon + } + + pub fn dispatch( + &self, + host: &mut dyn HostApi, + cmd: &str, + on_start_interactive: &mut dyn FnMut(u64), + ) -> Result { + eprintln!("[plugin] dispatching {cmd}"); + let result = match call( + &self.stream, + host, + HostRequest::Dispatch { + cmd: cmd.to_string(), + }, + on_start_interactive, + )? { + HostResponse::Bool(b) => Ok(b), + other => Err(PluginError::UnexpectedResponse(other)), + }; + eprintln!("[plugin] dispatch {cmd} result: {result:?}"); + result + } + + /// Send an interactive event for `command_id` and return the step the + /// plugin command produces. Interactive events are not expected to trigger + /// nested host API calls, so this path does not supply a `HostApi`. + pub fn interactive_event( + &self, + command_id: u64, + event: InteractiveEvent, + ) -> Result { + send( + &mut self.stream.lock().unwrap(), + &HostToPlugin::Request(HostRequest::InteractiveEvent { command_id, event }), + )?; + loop { + match recv::(&mut self.stream.lock().unwrap())? { + PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(s), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(other)) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during interactive event: {req:?}" + )); + send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?; + } + } + } + } + + /// Ask the plugin process for the current prompt of an interactive command. + pub fn get_prompt(&self, command_id: u64) -> Result { + send( + &mut self.stream.lock().unwrap(), + &HostToPlugin::Request(HostRequest::GetPrompt { command_id }), + )?; + loop { + match recv::(&mut self.stream.lock().unwrap())? { + PluginToHost::Response(HostResponse::Text(s)) => return Ok(s), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(other)) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during get_prompt: {req:?}" + )); + send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?; + } + } + } + } + + /// Ask the plugin process whether an interactive command wants object picks. + pub fn needs_entity_pick(&self, command_id: u64) -> Result { + send( + &mut self.stream.lock().unwrap(), + &HostToPlugin::Request(HostRequest::NeedsEntityPick { command_id }), + )?; + loop { + match recv::(&mut self.stream.lock().unwrap())? { + PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(other)) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during needs_entity_pick: {req:?}" + )); + send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?; + } + } + } + } + + pub fn is_alive(&self) -> bool { + match self.child.lock().unwrap().try_wait() { + Ok(None) => true, + Ok(Some(_)) | Err(_) => false, + } + } + + pub fn kill(&self) -> std::io::Result<()> { + let _ = call_no_host(&self.stream, HostRequest::Shutdown); + self.child.lock().unwrap().kill() + } +} + +impl Drop for PluginProcess { + fn drop(&mut self) { + let _ = self.kill(); + } +} + +/// Send a host request and wait for the response, handling any nested plugin +/// requests inline using the supplied `HostApi`. +fn call( + stream: &Mutex, + host: &mut dyn HostApi, + req: HostRequest, + on_start_interactive: &mut dyn FnMut(u64), +) -> Result { + eprintln!("[plugin] host -> runner: {req:?}"); + send(&mut stream.lock().unwrap(), &HostToPlugin::Request(req))?; + loop { + let msg = recv::(&mut stream.lock().unwrap())?; + eprintln!("[plugin] runner -> host: {msg:?}"); + match msg { + PluginToHost::Response(resp) => return Ok(resp), + PluginToHost::Request(plugin_req) => { + let resp = handle_plugin_request(host, plugin_req, on_start_interactive); + eprintln!("[plugin] host -> runner response: {resp:?}"); + send(&mut stream.lock().unwrap(), &HostToPlugin::Response(resp))?; + } + } + } +} + +/// Best-effort shutdown request that does not wait for a response. +fn call_no_host( + stream: &Mutex, + req: HostRequest, +) -> Result<(), crate::ipc::transport::TransportError> { + send(&mut stream.lock().unwrap(), &HostToPlugin::Request(req)) +} + +/// Locate the executable to spawn for running a plugin. +/// +/// The host spawns *itself* in runner mode (`--ocs-plugin-runner`), so the +/// runner is always available and stays in sync with the host binary. This +/// avoids shipping a separate `ocs_plugin_runner` binary and works the same on +/// Windows, macOS, and Linux. +/// +/// For testing or unusual deployment layouts, set `OCS_PLUGIN_RUNNER_EXE` to +/// the host executable path. +fn runner_executable() -> Result { + if let Ok(path) = std::env::var("OCS_PLUGIN_RUNNER_EXE") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + } + let path = std::env::current_exe()?; + if path.exists() { + Ok(path) + } else { + Err(PluginError::Runner(format!( + "cannot find current executable at {}", + path.display() + ))) + } +} + +/// Generate a unique local socket name. +fn generate_socket_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("ocs_plugin_{}_{}", std::process::id(), n) +} diff --git a/crates/ocs_plugin_api/src/ribbon.rs b/crates/ocs_plugin_api/src/ribbon.rs index 7d01e2f5..fc58ccef 100644 --- a/crates/ocs_plugin_api/src/ribbon.rs +++ b/crates/ocs_plugin_api/src/ribbon.rs @@ -1,10 +1,14 @@ //! Ribbon description types — the plain-data vocabulary a [`CadModule`] uses to //! declare its tab. No UI-framework dependency: the host renders these. +#[cfg(feature = "host")] +pub mod owned; + // ── Events ──────────────────────────────────────────────────────────────── /// Events a module tool can emit to the host application. #[derive(Debug, Clone)] +#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] pub enum ModuleEvent { /// Fire a named CAD command (e.g. "LINE", "CIRCLE"). Command(String), @@ -105,6 +109,7 @@ pub enum RibbonItem { /// Identifies which style list a `StyleComboGroup` refers to. #[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] pub enum StyleKey { TextStyle, DimStyle, @@ -119,6 +124,7 @@ impl From for RibbonItem { } /// A named group of tool buttons shown together in the ribbon. +#[derive(Clone)] pub struct RibbonGroup { pub title: &'static str, pub tools: Vec, diff --git a/crates/ocs_plugin_api/src/ribbon/owned.rs b/crates/ocs_plugin_api/src/ribbon/owned.rs new file mode 100644 index 00000000..153fb557 --- /dev/null +++ b/crates/ocs_plugin_api/src/ribbon/owned.rs @@ -0,0 +1,297 @@ +//! Owned, serializable versions of the ribbon vocabulary for IPC. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnedToolDef { + pub id: String, + pub label: String, + pub icon: OwnedIconKind, + pub event: ModuleEvent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum OwnedIconKind { + Glyph(String), + Svg(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum OwnedRibbonItem { + Tool(OwnedToolDef), + LargeTool(OwnedToolDef), + Dropdown { + id: String, + icon: OwnedIconKind, + items: Vec<(String, String, OwnedIconKind)>, + default: String, + }, + LargeDropdown { + id: String, + label: String, + icon: OwnedIconKind, + items: Vec<(String, String, OwnedIconKind)>, + default: String, + }, + LayerComboGroup { + row2: Vec, + row3: Vec, + }, + PropertiesGroup { match_prop: OwnedToolDef }, + StyleComboGroup { + style_key: StyleKey, + combo_id: String, + manager_cmd: Option, + rows: Vec>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnedRibbonGroup { + pub title: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnedPluginManifest { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub api_version: u32, + pub ribbon_order: i32, + pub xdata_apps: Vec, + pub command_prefixes: Vec, +} + +impl From for OwnedIconKind { + fn from(i: IconKind) -> Self { + match i { + IconKind::Glyph(g) => OwnedIconKind::Glyph(g.to_string()), + IconKind::Svg(b) => OwnedIconKind::Svg(b.to_vec()), + } + } +} + +impl OwnedIconKind { + /// Leak the owned data to reconstruct an `IconKind` with `&'static` lifetime. + pub fn to_static(self) -> IconKind { + match self { + OwnedIconKind::Glyph(g) => IconKind::Glyph(&*Box::leak(g.into_boxed_str())), + OwnedIconKind::Svg(b) => IconKind::Svg(&*Box::leak(b.into_boxed_slice())), + } + } +} + +impl From for OwnedToolDef { + fn from(t: ToolDef) -> Self { + Self { + id: t.id.to_string(), + label: t.label.to_string(), + icon: t.icon.into(), + event: t.event, + } + } +} + +impl OwnedToolDef { + pub fn to_static(self) -> ToolDef { + ToolDef { + id: &*Box::leak(self.id.into_boxed_str()), + label: &*Box::leak(self.label.into_boxed_str()), + icon: self.icon.to_static(), + event: self.event, + } + } +} + +impl From for OwnedRibbonItem { + fn from(item: RibbonItem) -> Self { + match item { + RibbonItem::Tool(t) => OwnedRibbonItem::Tool(t.into()), + RibbonItem::LargeTool(t) => OwnedRibbonItem::LargeTool(t.into()), + RibbonItem::Dropdown { id, icon, items, default } => OwnedRibbonItem::Dropdown { + id: id.to_string(), + icon: icon.into(), + items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(), + default: default.to_string(), + }, + RibbonItem::LargeDropdown { id, label, icon, items, default } => OwnedRibbonItem::LargeDropdown { + id: id.to_string(), + label: label.to_string(), + icon: icon.into(), + items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(), + default: default.to_string(), + }, + RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup { + row2: row2.into_iter().map(Into::into).collect(), + row3: row3.into_iter().map(Into::into).collect(), + }, + RibbonItem::PropertiesGroup { match_prop } => OwnedRibbonItem::PropertiesGroup { + match_prop: match_prop.into(), + }, + RibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => OwnedRibbonItem::StyleComboGroup { + style_key, + combo_id: combo_id.to_string(), + manager_cmd: manager_cmd.map(|s| s.to_string()), + rows: rows.into_iter().map(|r| r.into_iter().map(Into::into).collect()).collect(), + }, + } + } +} + +impl OwnedRibbonItem { + pub fn to_static(self) -> RibbonItem { + match self { + OwnedRibbonItem::Tool(t) => RibbonItem::Tool(t.to_static()), + OwnedRibbonItem::LargeTool(t) => RibbonItem::LargeTool(t.to_static()), + OwnedRibbonItem::Dropdown { id, icon, items, default } => RibbonItem::Dropdown { + id: &*Box::leak(id.into_boxed_str()), + icon: icon.to_static(), + items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(), + default: &*Box::leak(default.into_boxed_str()), + }, + OwnedRibbonItem::LargeDropdown { id, label, icon, items, default } => RibbonItem::LargeDropdown { + id: &*Box::leak(id.into_boxed_str()), + label: &*Box::leak(label.into_boxed_str()), + icon: icon.to_static(), + items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(), + default: &*Box::leak(default.into_boxed_str()), + }, + OwnedRibbonItem::LayerComboGroup { row2, row3 } => RibbonItem::LayerComboGroup { + row2: row2.into_iter().map(|t| t.to_static()).collect(), + row3: row3.into_iter().map(|t| t.to_static()).collect(), + }, + OwnedRibbonItem::PropertiesGroup { match_prop } => RibbonItem::PropertiesGroup { + match_prop: match_prop.to_static(), + }, + OwnedRibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => RibbonItem::StyleComboGroup { + style_key, + combo_id: &*Box::leak(combo_id.into_boxed_str()), + manager_cmd: manager_cmd.map(|s| &*Box::leak(s.into_boxed_str())), + rows: rows.into_iter().map(|r| r.into_iter().map(|t| t.to_static()).collect()).collect(), + }, + } + } +} + +impl From for OwnedRibbonGroup { + fn from(g: RibbonGroup) -> Self { + Self { + title: g.title.to_string(), + tools: g.tools.into_iter().map(Into::into).collect(), + } + } +} + +impl OwnedRibbonGroup { + pub fn to_static(self) -> RibbonGroup { + RibbonGroup { + title: &*Box::leak(self.title.into_boxed_str()), + tools: self.tools.into_iter().map(|t| t.to_static()).collect(), + } + } +} + +/// Convert owned ribbon groups into a `CadModule` by leaking the strings once. +pub fn to_module( + id: String, + title: String, + groups: Vec, +) -> Box { + struct M { + id: &'static str, + title: &'static str, + groups: Vec, + } + impl CadModule for M { + fn id(&self) -> &'static str { + self.id + } + fn title(&self) -> &'static str { + self.title + } + fn ribbon_groups(&self) -> Vec { + self.groups.clone() + } + } + let id = &*Box::leak(id.into_boxed_str()); + let title = &*Box::leak(title.into_boxed_str()); + Box::new(M { + id, + title, + groups: groups.into_iter().map(OwnedRibbonGroup::to_static).collect(), + }) +} + +/// A cheaply-cloneable `CadModule` wrapper for plugin ribbon data. +#[derive(Clone)] +pub struct SharedCadModule(Arc); + +impl CadModule for SharedCadModule { + fn id(&self) -> &'static str { + self.0.id() + } + fn title(&self) -> &'static str { + self.0.title() + } + fn ribbon_groups(&self) -> Vec { + self.0.ribbon_groups() + } +} + +/// Convert owned ribbon groups into a shareable `CadModule`. +pub fn to_shared_module( + id: String, + title: String, + groups: Vec, +) -> SharedCadModule { + let module = to_module(id, title, groups); + SharedCadModule(Arc::from(module)) +} + +#[cfg(all(test, feature = "host"))] +mod tests { + use super::*; + + #[test] + fn owned_ribbon_group_round_trips_through_static() { + let owned = OwnedRibbonGroup { + title: "Geometry".to_string(), + tools: vec![OwnedRibbonItem::Tool(OwnedToolDef { + id: "line".to_string(), + label: "Line".to_string(), + icon: OwnedIconKind::Glyph("L".to_string()), + event: ModuleEvent::Command("LINE".to_string()), + })], + }; + let static_group = owned.clone().to_static(); + assert_eq!(static_group.title, "Geometry"); + assert_eq!(static_group.tools.len(), 1); + let back: OwnedRibbonGroup = static_group.into(); + assert_eq!(back.title, owned.title); + } + + #[test] + fn shared_module_clones_without_re_leaking() { + let owned = vec![OwnedRibbonGroup { + title: "Draw".to_string(), + tools: vec![OwnedRibbonItem::Tool(OwnedToolDef { + id: "circle".to_string(), + label: "Circle".to_string(), + icon: OwnedIconKind::Glyph("C".to_string()), + event: ModuleEvent::Command("CIRCLE".to_string()), + })], + }]; + let shared = to_shared_module("opencad.demo".to_string(), "Demo".to_string(), owned); + let cloned = shared.clone(); + assert_eq!(shared.title(), "Demo"); + assert_eq!(shared.id(), "opencad.demo"); + assert_eq!(cloned.title(), shared.title()); + assert_eq!(shared.ribbon_groups().len(), cloned.ribbon_groups().len()); + } +} diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs new file mode 100644 index 00000000..b67104e9 --- /dev/null +++ b/crates/ocs_plugin_api/src/runner.rs @@ -0,0 +1,176 @@ +//! Out-of-process plugin runner logic. +//! +//! This module is used by the host when it spawns itself in runner mode +//! (`--ocs-plugin-runner `). Keeping the runner code inside +//! `ocs_plugin_api` means the host only needs to know the CLI contract, not the +//! internal plugin-loading and IPC details. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +use crate::host::{BuiltinPlugin, InteractiveCommand}; +use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi}; +use crate::ipc::protocol::{HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost}; +use crate::ipc::transport::{recv, send}; +use crate::ribbon::owned::OwnedRibbonGroup; + +/// Entry point for the plugin runner child process. +/// +/// Connects back to the host on `socket_name`, loads the cdylib at +/// `cdylib_path`, and runs the request loop until the host sends `Shutdown`. +/// This function never returns normally; it exits the process on shutdown or +/// fatal error so the child does not fall through to the host's GUI main. +pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box> { + eprintln!("[runner] starting for {cdylib_path:?} on {socket_name}"); + let plugin = unsafe { load_plugin(cdylib_path)? }; + let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new())); + + let client = IpcClient::connect(socket_name)?; + eprintln!("[runner] connected to host"); + + loop { + let msg: HostToPlugin = recv(&mut client.stream_ref())?; + eprintln!("[runner] host -> runner: {msg:?}"); + match msg { + HostToPlugin::Request(req) => { + let resp = handle_host_request(&*plugin, &interactive, &client, req); + eprintln!("[runner] runner -> host: {resp:?}"); + send(&mut client.stream_ref(), &PluginToHost::Response(resp))?; + } + HostToPlugin::Response(_) => { + // Responses are consumed by PluginHostApi::request synchronously. + // Reaching here means the host sent a response without a pending + // plugin request. + eprintln!("[runner] unexpected HostToPlugin::Response"); + } + } + } +} + +fn handle_host_request( + plugin: &dyn BuiltinPlugin, + interactive: &InteractiveRegistry, + client: &IpcClient, + req: HostRequest, +) -> HostResponse { + match req { + HostRequest::GetManifest => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) { + Ok(m) => HostResponse::Manifest(m.into()), + Err(_) => HostResponse::Error("plugin manifest() panicked".to_string()), + } + } + HostRequest::GetRibbon => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.ribbon())) { + Ok(groups) => HostResponse::Ribbon( + groups + .ribbon_groups() + .into_iter() + .map(OwnedRibbonGroup::from) + .collect(), + ), + Err(_) => HostResponse::Error("plugin ribbon() panicked".to_string()), + } + } + HostRequest::Dispatch { cmd } => { + // The host supplies the active tab index as part of the dispatch + // context. We cache it inside PluginHostApi. + let mut proxy = PluginHostApi::new(client.clone(), 0, interactive.clone()); + let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.dispatch(&mut proxy, &cmd) + })); + match handled { + Ok(b) => HostResponse::Bool(b), + Err(_) => HostResponse::Error("plugin dispatch panicked".to_string()), + } + } + HostRequest::InteractiveEvent { + command_id, + event, + } => { + let step = { + let mut registry = interactive.borrow_mut(); + let Some(cmd) = registry.get_mut(&command_id) else { + return HostResponse::Error(format!("unknown interactive command {command_id}")); + }; + let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { + InteractiveEvent::Point(pt) => cmd_ref.on_point(pt), + InteractiveEvent::Enter => cmd_ref.on_enter(), + InteractiveEvent::ObjectPick { handle, pt } => { + cmd_ref.on_object_pick(handle, pt) + } + })) + }; + match step { + Ok(s) => HostResponse::CommandStep(s), + Err(_) => HostResponse::Error("interactive command panicked".to_string()), + } + } + HostRequest::GetPrompt { command_id } => { + let result = { + let registry = interactive.borrow(); + registry.get(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.prompt())) + }) + }; + match result { + Some(Ok(s)) => HostResponse::Text(s), + Some(Err(_)) => HostResponse::Error("prompt() panicked".to_string()), + None => HostResponse::Error(format!("unknown interactive command {command_id}")), + } + } + HostRequest::NeedsEntityPick { command_id } => { + let result = { + let registry = interactive.borrow(); + registry.get(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.needs_object_pick())) + }) + }; + match result { + Some(Ok(b)) => HostResponse::Bool(b), + Some(Err(_)) => HostResponse::Error("needs_object_pick() panicked".to_string()), + None => HostResponse::Error(format!("unknown interactive command {command_id}")), + } + } + HostRequest::Shutdown => { + // The runner will exit after this response is sent. + std::process::exit(0); + } + } +} + +unsafe fn load_plugin( + path: &Path, +) -> Result, Box> { + let lib = libloading::Library::new(path)?; + + let version: libloading::Symbol u32> = lib + .get(b"ocs_plugin_api_version") + .map_err(|_| "missing ocs_plugin_api_version symbol")?; + let v = version(); + if v != crate::API_VERSION { + return Err(format!( + "API version {v} != host {}", + crate::API_VERSION + ) + .into()); + } + + let register: libloading::Symbol *mut Box> = lib + .get(b"ocs_plugin_register") + .map_err(|_| "missing ocs_plugin_register symbol")?; + let raw = register(); + if raw.is_null() { + return Err("ocs_plugin_register returned null".into()); + } + let plugin = *Box::from_raw(raw); + + // Intentionally leak the library so its vtables remain valid for the + // lifetime of the process. The runner exits when the host disconnects. + let _ = std::mem::ManuallyDrop::new(lib); + + Ok(plugin) +} diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index ea02f5bc..d4e3d692 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -30,8 +30,9 @@ engine crate, and user-installable packages from a curated index. ## Non-goals -- Sandboxing or signature verification (installing a plugin runs native code; the - user trusts the repos they install from). +- Signature verification (installing a plugin runs native code; the user trusts + the repos they install from). Process isolation limits the blast radius of a + buggy or malicious plugin, but it is not a security sandbox. - Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi). - Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model. @@ -44,12 +45,12 @@ engine crate, and user-installable packages from a curated index. │ Layer A — Host (OpenCADStudio) │ │ iced UI · Scene · Document · Undo · Command line │ │ Core ribbon tabs: Home, Model, View, … (NOT plugins) │ -│ Generic plugin runtime: discovery, libloading, dispatch │ +│ Generic plugin runtime: discovery, spawn, dispatch │ └───────────────────────────────┬────────────────────────────────────┘ - │ &mut dyn HostApi (ocs_plugin_api) + │ IPC over local socket (ocs_plugin_api) ┌───────────────────────────────▼────────────────────────────────────┐ -│ Layer B — Plugin package (external repo, cdylib) │ -│ Cargo.toml · plugin.toml · src/lib.rs │ +│ Layer B — Plugin process (external repo, cdylib) │ +│ host spawns itself in runner mode · Cargo.toml · plugin.toml │ │ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │ └───────────────────────────────┬────────────────────────────────────┘ │ pure Rust API @@ -61,8 +62,8 @@ engine crate, and user-installable packages from a curated index. | Layer | Lives in | May depend on | |-------|----------|---------------| -| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` | everything | -| **B — Plugin** | a separate repo (cdylib) | `ocs_plugin_api` + optional engine | +| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` runtime | everything | +| **B — Plugin** | a separate repo (cdylib), spawned by the host in runner mode | `ocs_plugin_api` + optional engine | | **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) | **Hard rules** @@ -83,7 +84,8 @@ plugin compiles against. Two tiers: `IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this cheaply. - **`host` feature** (pulls `acadrust`): the runtime surface — the `HostApi` - trait, the `BuiltinPlugin` entry-point trait, and the `export_plugin!` macro. + trait, the `BuiltinPlugin` entry-point trait, the `export_plugin!` macro, and + the out-of-process plugin runtime (`PluginProcess`, `runner`). A plugin enables the `host` feature. @@ -119,9 +121,9 @@ concrete types: | Category | Methods | |----------|---------| -| Document | `document()`, `document_mut()`, `add_entity()`, `bump_geometry()` | +| Document | `document()` / `document_mut()` return a **local cached copy** of the document (API v3); the first access in a dispatch clones the full `CadDocument` over IPC. Use `add_entity()` / `write_record()` for host-visible mutations. `add_entity()`, `bump_geometry()` | | XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so data round-trips through DWG/DXF | -| Tab state | object-safe `plugin_state_any*`; use the `ocs_plugin_api::host::plugin_state` / `plugin_state_mut` / `ensure_plugin_state` helpers (keyed by `manifest.id`) | +| Tab state | object-safe `plugin_state_any*` helpers exist for in-process use; out-of-process plugins should keep state inside the plugin crate because `dyn Any` is not serializable | | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | @@ -203,7 +205,7 @@ version = "0.1.0" description = "…" [opencad] -api_version = 2 +api_version = 3 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] @@ -292,17 +294,29 @@ On startup the host scans `/OpenCADStudio/plugins//` for a libocs_example_plugin.so # any name with the platform extension ``` -For each compatible package it `dlopen`s the library (`libloading`), calls -`ocs_plugin_api_version` and refuses on mismatch, then `ocs_plugin_register` to -obtain the boxed `BuiltinPlugin`. Loaded libraries stay **resident for the -session** (ribbon tabs and dispatch hold their vtables, so they are never -reloaded mid-session). External plugins merge into the same ribbon and +For each compatible package the host spawns **itself** in runner mode +(`--ocs-plugin-runner `). The child process loads the `cdylib` +in its own address space and connects back to the host over an `interprocess` +local socket. The runner checks `ocs_plugin_api_version` and refuses on +mismatch, then calls `ocs_plugin_register` to obtain the boxed `BuiltinPlugin`. +Each plugin runs in a separate OS process, so a plugin crash or memory +corruption cannot affect the host or other plugins. Plugin processes stay +**resident for the session**; external plugins merge into the same ribbon and `try_dispatch` path the host uses and honour the enable/disable set (`disabled_plugins` in `settings.txt`). `` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or `$XDG_CONFIG_HOME` / `~/.config` (Linux). +## Failure handling + +| Failure | Behavior | +|---|---| +| Plugin panics | Caught inside the plugin runner child; an error response is returned to the host and stays alive. | +| Plugin crash / hang / malformed message | The host detects a dead process via `try_wait` on the next dispatch or ribbon rebuild; the tab is dropped and an error is logged. | +| Spawn failure | Reported per-plugin during startup and surfaced in the Plugin Manager / command line. | +| Oversized message | The length-framed transport rejects messages larger than 64 MiB. | + --- ## Marketplace @@ -331,12 +345,13 @@ installs plugins from GitHub Releases: ## Compatibility & ABI -Loading uses **approach B**: the plugin returns a boxed `BuiltinPlugin` across the -`cdylib` boundary. This is sound only when the plugin was built with the **same -Rust toolchain and `ocs_plugin_api` version** as the host. The -`ocs_plugin_api_version` symbol gates the API version; it does **not** detect a -toolchain mismatch. In practice CI built with current stable Rust matches a host -built the same way. +Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawns +this child from its own executable (`--ocs-plugin-runner` mode), so the runner +and host always share the same `ocs_plugin_api` build. The runner checks +`ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its +own OS process, so the host is protected from plugin crashes and memory +corruption. Process isolation removes the need for the host and plugin to share +a Rust toolchain ABI beyond the stable `ocs_plugin_api` contract. A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built by any toolchain interoperate — required before trusting prebuilt binaries from @@ -350,15 +365,18 @@ Done: - [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature (`HostApi` / `BuiltinPlugin` / `export_plugin!`). -- [x] Runtime discovery + `libloading` loading with an `api_version` gate. +- [x] Runtime discovery + out-of-process loading (host spawns itself in runner + mode) with an `api_version` gate and `interprocess` local-socket IPC. - [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state. - [x] Marketplace — curated registry + manual repo link, install / upgrade / reinstall / uninstall, enable/disable. +- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick). Next: +- [ ] Incremental document snapshots instead of cloning `CadDocument` over IPC. - [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries. -- [ ] Trust: checksums / signatures before `dlopen`. +- [ ] Trust: checksums / signatures before spawning plugin processes. - [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks. - [ ] External automation API (drive OCS headless from a process) — issue #29. @@ -368,8 +386,11 @@ Next: | Piece | Location | |-------|----------| -| Contract crate | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | -| Plugin runtime (host) | `src/plugin/`, `src/app/plugin_host.rs` | +| Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | +| Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | +| Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | +| Core module registry generator | `build.rs` (writes to `OUT_DIR`, included by `src/modules/registry.rs`) | | Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) | | Template scaffold | [`docs/plugin-template/`](plugin-template) | | Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) | diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index 904c73c6..6153aeaa 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -7,7 +7,7 @@ author = "Your Name" license = "GPL-3.0-only" [opencad] -api_version = 2 +api_version = 3 ribbon_order = 60 command_prefixes = ["MP_"] xdata_apps = ["MYPLUGIN_RECORD"] \ No newline at end of file diff --git a/src/app/automation.rs b/src/app/automation.rs index 7fd4b061..585c3ba3 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -560,7 +560,7 @@ mod tests { fn save_then_open_round_trips() { let mut app = OpenCADStudio::new_for_test(); let path = std::env::temp_dir().join("ocs_automation_test.dxf"); - let p = path.to_string_lossy(); + let p = path.to_string_lossy().replace('\\', "\\\\"); app.automation_op(r#"{"op":"new"}"#); assert_eq!( app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], diff --git a/src/app/mod.rs b/src/app/mod.rs index 8bbcc1ce..8c6f065e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1980,7 +1980,7 @@ impl OpenCADStudio { // their ribbon tabs into the ribbon. Skipped under test/wasm. #[cfg(all(not(target_arch = "wasm32"), not(test)))] { - for (id, res) in crate::plugin::external::load_at_startup() { + for (id, res) in crate::plugin::external::load_at_startup(&mut app) { if let Err(e) = res { app.command_line .push_error(&format!("Plugin '{id}' failed to load: {e}")); @@ -2000,6 +2000,22 @@ impl OpenCADStudio { Self::new() } + /// Install `cmd` as the active interactive command for tab `tab`. + pub(crate) fn set_active_command( + &mut self, + tab: usize, + cmd: Box, + ) { + if let Some(t) = self.tabs.get_mut(tab) { + t.active_cmd = Some(cmd); + } + } + + /// Push an error message from the plugin runtime to the command line. + pub(crate) fn push_plugin_error(&mut self, msg: &str) { + self.command_line.push_error(msg); + } + #[cfg(test)] pub(crate) fn command_history_info(&self) -> Vec { use crate::ui::command_line::EntryKind; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0cddd747..7dfca010 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -260,6 +260,89 @@ impl crate::command::CadCommand for PluginInteractiveAdapter { } } +/// Bridges an out-of-process plugin's interactive command to the host's +/// `CadCommand`. Events are sent over IPC and the returned `CommandStep` is +/// translated into a `CmdResult`. Prompt and object-pick mode are cached and +/// refreshed after each event. +pub(crate) struct PluginProcessInteractiveAdapter { + pub process: std::sync::Arc, + pub command_id: u64, + prompt: Option, + needs_entity_pick: Option, +} + +impl PluginProcessInteractiveAdapter { + pub(crate) fn new( + process: std::sync::Arc, + command_id: u64, + ) -> Self { + let prompt = process.get_prompt(command_id).ok(); + let needs_entity_pick = process.needs_entity_pick(command_id).ok(); + Self { + process, + command_id, + prompt, + needs_entity_pick, + } + } + + fn refresh(&mut self) { + self.prompt = self.process.get_prompt(self.command_id).ok(); + self.needs_entity_pick = self.process.needs_entity_pick(self.command_id).ok(); + } +} + +impl crate::command::CadCommand for PluginProcessInteractiveAdapter { + fn name(&self) -> &'static str { + "PLUGIN" + } + fn prompt(&self) -> String { + self.prompt.clone().unwrap_or_default() + } + fn on_point(&mut self, pt: glam::Vec3) -> crate::command::CmdResult { + use ocs_plugin_api::ipc::protocol::InteractiveEvent; + let result = self + .process + .interactive_event( + self.command_id, + InteractiveEvent::Point([pt.x as f64, pt.y as f64, pt.z as f64]), + ) + .map(plugin_step_to_result) + .unwrap_or(crate::command::CmdResult::Cancel); + self.refresh(); + result + } + fn on_enter(&mut self) -> crate::command::CmdResult { + use ocs_plugin_api::ipc::protocol::InteractiveEvent; + let result = self + .process + .interactive_event(self.command_id, InteractiveEvent::Enter) + .map(plugin_step_to_result) + .unwrap_or(crate::command::CmdResult::Cancel); + self.refresh(); + result + } + fn needs_entity_pick(&self) -> bool { + self.needs_entity_pick.unwrap_or(false) + } + fn on_entity_pick(&mut self, handle: Handle, pt: glam::Vec3) -> crate::command::CmdResult { + use ocs_plugin_api::ipc::protocol::InteractiveEvent; + let result = self + .process + .interactive_event( + self.command_id, + InteractiveEvent::ObjectPick { + handle, + pt: [pt.x as f64, pt.y as f64, pt.z as f64], + }, + ) + .map(plugin_step_to_result) + .unwrap_or(crate::command::CmdResult::Cancel); + self.refresh(); + result + } +} + fn plugin_step_to_result( step: ocs_plugin_api::host::CommandStep, ) -> crate::command::CmdResult { diff --git a/src/cli.rs b/src/cli.rs index fb25e6e5..211fef23 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -63,6 +63,10 @@ pub struct Cli { /// Log level (error|warn|info|debug|trace). Also honours RUST_LOG. #[arg(long, value_name = "LEVEL")] pub log: Option, + + /// Internal: run as the plugin runner child process. + #[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)] + pub ocs_plugin_runner: Option>, } /// GUI startup configuration, handed from `main` to `app::boot` out-of-band diff --git a/src/main.rs b/src/main.rs index b77f6d18..178e6336 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,23 @@ fn main() -> iced::Result { use clap::Parser; let args = cli::Cli::parse(); + // Plugin runner mode: the host spawns itself with this hidden flag to + // load a plugin cdylib in an isolated process. Hand off immediately so + // the child never touches GUI state. + if let Some(runner_args) = &args.ocs_plugin_runner { + if runner_args.len() != 2 { + eprintln!("--ocs-plugin-runner expects "); + std::process::exit(1); + } + let socket = &runner_args[0]; + let cdylib = std::path::Path::new(&runner_args[1]); + if let Err(e) = ocs_plugin_api::runner::run(socket, cdylib) { + eprintln!("[runner] fatal: {e}"); + std::process::exit(1); + } + return Ok(()); + } + // Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then // surfaces wgpu / iced / winit diagnostics that are otherwise silent. if let Some(level) = &args.log { diff --git a/src/modules/registry.rs b/src/modules/registry.rs index 7b31e6c7..42e2421a 100644 --- a/src/modules/registry.rs +++ b/src/modules/registry.rs @@ -1,18 +1,4 @@ -// AUTO-GENERATED by build.rs — do not edit by hand. -// To add a module: create src/modules/my_name/mod.rs + add `pub mod my_name;` to modules/mod.rs. - -use crate::modules::CadModule; - -/// Returns one boxed instance of every registered CAD module. -/// Called once at startup by `Ribbon::new()`. -pub fn all_modules() -> Vec> { - vec![ - Box::new(super::draw::DrawModule), - Box::new(super::model::ModelModule), - Box::new(super::insert::InsertModule), - Box::new(super::annotate::AnnotateModule), - Box::new(super::view::ViewModule), - Box::new(super::manage::ManageModule), - Box::new(super::layout::LayoutModule), - ] -} +// AUTO-GENERATED include — see build.rs for the actual generator. +// The generated registry is written to Cargo's OUT_DIR and included here so +// the source tree is not modified during build. +include!(concat!(env!("OUT_DIR"), "/modules_registry.rs")); diff --git a/src/plugin/external.rs b/src/plugin/external.rs index f3417fac..da087487 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -54,7 +54,11 @@ impl ExternalPlugin { } /// `/OpenCADStudio/plugins`, matching the settings/recent-files store. +/// Overridable via `OCS_PLUGINS_DIR` for tests. pub fn plugins_dir() -> Option { + if let Ok(p) = std::env::var("OCS_PLUGINS_DIR") { + return Some(PathBuf::from(p)); + } let base: PathBuf = if cfg!(target_os = "windows") { std::env::var_os("APPDATA").map(PathBuf::from)? } else if cfg!(target_os = "macos") { @@ -217,42 +221,41 @@ fn parse_string_array(s: &str) -> Vec { // ── Runtime loading (desktop only) ────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -pub use loader::{load_at_startup, loaded_ids, with_loaded}; +pub(crate) use loader::with_loaded; + +#[cfg(all(not(target_arch = "wasm32"), not(test)))] +pub(crate) use loader::{load_at_startup, loaded_ids}; + +#[cfg(all(not(target_arch = "wasm32"), test))] +pub(crate) use loader::{load_at_startup, loaded_ids}; #[cfg(not(target_arch = "wasm32"))] +#[cfg_attr(test, allow(dead_code))] mod loader { use super::{lib_extension, ExternalPlugin}; - use ocs_plugin_api::host::BuiltinPlugin; use std::path::{Path, PathBuf}; + use std::sync::Arc; - /// A loaded external plugin. The library must outlive the boxed plugin, so - /// `plugin` is declared before `_lib` (fields drop in declaration order). + /// A loaded external plugin. Holds the spawned process and a shareable + /// ribbon module built from the process's cached ribbon data. pub struct LoadedPlugin { - plugin: Box, - _lib: libloading::Library, + pub process: Arc, + pub module: ocs_plugin_api::ribbon::owned::SharedCadModule, pub id: String, } - impl LoadedPlugin { - pub fn plugin(&self) -> &dyn BuiltinPlugin { - self.plugin.as_ref() - } - } - use std::cell::RefCell; - // Process-wide store of loaded external plugins. The libraries must stay - // resident for the whole session — ribbon tabs and command dispatch hold - // vtables that live inside them — so this is filled once at startup and - // never cleared mid-session (reloading would dangle live ribbon modules). + // Process-wide store of spawned external plugins. The runner processes stay + // alive for the whole session; this is filled once at startup. thread_local! { static LOADED: RefCell> = const { RefCell::new(Vec::new()) }; } - /// Discover packages and load every API-compatible one with a native - /// library into the process store. Call once at startup. Returns per-id - /// results so the host can report load failures. - pub fn load_at_startup() -> Vec<(String, Result<(), String>)> { + /// Discover packages and spawn every API-compatible one as a separate + /// process. Call once at startup. Returns per-id results so the host can + /// report load failures. + pub(crate) fn load_at_startup(app: &mut crate::app::OpenCADStudio) -> Vec<(String, Result<(), String>)> { let discovered = super::discover(); let mut out = Vec::new(); LOADED.with(|cell| { @@ -264,7 +267,7 @@ mod loader { if !d.api_compatible() || !d.lib_present { continue; } - match load(d) { + match load(d, app) { Ok(lp) => { out.push((lp.id.clone(), Ok(()))); store.push(lp); @@ -295,55 +298,29 @@ mod loader { }) } - /// Load a discovered package's `cdylib`, gating on the API version before - /// any of its code runs. Approach B (see `docs/plugin-architecture.md`): - /// the plugin hands back a boxed `BuiltinPlugin`; this assumes the package - /// was built against the same toolchain and `ocs_plugin_api` version, which - /// the version symbol enforces. - /// - /// # Safety - /// Calls `dlopen`/`dlsym` on an arbitrary file and trusts its exported - /// symbols' signatures. Only invoke on packages the user installed. - pub fn load(p: &ExternalPlugin) -> Result { + /// Spawn a discovered package's `cdylib` in a separate process and cache + /// its ribbon module. The runner performs the API version gate before any + /// plugin code runs. + pub fn load( + p: &ExternalPlugin, + app: &mut crate::app::OpenCADStudio, + ) -> Result { let path = lib_file(&p.dir).ok_or("no native library in package")?; - unsafe { - let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?; - - let version: libloading::Symbol u32> = lib - .get(b"ocs_plugin_api_version") - .map_err(|_| "missing ocs_plugin_api_version symbol".to_string())?; - // Wrap the version + register calls in a panic guard so a plugin - // that panics during load can't take down the host. (#145) - let v = crate::plugin::guard("version", || version()) - .ok_or_else(|| "ocs_plugin_api_version panicked".to_string())?; - if v != ocs_plugin_api::API_VERSION { - return Err(format!( - "API version {v} != host {}", - ocs_plugin_api::API_VERSION - )); - } - - let register: libloading::Symbol< - extern "C" fn() -> *mut Box, - > = lib - .get(b"ocs_plugin_register") - .map_err(|_| "missing ocs_plugin_register symbol".to_string())?; - let raw = crate::plugin::guard("register", || register()) - .ok_or_else(|| "ocs_plugin_register panicked".to_string())?; - if raw.is_null() { - return Err("ocs_plugin_register returned null".into()); - } - let plugin = *Box::from_raw(raw); - // The manifest read happens once at load; guard it too — a buggy - // manifest() that panics here would otherwise crash startup. (#145) - let id = crate::plugin::guard("manifest", || plugin.manifest().id.to_string()) - .ok_or_else(|| "plugin manifest() panicked".to_string())?; - Ok(LoadedPlugin { - plugin, - _lib: lib, - id, - }) - } + let mut host = crate::app::plugin_host::HostSession::new(app, 0); + let process = + ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host).map_err(|e| e.to_string())?; + let id = process.id().to_string(); + let name = process.manifest().name.clone(); + let module = ocs_plugin_api::ribbon::owned::to_shared_module( + id.clone(), + name, + process.ribbon().to_vec(), + ); + Ok(LoadedPlugin { + process: Arc::new(process), + module, + id, + }) } } @@ -372,7 +349,7 @@ command_prefixes = ["SS_", "STORM_"] 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!(!p.api_compatible()); } #[test] @@ -386,4 +363,88 @@ command_prefixes = ["SS_", "STORM_"] assert!(!p.api_compatible()); assert!(!p.loadable()); } + + /// Integration smoke test for the out-of-process plugin path. + /// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the + /// `OpenCADStudio` binary is built; the test uses it as the runner host. + #[test] + fn spawn_and_dispatch_test_plugin() { + let path = match std::env::var_os("OCS_TEST_PLUGIN") { + Some(p) => std::path::PathBuf::from(p), + None => return, + }; + if !path.exists() { + eprintln!("OCS_TEST_PLUGIN does not exist: {}", path.display()); + return; + } + let host_exe = std::path::PathBuf::from( + 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()); + std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe); + + let mut app = crate::app::OpenCADStudio::new_for_test(); + let mut host = crate::app::plugin_host::HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host) + .expect("spawn test plugin"); + assert_eq!(process.id(), "opencad.my_plugin"); + let mut started = false; + let handled = process + .dispatch(&mut host, "MP_HELLO", &mut |_id| { + started = true; + }) + .expect("dispatch MP_HELLO"); + assert!(handled, "plugin should handle MP_HELLO"); + assert!(!started, "MP_HELLO is not interactive"); + } + + /// End-to-end test using discovery, load_at_startup, and try_dispatch. + #[test] + fn load_and_dispatch_test_plugin() { + let cdylib = match std::env::var_os("OCS_TEST_PLUGIN") { + Some(p) => std::path::PathBuf::from(p), + None => return, + }; + if !cdylib.exists() { + eprintln!("OCS_TEST_PLUGIN does not exist: {}", cdylib.display()); + return; + } + let host_exe = std::path::PathBuf::from( + 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()); + std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe); + + // Build a fake plugin package in a temp dir. + let tmp = std::env::temp_dir().join("ocs_test_plugin_package"); + let _ = std::fs::remove_dir_all(&tmp); + let pkg = tmp.join("opencad.my_plugin"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::copy(&cdylib, pkg.join(cdylib.file_name().unwrap())).unwrap(); + std::fs::copy( + std::path::Path::new(&cdylib).parent().unwrap().parent().unwrap().parent().unwrap().join("plugin.toml"), + pkg.join("plugin.toml"), + ) + .unwrap_or_else(|_| { + // Fallback: use the template plugin.toml. + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/plugin-template/plugin.toml"), + pkg.join("plugin.toml"), + ) + .unwrap() + }); + std::env::set_var("OCS_PLUGINS_DIR", &tmp); + + let mut app = crate::app::OpenCADStudio::new_for_test(); + let results = super::external::load_at_startup(&mut app); + assert!( + results.iter().any(|(id, r)| id == "opencad.my_plugin" && r.is_ok()), + "test plugin should load: {results:?}" + ); + + let handled = super::try_dispatch(&mut app, 0, "MP_HELLO"); + assert!(handled, "try_dispatch should handle MP_HELLO"); + } } diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 4a7e74fc..6372c5f0 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -24,16 +24,13 @@ pub fn ribbon_modules_enabled( let mut addons: Vec<(i32, Box)> = Vec::new(); crate::plugin::external::with_loaded(|loaded| { for lp in loaded { - if disabled.contains(lp.id.as_str()) { + if disabled.contains(lp.id.as_str()) || !lp.process.is_alive() { continue; } - // Guard the plugin's ribbon build so a panic there can't take - // down the host — the plugin just contributes no tab. (#145) - if let Some(entry) = crate::plugin::guard("ribbon", || { - (lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()) - }) { - addons.push(entry); - } + addons.push(( + lp.process.manifest().ribbon_order, + Box::new(lp.module.clone()) as Box, + )); } }); addons.sort_by_key(|(order, _)| *order); @@ -50,22 +47,52 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo { use super::host::HostSession; let disabled = app.disabled_plugin_ids(); + let mut started: Option<(u64, std::sync::Arc)> = None; + let mut dead_plugins: Vec = Vec::new(); + let mut dispatch_errors: Vec<(String, String)> = Vec::new(); let handled = crate::plugin::external::with_loaded(|loaded| { let mut host = HostSession::new(app, tab); for lp in loaded { if disabled.contains(lp.id.as_str()) { continue; } - // A panic inside the plugin's dispatch must not crash the host; - // treat a panicking plugin as "didn't handle it". (#145) - if crate::plugin::guard("dispatch", || lp.plugin().dispatch(&mut host, cmd)) - .unwrap_or(false) - { - return true; + if !lp.process.is_alive() { + dead_plugins.push(lp.id.clone()); + continue; + } + let process = std::sync::Arc::clone(&lp.process); + let mut start = |command_id: u64| { + started = Some((command_id, std::sync::Arc::clone(&process))); + }; + match crate::plugin::guard("dispatch", || lp.process.dispatch(&mut host, cmd, &mut start)) { + Some(Ok(true)) => return true, + Some(Ok(false)) => {} + Some(Err(e)) => { + eprintln!("[plugin] dispatch error for '{}': {e}", lp.id); + dispatch_errors.push((lp.id.clone(), e.to_string())); + } + None => { + // Panic already logged by guard. + } } } false }); + for id in dead_plugins { + app.push_plugin_error(&format!("Plugin '{id}' process died; skipping dispatch")); + } + for (id, err) in dispatch_errors { + app.push_plugin_error(&format!("Plugin '{id}' dispatch error: {err}")); + } + if let Some((command_id, process)) = started { + app.set_active_command( + tab, + Box::new(crate::app::plugin_host::PluginProcessInteractiveAdapter::new( + process, + command_id, + )), + ); + } if handled { return true; }