дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
2dc59ef2e2
коміт
e65825e661
18 files changed
+428
-33
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача