Uninstall plugins from settings
This commit is contained in:
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()?)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user