diff --git a/plugin-wiki/插件开发指南.md b/plugin-wiki/插件开发指南.md new file mode 100644 index 0000000..f0c1abc --- /dev/null +++ b/plugin-wiki/插件开发指南.md @@ -0,0 +1,126 @@ +# SecScore 插件开发指南(V1) + +## 1. 当前插件能力 + +当前版本支持: + +- 在“插件管理”中安装、卸载、启用、禁用插件 +- 启动时自动加载已启用插件 +- 插件变更后自动热重载(安装/卸载/启用状态变化) +- 插件通过 `setup(ctx)` 运行,并可订阅应用事件 + +当前限制: + +- `main` 入口建议使用单文件 ESM(不建议写相对 `import`) +- `permissions` 字段目前用于声明,不做强制拦截 +- 插件运行在前端渲染层,属于“本机受信任代码”模型 + +## 2. 插件目录结构 + +```text +my-plugin/ + manifest.json + main.js +``` + +`manifest.json` 最小示例: + +```json +{ + "id": "demo.hello", + "name": "Hello Plugin", + "version": "0.1.0", + "description": "我的第一个 SecScore 插件", + "author": "YourName", + "main": "main.js", + "permissions": ["events.read"], + "enabled": true +} +``` + +字段说明: + +- `id`:插件唯一标识,仅允许字母/数字/`.`/`_`/`-` +- `name`:插件名称 +- `version`:版本号 +- `main`:入口脚本(相对路径) +- `enabled`:是否启用 +- `permissions`:声明型权限数组(V1 不强制) + +## 3. 入口脚本写法 + +`main.js` 示例: + +```js +export default { + setup(ctx) { + ctx.log("插件已加载") + + const offRoute = ctx.on("route-changed", (detail) => { + ctx.log("路由变化", detail) + }) + + const offData = ctx.on("data-updated", (detail) => { + ctx.log("数据变化", detail) + }) + + // 示例:调用内置 API(仍受后端权限控制) + ctx.api + ?.queryStudents?.() + .then((res) => ctx.log("学生数量", res?.data?.length ?? 0)) + .catch((err) => ctx.log("查询失败", String(err))) + + // 返回清理函数(可选) + return () => { + offRoute() + offData() + ctx.log("插件已卸载") + } + }, +} +``` + +也支持: + +```js +export default function setup(ctx) { + ctx.log("以函数形式加载") +} +``` + +## 4. `ctx` 上下文 + +`setup(ctx)` 可用能力: + +- `ctx.id` / `ctx.name` / `ctx.version` +- `ctx.permissions`:manifest 中的权限声明 +- `ctx.api`:应用 `window.api`(可调用现有 IPC API) +- `ctx.log(message, meta?)`:插件日志输出 +- `ctx.on(event, handler)`:订阅事件并返回取消订阅函数 + +可订阅事件: + +- `data-updated`:对应 `ss:data-updated` +- `route-changed`:对应 `ss:route-changed` +- `plugins-updated`:对应 `ss:plugins-updated` + +## 5. 安装与调试 + +1. 准备好插件目录(包含 `manifest.json` 与入口文件) +2. 打开 SecScore -> `插件管理` -> `安装插件` +3. 选择插件目录并确认安装 +4. 查看日志: + - 开发环境浏览器控制台 + - 或应用日志(设置页日志模块) + +卸载行为说明: + +- 卸载会删除安装到应用插件目录中的插件副本 +- 源插件目录(你自己本地开发目录)不会被改动 + +## 6. 推荐开发流程 + +1. 先写最小插件(仅 `ctx.log`) +2. 再接入事件监听(`route-changed` / `data-updated`) +3. 最后增加 API 调用和容错处理(`try/catch`) +4. 升级版本时同步更新 `manifest.json` 的 `version` diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a5e24c..7e80657 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod http_server; pub mod log; pub mod mcp; pub mod oauth_server; +pub mod plugin; pub mod reason; pub mod response; pub mod reward; @@ -32,6 +33,7 @@ pub use http_server::*; pub use log::*; pub use mcp::*; pub use oauth_server::*; +pub use plugin::*; pub use reason::*; pub use response::*; pub use reward::*; diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs new file mode 100644 index 0000000..d39c1e3 --- /dev/null +++ b/src-tauri/src/commands/plugin.rs @@ -0,0 +1,165 @@ +use parking_lot::RwLock; +use std::path::PathBuf; +use std::sync::Arc; +use tauri::State; + +use crate::services::plugin::{ + Plugin, PluginManifest, PluginRuntimeModule, PluginService, PluginStats, +}; +use crate::services::PermissionLevel; +use crate::state::AppState; + +use super::response::IpcResponse; + +fn check_admin_permission(state: &Arc>, sender_id: Option) -> bool { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + let id = sender_id.unwrap_or(0); + permissions.require_permission(id, PermissionLevel::Admin) +} + +#[tauri::command] +pub fn plugin_get_all(state: State<'_, Arc>>) -> Result>, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + let all_plugins = (*plugins).get_all_plugins().to_vec(); + Ok(IpcResponse::success(all_plugins)) +} + +#[tauri::command] +pub fn plugin_get( + plugin_id: String, + state: State<'_, Arc>>, +) -> Result, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + + match plugins.get_plugin(&plugin_id) { + Some(plugin) => Ok(IpcResponse::success(plugin.clone())), + None => Ok(IpcResponse::error("Plugin not found")), + } +} + +#[tauri::command] +pub fn plugin_get_stats(state: State<'_, Arc>>) -> Result, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + let stats = plugins.get_plugin_stats(); + Ok(IpcResponse::success(stats)) +} + +#[tauri::command] +pub fn plugin_toggle( + plugin_id: String, + enabled: bool, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + let state_guard = state.read(); + let mut plugins = state_guard.plugins.write(); + plugins.toggle_plugin(&plugin_id, enabled)?; + Ok(IpcResponse::success(())) +} + +#[tauri::command] +pub fn plugin_install( + manifest: PluginManifest, + plugin_dir: PathBuf, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + let state_guard = state.read(); + let mut plugins = state_guard.plugins.write(); + let plugin = plugins.install_plugin(&state_guard.app_handle, manifest, plugin_dir)?; + Ok(IpcResponse::success(plugin)) +} + +#[tauri::command] +pub fn plugin_uninstall( + plugin_id: String, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + let state_guard = state.read(); + let mut plugins = state_guard.plugins.write(); + plugins.uninstall_plugin(&plugin_id)?; + Ok(IpcResponse::success(())) +} + +#[tauri::command] +pub fn plugin_load_manifest( + path: PathBuf, + sender_id: Option, + state: State<'_, Arc>>, +) -> Result, String> { + if !check_admin_permission(&state, sender_id) { + return Ok(IpcResponse::error("Permission denied: admin required")); + } + let manifest = PluginService::load_plugin_manifest(&path)?; + Ok(IpcResponse::success(manifest)) +} + +#[tauri::command] +pub fn plugin_get_dir( + plugin_id: String, + state: State<'_, Arc>>, +) -> Result, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + + match plugins.get_plugin_dir(&plugin_id) { + Some(dir) => Ok(IpcResponse::success(dir.to_string_lossy().to_string())), + None => Ok(IpcResponse::error("Plugin not found")), + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginListItem { + pub id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub enabled: bool, +} + +impl From for PluginListItem { + fn from(plugin: Plugin) -> Self { + Self { + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + author: plugin.author, + enabled: plugin.enabled, + } + } +} + +#[tauri::command] +pub fn plugin_get_list(state: State<'_, Arc>>) -> Result>, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + let list: Vec = (*plugins).get_all_plugins().iter().map(|p| (*p).clone().into()).collect(); + Ok(IpcResponse::success(list)) +} + +#[tauri::command] +pub fn plugin_get_runtime_modules( + state: State<'_, Arc>>, +) -> Result>, String> { + let state_guard = state.read(); + let plugins = state_guard.plugins.read(); + let modules = plugins.get_runtime_modules()?; + Ok(IpcResponse::success(modules)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76f16d9..3c5706c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,6 +106,16 @@ pub fn run() { log_clear, log_set_level, log_write, + plugin_get_all, + plugin_get, + plugin_get_stats, + plugin_toggle, + plugin_install, + plugin_uninstall, + plugin_load_manifest, + plugin_get_dir, + plugin_get_list, + plugin_get_runtime_modules, data_export_json, data_import_json, window_minimize, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index facd7c7..588157b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -3,6 +3,7 @@ pub mod auto_score; pub mod data; pub mod logger; pub mod permission; +pub mod plugin; pub mod security; pub mod settings; pub mod theme; @@ -17,6 +18,7 @@ pub use auto_score::{ pub use data::DataService; pub use logger::LoggerService; pub use permission::{PermissionLevel, PermissionService}; +pub use plugin::{Plugin, PluginManifest, PluginRuntimeModule, PluginService, PluginStats}; pub use security::SecurityService; pub use settings::{SettingsKey, SettingsService, SettingsSpec, SettingsValue}; pub use theme::{ThemeConfig, ThemeService}; diff --git a/src-tauri/src/services/plugin.rs b/src-tauri/src/services/plugin.rs new file mode 100644 index 0000000..3cec7dc --- /dev/null +++ b/src-tauri/src/services/plugin.rs @@ -0,0 +1,478 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginManifest { + pub id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub main: Option, + pub assets: Option>, + pub permissions: Option>, + pub enabled: bool, +} + +impl Default for PluginManifest { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + version: "1.0.0".to_string(), + description: None, + author: None, + main: None, + assets: None, + permissions: None, + enabled: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Plugin { + pub id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub main: Option, + pub permissions: Option>, + pub enabled: bool, + pub installed_at: String, + pub manifest_path: String, +} + +impl From for Plugin { + fn from(manifest: PluginManifest) -> Self { + Self { + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + author: manifest.author, + main: manifest.main, + permissions: manifest.permissions, + enabled: manifest.enabled, + installed_at: chrono::Utc::now().to_rfc3339(), + manifest_path: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimeModule { + pub id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub main: String, + pub code: String, + pub permissions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PluginStats { + pub total_plugins: usize, + pub enabled_plugins: usize, + pub disabled_plugins: usize, +} + +pub struct PluginService { + plugins: Vec, + plugin_dirs: HashMap, +} + +impl Default for PluginService { + fn default() -> Self { + Self::new() + } +} + +impl PluginService { + pub fn new() -> Self { + Self { + plugins: Vec::new(), + plugin_dirs: HashMap::new(), + } + } + + pub fn get_plugins_dir(app_handle: &AppHandle) -> PathBuf { + app_handle + .path() + .app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("plugins") + } + + pub fn initialize(&mut self, app_handle: &AppHandle) -> Result<(), String> { + let plugins_dir = Self::get_plugins_dir(app_handle); + + if !plugins_dir.exists() { + fs::create_dir_all(&plugins_dir).map_err(|e| format!("Failed to create plugins directory: {}", e))?; + } + + self.plugins.clear(); + self.plugin_dirs.clear(); + self.load_plugins_from_dir(&plugins_dir)?; + Ok(()) + } + + fn load_plugins_from_dir(&mut self, dir: &Path) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(dir).map_err(|e| format!("Failed to read plugins directory: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + let manifest_path = path.join("manifest.json"); + if manifest_path.exists() { + match Self::load_plugin_manifest_from_file(&manifest_path) { + Ok(manifest) => { + if let Err(error) = Self::validate_manifest(&manifest) { + eprintln!( + "Skip invalid plugin manifest at {}: {}", + manifest_path.to_string_lossy(), + error + ); + continue; + } + if self.plugins.iter().any(|existing| existing.id == manifest.id) { + eprintln!( + "Skip duplicated plugin id {} at {}", + manifest.id, + manifest_path.to_string_lossy() + ); + continue; + } + let mut plugin: Plugin = manifest.into(); + plugin.manifest_path = manifest_path.to_string_lossy().to_string(); + let plugin_id = plugin.id.clone(); + self.plugins.push(plugin); + self.plugin_dirs.insert(plugin_id, path); + } + Err(error) => { + eprintln!( + "Skip plugin with invalid manifest at {}: {}", + manifest_path.to_string_lossy(), + error + ); + } + } + } + } + } + + Ok(()) + } + + fn validate_manifest(manifest: &PluginManifest) -> Result<(), String> { + let id = manifest.id.trim(); + let name = manifest.name.trim(); + let version = manifest.version.trim(); + + if id.is_empty() { + return Err("Plugin id cannot be empty".to_string()); + } + if !id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-') + { + return Err("Plugin id can only contain letters, numbers, dot, underscore, hyphen".to_string()); + } + if name.is_empty() { + return Err("Plugin name cannot be empty".to_string()); + } + if version.is_empty() { + return Err("Plugin version cannot be empty".to_string()); + } + + if let Some(main) = manifest.main.as_ref() { + let entry = main.trim(); + if entry.is_empty() { + return Err("Plugin main cannot be empty".to_string()); + } + if Path::new(entry).is_absolute() { + return Err("Plugin main must be a relative path".to_string()); + } + } + + Ok(()) + } + + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> { + if !source.exists() || !source.is_dir() { + return Err(format!( + "Plugin source directory does not exist: {}", + source.to_string_lossy() + )); + } + + fs::create_dir_all(target) + .map_err(|e| format!("Failed to create plugin target directory: {}", e))?; + + for entry in fs::read_dir(source) + .map_err(|e| format!("Failed to read plugin source directory: {}", e))? + { + let entry = entry.map_err(|e| format!("Failed to read plugin file entry: {}", e))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + + if source_path.is_dir() { + Self::copy_dir_recursive(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path).map_err(|e| { + format!( + "Failed to copy plugin file from {} to {}: {}", + source_path.to_string_lossy(), + target_path.to_string_lossy(), + e + ) + })?; + } + } + + Ok(()) + } + + fn load_plugin_manifest_from_file(manifest_path: &Path) -> Result { + let content = fs::read_to_string(manifest_path) + .map_err(|e| format!("Failed to read manifest.json: {}", e))?; + + serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest.json: {}", e)) + } + + fn persist_manifest_enabled(manifest_path: &Path, enabled: bool) -> Result<(), String> { + let mut manifest = Self::load_plugin_manifest_from_file(manifest_path)?; + manifest.enabled = enabled; + let serialized = + serde_json::to_string_pretty(&manifest).map_err(|e| format!("Failed to serialize manifest.json: {}", e))?; + fs::write(manifest_path, format!("{}\n", serialized)) + .map_err(|e| format!("Failed to write manifest.json: {}", e))?; + Ok(()) + } + + fn resolve_plugin_relative_path(plugin_dir: &Path, relative_path: &str) -> Result { + let relative_path = relative_path.trim(); + if relative_path.is_empty() { + return Err("Plugin entry file path cannot be empty".to_string()); + } + if Path::new(relative_path).is_absolute() { + return Err("Plugin entry file must be a relative path".to_string()); + } + + let plugin_dir_canonical = fs::canonicalize(plugin_dir).map_err(|e| { + format!( + "Failed to resolve plugin directory {}: {}", + plugin_dir.to_string_lossy(), + e + ) + })?; + let entry_path = plugin_dir.join(relative_path); + let entry_path_canonical = fs::canonicalize(&entry_path).map_err(|e| { + format!( + "Failed to resolve plugin entry {}: {}", + entry_path.to_string_lossy(), + e + ) + })?; + + if !entry_path_canonical.starts_with(&plugin_dir_canonical) { + return Err("Plugin entry file must stay inside plugin directory".to_string()); + } + if !entry_path_canonical.is_file() { + return Err(format!( + "Plugin entry file not found: {}", + entry_path_canonical.to_string_lossy() + )); + } + + Ok(entry_path_canonical) + } + + pub fn install_plugin( + &mut self, + app_handle: &AppHandle, + manifest: PluginManifest, + plugin_dir: PathBuf, + ) -> Result { + Self::validate_manifest(&manifest)?; + if self.plugins.iter().any(|existing| existing.id == manifest.id) { + return Err("Plugin already installed".to_string()); + } + if !plugin_dir.exists() || !plugin_dir.is_dir() { + return Err(format!( + "Plugin directory not found: {}", + plugin_dir.to_string_lossy() + )); + } + + let source_manifest = Self::load_plugin_manifest(&plugin_dir)?; + Self::validate_manifest(&source_manifest)?; + if source_manifest.id != manifest.id { + return Err("Manifest mismatch: plugin id in folder does not match selected plugin".to_string()); + } + + let plugins_dir = Self::get_plugins_dir(app_handle); + if !plugins_dir.exists() { + fs::create_dir_all(&plugins_dir) + .map_err(|e| format!("Failed to create plugins directory: {}", e))?; + } + let target_dir = plugins_dir.join(&source_manifest.id); + if target_dir.exists() { + return Err(format!( + "Plugin target directory already exists: {}", + target_dir.to_string_lossy() + )); + } + + Self::copy_dir_recursive(&plugin_dir, &target_dir)?; + + let target_manifest_path = target_dir.join("manifest.json"); + let mut plugin: Plugin = source_manifest.into(); + plugin.manifest_path = target_manifest_path.to_string_lossy().to_string(); + let plugin_id = plugin.id.clone(); + + self.plugins.push(plugin.clone()); + self.plugin_dirs.insert(plugin_id, target_dir); + + Ok(plugin) + } + + pub fn uninstall_plugin(&mut self, plugin_id: &str) -> Result<(), String> { + let exists = self.plugins.iter().any(|plugin| plugin.id == plugin_id); + if !exists { + return Err("Plugin not found".to_string()); + } + + if let Some(plugin_dir) = self.plugin_dirs.get(plugin_id) { + if plugin_dir.exists() { + fs::remove_dir_all(plugin_dir).map_err(|e| { + format!( + "Failed to remove plugin directory {}: {}", + plugin_dir.to_string_lossy(), + e + ) + })?; + } + } + + self.plugins.retain(|plugin| plugin.id != plugin_id); + self.plugin_dirs.remove(plugin_id); + Ok(()) + } + + pub fn toggle_plugin(&mut self, plugin_id: &str, enabled: bool) -> Result<(), String> { + let manifest_path = self + .plugin_dirs + .get(plugin_id) + .ok_or_else(|| "Plugin directory not found".to_string())? + .join("manifest.json"); + let plugin = self + .plugins + .iter_mut() + .find(|plugin| plugin.id == plugin_id) + .ok_or_else(|| "Plugin not found".to_string())?; + plugin.enabled = enabled; + Self::persist_manifest_enabled(&manifest_path, enabled)?; + Ok(()) + } + + pub fn get_plugin(&self, plugin_id: &str) -> Option<&Plugin> { + self.plugins.iter().find(|p| p.id == plugin_id) + } + + pub fn get_all_plugins(&self) -> &[Plugin] { + &self.plugins + } + + pub fn get_plugin_stats(&self) -> PluginStats { + let total_plugins = self.plugins.len(); + let enabled_plugins = self.plugins.iter().filter(|p| p.enabled).count(); + let disabled_plugins = total_plugins - enabled_plugins; + + PluginStats { + total_plugins, + enabled_plugins, + disabled_plugins, + } + } + + pub fn get_plugin_dir(&self, plugin_id: &str) -> Option<&PathBuf> { + self.plugin_dirs.get(plugin_id) + } + + pub fn get_runtime_modules(&self) -> Result, String> { + let mut runtime_modules = Vec::new(); + + for plugin in self.plugins.iter().filter(|plugin| plugin.enabled) { + let Some(main) = plugin.main.clone() else { + continue; + }; + let Some(plugin_dir) = self.plugin_dirs.get(&plugin.id) else { + eprintln!( + "Skip plugin runtime loading because plugin directory missing: {}", + plugin.id + ); + continue; + }; + let entry_path = match Self::resolve_plugin_relative_path(plugin_dir, &main) { + Ok(path) => path, + Err(error) => { + eprintln!( + "Skip plugin runtime loading for {} because entry path is invalid: {}", + plugin.id, error + ); + continue; + } + }; + let code = match fs::read_to_string(&entry_path) { + Ok(code) => code, + Err(error) => { + eprintln!( + "Skip plugin runtime loading for {} because entry file cannot be read: {}", + plugin.id, error + ); + continue; + } + }; + + runtime_modules.push(PluginRuntimeModule { + id: plugin.id.clone(), + name: plugin.name.clone(), + version: plugin.version.clone(), + description: plugin.description.clone(), + author: plugin.author.clone(), + main, + code, + permissions: plugin.permissions.clone().unwrap_or_default(), + }); + } + + Ok(runtime_modules) + } + + pub fn load_plugin_manifest(path: &PathBuf) -> Result { + let manifest_path = path.join("manifest.json"); + + if !manifest_path.exists() { + return Err("manifest.json not found".to_string()); + } + + let manifest = Self::load_plugin_manifest_from_file(&manifest_path)?; + Self::validate_manifest(&manifest)?; + Ok(manifest) + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8dffb7c..76e29db 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,8 +6,8 @@ use tauri::AppHandle; use crate::services::{ auth::AuthService, auto_score::AutoScoreService, data::DataService, logger::LoggerService, - permission::PermissionService, security::SecurityService, settings::SettingsService, - theme::ThemeService, SettingsKey, SettingsValue, + permission::PermissionService, plugin::PluginService, security::SecurityService, + settings::SettingsService, theme::ThemeService, SettingsKey, SettingsValue, }; pub struct AppState { @@ -20,6 +20,7 @@ pub struct AppState { pub auto_score: Arc>, pub logger: Arc>, pub data: Arc>, + pub plugins: Arc>, pub http_client: Client, pub app_handle: AppHandle, } @@ -34,6 +35,7 @@ impl AppState { let auto_score = Arc::new(RwLock::new(AutoScoreService::new())); let logger = Arc::new(RwLock::new(LoggerService::new())); let data = Arc::new(RwLock::new(DataService::new())); + let plugins = Arc::new(RwLock::new(PluginService::new())); let db = Arc::new(RwLock::new(None)); let http_client = Client::builder() @@ -51,6 +53,7 @@ impl AppState { auto_score, logger, data, + plugins, http_client, app_handle, } @@ -88,6 +91,11 @@ impl AppState { auto_score.initialize(&self.app_handle).await?; } + { + let mut plugins = self.plugins.write(); + plugins.initialize(&self.app_handle)?; + } + Ok(()) } } diff --git a/src/App.tsx b/src/App.tsx index 3708538..73d911c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { UpOutlined, DownOutlined, HolderOutlined, + CrownOutlined, } from "@ant-design/icons" import { useEffect, useMemo, useRef, useState } from "react" import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom" @@ -34,6 +35,7 @@ import { OAuthCallback } from "./components/OAuth/OAuthCallback" import { ThemeProvider, useTheme } from "./contexts/ThemeContext" import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation" import { resolveStoredFontFamily } from "./shared/fontFamily" +import { getPluginRuntime } from "./plugins/runtime" const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice( @@ -129,6 +131,7 @@ function MainContent(): React.JSX.Element { const syncCheckingRef = useRef(false) const syncApplyLoadingRef = useRef(false) const lastLocalMutationAtRef = useRef(0) + const pluginRuntimeRef = useRef(getPluginRuntime()) const activeMenu = useMemo(() => { const p = location.pathname @@ -141,10 +144,37 @@ function MainContent(): React.JSX.Element { if (p.startsWith("/reasons")) return "reasons" if (p.startsWith("/auto-score")) return "auto-score" if (p.startsWith("/reward-settings")) return "reward-settings" + if (p.startsWith("/plugins")) return "plugins" if (p.startsWith("/settings")) return "settings" return "home" }, [location.pathname]) + useEffect(() => { + const runtime = pluginRuntimeRef.current + runtime.start().catch((error) => { + console.error("Failed to start plugin runtime:", error) + }) + + const handlePluginsUpdated = () => { + runtime.reload().catch((error) => { + console.error("Failed to reload plugin runtime:", error) + }) + } + window.addEventListener("ss:plugins-updated", handlePluginsUpdated) + + return () => { + window.removeEventListener("ss:plugins-updated", handlePluginsUpdated) + runtime.stop().catch((error) => { + console.error("Failed to stop plugin runtime:", error) + }) + } + }, []) + + useEffect(() => { + const normalizedPath = location.pathname === "/" ? "/home" : location.pathname + window.dispatchEvent(new CustomEvent("ss:route-changed", { detail: { path: normalizedPath } })) + }, [location.pathname]) + useEffect(() => { const checkWizard = async () => { if (!(window as any).api) return @@ -429,12 +459,13 @@ function MainContent(): React.JSX.Element { if (key === "home") navigate("/") if (key === "students") navigate("/students") if (key === "score") navigate("/score") + if (key === "auto-score") navigate("/auto-score") + if (key === "reward-settings") navigate("/reward-settings") if (key === "boards") navigate("/boards") if (key === "leaderboard") navigate("/leaderboard") if (key === "settlements") navigate("/settlements") if (key === "reasons") navigate("/reasons") - if (key === "auto-score") navigate("/auto-score") - if (key === "reward-settings") navigate("/reward-settings") + if (key === "plugins") navigate("/plugins") if (key === "settings") navigate("/settings") } @@ -481,6 +512,7 @@ function MainContent(): React.JSX.Element { leaderboard: , settlements: , reasons: , + plugins: , settings: , } diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 8012d48..d60ad0d 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -18,6 +18,7 @@ import { Tooltip, message, } from "antd" +import { InfoCircleOutlined } from "@ant-design/icons" import { type ImmutableTree } from "@react-awesome-query-builder/antd" import type { ColumnsType } from "antd/es/table" import dayjs from "dayjs" @@ -855,7 +856,18 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element > - + + {t("autoScore.startAt")} + + + + + } + > import("./Leaderboard") const loadSettlementHistory = () => import("./SettlementHistory") const loadRewardSettings = () => import("./RewardSettings") const loadBoardManager = () => import("./BoardManager") +const loadPluginManager = () => import("./PluginManager") const Home = lazy(() => loadHome().then((m) => ({ default: m.Home }))) const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager }))) @@ -35,6 +36,7 @@ const SettlementHistory = lazy(() => ) const RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings }))) const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager }))) +const PluginManager = lazy(() => loadPluginManager().then((m) => ({ default: m.PluginManager }))) const warmupRouteChunks = () => Promise.allSettled([ @@ -48,6 +50,7 @@ const warmupRouteChunks = () => loadSettlementHistory(), loadRewardSettings(), loadBoardManager(), + loadPluginManager(), ]) const { Content } = Layout @@ -110,6 +113,7 @@ export function ContentArea({ if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements") if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons") if (normalizedPath.startsWith("/reward-settings")) return t("sidebar.rewardSettings") + if (normalizedPath.startsWith("/plugins")) return t("sidebar.plugins") if (normalizedPath.startsWith("/settings")) return t("sidebar.settings") return "SecScore" })() @@ -380,6 +384,7 @@ export function ContentArea({ path="/reward-settings" element={} /> + } /> } /> } /> diff --git a/src/components/PluginManager.tsx b/src/components/PluginManager.tsx new file mode 100644 index 0000000..9675dc7 --- /dev/null +++ b/src/components/PluginManager.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect, useCallback } from "react" +import { + Table, + Button, + Modal, + Form, + Input, + Switch, + message, + Tag, + Popconfirm, + Card, + Space, + Descriptions, + Upload, + Tooltip, +} from "antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" +import { UploadOutlined, DeleteOutlined, FolderOpenOutlined } from "@ant-design/icons" + +interface Plugin { + id: string + name: string + version: string + description?: string + author?: string + enabled: boolean + installed_at?: string + manifest_path?: string +} + +interface PluginStats { + total_plugins: number + enabled_plugins: number + disabled_plugins: number +} + +export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { t } = useTranslation() + const [data, setData] = useState([]) + const [stats, setStats] = useState({ total_plugins: 0, enabled_plugins: 0, disabled_plugins: 0 }) + const [loading, setLoading] = useState(false) + const [installModalVisible, setInstallModalVisible] = useState(false) + const [installLoading, setInstallLoading] = useState(false) + const [selectedPath, setSelectedPath] = useState("") + const [messageApi, contextHolder] = message.useMessage() + const emitPluginsUpdated = (action: "install" | "uninstall" | "toggle", pluginId?: string) => { + window.dispatchEvent( + new CustomEvent("ss:plugins-updated", { + detail: { + source: "plugin-manager", + action, + pluginId, + }, + }) + ) + } + + const fetchPlugins = useCallback(async () => { + if (!(window as any).api) return + setLoading(true) + try { + const res = await (window as any).api.pluginGetList() + if (res.success && res.data) { + setData(res.data) + } + const statsRes = await (window as any).api.pluginGetStats() + if (statsRes.success && statsRes.data) { + setStats(statsRes.data) + } + } catch (e) { + console.error("Failed to fetch plugins:", e) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchPlugins() + }, [fetchPlugins]) + + const handleToggle = async (plugin: Plugin, enabled: boolean) => { + if (!(window as any).api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + try { + const res = await (window as any).api.pluginToggle(plugin.id, enabled) + if (res.success) { + messageApi.success(enabled ? t("plugin.enabled") : t("plugin.disabled")) + fetchPlugins() + emitPluginsUpdated("toggle", plugin.id) + } else { + messageApi.error(res.message || t("plugin.toggleFailed")) + } + } catch (e) { + console.error("Failed to toggle plugin:", e) + messageApi.error(t("plugin.toggleFailed")) + } + } + + const handleUninstall = async (pluginId: string) => { + if (!(window as any).api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + try { + const res = await (window as any).api.pluginUninstall(pluginId) + if (res.success) { + messageApi.success(t("plugin.uninstallSuccess")) + fetchPlugins() + emitPluginsUpdated("uninstall", pluginId) + } else { + messageApi.error(res.message || t("plugin.uninstallFailed")) + } + } catch (e) { + console.error("Failed to uninstall plugin:", e) + messageApi.error(t("plugin.uninstallFailed")) + } + } + + const handleInstall = async () => { + if (!selectedPath) { + messageApi.warning(t("plugin.selectFolder")) + return + } + setInstallLoading(true) + try { + const manifestRes = await (window as any).api.pluginLoadManifest(selectedPath) + if (!manifestRes.success) { + messageApi.error(manifestRes.message || t("plugin.invalidManifest")) + setInstallLoading(false) + return + } + + const installRes = await (window as any).api.pluginInstall(manifestRes.data, selectedPath) + if (installRes.success) { + messageApi.success(t("plugin.installSuccess")) + setInstallModalVisible(false) + setSelectedPath("") + fetchPlugins() + emitPluginsUpdated("install", installRes.data?.id) + } else { + messageApi.error(installRes.message || t("plugin.installFailed")) + } + } catch (e) { + console.error("Failed to install plugin:", e) + messageApi.error(t("plugin.installFailed")) + } finally { + setInstallLoading(false) + } + } + + const columns: ColumnsType = [ + { + title: t("plugin.name"), + dataIndex: "name", + key: "name", + width: 150, + render: (name: string, record: Plugin) => ( + + {name} + v{record.version} + + ), + }, + { + title: t("plugin.author"), + dataIndex: "author", + key: "author", + width: 120, + render: (author?: string) => author || "-", + }, + { + title: t("plugin.description"), + dataIndex: "description", + key: "description", + render: (desc?: string) => ( + + + {desc || "-"} + + + ), + }, + { + title: t("plugin.status"), + dataIndex: "enabled", + key: "enabled", + width: 100, + render: (enabled: boolean, record: Plugin) => ( + handleToggle(record, checked)} + disabled={!canEdit} + /> + ), + }, + { + title: t("common.operation"), + key: "operation", + width: 100, + render: (_, record: Plugin) => ( + handleUninstall(record.id)} + disabled={!canEdit} + okText={t("common.yes")} + cancelText={t("common.no")} + > + + + ), + }, + ] + + return ( +
+ {contextHolder} + + + + + {stats.total_plugins} + + + {stats.enabled_plugins} + + + {stats.disabled_plugins} + + + + +
+

{t("plugin.title")}

+ +
+ + + + { + setInstallModalVisible(false) + setSelectedPath("") + }} + onOk={handleInstall} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + confirmLoading={installLoading} + > +
+ + setSelectedPath(e.target.value)} + addonAfter={ + { + const path = (file as any).path || file.name + setSelectedPath(path) + return false + }} + > +