Initial commit

This commit is contained in:
Sebastian 2026-06-23 22:54:33 +02:00 committed by Hakan Seven
commit dcfaed0182
26 changed files with 2080 additions and 143 deletions

View file

@ -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"]

View file

@ -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 <socket> <cdylib>`) 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 <socket_name> <cdylib_path>`:
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.

View file

@ -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,

View file

@ -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<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
/// Plugin-side connection to the host.
#[derive(Clone)]
pub struct IpcClient {
stream: Rc<RefCell<Stream>>,
}
impl IpcClient {
pub fn connect(name: &str) -> std::io::Result<Self> {
let name = name
.to_ns_name::<GenericNamespaced>()
.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<PluginResponse, crate::ipc::transport::TransportError> {
send(&mut self.stream.borrow_mut(), &PluginToHost::Request(req))?;
loop {
match recv::<HostToPlugin>(&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<CadDocument>,
interactive: InteractiveRegistry,
next_command_id: Cell<u64>,
/// 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<HashMap<(Handle, String), &'static ExtendedDataRecord>>,
}
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<dyn InteractiveCommand>) {
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<dyn Any + Send + Sync>,
) -> &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::<GenericNamespaced>()
.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::<GenericNamespaced>().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::<PluginToHost>(&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::<PluginToHost>(&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));
}
}

View file

@ -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::<GenericNamespaced>()
.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::<GenericNamespaced>().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::<HostToPlugin>(&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::<PluginToHost>(&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<u8> 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:?}"),
}
}
}

View file

@ -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<OwnedRibbonGroup>),
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<ExtendedDataRecord>),
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,
}
}
}

View file

@ -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()),
}
}

View file

@ -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<T: Serialize>(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<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportError> {
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)?)
}

View file

@ -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};

View file

@ -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 {

View file

@ -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<Stream>,
child: Mutex<Child>,
id: String,
manifest: OwnedPluginManifest,
ribbon: Vec<OwnedRibbonGroupAlias>,
}
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<Self, PluginError> {
let socket_name = generate_socket_name();
let socket_name_ref: interprocess::local_socket::Name = socket_name
.clone()
.to_ns_name::<GenericNamespaced>()
.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<bool, PluginError> {
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<CommandStep, PluginError> {
send(
&mut self.stream.lock().unwrap(),
&HostToPlugin::Request(HostRequest::InteractiveEvent { command_id, event }),
)?;
loop {
match recv::<PluginToHost>(&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<String, PluginError> {
send(
&mut self.stream.lock().unwrap(),
&HostToPlugin::Request(HostRequest::GetPrompt { command_id }),
)?;
loop {
match recv::<PluginToHost>(&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<bool, PluginError> {
send(
&mut self.stream.lock().unwrap(),
&HostToPlugin::Request(HostRequest::NeedsEntityPick { command_id }),
)?;
loop {
match recv::<PluginToHost>(&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<Stream>,
host: &mut dyn HostApi,
req: HostRequest,
on_start_interactive: &mut dyn FnMut(u64),
) -> Result<HostResponse, PluginError> {
eprintln!("[plugin] host -> runner: {req:?}");
send(&mut stream.lock().unwrap(), &HostToPlugin::Request(req))?;
loop {
let msg = recv::<PluginToHost>(&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<Stream>,
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<PathBuf, PluginError> {
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)
}

View file

@ -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<ToolDef> 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<RibbonItem>,

View file

@ -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<u8>),
}
#[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<OwnedToolDef>,
row3: Vec<OwnedToolDef>,
},
PropertiesGroup { match_prop: OwnedToolDef },
StyleComboGroup {
style_key: StyleKey,
combo_id: String,
manager_cmd: Option<String>,
rows: Vec<Vec<OwnedToolDef>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnedRibbonGroup {
pub title: String,
pub tools: Vec<OwnedRibbonItem>,
}
#[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<String>,
pub command_prefixes: Vec<String>,
}
impl From<IconKind> 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<ToolDef> 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<RibbonItem> 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<RibbonGroup> 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<OwnedRibbonGroup>,
) -> Box<dyn CadModule> {
struct M {
id: &'static str,
title: &'static str,
groups: Vec<RibbonGroup>,
}
impl CadModule for M {
fn id(&self) -> &'static str {
self.id
}
fn title(&self) -> &'static str {
self.title
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
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<dyn CadModule>);
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<RibbonGroup> {
self.0.ribbon_groups()
}
}
/// Convert owned ribbon groups into a shareable `CadModule`.
pub fn to_shared_module(
id: String,
title: String,
groups: Vec<OwnedRibbonGroup>,
) -> 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());
}
}

View file

@ -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 <socket> <cdylib>`). 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<dyn std::error::Error>> {
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<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
let lib = libloading::Library::new(path)?;
let version: libloading::Symbol<extern "C" fn() -> 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<extern "C" fn() -> *mut Box<dyn BuiltinPlugin>> = 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)
}