forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
f6219a9609
commit
29c71be101
14 files changed
+402
-36
No files matched your search
@@ -194,6 +194,132 @@ fn parse_string_array(s: &str) -> Vec<String> {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+5
-18
@@ -2,21 +2,8 @@
|
||||
// access) and implements the stable `HostApi` contract plugins target.
|
||||
|
||||
pub(crate) use crate::app::plugin_host::HostSession;
|
||||
/// The stable runtime surface a plugin's `dispatch` receives.
|
||||
pub use ocs_plugin_api::host::HostApi;
|
||||
|
||||
use crate::modules::CadModule;
|
||||
|
||||
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;
|
||||
}
|
||||
/// The stable contract types a plugin targets. `BuiltinPlugin` (the package
|
||||
/// 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
|
||||
/// the same trait. See `docs/plugin-architecture.md`.
|
||||
pub use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
|
||||
+38
-5
@@ -44,6 +44,16 @@ pub fn ribbon_modules_enabled(
|
||||
.filter(|p| !disabled.contains(p.manifest().id))
|
||||
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
||||
.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);
|
||||
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||
core
|
||||
@@ -53,12 +63,35 @@ pub fn ribbon_modules_enabled(
|
||||
/// Disabled plugins (toggled off in the Plugin Manager) are skipped.
|
||||
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||
let disabled = app.disabled_plugin_ids();
|
||||
let mut host = HostSession::new(app, tab);
|
||||
for plugin in all_plugins() {
|
||||
if disabled.contains(plugin.manifest().id) {
|
||||
continue;
|
||||
{
|
||||
let mut host = HostSession::new(app, tab);
|
||||
for plugin in all_plugins() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user