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-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.
| 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. |
- 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.
- Sandboxed scripting (Python/Lua); replacing the
opencadcodecentity model.
┌────────────────────────────────────────────────────────────────────┐
│ 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
- The host (
src/plugin/) imports no plugin code — it only knows the contract. - Engine crates import neither
iced,opencadcodec, norOpenCADStudio. - A plugin never edits host source; it runs entirely from its own crate.
crates/ocs_plugin_api is the semver-versioned API a
plugin compiles against. Two tiers:
- Dependency-free core (default):
PluginManifest/ApiVersionand the ribbon vocabulary —CadModule,ToolDef,RibbonGroup,RibbonItem,IconKind,ModuleEvent,StyleKey. Engine crates and tooling depend on this cheaply. hostfeature (pullsopencadcodec): the runtime surface — theHostApitrait, theBuiltinPluginentry-point trait, theexport_plugin!macro, and the out-of-process plugin runtime (PluginProcess,runner).
A plugin enables the host feature.
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 theopencadcodeccrate the host was built against.rustc_version— the output ofrustc --versionfor 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.
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],
}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
fn on_custom_grip_event( // API v11
&mut self,
host: &mut dyn HostApi,
event: CustomGripEvent,
) -> CustomGripResponse {}
}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 Document 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. |
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.
A plugin is a standalone crate that builds a cdylib:
# 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.
// 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);# 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/;
the live reference is the
opencad-example-plugin
repository.
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.
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:
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".
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.
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():
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.
Three additive surfaces, one rule: plugin DECLARED, plugin OWNED. The host renders, hit-tests and drags plugin-declared grip points, but it never interprets them — no entity change, constraint or record of its own ever follows from a custom grip. All semantics live in the declaring plugin.
1. Reading built-in grips. HostApi::entity_grips(handle) enumerates an
entity's grips with full viewport parity — the same per-entity enumeration the
selection cache is built from (shape and field semantics match the
automation grips read; moves_entity is the translate-grip flag). It is
generic over every entity type: an arc answers its center / endpoints / its
mid-arc radius grip, a spline its control points, and so on. Reference-only
entities and locked layers answer an empty list — the same answer the
viewport gives.
2. Programmatic grip edit. HostApi::grip_drag(handle, grip_id, to)
performs the same edit an interactive drag performs: the entity-typed grip
modes, snaps/ortho/polar off for the move, exactly one undo step labeled like
the interactive grip step ("GRIP"), byte-exact restore on undo, and the
interactive path's refusals (unknown entity/grip, reference-only, locked
layer, point out of view). The viewport, the automation grip_drag op and
this method all drive ONE shared pipeline.
3. Plugin-declared custom grip points.
HostApi::set_plugin_grips(plugin_id, grips) declares (or, with an empty
batch, clears) the calling plugin's grips: grip_id (a plugin-chosen stable
id), anchor (any live entity handle — ownership scoping and hit-test
legibility only), point (absolute world — the host never moves, offsets
or auto-follows it when the anchor entity moves), axis (an optional
hard-constrained drag direction), and shape. The declaration lifecycle:
- Session-only display state. Nothing of a custom grip is persisted in
the drawing file; the plugin re-declares after its own notification
boundaries (the pull model —
on_notificationmarks the safe refresh points). Whole batches REPLACE the plugin's previous set, applied as a diff keyed bygrip_id: an unchanged re-declare costs nothing. - The one-undo-step contract. Drags ride the viewport's existing grip
drag lifecycle: press engages, the cursor streams move events, mouse-up
settles, Esc cancels. Move events are coalesced (at most one in flight,
superseded positions dropped); the plugin answers each with a
CustomGripResponsewhosepreviewREPLACES its live preview wires — the only feedback the host shows (no host ghost; the anchor entity never moves by host action mid-drag). The settle event carries the final point and gives the plugin its ONLY write window: from insideon_custom_grip_eventthe plugin can read the document and write — including opening its ownpush_undoscope — and a settled drag totals exactly the plugin's one labeled undo step. The host records nothing of its own; cancel means zero writes, zero undo steps, byte-identical drawing. Every callback is panic-guarded: a buggy handler is contained and reported, never fatal. - Reference-only and locking. Custom grips never render on reference-only designated entities, never mutate entities through built-in paths, and never bypass layer locks — the plugin's own settle writes go through the same HostApi as any other plugin write. A grip coincident with click-through (reference-only) geometry stays fully hittable and wins the hit, because grips are tested before any wire pick.
- Visibility. Always visible while the declaring plugin is loaded, alive and not disabled (the same live-manager gating as the entity-class labels), with hover highlighting through the same overlay the built-in grips use, in a distinct accent. Death/unload removes the grip — and nothing else. Hit-testing scales through a per-tab spatial grid with viewport culling (never a linear scan per cursor move); per-plugin and per-tab caps (4 096 / 16 384) keep the budget machinery honest — custom grips are not subject to the selection grip budget.
- Ownership. Over the plugin IPC the host attaches the speaking process's own manifest id and refuses mismatches, so a plugin cannot declare grips in another plugin's name — and only the declaring plugin ever receives its grips' events. The declaration requires the V4 transport (the settle's nested writes ride the multiplexed link).
- Live geometry (v11.2). A begin/move answer may also carry
live_entities: Vec<(handle, entity)>— the plugin-directed visual channel, computed by the plugin exactly as the settle will commit it. The host swaps those entities in through the same visual-only semantics its own grip drags show: the elements visibly move, ZERO history steps mid-drag, the originals are restored byte-identically before the settle, and a cancel discards the phase untouched. The payload is capped at 256 entities per response (a larger set is refused with an error line); a response carrying onlypreviewbehaves exactly as before. - A plugin can never freeze the host (v11.1). Every delivery — begin,
move, settle, cancel — is issued WITHOUT waiting and collected at pump
ticks (coalescing unchanged: at most one move in flight, newest point
delivered next). Nested COMMAND frames (
run_command/execute_command) are REFUSED inside grip deliveries with an honestErr: their execution would need the host's message loop, which a delivery on the UI thread cannot pump — the freeze the consumer's settle-replay wedge exhibited. Every other nested request (reads,push_undo, direct writes,entities_batch) keeps its full inline serving, so the settle callback stays the plugin's complete write window. A delivery that outlives its settle deadline is abandoned: the plugin's connection is torn down, any partially-open scope is closed (no dangling history), the drag ends as a cancel, and an error line names the plugin. The UI loop keeps painting throughout — it never waits on plugin answers. - Cheap refresh reads (v11.1).
entities_batch(&[handle])answers live per-entity reads so a write-then-reread refresh pass never re-clones the invalidated full-document snapshot (the per-boundary quadratic the consumer's live PI-marker pass hit). Prefer it in every refresh path. - Live geometry never re-solves constraints (v13). The visual phase swaps entities through the restyled bump policy: no parametric solve, no associative re-resolve — the plugin returns every affected entity (markers included) exactly as the settle will commit, and bonded partners never move by host action mid-drag. The consumer's captured defect (radius 15 → 14.39 mid-drag, then its own settle drift gate refused) is structurally gone; the elements follow the drag again.
- Constraints as HostApi (v13).
constraint_create(PluginConstraint)creates coincident/tangent constraints through the interactive commands' own machinery — no command line, no prompt echo, the solver behaves identically — and joins the caller's openpush_undoscope like every other write (the driver'sbegin_undono longer closes a plugin scope: a re-bond composed of writes + constraints + command replays is the plugin's ONE labeled step).CommandRequest::RunQuietis the interim twin for command replays that must stay silent: the same machinery asRun, with the command-line echo suppressed (errors still surface). - Deterministic notification attach (v13). The runner acks
PluginNotification::ChannelReadyat main-loop entry; the host defers a process's boot-window broadcasts until the ack and flushes them then (a grace window covers older runners that never ack, logged once). A fresh boot'sDocumentChangedcan no longer race the runner's startup and vanish — the silent 4-in-5 attach flake is closed by the same determinism the dispatch table already has. - Dirty regions (v13).
DocumentChangedRegionsrides every boundary notification next toDocumentChanged: the touched entity handles and (entity, app) record pairs since the previous boundary. Boundary work is O(changed), not O(store); empty sets mean "too much changed / unknown — walk everything" (an honest degradation, never a wrong claim). - Batched record walks (v13).
read_xrecords,update_xrecordsandlist_xrecords_with_entriescollapse the list-then-read/write-per-record store walk into one frame per pass — the record-carrier twin ofentities_batch. With dirty regions, a boundary pass is 2–3 frames instead of dozens.
MCP parity. Plugin-declared grips appear in the automation grips read
(each entry marked with its owning plugin), and grip_drag takes a plugin
parameter to drag one: {"op":"grip_drag","plugin":"opencad.civil_ifc", "grip_id":7,"point":[10,0,0]} routes through the same live machinery — the
consumer can drive declare → read → drag → settle end-to-end. MCP stays a
thin view over the same internals; plugins themselves speak HostApi and IPC
frames only, never the automation bridge.
Custom grips never carry host-side semantics. The host stores a point, a
shape and an axis — nothing else. What a grip means (an alignment's implied
PI, a pipe endpoint, a widening handle) is entirely the declaring plugin's,
resolved in its own on_custom_grip_event from the event's grip id and point.
The host exposes exactly two programmatic surfaces, and they are separate
protocols over one shared pipeline: plugins speak HostApi (in-process or
over the plugin IPC frames) and never the automation bridge; automation
clients speak MCP (sessions, session_id, request_id) and never the
plugin transport. Where both touch the same machinery — grip enumeration, the
drag apply, hit-testing, the preview channel — they call the same internals,
so their answers agree structurally (a drift test pins the grip read).
Which surface carries what:
| Capability | MCP op | HostApi |
|---|---|---|
| Built-in grip read / drag | grips / grip_drag{handle,…} |
entity_grips / grip_drag |
| Custom grip read / drag | grips → plugin_grips / grip_drag{plugin,…} |
via set_plugin_grips + on_custom_grip_event |
| Custom grip declaration | — (a view; only the owner declares) | set_plugin_grips |
| Entity CRUD, blocks, text | entities_*, block_*, text_replace |
add/update/remove_entity, solid/table_operation |
| Selection | select (handles and type/vertex modes), selection sets |
selection / set_selection (explicit handles) |
| Undo | undo, redo — drives the user's stack |
push_undo scopes, update_entities_transaction |
| Commands | run, start, input, cancel |
run_command (incl. the echo-free RunQuiet) |
| Constraints | only command replays (with echo) | constraint_create (no command line, joins the caller's scope) |
| Entity XDATA | xdata_get / xdata_set |
read_record / write_record / remove_record |
| NOD XRecords | — | create/read/update/delete/list_xrecord + the v13 batches |
| Bulk data | batch (batched ops) |
entities_batch, record batches (batched data) |
| Notifications | events (the session journal) |
on_notification (DocumentChanged, DocumentChangedRegions, …) |
| Viewport automation | click, pointer, getpoint, user_select, view_focus, capture |
— (a plugin's grip events are its gestures) |
| Reference-only designation | — | set_reference_only |
| Files | new/open/save/save_verified/wblock/plot |
document_path, tab lifecycle |
Semantic traps worth knowing before writing a consumer on either side:
- MCP
recordsis not records. It is the automation session's operation journal; entity data lives inentities/query/properties/xdata_get. A plugin's NOD XRecords (its real carrier) are HostApi-only — automation must assert them through undo/audit/file, not direct reads. - Undo is deliberately asymmetric. Automation drives the user's stack
(
undo/redo); plugins compose labeledpush_undoscopes. A settled custom-grip drag totals exactly the plugin's one labeled step, and a later MCPundopops it like any user step. grip_idnamespaces differ but are labeled. Built-ins are entity-local indices; custom grips are plugin-chosen ids routed by(plugin, id). The MCP payload marks the owner so the two can never be confused.- Selection scoping differs. The MCP
gripsread reflects the current selection's cached grips;entity_gripsanswers per handle, generically over every entity type (boxed variants included), no selection needed. - The never-interpret rule holds on both surfaces. Even where MCP touches custom grips it only routes; the sole writer of meaning is the owning plugin, at settle, in its own scope.
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.
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 | 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. |
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.jsonin this repo lists discoverable plugins. The host fetches it frommainat runtime and shows each entry under Available plugins. To list a plugin, open a PR adding{ "repo", "name", "description" }(seeplugins/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 insettings.json. - Install / upgrade / reinstall — pick a release from the dropdown and
Install; the host downloads the platform asset +
plugin.tomlinto the plugins folder, checkingapi_versionfirst. 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.
Plugins are loaded as cdylibs 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.
Done:
- Stable
ocs_plugin_apicrate — dependency-free core +hostfeature (HostApi/BuiltinPlugin/export_plugin!). - Runtime discovery + out-of-process loading (host spawns itself in runner
mode) with an
api_versiongate andinterprocesslocal-socket IPC. - XDATA helpers,
ModuleEvent::PluginFileDialog, per-tab plugin state. - Marketplace — curated registry + manual repo link, install / upgrade / reinstall / uninstall, enable/disable.
- Interactive command round-trip over IPC (prompt, point/enter/object-pick).
- Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
- 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, persrc/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
Documentover IPC. -
#[repr(C)]vtable / strict handshake for cross-toolchain binaries. - Trust: checksums / signatures before spawning plugin processes.
- Interchange (LandXML / SWMM) and live
on_entity_committedhooks.
| Piece | Location |
|---|---|
| Contract crate + runtime | crates/ocs_plugin_api |
| Internal API architecture | crates/ocs_plugin_api/ARCHITECTURE.md |
| Plugin runner implementation | crates/ocs_plugin_api/src/runner.rs |
| Host spawn logic | 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 |
| Template scaffold | docs/plugin-template/ |
| Live example plugin | opencad-example-plugin |