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>
This commit is contained in:
parent
29c71be101
commit
020a3a4c84
12 changed files with 599 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -35,6 +35,7 @@ dependencies = [
|
|||
"rayon",
|
||||
"rfd",
|
||||
"rustc-hash 2.1.2",
|
||||
"serde_json",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
"truck-polymesh",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ open = "5"
|
|||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||
# Runtime loading of external plugin cdylibs (phase 2, desktop only).
|
||||
libloading = "0.8"
|
||||
# Parse the GitHub Releases API response for the plugin marketplace.
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
|
|
|
|||
35
plugins/README.md
Normal file
35
plugins/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Plugin registry
|
||||
|
||||
`registry.json` is the curated list of third-party plugins Open CAD Studio
|
||||
offers in its **Plugin Manager → marketplace**. The app fetches it from this
|
||||
repo's `main` branch at runtime, so adding an entry makes a plugin discoverable
|
||||
to every user without an app update.
|
||||
|
||||
## Add your plugin
|
||||
|
||||
Open a pull request adding one object to the array in
|
||||
[`registry.json`](registry.json):
|
||||
|
||||
```json
|
||||
{
|
||||
"repo": "your-account/your-plugin-repo",
|
||||
"name": "Human-readable name",
|
||||
"description": "One line describing what it does."
|
||||
}
|
||||
```
|
||||
|
||||
Requirements for the linked repo:
|
||||
|
||||
- Builds a `cdylib` against [`ocs_plugin_api`](../crates/ocs_plugin_api) and
|
||||
exports the host symbols via `ocs_plugin_api::export_plugin!`.
|
||||
- Publishes per-platform binaries plus `plugin.toml` as **GitHub Release**
|
||||
assets (see `crates/ocs_example_plugin` and its release workflow for a
|
||||
template). Asset names carry the platform, e.g.
|
||||
`your.plugin-linux-x86_64.so`, `…-windows-x86_64.dll`, `…-macos-aarch64.dylib`.
|
||||
- `plugin.toml` declares an `api_version` compatible with the host.
|
||||
|
||||
The host reads the release matching the user's platform, checks the API
|
||||
version, and installs it into the user's plugins folder.
|
||||
|
||||
> Listing is curation, not endorsement or a security review. Installing a
|
||||
> plugin runs its native code; users install at their own risk.
|
||||
7
plugins/registry.json
Normal file
7
plugins/registry.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[
|
||||
{
|
||||
"repo": "HakanSeven12/opencad-example-plugin",
|
||||
"name": "Example Plugin",
|
||||
"description": "Reference dynamically-loaded add-on (ribbon tab + EX_HELLO command)."
|
||||
}
|
||||
]
|
||||
|
|
@ -303,6 +303,18 @@ pub(super) struct OpenCADStudio {
|
|||
/// Ids of external packages actually loaded this session (a subset of
|
||||
/// `external_plugins` — compatible, with a library, dlopen'd at startup).
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet<String>,
|
||||
/// Curated plugin registry fetched from the OpenCADStudio repo.
|
||||
plugin_registry: Vec<crate::plugin::external::RegistryEntry>,
|
||||
/// User-linked plugin source repos (`owner/repo`) beyond the curated list.
|
||||
plugin_repos: Vec<String>,
|
||||
/// Add-repository text field in the Plugin Manager.
|
||||
plugin_repo_input: String,
|
||||
/// Installable release tags fetched per linked repo (for the dropdown).
|
||||
repo_release_tags: rustc_hash::FxHashMap<String, Vec<String>>,
|
||||
/// The release tag currently selected per linked repo.
|
||||
repo_selected_tag: rustc_hash::FxHashMap<String, String>,
|
||||
/// Last marketplace status / error line shown in the Plugin Manager.
|
||||
marketplace_status: String,
|
||||
/// PDSIZE text buffer for the Point Style (DDPTYPE) dialog.
|
||||
point_size_buf: String,
|
||||
/// Point Style size mode: `true` = relative to screen, `false` = absolute.
|
||||
|
|
@ -1044,6 +1056,23 @@ pub enum Message {
|
|||
PluginManagerClose,
|
||||
/// Enable (`true`) or disable (`false`) the plugin with this id.
|
||||
SetPluginEnabled(String, bool),
|
||||
// ── Plugin marketplace (install from a linked repo's releases) ─────────
|
||||
/// Edit the add-repository text field.
|
||||
PluginRepoInput(String),
|
||||
/// Link the repository currently in the text field.
|
||||
PluginRepoAdd,
|
||||
/// Unlink a repository.
|
||||
PluginRepoRemove(String),
|
||||
/// The curated registry was fetched.
|
||||
PluginRegistryFetched(Result<Vec<crate::plugin::external::RegistryEntry>, String>),
|
||||
/// Installable release tags fetched for `owner/repo`.
|
||||
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
||||
/// Choose a release tag for a repo (`repo`, `tag`).
|
||||
PluginReleaseSelect(String, String),
|
||||
/// Install the selected release of `owner/repo`.
|
||||
PluginInstall(String),
|
||||
/// Result of an install: the plugin id, or an error message.
|
||||
PluginInstalled(Result<String, String>),
|
||||
// ── Point Style (DDPTYPE) dialog ──────────────────────────────────────
|
||||
/// Set the full PDMODE value from a glyph-grid cell.
|
||||
PointStyleSetMode(i16),
|
||||
|
|
@ -1469,6 +1498,12 @@ impl OpenCADStudio {
|
|||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||
external_plugins: Vec::new(),
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||
plugin_registry: Vec::new(),
|
||||
plugin_repos: Vec::new(),
|
||||
plugin_repo_input: String::new(),
|
||||
repo_release_tags: rustc_hash::FxHashMap::default(),
|
||||
repo_selected_tag: rustc_hash::FxHashMap::default(),
|
||||
marketplace_status: String::new(),
|
||||
point_size_buf: String::new(),
|
||||
point_size_relative: true,
|
||||
default_assoc_prompted: false,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ pub struct UserSettings {
|
|||
/// plugins keep their manifest listed but drop their ribbon tab and command
|
||||
/// dispatch.
|
||||
pub disabled_plugins: Vec<String>,
|
||||
/// Linked plugin source repositories (`owner/repo`) the marketplace installs
|
||||
/// from.
|
||||
pub plugin_repos: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for UserSettings {
|
||||
|
|
@ -101,6 +104,7 @@ impl Default for UserSettings {
|
|||
],
|
||||
default_assoc_prompted: false,
|
||||
disabled_plugins: Vec::new(),
|
||||
plugin_repos: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -143,6 +147,14 @@ impl UserSettings {
|
|||
.map(|t| t.to_string())
|
||||
.collect();
|
||||
}
|
||||
"plugin_repos" => {
|
||||
s.plugin_repos = val
|
||||
.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.to_string())
|
||||
.collect();
|
||||
}
|
||||
"snap_modes" => {
|
||||
let modes: Vec<SnapType> =
|
||||
val.split(',').filter_map(|t| snap_from_id(t.trim())).collect();
|
||||
|
|
@ -168,7 +180,7 @@ impl UserSettings {
|
|||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let body = format!(
|
||||
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\n",
|
||||
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\n",
|
||||
b(self.dyn_input),
|
||||
b(self.ortho),
|
||||
b(self.polar),
|
||||
|
|
@ -179,6 +191,7 @@ impl UserSettings {
|
|||
b(self.default_assoc_prompted),
|
||||
modes,
|
||||
self.disabled_plugins.join(","),
|
||||
self.plugin_repos.join(","),
|
||||
);
|
||||
let _ = std::fs::write(path, body);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ impl OpenCADStudio {
|
|||
v.sort();
|
||||
v
|
||||
},
|
||||
plugin_repos: self.plugin_repos.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +162,7 @@ impl OpenCADStudio {
|
|||
self.snapper.enabled = s.snap_modes.iter().copied().collect();
|
||||
self.default_assoc_prompted = s.default_assoc_prompted;
|
||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||
self.plugin_repos = s.plugin_repos.clone();
|
||||
self.rebuild_ribbon_modules();
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +180,58 @@ impl OpenCADStudio {
|
|||
self.disabled_plugins.clone()
|
||||
}
|
||||
|
||||
/// Background task: fetch the curated plugin registry.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fetch_registry_task(&self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async { crate::plugin::marketplace::fetch_registry() },
|
||||
Message::PluginRegistryFetched,
|
||||
)
|
||||
}
|
||||
|
||||
/// Background task: fetch `owner/repo`'s installable release tags.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn fetch_releases_task(&self, repo: String) -> Task<Message> {
|
||||
let label = repo.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
crate::plugin::marketplace::fetch_releases(&repo).map(|rs| {
|
||||
rs.into_iter()
|
||||
.filter(|r| r.installable())
|
||||
.map(|r| r.tag)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
move |res| Message::PluginReleasesFetched(label, res),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn fetch_releases_task(&self, _repo: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Background task: download and install the `tag` release of `owner/repo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn install_task(&self, repo: String, tag: String) -> Task<Message> {
|
||||
Task::perform(
|
||||
async move {
|
||||
let releases = crate::plugin::marketplace::fetch_releases(&repo)?;
|
||||
let rel = releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag == tag)
|
||||
.ok_or_else(|| format!("release {tag} not found"))?;
|
||||
crate::plugin::marketplace::install(&rel)
|
||||
},
|
||||
Message::PluginInstalled,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn install_task(&self, _repo: String, _tag: String) -> Task<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Write PDSIZE from the dialog buffer with the current relative/absolute
|
||||
/// sign. A relative size is stored negative; absolute positive. Switching to
|
||||
/// absolute with an empty/zero size seeds a positive value from the current
|
||||
|
|
@ -5530,6 +5584,19 @@ impl OpenCADStudio {
|
|||
// opens so newly dropped-in packages show up.
|
||||
self.external_plugins = crate::plugin::external::discover();
|
||||
self.active_modal = Some(super::ModalKind::PluginManager);
|
||||
// Fetch the curated registry and release lists for linked repos.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut tasks = vec![self.fetch_registry_task()];
|
||||
tasks.extend(
|
||||
self.plugin_repos
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|r| self.fetch_releases_task(r)),
|
||||
);
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginManagerClose => {
|
||||
|
|
@ -5546,6 +5613,92 @@ impl OpenCADStudio {
|
|||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginRepoInput(s) => {
|
||||
self.plugin_repo_input = s;
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginRepoAdd => {
|
||||
let repo = self
|
||||
.plugin_repo_input
|
||||
.trim()
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if repo.is_empty() || self.plugin_repos.contains(&repo) {
|
||||
return Task::none();
|
||||
}
|
||||
self.plugin_repos.push(repo.clone());
|
||||
self.plugin_repo_input.clear();
|
||||
self.persist_settings_if_changed();
|
||||
self.marketplace_status = format!("Fetching releases for {repo}…");
|
||||
self.fetch_releases_task(repo)
|
||||
}
|
||||
Message::PluginRepoRemove(repo) => {
|
||||
self.plugin_repos.retain(|r| r != &repo);
|
||||
self.repo_release_tags.remove(&repo);
|
||||
self.repo_selected_tag.remove(&repo);
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginRegistryFetched(Ok(entries)) => {
|
||||
// Fetch releases for every curated repo so the dropdowns fill in.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tasks: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|e| self.fetch_releases_task(e.repo.clone()))
|
||||
.collect();
|
||||
self.plugin_registry = entries;
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.plugin_registry = entries;
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::PluginRegistryFetched(Err(e)) => {
|
||||
self.marketplace_status = format!("Registry: {e}");
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginReleasesFetched(repo, Ok(tags)) => {
|
||||
if let Some(first) = tags.first() {
|
||||
self.repo_selected_tag
|
||||
.entry(repo.clone())
|
||||
.or_insert_with(|| first.clone());
|
||||
}
|
||||
self.marketplace_status =
|
||||
format!("{repo}: {} installable release(s)", tags.len());
|
||||
self.repo_release_tags.insert(repo, tags);
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginReleasesFetched(repo, Err(e)) => {
|
||||
self.marketplace_status = format!("{repo}: {e}");
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginReleaseSelect(repo, tag) => {
|
||||
self.repo_selected_tag.insert(repo, tag);
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginInstall(repo) => {
|
||||
let Some(tag) = self.repo_selected_tag.get(&repo).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.marketplace_status = format!("Installing {repo} {tag}…");
|
||||
self.install_task(repo, tag)
|
||||
}
|
||||
Message::PluginInstalled(Ok(id)) => {
|
||||
self.marketplace_status = format!("Installed '{id}'. Restart to load it.");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.external_plugins = crate::plugin::external::discover();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::PluginInstalled(Err(e)) => {
|
||||
self.marketplace_status = format!("Install failed: {e}");
|
||||
Task::none()
|
||||
}
|
||||
Message::PointStyleSetMode(mode) => {
|
||||
self.set_point_mode_bits(!0, mode);
|
||||
Task::none()
|
||||
|
|
|
|||
|
|
@ -1126,6 +1126,14 @@ impl OpenCADStudio {
|
|||
&self.disabled_plugins,
|
||||
&self.external_plugins,
|
||||
&self.loaded_plugin_ids,
|
||||
crate::ui::plugin_manager::MarketView {
|
||||
registry: &self.plugin_registry,
|
||||
input: &self.plugin_repo_input,
|
||||
repos: &self.plugin_repos,
|
||||
release_tags: &self.repo_release_tags,
|
||||
selected_tag: &self.repo_selected_tag,
|
||||
status: &self.marketplace_status,
|
||||
},
|
||||
),
|
||||
520,
|
||||
460,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One entry in the curated plugin registry (`plugins/registry.json`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryEntry {
|
||||
pub repo: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// An add-on package found on disk (not necessarily loaded or compatible).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExternalPlugin {
|
||||
|
|
@ -125,7 +133,7 @@ fn lib_present_in(dir: &std::path::Path) -> bool {
|
|||
/// 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> {
|
||||
pub(crate) fn parse_plugin_toml(text: &str) -> Option<ExternalPlugin> {
|
||||
let mut id = None;
|
||||
let mut name = String::new();
|
||||
let mut version = String::new();
|
||||
|
|
|
|||
198
src/plugin/marketplace.rs
Normal file
198
src/plugin/marketplace.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! 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)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
pub mod external;
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod registry;
|
||||
|
||||
pub use registry::{all_ribbon_modules, ribbon_modules_enabled};
|
||||
|
|
|
|||
|
|
@ -5,11 +5,21 @@
|
|||
//! `docs/plugin-architecture.md`.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::plugin::external::ExternalPlugin;
|
||||
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
|
||||
use crate::plugin::manifest::PluginManifest;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, Space};
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
use rustc_hash::FxHashSet;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
/// Marketplace state passed to the Plugin Manager view.
|
||||
pub struct MarketView<'a> {
|
||||
pub registry: &'a [RegistryEntry],
|
||||
pub input: &'a str,
|
||||
pub repos: &'a [String],
|
||||
pub release_tags: &'a FxHashMap<String, Vec<String>>,
|
||||
pub selected_tag: &'a FxHashMap<String, String>,
|
||||
pub status: &'a str,
|
||||
}
|
||||
|
||||
// Register the command names for autocomplete.
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
|
|
@ -199,11 +209,131 @@ fn external_card<'a>(p: &ExternalPlugin, loaded: bool) -> Element<'a, Message> {
|
|||
.into()
|
||||
}
|
||||
|
||||
fn pill_button<'a>(label: &str, msg: Message, bg: Color) -> Element<'a, Message> {
|
||||
button(text(label.to_string()).size(12).color(WHITE))
|
||||
.padding([4, 12])
|
||||
.on_press(msg)
|
||||
.style(move |_: &Theme, status| {
|
||||
let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 }
|
||||
} else {
|
||||
bg
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(c)),
|
||||
text_color: WHITE,
|
||||
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
const GREEN: Color = Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 };
|
||||
const RED: Color = Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 };
|
||||
|
||||
/// Release dropdown + Install (+ optional unlink) for one repo.
|
||||
fn install_controls<'a>(
|
||||
repo: &str,
|
||||
tags: Vec<String>,
|
||||
selected: Option<String>,
|
||||
removable: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let repo_s = repo.to_string();
|
||||
let picker: Element<'_, Message> = if tags.is_empty() {
|
||||
text("no releases").size(11).color(DIM).into()
|
||||
} else {
|
||||
let r = repo_s.clone();
|
||||
pick_list(tags, selected, move |tag| {
|
||||
Message::PluginReleaseSelect(r.clone(), tag)
|
||||
})
|
||||
.text_size(12)
|
||||
.into()
|
||||
};
|
||||
let mut controls = row![
|
||||
picker,
|
||||
Space::new().width(8),
|
||||
pill_button("Install", Message::PluginInstall(repo_s.clone()), GREEN),
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
.spacing(4);
|
||||
if removable {
|
||||
controls = controls.push(Space::new().width(6));
|
||||
controls = controls.push(pill_button("✕", Message::PluginRepoRemove(repo_s), RED));
|
||||
}
|
||||
controls.into()
|
||||
}
|
||||
|
||||
fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
|
||||
container(body.spacing(4).padding([10, 12]))
|
||||
.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()
|
||||
}
|
||||
|
||||
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
|
||||
let mut col = column![text("Available plugins").size(13).color(ACCENT)].spacing(6);
|
||||
|
||||
// Curated registry entries (from the OpenCADStudio repo).
|
||||
for e in m.registry {
|
||||
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(&e.repo).cloned();
|
||||
let header = row![
|
||||
text(e.name.clone()).size(14).color(WHITE),
|
||||
Space::new().width(Fill),
|
||||
install_controls(&e.repo, tags, selected, false),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
let mut body = column![header, text(e.repo.clone()).size(11).color(ACCENT)];
|
||||
if !e.description.is_empty() {
|
||||
body = body.push(text(e.description.clone()).size(12).color(DIM));
|
||||
}
|
||||
col = col.push(market_card(body));
|
||||
}
|
||||
|
||||
// Manual: link any repo by owner/repo.
|
||||
col = col.push(Space::new().height(6));
|
||||
col = col.push(text("Add a repository").size(12).color(DIM));
|
||||
col = col.push(
|
||||
row![
|
||||
text_input("owner/repo", m.input)
|
||||
.on_input(Message::PluginRepoInput)
|
||||
.on_submit(Message::PluginRepoAdd)
|
||||
.size(13)
|
||||
.width(Fill),
|
||||
Space::new().width(8),
|
||||
pill_button("Add", Message::PluginRepoAdd, Color { r: 0.2, g: 0.4, b: 0.62, a: 1.0 }),
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
for repo in m.repos {
|
||||
let tags = m.release_tags.get(repo).cloned().unwrap_or_default();
|
||||
let selected = m.selected_tag.get(repo).cloned();
|
||||
let header = row![
|
||||
text(repo.clone()).size(13).color(WHITE),
|
||||
Space::new().width(Fill),
|
||||
install_controls(repo, tags, selected, true),
|
||||
]
|
||||
.align_y(iced::Center);
|
||||
col = col.push(market_card(column![header]));
|
||||
}
|
||||
|
||||
if !m.status.is_empty() {
|
||||
col = col.push(text(m.status.to_string()).size(11).color(DIM));
|
||||
}
|
||||
col.into()
|
||||
}
|
||||
|
||||
pub fn view_window<'a>(
|
||||
plugins: &[&'static PluginManifest],
|
||||
disabled: &FxHashSet<String>,
|
||||
externals: &[ExternalPlugin],
|
||||
loaded: &FxHashSet<String>,
|
||||
market: MarketView,
|
||||
) -> Element<'a, Message> {
|
||||
let title = text("Installed Plugins").size(20).color(WHITE);
|
||||
let subtitle = text(format!(
|
||||
|
|
@ -235,6 +365,9 @@ pub fn view_window<'a>(
|
|||
list = list.push(external_card(p, loaded.contains(&p.id)));
|
||||
}
|
||||
}
|
||||
// Marketplace: install from a linked repository's releases.
|
||||
list = list.push(Space::new().height(14));
|
||||
list = list.push(marketplace_section(&market));
|
||||
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
|
||||
|
||||
container(
|
||||
|
|
|
|||
Loading…
Reference in a new issue