Files
OneCAD-Draft/src/plugin/marketplace.rs
T
Hakan SevenandClaude Opus 4.8 020a3a4c84 feat(plugin): marketplace — install plugins from a curated registry
Add a Plugin Manager marketplace that installs external add-ons from a
GitHub repo's Releases. A curated registry (plugins/registry.json in this
repo, extended by PR) lists discoverable plugins; the host fetches it,
shows each entry with a release dropdown, and on install downloads the
asset matching the user's platform plus plugin.toml into the plugins
folder (API-version checked first). Users can also link arbitrary
owner/repo manually. Linked repos persist in settings.

Desktop only (ureq + serde_json); the install path reuses the phase-2
loader. Part of the #100 extensibility epic (phase 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:24:56 +03:00

199 lines
6.3 KiB
Rust

//! Phase-2 plugin marketplace (desktop): install external add-ons from a linked
//! GitHub repository's Releases.
//!
//! Flow: the user links an `owner/repo`; the host reads its releases, offers the
//! ones carrying a binary for this platform, and on install downloads that
//! binary plus `plugin.toml` into the plugins folder. The API-version gate runs
//! at install time (and again at load). Security (signatures, sandboxing) is
//! intentionally out of scope here — the user vouches for the repos they link.
#![cfg(not(target_arch = "wasm32"))]
use std::io::Read as _;
use super::external;
use super::external::RegistryEntry;
/// The curated registry, read from the OpenCADStudio repo's `main` branch.
const REGISTRY_URL: &str =
"https://raw.githubusercontent.com/HakanSeven12/OpenCADStudio/main/plugins/registry.json";
/// Fetch the curated plugin registry (`plugins/registry.json`).
pub fn fetch_registry() -> Result<Vec<RegistryEntry>, String> {
let body = download_string(REGISTRY_URL)?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let arr = json.as_array().ok_or("registry is not a JSON array")?;
Ok(arr
.iter()
.filter_map(|e| {
let repo = e["repo"].as_str()?.to_string();
Some(RegistryEntry {
repo,
name: e["name"].as_str().unwrap_or_default().to_string(),
description: e["description"].as_str().unwrap_or_default().to_string(),
})
})
.collect())
}
/// One release of a linked repo.
#[derive(Debug, Clone)]
pub struct Release {
pub tag: String,
pub assets: Vec<Asset>,
}
#[derive(Debug, Clone)]
pub struct Asset {
pub name: String,
pub url: String,
}
impl Release {
/// The platform-matching native library asset, if the release has one.
fn lib_asset(&self) -> Option<&Asset> {
let ext = external_lib_ext();
let suffix = format!("{}.{ext}", platform_suffix());
self.assets
.iter()
.find(|a| a.name.ends_with(&suffix))
.or_else(|| self.assets.iter().find(|a| a.name.ends_with(&format!(".{ext}"))))
}
fn toml_asset(&self) -> Option<&Asset> {
self.assets.iter().find(|a| a.name == "plugin.toml")
}
/// True when this release ships an installable package for this platform.
pub fn installable(&self) -> bool {
self.lib_asset().is_some() && self.toml_asset().is_some()
}
}
/// `os-arch` tag used in release asset names, e.g. `linux-x86_64`.
fn platform_suffix() -> String {
let os = if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
format!("{os}-{arch}")
}
/// Native dynamic-library extension for this platform (no dot).
fn external_lib_ext() -> &'static str {
if cfg!(target_os = "windows") {
"dll"
} else if cfg!(target_os = "macos") {
"dylib"
} else {
"so"
}
}
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(15)))
.build()
.into()
}
const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION"));
/// Fetch the releases of `owner/repo` from the GitHub API.
pub fn fetch_releases(repo: &str) -> Result<Vec<Release>, String> {
let url = format!("https://api.github.com/repos/{repo}/releases");
let body = agent()
.get(&url)
.header("User-Agent", UA)
.header("Accept", "application/vnd.github+json")
.call()
.map_err(|e| e.to_string())?
.body_mut()
.read_to_string()
.map_err(|e| e.to_string())?;
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let arr = json.as_array().ok_or("unexpected releases response")?;
let mut out = Vec::new();
for r in arr {
let tag = r["tag_name"].as_str().unwrap_or_default().to_string();
let assets = r["assets"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|asset| {
let name = asset["name"].as_str()?.to_string();
let url = asset["browser_download_url"].as_str()?.to_string();
Some(Asset { name, url })
})
.collect()
})
.unwrap_or_default();
if !tag.is_empty() {
out.push(Release { tag, assets });
}
}
Ok(out)
}
fn download_string(url: &str) -> Result<String, String> {
agent()
.get(url)
.header("User-Agent", UA)
.call()
.map_err(|e| e.to_string())?
.body_mut()
.read_to_string()
.map_err(|e| e.to_string())
}
fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
agent()
.get(url)
.header("User-Agent", UA)
.call()
.map_err(|e| e.to_string())?
.body_mut()
.as_reader()
.read_to_end(&mut buf)
.map_err(|e| e.to_string())?;
Ok(buf)
}
/// Download and install a release's package into the plugins folder. Verifies
/// the API version from the package's `plugin.toml` first. Returns the plugin
/// id on success.
pub fn install(release: &Release) -> Result<String, String> {
let lib = release.lib_asset().ok_or("no library for this platform")?;
let toml = release.toml_asset().ok_or("release has no plugin.toml")?;
let toml_text = download_string(&toml.url)?;
let manifest =
external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?;
if manifest.api_version != ocs_plugin_api::API_VERSION {
return Err(format!(
"API version {} is incompatible (host is {})",
manifest.api_version,
ocs_plugin_api::API_VERSION
));
}
let dir = external::plugins_dir()
.ok_or("cannot locate the plugins folder")?
.join(&manifest.id);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let bytes = download_bytes(&lib.url)?;
std::fs::write(dir.join(&lib.name), bytes).map_err(|e| e.to_string())?;
std::fs::write(dir.join("plugin.toml"), toml_text).map_err(|e| e.to_string())?;
Ok(manifest.id)
}