Initial commit
This commit is contained in:
parent
125b89d8da
commit
dcfaed0182
26 changed files with 2080 additions and 143 deletions
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
5
build.rs
5
build.rs
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
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.
|
/// 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,
|
||||||
|
|
|
||||||
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")]
|
#[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};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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
|
//! 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>,
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
17
src/main.rs
17
src/main.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue