cad-editor/crates/ocs_plugin_api
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Sebastian 73bb7bd673
1GiB guard for mmap
Adding a MAX_GUARD for mmap to file
2026-08-17 18:25:15 +02:00
..
src 1GiB guard for mmap 2026-08-17 18:25:15 +02:00
build.rs feat(plugin): harden V4 host integration 2026-08-15 20:17:43 +03:00
Cargo.toml chore: update workspace dependencies 2026-08-15 23:11:21 +03:00
README.md Snapshot: V4 plugin API and host integration 2026-08-12 21:01:39 +02:00
REPORT.md Initial commit 2026-06-25 00:01:36 +03:00

ocs_plugin_api

Versioned, out-of-process plugin API for Open CAD Studio. This crate defines the contract between the host CAD application and third-party plugins. It is designed to stay small and dependency-free in its default configuration so that plugin crates and external tooling can depend on the manifest/ribbon surface cheaply. The runtime host surface is enabled by the host feature.

Plugin Architecture

OCS is scriptable and extensible through a versioned plugin API. The API supports three major protocol generations: V2, V3, and V4. Each generation keeps the previous ABI stable by appending new vtable entries and new enum variants at the end, so older plugins continue to load on newer hosts.

Version Delivery model Key capability ABI / wire change
V2 Out-of-process runner Synchronous commands + interactive point collection (HostApi::start_interactive) Baseline
V3 Out-of-process runner Local cached document()/document_mut() + shared-memory DocumentReader/document_view for large reads New vtable entries appended; new PluginRequest/PluginResponse variants appended
V4 Out-of-process runner over a multiplexed local socket Full-duplex notifications + asynchronous REPL ExecuteCode New HostToPluginV4/PluginToHostV4 frame layer; BuiltinPlugin::on_notification and start_execute_code appended to trait

The host controls which generations are accepted at runtime through OCS_PLUGIN_MAX_API_VERSION (e.g. 2 for V2-only mode, 3 to disable V4). The current host advertises API_VERSION = 4 and supports plugins back to API_VERSION_MIN_SUPPORTED = 2.

High-level layout

flowchart LR
    subgraph HostProcess["Host process"]
        App[OpenCADStudio]
        PM[PluginManager]
        PP[PluginProcess]
        HA[HostApi impl]
    end
    subgraph RunnerProcess["Runner process"]
        RL[Runner loop]
        V4C[V4Client]
        Proxy[HostApi proxy]
    end
    subgraph Library["Plugin cdylib"]
        BP[BuiltinPlugin impl]
    end

    App --> PM
    PM --> PP
    PP -- local socket --> RL
    RL --> V4C
    V4C --> Proxy
    Proxy --> BP
    PP -. document view / shared memory .-> HA

A plugin is a Rust cdylib (or any language exposing the C symbols) that:

  1. Exports ocs_plugin_api_version() — returns the API major the plugin was built against.
  2. Exports ocs_plugin_register() — returns a Box<dyn BuiltinPlugin>.
  3. Implements BuiltinPlugin (manifest, ribbon, dispatch, optional REPL execution, optional notifications).

The host does not load the plugin into its own address space. It re-executes itself as --ocs-runner <cdylib-path> <token> and talks to the runner over a local socket. This isolates plugin crashes from the CAD process.

Version handshake

sequenceDiagram
    participant H as Host
    participant R as Runner process
    participant L as Plugin cdylib

    H->>R: spawn --ocs-runner path token
    R->>L: dlopen, ocs_plugin_register()
    L-->>R: Box<dyn BuiltinPlugin>
    R->>H: V3 or V4 handshake token
    H->>R: GetManifest
    R-->>H: PluginManifest { api_version }
    alt api_version accepted
        H->>R: Dispatch / ExecuteCode / InteractiveEvent ...
    else api_version rejected
        H->>R: Shutdown
    end

V2/V3 wire protocol

V2 and V3 share the same request/response framing. The host sends a HostToPlugin::Request(HostRequest::*); the runner executes it and returns a PluginToHost::Response(HostResponse::*). Nested plugin-to-host requests (for example add_entity) are allowed inside a dispatch: the runner creates a temporary HostApi proxy, forwards the PluginRequest, awaits the host reply, and resumes the original call.

sequenceDiagram
    participant H as Host
    participant R as V2/V3 Runner
    participant P as BuiltinPlugin

    H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" })
    R->>P: dispatch(host, "LINE")
    P->>R: PluginToHost::Request(AddEntity(...))
    R->>H: forward AddEntity request
    H-->>R: handle
    R-->>P: handle
    P-->>R: true
    R-->>H: PluginToHost::Response(Bool(true))

V4 wire protocol

V4 replaces the simple request/response pipe with a multiplexed local socket. The frame format supports:

  • Host → runner requests (HostToPluginV4::Request { id, payload: HostRequest })
  • Runner → host responses (PluginToHostV4::Response { id, payload: HostResponse })
  • Bi-directional, best-effort notifications (NotificationEnvelope)

Because the socket is full-duplex, the host can push HostNotification::DocumentChanged or InputLine to a plugin at any time, and the plugin can stream PluginNotification::Output/Progress/Log back while a long command is still running.

sequenceDiagram
    participant H as Host
    participant R as V4 Runner
    participant P as BuiltinPlugin

    H->>R: Request(id=7, ExecuteCode { code: "1+1", tab_index: 2 })
    R->>P: start_execute_code(host, 7, "1+1", Editor, respond)
    P-->>R: true (async)
    Note over P: spawned on worker thread
    P->>R: Notification(Output { text: "calculating..." })
    R->>H: forward Output
    P->>R: Response(id=7, CodeExecutionResult { success: true, output: "2" })
    R->>H: delayed response

V4 asynchronous REPL execution

V4 introduces BuiltinPlugin::start_execute_code. It is the plugin's entry point for REPL-style code evaluation. The signature receives the active tab's HostApi so the REPL session is tied to the document tab that invoked it, plus a respond callback that the plugin calls when evaluation finishes (possibly from a background thread).

V4 also introduces PluginRequestSender: a thread-safe, 'static interface that lets a plugin send PluginRequests back to the host from any thread. This is useful when the plugin spawns a background worker (for example, the Python REPL's mutation thread) that needs to read the document snapshot or apply mutations without blocking the runner loop. Obtain it from HostApi::plugin_request_sender(); it is only available when the plugin is hosted out-of-process by a V4 runner.

use ocs_plugin_api::host::{
    BuiltinPlugin, CommandSource, ExecutionResult, HostApi, execute_code_guard,
    PluginRequestSender,
};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};

struct ReplPlugin;

impl BuiltinPlugin for ReplPlugin {
    // ... manifest, ribbon, dispatch ...

    fn start_execute_code(
        &mut self,
        host: &mut dyn HostApi,
        _command_id: u64,
        code: &str,
        _source: CommandSource,
        respond: Box<dyn FnOnce(ExecutionResult) + Send>,
    ) -> bool {
        let tab = host.tab_index();
        let code = code.to_string();

        // A V4 host can provide a sender for background threads.
        let sender: Option<Arc<dyn PluginRequestSender>> = host.plugin_request_sender().map(Arc::from);

        execute_code_guard(respond, move || {
            // Long-running evaluation happens here without blocking the runner loop.
            // If a sender is available, worker threads can use it to call back into
            // the host, e.g. to refresh the document snapshot.
            let result = evaluate(&code, tab, sender.as_deref());
            ExecutionResult {
                success: result.is_ok(),
                output: result.ok(),
                error: result.err(),
                error_type: None,
                traceback: None,
                line_number: None,
                column_number: None,
                duration_ms: 0.0,
            }
        });
        true
    }
}
// Used from a background thread.
fn refresh_snapshot(sender: &dyn PluginRequestSender) -> Result<String, Box<dyn Error>> {
    match sender.request(PluginRequest::OpenDocumentView)? {
        PluginResponse::DocumentView { path, version } => Ok(path),
        other => Err(format!("unexpected response: {other:?}").into()),
    }
}

The host side calls it synchronously but with a long timeout:

use ocs_plugin_api::host::CommandSource;
use ocs_plugin_api::process::{PluginError, PluginProcess};

fn run_editor_snippet(
    plugin: &PluginProcess,
    host: &mut dyn HostApi,
) -> Result<String, PluginError> {
    let result = plugin.execute_code(host, 42, CommandSource::Editor, "1+1")?;
    Ok(result.output.unwrap_or_default())
}

PluginProcess::execute_code reads host.tab_index() and forwards it in the ExecuteCode request, so the plugin always knows which document tab the REPL session belongs to.

Embedded build-time metadata

ocs_plugin_api embeds two JSON blobs at build time:

  • Type registry — a stable, language-binding-friendly schema generated by tracing a curated allow-list of acadrust types with serde-reflection.
  • Version infoocs_version, ocs_plugin_api_version, acadrust_version, api_version, etc.

Access them without pulling the host feature:

let registry_json = ocs_plugin_api::get_embedded_type_registry_json();
let version_json = ocs_plugin_api::get_embedded_version_info_json();

Minimal plugin example

