feat(plugin): load external cdylib plugins at runtime (phase 2)
Move BuiltinPlugin into ocs_plugin_api (host feature) so out-of-tree crates can implement it, and add export_plugin! to emit the two C symbols a cdylib exposes: ocs_plugin_api_version (checked first, so an ABI-incompatible build never runs) and ocs_plugin_register -> boxed BuiltinPlugin. The host loads every compatible package from the plugins folder at startup via libloading (desktop only), keeps the library resident for the session, and merges its ribbon tab + command dispatch into the same paths as built-ins (honouring enable/disable). The Plugin Manager shows external packages with a Loaded / incompatible status. Approach B: the plugin hands back a boxed trait object, assuming a matching toolchain + ocs_plugin_api version (the version symbol enforces the latter). crates/ocs_example_plugin is the reference cdylib. Part of the #100 extensibility epic (phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6219a9609
commit
29c71be101
14 changed files with 402 additions and 36 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -27,6 +27,7 @@ dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"inventory",
|
"inventory",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"libloading",
|
||||||
"lzma-sys",
|
"lzma-sys",
|
||||||
"ocs_plugin_api",
|
"ocs_plugin_api",
|
||||||
"open",
|
"open",
|
||||||
|
|
@ -3623,6 +3624,13 @@ dependencies = [
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ocs_example_plugin"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ocs_plugin_api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocs_plugin_api"
|
name = "ocs_plugin_api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/ocs_plugin_api"]
|
members = ["crates/ocs_plugin_api", "crates/ocs_example_plugin"]
|
||||||
|
|
||||||
# Embed the application icon into the Windows .exe so Explorer, the taskbar,
|
# Embed the application icon into the Windows .exe so Explorer, the taskbar,
|
||||||
# the Start-menu tile and file-association entries show it (issue #107).
|
# the Start-menu tile and file-association entries show it (issue #107).
|
||||||
|
|
@ -79,6 +79,8 @@ 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"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|
|
||||||
16
crates/ocs_example_plugin/Cargo.toml
Normal file
16
crates/ocs_example_plugin/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "ocs_example_plugin"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Reference external add-on for Open CAD Studio — builds a cdylib the host loads at runtime."
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
|
# A dynamically-loaded plugin is a C-ABI dynamic library. `cargo build -p
|
||||||
|
# ocs_example_plugin` produces target/<profile>/libocs_example_plugin.so (or
|
||||||
|
# .dll/.dylib); drop it beside a plugin.toml in the plugins folder.
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# The stable contract, with the runtime `host` surface (HostApi/BuiltinPlugin).
|
||||||
|
ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] }
|
||||||
37
crates/ocs_example_plugin/README.md
Normal file
37
crates/ocs_example_plugin/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# ocs_example_plugin
|
||||||
|
|
||||||
|
Reference **dynamically-loaded** add-on for Open CAD Studio. It depends only on
|
||||||
|
`ocs_plugin_api` (with the `host` feature) — never on the `OpenCADStudio`
|
||||||
|
binary — so it shows the full surface an out-of-tree plugin targets: a
|
||||||
|
`PluginManifest`, a `CadModule` ribbon tab, a `BuiltinPlugin` entry point, and
|
||||||
|
the `export_plugin!` C-ABI export.
|
||||||
|
|
||||||
|
## Build & install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build -p ocs_example_plugin # → target/debug/libocs_example_plugin.so
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the library and `plugin.toml` into a folder named after the plugin id under
|
||||||
|
the user plugins directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
<config>/OpenCADStudio/plugins/opencad.example/
|
||||||
|
plugin.toml
|
||||||
|
libocs_example_plugin.so # .dll on Windows, .dylib on macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
|
||||||
|
`$XDG_CONFIG_HOME` / `~/.config` (Linux).
|
||||||
|
|
||||||
|
Restart Open CAD Studio. The host loads the cdylib at startup (after checking
|
||||||
|
`ocs_plugin_api_version`), adds the **Example** ribbon tab, and routes `EX_`
|
||||||
|
commands to it. `PLUGINS` lists it under *External* as **Loaded**; run `EX_HELLO`
|
||||||
|
to see it respond.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
- `ocs_plugin_api::export_plugin!(MyPlugin)` emits `ocs_plugin_api_version()` and
|
||||||
|
`ocs_plugin_register()`.
|
||||||
|
- The package must be built with the **same toolchain and `ocs_plugin_api`
|
||||||
|
version** as the host (approach B — the version symbol enforces the latter).
|
||||||
13
crates/ocs_example_plugin/plugin.toml
Normal file
13
crates/ocs_example_plugin/plugin.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.example"
|
||||||
|
name = "Example Plugin"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Reference dynamically-loaded add-on"
|
||||||
|
author = "Open CAD Studio contributors"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
api_version = 1
|
||||||
|
ribbon_order = 50
|
||||||
|
command_prefixes = ["EX_"]
|
||||||
|
xdata_apps = []
|
||||||
68
crates/ocs_example_plugin/src/lib.rs
Normal file
68
crates/ocs_example_plugin/src/lib.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
//! Reference external add-on, built as a `cdylib` the host loads at runtime.
|
||||||
|
//!
|
||||||
|
//! It depends only on `ocs_plugin_api` (with the `host` feature) — never on the
|
||||||
|
//! `OpenCADStudio` binary — so it demonstrates the stable contract an
|
||||||
|
//! out-of-tree plugin targets: a `PluginManifest`, a `CadModule` ribbon tab, a
|
||||||
|
//! `BuiltinPlugin` entry point, and the `export_plugin!` C-ABI export.
|
||||||
|
|
||||||
|
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: "Reference dynamically-loaded add-on",
|
||||||
|
api_version: ApiVersion::CURRENT,
|
||||||
|
ribbon_order: 50,
|
||||||
|
xdata_apps: &[],
|
||||||
|
command_prefixes: &["EX_"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Ribbon tab for the example plugin.
|
||||||
|
struct ExampleModule;
|
||||||
|
|
||||||
|
impl CadModule for ExampleModule {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"example"
|
||||||
|
}
|
||||||
|
fn title(&self) -> &'static str {
|
||||||
|
"Example"
|
||||||
|
}
|
||||||
|
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||||
|
vec![RibbonGroup {
|
||||||
|
title: "Demo",
|
||||||
|
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||||
|
id: "EX_HELLO",
|
||||||
|
label: "Hello",
|
||||||
|
icon: IconKind::Glyph("◆"),
|
||||||
|
event: ModuleEvent::Command("EX_HELLO".to_string()),
|
||||||
|
})],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plugin entry point handed to the host.
|
||||||
|
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 from the external example plugin (cdylib loaded).");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the C-ABI symbols the host loader looks for.
|
||||||
|
ocs_plugin_api::export_plugin!(ExamplePlugin);
|
||||||
|
|
@ -16,6 +16,47 @@ use std::any::Any;
|
||||||
use acadrust::xdata::ExtendedDataRecord;
|
use acadrust::xdata::ExtendedDataRecord;
|
||||||
use acadrust::{CadDocument, EntityType, Handle};
|
use acadrust::{CadDocument, EntityType, Handle};
|
||||||
|
|
||||||
|
use crate::manifest::PluginManifest;
|
||||||
|
use crate::ribbon::CadModule;
|
||||||
|
|
||||||
|
/// An add-on package's entry point: its manifest, optional ribbon tab, and
|
||||||
|
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
|
||||||
|
/// implement the same trait from this crate, so an out-of-tree add-on targets
|
||||||
|
/// the stable contract rather than the host binary.
|
||||||
|
pub trait BuiltinPlugin: Send + Sync {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest;
|
||||||
|
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||||
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime.
|
||||||
|
///
|
||||||
|
/// Emits the two C symbols the loader looks for: `ocs_plugin_api_version`
|
||||||
|
/// (checked before anything else, so an ABI-incompatible build is rejected
|
||||||
|
/// without running its code) and `ocs_plugin_register` (constructs the plugin
|
||||||
|
/// and hands ownership to the host as a boxed trait object).
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// ocs_plugin_api::export_plugin!(MyPlugin::new());
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! export_plugin {
|
||||||
|
($ctor:expr) => {
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ocs_plugin_api_version() -> u32 {
|
||||||
|
$crate::API_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn ocs_plugin_register(
|
||||||
|
) -> *mut ::std::boxed::Box<dyn $crate::host::BuiltinPlugin> {
|
||||||
|
let plugin: ::std::boxed::Box<dyn $crate::host::BuiltinPlugin> =
|
||||||
|
::std::boxed::Box::new($ctor);
|
||||||
|
::std::boxed::Box::into_raw(::std::boxed::Box::new(plugin))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// The plugin-facing runtime surface for one active document tab.
|
/// The plugin-facing runtime surface for one active document tab.
|
||||||
pub trait HostApi {
|
pub trait HostApi {
|
||||||
/// Index of the tab this session targets.
|
/// Index of the tab this session targets.
|
||||||
|
|
|
||||||
|
|
@ -270,16 +270,29 @@ This mirrors QGIS: the application ships core menus; plugins add tabs/tools with
|
||||||
### Phase 2 — Dynamic loading (desktop)
|
### Phase 2 — Dynamic loading (desktop)
|
||||||
|
|
||||||
```
|
```
|
||||||
%APPDATA%/OpenCADStudio/plugins/
|
<config>/OpenCADStudio/plugins/
|
||||||
opencad.storm_sewer/
|
opencad.example/
|
||||||
plugin.toml
|
plugin.toml
|
||||||
opencad_storm_sewer.dll # cdylib
|
libocs_example_plugin.so # cdylib (.dll / .dylib per platform)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `libloading` + `#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTable`
|
- [x] Discover packages — scan the plugins folder, read `plugin.toml`, check for
|
||||||
- `api_version` compatibility gate at load time
|
a native library, gate on `api_version`. Surfaced in the Plugin Manager with
|
||||||
- [x] Enable/disable in settings (like QGIS plugin manager) — landed early in phase 1
|
a status pill (`src/plugin/external.rs`).
|
||||||
- Enable/disable in settings (like QGIS plugin manager)
|
- [x] Load via `libloading` — each cdylib exports two C symbols (via the
|
||||||
|
`ocs_plugin_api::export_plugin!` macro): `ocs_plugin_api_version` (checked
|
||||||
|
first, so an incompatible build never runs) and `ocs_plugin_register` →
|
||||||
|
`*mut Box<dyn BuiltinPlugin>`. Loaded once at startup; the library stays
|
||||||
|
resident for the session (ribbon tabs / dispatch hold its vtables). External
|
||||||
|
plugins merge into the same ribbon + `try_dispatch` path as built-ins and
|
||||||
|
honour the enable/disable set.
|
||||||
|
- [x] `api_version` compatibility gate at load time.
|
||||||
|
- [x] Enable/disable in settings (like QGIS plugin manager) — landed in phase 1.
|
||||||
|
|
||||||
|
**ABI approach:** the plugin hands back a boxed `BuiltinPlugin` (not a `repr(C)`
|
||||||
|
vtable). This assumes the package was built against the same toolchain and
|
||||||
|
`ocs_plugin_api` version; the version symbol enforces the latter. Reference
|
||||||
|
implementation: [`crates/ocs_example_plugin`](../crates/ocs_example_plugin).
|
||||||
|
|
||||||
### Phase 3 — Interchange & QA
|
### Phase 3 — Interchange & QA
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,11 @@ pub(super) struct OpenCADStudio {
|
||||||
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
||||||
disabled_plugins: rustc_hash::FxHashSet<String>,
|
disabled_plugins: rustc_hash::FxHashSet<String>,
|
||||||
/// External add-on packages found in the plugins folder, refreshed when the
|
/// External add-on packages found in the plugins folder, refreshed when the
|
||||||
/// Plugin Manager opens. Discovery only — not yet loaded (phase 2).
|
/// Plugin Manager opens.
|
||||||
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
||||||
|
/// Ids of external packages actually loaded this session (a subset of
|
||||||
|
/// `external_plugins` — compatible, with a library, dlopen'd at startup).
|
||||||
|
loaded_plugin_ids: rustc_hash::FxHashSet<String>,
|
||||||
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog.
|
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog.
|
||||||
point_size_buf: String,
|
point_size_buf: String,
|
||||||
/// Point Style size mode: `true` = relative to screen, `false` = absolute.
|
/// Point Style size mode: `true` = relative to screen, `false` = absolute.
|
||||||
|
|
@ -1465,6 +1468,7 @@ impl OpenCADStudio {
|
||||||
modal_dragging: false,
|
modal_dragging: false,
|
||||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||||
external_plugins: Vec::new(),
|
external_plugins: Vec::new(),
|
||||||
|
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||||
point_size_buf: String::new(),
|
point_size_buf: String::new(),
|
||||||
point_size_relative: true,
|
point_size_relative: true,
|
||||||
default_assoc_prompted: false,
|
default_assoc_prompted: false,
|
||||||
|
|
@ -1644,6 +1648,20 @@ impl OpenCADStudio {
|
||||||
if let Some(s) = settings::UserSettings::load() {
|
if let Some(s) = settings::UserSettings::load() {
|
||||||
app.apply_settings(&s);
|
app.apply_settings(&s);
|
||||||
}
|
}
|
||||||
|
// Load external plugin packages from the plugins folder once, then fold
|
||||||
|
// their ribbon tabs into the ribbon. Skipped under test/wasm.
|
||||||
|
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
|
||||||
|
{
|
||||||
|
for (id, res) in crate::plugin::external::load_at_startup() {
|
||||||
|
if let Err(e) = res {
|
||||||
|
app.command_line
|
||||||
|
.push_error(&format!("Plugin '{id}' failed to load: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.loaded_plugin_ids =
|
||||||
|
crate::plugin::external::loaded_ids().into_iter().collect();
|
||||||
|
app.rebuild_ribbon_modules();
|
||||||
|
}
|
||||||
app.last_saved_settings = Some(app.current_settings());
|
app.last_saved_settings = Some(app.current_settings());
|
||||||
app.sync_ribbon_layers();
|
app.sync_ribbon_layers();
|
||||||
app
|
app
|
||||||
|
|
|
||||||
|
|
@ -1125,6 +1125,7 @@ impl OpenCADStudio {
|
||||||
&crate::plugin::installed_manifests(),
|
&crate::plugin::installed_manifests(),
|
||||||
&self.disabled_plugins,
|
&self.disabled_plugins,
|
||||||
&self.external_plugins,
|
&self.external_plugins,
|
||||||
|
&self.loaded_plugin_ids,
|
||||||
),
|
),
|
||||||
520,
|
520,
|
||||||
460,
|
460,
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,132 @@ fn parse_string_array(s: &str) -> Vec<String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Runtime loading (desktop only) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use loader::{load, load_at_startup, loaded_ids, with_loaded, LoadedPlugin};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod loader {
|
||||||
|
use super::{lib_extension, ExternalPlugin};
|
||||||
|
use ocs_plugin_api::host::BuiltinPlugin;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A loaded external plugin. The library must outlive the boxed plugin, so
|
||||||
|
/// `plugin` is declared before `_lib` (fields drop in declaration order).
|
||||||
|
pub struct LoadedPlugin {
|
||||||
|
plugin: Box<dyn BuiltinPlugin>,
|
||||||
|
_lib: libloading::Library,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadedPlugin {
|
||||||
|
pub fn plugin(&self) -> &dyn BuiltinPlugin {
|
||||||
|
self.plugin.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
// Process-wide store of loaded external plugins. The libraries must stay
|
||||||
|
// resident for the whole session — ribbon tabs and command dispatch hold
|
||||||
|
// 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! {
|
||||||
|
static LOADED: RefCell<Vec<LoadedPlugin>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover packages and load every API-compatible one with a native
|
||||||
|
/// library into the process store. Call once at startup. Returns per-id
|
||||||
|
/// results so the host can report load failures.
|
||||||
|
pub fn load_at_startup() -> Vec<(String, Result<(), String>)> {
|
||||||
|
let discovered = super::discover();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
LOADED.with(|cell| {
|
||||||
|
let mut store = cell.borrow_mut();
|
||||||
|
if !store.is_empty() {
|
||||||
|
return; // already loaded this session
|
||||||
|
}
|
||||||
|
for d in &discovered {
|
||||||
|
if !d.api_compatible() || !d.lib_present {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match load(d) {
|
||||||
|
Ok(lp) => {
|
||||||
|
out.push((lp.id.clone(), Ok(())));
|
||||||
|
store.push(lp);
|
||||||
|
}
|
||||||
|
Err(e) => out.push((d.id.clone(), Err(e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of the plugins currently loaded in the process store.
|
||||||
|
pub fn loaded_ids() -> Vec<String> {
|
||||||
|
LOADED.with(|c| c.borrow().iter().map(|lp| lp.id.clone()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` over the loaded plugins (borrowing the store).
|
||||||
|
pub fn with_loaded<R>(f: impl FnOnce(&[LoadedPlugin]) -> R) -> R {
|
||||||
|
LOADED.with(|c| f(&c.borrow()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the native library beside `plugin.toml`, if any.
|
||||||
|
fn lib_file(dir: &Path) -> Option<PathBuf> {
|
||||||
|
let ext = lib_extension();
|
||||||
|
std::fs::read_dir(dir).ok()?.flatten().find_map(|e| {
|
||||||
|
let p = e.path();
|
||||||
|
(p.extension().and_then(|s| s.to_str()) == Some(ext)).then_some(p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a discovered package's `cdylib`, gating on the API version before
|
||||||
|
/// any of its code runs. Approach B (see `docs/plugin-architecture.md`):
|
||||||
|
/// the plugin hands back a boxed `BuiltinPlugin`; this assumes the package
|
||||||
|
/// was built against the same toolchain and `ocs_plugin_api` version, which
|
||||||
|
/// the version symbol enforces.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// 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")?;
|
||||||
|
unsafe {
|
||||||
|
let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
||||||
|
.get(b"ocs_plugin_api_version")
|
||||||
|
.map_err(|_| "missing ocs_plugin_api_version symbol".to_string())?;
|
||||||
|
let v = version();
|
||||||
|
if v != ocs_plugin_api::API_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"API version {v} != host {}",
|
||||||
|
ocs_plugin_api::API_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = register();
|
||||||
|
if raw.is_null() {
|
||||||
|
return Err("ocs_plugin_register returned null".into());
|
||||||
|
}
|
||||||
|
let plugin = *Box::from_raw(raw);
|
||||||
|
let id = plugin.manifest().id.to_string();
|
||||||
|
Ok(LoadedPlugin {
|
||||||
|
plugin,
|
||||||
|
_lib: lib,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,8 @@
|
||||||
// access) and implements the stable `HostApi` contract plugins target.
|
// access) and implements the stable `HostApi` contract plugins target.
|
||||||
|
|
||||||
pub(crate) use crate::app::plugin_host::HostSession;
|
pub(crate) use crate::app::plugin_host::HostSession;
|
||||||
/// The stable runtime surface a plugin's `dispatch` receives.
|
/// The stable contract types a plugin targets. `BuiltinPlugin` (the package
|
||||||
pub use ocs_plugin_api::host::HostApi;
|
/// entry point) and `HostApi` (the runtime surface its `dispatch` receives)
|
||||||
|
/// both live in `ocs_plugin_api` so in-tree and out-of-tree add-ons implement
|
||||||
use crate::modules::CadModule;
|
/// the same trait. See `docs/plugin-architecture.md`.
|
||||||
|
pub use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
||||||
use super::manifest::PluginManifest;
|
|
||||||
|
|
||||||
/// Add-on package entry point (phase 1: in-tree, in-process).
|
|
||||||
///
|
|
||||||
/// One `PluginRegistration` per package — ribbon tab, manifest, and command
|
|
||||||
/// dispatch are owned here. `dispatch` receives `&mut dyn HostApi` (the stable
|
|
||||||
/// `ocs_plugin_api` contract), not the host's concrete session type. See
|
|
||||||
/// `docs/plugin-architecture.md`.
|
|
||||||
pub trait BuiltinPlugin: Send + Sync {
|
|
||||||
fn manifest(&self) -> &'static PluginManifest;
|
|
||||||
fn ribbon(&self) -> Box<dyn CadModule>;
|
|
||||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
|
||||||
}
|
|
||||||
|
|
@ -44,6 +44,16 @@ pub fn ribbon_modules_enabled(
|
||||||
.filter(|p| !disabled.contains(p.manifest().id))
|
.filter(|p| !disabled.contains(p.manifest().id))
|
||||||
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
||||||
.collect();
|
.collect();
|
||||||
|
// Dynamically-loaded external plugins contribute tabs too (their libraries
|
||||||
|
// stay resident for the session, so these vtables remain valid).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
crate::plugin::external::with_loaded(|loaded| {
|
||||||
|
for lp in loaded {
|
||||||
|
if !disabled.contains(lp.id.as_str()) {
|
||||||
|
addons.push((lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
addons.sort_by_key(|(order, _)| *order);
|
addons.sort_by_key(|(order, _)| *order);
|
||||||
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||||
core
|
core
|
||||||
|
|
@ -53,12 +63,35 @@ pub fn ribbon_modules_enabled(
|
||||||
/// Disabled plugins (toggled off in the Plugin Manager) are skipped.
|
/// Disabled plugins (toggled off in the Plugin Manager) are skipped.
|
||||||
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||||
let disabled = app.disabled_plugin_ids();
|
let disabled = app.disabled_plugin_ids();
|
||||||
let mut host = HostSession::new(app, tab);
|
{
|
||||||
for plugin in all_plugins() {
|
let mut host = HostSession::new(app, tab);
|
||||||
if disabled.contains(plugin.manifest().id) {
|
for plugin in all_plugins() {
|
||||||
continue;
|
if disabled.contains(plugin.manifest().id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if plugin.dispatch(&mut host, cmd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if plugin.dispatch(&mut host, cmd) {
|
}
|
||||||
|
// Then the dynamically-loaded external plugins. The store is a separate
|
||||||
|
// borrow from `app`, so wrapping `app` in a fresh `HostSession` here is
|
||||||
|
// sound.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let handled = crate::plugin::external::with_loaded(|loaded| {
|
||||||
|
let mut host = HostSession::new(app, tab);
|
||||||
|
for lp in loaded {
|
||||||
|
if disabled.contains(lp.id.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if lp.plugin().dispatch(&mut host, cmd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
if handled {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,13 +156,15 @@ fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> {
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn external_card<'a>(p: &ExternalPlugin) -> Element<'a, Message> {
|
fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
||||||
let (status, color) = if !p.api_compatible() {
|
let (status, color) = if loaded {
|
||||||
|
("Loaded", Color { r: 0.2, g: 0.5, b: 0.3, a: 1.0 })
|
||||||
|
} else if !p.api_compatible() {
|
||||||
("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 })
|
("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 })
|
||||||
} else if !p.lib_present {
|
} else if !p.lib_present {
|
||||||
("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
|
("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
|
||||||
} else {
|
} else {
|
||||||
("Ready to load", Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 })
|
("Restart to load", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
|
||||||
};
|
};
|
||||||
let header = row![
|
let header = row![
|
||||||
text(p.name.clone()).size(15).color(WHITE),
|
text(p.name.clone()).size(15).color(WHITE),
|
||||||
|
|
@ -201,6 +203,7 @@ pub fn view_window<'a>(
|
||||||
plugins: &[&'static PluginManifest],
|
plugins: &[&'static PluginManifest],
|
||||||
disabled: &FxHashSet<String>,
|
disabled: &FxHashSet<String>,
|
||||||
externals: &[ExternalPlugin],
|
externals: &[ExternalPlugin],
|
||||||
|
loaded: &FxHashSet<String>,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let title = text("Installed Plugins").size(20).color(WHITE);
|
let title = text("Installed Plugins").size(20).color(WHITE);
|
||||||
let subtitle = text(format!(
|
let subtitle = text(format!(
|
||||||
|
|
@ -229,7 +232,7 @@ pub fn view_window<'a>(
|
||||||
.color(DIM),
|
.color(DIM),
|
||||||
);
|
);
|
||||||
for p in externals {
|
for p in externals {
|
||||||
list = list.push(external_card(p));
|
list = list.push(external_card(p, loaded.contains(&p.id)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
|
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue