forked from 1b-agent/opencadstudio-upstream
- plugin-architecture.md: new "Entity class display labels (API v10)" section documenting the split (stable identity keys in XDATA, display labels in the plugin declaration), the live-loaded-plugin gating (missing, disabled, cap-excluded, failed, removed, or dead → bare native type), the "<Label> (<Native>)" display rule, that raw schema fields (`type`, `dxf_name`, `record_type`) and LIST are unchanged, and that the label is display-only. `plugin.toml` deliberately does not carry the table: it is the pre-load discovery file and labels must activate on live manifests. - Template v2 demonstrates the whole convention: declares `SURVEY-CLASS:POINT -> "Survey Point"` and stamps the identity key in MARK_FIRST_SURVEY_POINT, so marked points show as "Survey Point (Point)" while the plugin is loaded. plugin.toml's api_version is bumped to 10 (keep in sync with ApiVersion::CURRENT) with a NOTE on where the class table lives. DESIGN.md documents the flow. - Template v2's stale `acadrust` pin (8a28c21, predating the host's codec bump to 278c49a) is realigned with the host's pin: without it the template no longer compiles on main — its `acadrust::EntityType` and the host re-exports are distinct types when the revisions disagree (verified the same four type errors occur before and after this feature's template edit). - changelogs/unreleased.md entry (Plugins section).
523 lines
24 KiB
Markdown
523 lines
24 KiB
Markdown
# Open CAD Studio — Plugin Architecture
|
|
|
|
**Status:** Accepted
|
|
**Author:** Open CAD Studio contributors
|
|
**Date:** June 2026
|
|
|
|
This document is the **authoritative spec** for how add-on packages integrate
|
|
with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style
|
|
extensibility: a small metadata file, a single entry point, an optional separate
|
|
engine crate, and user-installable packages from a curated index.
|
|
|
|
> Plugins are **out-of-process dynamic libraries** (`cdylib`). The host loads
|
|
> first-party packages from its application resources and third-party packages
|
|
> from the user plugins folder. Both use the generic runtime
|
|
> (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate
|
|
> (`crates/ocs_plugin_api`). A user package with the same id explicitly
|
|
> overrides a bundled package.
|
|
|
|
---
|
|
|
|
## Design goals
|
|
|
|
| Goal | Rationale |
|
|
|------|-----------|
|
|
| **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. |
|
|
| **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. |
|
|
| **Out-of-tree by default** | Third-party plugins remain independent; selected first-party plugins may build and ship with OCS. |
|
|
| **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. |
|
|
| **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. |
|
|
|
|
## Non-goals
|
|
|
|
- Signature verification (installing a plugin runs native code; the user trusts
|
|
the repos they install from). Process isolation limits the blast radius of a
|
|
buggy or malicious plugin, but it is not a security sandbox.
|
|
- Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi).
|
|
- Sandboxed scripting (Python/Lua); replacing the `opencadcodec` entity model.
|
|
|
|
---
|
|
|
|
## Three layers
|
|
|
|
```
|
|
┌────────────────────────────────────────────────────────────────────┐
|
|
│ Layer A — Host (OpenCADStudio) │
|
|
│ iced UI · Scene · Document · Undo · Command line │
|
|
│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │
|
|
│ Generic plugin runtime: discovery, spawn, dispatch │
|
|
└───────────────────────────────┬────────────────────────────────────┘
|
|
│ IPC over local socket (ocs_plugin_api)
|
|
┌───────────────────────────────▼────────────────────────────────────┐
|
|
│ Layer B — Plugin process (external repo, cdylib) │
|
|
│ host spawns itself in runner mode · Cargo.toml · plugin.toml │
|
|
│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │
|
|
└───────────────────────────────┬────────────────────────────────────┘
|
|
│ pure Rust API
|
|
┌───────────────────────────────▼────────────────────────────────────┐
|
|
│ Layer C — Domain engine crate (optional) │
|
|
│ hydraulics / COGO / … — `std` only, no iced/opencadcodec │
|
|
└────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
| Layer | Lives in | May depend on |
|
|
|-------|----------|---------------|
|
|
| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` runtime | everything |
|
|
| **B — Plugin** | a separate repo (cdylib), spawned by the host in runner mode | `ocs_plugin_api` + optional engine |
|
|
| **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) |
|
|
|
|
**Hard rules**
|
|
|
|
1. The host (`src/plugin/`) imports no plugin code — it only knows the contract.
|
|
2. Engine crates import neither `iced`, `opencadcodec`, nor `OpenCADStudio`.
|
|
3. A plugin never edits host source; it runs entirely from its own crate.
|
|
|
|
---
|
|
|
|
## The contract crate — `ocs_plugin_api`
|
|
|
|
[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) is the semver-versioned API a
|
|
plugin compiles against. Two tiers:
|
|
|
|
- **Dependency-free core** (default): `PluginManifest` / `ApiVersion` and the
|
|
ribbon vocabulary — `CadModule`, `ToolDef`, `RibbonGroup`, `RibbonItem`,
|
|
`IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this
|
|
cheaply.
|
|
- **`host` feature** (pulls `opencadcodec`): the runtime surface — the `HostApi`
|
|
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.
|
|
|
|
### Build-time ABI metadata
|
|
|
|
For API v4 and later the host records two pieces of build-time metadata that
|
|
affect whether a prebuilt plugin can safely be loaded:
|
|
|
|
- `acadrust_source` — the exact git source of the `opencadcodec` crate the host was
|
|
built against.
|
|
- `rustc_version` — the output of `rustc --version` for the compiler that built
|
|
the host.
|
|
|
|
A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4
|
|
and later, `rustc_version` is required and the host refuses to load the plugin
|
|
unless it matches exactly — omitting it is treated as a mismatch, not skipped.
|
|
This turns the current silent runner crash into a one-line diagnosis such as:
|
|
|
|
```
|
|
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
|
|
```
|
|
|
|
`acadrust_source` is also required for API v4 and later. Missing, empty, or
|
|
mismatched metadata makes the package incompatible.
|
|
|
|
### `PluginManifest`
|
|
|
|
```rust
|
|
pub struct PluginManifest {
|
|
pub id: &'static str, // reverse-DNS: "opencad.example"
|
|
pub name: &'static str,
|
|
pub version: &'static str,
|
|
pub description: &'static str,
|
|
pub api_version: ApiVersion, // host ABI major; must match the host
|
|
pub ribbon_order: i32, // sort key among add-on tabs
|
|
pub xdata_apps: &'static [&'static str],
|
|
pub command_prefixes: &'static [&'static str],
|
|
}
|
|
```
|
|
|
|
### `BuiltinPlugin` — the entry point
|
|
|
|
```rust
|
|
pub trait BuiltinPlugin: Send + Sync {
|
|
fn manifest(&self) -> &'static PluginManifest;
|
|
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
|
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
|
fn on_load(&mut self, host: &mut dyn HostApi) {}
|
|
fn entity_classes(&self) -> Option<&'static EntityClasses> {} // API v10
|
|
}
|
|
```
|
|
|
|
### `HostApi` — the plugin-facing runtime surface
|
|
|
|
`dispatch` receives `&mut dyn HostApi`, so a plugin never touches the host's
|
|
concrete types:
|
|
|
|
| Category | Methods |
|
|
|----------|---------|
|
|
| Document | `document()` / `document_mut()` return a **local cached copy** of the document (API v3); the first access in a dispatch clones the full `CadDocument` over IPC. Use `add_entity()` / `write_record()` for host-visible mutations. `add_entity()`, `bump_geometry()` |
|
|
| XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so data round-trips through DWG/DXF |
|
|
| Tab state | object-safe `plugin_state_any*` helpers exist for in-process use; out-of-process plugins should keep state inside the plugin crate because `dyn Any` is not serializable |
|
|
| Command line | `push_info`, `push_output`, `push_error` |
|
|
| Undo / dirty | `push_undo`, `set_dirty` |
|
|
| Tab | `tab_index()` |
|
|
| Document identity (V5) | `document_path(tab_id)` returns the saved path for a document tab. |
|
|
| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. |
|
|
|
|
### `export_plugin!` — the C-ABI export
|
|
|
|
```rust
|
|
ocs_plugin_api::export_plugin!(MyPlugin);
|
|
```
|
|
|
|
emits the two symbols the loader looks for:
|
|
|
|
- `ocs_plugin_api_version() -> u32` — checked **first**, so an API-incompatible
|
|
build never runs its code.
|
|
- `ocs_plugin_register() -> *mut Box<dyn BuiltinPlugin>` — constructs the plugin
|
|
and hands ownership to the host.
|
|
|
|
---
|
|
|
|
## Writing a plugin
|
|
|
|
A plugin is a standalone crate that builds a `cdylib`:
|
|
|
|
```toml
|
|
# Cargo.toml
|
|
[lib]
|
|
crate-type = ["cdylib"]
|
|
|
|
[dependencies]
|
|
ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] }
|
|
|
|
# Match the host's opencadcodec so the loaded library is binary-compatible.
|
|
[patch.crates-io]
|
|
opencadcodec = { git = "https://github.com/HakanSeven12/opencadcodec", branch = "main" }
|
|
```
|
|
|
|
Your release build must also use the same `rustc` as the host. Record it in
|
|
`plugin.toml` (see below) and pin the toolchain in your CI matrix so every
|
|
published asset matches the host's compiler.
|
|
|
|
```rust
|
|
// src/lib.rs
|
|
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
|
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
|
|
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
|
|
|
static MANIFEST: PluginManifest = PluginManifest {
|
|
id: "opencad.example", name: "Example Plugin", version: "0.1.0",
|
|
description: "…", api_version: ApiVersion::CURRENT,
|
|
ribbon_order: 50, xdata_apps: &[], command_prefixes: &["EX_"],
|
|
};
|
|
|
|
struct ExampleModule;
|
|
impl CadModule for ExampleModule {
|
|
fn id(&self) -> &'static str { "example" }
|
|
fn title(&self) -> &'static str { "Example" }
|
|
// Returns a slice, not an owned Vec — build once into a static and hand
|
|
// back a reference, rather than rebuilding (and dropping) it every call.
|
|
fn ribbon_groups(&self) -> &[RibbonGroup] {
|
|
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
|
|
GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
|
|
id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"),
|
|
event: ModuleEvent::Command("EX_HELLO".to_string()),
|
|
})]}]})
|
|
}
|
|
}
|
|
|
|
struct ExamplePlugin;
|
|
impl BuiltinPlugin for ExamplePlugin {
|
|
fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
|
|
fn ribbon(&self) -> Box<dyn CadModule> { Box::new(ExampleModule) }
|
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
|
match cmd { "EX_HELLO" => { host.push_info("Hello"); true } _ => false }
|
|
}
|
|
}
|
|
|
|
ocs_plugin_api::export_plugin!(ExamplePlugin);
|
|
```
|
|
|
|
```toml
|
|
# plugin.toml — shipped beside the binary; values mirror MANIFEST
|
|
[plugin]
|
|
id = "opencad.example"
|
|
name = "Example Plugin"
|
|
version = "0.1.0"
|
|
description = "…"
|
|
|
|
[opencad]
|
|
api_version = 5
|
|
ribbon_order = 50
|
|
command_prefixes = ["EX_"]
|
|
xdata_apps = []
|
|
# Both are filled at release-build time; see the CI step below.
|
|
rustc_version = "rustc 1.98.0 (hash date)"
|
|
acadrust_source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=<short>#<full-40-char-commit>"
|
|
```
|
|
|
|
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
|
|
the live reference is the
|
|
[`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin)
|
|
repository.
|
|
|
|
### Commands
|
|
|
|
A plugin owns its `command_prefixes` (e.g. `EX_`). The host's command router
|
|
calls `try_dispatch` first; a returning `true` consumes the command. A plugin
|
|
tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to
|
|
`dispatch(host, "EX_FOO")`.
|
|
|
|
`ModuleEvent::PluginFileDialog { command, title, filter_name, extensions }` lets a
|
|
tool request a native file picker; on selection the host dispatches
|
|
`"<command> <path>"` back with the path's original case preserved.
|
|
|
|
A leading `>` or the persistent literal-input toggle preserves both spaces
|
|
and case. Automation and `ModuleEvent::PluginFileDialog` already preserve
|
|
case without this prefix.
|
|
|
|
### Interactive (click-to-place) commands
|
|
|
|
For tools that collect points — placing a structure, drawing a pipe — call
|
|
`host.start_interactive(Box::new(my_cmd))` from `dispatch`, where `my_cmd`
|
|
implements `ocs_plugin_api::host::InteractiveCommand`:
|
|
|
|
```rust
|
|
fn on_point(&mut self, pt: [f64; 3]) -> CommandStep {
|
|
// … return NeedPoint, Commit(entity), CommitAndEnd(entity), Done, or Cancel
|
|
}
|
|
```
|
|
|
|
The host drives it through its normal point-collection flow, so the **same**
|
|
command works by clicking in the viewport and by feeding coordinates over the
|
|
`--serve` automation API (`run "MY_CMD 0,0 10,10"`). This is what bumped the
|
|
API to **v2** — the added `HostApi` method changes the contract's vtable, so v1
|
|
binaries are refused at load.
|
|
|
|
To reference **existing** geometry (e.g. connect a pipe between two structures),
|
|
set `needs_object_pick() -> true`; the host then calls
|
|
`on_object_pick(handle, pt)` with the clicked entity's handle (read its
|
|
XDATA/geometry via `HostApi`). Over `--serve` the pick is supplied as a hex
|
|
handle: `run "MY_CMD 2F 30"`.
|
|
|
|
### XDATA — domain persistence
|
|
|
|
Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a
|
|
side database, so it round-trips through DWG/DXF. `write_record` also registers
|
|
the APPID. Document your schemas in the plugin's own `PLUGIN.md`.
|
|
|
|
### Entity class display labels (API v10)
|
|
|
|
A plugin can give its XDATA-stamped entities human type names in the host UI.
|
|
The split between *identity* (in the drawing) and *labels* (in the plugin) is
|
|
the point:
|
|
|
|
- **Identity** — the plugin stamps one string value into an entity's XDATA
|
|
record: `"<class_prefix><KEY>"`, e.g. `"CVIFC-CLASS:LINE"`. The key is
|
|
stable, meaningless to the host, and round-trips the DWG/DXF verbatim.
|
|
- **Labels** — the plugin declares a table and exposes it through
|
|
`BuiltinPlugin::entity_classes()`:
|
|
|
|
```rust
|
|
use ocs_plugin_api::manifest::EntityClasses;
|
|
|
|
static ENTITY_CLASSES: EntityClasses = EntityClasses {
|
|
app_id: "CVIFC", // one of the manifest's xdata_apps
|
|
class_prefix: "CVIFC-CLASS:",
|
|
labels: &[
|
|
("LINE", "Linear Element"),
|
|
("CIRCULARARC", "Circular Arc Element"),
|
|
],
|
|
};
|
|
|
|
impl BuiltinPlugin for MyPlugin {
|
|
fn entity_classes(&self) -> Option<&'static EntityClasses> {
|
|
Some(&ENTITY_CLASSES)
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
The host resolves them at display time only: the single-entity Properties
|
|
panel title becomes `"Linear Element (Line)"`, and the records/automation
|
|
`display_type` field takes the same form. The rule is display-only — a label
|
|
never governs behavior, and the raw schema fields (`type`, `dxf_name`,
|
|
`record_type`, the command-line `LIST` output) stay native so existing scripts
|
|
and filters keep working.
|
|
|
|
**Gating.** Declarations are consulted through the live plugin manager, never
|
|
cached: a plugin that is not installed, is disabled, is excluded by
|
|
`OCS_PLUGIN_MAX_API_VERSION`, failed at load, has been removed, or whose
|
|
process has died contributes nothing — its entities display as their bare
|
|
native type ("Line"), immediately, with no re-open needed. An `app_id` claimed
|
|
by more than one loaded plugin resolves through the first-loaded declaration;
|
|
later duplicates are ignored (debug-logged). A class key that is empty,
|
|
missing from the table, or mapped to an empty label falls back to the native
|
|
type. Because labels are display text resolved from the loaded plugin, they
|
|
are plugin-localizable and fixable without rewriting drawings — and
|
|
`plugin.toml` (the pre-load discovery file) deliberately does not carry them.
|
|
|
|
---
|
|
|
|
## Building & distribution
|
|
|
|
Build per platform and publish to **GitHub Releases**:
|
|
|
|
```
|
|
cargo build --release # → target/release/lib<crate>.so | <crate>.dll | lib<crate>.dylib
|
|
```
|
|
|
|
A release attaches one binary per platform plus `plugin.toml`, with the platform
|
|
in the asset name so the host can pick the right one:
|
|
|
|
```
|
|
opencad.example-linux-x86_64.so
|
|
opencad.example-windows-x86_64.dll
|
|
opencad.example-macos-aarch64.dylib
|
|
plugin.toml
|
|
```
|
|
|
|
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
|
|
and uploads these on a `v*` tag. It also fills `plugin.toml`'s `rustc_version`
|
|
and `acadrust_source` placeholders from the CI run's actual toolchain and
|
|
resolved dependency, rather than hand-editing them per release — see
|
|
`docs/plugin-template/.github/workflows/release.yml` for the exact `sed`
|
|
substitutions, including reading `acadrust_source` out of `Cargo.lock`'s
|
|
resolved `opencadcodec` package entry (`source = "git+...#<hash>"`) since that's
|
|
the same value the host embeds at its own build time. The template's CI pins
|
|
`rust-toolchain@stable`, which is a floating target — a real release pipeline
|
|
should pin the exact toolchain version the host was built with (check
|
|
`crates/ocs_plugin_api/src/version_info.rs`'s embedded value, or the host's
|
|
own CI config) rather than trusting "whatever's current stable today" to
|
|
keep matching it.
|
|
|
|
---
|
|
|
|
## Loading
|
|
|
|
On startup the host scans `<config>/OpenCADStudio/plugins/<id>/` for a
|
|
`plugin.toml` + native library (`src/plugin/external.rs`):
|
|
|
|
```
|
|
<config>/OpenCADStudio/plugins/
|
|
opencad.example/
|
|
plugin.toml
|
|
libocs_example_plugin.so # any name with the platform extension
|
|
```
|
|
|
|
For each compatible package the host spawns **itself** in runner mode
|
|
(`--ocs-plugin-runner <socket> <cdylib>`). The child process loads the `cdylib`
|
|
in its own address space and connects back to the host over an `interprocess`
|
|
local socket. The runner checks `ocs_plugin_api_version` and refuses on
|
|
mismatch, then calls `ocs_plugin_register` to obtain the boxed `BuiltinPlugin`.
|
|
Each plugin runs in a separate OS process, so a plugin crash or memory
|
|
corruption cannot affect the host or other plugins. Plugin processes stay
|
|
**resident for the session**; external plugins merge into the same ribbon and
|
|
`try_dispatch` path the host uses and honour the enable/disable set
|
|
(`disabled_plugins` in `settings.txt`).
|
|
|
|
Two timeouts protect the host from a stuck runner:
|
|
|
|
| Timeout | Env var | Default | Floor |
|
|
|---|---|---|---|
|
|
| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — |
|
|
| Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s |
|
|
|
|
A call timeout covers the full round-trip, including any nested plugin→host
|
|
requests handled inline. When it fires the host kills the runner, marks the
|
|
plugin dead, and reports a `CallTimeout` error via the normal plugin error path.
|
|
|
|
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
|
|
`$XDG_CONFIG_HOME` / `~/.config` (Linux).
|
|
|
|
## Failure handling
|
|
|
|
| Failure | Behavior |
|
|
|---|---|
|
|
| Plugin panics | Caught inside the plugin runner child; an error response is returned to the host and stays alive. |
|
|
| Plugin crash / hang / malformed message | The host detects a dead process via `try_wait` on the next dispatch or ribbon rebuild; the tab is dropped and an error is logged. |
|
|
| Slow or non-responsive call | The per-call timeout (`OCS_PLUGIN_CALL_TIMEOUT_SECS`) fires, kills the runner, and surfaces a `CallTimeout` error. |
|
|
| 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
|
|
|
|
The desktop **Plugin Manager** (`PLUGINS` / `PLUGINMANAGER`, or the Start-page
|
|
button) installs plugins from GitHub Releases. In the browser, the same entry
|
|
points show a desktop-download notice because marketplace packages are native
|
|
dynamic libraries:
|
|
|
|
- **Curated registry** — [`plugins/registry.json`](../plugins/registry.json) in
|
|
this repo lists discoverable plugins. The host fetches it from `main` at
|
|
runtime and shows each entry under *Available plugins*. To list a plugin, open
|
|
a PR adding `{ "repo", "name", "description" }` (see
|
|
[`plugins/README.md`](../plugins/README.md)); merged PRs reach every user with
|
|
no app update.
|
|
- **Manual link** — *Add a repository* (`owner/repo`) for unlisted or private
|
|
dev repos; linked repos persist in `settings.json`.
|
|
- **Install / upgrade / reinstall** — pick a release from the dropdown and
|
|
*Install*; the host downloads the platform asset + `plugin.toml` into the
|
|
plugins folder, checking `api_version` first. Reinstalling overwrites and
|
|
clears any stale library; picking a newer release upgrades. Changes take effect
|
|
on the next restart (the running library stays resident).
|
|
- **Uninstall** — removes the package folder (effective next restart).
|
|
- **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without
|
|
uninstalling.
|
|
|
|
One linked repository is one marketplace entry. Publish separate plugins from
|
|
separate repositories so release and update histories stay unambiguous.
|
|
|
|
---
|
|
|
|
## Compatibility & ABI
|
|
|
|
Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawns
|
|
this child from its own executable (`--ocs-plugin-runner` mode), so the runner
|
|
and host always share the same `ocs_plugin_api` build. The runner checks
|
|
`ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its
|
|
own OS process, so a plugin crash or memory-corruption bug cannot take down the
|
|
host process itself. The runner loads the plugin library in-process, however,
|
|
so API v4+ requires matching `rustc_version` and `acadrust_source` metadata.
|
|
|
|
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
|
|
arbitrary build environments.
|
|
|
|
---
|
|
|
|
## Roadmap
|
|
|
|
Done:
|
|
|
|
- [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature
|
|
(`HostApi` / `BuiltinPlugin` / `export_plugin!`).
|
|
- [x] Runtime discovery + out-of-process loading (host spawns itself in runner
|
|
mode) with an `api_version` gate and `interprocess` local-socket IPC.
|
|
- [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state.
|
|
- [x] Marketplace — curated registry + manual repo link, install / upgrade /
|
|
reinstall / uninstall, enable/disable.
|
|
- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick).
|
|
- [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
|
|
- [x] External automation (`--mcp` — local stdio MCP/JSON-RPC server for AI
|
|
clients; `--serve` — headless JSON automation server; both drive OCS
|
|
from a separate process without the GUI, per `src/cli.rs`/`src/mcp.rs`/
|
|
`app::serve`). This closes the "drive OCS headless from a process" goal
|
|
of issue #29; this line previously and incorrectly listed it as not yet
|
|
started.
|
|
|
|
Next:
|
|
|
|
- [ ] Incremental document snapshots instead of cloning `CadDocument` over IPC.
|
|
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
|
|
- [ ] Trust: checksums / signatures before spawning plugin processes.
|
|
- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
|
|
|
|
---
|
|
|
|
## Reference
|
|
|
|
| Piece | Location |
|
|
|-------|----------|
|
|
| Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) |
|
|
| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) |
|
|
| Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) |
|
|
| Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) |
|
|
| Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` |
|
|
| Core module registry generator | `build.rs` (writes to `OUT_DIR`, included by `src/modules/registry.rs`) |
|
|
| Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) |
|
|
| Template scaffold | [`docs/plugin-template/`](plugin-template) |
|
|
| Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) |
|