use ocs_plugin_api::export_plugin;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginManifest};
use ocs_plugin_api::manifest::ApiVersion;
use ocs_plugin_api::ribbon::{
    CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef,
};

static MANIFEST: PluginManifest = PluginManifest {
    id: "com.example.hello",
    name: "Hello Plugin",
    version: "0.1.0",
    description: "A minimal example plugin.",
    api_version: ApiVersion { major: 4 },
    ribbon_order: 100,
    xdata_apps: &[],
    command_prefixes: &["HELLO"],
};

struct HelloModule;
impl CadModule for HelloModule {
    fn id(&self) -> &'static str { MANIFEST.id }
    fn title(&self) -> &'static str { "Hello" }
    fn ribbon_groups(&self) -> &[RibbonGroup] {
        static GROUPS: &[RibbonGroup] = &[RibbonGroup {
            title: "Example",
            tools: vec![RibbonItem::Tool(ToolDef {
                id: "HELLO",
                label: "Say Hello",
                icon: IconKind::Glyph("H"),
                event: ModuleEvent::Command("HELLO".to_string()),
            })],
        }];
        GROUPS
    }
}

struct HelloPlugin;
impl BuiltinPlugin for HelloPlugin {
    fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
    fn ribbon(&self) -> Box<dyn CadModule> { Box::new(HelloModule) }
    fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
        if cmd == "HELLO" {
            host.push_info("Hello from the plugin!");
            return true;
        }
        false
    }
}

export_plugin!(HelloPlugin);

Host-side loading example

use std::path::Path;
use std::sync::Arc;
use ocs_plugin_api::process::{PluginError, PluginManager};

fn main() -> Result<(), PluginError> {
    let mut manager = PluginManager::new();
    manager.set_notification_handler(Arc::new(|_id, _cmd, _notif| {}));

    let mut host_api = /* host-provided HostApi implementation */;
    let _plugin_id = manager.load(
        Path::new("./target/release/libhello_plugin.dll"),
        &mut host_api,
    )?;

    let result = manager.dispatch(&mut host_api, "HELLO", |_| false);
    if result.handled {
        println!("Plugin handled HELLO");
    }

    Ok(())
}

V4 shared-memory document view example

V4 plugins can request a zero-copy snapshot of the active document and mutate it from a background thread through PluginRequestSender.

use std::sync::Arc;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginRequestSender};
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
use ocs_plugin_api::shm::DocumentViewDataV4;
use ocs_plugin_api::shm::SharedDocumentReader;

struct SnapshotWorker {
    reader: SharedDocumentReader<DocumentViewDataV4>,
    sender: Arc<dyn PluginRequestSender>,
}

impl SnapshotWorker {
    fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result<u64> {
        use acadrust::entities::Point;
        use acadrust::EntityType;

        let mut p = Point::from_coords(x, y, z);
        p.common.layer = "0".to_string();
        let entity = EntityType::Point(p);

        match self.sender.request(PluginRequest::AddEntity(entity))? {
            PluginResponse::Handle(h) => Ok(h.value()),
            other => Err(anyhow::anyhow!("unexpected add response: {other:?}")),
        }
    }
}

In the BuiltinPlugin dispatch method:

fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
    if cmd != "SNAPSHOT_DEMO" {
        return false;
    }

    let Some(view) = host.document_view_v4(host.tab_id()) else {
        host.push_error("host does not support V4 document views");
        return true;
    };

    let Some(sender) = host.plugin_request_sender() else {
        host.push_error("host does not provide a V4 request sender");
        return true;
    };

    let reader = SharedDocumentReader::<DocumentViewDataV4>::open(Path::new(&view.path))
        .expect("open snapshot");
    let worker = SnapshotWorker {
        reader,
        sender: Arc::from(sender),
    };

    std::thread::spawn(move || {
        let _ = worker.add_point(10.0, 20.0, 0.0);
    });
    true
}

V4 request proxy pattern

When a plugin spawns a child process (for example a Python REPL) that needs to mutate the host document, the plugin can run a local TCP request proxy and pass the port to the child. The child connects to the proxy; the proxy forwards PluginRequest frames to the host's PluginRequestSender and returns the responses.

use std::net::TcpListener;
use ocs_plugin_api::ipc::proxy::run_request_proxy_with_shutdown;

let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();

let sender: Arc<dyn PluginRequestSender> = Arc::from(host.plugin_request_sender().unwrap());
std::thread::spawn(move || {
    let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
    run_request_proxy_with_shutdown(listener, sender, shutdown_rx).ok();
});

// Pass `port` to the child process via environment variable.

Further reading