//! The plugin host: assembles the extension //! registry (`ide-extension`), the grant ledger and the Wasm runtime //! (`ide-plugin`). It runs where the project is — in the desktop app for local //! projects, in `openidea-server` for remote ones; the desktop shell only adapts //! it to Tauri commands. //! //! Lifecycle of a package: its manifest is parsed and every declaration //! validated by its extension point (no side effects); the grant ledger decides //! whether it may load (project and new plugins wait for the user's consent); //! then its contributions are committed all or nothing. Code is instantiated //! only when one of its activation events fires (a command runs, or start-up //! finished for `on-startup-finished`). //! //! Sources: `//` (installed), `/.openidea/plugins/*/` //! (project plugins, loaded by [`PluginHost::sync_project`] for trusted //! projects) and folders linked with `openidea-plugin link` (`links.json`). mod api; pub mod commit; mod hover; mod icon_extensions; mod icon_patterns; mod icon_settings; mod icons; pub mod index_ops; mod inlay_hints; mod install; pub mod intellij; mod market; pub mod navigation; mod points; mod providers; pub mod remote; pub mod remote_host; mod run_detectors; mod semantic_tokens; mod theme; mod types; pub mod ui_points; pub mod views; pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; pub(crate) use hover::merge as merge_hover; 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(crate) use inlay_hints::merge as merge_inlay_hints; 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, }; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; pub use providers::{ProviderKind, set_editor_providers}; pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for}; pub(crate) use semantic_tokens::merge as merge_semantic_tokens; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; use ide_core::{IdeError, IdeResult, LanguageId}; use ide_extension::ledger::{self, BinaryGrant, Decision, Ledger, Request, Subject}; use ide_extension::{ ActivationEvent, Capability, CapabilityInfo, ContributionIndex, ExtensionRegistry, MANIFEST_FILE, Manifest, Package, UiContribution, UiStore, }; use ide_language::plugin_point::PluginLanguages; use ide_language::{LanguageConfig, ProgramPin}; use ide_plugin::{ Actor, ActorError, CANCELLED, CallError, HostApi, HostError, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup, }; use parking_lot::{Condvar, Mutex, RwLock}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant}; const STATE_FILE: &str = "state.json"; const LINKS_FILE: &str = "links.json"; /// Project plugins live here, relative to a project root. pub const DEV_PLUGINS_DIR: &str = ".openidea/plugins"; #[derive(Default, Serialize, Deserialize)] struct PersistedState { #[serde(default)] disabled: BTreeSet, #[serde(default)] theme: Option, /// Marketplace index URL (or folder path). #[serde(default, skip_serializing_if = "Option::is_none")] market_url: Option, } #[derive(Clone, PartialEq, Serialize, Deserialize)] struct ThemeRef { plugin: String, theme: String, /// The theme's window frame colour as the UI resolved it (`#rrggbb[aa]`), so /// new windows open in it before the plugin is loaded. #[serde(default, skip_serializing_if = "Option::is_none")] frame: Option, } #[derive(Default, Serialize, Deserialize)] struct Links { #[serde(default)] paths: Vec, } struct Meta { status: PluginStatus, error: Option, warnings: Vec, /// Capabilities the ledger granted (valid while live). granted: Vec, /// Required capabilities still awaiting consent (`NeedsConsent`). pending: Vec, /// Capabilities the declarations need beyond the manifest (`process:` of a language server). declared_extra: Vec, stats: PluginStats, /// Durations of the recent calls (ms), for the p95. latencies: VecDeque, /// Consecutive crashes; reset by a call that returns. failures: u32, /// No restart before this (backoff after a crash). retry_at: Option, /// The component exports `syntax-provider` (known once activated). has_syntax: bool, /// Next `on-schedule` activation. next_schedule: Option, /// The trusted publisher of a signed installed package. publisher: Option, } impl Meta { fn new() -> Self { Self { status: PluginStatus::Disabled, error: None, warnings: Vec::new(), granted: Vec::new(), pending: Vec::new(), declared_extra: Vec::new(), stats: PluginStats::default(), latencies: VecDeque::new(), failures: 0, retry_at: None, has_syntax: false, publisher: None, next_schedule: None, } } fn record_call(&mut self, elapsed: Duration, ok: bool) { let ms = elapsed.as_secs_f64() * 1000.0; self.stats.calls += 1; self.stats.max_call_ms = self.stats.max_call_ms.max(ms); if !ok { self.stats.errors += 1; } if self.latencies.len() == LATENCY_WINDOW { self.latencies.pop_front(); } self.latencies.push_back(ms as f32); } fn p95(&self) -> f64 { if self.latencies.is_empty() { return 0.0; } let mut sorted: Vec = self.latencies.iter().copied().collect(); sorted.sort_by(f32::total_cmp); f64::from(sorted[(sorted.len() * 95).div_ceil(100).saturating_sub(1)]) } } /// Calls kept for the latency percentile. const LATENCY_WINDOW: usize = 128; /// Events waiting for one plugin (from one window). struct EventQueue { since: Instant, events: Vec, } /// Most events queued per plugin and window; older ones are dropped. const MAX_QUEUED_EVENTS: usize = 500; /// How long events for a plugin that is not running are collected before it /// is activated to receive them. 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, /// Project that owns a project plugin. dev_root: Option, meta: Mutex, /// The running instance, on its own thread. runtime: Mutex>>, /// Event batches handed to the actor and not finished yet. batches: Arc>, } #[derive(Default)] struct Batches { /// Posted, not finished. pending: u32, /// Kinds of the batch being handled right now. running: Option>, } impl LoadedPlugin { /// Statistics of one call; a call that returned (`healthy`) ends a crash streak. fn record_call(&self, elapsed: Duration, ok: bool, healthy: bool) { let mut meta = self.meta.lock(); meta.record_call(elapsed, ok); if healthy && meta.failures > 0 { meta.failures = 0; meta.retry_at = None; meta.error = None; } } fn id(&self) -> &str { self.pkg.id() } fn manifest(&self) -> &Manifest { &self.pkg.manifest } fn dir(&self) -> &Path { &self.pkg.dir } fn status(&self) -> PluginStatus { self.meta.lock().status } fn subject(&self) -> Subject { match self.source { PluginSource::Installed => Subject::Installed(self.id().to_owned()), PluginSource::Dev | PluginSource::Linked => Subject::Path(self.dir().to_path_buf()), } } } /// The user's answer on an install or consent page. #[derive(Clone, Debug, Default, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct Consent { /// Optional capabilities the user accepted (`net:crates.io`, …). Required /// ones are implied by consenting at all. pub optional: Vec, /// The user accepted a package signed by another key than the installed /// copy ([`InstallPreview::signer_changed`]). #[serde(default)] pub signer_change: bool, } impl Consent { /// Accept everything the plugin asks for (tests, trusted official plugins). pub fn all() -> Self { Self { optional: vec!["*".into()], signer_change: true } } fn accepts(&self, cap: &Capability) -> bool { self.optional.iter().any(|s| s == "*" || s.parse::().is_ok_and(|c| c == *cap)) } } /// What the install page shows before anything is copied. #[derive(Clone, Debug, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct InstallPreview { pub id: String, pub name: String, pub version: String, pub description: String, pub authors: Vec, pub has_code: bool, pub capabilities: Vec, pub contributions: Vec, pub warnings: Vec, /// Version currently installed under the same id. pub replaces: Option, /// Not signed by a publisher the marketplace index trusts: warn the user. pub unsigned: bool, /// The verified publisher. pub publisher: Option, /// Replaces an installed copy signed with another key, which didn't /// endorse this package's (or this one is unsigned): the user confirms it /// ([`Consent::signer_change`]). pub signer_changed: bool, /// Something to confirm: a first install, an unsigned package, another /// signer, or capabilities the installed version wasn't granted or /// refused. Without, an update installs with the grants it has. pub needs_consent: bool, } type ProjectPolicy = Arc bool + Send + Sync>; /// (plugin, window) of a batch of workbench events. type QueueKey = (String, Option); type PathVar = Arc Option + Send + Sync>; pub struct PluginHost { project_policy: RwLock, engine: PluginEngine, plugins_dir: PathBuf, sink: Arc, limits: Limits, registry: Arc, ui: Arc, languages: Arc, run_detectors: Arc, /// `[[contributes.providers]]` of enabled plugins. providers: Arc, navigation: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, plugins: RwLock>>, state: Mutex, /// What every plugin's [`api::PluginApi`] shares: sink, registry, /// workspace, status items, tasks, subscriptions. shared: Arc, /// Workbench events waiting for delivery: (plugin, window) → batch. queues: Mutex>, /// Serializes loading / unloading so concurrent windows can't race. lifecycle: Mutex<()>, /// Set once the first [`load_all`](Self::load_all) has finished. initial_load: (Mutex, Condvar), /// `on-startup-finished` has fired. startup_finished: AtomicBool, /// The theme last announced with `ThemeChanged`. announced_theme: Mutex>, /// [`PluginLanguages::revision`] last announced with `LanguagesChanged`. announced_languages: AtomicU64, /// `PATH` used to resolve `process:` programs at grant time. path_var: RwLock, /// (plugin id, `process:` capability) → the approved program; read by the /// language store's policy (plugin language servers and debug adapters). pins: Arc>>, /// The last marketplace index read: trust root for signatures, revocations. market: RwLock>, /// The official publisher's keys the index is pinned to (none: as listed). official_keys: RwLock>, /// `id@version` of the updates last announced. announced_updates: Mutex>, /// The marketplace used while the user has set none (the app's official one). default_market_url: RwLock>, /// Updates not confirmed yet (the new version hasn't started once): what /// to roll back to. updates: Mutex>, } struct PendingUpdate { previous: String, grant: Option, } impl PluginHost { /// Creates the host; call [`load_all`](Self::load_all) to load installed plugins. pub fn new(plugins_dir: PathBuf, sink: Arc, limits: Limits) -> IdeResult { std::fs::create_dir_all(&plugins_dir)?; install::recover(&plugins_dir); let state = std::fs::read_to_string(plugins_dir.join(STATE_FILE)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let registry = Arc::new(ExtensionRegistry::new()); let ui = UiStore::new(); let index = Arc::new(ContributionIndex::new( plugins_dir.join(".cache").join("contributions"), env!("CARGO_PKG_VERSION"), )); ui.set_index(Some(index.clone())); let languages = PluginLanguages::new(); let run_detectors: Arc = Arc::default(); let providers: Arc = Arc::default(); register_builtin_points(®istry, &ui, &languages, &run_detectors, &providers).map_err(IdeError::internal)?; let pins: Arc>> = Arc::default(); let policy_pins = pins.clone(); languages.set_process_policy(Arc::new(move |plugin, cap| { policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() })); let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone()); let official_keys = market::built_in_official_keys(); let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys)); let host = Self { market, official_keys: RwLock::new(official_keys), announced_updates: Mutex::default(), default_market_url: RwLock::new(None), updates: Mutex::default(), project_policy: RwLock::new(Arc::new(|_| false)), engine: PluginEngine::new().map_err(IdeError::internal)?, ledger: Mutex::new(Ledger::load(&plugins_dir)), plugins_dir, sink, limits, registry, ui, languages, run_detectors, providers, navigation: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), shared, queues: Mutex::default(), lifecycle: Mutex::new(()), initial_load: (Mutex::new(false), Condvar::new()), startup_finished: AtomicBool::new(false), announced_theme: Mutex::new(None), announced_languages: AtomicU64::new(0), path_var: RwLock::new(Arc::new(|| std::env::var_os("PATH"))), pins, }; host.refresh_pins(&host.ledger.lock()); Ok(host) } pub fn plugins_dir(&self) -> &Path { &self.plugins_dir } /// The extension registry: feature crates register their points here. pub fn registry(&self) -> &Arc { &self.registry } /// `PATH` for resolving `process:` programs when they are granted. pub fn set_program_path(&self, path: PathVar) { *self.path_var.write() = path; } /// Loads every installed and linked package (already loaded ones are kept). /// Instantiates no Wasm: code runs on its activation events. pub fn load_all(&self) { let _guard = self.lifecycle.lock(); let dirs = package_dirs(&self.plugins_dir); self.migrate_grants(&dirs); for dir in dirs { if !self.is_loaded_from(&dir) { self.load_dir(&dir, PluginSource::Installed, None); } } for dir in self.links().paths { if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { self.load_dir(&dir, PluginSource::Linked, None); } } let (done, cv) = &self.initial_load; *done.lock() = true; cv.notify_all(); self.changed(); } /// Fires `on-startup-finished`: activates the plugins waiting for it. /// Plugins loaded afterwards with that event activate right away. pub fn startup_finished(&self) { if self.startup_finished.swap(true, Ordering::AcqRel) { return; } let waiting: Vec<_> = self .plugins .read() .values() .filter(|p| { p.status() == PluginStatus::Inactive && p.manifest().activation.contains(&ActivationEvent::StartupFinished) }) .cloned() .collect(); for p in waiting { let _ = self.ensure_active(&p, None); } self.changed(); } /// Where compiled components are kept between runs. fn component_cache(&self) -> PathBuf { self.plugins_dir.join(".cache").join("components") } /// Precompiles every loaded plugin's component that is not cached yet and /// drops cache entries no loaded plugin uses (after an engine upgrade or an /// uninstall), compiled components and indexed contributions alike. Slow; /// call it on a background thread after start-up. pub fn warm_component_cache(&self) { let digests: Vec> = self.plugins.read().values().filter_map(|p| p.pkg.digest.clone()).collect(); self.index.prune(digests.iter().map(|d| &**d)); let cache = self.component_cache(); let wasm: Vec = self .plugins .read() .values() .filter(|p| p.status() != PluginStatus::Disabled) .filter_map(|p| p.manifest().wasm.as_ref().map(|w| p.dir().join(w))) .collect(); let mut keep = BTreeSet::new(); for file in wasm { match self.engine.precompile(&file, &cache) { Ok(name) => { keep.insert(name); } Err(e) => tracing::debug!("precompiling {}: {e}", file.display()), } } if let Ok(entries) = std::fs::read_dir(&cache) { for e in entries.flatten() { let name = e.file_name().to_string_lossy().into_owned(); if !keep.contains(&name) { let _ = std::fs::remove_file(e.path()); } } } } /// Fires an activation event from the UI (`on-language:rust`, `on-file:…`, `on-view:…`). pub fn activation_event(&self, event: &ActivationEvent, project: Option<&Path>) { let targets: Vec<_> = self .plugins .read() .values() .filter(|p| p.status() == PluginStatus::Inactive) .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) .filter(|p| p.manifest().activation.iter().any(|a| activation_matches(a, event))) .cloned() .collect(); if targets.is_empty() { return; } for p in targets { let _ = self.ensure_active(&p, None); } self.changed(); } /// Waits (at most `timeout`) for the first [`load_all`](Self::load_all), so a /// listing made at startup already holds the installed plugins and the /// selected theme. Returns whether loading has finished. pub fn wait_initial_load(&self, timeout: Duration) -> bool { let (done, cv) = &self.initial_load; let mut done = done.lock(); if !*done { cv.wait_while_for(&mut done, |d| !*d, timeout); } *done } /// Whether project plugins of a root may load (project trust). pub fn set_project_policy(&self, policy: ProjectPolicy) { *self.project_policy.write() = policy; } /// Unloads the project plugins of `root` (the project became untrusted or closed). pub fn restrict_project(&self, root: &Path) { let _guard = self.lifecycle.lock(); let ids: Vec<_> = self .plugins .read() .iter() .filter(|(_, p)| p.dev_root.as_deref() == Some(root)) .map(|(id, _)| id.clone()) .collect(); for id in ids { self.unload(&id); } self.changed(); } /// Loads new project plugins from `/.openidea/plugins/` (trusted /// projects only) and unloads vanished ones. Each still needs the user's /// consent before it runs. pub fn sync_project(&self, root: &Path) { if !(self.project_policy.read())(root) { self.restrict_project(root); return; } let _guard = self.lifecycle.lock(); let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins")); let stale: Vec = self .plugins .read() .iter() .filter(|(_, p)| p.dev_root.as_deref() == Some(root) && !dirs.iter().any(|d| d == p.dir())) .map(|(id, _)| id.clone()) .collect(); let mut changed = !stale.is_empty(); for id in stale { self.unload(&id); } for dir in dirs { if !self.is_loaded_from(&dir) { self.load_dir(&dir, PluginSource::Dev, Some(root.to_path_buf())); changed = true; } } if changed { self.changed(); } } /// Re-reads `links.json` (written by `openidea-plugin link`): loads new /// linked plugins and unloads unlinked ones. pub fn sync_links(&self) { let links = self.links().paths; let _guard = self.lifecycle.lock(); let stale: Vec = self .plugins .read() .iter() .filter(|(_, p)| p.source == PluginSource::Linked && !links.iter().any(|l| l == p.dir())) .map(|(id, _)| id.clone()) .collect(); let mut changed = !stale.is_empty(); for id in stale { self.unload(&id); } for dir in links { if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() { self.load_dir(&dir, PluginSource::Linked, None); changed = true; } } if changed { self.changed(); } } /// Links a development plugin folder (loaded until unlinked; granted by path). pub fn link(&self, dir: &Path) -> IdeResult { let dir = dunce::canonicalize(dir)?; Manifest::load(&dir).map_err(IdeError::invalid)?; let mut links = self.links(); if !links.paths.contains(&dir) { links.paths.push(dir.clone()); self.save_links(&links)?; } self.sync_links(); let id = self.plugins.read().values().find(|p| p.dir() == dir).map(|p| p.id().to_owned()); self.info_of(&id.ok_or_else(|| IdeError::invalid("the linked plugin's id is already in use"))?) } pub fn unlink(&self, id: &str) -> IdeResult<()> { let plugin = self.get(id)?; if plugin.source != PluginSource::Linked { return Err(IdeError::invalid(format!("plugin {id:?} is not linked"))); } let mut links = self.links(); links.paths.retain(|p| p != plugin.dir()); self.save_links(&links)?; self.sync_links(); Ok(()) } /// Installed and linked plugins plus the project plugins of `project`. pub fn list(&self, project: Option<&Path>) -> PluginsSnapshot { let visible: Vec> = self .plugins .read() .values() .filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project) .cloned() .collect(); let ids: BTreeSet<&str> = visible.iter().map(|p| p.id()).collect(); let ui: Vec = self.ui.snapshot().into_iter().filter(|c| ids.contains(c.plugin_id.as_str())).collect(); let mut plugins: Vec = visible.iter().map(|p| self.info(p)).collect(); plugins.sort_by_key(|p| p.name.to_lowercase()); PluginsSnapshot { plugins, active_theme: self.active_theme(), ui } } pub fn info_of(&self, id: &str) -> IdeResult { let plugin = self.get(id)?; Ok(self.info(&plugin)) } /// UI contributions of one point (all live plugins). pub fn ui_items(&self, point: &str) -> Vec { self.ui.items(point, None) } /// Validated assets of a plugin whose id starts with `prefix` (icon theme SVGs). pub fn assets(&self, id: &str, prefix: &str) -> IdeResult> { let plugin = self.get(id)?; if !plugin.status().is_live() { return Err(IdeError::unavailable(format!("plugin {id:?} is not enabled"))); } Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect()) } /// The UI language windows run in, as `locale()` reports it to plugin code. pub fn set_ui_locale(&self, tag: &str) { *self.shared.locale.write() = tag.to_owned(); } pub fn ui_locale(&self) -> String { self.shared.locale.read().clone() } pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; { let mut state = self.state.lock(); if enabled { state.disabled.remove(id) } else { state.disabled.insert(id.to_owned()) }; } self.save_state(); self.stop(&plugin); if enabled { self.start(&plugin); } else { plugin.meta.lock().status = PluginStatus::Disabled; } self.changed(); Ok(()) } /// Re-reads a plugin from disk (e.g. after rebuilding a dev plugin). pub fn reload(&self, id: &str) -> IdeResult { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone()); self.unload(id); let new_id = self.load_dir(&dir, source, root); self.changed(); self.info_of(&new_id) } /// Validates a package for the install page: nothing is copied or run. pub fn preview_install(&self, src: &Path) -> IdeResult { let (pkg, set) = self.validate_package(src)?; if let Some(problem) = self.signature_problem(src) { return Err(IdeError::invalid(format!("refusing {}: {problem}", pkg.id()))); } let publisher = self.verified_publisher(src); let m = &pkg.manifest; let replaces = self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()); // An update shows what the installed version was granted. let grant = replaces .as_ref() .and_then(|_| self.ledger.lock().get(&Subject::Installed(m.id.clone())).cloned()) .filter(|g| g.confirmed); let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.has(c)); let mut capabilities: Vec = m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); for extra in &set.capabilities { if !m.declares(extra) { capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); } } capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id))); let signer_changed = self.signer_changed(&m.id, src); let answered = |c: &CapabilityInfo| { c.granted || (c.optional && grant.as_ref().is_some_and(|g| g.declined.contains(&c.id))) }; let needs_consent = grant.is_none() || publisher.is_none() || signer_changed || !capabilities.iter().all(answered); Ok(InstallPreview { id: m.id.clone(), name: m.name.clone(), version: m.version.clone(), description: m.description.clone(), authors: m.authors.clone(), has_code: m.wasm.is_some(), capabilities, contributions: counts(m), warnings: set.warnings.clone(), replaces, unsigned: publisher.is_none(), publisher, signer_changed, needs_consent, }) } /// Installs the package in `src` (manifest + the files its declarations /// reference) with the user's `consent`, replacing an installed version /// with the same id atomically. pub fn install_from_dir(&self, src: &Path, consent: &Consent) -> IdeResult { let _guard = self.lifecycle.lock(); let (pkg, set) = self.validate_package(src)?; let id = pkg.id().to_owned(); if let Some(existing) = self.plugins.read().get(&id) && existing.source != PluginSource::Installed { return Err(IdeError::new( ide_core::error::ErrorKind::AlreadyExists, format!("a development plugin with id {id:?} is loaded from {}", existing.dir().display()), )); } // A signature that doesn't hold is worse than none. if let Some(problem) = self.signature_problem(src) { return Err(IdeError::invalid(format!("refusing {id}: {problem}"))); } if !consent.signer_change && self.signer_changed(&id, src) { return Err(IdeError::invalid(format!( "refusing {id}: it is signed by another key than the installed version; confirm the change of signer" ))); } let version = pkg.manifest.version.clone(); if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) { return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason))); } let mut files = set.files.clone(); files.extend(pkg.manifest.wasm.clone()); let hash = ledger::package_hash(src, &files)?; let root = self.plugins_dir.join(&id); let current = install::active_dir(&root); let same_dir = dunce::canonicalize(src) .ok() .zip(current.as_ref().and_then(|c| dunce::canonicalize(c).ok())) .is_some_and(|(a, b)| a == b); let subject = Subject::Installed(id.clone()); let old_grant = self.ledger.lock().get(&subject).cloned(); self.unload(&id); let previous = if same_dir { std::fs::write(current.as_ref().expect("same dir").join(install::HASH_FILE), &hash)?; None } else { let version_of = |d: &Path| Manifest::load(d).ok().map(|m| m.version); let previous = install::install_version(src, &root, &version, &files, &hash, version_of)?; market::copy_signature(src, &root.join(&version)); previous }; // Consent covers what the install page showed. let request = request_for(&subject, &pkg.manifest, &hash, &set.capabilities); self.record_consent(&request, consent); let dir = install::active_dir(&root).ok_or_else(|| IdeError::internal("the installed version is missing"))?; self.load_dir(&dir, PluginSource::Installed, None); if let Some(previous) = previous { let failed = self .get(&id) .ok() .filter(|p| p.status() == PluginStatus::Failed) .map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into())); if let Some(reason) = failed { self.roll_back(&id, &previous, &version, old_grant); self.changed(); return Err(IdeError::invalid(format!( "{} {version} could not start ({reason}); version {previous} was restored", pkg.manifest.name ))); } // Confirmed once the new version's code has started (or right away without code). if pkg.manifest.wasm.is_some() { self.updates.lock().insert(id.clone(), PendingUpdate { previous, grant: old_grant }); } } install::prune(&root); self.changed(); self.info_of(&id) } /// Makes `previous` the active version of `id` again and drops `failed` /// (its directory, its consent). Called with the plugin unloaded or not. fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option) { let root = self.plugins_dir.join(id); self.unload(id); if let Err(e) = install::activate_version(&root, previous) { tracing::warn!("plugin {id}: rolling back to {previous}: {e}"); return; } if failed != previous { let _ = std::fs::remove_dir_all(root.join(failed)); } { let mut ledger = self.ledger.lock(); ledger.restore(&Subject::Installed(id.to_owned()), grant); self.save_ledger(&ledger); } self.updates.lock().remove(id); if let Some(dir) = install::active_dir(&root) { self.load_dir(&dir, PluginSource::Installed, None); } } /// An update whose code failed to start: back to the previous version. /// Returns the message for the user when it rolled back. fn roll_back_failed_update( &self, plugin: &Arc, reason: &str, client: Option<&str>, ) -> Option { let id = plugin.id().to_owned(); let pending = self.updates.lock().remove(&id)?; let version = plugin.manifest().version.clone(); let name = plugin.manifest().name.clone(); // The caller may hold the lifecycle lock (activation during start-up): don't wait for it forever. let _guard = self.lifecycle.try_lock_for(Duration::from_secs(5)); self.roll_back(&id, &pending.previous, &version, pending.grant); let message = format!("{name} {version} failed to start ({reason}); version {} was restored", pending.previous); self.emit( client, PluginEvent::Notification { plugin_id: id, level: ide_plugin::NotificationLevel::Warning, title: format!("{name} update rolled back"), body: message.clone(), actions: Vec::new(), }, ); self.changed(); Some(message) } /// Settings › Plugins › Roll Back: the version the last update replaced. pub fn roll_back_update(&self, id: &str) -> IdeResult { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; if plugin.source != PluginSource::Installed { return Err(IdeError::invalid("only installed plugins have versions to roll back to")); } let root = self.plugins_dir.join(id); let previous = install::previous_version(&root) .ok_or_else(|| IdeError::not_found(format!("{id} has no earlier version to roll back to")))?; let current = plugin.manifest().version.clone(); // The earlier version's consent: what it declared, as granted now where it overlaps. let grant = self.ledger.lock().get(&Subject::Installed(id.to_owned())).cloned(); self.roll_back(id, &previous, ¤t, grant); self.changed(); self.info_of(id) } /// Confirms a plugin waiting for consent (project plugins, new capabilities). pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; if plugin.invalid { return Err(IdeError::invalid("the plugin's manifest is invalid")); } let hash = self.package_hash_of(&plugin); let extra = plugin.meta.lock().declared_extra.clone(); let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &extra); self.record_consent(&request, consent); self.stop(&plugin); if !self.state.lock().disabled.contains(id) { self.start(&plugin); } self.changed(); self.info_of(id) } /// Grants or revokes one capability (Settings › Plugins › Permissions). /// Revoking a required one stops the plugin until consent is given again. pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; let cap: Capability = capability.parse().map_err(IdeError::invalid)?; if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) { return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}"))); } { let mut ledger = self.ledger.lock(); let subject = plugin.subject(); if ledger.get(&subject).is_none() { return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet"))); } ledger.set(&subject, &cap, granted); if granted && let Capability::Process(program) = &cap && let Some(bin) = self.resolve_binary(program) { ledger.set_binary(&subject, &cap, bin); } self.save_ledger(&ledger); } self.stop(&plugin); if !self.state.lock().disabled.contains(id) { self.start(&plugin); } self.changed(); self.info_of(id) } /// The program a `process:` grant allows, checked against the file /// approved for it. Errors ask the user to confirm again. pub fn program_for(&self, id: &str, program: &str) -> IdeResult { let plugin = self.get(id)?; let cap = Capability::Process(program.to_owned()); self.ledger .lock() .check_binary(&plugin.subject(), &cap) .map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e)) } pub fn uninstall(&self, id: &str) -> IdeResult<()> { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; if plugin.source != PluginSource::Installed { return Err(IdeError::invalid( "development plugins are removed by deleting them from .openidea/plugins or unlinking them", )); } // Its secrets (kept outside its directory, in the OS keychain on the desktop) go with it. let secrets: Vec = plugin .manifest() .contributes .get(providers::SECRETS_POINT) .and_then(toml::Value::as_array) .into_iter() .flatten() .filter_map(|s| s.get("name")?.as_str().map(str::to_owned)) .collect(); self.unload(id); { let store = self.shared.secrets.read().clone(); for name in &secrets { if let Err(e) = store.set(id, name, None) { tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}"); } } } // Every version (`/`), or the flat directory of an older install. let root = self.plugins_dir.join(id); std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?; self.updates.lock().remove(id); // Its storage goes with it. let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id)); { let mut ledger = self.ledger.lock(); ledger.remove(&plugin.subject()); self.save_ledger(&ledger); } { let mut state = self.state.lock(); state.disabled.remove(id); if state.theme.as_ref().is_some_and(|t| t.plugin == id) { state.theme = None; } } self.save_state(); self.changed(); Ok(()) } /// Runs a plugin command, activating the plugin first when needed /// (`on-command`). Blocks for up to the configured time limit. pub fn run_command( &self, id: &str, command: &str, arg: Option<&str>, invocation: Invocation, ) -> IdeResult> { let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned)); self.code_call(id, invocation, "running the command", move |rt, inv| rt.run_command(&cmd, arg.as_deref(), inv)) } /// Content of a declared view, or of a custom editor (`invocation.editor` /// is its document). Activates the plugin (`on-view`). pub fn render_view( &self, id: &str, view: &str, viewport: Option, invocation: Invocation, ) -> IdeResult { self.declared_view(id, view)?; let view = view.to_owned(); self.code_call(id, invocation, "rendering the view", move |rt, inv| rt.render_view(&view, viewport, inv)) } /// A user action in a view or custom editor. pub fn view_event( &self, id: &str, view: &str, event: ide_plugin::ViewEvent, invocation: Invocation, ) -> IdeResult<()> { self.declared_view(id, view)?; let view = view.to_owned(); self.code_call(id, invocation, "handling the view's event", move |rt, inv| { rt.view_event(&view, event.clone(), inv) }) } /// The Services pane of node `key` of the plugin's `location = "services"` /// view `view`. pub fn service_detail( &self, id: &str, view: &str, key: &str, invocation: Invocation, ) -> IdeResult> { self.services_view(id, view)?; let (view, key) = (view.to_owned(), key.to_owned()); self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| { rt.service_detail(&view, &key, inv) }) } /// Runs the program of the log tab `tab` of node `key`'s pane, with the /// arguments of the toggles in `on`, streaming its output until it exits /// or `cancel` fires; returns its exit code. The pane is asked of the /// plugin again, so the UI never names what runs. Needs the plugin's /// `process:` grant. #[allow(clippy::too_many_arguments)] pub fn stream_service_log( &self, id: &str, view: &str, key: &str, tab: &str, on: &[String], invocation: Invocation, cancel: &ide_core::CancellationToken, on_line: &dyn Fn(PluginOutputLine), ) -> IdeResult> { if cancel.is_cancelled() { return Err(IdeError::cancelled()); } let cwd = invocation.workspace_root.clone(); let tabs = self.service_detail(id, view, key, invocation)?; let Some(ide_plugin::ServicePane::Log(log)) = tabs.into_iter().find(|t| t.id == tab).map(|t| t.body) else { return Err(IdeError::not_found(format!("the pane of {key:?} has no log tab {tab:?}"))); }; let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args); let args: Vec = log.args.into_iter().chain(toggled).collect(); let plugin = self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("plugin {id:?}")))?; let cap = Capability::Process(log.program.clone()); if !plugin.meta.lock().granted.iter().any(|g| g.covers(&cap)) { let denied = HostError::Denied(cap.to_string()).to_string(); return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, denied)); } let api = api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }; api.stream_process(&log.program, args, cwd.as_deref(), cancel, on_line).map_err(|e| match e { HostError::Cancelled => IdeError::cancelled(), e @ HostError::Denied(_) => IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e.to_string()), HostError::NotFound(m) => IdeError::not_found(m), HostError::Invalid(m) => IdeError::invalid(m), e => IdeError::unavailable(e.to_string()), }) } fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> { let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS] .iter() .any(|point| self.ui.items(point, Some(id)).iter().any(|i| i.key == view)); if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) } } fn services_view(&self, id: &str, view: &str) -> IdeResult<()> { let services = self .ui .items(ui_points::VIEWS, Some(id)) .iter() .any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)); if services { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}"))) } } /// One call into a plugin's code for the UI: checks it may run (asking /// for missing consent), activates it, retries once when the instance was /// reclaimed meanwhile, and reports crashes. fn code_call( &self, id: &str, invocation: Invocation, what: &str, f: impl Fn(&mut Runtime, Invocation) -> Result + Clone + Send + 'static, ) -> IdeResult { let plugin = self.get(id)?; let client = invocation.client.clone(); { let meta = plugin.meta.lock(); let reason = match meta.status { PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")), PluginStatus::Static => Some(format!("plugin {id:?} has no code")), PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")), PluginStatus::Failed => { Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed"))) } PluginStatus::Active | PluginStatus::Inactive => None, }; if let Some(reason) = reason { return Err(IdeError::unavailable(reason)); } } let pending = plugin.meta.lock().pending.clone(); if !pending.is_empty() { // Migrated from 0.1: ask for what was not granted automatically, on first use. self.emit( client.as_deref(), PluginEvent::ConsentRequested { plugin_id: id.to_owned(), capabilities: pending.iter().map(|c| CapabilityInfo::new(c, false, false)).collect(), }, ); return Err(IdeError::new( ide_core::error::ErrorKind::PermissionDenied, format!("plugin {id:?} needs your permission first"), )); } // Twice at most: the instance may be reclaimed between picking it and queueing the call. for attempt in 0..2 { let (actor, activated) = self.ensure_active(&plugin, client.as_deref())?; if activated { self.changed(); } let started = Instant::now(); let (call, inv) = (f.clone(), invocation.clone()); let result = actor.call(None, move |rt| call(rt, inv)); if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) { let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); // Cancelling or a missing grant is not the plugin failing. let ok = match &result { Ok(_) => true, Err(ActorError::Call(CallError::Guest(e))) => !e.is_failure(), Err(_) => false, }; self.record_call(&plugin, started.elapsed(), ok, !crashed); } match result { Ok(value) => return Ok(value), Err(ActorError::Call(CallError::Guest(e))) => return Err(e.into()), Err(ActorError::Call(CallError::Trap(e))) => { self.reap(&plugin, &actor, client.as_deref()); return Err(IdeError::internal(format!("plugin {id:?} {e}"))); } Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled) => { self.reap(&plugin, &actor, client.as_deref()); if attempt == 1 { break; } } } } Err(IdeError::unavailable(format!("plugin {id:?} stopped before {what}; try again"))) } /// Interrupts the plugin's running call, if any. Its instance is dropped /// and started again on next use; this does not count as a crash. pub fn cancel(&self, id: &str) { if let Some(actor) = self.plugins.read().get(id).and_then(|p| p.runtime.lock().clone()) { actor.cancel(); } } /// Runs a `syntax-provider` call of the plugin contributing `language`, /// activating it on first use. `None` when no running plugin provides it /// (or the call failed: the editor then shows plain text). fn syntax_call( &self, language: &LanguageId, f: impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static, ) -> Option { let owner = self.languages.owner_of(language)?; let plugin = self.get(&owner).ok()?; { let meta = plugin.meta.lock(); let usable = matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) && meta.pending.is_empty(); // Known not to provide syntax (activated before without it). if !usable || (meta.stats.activations > 0 && !meta.has_syntax) { return None; } } plugin.manifest().wasm.as_ref()?; let (actor, activated) = self.ensure_active(&plugin, None).ok()?; if activated { self.changed(); } if !plugin.meta.lock().has_syntax { return None; } // Edit path: stop waiting at the soft deadline (the result is dropped // but the instance kept); interrupt at the hard one. let started = Instant::now(); let hard = self.limits.edit_hard; let result = actor.call(Some(self.limits.edit_soft), move |rt| f(rt, hard)); self.finish_provider_call(&plugin, &actor, started, result) } /// Plugins' `runDetectors`, for [`ide_run::set_external_detection`]. pub fn run_detection(&self) -> Arc { Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() }) } /// The syntax of plugin languages, for [`ide_language::set_external_syntax`]. pub fn syntax_provider(self: &Arc) -> Arc { Arc::new(PluginSyntax(Arc::downgrade(self))) } /// The UI reports which file its focused editor shows (`editor-focused`). pub fn set_active_file(&self, client: &str, path: Option) { self.post_events(Some(client), vec![WorkbenchEvent::EditorFocused(path)]); } /// Where plugin secrets are kept (default: files under the plugins /// directory; the desktop app uses the OS keychain). pub fn set_secret_store(&self, store: Arc) { *self.shared.secrets.write() = store; } /// Stores (or, with `None`, deletes) a secret the plugin declares under /// `[[contributes.secrets]]`. pub fn set_secret(&self, id: &str, name: &str, value: Option<&str>) -> IdeResult<()> { let plugin = self.get(id)?; if !self.ui.items(providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == name) { return Err(IdeError::not_found(format!("plugin {id:?} declares no secret {name:?}"))); } let value = value.map(str::trim).filter(|v| !v.is_empty()); self.shared.secrets.read().set(plugin.id(), name, value).map_err(IdeError::internal) } /// The declared secrets of a plugin and whether each is set. pub fn secrets(&self, id: &str) -> IdeResult> { let plugin = self.get(id)?; let store = self.shared.secrets.read().clone(); Ok(self .ui .items(providers::SECRETS_POINT, Some(plugin.id())) .into_iter() .map(|s| { let set = store.get(plugin.id(), &s.key).is_some(); (s.key, set) }) .collect()) } /// What plugins see of the open projects (documents, edits, trust). pub fn set_workspace(&self, workspace: Option>) { *self.shared.workspace.write() = workspace; } /// Every plugin status bar item: (plugin, item id, item). pub fn status_items(&self) -> Vec<(String, String, ide_plugin::StatusItem)> { self.shared.status_items() } /// Plugin context keys in effect for window `client`: global keys overlaid with its own. pub fn context_keys(&self, client: Option<&str>) -> Vec { self.shared.context_keys(client) } /// A window labelled `label` is about to exist again (labels are reused). pub fn reopen_client(&self, label: &str) { self.shared.reopen_client(label); } /// Window `label` closed: drops its context keys and queued events, and ignores /// context writes of commands still running for it. pub fn forget_client(&self, label: &str) { self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label)); self.shared.forget_client(label); } /// The user cancelled a plugin task (`task_id` from `TaskStarted`). pub fn cancel_task(&self, task_id: &str) -> bool { task_id.parse().is_ok_and(|id| self.shared.cancel_task(id)) } /// The user answered (`Some`) or dismissed (`None`) a prompt /// (`PromptRequested`); false when it is no longer waiting. pub fn answer_prompt(&self, prompt_id: &str, answer: Option>) -> bool { prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer)) } /// A window answered a plugin's `CommitRequested`; false when the call no /// longer waits. pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool { request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer)) } /// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on /// what `selection` commits. A check that fails or isn't available is /// reported with `error`, so the commit still stops for it. pub fn commit_checks( &self, invocation: Invocation, selection: CommitSelection, checks: Vec, cancel: &ide_core::CancellationToken, ) -> IdeResult> { let declared = self.ui.items(ui_points::COMMIT_CHECKS, None); let wanted: Vec<_> = checks .into_iter() .filter_map(|c| { let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?; Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned())) }) .collect(); if wanted.is_empty() { return Ok(Vec::new()); } let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?; let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e { HostError::Cancelled => IdeError::cancelled(), HostError::Invalid(m) => IdeError::invalid(m), e => IdeError::unavailable(e.to_string()), })?; let mut out = Vec::new(); for (c, title) in wanted { if cancel.is_cancelled() { return Err(IdeError::cancelled()); } let granted = self .plugins .read() .get(&c.plugin_id) .is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead)); let result = if granted { let (check, draft) = (c.check.clone(), draft.clone()); self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| { rt.commit_check(&check, draft.clone(), inv) }) .map_err(|e| e.message) } else { Err(HostError::Denied(Capability::VcsRead.to_string()).to_string()) }; let (problems, error) = match result { Ok(problems) => (problems, None), Err(e) => (Vec::new(), Some(e)), }; out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error }); } Ok(out) } /// Kinds of workbench events some running or activatable plugin wants; /// the UI reports only these. pub fn event_kinds(&self) -> BTreeSet { let subs = self.shared.subscriptions.lock().clone(); let mut out = BTreeSet::new(); for p in self.plugins.read().values() { for kind in EventKind::ALL { if self.wants(p, kind, subs.get(p.id())) { out.insert(kind); } } } out } /// Whether `plugin` gets `kind` events: subscribed at run time or with /// `on-event:`, the capability granted, and able to run. fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet>) -> bool { let declared = plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name())); if !declared && !runtime.is_some_and(|s| s.contains(&kind)) { return false; } let meta = plugin.meta.lock(); matches!(meta.status, PluginStatus::Active | PluginStatus::Inactive) && kind.capability().is_none_or(|cap| meta.granted.iter().any(|g| g.covers(&cap))) } /// Queues workbench events from window `client` for the plugins that want /// them; [`flush_events`](Self::flush_events) delivers them in batches. pub fn post_events(&self, client: Option<&str>, events: Vec) { if events.is_empty() { return; } let subs = self.shared.subscriptions.lock().clone(); let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); let now = Instant::now(); let mut queues = self.queues.lock(); for p in plugins { let wanted: Vec = events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); if wanted.is_empty() { continue; } let queue = queues .entry((p.id().to_owned(), client.map(str::to_owned))) .or_insert_with(|| EventQueue { since: now, events: Vec::new() }); queue.events.extend(wanted); let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS); queue.events.drain(..excess); } } /// Delivers queued events: to running plugins right away, to idle ones /// (which must be activated) after [`IDLE_BATCH`], so frequent events /// don't keep waking them. Call it a few times a second. pub fn flush_events(&self) { self.flush_events_at(Instant::now()); } #[doc(hidden)] pub fn flush_events_at(&self, now: Instant) { let due: Vec<(QueueKey, Vec)> = { let mut queues = self.queues.lock(); let plugins = self.plugins.read(); let keys: Vec<_> = queues .iter() .filter(|((id, _), q)| { let running = plugins.get(id).is_some_and(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())); running || now.saturating_duration_since(q.since) >= IDLE_BATCH || q.events.len() >= MAX_QUEUED_EVENTS / 2 || q.events.contains(&WorkbenchEvent::Scheduled) }) .map(|(k, _)| k.clone()) .collect(); keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect() }; let mut changed = false; for ((id, client), events) in due { changed |= self.deliver(&id, client, events); } // Batches that crashed since. let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); for p in plugins { let dead = p.runtime.lock().clone().filter(|a| !a.is_alive()); if let Some(actor) = dead { self.reap(&p, &actor, None); } } if changed { self.changed(); } } /// Waits (at most `timeout`) until every posted event batch was handled. /// Returns whether they all were. #[doc(hidden)] pub fn wait_events(&self, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { let busy = self.plugins.read().values().any(|p| p.batches.lock().pending > 0); if !busy { self.flush_events_at(Instant::now()); return true; } if Instant::now() >= deadline { return false; } std::thread::sleep(Duration::from_millis(5)); } } /// Hands one batch to the plugin's `event-handler.handle` without waiting /// for it. A newer batch of the same kinds cooperatively cancels the one /// being handled (a save while the previous save is still being checked). /// Returns whether the plugin was activated. fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { let Ok(plugin) = self.get(id) else { return false }; if !plugin.meta.lock().pending.is_empty() { return false; } let Ok((actor, activated)) = self.ensure_active(&plugin, client.as_deref()) else { return false }; let invocation = Invocation { workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)), client: client.clone(), editor: None, selected_paths: Vec::new(), }; let kinds: BTreeSet = events.iter().map(WorkbenchEvent::kind).collect(); { let mut batches = plugin.batches.lock(); if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) { actor.cancel_soft(); } batches.pending += 1; } let started = Instant::now(); let (start, finish) = (plugin.batches.clone(), plugin.batches.clone()); let done_plugin = plugin.clone(); actor.post( move |rt| { start.lock().running = Some(kinds); rt.handle_events(events, invocation) }, move |result| { let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_)))); if !matches!(result, Err(ActorError::Stopped)) { done_plugin.record_call(started.elapsed(), result.is_ok(), !crashed); } match &result { Err(ActorError::Call(CallError::Guest(e))) => { tracing::debug!(plugin = %done_plugin.id(), "events: {e}") } Err(ActorError::Call(CallError::Trap(e))) => { tracing::warn!(plugin = %done_plugin.id(), "events: {e}") } _ => {} } let mut batches = finish.lock(); batches.pending = batches.pending.saturating_sub(1); batches.running = None; }, ); activated } /// Selects a plugin theme (`None` restores the built-in theme). pub fn set_theme(&self, selection: Option<(&str, &str)>) -> IdeResult> { if let Some((plugin, theme)) = selection { self.get(plugin)?; if !self.ui.items(theme::POINT_ID, Some(plugin)).iter().any(|t| t.key == theme) { return Err(IdeError::not_found(format!("plugin {plugin:?} has no theme {theme:?}"))); } } self.state.lock().theme = selection.map(|(plugin, theme)| ThemeRef { plugin: plugin.into(), theme: theme.into(), frame: None }); self.save_state(); let active = self.active_theme(); *self.announced_theme.lock() = active.clone(); self.emit(None, PluginEvent::ThemeChanged { theme: active.clone() }); Ok(active) } /// Records the selected theme's resolved window frame colour (`#rgb`, /// `#rrggbb` or `#rrggbbaa`); ignored without a selected theme. pub fn set_theme_frame(&self, frame: Option<&str>) -> IdeResult<()> { if let Some(f) = frame && !is_hex_color(f) { return Err(IdeError::invalid(format!("not a hex colour: {f:?}"))); } let changed = match self.state.lock().theme.as_mut() { Some(t) if t.frame.as_deref() != frame => { t.frame = frame.map(str::to_owned); true } _ => false, }; if changed { self.save_state(); } Ok(()) } /// Frame colour for a new window: the recorded one of the selected theme, /// unless its plugin is disabled. Available before plugins are loaded. pub fn startup_frame(&self) -> Option { let state = self.state.lock(); let t = state.theme.as_ref()?; if state.disabled.contains(&t.plugin) { return None; } t.frame.clone() } /// The selected plugin theme, when its plugin is enabled and live. pub fn active_theme(&self) -> Option { let sel = self.state.lock().theme.clone()?; let item = self.ui.items(theme::POINT_ID, Some(&sel.plugin)).into_iter().find(|t| t.key == sel.theme)?; let theme: ThemeDef = serde_json::from_value(item.decl).ok()?; Some(ActiveTheme { plugin_id: sel.plugin, theme }) } /// Languages contributed by live plugins. pub fn languages(&self) -> Vec { self.languages.all() } /// Plugin languages for a remote project's server, with the programs each /// plugin was granted ([`remote::RemotePluginLanguages::sync`]). pub fn remote_languages(&self) -> Vec { let plugins = self.plugins.read(); let ledger = self.ledger.lock(); self.languages .with_owners() .into_iter() .map(|(plugin, language)| { let grant = plugins.get(&plugin).and_then(|p| ledger.get(&p.subject()).cloned()); let granted = ide_language::plugin_point::required_capabilities(&language) .into_iter() .filter(|cap| grant.as_ref().is_some_and(|g| g.confirmed && g.has(cap))) .filter_map(|cap| match cap { Capability::Process(program) => Some(program), _ => None, }) .collect(); remote::RemoteLanguage { plugin, language, granted } }) .collect() } /// The overlay of plugin languages (for the language registry). pub fn language_store(&self) -> &Arc { &self.languages } /// Plugin packages found in `dirs` (first occurrence of an id wins). Invalid /// manifests are skipped; packages that fail validation carry a `problem`. pub fn catalog(&self, dirs: &[PathBuf]) -> Vec { let mut out: Vec = Vec::new(); for dir in dirs.iter().flat_map(|d| package_dirs(d)) { let Ok(m) = Manifest::load(&dir) else { continue }; if out.iter().any(|c| c.id == m.id) { continue; } let pkg = Package::new(m.clone(), dir.clone()); let problem = match self.prepare(&pkg) { Err(e) => Some(e), Ok(_) => m .wasm .as_ref() .filter(|w| !dir.join(w).is_file()) .map(|w| format!("{} is missing; build the plugin first", w.display())), }; out.push(CatalogPlugin { has_code: m.wasm.is_some(), capabilities: m .capabilities .all() .map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)) .collect(), contributions: counts(&m), id: m.id, name: m.name, version: m.version, description: m.description, authors: m.authors, homepage: m.homepage, change_notes: m.change_notes, icon: plugin_icon(&pkg).ok().flatten(), path: dir.into(), problem, }); } out.sort_by_key(|a| a.name.to_lowercase()); out } /// Deactivates every plugin (also done on drop). pub fn shutdown(&self) { let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); for id in self.plugins.read().keys() { self.shared.block_prompts(id); } for a in &actors { a.stop(false); } for a in actors { a.stop(true); } } /// Periodic upkeep (every few seconds): reports crashes nobody waited /// for, reclaims idle instances and fires due `on-schedule` activations /// (which don't keep a plugin alive: it is reclaimed again when idle). pub fn tick(&self) { self.tick_at(Instant::now()); } /// [`tick`](Self::tick) as if it were `now` (schedules only; tests). #[doc(hidden)] pub fn tick_at(&self, now: Instant) { let plugins: Vec<_> = self.plugins.read().values().cloned().collect(); let mut changed = false; for p in plugins { let actor = p.runtime.lock().clone(); if let Some(a) = &actor { if !a.is_alive() { self.reap(&p, a, None); continue; } if self.limits.idle_timeout.is_some_and(|idle| !a.is_busy() && a.idle_for() >= idle) { changed |= self.reclaim(&p, a); } } let every = p.manifest().activation.iter().filter_map(|e| match e { ActivationEvent::Schedule(secs) => Some(Duration::from_secs(u64::from(*secs).max(1))), _ => None, }); let Some(every) = every.min() else { continue }; let due = { let mut meta = p.meta.lock(); let next = *meta.next_schedule.get_or_insert(now + every); if next > now { false } else { meta.next_schedule = Some(now + every); meta.status == PluginStatus::Inactive && meta.pending.is_empty() } }; let restricted = p.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)); if due && !restricted && self.startup_finished.load(Ordering::Acquire) { // Activates it if needed and calls `handle([scheduled])`. self.queues .lock() .entry((p.id().to_owned(), None)) .or_insert_with(|| EventQueue { since: now, events: Vec::new() }) .events .push(WorkbenchEvent::Scheduled); } } if changed { self.changed(); } self.flush_events_at(now); } /// Number of plugins with a live Wasm instance. pub fn instances(&self) -> usize { self.plugins.read().values().filter(|p| p.runtime.lock().as_ref().is_some_and(|a| a.is_alive())).count() } // ---- internals ---- fn get(&self, id: &str) -> IdeResult> { let plugin = self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?; if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) { return Err(IdeError::unavailable("Project is in Restricted Mode")); } Ok(plugin) } fn is_loaded_from(&self, dir: &Path) -> bool { 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.prepare(&pkg).map_err(IdeError::invalid)?; if let Some(wasm) = &pkg.manifest.wasm { if !src.join(wasm).is_file() { return Err(IdeError::invalid(format!( "{} is missing (referenced by plugin.toml); build the plugin first", wasm.display() ))); } // Precompiled on the way: the installed copy has the same bytes. let component = self.engine.compile_cached(&src.join(wasm), &self.component_cache()).map_err(IdeError::invalid)?; self.engine.check_component(&component).map_err(IdeError::invalid)?; } Ok((pkg, set)) } /// Loads one package and starts it unless disabled. Returns the plugin id. fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option) -> String { let (manifest, error) = match Manifest::load(dir) { Ok(m) => (m, None), Err(e) => (placeholder_manifest(dir), Some(e)), }; let invalid = error.is_some(); let id = manifest.id.clone(); let mut pkg = Package::new(manifest, dir.to_path_buf()); if source == PluginSource::Installed && let Ok(digest) = std::fs::read_to_string(dir.join(install::HASH_FILE)) && !digest.trim().is_empty() { // Installed copies don't change: their validated UI declarations are indexed. pkg = pkg.with_digest(digest.trim()); } let plugin = Arc::new(LoadedPlugin { icon: plugin_icon(&pkg).ok().flatten(), pkg, invalid, source, dev_root, meta: Mutex::new(Meta::new()), batches: Arc::default(), runtime: Mutex::new(None), }); { let mut plugins = self.plugins.write(); if let Some(existing) = plugins.get(&id) { tracing::warn!( "plugin {id:?} at {} ignored: already loaded from {}", dir.display(), existing.dir().display() ); return id; } plugins.insert(id.clone(), plugin.clone()); } if let Some(e) = error { tracing::warn!("plugin {}: {e}", dir.display()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Failed; meta.error = Some(e); } else if !self.state.lock().disabled.contains(&id) { self.start(&plugin); } id } fn unload(&self, id: &str) { let Some(plugin) = self.plugins.write().remove(id) else { return }; self.stop(&plugin); } /// Validates, checks grants and commits the contributions; activates code /// whose activation event already fired. fn start(&self, plugin: &Arc) { self.registry.dispose_plugin(plugin.id()); if plugin.source == PluginSource::Installed && let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir()) { return self.set_failed(plugin, format!("revoked: {reason}")); } if plugin.source == PluginSource::Installed { let publisher = self.verified_publisher(plugin.dir()); plugin.meta.lock().publisher = publisher; } let set = match self.prepare(&plugin.pkg) { Ok(set) => set, Err(e) => return self.set_failed(plugin, e), }; let hash = self.package_hash_of(plugin); let request = request_for(&plugin.subject(), plugin.manifest(), &hash, &set.capabilities); let decision = { let mut ledger = self.ledger.lock(); let d = ledger.decide(&request); self.save_ledger(&ledger); d }; { let mut meta = plugin.meta.lock(); meta.warnings = set.warnings.clone(); meta.declared_extra = set.capabilities.clone(); meta.error = None; } let (granted, pending) = match decision { Decision::Allowed { granted, pending } => (granted, pending), Decision::NeedsConfirmation { new, .. } => { let mut meta = plugin.meta.lock(); meta.status = PluginStatus::NeedsConsent; meta.pending = new; meta.granted.clear(); return; } }; if let Err(e) = self.registry.commit(set) { return self.set_failed(plugin, e); } { let mut meta = plugin.meta.lock(); meta.granted = granted; meta.pending = pending; meta.status = if plugin.manifest().wasm.is_some() { PluginStatus::Inactive } else { PluginStatus::Static }; } let eager = plugin.manifest().activation.contains(&ActivationEvent::StartupFinished) && self.startup_finished.load(Ordering::Acquire); if eager { let _ = self.ensure_active(plugin, None); } } /// The plugin's running instance, instantiating and activating its code /// first if needed. Returns whether it was activated now. fn ensure_active(&self, plugin: &Arc, client: Option<&str>) -> IdeResult<(Arc, bool)> { let mut runtime = plugin.runtime.lock(); if let Some(actor) = runtime.as_ref() { if actor.is_alive() { return Ok((actor.clone(), false)); } // Crashed while nobody was waiting for it. let actor = actor.clone(); drop(runtime); self.reap(plugin, &actor, client); runtime = plugin.runtime.lock(); if let Some(actor) = runtime.as_ref().filter(|a| a.is_alive()) { return Ok((actor.clone(), false)); } } if plugin.status() != PluginStatus::Inactive { return Err(IdeError::unavailable(format!("plugin {:?} cannot be activated", plugin.id()))); } if let Some(wait) = plugin.meta.lock().retry_at.map(|at| at.saturating_duration_since(Instant::now())) && !wait.is_zero() { return Err(IdeError::unavailable(format!( "plugin {:?} crashed and restarts in {:.1}s", plugin.id(), wait.as_secs_f64() ))); } let Some(wasm) = plugin.manifest().wasm.clone() else { return Err(IdeError::unavailable(format!("plugin {:?} has no code", plugin.id()))); }; let started = Instant::now(); let granted = plugin.meta.lock().granted.clone(); let api: Arc = Arc::new(api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() }); let setup = RuntimeSetup { plugin_id: plugin.id().to_owned(), version: plugin.manifest().version.clone(), granted, host: api, limits: self.limits, }; let instance = self .engine .compile_cached(&plugin.dir().join(wasm), &self.component_cache()) .and_then(|component| Runtime::instantiate(&self.engine, &component, setup)); let mut rt = match instance { Ok(rt) => rt, Err(e) => { drop(runtime); if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { return Err(IdeError::unavailable(message)); } self.fail(plugin, &e, client); return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); } }; let crash = match rt.activate() { Ok(_) => None, Err(CallError::Trap(e)) => Some(format!("activation {e}")), Err(CallError::Guest(e)) => { drop(runtime); let e = format!("activation failed: {e}"); if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { return Err(IdeError::unavailable(message)); } self.fail(plugin, &e, client); return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); } }; if let Some(e) = crash { drop(runtime); if let Some(message) = self.roll_back_failed_update(plugin, &e, client) { return Err(IdeError::unavailable(message)); } self.crashed(plugin, &e, client); return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id()))); } // An update's first start: confirmed. self.updates.lock().remove(plugin.id()); let has_syntax = rt.has_syntax(); let actor = match Actor::spawn(plugin.id(), rt) { Ok(a) => Arc::new(a), Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))), }; *runtime = Some(actor.clone()); self.shared.allow_prompts(plugin.id()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Active; meta.has_syntax = has_syntax; meta.stats.activations += 1; meta.stats.activation_ms = started.elapsed().as_secs_f64() * 1000.0; Ok((actor, true)) } fn record_call(&self, plugin: &LoadedPlugin, elapsed: Duration, ok: bool, healthy: bool) { plugin.record_call(elapsed, ok, healthy); } /// Clears `actor` from the plugin if it ended, reporting its crash once. fn reap(&self, plugin: &LoadedPlugin, actor: &Arc, client: Option<&str>) { if actor.is_alive() { return; } { let mut runtime = plugin.runtime.lock(); if runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) { *runtime = None; } } let Some(error) = actor.take_crash() else { return }; self.clear_plugin_navigation(plugin.id()); if error.starts_with(CANCELLED) { // Cancelled on request: restart on next use, no backoff. let mut meta = plugin.meta.lock(); if meta.status == PluginStatus::Active { meta.status = PluginStatus::Inactive; } drop(meta); self.changed(); return; } self.crashed(plugin, &error, client); } /// A trap dropped the plugin's instance: restart it on next use after a /// backoff, or stop it for the session after too many crashes in a row. fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { let failures = { let mut meta = plugin.meta.lock(); meta.failures += 1; meta.stats.crashes += 1; meta.failures }; if failures >= self.limits.max_failures.max(1) { let error = if failures > 1 { format!("{error} (crashed {failures} times in a row)") } else { error.to_owned() }; return self.fail(plugin, &error, client); } let delay = self.limits.backoff(failures); tracing::warn!(plugin = %plugin.id(), "crashed ({failures}), restarting in {delay:?}: {error}"); { let mut meta = plugin.meta.lock(); if meta.status == PluginStatus::Active { meta.status = PluginStatus::Inactive; } meta.error = Some(error.to_owned()); meta.retry_at = Some(Instant::now() + delay); } if failures == 1 { self.emit( client, PluginEvent::Notification { plugin_id: plugin.id().to_owned(), level: NotificationLevel::Warning, title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name), body: format!("{error}\nIt restarts the next time it is needed."), actions: Vec::new(), }, ); } self.changed(); } /// Ends an idle instance; the plugin activates again on its next event. fn reclaim(&self, plugin: &LoadedPlugin, actor: &Arc) -> bool { { let mut runtime = plugin.runtime.lock(); if !runtime.as_ref().is_some_and(|r| Arc::ptr_eq(r, actor)) || actor.is_busy() { return false; } *runtime = None; } actor.stop(false); let mut meta = plugin.meta.lock(); if meta.status == PluginStatus::Active { meta.status = PluginStatus::Inactive; } meta.stats.reclaims += 1; tracing::debug!(plugin = %plugin.id(), "reclaimed after {:?} idle", actor.idle_for()); true } fn stop(&self, plugin: &LoadedPlugin) { self.clear_plugin_navigation(plugin.id()); // A call waiting on the user would keep the join below waiting. self.shared.block_prompts(plugin.id()); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(true); } self.registry.dispose_plugin(plugin.id()); self.shared.clear_plugin(plugin.id(), true); self.queues.lock().retain(|(id, _), _| id != plugin.id()); } fn set_failed(&self, plugin: &LoadedPlugin, error: String) { self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "{error}"); self.registry.dispose_plugin(plugin.id()); let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Failed; meta.error = Some(error); } fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}"); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(false); } self.shared.clear_plugin(plugin.id(), true); self.set_failed(plugin, error.to_owned()); self.emit( client, PluginEvent::Notification { plugin_id: plugin.id().to_owned(), level: NotificationLevel::Error, title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name), body: error.to_owned(), actions: Vec::new(), }, ); self.changed(); } /// Contributions or statuses changed: tell the UI and re-announce the theme if it changed. fn changed(&self) { // Held by this thread when called from a grant change, which refreshed them already. if let Some(ledger) = self.ledger.try_lock() { self.refresh_pins(&ledger); } self.emit(None, PluginEvent::CommandsChanged); let languages = self.languages.revision(); if self.announced_languages.swap(languages, Ordering::AcqRel) != languages { self.emit(None, PluginEvent::LanguagesChanged); } let active = self.active_theme(); let mut announced = self.announced_theme.lock(); if *announced != active { *announced = active.clone(); drop(announced); self.emit(None, PluginEvent::ThemeChanged { theme: active }); } } fn emit(&self, client: Option<&str>, event: PluginEvent) { self.sink.emit(client, event); } fn save_state(&self) { let json = serde_json::to_string_pretty(&*self.state.lock()).unwrap_or_default(); if let Err(e) = std::fs::write(self.plugins_dir.join(STATE_FILE), json) { tracing::warn!("saving plugin state: {e}"); } } fn save_ledger(&self, ledger: &Ledger) { if let Err(e) = ledger.save() { tracing::warn!("saving plugin grants: {e}"); } self.refresh_pins(ledger); // Grants of programs that exist only on a remote host change no local pin, // but what a remote server may start (`LanguagesChanged` re-syncs it). if !self.languages.with_owners().is_empty() { self.languages.invalidate(); } } /// Recomputes the approved programs of every plugin; plugin languages /// pick up a change on their next lookup. fn refresh_pins(&self, ledger: &Ledger) { let mut pins = HashMap::new(); for plugin in self.plugins.read().values() { let subject = plugin.subject(); let declared = plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone()); for cap in declared.filter(|c| matches!(c, Capability::Process(_))) { if let Some(bin) = ledger.pinned_binary(&subject, &cap) { pins.insert( (plugin.id().to_owned(), cap.to_string()), ProgramPin { path: bin.path, hash: bin.hash }, ); } } } let mut current = self.pins.write(); if *current != pins { *current = pins; drop(current); self.languages.invalidate(); } } fn links(&self) -> Links { std::fs::read_to_string(self.plugins_dir.join(LINKS_FILE)) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn save_links(&self, links: &Links) -> IdeResult<()> { let json = serde_json::to_string_pretty(links).map_err(|e| IdeError::internal(e.to_string()))?; std::fs::write(self.plugins_dir.join(LINKS_FILE), json)?; Ok(()) } /// The installed package's recorded hash; development plugins are granted by path. fn package_hash_of(&self, plugin: &LoadedPlugin) -> String { if plugin.source != PluginSource::Installed { return String::new(); } let file = plugin.dir().join(install::HASH_FILE); if let Ok(h) = std::fs::read_to_string(&file) { return h.trim().to_owned(); } // Installed before hashes were recorded: hash once and remember. 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); hash } /// One-time migration from 0.1, where declaring a permission granted it. fn migrate_grants(&self, installed: &[PathBuf]) { let mut ledger = self.ledger.lock(); if !ledger.needs_migration() { return; } let manifests: Vec = installed.iter().filter_map(|d| Manifest::load(d).ok()).collect(); let requests: Vec> = manifests.iter().map(|m| request_for(&Subject::Installed(m.id.clone()), m, "", &[])).collect(); ledger.migrate(&requests); self.save_ledger(&ledger); } fn record_consent(&self, request: &Request<'_>, consent: &Consent) { let accepted: Vec = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect(); let binaries: BTreeMap = request .required .iter() .chain(&accepted) .filter_map(|c| match c { Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)), _ => None, }) .collect(); let mut ledger = self.ledger.lock(); ledger.confirm(request, &accepted, binaries); self.save_ledger(&ledger); } fn resolve_binary(&self, program: &str) -> Option { let path_var = (self.path_var.read())(); let path = ledger::resolve_program(program, path_var.as_deref()) .map_err(|e| tracing::info!("process:{program}: {e}")) .ok()?; let hash = ledger::file_hash(&path).ok()?; Some(BinaryGrant { path, hash }) } fn info(&self, p: &LoadedPlugin) -> PluginInfo { let m = p.manifest(); // The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the // ledger and locks each plugin's): the other order deadlocks. let grant = self.ledger.lock().get(&p.subject()).cloned(); let meta = p.meta.lock(); let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.confirmed && g.has(c)); let mut capabilities: Vec = m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); for extra in &meta.declared_extra { if !m.declares(extra) { capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); } } PluginInfo { id: m.id.clone(), name: m.name.clone(), version: m.version.clone(), description: m.description.clone(), 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), status: meta.status, error: meta.error.clone(), has_code: m.wasm.is_some(), capabilities, contributions: self.registry.contributions(&m.id), activation: m.activation.iter().map(ToString::to_string).collect(), warnings: meta.warnings.clone(), stats: PluginStats { p95_call_ms: meta.p95(), ..meta.stats }, publisher: meta.publisher.clone(), previous_version: (p.source == PluginSource::Installed) .then(|| install::previous_version(&self.plugins_dir.join(&m.id))) .flatten(), } } } impl Drop for PluginHost { fn drop(&mut self) { self.shutdown(); } } fn request_for<'a>(subject: &Subject, m: &'a Manifest, hash: &'a str, declared: &[Capability]) -> Request<'a> { let mut optional = m.capabilities.optional.clone(); // What declarations need (a language server's `process:`) is optional: // refusing it keeps the rest of the plugin working. for cap in declared { if !m.declares(cap) && !optional.contains(cap) { optional.push(cap.clone()); } } Request { subject: subject.clone(), plugin_id: &m.id, version: &m.version, package_hash: hash, required: m.capabilities.required.clone(), optional, } } fn counts(m: &Manifest) -> Vec { m.declaration_counts().into_iter().map(|(point, count)| ContributionCount { point, count }).collect() } fn activation_matches(declared: &ActivationEvent, fired: &ActivationEvent) -> bool { match (declared, fired) { (ActivationEvent::Language(a), ActivationEvent::Language(b)) => a == b || a == "*", (ActivationEvent::View(a), ActivationEvent::View(b)) => a == b, (ActivationEvent::File(glob), ActivationEvent::File(path)) => glob_match(glob, path), _ => false, } } /// `**` matches across `/`, `*` and `?` within one segment. fn glob_match(glob: &str, path: &str) -> bool { fn go(p: &[u8], s: &[u8]) -> bool { match p.first() { None => s.is_empty(), Some(b'*') if p.get(1) == Some(&b'*') => { let rest = p[2..].strip_prefix(b"/").unwrap_or(&p[2..]); if rest.is_empty() { return true; } (0..=s.len()).any(|i| (i == 0 || s[i - 1] == b'/') && go(rest, &s[i..])) || go(rest, s) } Some(b'*') => (0..=s.len()).take_while(|&i| i == 0 || s[i - 1] != b'/').any(|i| go(&p[1..], &s[i..])), Some(b'?') => s.first().is_some_and(|c| *c != b'/') && go(&p[1..], &s[1..]), Some(c) => s.first() == Some(c) && go(&p[1..], &s[1..]), } } go(glob.as_bytes(), path.as_bytes()) } fn is_hex_color(s: &str) -> bool { s.strip_prefix('#').is_some_and(|h| matches!(h.len(), 3 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit())) } /// Subdirectories of `dir` that contain a `plugin.toml`, sorted. /// The active version of every installed plugin (`/`, or `` /// for installs from before versioning). fn package_dirs(dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; let mut dirs: Vec = entries .flatten() .map(|e| e.path()) .filter(|p| !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))) .filter_map(|p| install::active_dir(&p)) .collect(); dirs.sort(); dirs } /// Stand-in for a package whose manifest failed to parse, so it still shows up (as failed). fn placeholder_manifest(dir: &Path) -> Manifest { let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); Manifest { id: format!("invalid-{}", name.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "-")), name, version: "0.0.0".into(), description: String::new(), authors: Vec::new(), homepage: None, license: None, change_notes: None, declared_categories: Vec::new(), icon: None, icon_dark: None, engine: ide_extension::ENGINE_VERSION.into(), legacy: false, wasm: None, activation: Vec::new(), capabilities: Default::default(), contributes: Default::default(), } } /// [`PluginHost::syntax_provider`]: plugin languages' `syntax-provider` exports. struct PluginSyntax(std::sync::Weak); type SyntaxFn = fn(&mut Runtime, &str, &str, Duration) -> Result; /// A syntax call with its own copies of the arguments, to run on the plugin's thread. fn owned( lang: &LanguageId, text: &str, f: SyntaxFn, ) -> impl FnOnce(&mut Runtime, Duration) -> Result + Send + 'static { let (lang, text) = (lang.as_str().to_owned(), text.to_owned()); move |rt, timeout| f(rt, &lang, &text, timeout) } impl ide_language::ExternalSyntax for PluginSyntax { fn highlights(&self, lang: &LanguageId, text: &str) -> Option> { let tokens = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_highlights))?; Some( tokens .into_iter() .map(|t| ide_language::ExternalToken { start: t.start, end: t.end, kind: t.kind }) .collect(), ) } fn outline(&self, lang: &LanguageId, text: &str) -> Option> { let symbols = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_outline))?; Some( symbols .into_iter() .map(|s| ide_language::ExternalSymbol { name: s.name, detail: s.detail, kind: s.kind, start: s.start, end: s.end, name_start: s.name_start, name_end: s.name_end, parent: s.parent, }) .collect(), ) } fn folding(&self, lang: &LanguageId, text: &str) -> Option> { let folds = self.0.upgrade()?.syntax_call(lang, owned(lang, text, Runtime::syntax_folding))?; Some( folds .into_iter() .map(|f| ide_language::ExternalFold { start_line: f.start_line, end_line: f.end_line, kind: f.kind }) .collect(), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn globs() { assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml")); assert!(glob_match("**/*Mapper.xml", "UserMapper.xml")); assert!(!glob_match("*.xml", "a/b.xml")); assert!(glob_match("pom.xml", "pom.xml")); assert!(glob_match("src/**", "src/a/b")); assert!(glob_match("a?c", "abc") && !glob_match("a?c", "a/c")); } }