use std::sync::Arc; use axum::{ Json, extract::{Path, State}, }; use pinakes_plugin::PluginManager; use rustc_hash::FxHashMap; use crate::{ dto::{ InstallPluginRequest, PluginEventRequest, PluginResponse, PluginUiPageEntry, PluginUiWidgetEntry, TogglePluginRequest, }, error::ApiError, state::AppState, }; fn require_plugin_manager( state: &AppState, ) -> Result, ApiError> { state.plugin_manager.clone().ok_or_else(|| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( "Plugin system is not enabled".to_string(), )) }) } /// List all installed plugins #[utoipa::path( get, path = "/api/v1/plugins", tag = "plugins", responses( (status = 200, description = "List of plugins", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { let plugin_manager = require_plugin_manager(&state)?; let plugins = plugin_manager.list_plugins().await; let mut responses = Vec::with_capacity(plugins.len()); for meta in plugins { let enabled = plugin_manager.is_plugin_enabled(&meta.id).await; responses.push(PluginResponse::new(meta, enabled)); } Ok(Json(responses)) } /// Get a specific plugin by ID #[utoipa::path( get, path = "/api/v1/plugins/{id}", tag = "plugins", params(("id" = String, Path, description = "Plugin ID")), responses( (status = 200, description = "Plugin details", body = PluginResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Not found"), ), security(("bearer_auth" = [])) )] pub async fn get_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { let plugin_manager = require_plugin_manager(&state)?; let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| { ApiError(pinakes_core::error::PinakesError::NotFound(format!( "Plugin not found: {id}" ))) })?; let enabled = plugin_manager.is_plugin_enabled(&id).await; Ok(Json(PluginResponse::new(plugin, enabled))) } /// Install a plugin from URL or file path #[utoipa::path( post, path = "/api/v1/plugins", tag = "plugins", request_body = InstallPluginRequest, responses( (status = 200, description = "Plugin installed", body = PluginResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), ), security(("bearer_auth" = [])) )] pub async fn install_plugin( State(state): State, Json(req): Json, ) -> Result, ApiError> { let plugin_manager = require_plugin_manager(&state)?; let plugin_id = plugin_manager .install_plugin(&req.source) .await .map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( format!("Failed to install plugin: {e}"), )) })?; let plugin = plugin_manager.get_plugin(&plugin_id).await.ok_or_else(|| { ApiError(pinakes_core::error::PinakesError::NotFound( "Plugin installed but not found".to_string(), )) })?; let enabled = plugin_manager.is_plugin_enabled(&plugin_id).await; Ok(Json(PluginResponse::new(plugin, enabled))) } /// Uninstall a plugin #[utoipa::path( delete, path = "/api/v1/plugins/{id}", tag = "plugins", params(("id" = String, Path, description = "Plugin ID")), responses( (status = 200, description = "Plugin uninstalled"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), (status = 404, description = "Not found"), ), security(("bearer_auth" = [])) )] pub async fn uninstall_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { let plugin_manager = require_plugin_manager(&state)?; plugin_manager.uninstall_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( format!("Failed to uninstall plugin: {e}"), )) })?; Ok(Json(serde_json::json!({"uninstalled": true}))) } /// Enable or disable a plugin #[utoipa::path( patch, path = "/api/v1/plugins/{id}/toggle", tag = "plugins", params(("id" = String, Path, description = "Plugin ID")), request_body = TogglePluginRequest, responses( (status = 200, description = "Plugin toggled"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), (status = 404, description = "Not found"), ), security(("bearer_auth" = [])) )] pub async fn toggle_plugin( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let plugin_manager = require_plugin_manager(&state)?; if req.enabled { plugin_manager.enable_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( format!("Failed to enable plugin: {e}"), )) })?; } else { plugin_manager.disable_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( format!("Failed to disable plugin: {e}"), )) })?; } // Re-discover capabilities after toggle so cached data stays current if let Some(ref pipeline) = state.plugin_pipeline && let Err(e) = pipeline.discover_capabilities().await { tracing::warn!( plugin_id = %id, error = %e, "failed to re-discover capabilities after plugin toggle" ); } Ok(Json(serde_json::json!({ "id": id, "enabled": req.enabled }))) } /// List all UI pages provided by loaded plugins #[utoipa::path( get, path = "/api/v1/plugins/ui/pages", tag = "plugins", responses( (status = 200, description = "Plugin UI pages", body = Vec), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])) )] pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { let plugin_manager = require_plugin_manager(&state)?; let pages = plugin_manager.list_ui_pages_with_endpoints().await; let entries = pages .into_iter() .map(|(plugin_id, page, allowed_endpoints)| { PluginUiPageEntry { plugin_id, page, allowed_endpoints, } }) .collect(); Ok(Json(entries)) } /// List all UI widgets provided by loaded plugins #[utoipa::path( get, path = "/api/v1/plugins/ui/widgets", tag = "plugins", responses( (status = 200, description = "Plugin UI widgets", body = Vec), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])) )] pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { let plugin_manager = require_plugin_manager(&state)?; let widgets = plugin_manager.list_ui_widgets().await; let entries = widgets .into_iter() .map(|(plugin_id, widget)| PluginUiWidgetEntry { plugin_id, widget }) .collect(); Ok(Json(entries)) } /// Receive a plugin event emitted from the UI and dispatch it to interested /// server-side event-handler plugins via the pipeline. #[utoipa::path( post, path = "/api/v1/plugins/events", tag = "plugins", request_body = PluginEventRequest, responses( (status = 200, description = "Event received"), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])) )] pub async fn emit_plugin_event( State(state): State, Json(req): Json, ) -> Result, ApiError> { tracing::info!(event = %req.event, "plugin UI event received"); state.emit_plugin_event(&req.event, &req.payload); Ok(Json( serde_json::json!({ "received": true, "event": req.event }), )) } /// List merged CSS custom property overrides from all enabled plugins #[utoipa::path( get, path = "/api/v1/plugins/ui/theme", tag = "plugins", responses( (status = 200, description = "Plugin UI theme extensions"), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])) )] pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { let plugin_manager = require_plugin_manager(&state)?; Ok(Json(plugin_manager.list_ui_theme_extensions().await)) } /// Reload a plugin (for development) #[utoipa::path( post, path = "/api/v1/plugins/{id}/reload", tag = "plugins", params(("id" = String, Path, description = "Plugin ID")), responses( (status = 200, description = "Plugin reloaded"), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), (status = 404, description = "Not found"), ), security(("bearer_auth" = [])) )] pub async fn reload_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { let plugin_manager = require_plugin_manager(&state)?; plugin_manager.reload_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( format!("Failed to reload plugin: {e}"), )) })?; // Re-discover capabilities after reload so cached data stays current if let Some(ref pipeline) = state.plugin_pipeline && let Err(e) = pipeline.discover_capabilities().await { tracing::warn!( plugin_id = %id, error = %e, "failed to re-discover capabilities after plugin reload" ); } Ok(Json(serde_json::json!({"reloaded": true}))) }