Uninstall plugins from settings

This commit is contained in:
2026-05-07 23:28:36 -04:00
parent 8ec468f57e
commit eddd694ae9
6 changed files with 230 additions and 4 deletions
@@ -5,7 +5,7 @@ use std::{
}; };
use directories::ProjectDirs; use directories::ProjectDirs;
use ely_domain::PluginManifest; use ely_domain::{PluginId, PluginManifest};
use thiserror::Error; use thiserror::Error;
use super::plugin_packages::{PluginPackageError, PluginPackageReader, VerifiedPluginPackage}; use super::plugin_packages::{PluginPackageError, PluginPackageReader, VerifiedPluginPackage};
@@ -59,6 +59,9 @@ pub enum PluginPackageStoreError {
#[error("failed to clean plugin package staging directory: {path}")] #[error("failed to clean plugin package staging directory: {path}")]
CleanupFailed { path: PathBuf, source: io::Error }, 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")] #[error("system clock is unavailable for plugin package staging")]
ClockUnavailable { source: SystemTimeError }, 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 { fn package_path(&self, package: &VerifiedPluginPackage) -> PathBuf {
self.plugin_root(package).join(format!("{}.rplug", package.package_hash())) self.plugin_root(package).join(format!("{}.rplug", package.package_hash()))
} }
@@ -296,6 +308,21 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn removes_stored_packages_for_plugin() -> Result<(), Box<dyn Error>> {
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<PathBuf, Box<dyn Error>> { fn write_package(root: &Path, name: &str, component: &[u8]) -> Result<PathBuf, Box<dyn Error>> {
let package = root.join(format!("{name}.rplug")); let package = root.join(format!("{name}.rplug"));
fs::create_dir_all(package.join("signatures"))?; fs::create_dir_all(package.join("signatures"))?;
@@ -9,7 +9,7 @@ use gpui_component::{
scroll::ScrollableElement, scroll::ScrollableElement,
}; };
use super::super::plugins::PendingPluginInstall; use super::super::plugins::{PendingPluginInstall, PendingPluginUninstall};
use super::{ElyShell, render_canvas_surface}; use super::{ElyShell, render_canvas_surface};
impl ElyShell { impl ElyShell {
@@ -92,6 +92,9 @@ impl ElyShell {
.when_some(self.pending_plugin_install.clone(), |this, pending| { .when_some(self.pending_plugin_install.clone(), |this, pending| {
this.child(render_plugin_install_confirmation(&pending, cx)) 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_list(snapshot, cx))
.child(render_plugin_audit_list(snapshot)), .child(render_plugin_audit_list(snapshot)),
) )
@@ -171,6 +174,68 @@ fn render_plugin_install_confirmation(
.into_any_element() .into_any_element()
} }
fn render_plugin_uninstall_confirmation(
pending: &PendingPluginUninstall,
cx: &mut Context<ElyShell>,
) -> 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<ElyShell>) -> AnyElement { fn render_plugin_list(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
if snapshot.installed_plugins.is_empty() { if snapshot.installed_plugins.is_empty() {
return div() return div()
@@ -208,6 +273,7 @@ fn render_plugin_row(
let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED }; let status_color = if plugin.enabled() { colors::SUCCESS } else { colors::MUTED };
let status_label = if plugin.enabled() { "Enabled" } else { "Disabled" }; let status_label = if plugin.enabled() { "Enabled" } else { "Disabled" };
let plugin_id = plugin.id().clone(); let plugin_id = plugin.id().clone();
let plugin_name = plugin.manifest().name().to_string();
let target_enabled = !plugin.enabled(); let target_enabled = !plugin.enabled();
div() div()
@@ -250,7 +316,14 @@ fn render_plugin_row(
.child(format!("{} permissions", plugin.manifest().permissions().len())) .child(format!("{} permissions", plugin.manifest().permissions().len()))
.child(format!("{high_risk_count} high risk")) .child(format!("{high_risk_count} high risk"))
.child(div().text_color(rgb(status_color)).child(status_label)) .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() .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<ElyShell>,
) -> 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 { fn render_plugin_audit_list(snapshot: &BrowserSnapshot) -> AnyElement {
if snapshot.plugin_audit_events.is_empty() { if snapshot.plugin_audit_events.is_empty() {
return div().into_any_element(); return div().into_any_element();
@@ -332,6 +423,7 @@ fn plugin_audit_action_label(action: &PluginAuditAction) -> &'static str {
PluginAuditAction::Installed => "Installed", PluginAuditAction::Installed => "Installed",
PluginAuditAction::Enabled => "Enabled", PluginAuditAction::Enabled => "Enabled",
PluginAuditAction::Disabled => "Disabled", PluginAuditAction::Disabled => "Disabled",
PluginAuditAction::Uninstalled => "Uninstalled",
} }
} }
+3 -1
View File
@@ -9,7 +9,7 @@ use gpui::{App, AppContext, Context, Entity, FocusHandle, Focusable, Subscriptio
use gpui_component::input::{InputEvent, InputState, SelectAll}; use gpui_component::input::{InputEvent, InputState, SelectAll};
use downloads::PendingDownloadFileAction; use downloads::PendingDownloadFileAction;
use plugins::PendingPluginInstall; use plugins::{PendingPluginInstall, PendingPluginUninstall};
use crate::{ use crate::{
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
@@ -32,6 +32,7 @@ pub struct ElyShell {
download_security_confirmation: Option<PendingDownloadFileAction>, download_security_confirmation: Option<PendingDownloadFileAction>,
plugin_install_error: Option<String>, plugin_install_error: Option<String>,
pending_plugin_install: Option<PendingPluginInstall>, pending_plugin_install: Option<PendingPluginInstall>,
pending_plugin_uninstall: Option<PendingPluginUninstall>,
_command_subscription: Subscription, _command_subscription: Subscription,
} }
@@ -92,6 +93,7 @@ impl ElyShell {
download_security_confirmation: None, download_security_confirmation: None,
plugin_install_error: None, plugin_install_error: None,
pending_plugin_install: None, pending_plugin_install: None,
pending_plugin_uninstall: None,
_command_subscription: command_subscription, _command_subscription: command_subscription,
} }
} }
+58
View File
@@ -16,6 +16,12 @@ pub(super) struct PendingPluginInstall {
high_risk_permissions: Vec<PluginPermission>, high_risk_permissions: Vec<PluginPermission>,
} }
#[derive(Clone, Debug)]
pub(super) struct PendingPluginUninstall {
plugin_id: PluginId,
plugin_name: String,
}
impl PendingPluginInstall { impl PendingPluginInstall {
fn new(package: VerifiedPluginPackage, high_risk_permissions: Vec<PluginPermission>) -> Self { fn new(package: VerifiedPluginPackage, high_risk_permissions: Vec<PluginPermission>) -> Self {
Self { package, high_risk_permissions } 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 { impl ElyShell {
pub(super) fn choose_plugin_package(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub(super) fn choose_plugin_package(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions { let prompt = cx.prompt_for_paths(PathPromptOptions {
@@ -86,6 +106,31 @@ impl ElyShell {
cx.notify(); cx.notify();
} }
pub(super) fn request_plugin_uninstall(
&mut self,
plugin_id: PluginId,
plugin_name: String,
cx: &mut Context<Self>,
) {
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>) {
self.pending_plugin_uninstall = None;
cx.notify();
}
pub(super) fn confirm_plugin_uninstall(&mut self, cx: &mut Context<Self>) {
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( pub(super) fn set_plugin_enabled(
&mut self, &mut self,
plugin_id: PluginId, plugin_id: PluginId,
@@ -108,6 +153,19 @@ impl ElyShell {
cx.notify(); cx.notify();
} }
fn uninstall_plugin(&mut self, plugin_id: PluginId, cx: &mut Context<Self>) {
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( fn handle_plugin_package_result(
&mut self, &mut self,
result: Result<VerifiedPluginPackage, PluginPackageError>, result: Result<VerifiedPluginPackage, PluginPackageError>,
@@ -19,6 +19,7 @@ pub enum PluginAuditAction {
Installed, Installed,
Enabled, Enabled,
Disabled, Disabled,
Uninstalled,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -119,6 +120,18 @@ impl BrowserCore {
Ok(()) 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) { fn record_plugin_audit_event(&mut self, plugin_id: PluginId, action: PluginAuditAction) {
self.plugin_audit_events.push(PluginAuditEvent::record( self.plugin_audit_events.push(PluginAuditEvent::record(
plugin_id, plugin_id,
+34
View File
@@ -95,6 +95,40 @@ fn records_plugin_enable_disable_audit_events() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
fn uninstalls_plugin_and_records_audit_event() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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] #[test]
fn rejects_unknown_plugin_state_change() -> Result<(), Box<dyn Error>> { fn rejects_unknown_plugin_state_change() -> Result<(), Box<dyn Error>> {
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;