From f6219a9609ac10e5d798577a54cba17b21841e1c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 11:43:24 +0300 Subject: [PATCH] feat(plugin): discover external plugin packages from the plugins folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 groundwork: scan /OpenCADStudio/plugins//plugin.toml, parse the documented keys (no new TOML dep), check for a platform native library beside it, and gate on the API version. The Plugin Manager gains an "External (plugins folder)" section showing each package with a status pill (Ready to load / API incompatible / No library). Discovery only — actually loading the cdylib is the next step. Part of the #100 extensibility epic (phase 2). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 4 + src/app/update.rs | 3 + src/app/view.rs | 1 + src/plugin/external.rs | 236 +++++++++++++++++++++++++++++++++++++++ src/plugin/mod.rs | 1 + src/ui/plugin_manager.rs | 83 ++++++++++++-- 6 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 src/plugin/external.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 14ee0ebdc..b2028a92e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -297,6 +297,9 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, + /// External add-on packages found in the plugins folder, refreshed when the + /// Plugin Manager opens. Discovery only — not yet loaded (phase 2). + external_plugins: Vec, /// PDSIZE text buffer for the Point Style (DDPTYPE) dialog. point_size_buf: String, /// Point Style size mode: `true` = relative to screen, `false` = absolute. @@ -1461,6 +1464,7 @@ impl OpenCADStudio { modal_drag_last: None, modal_dragging: false, disabled_plugins: rustc_hash::FxHashSet::default(), + external_plugins: Vec::new(), point_size_buf: String::new(), point_size_relative: true, default_assoc_prompted: false, diff --git a/src/app/update.rs b/src/app/update.rs index 9312061cd..226b7615c 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -5526,6 +5526,9 @@ impl OpenCADStudio { // ── Plugin Manager window ───────────────────────────────────── Message::PluginManagerOpen => { + // Refresh the on-disk external-plugin list each time the manager + // opens so newly dropped-in packages show up. + self.external_plugins = crate::plugin::external::discover(); self.active_modal = Some(super::ModalKind::PluginManager); Task::none() } diff --git a/src/app/view.rs b/src/app/view.rs index b1b6b7867..a6253b738 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1124,6 +1124,7 @@ impl OpenCADStudio { crate::ui::plugin_manager::view_window( &crate::plugin::installed_manifests(), &self.disabled_plugins, + &self.external_plugins, ), 520, 460, diff --git a/src/plugin/external.rs b/src/plugin/external.rs new file mode 100644 index 000000000..1c1ac325b --- /dev/null +++ b/src/plugin/external.rs @@ -0,0 +1,236 @@ +//! Phase-2 external plugin discovery. +//! +//! Scans the per-user plugins directory for installed add-on packages and +//! reads their `plugin.toml` so the host can list them and gate them on the +//! API version — *before* any native code is loaded. Actually loading the +//! `cdylib` is a separate step; this module only inspects what is on disk. +//! +//! Layout (mirrors the spec in `docs/plugin-architecture.md`): +//! ```text +//! /OpenCADStudio/plugins/ +//! opencad.storm_sewer/ +//! plugin.toml +//! +//! ``` + +use std::path::PathBuf; + +/// An add-on package found on disk (not necessarily loaded or compatible). +#[derive(Debug, Clone)] +pub struct ExternalPlugin { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub api_version: u32, + pub ribbon_order: i32, + pub command_prefixes: Vec, + /// The package directory under the plugins folder. + pub dir: PathBuf, + /// Whether a native library for this platform sits beside `plugin.toml`. + pub lib_present: bool, +} + +impl ExternalPlugin { + /// True when the package's API version matches the host ABI major. + pub fn api_compatible(&self) -> bool { + self.api_version == ocs_plugin_api::API_VERSION + } + + /// True when the package can be loaded today: compatible API *and* a native + /// library present for this platform. + pub fn loadable(&self) -> bool { + self.api_compatible() && self.lib_present + } +} + +/// `/OpenCADStudio/plugins`, matching the settings/recent-files store. +pub fn plugins_dir() -> Option { + let base: PathBuf = if cfg!(target_os = "windows") { + std::env::var_os("APPDATA").map(PathBuf::from)? + } else if cfg!(target_os = "macos") { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push("Library"); + p.push("Application Support"); + p + } else if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { + PathBuf::from(d) + } else { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push(".config"); + p + }; + let mut p = base; + p.push("OpenCADStudio"); + p.push("plugins"); + Some(p) +} + +/// Native dynamic-library extension for the current platform (no dot). +fn lib_extension() -> &'static str { + if cfg!(target_os = "windows") { + "dll" + } else if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + } +} + +/// Discover every package under the plugins directory, sorted by `ribbon_order` +/// then id. Missing directory → empty list (not an error). +pub fn discover() -> Vec { + let Some(root) = plugins_dir() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(&root) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let toml_path = dir.join("plugin.toml"); + let Ok(text) = std::fs::read_to_string(&toml_path) else { + continue; + }; + if let Some(mut p) = parse_plugin_toml(&text) { + p.lib_present = lib_present_in(&dir); + p.dir = dir; + found.push(p); + } + } + found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id))); + found +} + +/// True when a file with this platform's dynamic-library extension exists in +/// `dir` (any name — the package owns its lib naming). +fn lib_present_in(dir: &std::path::Path) -> bool { + let ext = lib_extension(); + std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .any(|e| e.path().extension().and_then(|s| s.to_str()) == Some(ext)) + }) + .unwrap_or(false) +} + +/// Minimal `plugin.toml` reader for the documented `[plugin]` / `[opencad]` +/// keys. Deliberately small (string / integer / string-array values) so the +/// host doesn't pull in a full TOML parser for a fixed, host-defined schema. +/// Returns `None` when the required `id` is missing. `dir` / `lib_present` are +/// filled in by the caller. +fn parse_plugin_toml(text: &str) -> Option { + let mut id = None; + let mut name = String::new(); + let mut version = String::new(); + let mut description = String::new(); + let mut api_version: u32 = 0; + let mut ribbon_order: i32 = 0; + let mut command_prefixes: Vec = Vec::new(); + + for raw in text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('[') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + match key { + "id" => id = Some(unquote(value)), + "name" => name = unquote(value), + "version" => version = unquote(value), + "description" => description = unquote(value), + "api_version" => api_version = value.parse().unwrap_or(0), + "ribbon_order" => ribbon_order = value.parse().unwrap_or(0), + "command_prefixes" => command_prefixes = parse_string_array(value), + _ => {} + } + } + + Some(ExternalPlugin { + id: id?, + name, + version, + description, + api_version, + ribbon_order, + command_prefixes, + dir: PathBuf::new(), + lib_present: false, + }) +} + +/// Strip surrounding single or double quotes from a TOML scalar. +fn unquote(s: &str) -> String { + let s = s.trim(); + let bytes = s.as_bytes(); + if bytes.len() >= 2 + && (bytes[0] == b'"' || bytes[0] == b'\'') + && bytes[bytes.len() - 1] == bytes[0] + { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Parse `["a", "b"]` into `["a", "b"]`. Tolerant of spacing and missing +/// brackets; ignores empty entries. +fn parse_string_array(s: &str) -> Vec { + s.trim_start_matches('[') + .trim_end_matches(']') + .split(',') + .map(unquote) + .filter(|e| !e.is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_documented_keys() { + let toml = r#" +[plugin] +id = "opencad.storm_sewer" +name = "Storm Sewer" +version = "0.2.0" +description = "Gravity storm-drain design" + +[opencad] +api_version = 1 +ribbon_order = 50 +command_prefixes = ["SS_", "STORM_"] +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert_eq!(p.id, "opencad.storm_sewer"); + assert_eq!(p.name, "Storm Sewer"); + assert_eq!(p.version, "0.2.0"); + assert_eq!(p.api_version, 1); + assert_eq!(p.ribbon_order, 50); + assert_eq!(p.command_prefixes, vec!["SS_", "STORM_"]); + assert!(p.api_compatible()); + } + + #[test] + fn missing_id_is_rejected() { + assert!(parse_plugin_toml("name = \"x\"").is_none()); + } + + #[test] + fn incompatible_api_flagged() { + let p = parse_plugin_toml("id=\"a\"\napi_version = 9999").unwrap(); + assert!(!p.api_compatible()); + assert!(!p.loadable()); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index cc76fb341..166798e31 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -4,6 +4,7 @@ // Domain plugins (e.g. storm_sewer) live under `src/modules//` and // register here via `inventory::submit!(PluginRegistration { … })`. +pub mod external; pub mod host; pub mod manifest; pub mod registry; diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs index 4770a56f7..9f21116ca 100644 --- a/src/ui/plugin_manager.rs +++ b/src/ui/plugin_manager.rs @@ -5,6 +5,7 @@ //! `docs/plugin-architecture.md`. use crate::app::Message; +use crate::plugin::external::ExternalPlugin; use crate::plugin::manifest::PluginManifest; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Color, Element, Fill, Theme}; @@ -140,9 +141,66 @@ fn plugin_card<'a>(m: &PluginManifest, disabled: bool) -> Element<'a, Message> { .into() } +/// Coloured status pill for a discovered external package. +fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> { + container(text(label.to_string()).size(11).color(WHITE)) + .padding([2, 8]) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +fn external_card<'a>(p: &ExternalPlugin) -> Element<'a, Message> { + let (status, color) = if !p.api_compatible() { + ("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 }) + } else if !p.lib_present { + ("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 }) + } else { + ("Ready to load", Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 }) + }; + let header = row![ + text(p.name.clone()).size(15).color(WHITE), + Space::new().width(8), + badge(format!("v{}", p.version)), + Space::new().width(8), + badge(format!("API {}", p.api_version)), + Space::new().width(Fill), + status_badge(status, color), + ] + .align_y(iced::Center); + + let id_line = text(p.id.clone()).size(11).color(ACCENT); + let mut body = column![header, id_line].spacing(5); + if !p.description.is_empty() { + body = body.push(text(p.description.clone()).size(12).color(DIM)); + } + if !p.command_prefixes.is_empty() { + body = body.push( + text(format!("Commands: {}", p.command_prefixes.join(", "))) + .size(11) + .color(DIM), + ); + } + container(body.padding([12, 14])) + .width(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(CARD)), + border: Border { color: BORDER, width: 1.0, radius: 6.0.into() }, + ..Default::default() + }) + .into() +} + pub fn view_window<'a>( plugins: &[&'static PluginManifest], disabled: &FxHashSet, + externals: &[ExternalPlugin], ) -> Element<'a, Message> { let title = text("Installed Plugins").size(20).color(WHITE); let subtitle = text(format!( @@ -153,17 +211,28 @@ pub fn view_window<'a>( .size(12) .color(DIM); - let body: Element<'_, Message> = if plugins.is_empty() { - container(text("No plugins installed.").size(13).color(DIM)) - .padding(20) - .into() + let mut list = column![].spacing(10); + if plugins.is_empty() { + list = list.push(text("No built-in plugins.").size(13).color(DIM)); } else { - let mut list = column![].spacing(10); for m in plugins { list = list.push(plugin_card(m, disabled.contains(m.id))); } - scrollable(list.width(Fill)).height(Fill).into() - }; + } + // External (plugins-folder) packages — discovered, not yet loaded. + if !externals.is_empty() { + list = list.push(Space::new().height(10)); + list = list.push(text("External (plugins folder)").size(13).color(ACCENT)); + list = list.push( + text("Detected on disk. Dynamic loading is not active yet.") + .size(11) + .color(DIM), + ); + for p in externals { + list = list.push(external_card(p)); + } + } + let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into(); container( column![title, subtitle, Space::new().height(12), body]