refactor(plugin): drop built-in plugins, external-only

OpenCADStudio no longer bundles plugins — every add-on is an external
cdylib loaded from the plugins folder or installed via the marketplace.

- Remove the demo plugin and the in-tree ocs_example_plugin crate.
- Remove the inventory-based built-in registration path (PluginRegistration,
  all_plugins, installed_manifests); registry now serves core ribbon tabs
  plus loaded external plugins, and try_dispatch routes only to externals.
- Plugin Manager drops the 'compiled into this build' section; the
  enable/disable toggle moves onto loaded external cards.
- Prune the now-dead HostSession/DocumentTab helpers and re-exports.

Part of the #100 extensibility epic (phase 2).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-17 15:50:52 +03:00
1 parent 020a3a4c84
commit eb53e445ab
22 files changed
+65 -585

No files matched your search

+1 -1
View File
@@ -205,7 +205,7 @@ fn parse_string_array(s: &str) -> Vec<String> {
// ── Runtime loading (desktop only) ──────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
pub use loader::{load, load_at_startup, loaded_ids, with_loaded, LoadedPlugin};
pub use loader::{load_at_startup, loaded_ids, with_loaded};
#[cfg(not(target_arch = "wasm32"))]
mod loader {
+4 -6
View File
@@ -1,9 +1,7 @@
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field
// access) and implements the stable `HostApi` contract plugins target.
pub(crate) use crate::app::plugin_host::HostSession;
/// 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};
// The session adapter the registry wraps `app` in to dispatch to plugins. The
// plugin-facing contract — `BuiltinPlugin` + `HostApi` — lives in
// `ocs_plugin_api`; external cdylibs target it directly.
pub(crate) use crate::app::plugin_host::HostSession;
-7
View File
@@ -1,7 +0,0 @@
//! Plugin identity and capability declaration.
//!
//! These types now live in the standalone, dependency-free `ocs_plugin_api`
//! crate so external add-ons can target a semver-stable contract. Re-exported
//! here to keep the `crate::plugin::manifest::*` path stable for in-tree use.
pub use ocs_plugin_api::manifest::*;
+4 -8
View File
@@ -1,15 +1,11 @@
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
//
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
// register here via `inventory::submit!(PluginRegistration { … })`.
// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the
// user plugins folder (see `external`) and installed via the marketplace; the
// host ships no built-in add-ons. See `docs/plugin-architecture.md`.
pub mod external;
pub mod host;
pub mod manifest;
pub mod marketplace;
pub mod registry;
pub use registry::{all_ribbon_modules, ribbon_modules_enabled};
pub(crate) use host::BuiltinPlugin;
pub(crate) use registry::{installed_manifests, try_dispatch};
pub(crate) use registry::try_dispatch;
+30 -147
View File
@@ -1,84 +1,47 @@
// Compile-time plugin registry via `inventory`.
// Plugin registry — external (dynamically-loaded) plugins only. OpenCADStudio
// ships no built-in add-ons; every plugin is a cdylib loaded from the plugins
// folder at startup (see `external`) and the marketplace installs them there.
use super::host::{BuiltinPlugin, HostSession};
use super::manifest::PluginManifest;
use crate::app::OpenCADStudio;
use crate::modules::{registry as core_registry, CadModule};
pub struct PluginRegistration {
pub construct: fn() -> Box<dyn BuiltinPlugin>,
}
inventory::collect!(PluginRegistration);
/// Construct every registered built-in plugin (once per process).
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
inventory::iter::<PluginRegistration>
.into_iter()
.map(|r| (r.construct)())
.collect()
}
/// Static manifest of every installed add-on, sorted by `ribbon_order` then id
/// for a stable display. Used by the plugin manager window.
pub fn installed_manifests() -> Vec<&'static PluginManifest> {
let mut manifests: Vec<&'static PluginManifest> =
all_plugins().iter().map(|p| p.manifest()).collect();
manifests.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(b.id)));
manifests
}
/// Core ribbon tabs plus *every* add-on tab (sorted by `manifest.ribbon_order`).
/// Core ribbon tabs plus every loaded external add-on tab.
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
ribbon_modules_enabled(&rustc_hash::FxHashSet::default())
}
/// Core ribbon tabs plus add-on tabs whose plugin id is **not** in `disabled`
/// (sorted by `manifest.ribbon_order`). Used by the Plugin Manager toggle.
/// Core ribbon tabs plus the tabs of loaded external plugins whose id is **not**
/// in `disabled` (sorted by `manifest.ribbon_order`).
pub fn ribbon_modules_enabled(
disabled: &rustc_hash::FxHashSet<String>,
) -> Vec<Box<dyn CadModule>> {
let mut core = core_registry::all_modules();
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
.into_iter()
.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).
// Dynamically-loaded external plugins contribute tabs (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()));
{
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()) {
addons.push((lp.plugin().manifest().ribbon_order, lp.plugin().ribbon()));
}
}
}
});
addons.sort_by_key(|(order, _)| *order);
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
});
addons.sort_by_key(|(order, _)| *order);
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
}
let _ = disabled;
core
}
/// Try each *enabled* plugin until one handles `cmd`. Returns true if handled.
/// Disabled plugins (toggled off in the Plugin Manager) are skipped.
/// Dispatch `cmd` to a loaded external plugin (skipping disabled ones).
/// Returns true if one handled it.
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;
}
if plugin.dispatch(&mut host, cmd) {
return true;
}
}
}
// 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"))]
{
use super::host::HostSession;
let disabled = app.disabled_plugin_ids();
let handled = crate::plugin::external::with_loaded(|loaded| {
let mut host = HostSession::new(app, tab);
for lp in loaded {
@@ -95,100 +58,20 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo
return true;
}
}
let _ = (app, tab, cmd);
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
#[test]
fn discovers_registered_plugins() {
let plugins = all_plugins();
assert!(
!plugins.is_empty(),
"expected at least one PluginRegistration (demo_plugin)"
);
assert!(
plugins
.iter()
.any(|p| p.manifest().id == "opencad.demo_plugin"),
"demo_plugin missing; ids: {:?}",
plugins.iter().map(|p| p.manifest().id).collect::<Vec<_>>()
);
}
#[test]
fn installed_manifests_lists_demo_plugin() {
let manifests = installed_manifests();
assert!(
manifests.iter().any(|m| m.id == "opencad.demo_plugin"),
"ids: {:?}",
manifests.iter().map(|m| m.id).collect::<Vec<_>>()
);
// Sorted by (ribbon_order, id) — verify non-decreasing order.
let mut prev: Option<(i32, &str)> = None;
for m in &manifests {
let key = (m.ribbon_order, m.id);
if let Some(p) = prev {
assert!(p <= key, "manifests not sorted: {p:?} then {key:?}");
}
prev = Some(key);
}
}
#[test]
fn addon_ribbon_tabs_merge_after_core() {
fn ribbon_is_core_only_without_external_plugins() {
// No external plugins are loaded under test, so the ribbon is exactly
// the built-in core tabs.
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Demo Plugin"), "ribbon tabs: {titles:?}");
let core = core_registry::all_modules();
assert_eq!(titles.len(), core.len() + all_plugins().len());
}
#[test]
fn disabled_plugin_drops_its_ribbon_tab() {
let mut disabled = rustc_hash::FxHashSet::default();
disabled.insert("opencad.demo_plugin".to_string());
let titles: Vec<&str> = ribbon_modules_enabled(&disabled)
.iter()
.map(|m| m.title())
.collect();
assert!(
!titles.contains(&"Demo Plugin"),
"disabled plugin still present: {titles:?}"
);
// Only the add-on tab is dropped; core tabs stay.
assert!(!titles.is_empty(), "expected core ribbon tabs");
assert_eq!(titles.len(), core_registry::all_modules().len());
}
#[test]
fn try_dispatch_routes_demo_command() {
let mut app = OpenCADStudio::new_for_test();
assert!(try_dispatch(&mut app, 0, "DP_HELLO"));
let info = app.command_history_info();
assert!(
info.iter().any(|t| t.contains("demo_plugin") && t.contains("plugin host OK")),
"info history: {info:?}"
);
}
#[test]
fn plugin_file_dialog_dispatch_preserves_case() {
// ModuleEvent::PluginFileDialog dispatches "<command> <path>" verbatim;
// the mixed-case path must reach the plugin unaltered.
let mut app = OpenCADStudio::new_for_test();
let line = "DP_IMPORT /home/User/My Points.CSV";
assert!(try_dispatch(&mut app, 0, line));
let info = app.command_history_info();
assert!(
info.iter().any(|t| t.contains("/home/User/My Points.CSV")),
"path case not preserved; info: {info:?}"
);
}
#[test]
fn unknown_plugin_command_falls_through() {
let mut app = OpenCADStudio::new_for_test();
assert!(!try_dispatch(&mut app, 0, "DP_NOPE"));
}
}
}