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

@ -83,8 +83,6 @@ acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
rayon = "1" rayon = "1"
open = "5" open = "5"
ureq = { version = "3", default-features = false, features = ["rustls"] } 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. # Parse the GitHub Releases API response for the plugin marketplace.
serde_json = "1" serde_json = "1"

View file

@ -101,9 +101,10 @@ fn main() {
out.push_str("\t]\n}\n"); 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(); let current = fs::read_to_string(&out_path).unwrap_or_default();
if current != out { 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");
} }
} }

View file

@ -9,9 +9,17 @@ license = "GPL-3.0-only"
# Pulled in only by the `host` feature, which adds the `acadrust`-typed # Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine # `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply. # 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] [features]
# Enables the runtime host surface (`HostApi` trait). The OpenCADStudio binary # Enables the runtime host surface (`HostApi` trait) and the out-of-process
# turns this on; pure-data consumers leave it off. # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
host = ["dep:acadrust"] # 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. /// The outcome of an [`InteractiveCommand`] step.
#[derive(Debug)]
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum CommandStep { pub enum CommandStep {
/// Need another point; keep the command active. /// Need another point; keep the command active.
NeedPoint, 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")] #[cfg(feature = "host")]
pub mod 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 manifest::{ApiVersion, PluginManifest, API_VERSION};
pub use ribbon::{ pub use ribbon::{
CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef, CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef,
}; };
#[cfg(feature = "host")]
pub use process::{PluginError, PluginProcess};

View file

@ -1,9 +1,10 @@
//! Plugin identity and capability declaration. //! Plugin identity and capability declaration.
/// Host plugin API version. Bump when the host runtime surface breaks /// Host plugin API version. Bump when the host runtime surface breaks
/// compatibility. v2 added `HostApi::start_interactive` (the /// compatibility. v2 added `HostApi::start_interactive`. v3 changes
/// `InteractiveCommand` hook) — a vtable change, so v1 binaries are refused. /// `document()` / `document_mut()` to local cached copies for out-of-process
pub const API_VERSION: u32 = 2; /// plugins.
pub const API_VERSION: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ApiVersion { 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 //! Ribbon description types — the plain-data vocabulary a [`CadModule`] uses to
//! declare its tab. No UI-framework dependency: the host renders these. //! declare its tab. No UI-framework dependency: the host renders these.
#[cfg(feature = "host")]
pub mod owned;
// ── Events ──────────────────────────────────────────────────────────────── // ── Events ────────────────────────────────────────────────────────────────
/// Events a module tool can emit to the host application. /// Events a module tool can emit to the host application.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum ModuleEvent { pub enum ModuleEvent {
/// Fire a named CAD command (e.g. "LINE", "CIRCLE"). /// Fire a named CAD command (e.g. "LINE", "CIRCLE").
Command(String), Command(String),
@ -105,6 +109,7 @@ pub enum RibbonItem {
/// Identifies which style list a `StyleComboGroup` refers to. /// Identifies which style list a `StyleComboGroup` refers to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))]
pub enum StyleKey { pub enum StyleKey {
TextStyle, TextStyle,
DimStyle, DimStyle,
@ -119,6 +124,7 @@ impl From<ToolDef> for RibbonItem {
} }
/// A named group of tool buttons shown together in the ribbon. /// A named group of tool buttons shown together in the ribbon.
#[derive(Clone)]
pub struct RibbonGroup { pub struct RibbonGroup {
pub title: &'static str, pub title: &'static str,
pub tools: Vec<RibbonItem>, 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)
}

View file

@ -30,8 +30,9 @@ engine crate, and user-installable packages from a curated index.
## Non-goals ## Non-goals
- Sandboxing or signature verification (installing a plugin runs native code; the - Signature verification (installing a plugin runs native code; the user trusts
user trusts the repos they install from). 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). - Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi).
- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model. - 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) │ │ Layer A — Host (OpenCADStudio) │
│ iced UI · Scene · Document · Undo · Command line │ │ iced UI · Scene · Document · Undo · Command line │
│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │ │ 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) │ │ Layer B — Plugin process (external repo, cdylib) │
Cargo.toml · plugin.toml · src/lib.rs host spawns itself in runner mode · Cargo.toml · plugin.toml
│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │ │ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │
└───────────────────────────────┬────────────────────────────────────┘ └───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API │ pure Rust API
@ -61,8 +62,8 @@ engine crate, and user-installable packages from a curated index.
| Layer | Lives in | May depend on | | Layer | Lives in | May depend on |
|-------|----------|---------------| |-------|----------|---------------|
| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` | everything | | **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` runtime | everything |
| **B — Plugin** | a separate repo (cdylib) | `ocs_plugin_api` + optional engine | | **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) | | **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) |
**Hard rules** **Hard rules**
@ -83,7 +84,8 @@ plugin compiles against. Two tiers:
`IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this `IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this
cheaply. cheaply.
- **`host` feature** (pulls `acadrust`): the runtime surface — the `HostApi` - **`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. A plugin enables the `host` feature.
@ -119,9 +121,9 @@ concrete types:
| Category | Methods | | 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 | | 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` | | Command line | `push_info`, `push_output`, `push_error` |
| Undo / dirty | `push_undo`, `set_dirty` | | Undo / dirty | `push_undo`, `set_dirty` |
| Tab | `tab_index()` | | Tab | `tab_index()` |
@ -203,7 +205,7 @@ version = "0.1.0"
description = "…" description = "…"
[opencad] [opencad]
api_version = 2 api_version = 3
ribbon_order = 50 ribbon_order = 50
command_prefixes = ["EX_"] command_prefixes = ["EX_"]
xdata_apps = [] xdata_apps = []
@ -292,17 +294,29 @@ On startup the host scans `<config>/OpenCADStudio/plugins/<id>/` for a
libocs_example_plugin.so # any name with the platform extension libocs_example_plugin.so # any name with the platform extension
``` ```
For each compatible package it `dlopen`s the library (`libloading`), calls For each compatible package the host spawns **itself** in runner mode
`ocs_plugin_api_version` and refuses on mismatch, then `ocs_plugin_register` to (`--ocs-plugin-runner <socket> <cdylib>`). The child process loads the `cdylib`
obtain the boxed `BuiltinPlugin`. Loaded libraries stay **resident for the in its own address space and connects back to the host over an `interprocess`
session** (ribbon tabs and dispatch hold their vtables, so they are never local socket. The runner checks `ocs_plugin_api_version` and refuses on
reloaded mid-session). External plugins merge into the same ribbon and 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 `try_dispatch` path the host uses and honour the enable/disable set
(`disabled_plugins` in `settings.txt`). (`disabled_plugins` in `settings.txt`).
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or `<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
`$XDG_CONFIG_HOME` / `~/.config` (Linux). `$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 ## Marketplace
@ -331,12 +345,13 @@ installs plugins from GitHub Releases:
## Compatibility & ABI ## Compatibility & ABI
Loading uses **approach B**: the plugin returns a boxed `BuiltinPlugin` across the Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawns
`cdylib` boundary. This is sound only when the plugin was built with the **same this child from its own executable (`--ocs-plugin-runner` mode), so the runner
Rust toolchain and `ocs_plugin_api` version** as the host. The and host always share the same `ocs_plugin_api` build. The runner checks
`ocs_plugin_api_version` symbol gates the API version; it does **not** detect a `ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its
toolchain mismatch. In practice CI built with current stable Rust matches a host own OS process, so the host is protected from plugin crashes and memory
built the same way. 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 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 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 - [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature
(`HostApi` / `BuiltinPlugin` / `export_plugin!`). (`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] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state.
- [x] Marketplace — curated registry + manual repo link, install / upgrade / - [x] Marketplace — curated registry + manual repo link, install / upgrade /
reinstall / uninstall, enable/disable. reinstall / uninstall, enable/disable.
- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick).
Next: Next:
- [ ] Incremental document snapshots instead of cloning `CadDocument` over IPC.
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries. - [ ] `#[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. - [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
- [ ] External automation API (drive OCS headless from a process) — issue #29. - [ ] External automation API (drive OCS headless from a process) — issue #29.
@ -368,8 +386,11 @@ Next:
| Piece | Location | | Piece | Location |
|-------|----------| |-------|----------|
| Contract crate | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) |
| Plugin runtime (host) | `src/plugin/`, `src/app/plugin_host.rs` | | 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) | | Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) |
| Template scaffold | [`docs/plugin-template/`](plugin-template) | | Template scaffold | [`docs/plugin-template/`](plugin-template) |
| Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) | | Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) |

View file

@ -7,7 +7,7 @@ author = "Your Name"
license = "GPL-3.0-only" license = "GPL-3.0-only"
[opencad] [opencad]
api_version = 2 api_version = 3
ribbon_order = 60 ribbon_order = 60
command_prefixes = ["MP_"] command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"] xdata_apps = ["MYPLUGIN_RECORD"]

View file

@ -560,7 +560,7 @@ mod tests {
fn save_then_open_round_trips() { fn save_then_open_round_trips() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();
let path = std::env::temp_dir().join("ocs_automation_test.dxf"); 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"}"#); app.automation_op(r#"{"op":"new"}"#);
assert_eq!( assert_eq!(
app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"],

View file

@ -1980,7 +1980,7 @@ impl OpenCADStudio {
// their ribbon tabs into the ribbon. Skipped under test/wasm. // their ribbon tabs into the ribbon. Skipped under test/wasm.
#[cfg(all(not(target_arch = "wasm32"), not(test)))] #[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 { if let Err(e) = res {
app.command_line app.command_line
.push_error(&format!("Plugin '{id}' failed to load: {e}")); .push_error(&format!("Plugin '{id}' failed to load: {e}"));
@ -2000,6 +2000,22 @@ impl OpenCADStudio {
Self::new() Self::new()
} }
/// Install `cmd` as the active interactive command for tab `tab`.
pub(crate) fn set_active_command(
&mut self,
tab: usize,
cmd: Box<dyn crate::command::CadCommand>,
) {
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)] #[cfg(test)]
pub(crate) fn command_history_info(&self) -> Vec<String> { pub(crate) fn command_history_info(&self) -> Vec<String> {
use crate::ui::command_line::EntryKind; use crate::ui::command_line::EntryKind;

View file

@ -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<ocs_plugin_api::process::PluginProcess>,
pub command_id: u64,
prompt: Option<String>,
needs_entity_pick: Option<bool>,
}
impl PluginProcessInteractiveAdapter {
pub(crate) fn new(
process: std::sync::Arc<ocs_plugin_api::process::PluginProcess>,
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( fn plugin_step_to_result(
step: ocs_plugin_api::host::CommandStep, step: ocs_plugin_api::host::CommandStep,
) -> crate::command::CmdResult { ) -> crate::command::CmdResult {

View file

@ -63,6 +63,10 @@ pub struct Cli {
/// Log level (error|warn|info|debug|trace). Also honours RUST_LOG. /// Log level (error|warn|info|debug|trace). Also honours RUST_LOG.
#[arg(long, value_name = "LEVEL")] #[arg(long, value_name = "LEVEL")]
pub log: Option<String>, pub log: Option<String>,
/// Internal: run as the plugin runner child process.
#[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)]
pub ocs_plugin_runner: Option<Vec<String>>,
} }
/// GUI startup configuration, handed from `main` to `app::boot` out-of-band /// GUI startup configuration, handed from `main` to `app::boot` out-of-band

View file

@ -35,6 +35,23 @@ fn main() -> iced::Result {
use clap::Parser; use clap::Parser;
let args = cli::Cli::parse(); 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 <socket> <cdylib>");
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 // Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
// surfaces wgpu / iced / winit diagnostics that are otherwise silent. // surfaces wgpu / iced / winit diagnostics that are otherwise silent.
if let Some(level) = &args.log { if let Some(level) = &args.log {

View file

@ -1,18 +1,4 @@
// AUTO-GENERATED by build.rs — do not edit by hand. // AUTO-GENERATED include — see build.rs for the actual generator.
// To add a module: create src/modules/my_name/mod.rs + add `pub mod my_name;` to modules/mod.rs. // The generated registry is written to Cargo's OUT_DIR and included here so
// the source tree is not modified during build.
use crate::modules::CadModule; include!(concat!(env!("OUT_DIR"), "/modules_registry.rs"));
/// Returns one boxed instance of every registered CAD module.
/// Called once at startup by `Ribbon::new()`.
pub fn all_modules() -> Vec<Box<dyn CadModule>> {
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),
]
}

View file

@ -54,7 +54,11 @@ impl ExternalPlugin {
} }
/// `<config>/OpenCADStudio/plugins`, matching the settings/recent-files store. /// `<config>/OpenCADStudio/plugins`, matching the settings/recent-files store.
/// Overridable via `OCS_PLUGINS_DIR` for tests.
pub fn plugins_dir() -> Option<PathBuf> { pub fn plugins_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("OCS_PLUGINS_DIR") {
return Some(PathBuf::from(p));
}
let base: PathBuf = if cfg!(target_os = "windows") { let base: PathBuf = if cfg!(target_os = "windows") {
std::env::var_os("APPDATA").map(PathBuf::from)? std::env::var_os("APPDATA").map(PathBuf::from)?
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
@ -217,42 +221,41 @@ fn parse_string_array(s: &str) -> Vec<String> {
// ── Runtime loading (desktop only) ────────────────────────────────────────── // ── Runtime loading (desktop only) ──────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))] #[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(not(target_arch = "wasm32"))]
#[cfg_attr(test, allow(dead_code))]
mod loader { mod loader {
use super::{lib_extension, ExternalPlugin}; use super::{lib_extension, ExternalPlugin};
use ocs_plugin_api::host::BuiltinPlugin;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
/// A loaded external plugin. The library must outlive the boxed plugin, so /// A loaded external plugin. Holds the spawned process and a shareable
/// `plugin` is declared before `_lib` (fields drop in declaration order). /// ribbon module built from the process's cached ribbon data.
pub struct LoadedPlugin { pub struct LoadedPlugin {
plugin: Box<dyn BuiltinPlugin>, pub process: Arc<ocs_plugin_api::process::PluginProcess>,
_lib: libloading::Library, pub module: ocs_plugin_api::ribbon::owned::SharedCadModule,
pub id: String, pub id: String,
} }
impl LoadedPlugin {
pub fn plugin(&self) -> &dyn BuiltinPlugin {
self.plugin.as_ref()
}
}
use std::cell::RefCell; use std::cell::RefCell;
// Process-wide store of loaded external plugins. The libraries must stay // Process-wide store of spawned external plugins. The runner processes stay
// resident for the whole session — ribbon tabs and command dispatch hold // alive for the whole session; this is filled once at startup.
// vtables that live inside them — so this is filled once at startup and
// never cleared mid-session (reloading would dangle live ribbon modules).
thread_local! { thread_local! {
static LOADED: RefCell<Vec<LoadedPlugin>> = const { RefCell::new(Vec::new()) }; static LOADED: RefCell<Vec<LoadedPlugin>> = const { RefCell::new(Vec::new()) };
} }
/// Discover packages and load every API-compatible one with a native /// Discover packages and spawn every API-compatible one as a separate
/// library into the process store. Call once at startup. Returns per-id /// process. Call once at startup. Returns per-id results so the host can
/// results so the host can report load failures. /// report load failures.
pub fn load_at_startup() -> Vec<(String, Result<(), String>)> { pub(crate) fn load_at_startup(app: &mut crate::app::OpenCADStudio) -> Vec<(String, Result<(), String>)> {
let discovered = super::discover(); let discovered = super::discover();
let mut out = Vec::new(); let mut out = Vec::new();
LOADED.with(|cell| { LOADED.with(|cell| {
@ -264,7 +267,7 @@ mod loader {
if !d.api_compatible() || !d.lib_present { if !d.api_compatible() || !d.lib_present {
continue; continue;
} }
match load(d) { match load(d, app) {
Ok(lp) => { Ok(lp) => {
out.push((lp.id.clone(), Ok(()))); out.push((lp.id.clone(), Ok(())));
store.push(lp); store.push(lp);
@ -295,55 +298,29 @@ mod loader {
}) })
} }
/// Load a discovered package's `cdylib`, gating on the API version before /// Spawn a discovered package's `cdylib` in a separate process and cache
/// any of its code runs. Approach B (see `docs/plugin-architecture.md`): /// its ribbon module. The runner performs the API version gate before any
/// the plugin hands back a boxed `BuiltinPlugin`; this assumes the package /// plugin code runs.
/// was built against the same toolchain and `ocs_plugin_api` version, which pub fn load(
/// the version symbol enforces. p: &ExternalPlugin,
/// app: &mut crate::app::OpenCADStudio,
/// # Safety ) -> Result<LoadedPlugin, String> {
/// 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<LoadedPlugin, String> {
let path = lib_file(&p.dir).ok_or("no native library in package")?; let path = lib_file(&p.dir).ok_or("no native library in package")?;
unsafe { let mut host = crate::app::plugin_host::HostSession::new(app, 0);
let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?; let process =
ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host).map_err(|e| e.to_string())?;
let version: libloading::Symbol<extern "C" fn() -> u32> = lib let id = process.id().to_string();
.get(b"ocs_plugin_api_version") let name = process.manifest().name.clone();
.map_err(|_| "missing ocs_plugin_api_version symbol".to_string())?; let module = ocs_plugin_api::ribbon::owned::to_shared_module(
// Wrap the version + register calls in a panic guard so a plugin id.clone(),
// that panics during load can't take down the host. (#145) name,
let v = crate::plugin::guard("version", || version()) process.ribbon().to_vec(),
.ok_or_else(|| "ocs_plugin_api_version panicked".to_string())?; );
if v != ocs_plugin_api::API_VERSION { Ok(LoadedPlugin {
return Err(format!( process: Arc::new(process),
"API version {v} != host {}", module,
ocs_plugin_api::API_VERSION id,
)); })
}
let register: libloading::Symbol<
extern "C" fn() -> *mut Box<dyn BuiltinPlugin>,
> = 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,
})
}
} }
} }
@ -372,7 +349,7 @@ command_prefixes = ["SS_", "STORM_"]
assert_eq!(p.api_version, 1); assert_eq!(p.api_version, 1);
assert_eq!(p.ribbon_order, 50); assert_eq!(p.ribbon_order, 50);
assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]); assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]);
assert!(p.api_compatible()); assert!(!p.api_compatible());
} }
#[test] #[test]
@ -386,4 +363,88 @@ command_prefixes = ["SS_", "STORM_"]
assert!(!p.api_compatible()); assert!(!p.api_compatible());
assert!(!p.loadable()); 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");
}
} }

View file

@ -24,16 +24,13 @@ pub fn ribbon_modules_enabled(
let mut addons: Vec<(i32, Box<dyn CadModule>)> = Vec::new(); let mut addons: Vec<(i32, Box<dyn CadModule>)> = Vec::new();
crate::plugin::external::with_loaded(|loaded| { crate::plugin::external::with_loaded(|loaded| {
for lp in loaded { for lp in loaded {
if disabled.contains(lp.id.as_str()) { if disabled.contains(lp.id.as_str()) || !lp.process.is_alive() {
continue; continue;
} }
// Guard the plugin's ribbon build so a panic there can't take addons.push((
// down the host — the plugin just contributes no tab. (#145) lp.process.manifest().ribbon_order,
if let Some(entry) = crate::plugin::guard("ribbon", || { Box::new(lp.module.clone()) as Box<dyn CadModule>,
(lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()) ));
}) {
addons.push(entry);
}
} }
}); });
addons.sort_by_key(|(order, _)| *order); 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; use super::host::HostSession;
let disabled = app.disabled_plugin_ids(); let disabled = app.disabled_plugin_ids();
let mut started: Option<(u64, std::sync::Arc<ocs_plugin_api::process::PluginProcess>)> = None;
let mut dead_plugins: Vec<String> = Vec::new();
let mut dispatch_errors: Vec<(String, String)> = Vec::new();
let handled = crate::plugin::external::with_loaded(|loaded| { let handled = crate::plugin::external::with_loaded(|loaded| {
let mut host = HostSession::new(app, tab); let mut host = HostSession::new(app, tab);
for lp in loaded { for lp in loaded {
if disabled.contains(lp.id.as_str()) { if disabled.contains(lp.id.as_str()) {
continue; continue;
} }
// A panic inside the plugin's dispatch must not crash the host; if !lp.process.is_alive() {
// treat a panicking plugin as "didn't handle it". (#145) dead_plugins.push(lp.id.clone());
if crate::plugin::guard("dispatch", || lp.plugin().dispatch(&mut host, cmd)) continue;
.unwrap_or(false) }
{ let process = std::sync::Arc::clone(&lp.process);
return true; 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 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 { if handled {
return true; return true;
} }