diff --git a/Cargo.lock b/Cargo.lock index adbd784c..82acaed2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "rayon", "rfd", "rustc-hash 2.1.2", + "serde_json", "truck-meshalgo", "truck-modeling", "truck-polymesh", diff --git a/Cargo.toml b/Cargo.toml index c234ae93..1a1981db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..9fee5104 --- /dev/null +++ b/plugins/README.md @@ -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. diff --git a/plugins/registry.json b/plugins/registry.json new file mode 100644 index 00000000..04879f25 --- /dev/null +++ b/plugins/registry.json @@ -0,0 +1,7 @@ +[ + { + "repo": "HakanSeven12/opencad-example-plugin", + "name": "Example Plugin", + "description": "Reference dynamically-loaded add-on (ribbon tab + EX_HELLO command)." + } +] diff --git a/src/app/mod.rs b/src/app/mod.rs index 80362374..c0004639 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, + /// Curated plugin registry fetched from the OpenCADStudio repo. + plugin_registry: Vec, + /// User-linked plugin source repos (`owner/repo`) beyond the curated list. + plugin_repos: Vec, + /// 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>, + /// The release tag currently selected per linked repo. + repo_selected_tag: rustc_hash::FxHashMap, + /// 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, String>), + /// Installable release tags fetched for `owner/repo`. + PluginReleasesFetched(String, Result, 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), // ── 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, diff --git a/src/app/settings.rs b/src/app/settings.rs index cfc980f2..c4be2e0c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -78,6 +78,9 @@ pub struct UserSettings { /// plugins keep their manifest listed but drop their ribbon tab and command /// dispatch. pub disabled_plugins: Vec, + /// Linked plugin source repositories (`owner/repo`) the marketplace installs + /// from. + pub plugin_repos: Vec, } 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 = val.split(',').filter_map(|t| snap_from_id(t.trim())).collect(); @@ -168,7 +180,7 @@ impl UserSettings { .collect::>() .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); } diff --git a/src/app/update.rs b/src/app/update.rs index 226b7615..7318f1ef 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 { + 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 { + 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::>() + }) + }, + move |res| Message::PluginReleasesFetched(label, res), + ) + } + + #[cfg(target_arch = "wasm32")] + fn fetch_releases_task(&self, _repo: String) -> Task { + 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 { + 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 { + 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() diff --git a/src/app/view.rs b/src/app/view.rs index 72a0e271..c7b37ff0 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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, diff --git a/src/plugin/external.rs b/src/plugin/external.rs index daf48c68..0bad7127 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -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 { +pub(crate) fn parse_plugin_toml(text: &str) -> Option { let mut id = None; let mut name = String::new(); let mut version = String::new(); diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs new file mode 100644 index 00000000..05831a0f --- /dev/null +++ b/src/plugin/marketplace.rs @@ -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, 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, +} + +#[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, 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 { + 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, 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 { + 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) +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 166798e3..5c978139 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -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}; diff --git a/src/ui/plugin_manager.rs b/src/ui/plugin_manager.rs index 8080b6e4..a3a18be1 100644 --- a/src/ui/plugin_manager.rs +++ b/src/ui/plugin_manager.rs @@ -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>, + pub selected_tag: &'a FxHashMap, + 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, + selected: Option, + 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, externals: &[ExternalPlugin], loaded: &FxHashSet, + 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(