feat(plugins): let a plugin set its own icon (#1761)

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 <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 22:45:42 +08:00
1 джерело 2dc59ef2e2
коміт e65825e661
18 files changed
+428 -33

No files matched your search

+57 -3
Переглянути файл
@@ -54,6 +54,10 @@ pub struct Manifest {
pub change_notes: Option<String>,
/// Marketplace categories as declared (see [`Manifest::categories`]).
pub declared_categories: Vec<String>,
/// SVG shown in Settings › Plugins and the marketplace.
pub icon: Option<PathBuf>,
/// Its variant for dark themes.
pub icon_dark: Option<PathBuf>,
/// `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<String>,
#[serde(default)]
icon: Option<PathBuf>,
#[serde(default)]
icon_dark: Option<PathBuf>,
#[serde(default)]
engines: Option<Engines>,
/// 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());
+13
Переглянути файл
@@ -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<String>,
}
/// 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<String>,
/// The manifest's icon, sanitised by whoever wrote the index and again by
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
}
impl IndexEntry {
@@ -672,6 +684,7 @@ mod tests {
capabilities: Vec::new(),
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
}
}
+41 -3
Переглянути файл
@@ -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#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.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("<rect")),
"{:?}",
index.plugins[1].icon
);
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
@@ -930,6 +940,34 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
#[test]
fn the_index_carries_plugin_icons() {
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
let key = t.join("acme.key");
std::fs::write(&key, PublisherKey::from_seed([6; 32]).seed_base64()).unwrap();
let src = t.join("src");
let vars = templates::Vars { id: "acme.iconic", name: "Iconic", sdk: None };
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let manifest = std::fs::read_to_string(src.join("plugin.toml")).unwrap();
let manifest = manifest.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"");
std::fs::write(src.join("plugin.toml"), format!("icon = \"icon.svg\"\n{manifest}")).unwrap();
std::fs::write(src.join("icon.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.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("<rect"));
}
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
#[test]
fn the_command_template_builds_against_the_sdk() {
+17 -6
Переглянути файл
@@ -26,6 +26,7 @@
use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id};
use super::icon_settings::{self, SettingsSection};
use ide_extension::package::PluginIcon;
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
@@ -33,8 +34,10 @@ use std::path::PathBuf;
pub const POINT_ID: &str = "iconThemes";
const MAX_ICONS: usize = 5000;
const MAX_SVG: usize = 64 << 10;
pub(super) const MAX_SVG: usize = 64 << 10;
const MAX_MAPPINGS: usize = 20_000;
/// A plugin's own icon is inline in every plugin listing and index entry.
pub const MAX_PLUGIN_ICON: usize = 16 << 10;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -90,16 +93,24 @@ pub fn svg_prefix(theme: &str) -> 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<String, String> {
/// 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<String, String> {
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<Option<PluginIcon>, 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<UiDecl, String> {
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<UiDecl, Str
for (icon, def) in &theme.icon_definitions {
let dark = def.dark().map(|path| (path, DARK_SUFFIX));
for (path, suffix) in std::iter::once((def.light(), "")).chain(dark) {
let clean = read_icon(pkg, path)?;
let clean = read_icon(pkg, path, MAX_SVG)?;
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
+14 -3
Переглянути файл
@@ -6,9 +6,10 @@
//! Release builds pin the official publisher to the keys they were built
//! with, so no index can sign `openidea.*` plugins with another key.
use super::icons::{MAX_PLUGIN_ICON, sanitize_svg};
use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
use ide_core::{IdeError, IdeResult};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, PluginIcon, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -67,6 +68,14 @@ fn pinned(mut index: Index, official_keys: &[String]) -> 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<PluginIcon> {
// 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<Vec<u8>, 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<Vec<PathBuf>> {
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())
}
+27 -5
Переглянути файл
@@ -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<ide_extension::package::PluginIcon>,
/// 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<ide_extension::PreparedSet, String> {
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<PathBuf> = 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,
+1 -1
Переглянути файл
@@ -124,7 +124,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
impl PluginHost {
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
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())
}
+3
Переглянути файл
@@ -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<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub icon: Option<PluginIcon>,
pub source: PluginSource,
/// Package directory.
pub path: ide_core::IpcPath,
@@ -113,6 +115,7 @@ pub struct CatalogPlugin {
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub icon: Option<PluginIcon>,
/// Package directory; install with `install_from_dir(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
+2 -2
Переглянути файл
@@ -380,7 +380,7 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
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<UiDecl, Stri
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path)?;
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
+24 -1
Переглянути файл
@@ -1,7 +1,7 @@
//! Versioned installs with automatic rollback, signed packages from
//! a static marketplace index, and revocation.
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PluginIcon, Publisher, PublisherKey, Revocation};
use ide_host::plugins::*;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
@@ -169,6 +169,7 @@ fn signed(
capabilities: vec![],
engine: "^1.0".into(),
categories: vec!["tools".into()],
icon: None,
};
(out, entry)
}
@@ -509,3 +510,25 @@ fn official_plugins_need_the_key_this_ide_was_built_with() {
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
}
#[test]
fn marketplace_icons_are_sanitised_or_dropped() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let icon = |light: &str| Some(PluginIcon { light: light.into(), dark: None });
let good =
IndexEntry { icon: icon(r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#), ..entry.clone() };
let hostile = IndexEntry { id: "acme.hostile".into(), icon: icon(r#"<svg onload="x()"><rect/></svg>"#), ..entry };
let padded = format!("<svg viewBox=\"0 0 16 16\"><!--{}--><rect/></svg>", " ".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("<rect"));
assert!(by_id("acme.hostile").entry.icon.is_none(), "an unsafe icon is dropped, the entry stays");
assert!(by_id("acme.padded").entry.icon.is_none(), "an icon over 16 KiB is dropped before it is sanitised");
}
+59
Переглянути файл
@@ -668,6 +668,7 @@ fn contribution_only_plugins() {
let theme = f.install(&theme_template());
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
assert!(theme.icon.is_none());
assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night");
let lang = f.install(&sample("language-toy"));
assert_eq!(lang.status, PluginStatus::Static);
@@ -2610,3 +2611,61 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
/// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`.
fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> 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#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
const CIRCLE: &str = r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>"#;
#[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("<rect"), "{}", icon.light);
assert!(icon.dark.unwrap().contains("<circle"));
assert!(f.installed("iconic").join("icon_dark.svg").is_file());
f.host.set_enabled("iconic", false).unwrap();
assert!(f.host.info_of("iconic").unwrap().icon.is_some(), "disabled plugins keep their icon");
}
#[test]
fn invalid_plugin_icons_refuse_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let big = format!("<svg>{}</svg>", "<path d=\"M0 0h1v1z\"/>".repeat(1000));
let padded = format!("<svg><!--{}--><rect/></svg>", " ".repeat(20 << 10));
let cases = [
("scripted", "icon = \"icon.svg\"\n", "<svg><script>alert(1)</script></svg>".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}");
}
}
+10
Переглянути файл
@@ -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
+13
Переглянути файл
@@ -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,
+17 -9
Переглянути файл
@@ -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 (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
<Icon name="plugin" size={large ? 32 : 20} />
<Icon name={pluginIconName(id, icon)} size={large ? 32 : 20} />
</span>
);
}
@@ -133,7 +141,7 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
onClick={onSelect}
title={plugin.description || plugin.name}
>
<PluginIcon />
<PluginIcon id={plugin.id} icon={plugin.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{plugin.name}</span>
<span className={styles.rowMeta}>
@@ -201,7 +209,7 @@ function CatalogRow({ entry, selected, onSelect }: { entry: CatalogPlugin; selec
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon />
<PluginIcon id={entry.id} icon={entry.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowMeta}>
@@ -398,7 +406,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
return (
<div className={styles.details} aria-label={`${plugin.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={plugin.id} icon={plugin.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{plugin.name}</h2>
{plugin.authors.length > 0 && <div className={styles.authors}>{vendorOf(plugin.authors)}</div>}
@@ -514,7 +522,7 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={entry.id} icon={entry.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
{entry.authors.length > 0 && <div className={styles.authors}>{vendorOf(entry.authors)}</div>}
@@ -681,7 +689,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon />
<PluginIcon id={entry.id} icon={entry.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowMeta}>
@@ -711,7 +719,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<PluginIcon id={entry.id} icon={entry.icon} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div>
+64
Переглянути файл
@@ -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 = "") => `<svg viewBox="0 0 16 16"${root}>${shape}</svg>`;
const rendered = (name: string) => render(<Icon name={name} size={20} />).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('<rect width="16" height="16"/>'), dark: null });
expect(name).toMatch(/^ext:pluginIcon\/a\.b\//);
expect(markup(name)).toContain("<rect");
});
it("keeps two icons of the same plugin id apart (a marketplace and a local copy)", () => {
const published = pluginIconName("a.b", { light: svg('<rect width="16" height="16"/>'), dark: null });
const local = pluginIconName("a.b", { light: svg('<circle cx="8" cy="8" r="8"/>'), dark: null });
expect(local).not.toBe(published);
expect(markup(published)).toContain("<rect");
expect(markup(local)).toContain("<circle");
});
it("registers the dark variant with it", () => {
const name = pluginIconName("c.d", {
light: svg('<rect width="16" height="16"/>'),
dark: svg('<circle cx="8" cy="8" r="8"/>'),
});
expect(markup(name)).toContain("<circle");
});
it("keeps the icons of plugins whose ids differ by _dark apart", () => {
const plain = pluginIconName("g", { light: svg('<rect width="16" height="16"/>'), dark: null });
const other = pluginIconName("g_dark", { light: svg('<circle cx="8" cy="8" r="8"/>'), dark: null });
expect(markup(plain)).not.toContain("<circle");
expect(markup(other)).toContain("<circle");
});
it("gives a changed icon without a dark variant none", () => {
pluginIconName("e.f", { light: svg('<rect width="16" height="16"/>'), dark: svg('<circle cx="8" cy="8" r="8"/>') });
const name = pluginIconName("e.f", { light: svg('<rect width="16" height="16"/>'), dark: null });
expect(markup(name)).not.toContain("<circle");
});
it("keeps the root's fill and stroke, and SVG's default black fill", () => {
const filled = rendered(
pluginIconName("h.i", {
light: svg('<circle cx="8" cy="8" r="7"/>', ' 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('<circle cx="8" cy="8" r="7"/>'), dark: null }));
expect(plain.querySelector("circle")?.closest("g")?.getAttribute("fill")).toBe("#000");
});
});
+11
Переглянути файл
@@ -60,6 +60,7 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): 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: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>', dark: null };
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "iconic", name: "Iconic", icon })]));
render(<PluginsPage />);
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();
+2
Переглянути файл
@@ -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 })),
+53
Переглянути файл
@@ -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<string>();
/** 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("<svg");
const start = svg.indexOf(">", open) + 1;
const end = svg.lastIndexOf("</svg>");
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}<g${fill}${inherited}>${svg.slice(start, end)}</g></svg>`;
}
/**
* 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;
}