From eddd694ae93db8ce33fa6d89c270934c3b51b1a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 7 May 2026 23:28:36 -0400 Subject: [PATCH] Uninstall plugins from settings --- .../src/services/plugin_package_store.rs | 29 +++++- .../src/shell/internal_pages/plugins.rs | 96 ++++++++++++++++++- crates/ely_app/src/shell/mod.rs | 4 +- crates/ely_app/src/shell/plugins.rs | 58 +++++++++++ crates/ely_browser_core/src/state/plugins.rs | 13 +++ crates/ely_browser_core/tests/plugins.rs | 34 +++++++ 6 files changed, 230 insertions(+), 4 deletions(-) diff --git a/crates/ely_app/src/services/plugin_package_store.rs b/crates/ely_app/src/services/plugin_package_store.rs index c43a7bc..50ebe45 100644 --- a/crates/ely_app/src/services/plugin_package_store.rs +++ b/crates/ely_app/src/services/plugin_package_store.rs @@ -5,7 +5,7 @@ use std::{ }; use directories::ProjectDirs; -use ely_domain::PluginManifest; +use ely_domain::{PluginId, PluginManifest}; use thiserror::Error; use super::plugin_packages::{PluginPackageError, PluginPackageReader, VerifiedPluginPackage}; @@ -59,6 +59,9 @@ pub enum PluginPackageStoreError { #[error("failed to clean plugin package staging directory: {path}")] CleanupFailed { path: PathBuf, source: io::Error }, + #[error("failed to remove plugin package directory: {path}")] + RemoveDirectoryFailed { path: PathBuf, source: io::Error }, + #[error("system clock is unavailable for plugin package staging")] ClockUnavailable { source: SystemTimeError }, } @@ -137,6 +140,15 @@ impl PluginPackageStore { )) } + pub fn remove_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginPackageStoreError> { + let path = self.root.join(plugin_id.as_str()); + match fs::remove_dir_all(path.as_path()) { + Ok(()) => Ok(()), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(PluginPackageStoreError::RemoveDirectoryFailed { path, source }), + } + } + fn package_path(&self, package: &VerifiedPluginPackage) -> PathBuf { self.plugin_root(package).join(format!("{}.rplug", package.package_hash())) } @@ -296,6 +308,21 @@ mod tests { Ok(()) } + #[test] + fn removes_stored_packages_for_plugin() -> Result<(), Box> { + let tree = TempTree::new("remove")?; + let source_package = write_package(tree.path(), "verified", b"wasm component")?; + let package = PluginPackageReader::read_directory_package(source_package.as_path())?; + let store = PluginPackageStore::new(tree.path().join("store")); + let stored_package = store.store(&package)?; + + store.remove_plugin(package.manifest().id())?; + + assert!(!stored_package.path.exists()); + assert!(!tree.path().join("store").join("com.elydora.verified").exists()); + Ok(()) + } + fn write_package(root: &Path, name: &str, component: &[u8]) -> Result> { let package = root.join(format!("{name}.rplug")); fs::create_dir_all(package.join("signatures"))?; diff --git a/crates/ely_app/src/shell/internal_pages/plugins.rs b/crates/ely_app/src/shell/internal_pages/plugins.rs index 01ce01a..11601dd 100644 --- a/crates/ely_app/src/shell/internal_pages/plugins.rs +++ b/crates/ely_app/src/shell/internal_pages/plugins.rs @@ -9,7 +9,7 @@ use gpui_component::{ scroll::ScrollableElement, }; -use super::super::plugins::PendingPluginInstall; +use super::super::plugins::{PendingPluginInstall, PendingPluginUninstall}; use super::{ElyShell, render_canvas_surface}; impl ElyShell { @@ -92,6 +92,9 @@ impl ElyShell { .when_some(self.pending_plugin_install.clone(), |this, pending| { this.child(render_plugin_install_confirmation(&pending, cx)) }) + .when_some(self.pending_plugin_uninstall.clone(), |this, pending| { + this.child(render_plugin_uninstall_confirmation(&pending, cx)) + }) .child(render_plugin_list(snapshot, cx)) .child(render_plugin_audit_list(snapshot)), ) @@ -171,6 +174,68 @@ fn render_plugin_install_confirmation( .into_any_element() } +fn render_plugin_uninstall_confirmation( + pending: &PendingPluginUninstall, + cx: &mut Context, +) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::ERROR)) + .px_3() + .py_2() + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(colors::ERROR)) + .child(format!("Confirm uninstall for {}", pending.plugin_name())), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(pending.plugin_id().as_str().to_string()), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("cancel-plugin-uninstall") + .ghost() + .xsmall() + .label("Cancel") + .on_click(cx.listener(|shell, _, _, cx| { + shell.cancel_plugin_uninstall(cx); + })), + ) + .child( + Button::new("confirm-plugin-uninstall") + .danger() + .xsmall() + .label("Uninstall") + .on_click(cx.listener(|shell, _, _, cx| { + shell.confirm_plugin_uninstall(cx); + })), + ), + ) + .into_any_element() +} + fn render_plugin_list(snapshot: &BrowserSnapshot, cx: &mut Context) -> AnyElement { if snapshot.installed_plugins.is_empty() { return div() @@ -208,6 +273,7 @@ fn render_plugin_row( let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED }; let status_label = if plugin.enabled() { "Enabled" } else { "Disabled" }; let plugin_id = plugin.id().clone(); + let plugin_name = plugin.manifest().name().to_string(); let target_enabled = !plugin.enabled(); div() @@ -250,7 +316,14 @@ fn render_plugin_row( .child(format!("{} permissions", plugin.manifest().permissions().len())) .child(format!("{high_risk_count} high risk")) .child(div().text_color(rgb(status_color)).child(status_label)) - .child(render_plugin_state_button(index, plugin, plugin_id, target_enabled, cx)), + .child(render_plugin_state_button( + index, + plugin, + plugin_id.clone(), + target_enabled, + cx, + )) + .child(render_plugin_uninstall_button(index, plugin_id, plugin_name, cx)), ) .into_any_element() } @@ -281,6 +354,24 @@ fn render_plugin_state_button( } } +fn render_plugin_uninstall_button( + index: usize, + plugin_id: PluginId, + plugin_name: String, + cx: &mut Context, +) -> AnyElement { + Button::new(("uninstall-plugin", index)) + .danger() + .xsmall() + .icon(IconName::Delete) + .label("Remove") + .tooltip("Uninstall Plugin") + .on_click(cx.listener(move |shell, _, _, cx| { + shell.request_plugin_uninstall(plugin_id.clone(), plugin_name.clone(), cx); + })) + .into_any_element() +} + fn render_plugin_audit_list(snapshot: &BrowserSnapshot) -> AnyElement { if snapshot.plugin_audit_events.is_empty() { return div().into_any_element(); @@ -332,6 +423,7 @@ fn plugin_audit_action_label(action: &PluginAuditAction) -> &'static str { PluginAuditAction::Installed => "Installed", PluginAuditAction::Enabled => "Enabled", PluginAuditAction::Disabled => "Disabled", + PluginAuditAction::Uninstalled => "Uninstalled", } } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 77df7c3..afdf634 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -9,7 +9,7 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio use gpui_component::input::{InputEvent, InputState, SelectAll}; use downloads::PendingDownloadFileAction; -use plugins::PendingPluginInstall; +use plugins::{PendingPluginInstall, PendingPluginUninstall}; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, @@ -32,6 +32,7 @@ pub struct ElyShell { download_security_confirmation: Option, plugin_install_error: Option, pending_plugin_install: Option, + pending_plugin_uninstall: Option, _command_subscription: Subscription, } @@ -92,6 +93,7 @@ impl ElyShell { download_security_confirmation: None, plugin_install_error: None, pending_plugin_install: None, + pending_plugin_uninstall: None, _command_subscription: command_subscription, } } diff --git a/crates/ely_app/src/shell/plugins.rs b/crates/ely_app/src/shell/plugins.rs index 1a5f37f..56b1b06 100644 --- a/crates/ely_app/src/shell/plugins.rs +++ b/crates/ely_app/src/shell/plugins.rs @@ -16,6 +16,12 @@ pub(super) struct PendingPluginInstall { high_risk_permissions: Vec, } +#[derive(Clone, Debug)] +pub(super) struct PendingPluginUninstall { + plugin_id: PluginId, + plugin_name: String, +} + impl PendingPluginInstall { fn new(package: VerifiedPluginPackage, high_risk_permissions: Vec) -> Self { Self { package, high_risk_permissions } @@ -30,6 +36,20 @@ impl PendingPluginInstall { } } +impl PendingPluginUninstall { + fn new(plugin_id: PluginId, plugin_name: String) -> Self { + Self { plugin_id, plugin_name } + } + + pub(super) fn plugin_id(&self) -> &PluginId { + &self.plugin_id + } + + pub(super) fn plugin_name(&self) -> &str { + &self.plugin_name + } +} + impl ElyShell { pub(super) fn choose_plugin_package(&mut self, window: &mut Window, cx: &mut Context) { let prompt = cx.prompt_for_paths(PathPromptOptions { @@ -86,6 +106,31 @@ impl ElyShell { cx.notify(); } + pub(super) fn request_plugin_uninstall( + &mut self, + plugin_id: PluginId, + plugin_name: String, + cx: &mut Context, + ) { + self.pending_plugin_uninstall = Some(PendingPluginUninstall::new(plugin_id, plugin_name)); + self.plugin_install_error = None; + cx.notify(); + } + + pub(super) fn cancel_plugin_uninstall(&mut self, cx: &mut Context) { + self.pending_plugin_uninstall = None; + cx.notify(); + } + + pub(super) fn confirm_plugin_uninstall(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_plugin_uninstall.take() else { + cx.notify(); + return; + }; + + self.uninstall_plugin(pending.plugin_id, cx); + } + pub(super) fn set_plugin_enabled( &mut self, plugin_id: PluginId, @@ -108,6 +153,19 @@ impl ElyShell { cx.notify(); } + fn uninstall_plugin(&mut self, plugin_id: PluginId, cx: &mut Context) { + let result = match &mut self.state { + ShellState::Ready(core) => PluginPackageStore::application() + .and_then(|store| store.remove_plugin(&plugin_id)) + .map_err(|error| error.to_string()) + .and_then(|_| core.uninstall_plugin(&plugin_id).map_err(|error| error.to_string())), + ShellState::StartupError(message) => Err(message.clone()), + }; + + self.plugin_install_error = result.err(); + cx.notify(); + } + fn handle_plugin_package_result( &mut self, result: Result, diff --git a/crates/ely_browser_core/src/state/plugins.rs b/crates/ely_browser_core/src/state/plugins.rs index b68b668..8f04f62 100644 --- a/crates/ely_browser_core/src/state/plugins.rs +++ b/crates/ely_browser_core/src/state/plugins.rs @@ -19,6 +19,7 @@ pub enum PluginAuditAction { Installed, Enabled, Disabled, + Uninstalled, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -119,6 +120,18 @@ impl BrowserCore { Ok(()) } + pub fn uninstall_plugin(&mut self, plugin_id: &PluginId) -> Result<(), CoreError> { + let plugin_index = self + .installed_plugins + .iter() + .position(|plugin| plugin.id() == plugin_id) + .ok_or_else(|| CoreError::PluginNotFound { id: plugin_id.clone() })?; + + self.installed_plugins.remove(plugin_index); + self.record_plugin_audit_event(plugin_id.clone(), PluginAuditAction::Uninstalled); + Ok(()) + } + fn record_plugin_audit_event(&mut self, plugin_id: PluginId, action: PluginAuditAction) { self.plugin_audit_events.push(PluginAuditEvent::record( plugin_id, diff --git a/crates/ely_browser_core/tests/plugins.rs b/crates/ely_browser_core/tests/plugins.rs index 21ac276..659d26b 100644 --- a/crates/ely_browser_core/tests/plugins.rs +++ b/crates/ely_browser_core/tests/plugins.rs @@ -95,6 +95,40 @@ fn records_plugin_enable_disable_audit_events() -> Result<(), Box> { Ok(()) } +#[test] +fn uninstalls_plugin_and_records_audit_event() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + + core.uninstall_plugin(&plugin_id)?; + let snapshot = core.snapshot()?; + + assert!(snapshot.installed_plugins.is_empty()); + assert_eq!(snapshot.plugin_audit_events.len(), 2); + assert_eq!(snapshot.plugin_audit_events[1].plugin_id(), &plugin_id); + assert_eq!(snapshot.plugin_audit_events[1].action(), &PluginAuditAction::Uninstalled); + Ok(()) +} + +#[test] +fn allows_reinstall_after_plugin_uninstall() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let plugin_id = + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + core.uninstall_plugin(&plugin_id)?; + + let reinstalled_id = + core.install_plugin(plugin_manifest("com.elydora.reader", &["page:metadata"])?, false)?; + let snapshot = core.snapshot()?; + + assert_eq!(reinstalled_id, plugin_id); + assert_eq!(snapshot.installed_plugins.len(), 1); + assert_eq!(snapshot.plugin_audit_events.len(), 3); + assert_eq!(snapshot.plugin_audit_events[2].action(), &PluginAuditAction::Installed); + Ok(()) +} + #[test] fn rejects_unknown_plugin_state_change() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;