//! Plugin Tauri commands and asset protocol. use std::future::Future; use std::path::{Path, PathBuf}; use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput}; use backend::dto::{ ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto, PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto, }; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::{PluginId, RelativePath}; use http::{header, Response, StatusCode}; use tauri::{AppHandle, Manager, State}; use crate::state::AppState; /// Lists installed plugins for the admin surface. #[tauri::command] pub async fn plugin_list_plugins( state: State<'_, AppState>, ) -> Result, ErrorDto> { state .list_plugins .execute() .await .map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect()) .map_err(ErrorDto::from) } /// Reviews a local plugin package without committing it. #[tauri::command] pub async fn plugin_review_package( input: ReviewPluginPackageDto, state: State<'_, AppState>, ) -> Result { let input = match input.source_kind.as_str() { "archive" => ReviewPluginPackageInput::Archive { path: input.path }, "directory" => ReviewPluginPackageInput::Directory { path: input.path }, other => { return Err(ErrorDto::invalid(format!( "invalid plugin source kind: {other}" ))) } }; state .review_plugin_package .execute(input) .await .map(PluginReviewDto::from) .map_err(ErrorDto::from) } /// Installs a plugin from a local archive. #[tauri::command] pub async fn plugin_install_from_archive( path: String, state: State<'_, AppState>, ) -> Result { state .install_plugin_from_archive .execute(path) .await .map(PluginInstallResultDto::from) .map_err(ErrorDto::from) } /// Installs a plugin from a local directory snapshot. #[tauri::command] pub async fn plugin_install_from_directory( path: String, state: State<'_, AppState>, ) -> Result { state .install_plugin_from_directory .execute(path) .await .map(PluginInstallResultDto::from) .map_err(ErrorDto::from) } /// Enables or disables a plugin. #[tauri::command] pub async fn plugin_set_enabled( plugin_id: String, enabled: bool, state: State<'_, AppState>, ) -> Result { state .set_plugin_enabled .execute(SetPluginEnabledInput { plugin_id, enabled }) .await .map(PluginAdminDto::from) .map_err(ErrorDto::from) } /// Uninstalls a plugin. #[tauri::command] pub async fn plugin_uninstall( plugin_id: String, state: State<'_, AppState>, ) -> Result { state .uninstall_plugin .execute(UninstallPluginInput { plugin_id }) .await .map(PluginUninstallResultDto::from) .map_err(ErrorDto::from) } /// Lists active runtime contributions for frontend bootstrap. #[tauri::command] pub async fn plugin_list_runtime_contributions( state: State<'_, AppState>, ) -> Result { state .list_plugin_runtime_contributions .execute() .await .map(PluginRuntimeContributionCatalogDto::from) .map_err(ErrorDto::from) } /// Opens the plugin store folder, or one plugin folder when an id is provided. #[tauri::command] pub fn plugin_open_plugins_folder( plugin_id: Option, app: AppHandle, ) -> Result<(), ErrorDto> { let mut path = app .path() .app_data_dir() .map_err(|e| ErrorDto::invalid(e.to_string()))? .join("plugins"); if let Some(id) = plugin_id { let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?; path = path.join("installed").join(id.as_str()); } open_folder(&path) } /// Serves `idea-plugin://///` after /// checking registry state, hash and root confinement. pub fn plugin_asset_protocol( app: &AppHandle, request: http::Request>, ) -> Response> { match plugin_asset_response(app, request) { Ok(response) => response, Err((status, message)) => Response::builder() .status(status) .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") .body(message.into_bytes()) .expect("valid protocol error response"), } } fn plugin_asset_response( app: &AppHandle, request: http::Request>, ) -> Result>, (StatusCode, String)> { let uri = request.uri(); let plugin_id = uri .host() .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned())) .and_then(|raw| { PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string())) })?; let mut parts = uri.path().trim_start_matches('/').splitn(3, '/'); let version_segment = parts.next().unwrap_or_default(); let hash = parts.next().unwrap_or_default(); let rel = parts.next().unwrap_or_default(); if version_segment.is_empty() || hash.is_empty() { return Err(( StatusCode::BAD_REQUEST, "invalid plugin asset URL".to_owned(), )); } let rel = RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let state = app.state::(); let allowed = block_on_protocol_future(asset_allowed( &plugin_id, hash, &rel, state.plugin_registry_store.as_ref(), state.plugin_package_store.as_ref(), state.plugin_manifest_validator.as_ref(), ))?; if !allowed { return Err(( StatusCode::FORBIDDEN, "plugin asset is not active".to_owned(), )); } let app_data = app .path() .app_data_dir() .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let root = app_data .join("plugins") .join("installed") .join(plugin_id.as_str()); let target = root.join(rel.as_str()); let root = root .canonicalize() .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; let target = target .canonicalize() .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; if !target.starts_with(&root) { return Err(( StatusCode::FORBIDDEN, "asset escapes plugin root".to_owned(), )); } let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type(&target)) .body(bytes) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } async fn asset_allowed( plugin_id: &PluginId, hash: &str, rel: &RelativePath, registry_store: &dyn PluginRegistryStore, package_store: &dyn PluginPackageStore, validator: &dyn PluginManifestValidator, ) -> Result { let registry = registry_store .load_registry() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let Some(entry) = registry.find(plugin_id) else { return Ok(false); }; if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash { return Ok(false); } let package = domain::PluginPackageRef { plugin_id: Some(plugin_id.clone()), root: plugin_id.as_str().to_owned(), }; let manifest_bytes = package_store .read_manifest(&package) .await .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; let manifest = validator .validate(&manifest_bytes.bytes, &package) .map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?; let declared_icon = manifest.icon.as_ref() == Some(rel); let declared_main = manifest.main == *rel; Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/")) } fn block_on_protocol_future(future: F) -> F::Output { match tokio::runtime::Handle::try_current() { Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)), Err(_) => tauri::async_runtime::block_on(future), } } fn content_type(path: &Path) -> &'static str { match path .extension() .and_then(|e| e.to_str()) .unwrap_or_default() { "js" | "mjs" => "text/javascript; charset=utf-8", "json" => "application/json; charset=utf-8", "svg" => "image/svg+xml", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "webp" => "image/webp", "css" => "text/css; charset=utf-8", _ => "application/octet-stream", } } fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> { let mut command = if cfg!(target_os = "macos") { let mut c = std::process::Command::new("open"); c.arg(path); c } else if cfg!(target_os = "windows") { let mut c = std::process::Command::new("cmd"); c.args(["/C", "start", ""]).arg(path); c } else { let mut c = std::process::Command::new("xdg-open"); c.arg(path); c }; command .spawn() .map(|_| ()) .map_err(|e| ErrorDto::invalid(e.to_string())) } #[cfg(test)] mod tests { use super::{asset_allowed, block_on_protocol_future}; use std::sync::Mutex; use async_trait::async_trait; use domain::ports::{ PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, }; use domain::{ ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState, PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage, }; use http::StatusCode; struct FakeRegistry { registry: Mutex, } #[async_trait] impl PluginRegistryStore for FakeRegistry { async fn load_registry(&self) -> Result { Ok(self.registry.lock().unwrap().clone()) } async fn save_registry( &self, registry: &PluginRegistry, ) -> Result<(), PluginRegistryError> { *self.registry.lock().unwrap() = registry.clone(); Ok(()) } } struct FakePackages { manifest: Vec, } #[async_trait] impl PluginPackageStore for FakePackages { async fn list_installed(&self) -> Result, PluginStoreError> { Ok(Vec::new()) } async fn read_manifest( &self, _package: &domain::PluginPackageRef, ) -> Result { Ok(PluginManifestBytes { bytes: self.manifest.clone(), }) } async fn install_from_archive( &self, _archive: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("not used".to_owned())) } async fn install_from_directory( &self, _dir: &LocalPath, ) -> Result { Err(PluginStoreError::Invalid("not used".to_owned())) } async fn commit_install( &self, _staged: StagedPluginPackage, _plugin_id: &PluginId, ) -> Result { Err(PluginStoreError::Invalid("not used".to_owned())) } async fn remove_package( &self, _plugin_id: &PluginId, ) -> Result { Ok(RemovalOutcome::NotFound) } fn bundle_url( &self, plugin_id: &PluginId, entry: &RelativePath, hash: &ContentHash, ) -> Result { Ok(domain::PluginBundleUrl::new(format!( "idea-plugin://{}/current/{}/{}", plugin_id.as_str(), hash.as_str(), entry.as_str() ))) } } struct RejectingValidator; impl domain::ports::PluginManifestValidator for RejectingValidator { fn validate( &self, _bytes: &[u8], _package: &domain::PluginPackageRef, ) -> Result { Err(PluginManifestError::Invalid("broken manifest".to_owned())) } } #[tokio::test(flavor = "multi_thread")] async fn protocol_future_can_be_waited_inside_tauri_runtime() { let value = block_on_protocol_future(async { 42 }); assert_eq!(value, 42); } #[tokio::test] async fn asset_allowed_confines_invalid_active_manifest_to_forbidden() { let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap(); let hash = ContentHash::new("abc123").unwrap(); let registry = FakeRegistry { registry: Mutex::new(PluginRegistry { version: 1, plugins: vec![PluginRegistryEntry { id: plugin_id.clone(), lifecycle_state: PluginLifecycleState::Enabled, source: PluginInstallSource::Directory { path_label: "/source/plugin".to_owned(), }, content_hash: hash.clone(), restart_required: false, error: None, }], }), }; let packages = FakePackages { manifest: br#"{"broken":true}"#.to_vec(), }; let rel = RelativePath::new("dist/index.js").unwrap(); let err = asset_allowed( &plugin_id, hash.as_str(), &rel, ®istry, &packages, &RejectingValidator, ) .await .unwrap_err(); assert_eq!(err.0, StatusCode::FORBIDDEN); assert!(err.1.contains("broken manifest")); } }