From e65825e6619ce63e628e8a96c0e8c7a5698f4623 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:45:42 +0100 Subject: [PATCH] feat(plugins): let a plugin set its own icon (#1761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin can set `icon` (and `icon-dark`) in plugin.toml. The SVGs go through the existing sanitiser (16 KiB each), ship with the package, and show in Settings › Plugins (list rows and detail header) and the marketplace, whose index entries carry them; the IDE sanitises index icons again and drops unsafe ones. IDEs before plugin API 1.2 refuse a manifest with `icon`, so Manifest::needs_api refuses one whose engines.openidea still admits them (check and install), keeping the index from offering such a package to older IDEs. Closes #1732 Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-extension/src/manifest.rs | 60 ++++++++++++++++- crates/ide-extension/src/package.rs | 13 ++++ .../ide-host/src/bin/openidea-plugin/main.rs | 44 ++++++++++++- crates/ide-host/src/plugins/icons.rs | 23 +++++-- crates/ide-host/src/plugins/market.rs | 17 ++++- crates/ide-host/src/plugins/mod.rs | 32 ++++++++-- crates/ide-host/src/plugins/remote_host.rs | 2 +- crates/ide-host/src/plugins/types.rs | 3 + crates/ide-host/src/plugins/ui_points.rs | 4 +- crates/ide-host/tests/distribution.rs | 25 +++++++- crates/ide-host/tests/plugins.rs | 59 +++++++++++++++++ plugins/README.md | 10 +++ src/bindings.ts | 13 ++++ src/features/plugins/PluginsPage.tsx | 26 +++++--- .../plugins/__tests__/pluginIcon.test.tsx | 64 +++++++++++++++++++ .../plugins/__tests__/plugins.test.tsx | 11 ++++ src/features/plugins/backend.ts | 2 + src/features/plugins/pluginIcon.ts | 53 +++++++++++++++ 18 files changed, 428 insertions(+), 33 deletions(-) create mode 100644 src/features/plugins/__tests__/pluginIcon.test.tsx create mode 100644 src/features/plugins/pluginIcon.ts diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 5f96ae648..55505e5e3 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -54,6 +54,10 @@ pub struct Manifest { pub change_notes: Option, /// Marketplace categories as declared (see [`Manifest::categories`]). pub declared_categories: Vec, + /// SVG shown in Settings › Plugins and the marketplace. + pub icon: Option, + /// Its variant for dark themes. + pub icon_dark: Option, /// `engines.openidea` (a semver requirement), or the legacy `openidea-api`. pub engine: String, /// Written in the 0.1 format. @@ -175,6 +179,10 @@ struct Raw { #[serde(default)] categories: Vec, #[serde(default)] + icon: Option, + #[serde(default)] + icon_dark: Option, + #[serde(default)] engines: Option, /// 0.1 format. #[serde(default)] @@ -253,6 +261,8 @@ impl Manifest { license: raw.license, change_notes: raw.change_notes, declared_categories: raw.categories, + icon: raw.icon, + icon_dark: raw.icon_dark, engine, legacy, wasm: raw.wasm, @@ -270,6 +280,16 @@ impl Manifest { Self::parse(&src).map_err(|e| format!("{}: {e}", path.display())) } + /// Refuses `what`, new in plugin API 1.`minor`, while `engines.openidea` + /// still admits an older 1.x: those IDEs reject the manifest at install. + pub fn needs_api(&self, what: &str, minor: u64) -> Result<(), String> { + let older = |m| engine_compatible_with(&self.engine, &semver::Version::new(1, m, u64::MAX)); + if self.legacy || (0..minor).any(older) { + return Err(format!("{what} is new in plugin API 1.{minor}: set engines.openidea to \"^1.{minor}\"")); + } + Ok(()) + } + fn validate(&self) -> Result<(), String> { validate_plugin_id(&self.id)?; if self.name.trim().is_empty() { @@ -291,6 +311,16 @@ impl Manifest { if let Some(wasm) = &self.wasm { check_relative(wasm, "wasm")?; } + if let Some(icon) = &self.icon { + self.needs_api("`icon`", 2)?; + check_relative(icon, "icon")?; + } + if let Some(dark) = &self.icon_dark { + if self.icon.is_none() { + return Err("`icon-dark` needs `icon`".into()); + } + check_relative(dark, "icon-dark")?; + } if self.wasm.is_none() && !self.activation.is_empty() { return Err("`activation` needs `wasm`: there is no code to activate".into()); } @@ -376,9 +406,11 @@ pub fn engine_compatible(engine: &str, legacy: bool) -> bool { if legacy { return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.')); } - let Ok(req) = semver::VersionReq::parse(engine) else { return false }; - let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version"); - req.matches(&host) + engine_compatible_with(engine, &semver::Version::parse(ENGINE_VERSION).expect("valid engine version")) +} + +fn engine_compatible_with(engine: &str, api: &semver::Version) -> bool { + semver::VersionReq::parse(engine).is_ok_and(|req| req.matches(api)) } /// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher @@ -549,6 +581,28 @@ mod tests { assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version")); } + const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n"; + + #[test] + fn parses_icons() { + let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap(); + assert_eq!(m.icon.as_deref(), Some(Path::new("icon.svg"))); + assert_eq!(m.icon_dark.as_deref(), Some(Path::new("icon_dark.svg"))); + assert_eq!(Manifest::parse(BASE).unwrap().icon, None); + } + + #[test] + fn rejects_invalid_icons() { + assert!(err(&format!("{BASE}icon-dark = \"d.svg\"\n")).contains("`icon-dark` needs `icon`")); + assert!(err(&format!("{BASE}icon = \"../x.svg\"\n")).contains("icon")); + assert!(err(&format!("{BASE}icon = \"/abs.svg\"\n")).contains("icon")); + assert!(err(&format!("{BASE}icon = \"i.svg\"\nicon-dark = \"../d.svg\"\n")).contains("icon-dark")); + for engine in ["^1.0", ">=1.1, <2"] { + let old = BASE.replace("^1.2", engine); + assert!(err(&format!("{old}icon = \"i.svg\"\n")).contains("new in plugin API 1.2"), "{engine}"); + } + } + #[test] fn ids() { assert!(validate_plugin_id("openidea.material-icons").is_ok()); diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index f1bc9c040..9f21ce632 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -314,6 +314,14 @@ pub struct Endorsement { pub signature: String, } +/// A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginIcon { + pub light: String, + pub dark: Option, +} + /// A published plugin version. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] @@ -340,6 +348,10 @@ pub struct IndexEntry { /// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. #[serde(default)] pub categories: Vec, + /// The manifest's icon, sanitised by whoever wrote the index and again by + /// the IDE before showing it. + #[serde(default)] + pub icon: Option, } impl IndexEntry { @@ -672,6 +684,7 @@ mod tests { capabilities: Vec::new(), engine: "^1.0".into(), categories: Vec::new(), + icon: None, } } diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index ef9a7fa76..811075623 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -22,7 +22,7 @@ mod templates; use ide_extension::{Manifest, validate_plugin_id}; -use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href}; +use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href}; use ide_plugin::{Limits, PluginEngine}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -650,6 +650,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> { capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), engine: package::engine_requirement(&m.engine, built_for.as_ref()), categories: m.categories(), + icon: plugin_icon(&ide_extension::Package::new(m.clone(), scratch.clone()))?, }) })(); let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?; @@ -812,20 +813,24 @@ mod tests { /// A theme package `id` signed by `publisher` with `key`, packed into `out`. fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) { - signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out); + signed_theme_version(tmp, id, "0.1.0", "^1.2", publisher, key, out); } fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) { let src = tmp.join("src").join(format!("{id}-{version}")); let vars = templates::Vars { id, name: id, sdk: None }; + // A manifest icon needs API 1.2. + let icon = if engine == "^1.2" { "\nicon = \"icon.svg\"" } else { "" }; for (rel, content) in templates::render(templates::Template::Theme, &vars) { let path = src.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let content = content .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) - .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"")); + .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"{icon}")); std::fs::write(path, content).unwrap(); } + std::fs::write(src.join("icon.svg"), r#""#) + .unwrap(); std::fs::create_dir_all(out).unwrap(); let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"] .into_iter() @@ -921,6 +926,11 @@ mod tests { let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect(); assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]); assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme"); + assert!( + index.plugins[1].icon.as_ref().is_some_and(|i| i.light.contains(""#) + .unwrap(); + let out = t.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let (src, key) = (src.to_string_lossy().into_owned(), key.to_string_lossy().into_owned()); + let package = out.join("acme.iconic-0.1.0.oiplugin").to_string_lossy().into_owned(); + cmd_pack(&strings(&[&src, "--key", &key, "--publisher", "acme", "--out", &package])).unwrap(); + cmd_index(&strings(&[out.to_str().unwrap(), "--publisher", "acme", "--key", &key])).unwrap(); + let index = Index::parse(&std::fs::read_to_string(out.join("index.json")).unwrap()).unwrap(); + assert!(index.plugins[0].icon.as_ref().expect("icon").light.contains(" String { format!("iconTheme/{theme}/svg/") } -/// An SVG icon of the package, size-checked and sanitised. -pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result { +/// An SVG icon of the package, size-checked (`max` bytes) and sanitised. +pub(super) fn read_icon(pkg: &Package, path: &std::path::Path, max: usize) -> Result { check_relative(path, "icon")?; let svg = pkg.read_text(path)?; - if svg.len() > MAX_SVG { - return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); + if svg.len() > max { + return Err(format!("{}: larger than {} KiB", path.display(), max >> 10)); } sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display())) } +/// The manifest's `icon` / `icon-dark`, read and sanitised. +pub fn plugin_icon(pkg: &Package) -> Result, String> { + let read = |path: &std::path::Path| read_icon(pkg, path, MAX_PLUGIN_ICON); + let Some(light) = &pkg.manifest.icon else { return Ok(None) }; + let dark = pkg.manifest.icon_dark.as_deref().map(read).transpose()?; + Ok(Some(PluginIcon { light: read(light)?, dark })) +} + pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { let d: Decl = decode(POINT_ID, decl)?; validate_item_id(&d.id, "icon theme id")?; @@ -166,7 +177,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result Index { index } +/// An index's icon as the UI may show it: sanitised again, as any index may +/// have written it; without a light variant that passes, none. +fn safe_icon(icon: PluginIcon) -> Option { + // Before sanitising too: an index may hold megabytes of it. + let clean = |svg: String| (svg.len() <= MAX_PLUGIN_ICON).then(|| sanitize_svg(&svg).ok()).flatten(); + Some(PluginIcon { light: clean(icon.light)?, dark: icon.dark.and_then(clean) }) +} + /// Reads `url`: `http(s)://…`, `file://…` or a local path. pub fn read_url(url: &str, max: usize) -> Result, String> { if url.starts_with("https://") || url.starts_with("http://") { @@ -242,7 +251,9 @@ impl PluginHost { .get(&entry.id) .filter(|p| p.source == PluginSource::Installed) .map(|p| p.manifest().version.clone()); - MarketPlugin { entry: entry.clone(), installed, compatible } + let mut entry = entry.clone(); + entry.icon = entry.icon.and_then(safe_icon); + MarketPlugin { entry, installed, compatible } }) .collect() } @@ -350,7 +361,7 @@ impl PluginHost { pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult> { let manifest = Manifest::load(dir).map_err(IdeError::invalid)?; let pkg = ide_extension::Package::new(manifest, dir.to_path_buf()); - let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&pkg).map_err(IdeError::invalid)?; Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect()) } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 36a48662d..78abae291 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -37,7 +37,9 @@ pub mod views; pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; -pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; +pub use icons::{ + MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix, +}; pub use market::{ INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER, read_url as read_market_url, resolve as resolve_market_href, @@ -184,6 +186,8 @@ const IDLE_BATCH: Duration = Duration::from_secs(2); struct LoadedPlugin { pkg: Package, + /// Read at load so disabled and failed plugins show it too. + icon: Option, /// The manifest failed to parse: `pkg` is a placeholder. invalid: bool, source: PluginSource, @@ -1528,7 +1532,7 @@ impl PluginHost { continue; } let pkg = Package::new(m.clone(), dir.clone()); - let problem = match self.registry.prepare(&pkg) { + let problem = match self.prepare(&pkg) { Err(e) => Some(e), Ok(_) => m .wasm @@ -1551,6 +1555,7 @@ impl PluginHost { authors: m.authors, homepage: m.homepage, change_notes: m.change_notes, + icon: plugin_icon(&pkg).ok().flatten(), path: dir.into(), problem, }); @@ -1645,11 +1650,24 @@ impl PluginHost { self.plugins.read().values().any(|p| p.dir() == dir) } + /// Validates the manifest's icon and every declaration of `pkg`; the + /// icon files join the package files the declarations reference. + fn prepare(&self, pkg: &Package) -> Result { + plugin_icon(pkg)?; + let mut set = self.registry.prepare(pkg)?; + for icon in [&pkg.manifest.icon, &pkg.manifest.icon_dark].into_iter().flatten() { + if !set.files.contains(icon) { + set.files.push(icon.clone()); + } + } + Ok(set) + } + /// Parses and validates a package directory; nothing takes effect. fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> { let manifest = Manifest::load(src).map_err(IdeError::invalid)?; let pkg = Package::new(manifest, src.to_path_buf()); - let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&pkg).map_err(IdeError::invalid)?; if let Some(wasm) = &pkg.manifest.wasm { if !src.join(wasm).is_file() { return Err(IdeError::invalid(format!( @@ -1682,6 +1700,7 @@ impl PluginHost { pkg = pkg.with_digest(digest.trim()); } let plugin = Arc::new(LoadedPlugin { + icon: plugin_icon(&pkg).ok().flatten(), pkg, invalid, source, @@ -1731,7 +1750,7 @@ impl PluginHost { let publisher = self.verified_publisher(plugin.dir()); plugin.meta.lock().publisher = publisher; } - let set = match self.registry.prepare(&plugin.pkg) { + let set = match self.prepare(&plugin.pkg) { Ok(set) => set, Err(e) => return self.set_failed(plugin, e), }; @@ -2086,7 +2105,7 @@ impl PluginHost { return h.trim().to_owned(); } // Installed before hashes were recorded: hash once and remember. - let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); + let files = self.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default(); let files: Vec = files.into_iter().chain(plugin.manifest().wasm.clone()).collect(); let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default(); let _ = std::fs::write(&file, &hash); @@ -2153,6 +2172,7 @@ impl PluginHost { authors: m.authors.clone(), homepage: m.homepage.clone(), change_notes: m.change_notes.clone(), + icon: p.icon.clone(), source: p.source, path: p.dir().to_path_buf().into(), enabled: !self.state.lock().disabled.contains(&m.id), @@ -2262,6 +2282,8 @@ fn placeholder_manifest(dir: &Path) -> Manifest { license: None, change_notes: None, declared_categories: Vec::new(), + icon: None, + icon_dark: None, engine: ide_extension::ENGINE_VERSION.into(), legacy: false, wasm: None, diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index d84667dca..ce18c9b0a 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -124,7 +124,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { impl PluginHost { fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult> { - let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?; + let set = self.prepare(&plugin.pkg).map_err(IdeError::invalid)?; Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect()) } diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 88e240c08..63bb778f9 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -1,6 +1,7 @@ //! UI-facing plugin types and the event stream. use super::theme::ThemeDef; +use ide_extension::package::PluginIcon; use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution}; pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent}; use serde::{Deserialize, Serialize}; @@ -50,6 +51,7 @@ pub struct PluginInfo { pub authors: Vec, pub homepage: Option, pub change_notes: Option, + pub icon: Option, pub source: PluginSource, /// Package directory. pub path: ide_core::IpcPath, @@ -113,6 +115,7 @@ pub struct CatalogPlugin { pub authors: Vec, pub homepage: Option, pub change_notes: Option, + pub icon: Option, /// Package directory; install with `install_from_dir(path)`. pub path: ide_core::IpcPath, pub has_code: bool, diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 135ddd7d7..f0fb8f5a4 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -380,7 +380,7 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { out.files = files; if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) { let path = std::path::PathBuf::from(icon); - let svg = super::icons::read_icon(pkg, &path)?; + let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?; out.assets.push((view_icon_asset(&d.id), svg)); out.files.push(path); } @@ -473,7 +473,7 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result"#), ..entry.clone() }; + let hostile = IndexEntry { id: "acme.hostile".into(), icon: icon(r#""#), ..entry }; + let padded = format!("", " ".repeat(20 << 10)); + let padded = IndexEntry { id: "acme.padded".into(), icon: icon(&padded), ..hostile.clone() }; + f.host.set_market_url(Some(write_index(&market, &key, vec![good, hostile, padded], vec![], false))); + + let listed = f.host.refresh_market().unwrap(); + let by_id = |id: &str| listed.iter().find(|p| p.entry.id == id).unwrap_or_else(|| panic!("{id} not listed")); + assert!(by_id("acme.hello").entry.icon.as_ref().unwrap().light.contains(" PathBuf { + let pkg = dir.join(id); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("plugin.toml"), + format!("id = \"{id}\"\nname = \"Iconic\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n{extra}"), + ) + .unwrap(); + for (name, text) in files { + std::fs::write(pkg.join(name), text).unwrap(); + } + pkg +} + +const RECT: &str = r#""#; +const CIRCLE: &str = r#""#; + +#[test] +fn plugin_icons_install_with_the_package() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = icon_package( + tmp.path(), + "iconic", + "icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n", + &[("icon.svg", RECT), ("icon_dark.svg", CIRCLE)], + ); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let icon = info.icon.expect("icon"); + assert!(icon.light.contains("{}", "".repeat(1000)); + let padded = format!("", " ".repeat(20 << 10)); + let cases = [ + ("scripted", "icon = \"icon.svg\"\n", "".to_owned(), "icon.svg"), + ("oversized", "icon = \"icon.svg\"\n", big, "16 KiB"), + ("padded", "icon = \"icon.svg\"\n", padded, "16 KiB"), + ("missing", "icon = \"missing.svg\"\n", RECT.to_owned(), "missing.svg"), + ]; + for (id, extra, svg, expected) in cases { + let pkg = icon_package(tmp.path(), id, extra, &[("icon.svg", &svg)]); + let err = f.host.preview_install(&pkg).map(|_| ()).unwrap_err().message; + assert!(err.contains(expected), "{id}: {err}"); + } +} diff --git a/plugins/README.md b/plugins/README.md index 5a2062449..7854ec9c9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -94,6 +94,8 @@ homepage = "https://example.com" # optional license = "MIT" # optional change-notes = "1.2.0 — …" # shown under What's New categories = ["tools"] # marketplace categories, see below +icon = "icon.svg" # optional, see below +icon-dark = "icon_dark.svg" # optional engines.openidea = "^1.0" # the plugin API this package needs wasm = "dist/plugin.wasm" # omit for data-only plugins activation = ["on-language:markdown"] # optional, see below @@ -116,6 +118,14 @@ icon themes or languages is filed under those, any other under `other`. Categories an IDE doesn't know, and any after the first three it knows, are ignored (`check` warns about both). +`icon` is an SVG of the package shown in Settings › Plugins and the +marketplace; `icon-dark`, if given, replaces it with dark themes. Each is at +most 16 KiB and sanitised like file icons (no scripts, external references or +`foreignObject`); an invalid one fails the package. Without `icon`, the +plugin gets the generic plugin icon. `icon` is new in API 1.2: IDEs before it +refuse the manifest, so a package with one declares `engines.openidea = "^1.2"` +(`check` and installing refuse it otherwise). + All paths are relative to the package directory and may not leave it. A manifest is validated as a whole: if one contribution is invalid, nothing is installed or loaded. Contribution types the IDE does not know (a newer API) are diff --git a/src/bindings.ts b/src/bindings.ts index 59d4b663e..7696f16ed 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2029,6 +2029,7 @@ export type CatalogPlugin = { authors: string[], homepage: string | null, changeNotes: string | null, + icon: PluginIcon | null, /** Package directory; install with `install_from_dir(path)`. */ path: IpcPath, hasCode: boolean, @@ -4423,6 +4424,11 @@ export type IndexEntry = { engine?: string, /** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */ categories?: string[], + /** + * The manifest's icon, sanitised by whoever wrote the index and again by + * the IDE before showing it. + */ + icon?: PluginIcon | null, }; export type IndexStatus = { @@ -5701,6 +5707,12 @@ export type PluginEvent = /** Open `url` (`http(s)://`) in the system browser. */ { type: "openUrl"; pluginId: string; url: string }; +/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */ +export type PluginIcon = { + light: string, + dark: string | null, +}; + export type PluginInfo = { id: string, name: string, @@ -5709,6 +5721,7 @@ export type PluginInfo = { authors: string[], homepage: string | null, changeNotes: string | null, + icon: PluginIcon | null, source: PluginSource, /** Package directory. */ path: IpcPath, diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index ee094df84..5637a1bea 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -8,7 +8,14 @@ */ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import clsx from "clsx"; -import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings"; +import type { + CapabilityInfo, + CatalogPlugin, + MarketPlugin, + PluginIcon as PluginIconData, + PluginInfo, + PluginStatus, +} from "@/bindings"; import { Badge, Button, @@ -25,6 +32,7 @@ import { T, } from "@/ui"; import { RISK_LABEL, sortByRisk } from "./consent"; +import { pluginIconName } from "./pluginIcon"; import { itemsOf, uiPoint } from "./projection"; import { compareVersions, @@ -105,10 +113,10 @@ export function catalogState( const vendorOf = (authors: readonly string[]) => authors.join(", "); -function PluginIcon({ large }: { large?: boolean }) { +function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) { return ( - + ); } @@ -133,7 +141,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele onClick={onSelect} title={plugin.description || plugin.name} > - + {plugin.name} @@ -201,7 +209,7 @@ function CatalogRow({ entry, selected, onSelect }: { entry: CatalogPlugin; selec onClick={onSelect} title={entry.description || entry.name} > - + {entry.name} @@ -398,7 +406,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) { return (
- +

{plugin.name}

{plugin.authors.length > 0 &&
{vendorOf(plugin.authors)}
} @@ -514,7 +522,7 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { return (
- +

{entry.name}

{entry.authors.length > 0 &&
{vendorOf(entry.authors)}
} @@ -681,7 +689,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte onClick={onSelect} title={entry.description || entry.name} > - + {entry.name} @@ -711,7 +719,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle return (
- +

{entry.name}

{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}
diff --git a/src/features/plugins/__tests__/pluginIcon.test.tsx b/src/features/plugins/__tests__/pluginIcon.test.tsx new file mode 100644 index 000000000..27015f991 --- /dev/null +++ b/src/features/plugins/__tests__/pluginIcon.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { Icon } from "@/ui/icons"; +import { pluginIconName } from "../pluginIcon"; + +const svg = (shape: string, root = "") => `${shape}`; +const rendered = (name: string) => render().container; +const markup = (name: string) => rendered(name).innerHTML; + +describe("pluginIconName", () => { + it("gives plugins without an icon the generic one", () => { + expect(pluginIconName("a.b", null)).toBe("plugin"); + expect(pluginIconName("a.b", undefined)).toBe("plugin"); + }); + + it("renders a plugin's icon under a name of its own", () => { + const name = pluginIconName("a.b", { light: svg(''), dark: null }); + expect(name).toMatch(/^ext:pluginIcon\/a\.b\//); + expect(markup(name)).toContain(" { + const published = pluginIconName("a.b", { light: svg(''), dark: null }); + const local = pluginIconName("a.b", { light: svg(''), dark: null }); + expect(local).not.toBe(published); + expect(markup(published)).toContain(" { + const name = pluginIconName("c.d", { + light: svg(''), + dark: svg(''), + }); + expect(markup(name)).toContain(" { + const plain = pluginIconName("g", { light: svg(''), dark: null }); + const other = pluginIconName("g_dark", { light: svg(''), dark: null }); + expect(markup(plain)).not.toContain(" { + pluginIconName("e.f", { light: svg(''), dark: svg('') }); + const name = pluginIconName("e.f", { light: svg(''), dark: null }); + expect(markup(name)).not.toContain(" { + const filled = rendered( + pluginIconName("h.i", { + light: svg('', ' fill="#E5A50A" stroke="#FFFFFF"'), + dark: null, + }), + ); + const group = filled.querySelector("circle")?.closest("g"); + expect(group?.getAttribute("fill")).toBe("#E5A50A"); + expect(group?.getAttribute("stroke")).toBe("#FFFFFF"); + const plain = rendered(pluginIconName("j.k", { light: svg(''), dark: null })); + expect(plain.querySelector("circle")?.closest("g")?.getAttribute("fill")).toBe("#000"); + }); +}); diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 7828bb399..5e0ab8a88 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -60,6 +60,7 @@ function plugin(partial: Partial & Pick): PluginIn authors: [], homepage: null, changeNotes: null, + icon: null, source: "installed", path: `/plugins/${partial.id}`, enabled: true, @@ -533,6 +534,16 @@ describe("PluginsPage", () => { act(() => useProject.setState({ project: null })); }); + it("shows a plugin's own icon in its row and details", async () => { + const icon = { light: '', dark: null }; + setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "iconic", name: "Iconic", icon })])); + render(); + const list = await screen.findByRole("list", { name: "Installed plugins" }); + await waitFor(() => expect(within(list).getByText("Iconic")).toBeInTheDocument()); + expect(list.querySelector("circle")).not.toBeNull(); + expect(screen.getByLabelText("Iconic details").querySelector("circle")).not.toBeNull(); + }); + it("restarts a failed plugin instead of disabling it", async () => { setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })])); const user = userEvent.setup(); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index e0289e15b..753bf93c4 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -300,6 +300,7 @@ function samplePlugins(): PluginInfo[] { authors: ["OpenIDEA contributors"], homepage: null, changeNotes: null, + icon: null, source: "installed" as const, enabled: true, error: null, @@ -377,6 +378,7 @@ export function sampleCatalog(): CatalogPlugin[] { authors: p.authors, homepage: p.homepage, changeNotes: `${p.version} — first release.`, + icon: null, path: `plugins/${p.id}`, hasCode: p.hasCode, capabilities: p.capabilities.map((c) => ({ ...c, granted: false })), diff --git a/src/features/plugins/pluginIcon.ts b/src/features/plugins/pluginIcon.ts new file mode 100644 index 000000000..45c54e346 --- /dev/null +++ b/src/features/plugins/pluginIcon.ts @@ -0,0 +1,53 @@ +import type { PluginIcon } from "@/bindings"; +import { registerExternalIcons } from "@/ui/icons"; + +// ponytail: never unregistered; bounded by the distinct icons seen this session. +const registered = new Set(); + +/** FNV-1a: a short key for an icon's markup. */ +function hash(text: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193); + return (h >>> 0).toString(16); +} + +const ROOT_ONLY = new Set(["viewBox", "width", "height", "id", "version"]); + +/** + * `Icon` keeps only the root's viewBox and draws on `fill="none"`: move the + * root's other attributes onto a group, with SVG's default black fill. + */ +function standalone(svg: string): string { + const open = svg.indexOf("", open) + 1; + const end = svg.lastIndexOf(""); + if (open < 0 || start <= open || end < start) return svg; + const tag = svg.slice(open, start); + const inherited = [...tag.matchAll(/\s([\w:-]+)="[^"]*"/g)] + .filter((m) => !ROOT_ONLY.has(m[1] ?? "") && !m[1]?.startsWith("xmlns")) + .map((m) => m[0]) + .join(""); + const fill = /\sfill="/.test(inherited) ? "" : ' fill="#000"'; + return `${tag}${svg.slice(start, end)}`; +} + +/** + * The icon name of a plugin's own icon, else the generic one. Names follow the + * markup, so copies of a plugin with different icons (marketplace, local) keep + * their own; plugin ids have no `/`, so no id yields another's name or `_dark`. + */ +export function pluginIconName(pluginId: string, icon: PluginIcon | null | undefined): string { + if (!icon) return "plugin"; + const name = `ext:pluginIcon/${pluginId}/${hash(`${icon.light}\n${icon.dark ?? ""}`)}`; + if (registered.has(name)) return name; + registered.add(name); + registerExternalIcons( + icon.dark + ? [ + [name, standalone(icon.light)], + [`${name}_dark`, standalone(icon.dark)], + ] + : [[name, standalone(icon.light)]], + ); + return name; +}