Initial commit
This commit is contained in:
parent
125b89d8da
commit
dcfaed0182
26 changed files with 2080 additions and 143 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
193
crates/ocs_plugin_api/REPORT.md
Normal file
193
crates/ocs_plugin_api/REPORT.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
373
crates/ocs_plugin_api/src/ipc/client.rs
Normal file
373
crates/ocs_plugin_api/src/ipc/client.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
113
crates/ocs_plugin_api/src/ipc/mod.rs
Normal file
113
crates/ocs_plugin_api/src/ipc/mod.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
112
crates/ocs_plugin_api/src/ipc/protocol.rs
Normal file
112
crates/ocs_plugin_api/src/ipc/protocol.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
58
crates/ocs_plugin_api/src/ipc/server.rs
Normal file
58
crates/ocs_plugin_api/src/ipc/server.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
52
crates/ocs_plugin_api/src/ipc/transport.rs
Normal file
52
crates/ocs_plugin_api/src/ipc/transport.rs
Normal 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)?)
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
315
crates/ocs_plugin_api/src/process.rs
Normal file
315
crates/ocs_plugin_api/src/process.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
297
crates/ocs_plugin_api/src/ribbon/owned.rs
Normal file
297
crates/ocs_plugin_api/src/ribbon/owned.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
176
crates/ocs_plugin_api/src/runner.rs
Normal file
176
crates/ocs_plugin_api/src/runner.rs
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue