From 6bd2e3f69f9a6203a7fe7e3e5e9d8c22c95e3af2 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 22:53:39 -0400 Subject: [PATCH] Install plugin packages --- crates/ely_app/src/services/mod.rs | 1 + .../ely_app/src/services/plugin_packages.rs | 232 ++++++++++++++++++ crates/ely_app/src/shell/internal_pages.rs | 2 +- .../src/shell/internal_pages/plugins.rs | 129 +++++++++- crates/ely_app/src/shell/mod.rs | 6 + crates/ely_app/src/shell/plugins.rs | 134 ++++++++++ 6 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 crates/ely_app/src/services/plugin_packages.rs create mode 100644 crates/ely_app/src/shell/plugins.rs diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index fa55803..c99d24b 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod download_checksums; pub mod download_files; +pub mod plugin_packages; diff --git a/crates/ely_app/src/services/plugin_packages.rs b/crates/ely_app/src/services/plugin_packages.rs new file mode 100644 index 0000000..53d2e1c --- /dev/null +++ b/crates/ely_app/src/services/plugin_packages.rs @@ -0,0 +1,232 @@ +use std::{ + fs, + io::{self, Read}, + path::{Path, PathBuf}, +}; + +use ely_domain::PluginManifest; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PluginPackageError { + #[error("plugin package must use .rplug extension: {path}")] + InvalidPackageExtension { path: PathBuf }, + + #[error("plugin package is unavailable: {path}")] + PackageUnavailable { path: PathBuf }, + + #[error("plugin package must be a directory: {path}")] + PackageNotDirectory { path: PathBuf }, + + #[error("plugin package is missing {entry}: {path}")] + MissingEntry { entry: &'static str, path: PathBuf }, + + #[error("failed to read plugin package entry {entry}: {path}")] + ReadFailed { entry: &'static str, path: PathBuf, source: io::Error }, + + #[error("plugin package checksum mismatch for component.wasm")] + ChecksumMismatch, + + #[error("plugin package signature mismatch for signatures/ed25519.sig")] + SignatureMismatch, + + #[error(transparent)] + Manifest(#[from] ely_domain::DomainError), +} + +pub struct PluginPackageReader; + +impl PluginPackageReader { + pub fn read_directory_package(path: &Path) -> Result { + require_rplug_directory(path)?; + + let manifest_path = path.join("plugin.toml"); + require_file("plugin.toml", &manifest_path)?; + let manifest_text = read_text("plugin.toml", &manifest_path)?; + let manifest = PluginManifest::from_toml(manifest_text.as_str())?; + + let component_path = path.join("component.wasm"); + require_file("component.wasm", &component_path)?; + let component_checksum = sha256_file(&component_path)?; + if component_checksum != manifest.checksum() { + return Err(PluginPackageError::ChecksumMismatch); + } + + let signature_path = path.join("signatures").join("ed25519.sig"); + require_file("signatures/ed25519.sig", &signature_path)?; + let signature = read_text("signatures/ed25519.sig", &signature_path)?; + if signature.trim().to_ascii_lowercase() != manifest.signature().value() { + return Err(PluginPackageError::SignatureMismatch); + } + + Ok(manifest) + } +} + +fn require_rplug_directory(path: &Path) -> Result<(), PluginPackageError> { + if path.extension().and_then(|value| value.to_str()) != Some("rplug") { + return Err(PluginPackageError::InvalidPackageExtension { path: path.to_path_buf() }); + } + + let metadata = fs::metadata(path) + .map_err(|_| PluginPackageError::PackageUnavailable { path: path.to_path_buf() })?; + if !metadata.is_dir() { + return Err(PluginPackageError::PackageNotDirectory { path: path.to_path_buf() }); + } + + Ok(()) +} + +fn require_file(entry: &'static str, path: &Path) -> Result<(), PluginPackageError> { + let metadata = fs::metadata(path) + .map_err(|_| PluginPackageError::MissingEntry { entry, path: path.to_path_buf() })?; + if !metadata.is_file() { + return Err(PluginPackageError::MissingEntry { entry, path: path.to_path_buf() }); + } + + Ok(()) +} + +fn read_text(entry: &'static str, path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| PluginPackageError::ReadFailed { + entry, + path: path.to_path_buf(), + source, + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|source| PluginPackageError::ReadFailed { + entry: "component.wasm", + path: path.to_path_buf(), + source, + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + + loop { + let read = file.read(&mut buffer).map_err(|source| PluginPackageError::ReadFailed { + entry: "component.wasm", + path: path.to_path_buf(), + source, + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use std::{ + error::Error, + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use sha2::{Digest, Sha256}; + + use super::PluginPackageReader; + + #[test] + fn reads_verified_directory_package() -> Result<(), Box> { + let package = write_package("verified", b"wasm component")?; + + let manifest = PluginPackageReader::read_directory_package(&package)?; + + assert_eq!(manifest.id().as_str(), "com.elydora.verified"); + assert_eq!(manifest.name(), "Verified Plugin"); + Ok(()) + } + + #[test] + fn rejects_package_checksum_mismatch() -> Result<(), Box> { + let package = write_package("checksum", b"wasm component")?; + fs::write(package.join("component.wasm"), b"changed")?; + + let error = PluginPackageReader::read_directory_package(&package) + .err() + .ok_or_else(|| std::io::Error::other("package read succeeded"))?; + + assert!(matches!(error, super::PluginPackageError::ChecksumMismatch)); + Ok(()) + } + + #[test] + fn rejects_package_signature_mismatch() -> Result<(), Box> { + let package = write_package("signature", b"wasm component")?; + fs::write(package.join("signatures").join("ed25519.sig"), "aa")?; + + let error = PluginPackageReader::read_directory_package(&package) + .err() + .ok_or_else(|| std::io::Error::other("package read succeeded"))?; + + assert!(matches!(error, super::PluginPackageError::SignatureMismatch)); + Ok(()) + } + + #[test] + fn rejects_non_package_directory() -> Result<(), Box> { + let package = temp_root()?.join("invalid"); + fs::create_dir_all(&package)?; + + let error = PluginPackageReader::read_directory_package(&package) + .err() + .ok_or_else(|| std::io::Error::other("package read succeeded"))?; + + assert!(matches!(error, super::PluginPackageError::InvalidPackageExtension { .. })); + Ok(()) + } + + fn write_package(name: &str, component: &[u8]) -> Result> { + let package = temp_root()?.join(format!("{name}.rplug")); + fs::create_dir_all(package.join("signatures"))?; + fs::write(package.join("component.wasm"), component)?; + + let checksum = sha256_bytes(component); + let signature = "b".repeat(128); + fs::write(package.join("signatures").join("ed25519.sig"), &signature)?; + fs::write( + package.join("plugin.toml"), + manifest_toml(name, checksum.as_str(), signature.as_str()), + )?; + + Ok(package) + } + + fn manifest_toml(name: &str, checksum: &str, signature: &str) -> String { + format!( + r#" +id = "com.elydora.{name}" +name = "Verified Plugin" +description = "Exports verified content." +author = "Elydora" +homepage = "https://elydora.com/plugins/{name}" +permissions = ["page:metadata"] +contributes = ["command-bar-command"] +min_ely_build = "0.1.0" +checksum = "{checksum}" + +[signature] +algorithm = "ed25519" +value = "{signature}" +"# + ) + } + + fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) + } + + fn temp_root() -> Result> { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let path = std::env::temp_dir().join(format!("ely-plugin-package-{nanos}")); + fs::create_dir_all(&path)?; + Ok(path) + } +} diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 4546290..173c8f5 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -25,7 +25,7 @@ impl ElyShell { "ely://downloads" => self.render_downloads_page(snapshot, cx), "ely://history" => self.render_history_page(snapshot, cx), "ely://archive" => self.render_archive_page(snapshot, cx), - "ely://settings/plugins" => self.render_plugins_page(snapshot), + "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), _ => render_default_page(tab), } } diff --git a/crates/ely_app/src/shell/internal_pages/plugins.rs b/crates/ely_app/src/shell/internal_pages/plugins.rs index 9671714..e1a3bd6 100644 --- a/crates/ely_app/src/shell/internal_pages/plugins.rs +++ b/crates/ely_app/src/shell/internal_pages/plugins.rs @@ -1,12 +1,22 @@ use ely_browser_core::{BrowserSnapshot, InstalledPlugin, PluginAuditAction, PluginAuditEvent}; use ely_design_system::colors; -use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; -use gpui_component::{StyledExt, scroll::ScrollableElement}; +use gpui::prelude::FluentBuilder; +use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; +use super::super::plugins::PendingPluginInstall; use super::{ElyShell, render_canvas_surface}; impl ElyShell { - pub(super) fn render_plugins_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + pub(super) fn render_plugins_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { render_canvas_surface( div() .size_full() @@ -51,12 +61,115 @@ impl ElyShell { )), ), ) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child("Install signed .rplug packages from local disk."), + ) + .child( + Button::new("install-plugin-package") + .primary() + .small() + .icon(IconName::Plus) + .label("Install") + .tooltip("Install Plugin from File") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_plugin_package(window, cx); + })), + ), + ) + .when_some(self.plugin_install_error.clone(), |this, message| { + this.child(render_plugin_install_error(message)) + }) + .when_some(self.pending_plugin_install.clone(), |this, pending| { + this.child(render_plugin_install_confirmation(&pending, cx)) + }) .child(render_plugin_list(snapshot)) .child(render_plugin_audit_list(snapshot)), ) } } +fn render_plugin_install_error(message: String) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::ERROR)) + .px_3() + .py_2() + .text_xs() + .text_color(rgb(colors::ERROR)) + .child(message) + .into_any_element() +} + +fn render_plugin_install_confirmation( + pending: &PendingPluginInstall, + 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 install for {}", pending.manifest().name())), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(high_risk_permissions_label(pending)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Button::new("cancel-plugin-install").ghost().xsmall().label("Cancel").on_click( + cx.listener(|shell, _, _, cx| { + shell.cancel_plugin_install(cx); + }), + ), + ) + .child( + Button::new("confirm-plugin-install") + .danger() + .xsmall() + .label("Confirm") + .on_click(cx.listener(|shell, _, _, cx| { + shell.confirm_plugin_install(cx); + })), + ), + ) + .into_any_element() +} + fn render_plugin_list(snapshot: &BrowserSnapshot) -> AnyElement { if snapshot.installed_plugins.is_empty() { return div() @@ -180,3 +293,13 @@ fn plugin_audit_action_label(action: &PluginAuditAction) -> &'static str { PluginAuditAction::Disabled => "Disabled", } } + +fn high_risk_permissions_label(pending: &PendingPluginInstall) -> String { + let permissions = pending + .high_risk_permissions() + .iter() + .map(|permission| permission.as_str()) + .collect::>() + .join(", "); + format!("High risk permissions: {permissions}") +} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index a017cf2..77df7c3 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,5 +1,6 @@ mod downloads; mod internal_pages; +mod plugins; mod render; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; @@ -8,6 +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 crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, @@ -28,6 +30,8 @@ pub struct ElyShell { download_action_error: Option, download_clear_confirmation: bool, download_security_confirmation: Option, + plugin_install_error: Option, + pending_plugin_install: Option, _command_subscription: Subscription, } @@ -86,6 +90,8 @@ impl ElyShell { download_action_error: None, download_clear_confirmation: false, download_security_confirmation: None, + plugin_install_error: None, + pending_plugin_install: None, _command_subscription: command_subscription, } } diff --git a/crates/ely_app/src/shell/plugins.rs b/crates/ely_app/src/shell/plugins.rs new file mode 100644 index 0000000..9d58dd1 --- /dev/null +++ b/crates/ely_app/src/shell/plugins.rs @@ -0,0 +1,134 @@ +use std::path::PathBuf; + +use ely_domain::{PluginManifest, PluginPermission}; +use gpui::{Context, PathPromptOptions, Window}; + +use crate::services::plugin_packages::{PluginPackageError, PluginPackageReader}; + +use super::{ElyShell, ShellState}; + +#[derive(Clone, Debug)] +pub(super) struct PendingPluginInstall { + manifest: PluginManifest, + high_risk_permissions: Vec, +} + +impl PendingPluginInstall { + fn new(manifest: PluginManifest, high_risk_permissions: Vec) -> Self { + Self { manifest, high_risk_permissions } + } + + pub(super) fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + pub(super) fn high_risk_permissions(&self) -> &[PluginPermission] { + &self.high_risk_permissions + } +} + +impl ElyShell { + pub(super) fn choose_plugin_package(&mut self, window: &mut Window, cx: &mut Context) { + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Select .rplug package".into()), + }); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(Some(paths))) => paths.into_iter().next(), + Ok(Ok(None)) => None, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.plugin_install_error = Some(error.to_string()); + cx.notify(); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.plugin_install_error = Some(error.to_string()); + cx.notify(); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = + window.background_executor().spawn(async move { load_plugin_package(path) }).await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_plugin_package_result(result, cx); + }); + }) + .detach(); + } + + pub(super) fn confirm_plugin_install(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_plugin_install.take() else { + cx.notify(); + return; + }; + + self.install_plugin_manifest(pending.manifest, true, cx); + } + + pub(super) fn cancel_plugin_install(&mut self, cx: &mut Context) { + self.pending_plugin_install = None; + cx.notify(); + } + + fn handle_plugin_package_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + match result { + Ok(manifest) => self.install_plugin_manifest(manifest, false, cx), + Err(error) => { + self.plugin_install_error = Some(error.to_string()); + self.pending_plugin_install = None; + cx.notify(); + } + } + } + + fn install_plugin_manifest( + &mut self, + manifest: PluginManifest, + high_risk_confirmed: bool, + cx: &mut Context, + ) { + let high_risk_permissions = manifest.high_risk_permissions().cloned().collect::>(); + if !high_risk_confirmed && !high_risk_permissions.is_empty() { + self.pending_plugin_install = + Some(PendingPluginInstall::new(manifest, high_risk_permissions)); + self.plugin_install_error = None; + cx.notify(); + return; + } + + let result = match &mut self.state { + ShellState::Ready(core) => core + .install_plugin(manifest, high_risk_confirmed) + .map(|_| ()) + .map_err(|error| error.to_string()), + ShellState::StartupError(message) => Err(message.clone()), + }; + + self.plugin_install_error = result.err(); + if self.plugin_install_error.is_none() { + self.pending_plugin_install = None; + } + cx.notify(); + } +} + +fn load_plugin_package(path: PathBuf) -> Result { + PluginPackageReader::read_directory_package(&path) +}