feat(plugin): discover external plugin packages from the plugins folder

Phase-2 groundwork: scan <config>/OpenCADStudio/plugins/<id>/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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-17 11:43:24 +03:00
commit f6219a9609
6 changed files with 321 additions and 7 deletions

View file

@ -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<String>,
/// 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<crate::plugin::external::ExternalPlugin>,
/// 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,

View file

@ -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()
}

View file

@ -1124,6 +1124,7 @@ impl OpenCADStudio {
crate::ui::plugin_manager::view_window(
&crate::plugin::installed_manifests(),
&self.disabled_plugins,
&self.external_plugins,
),
520,
460,

236
src/plugin/external.rs Normal file
View file

@ -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
//! <config>/OpenCADStudio/plugins/
//! opencad.storm_sewer/
//! plugin.toml
//! <libopencad_storm_sewer.so | .dll | .dylib>
//! ```
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<String>,
/// 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
}
}
/// `<config>/OpenCADStudio/plugins`, matching the settings/recent-files store.
pub fn plugins_dir() -> Option<PathBuf> {
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<ExternalPlugin> {
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<ExternalPlugin> {
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<String> = 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<String> {
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());
}
}

View file

@ -4,6 +4,7 @@
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
// register here via `inventory::submit!(PluginRegistration { … })`.
pub mod external;
pub mod host;
pub mod manifest;
pub mod registry;

View file

@ -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<String>,
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]