Initial commit

This commit is contained in:
Sebastian authored and Hakan Seven committed 2026-06-25 00:01:36 +03:00
1 parent 125b89d8da
commit dcfaed0182
26 files changed
+2080 -143

No files matched your search

+131 -70
View File
@@ -54,7 +54,11 @@ impl ExternalPlugin {
}
/// `<config>/OpenCADStudio/plugins`, matching the settings/recent-files store.
/// Overridable via `OCS_PLUGINS_DIR` for tests.
pub fn plugins_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("OCS_PLUGINS_DIR") {
return Some(PathBuf::from(p));
}
let base: PathBuf = if cfg!(target_os = "windows") {
std::env::var_os("APPDATA").map(PathBuf::from)?
} else if cfg!(target_os = "macos") {
@@ -217,42 +221,41 @@ fn parse_string_array(s: &str) -> Vec<String> {
// ── Runtime loading (desktop only) ──────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
pub use loader::{load_at_startup, loaded_ids, with_loaded};
pub(crate) use loader::with_loaded;
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
pub(crate) use loader::{load_at_startup, loaded_ids};
#[cfg(all(not(target_arch = "wasm32"), test))]
pub(crate) use loader::{load_at_startup, loaded_ids};
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(test, allow(dead_code))]
mod loader {
use super::{lib_extension, ExternalPlugin};
use ocs_plugin_api::host::BuiltinPlugin;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// A loaded external plugin. The library must outlive the boxed plugin, so
/// `plugin` is declared before `_lib` (fields drop in declaration order).
/// A loaded external plugin. Holds the spawned process and a shareable
/// ribbon module built from the process's cached ribbon data.
pub struct LoadedPlugin {
plugin: Box<dyn BuiltinPlugin>,
_lib: libloading::Library,
pub process: Arc<ocs_plugin_api::process::PluginProcess>,
pub module: ocs_plugin_api::ribbon::owned::SharedCadModule,
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).
// Process-wide store of spawned external plugins. The runner processes stay
// alive for the whole session; this is filled once at startup.
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>)> {
/// Discover packages and spawn every API-compatible one as a separate
/// process. Call once at startup. Returns per-id results so the host can
/// report load failures.
pub(crate) fn load_at_startup(app: &mut crate::app::OpenCADStudio) -> Vec<(String, Result<(), String>)> {
let discovered = super::discover();
let mut out = Vec::new();
LOADED.with(|cell| {
@@ -264,7 +267,7 @@ mod loader {
if !d.api_compatible() || !d.lib_present {
continue;
}
match load(d) {
match load(d, app) {
Ok(lp) => {
out.push((lp.id.clone(), Ok(())));
store.push(lp);
@@ -295,55 +298,29 @@ mod loader {
})
}
/// 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> {
/// Spawn a discovered package's `cdylib` in a separate process and cache
/// its ribbon module. The runner performs the API version gate before any
/// plugin code runs.
pub fn load(
p: &ExternalPlugin,
app: &mut crate::app::OpenCADStudio,
) -> 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())?;
// Wrap the version + register calls in a panic guard so a plugin
// that panics during load can't take down the host. (#145)
let v = crate::plugin::guard("version", || version())
.ok_or_else(|| "ocs_plugin_api_version panicked".to_string())?;
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 = crate::plugin::guard("register", || register())
.ok_or_else(|| "ocs_plugin_register panicked".to_string())?;
if raw.is_null() {
return Err("ocs_plugin_register returned null".into());
}
let plugin = *Box::from_raw(raw);
// The manifest read happens once at load; guard it too — a buggy
// manifest() that panics here would otherwise crash startup. (#145)
let id = crate::plugin::guard("manifest", || plugin.manifest().id.to_string())
.ok_or_else(|| "plugin manifest() panicked".to_string())?;
Ok(LoadedPlugin {
plugin,
_lib: lib,
id,
})
}
let mut host = crate::app::plugin_host::HostSession::new(app, 0);
let process =
ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host).map_err(|e| e.to_string())?;
let id = process.id().to_string();
let name = process.manifest().name.clone();
let module = ocs_plugin_api::ribbon::owned::to_shared_module(
id.clone(),
name,
process.ribbon().to_vec(),
);
Ok(LoadedPlugin {
process: Arc::new(process),
module,
id,
})
}
}
@@ -372,7 +349,7 @@ command_prefixes = ["SS_", "STORM_"]
assert_eq!(p.api_version, 1);
assert_eq!(p.ribbon_order, 50);
assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]);
assert!(p.api_compatible());
assert!(!p.api_compatible());
}
#[test]
@@ -386,4 +363,88 @@ command_prefixes = ["SS_", "STORM_"]
assert!(!p.api_compatible());
assert!(!p.loadable());
}
/// Integration smoke test for the out-of-process plugin path.
/// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the
/// `OpenCADStudio` binary is built; the test uses it as the runner host.
#[test]
fn spawn_and_dispatch_test_plugin() {
let path = match std::env::var_os("OCS_TEST_PLUGIN") {
Some(p) => std::path::PathBuf::from(p),
None => return,
};
if !path.exists() {
eprintln!("OCS_TEST_PLUGIN does not exist: {}", path.display());
return;
}
let host_exe = std::path::PathBuf::from(
std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.unwrap_or_else(|| std::env::current_exe().unwrap().into_os_string()),
);
assert!(host_exe.exists(), "host exe not found: {}", host_exe.display());
std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe);
let mut app = crate::app::OpenCADStudio::new_for_test();
let mut host = crate::app::plugin_host::HostSession::new(&mut app, 0);
let process = ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host)
.expect("spawn test plugin");
assert_eq!(process.id(), "opencad.my_plugin");
let mut started = false;
let handled = process
.dispatch(&mut host, "MP_HELLO", &mut |_id| {
started = true;
})
.expect("dispatch MP_HELLO");
assert!(handled, "plugin should handle MP_HELLO");
assert!(!started, "MP_HELLO is not interactive");
}
/// End-to-end test using discovery, load_at_startup, and try_dispatch.
#[test]
fn load_and_dispatch_test_plugin() {
let cdylib = match std::env::var_os("OCS_TEST_PLUGIN") {
Some(p) => std::path::PathBuf::from(p),
None => return,
};
if !cdylib.exists() {
eprintln!("OCS_TEST_PLUGIN does not exist: {}", cdylib.display());
return;
}
let host_exe = std::path::PathBuf::from(
std::env::var_os("OCS_PLUGIN_RUNNER_EXE")
.unwrap_or_else(|| std::env::current_exe().unwrap().into_os_string()),
);
assert!(host_exe.exists(), "host exe not found: {}", host_exe.display());
std::env::set_var("OCS_PLUGIN_RUNNER_EXE", &host_exe);
// Build a fake plugin package in a temp dir.
let tmp = std::env::temp_dir().join("ocs_test_plugin_package");
let _ = std::fs::remove_dir_all(&tmp);
let pkg = tmp.join("opencad.my_plugin");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::copy(&cdylib, pkg.join(cdylib.file_name().unwrap())).unwrap();
std::fs::copy(
std::path::Path::new(&cdylib).parent().unwrap().parent().unwrap().parent().unwrap().join("plugin.toml"),
pkg.join("plugin.toml"),
)
.unwrap_or_else(|_| {
// Fallback: use the template plugin.toml.
std::fs::copy(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/plugin-template/plugin.toml"),
pkg.join("plugin.toml"),
)
.unwrap()
});
std::env::set_var("OCS_PLUGINS_DIR", &tmp);
let mut app = crate::app::OpenCADStudio::new_for_test();
let results = super::external::load_at_startup(&mut app);
assert!(
results.iter().any(|(id, r)| id == "opencad.my_plugin" && r.is_ok()),
"test plugin should load: {results:?}"
);
let handled = super::try_dispatch(&mut app, 0, "MP_HELLO");
assert!(handled, "try_dispatch should handle MP_HELLO");
}
}
+41 -14
View File
@@ -24,16 +24,13 @@ pub fn ribbon_modules_enabled(
let mut addons: Vec<(i32, Box<dyn CadModule>)> = Vec::new();
crate::plugin::external::with_loaded(|loaded| {
for lp in loaded {
if disabled.contains(lp.id.as_str()) {
if disabled.contains(lp.id.as_str()) || !lp.process.is_alive() {
continue;
}
// Guard the plugin's ribbon build so a panic there can't take
// down the host — the plugin just contributes no tab. (#145)
if let Some(entry) = crate::plugin::guard("ribbon", || {
(lp.plugin().manifest().ribbon_order, lp.plugin().ribbon())
}) {
addons.push(entry);
}
addons.push((
lp.process.manifest().ribbon_order,
Box::new(lp.module.clone()) as Box<dyn CadModule>,
));
}
});
addons.sort_by_key(|(order, _)| *order);
@@ -50,22 +47,52 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo
{
use super::host::HostSession;
let disabled = app.disabled_plugin_ids();
let mut started: Option<(u64, std::sync::Arc<ocs_plugin_api::process::PluginProcess>)> = None;
let mut dead_plugins: Vec<String> = Vec::new();
let mut dispatch_errors: Vec<(String, String)> = Vec::new();
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;
}
// A panic inside the plugin's dispatch must not crash the host;
// treat a panicking plugin as "didn't handle it". (#145)
if crate::plugin::guard("dispatch", || lp.plugin().dispatch(&mut host, cmd))
.unwrap_or(false)
{
return true;
if !lp.process.is_alive() {
dead_plugins.push(lp.id.clone());
continue;
}
let process = std::sync::Arc::clone(&lp.process);
let mut start = |command_id: u64| {
started = Some((command_id, std::sync::Arc::clone(&process)));
};
match crate::plugin::guard("dispatch", || lp.process.dispatch(&mut host, cmd, &mut start)) {
Some(Ok(true)) => return true,
Some(Ok(false)) => {}
Some(Err(e)) => {
eprintln!("[plugin] dispatch error for '{}': {e}", lp.id);
dispatch_errors.push((lp.id.clone(), e.to_string()));
}
None => {
// Panic already logged by guard.
}
}
}
false
});
for id in dead_plugins {
app.push_plugin_error(&format!("Plugin '{id}' process died; skipping dispatch"));
}
for (id, err) in dispatch_errors {
app.push_plugin_error(&format!("Plugin '{id}' dispatch error: {err}"));
}
if let Some((command_id, process)) = started {
app.set_active_command(
tab,
Box::new(crate::app::plugin_host::PluginProcessInteractiveAdapter::new(
process,
command_id,
)),
);
}
if handled {
return true;
}