forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
5ccf5cfe36
commit
f6219a9609
6 files changed
+321
-7
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user