From 64d109a6140d6bff5afc8b9422ee5e67113aff49 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 30 Jul 2026 16:34:07 +0300 Subject: [PATCH] fix(plugins): harden marketplace loading Use public GitHub metadata to avoid unauthenticated API limits. Read bundled manifests before enabling installs and surface startup load failures. --- src/app/mod.rs | 15 ++++- src/app/update/file.rs | 12 +--- src/app/update/mod.rs | 15 +++-- src/app/view/modal.rs | 1 + src/plugin/external.rs | 13 +++- src/plugin/marketplace.rs | 107 ++++++++++++++++++++++++++++++-- src/ui/window/plugin_manager.rs | 81 +++++++++++++++++++----- 7 files changed, 203 insertions(+), 41 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index d28355a4d..b943a9678 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -523,6 +523,9 @@ pub(super) struct OpenCADStudio { /// `external_plugins` — compatible, with a library, dlopen'd at startup). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] loaded_plugin_ids: rustc_hash::FxHashSet, + /// Startup load failures keyed by plugin id. The Plugin Manager uses these + /// to distinguish an installed-but-failed package from one awaiting restart. + plugin_load_errors: rustc_hash::FxHashMap, /// Curated plugin registry fetched from the OpenCADStudio repo. plugin_registry: Vec, /// True while the curated registry request is in flight. @@ -540,7 +543,8 @@ pub(super) struct OpenCADStudio { /// Live filter for installed and available plugin cards. plugin_search_input: String, /// Installable release tags fetched per linked repo (for the dropdown). - repo_release_tags: rustc_hash::FxHashMap>, + repo_release_tags: + rustc_hash::FxHashMap>, /// The release tag currently selected per linked repo. repo_selected_tag: rustc_hash::FxHashMap, /// Repository currently shown in the Plugin Manager detail pane. @@ -2174,8 +2178,11 @@ pub enum Message { RecentThumbsLoaded( Vec<(std::path::PathBuf, Option)>, ), - /// Installable release tags fetched for `owner/repo`. - PluginReleasesFetched(String, Result, String>), + /// Installable releases and manifest API versions fetched for `owner/repo`. + PluginReleasesFetched( + String, + Result, String>, + ), /// Choose a release tag for a repo (`repo`, `tag`). PluginReleaseSelect(String, String), /// Select a plugin source and show its GitHub README in the detail pane. @@ -2696,6 +2703,7 @@ impl OpenCADStudio { disabled_plugins: rustc_hash::FxHashSet::default(), external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), + plugin_load_errors: rustc_hash::FxHashMap::default(), plugin_registry: Vec::new(), plugin_registry_loading: false, plugin_registry_error: None, @@ -2934,6 +2942,7 @@ impl OpenCADStudio { if let Err(e) = res { app.command_line .push_error(&format!("Plugin '{id}' failed to load: {e}")); + app.plugin_load_errors.insert(id, e); } } app.loaded_plugin_ids = crate::plugin::external::loaded_ids().into_iter().collect(); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 96f0fdf96..279ba226a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -273,19 +273,13 @@ impl OpenCADStudio { ) } - /// Background task: fetch `owner/repo`'s installable release tags. + /// Background task: fetch `owner/repo`'s installable releases and their + /// manifest API versions. #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task { 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::>() - }) - }, + async move { crate::plugin::marketplace::fetch_release_info(&repo) }, move |res| Message::PluginReleasesFetched(label, res), ) } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9a4e0006e..77915bc9a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4699,17 +4699,20 @@ impl OpenCADStudio { } Task::none() } - Message::PluginReleasesFetched(repo, Ok(tags)) => { - if let Some(first) = tags.first() { + Message::PluginReleasesFetched(repo, Ok(releases)) => { + if let Some(first) = releases.first() { self.repo_selected_tag .entry(repo.clone()) - .or_insert_with(|| first.clone()); + .or_insert_with(|| first.tag.clone()); } if self.marketplace_status == format!("Fetching releases for {repo}…") { self.marketplace_status = - format!("Repository added. {} installable release(s) found.", tags.len()); + format!( + "Repository added. {} installable release(s) found.", + releases.len() + ); } - self.repo_release_tags.insert(repo, tags); + self.repo_release_tags.insert(repo, releases); Task::none() } Message::PluginReleasesFetched(repo, Err(e)) => { @@ -4754,6 +4757,7 @@ impl OpenCADStudio { } Message::PluginInstalled(Ok(id)) => { self.marketplace_status = format!("Installed '{id}'. Restart to load it."); + self.plugin_load_errors.remove(&id); #[cfg(not(target_arch = "wasm32"))] { self.external_plugins = crate::plugin::external::discover(); @@ -4771,6 +4775,7 @@ impl OpenCADStudio { Ok(()) => { self.marketplace_status = format!("Uninstalled '{id}'. Restart to unload it."); + self.plugin_load_errors.remove(&id); self.external_plugins = crate::plugin::external::discover(); } Err(e) => { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index c5213cd29..4ce176dae 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -114,6 +114,7 @@ impl OpenCADStudio { &self.disabled_plugins, &self.external_plugins, &self.loaded_plugin_ids, + &self.plugin_load_errors, crate::ui::window::plugin_manager::MarketView { registry: &self.plugin_registry, registry_loading: self.plugin_registry_loading, diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 0bdd81f8a..d143d0e2b 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -25,6 +25,13 @@ pub struct RegistryEntry { pub description: String, } +/// Installable release metadata shown by the marketplace before download. +#[derive(Debug, Clone)] +pub struct ReleaseInfo { + pub tag: String, + pub api_version: u32, +} + /// An add-on package found on disk (not necessarily loaded or compatible). #[derive(Debug, Clone)] pub struct ExternalPlugin { @@ -48,7 +55,7 @@ pub struct ExternalPlugin { impl ExternalPlugin { /// True when the package's API version is supported by this host. pub fn api_compatible(&self) -> bool { - ocs_plugin_api::host_accepts_plugin_version(self.api_version) + self.api_version == ocs_plugin_api::API_VERSION } /// True when the package can be loaded today: compatible API *and* a native @@ -348,7 +355,7 @@ mod tests { use super::*; #[test] - fn api_v2_plugin_from_template_is_compatible() { + fn api_v2_plugin_from_template_is_incompatible() { let toml = r#" [plugin] id = "opencad.my_plugin" @@ -367,7 +374,7 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert_eq!(p.api_version, 2); assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin")); assert!(p.command_prefixes.contains(&"MP_".to_string())); - assert!(p.api_compatible(), "API v2 plugins must run on API v3 host"); + assert!(!p.api_compatible(), "old API plugins must be rebuilt"); } #[test] diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 6009df430..f0fb14f54 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -13,7 +13,7 @@ use std::io::Read as _; use std::path::{Path, PathBuf}; use super::external; -use super::external::RegistryEntry; +use super::external::{RegistryEntry, ReleaseInfo}; /// The curated registry, read from the OpenCADStudio repo's `main` branch. pub(crate) const REGISTRY_URL: &str = @@ -109,8 +109,68 @@ fn agent() -> ureq::Agent { const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); -/// Fetch the releases of `owner/repo` from the GitHub API. +/// Fetch releases without consuming GitHub API quota. The public Atom feed +/// supplies tags and the expanded-assets endpoint supplies download links. +/// Fall back to the API only if GitHub changes either public representation. pub fn fetch_releases(repo: &str) -> Result, String> { + match fetch_releases_public(repo) { + Ok(releases) => Ok(releases), + Err(public_error) => fetch_releases_api(repo).map_err(|api_error| { + format!( + "public release metadata failed: {public_error}; \ + GitHub API fallback failed: {api_error}" + ) + }), + } +} + +fn fetch_releases_public(repo: &str) -> Result, String> { + let atom_url = format!("https://github.com/{repo}/releases.atom"); + let atom = download_string(&atom_url)?; + let tag_marker = format!("https://github.com/{repo}/releases/tag/"); + let mut tags = atom + .lines() + .filter_map(|line| { + let start = line.find(&tag_marker)? + tag_marker.len(); + let rest = &line[start..]; + let end = rest.find('"')?; + let tag = &rest[..end]; + (!tag.is_empty()).then(|| tag.to_string()) + }) + .collect::>(); + tags.dedup(); + if tags.is_empty() { + return Err("release feed contains no tags".to_string()); + } + + let mut releases = Vec::new(); + for tag in tags { + let assets_url = format!("https://github.com/{repo}/releases/expanded_assets/{tag}"); + let html = download_string(&assets_url)?; + let asset_prefix = format!("/{repo}/releases/download/{tag}/"); + let mut assets = html + .split("href=\"") + .skip(1) + .filter_map(|tail| { + let end = tail.find('"')?; + let href = &tail[..end]; + if !href.starts_with(&asset_prefix) { + return None; + } + let name = href.rsplit('/').next()?.to_string(); + Some(Asset { + name, + url: format!("https://github.com{href}"), + }) + }) + .collect::>(); + assets.dedup_by(|left, right| left.url == right.url); + releases.push(Release { tag, assets }); + } + Ok(releases) +} + +fn fetch_releases_api(repo: &str) -> Result, String> { let url = format!("https://api.github.com/repos/{repo}/releases"); let body = agent() .get(&url) @@ -145,8 +205,46 @@ pub fn fetch_releases(repo: &str) -> Result, String> { Ok(out) } +/// Fetch installable releases and read each bundled manifest so compatibility +/// is known before the Plugin Manager offers an Install action. +pub fn fetch_release_info(repo: &str) -> Result, String> { + let releases = fetch_releases(repo)?; + let mut info = Vec::new(); + let mut last_error = None; + for release in releases.into_iter().filter(Release::installable) { + let result = (|| { + let manifest_asset = release + .toml_asset() + .ok_or_else(|| format!("release {} has no plugin.toml", release.tag))?; + let manifest_text = download_string(&manifest_asset.url)?; + let manifest = external::parse_plugin_toml(&manifest_text) + .ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?; + Ok::<_, String>(ReleaseInfo { + tag: release.tag, + api_version: manifest.api_version, + }) + })(); + match result { + Ok(release) => info.push(release), + Err(error) => last_error = Some(error), + } + } + if info.is_empty() { + Err(last_error.unwrap_or_else(|| "no installable releases found".to_string())) + } else { + Ok(info) + } +} + /// Fetch the repository README from its default branch as raw Markdown. pub fn fetch_readme(repo: &str) -> Result { + // GitHub's raw host is not subject to the unauthenticated REST API's + // 60-request/hour limit. `HEAD` resolves the repository's default branch. + let raw_url = format!("https://raw.githubusercontent.com/{repo}/HEAD/README.md"); + if let Ok(readme) = download_string(&raw_url) { + return Ok(readme); + } + let url = format!("https://api.github.com/repos/{repo}/readme"); agent() .get(&url) @@ -193,11 +291,10 @@ pub fn install(release: &Release, repository: &str) -> Result { let toml_text = download_string(&toml.url)?; let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?; - if !ocs_plugin_api::host_accepts_plugin_version(manifest.api_version) { + if manifest.api_version != ocs_plugin_api::API_VERSION { return Err(format!( - "API version {} is incompatible (host supports {}-{})", + "API version {} is incompatible (host requires {})", manifest.api_version, - ocs_plugin_api::API_VERSION_MIN_SUPPORTED, ocs_plugin_api::API_VERSION )); } diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index b0c1c2a32..a8fd66363 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -7,7 +7,7 @@ #![cfg_attr(target_arch = "wasm32", allow(dead_code))] use crate::app::Message; -use crate::plugin::external::{ExternalPlugin, RegistryEntry}; +use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo}; use iced::widget::{ button, column, container, markdown, row, rule, scrollable, text, text_input, Space, }; @@ -27,7 +27,7 @@ pub struct MarketView<'a> { pub input: &'a str, pub search: &'a str, pub repos: &'a [String], - pub release_tags: &'a FxHashMap>, + pub release_tags: &'a FxHashMap>, pub selected_tag: &'a FxHashMap, pub selected_repo: Option<&'a str>, pub readmes: &'a FxHashMap>, @@ -212,15 +212,20 @@ fn external_card<'a>( repository: Option, update_tag: Option, loaded: bool, + load_error: Option<&str>, disabled: bool, selected: bool, ) -> Element<'a, Message> { + let failed_old_api = + load_error.is_some() && p.api_version != ocs_plugin_api::API_VERSION; let (status, kind) = if loaded && disabled { ("Disabled", StatusKind::Muted) } else if loaded { ("Loaded", StatusKind::Success) - } else if !p.api_compatible() { + } else if !p.api_compatible() || failed_old_api { ("API incompatible", StatusKind::Danger) + } else if load_error.is_some() { + ("Load failed", StatusKind::Danger) } else if !p.lib_present { ("No library", StatusKind::Warning) } else { @@ -249,6 +254,26 @@ fn external_card<'a>( .style(muted_style), ); } + if let Some(error) = load_error { + let detail = if failed_old_api { + format!( + "Plugin API {} is incompatible with this build's API {} ribbon ABI. \ + Update or rebuild the plugin.", + p.api_version, + ocs_plugin_api::API_VERSION, + ) + } else { + format!("Load failed: {error}") + }; + info_body = info_body.push( + text(detail) + .size(11) + .width(Fill) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }), + ); + } let info: Element<'a, Message> = if let Some(repo) = repository.clone() { button(info_body.width(Fill)) @@ -316,11 +341,21 @@ fn pill_icon_button<'a>( /// Release dropdown + Install (+ optional unlink) for one repo. fn install_controls<'a>( repo: &str, - tags: Vec, + releases: Vec, selected: Option, removable: bool, ) -> Element<'a, Message> { let repo_s = repo.to_string(); + let tags = releases + .iter() + .map(|release| release.tag.clone()) + .collect::>(); + let selected_api = selected.as_ref().and_then(|selected| { + releases + .iter() + .find(|release| release.tag == *selected) + .map(|release| release.api_version) + }); let picker: Element<'_, Message> = if tags.is_empty() { text("no releases").size(11).style(muted_style).into() } else { @@ -330,17 +365,20 @@ fn install_controls<'a>( .text_size(12) .into() }; - let mut controls = row![ - picker, - Space::new().width(8), - pill_button( - "Install", - Message::PluginInstall(repo_s.clone()), - button::success, - ), - ] - .align_y(iced::Center) - .spacing(4); + let action = match selected_api { + Some(api_version) if api_version == ocs_plugin_api::API_VERSION => { + pill_button( + "Install", + Message::PluginInstall(repo_s.clone()), + button::success, + ) + } + Some(_) => status_badge("Incompatible", StatusKind::Danger), + None => status_badge("Unavailable", StatusKind::Muted), + }; + let mut controls = row![picker, Space::new().width(8), action] + .align_y(iced::Center) + .spacing(4); if removable { controls = controls.push(Space::new().width(6)); controls = controls.push(pill_icon_button( @@ -766,6 +804,7 @@ pub fn view_window<'a>( disabled: &FxHashSet, externals: &[ExternalPlugin], loaded: &FxHashSet, + load_errors: &FxHashMap, market: MarketView<'a>, theme: &'a Theme, sizing: crate::ui::modal::ModalSizing, @@ -796,12 +835,22 @@ pub fn view_window<'a>( let update_tag = repository .as_ref() .and_then(|repo| market.release_tags.get(repo)) - .and_then(|tags| newest_update(&p.version, tags)); + .and_then(|releases| { + let compatible_tags = releases + .iter() + .filter(|release| { + release.api_version == ocs_plugin_api::API_VERSION + }) + .map(|release| release.tag.clone()) + .collect::>(); + newest_update(&p.version, &compatible_tags) + }); list = list.push(external_card( p, repository, update_tag, loaded.contains(&p.id), + load_errors.get(&p.id).map(String::as_str), disabled.contains(&p.id), selected, ));