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:
parent
5ccf5cfe36
commit
f6219a9609
6 changed files with 321 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
236
src/plugin/external.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue