mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat: 插件系统 V1
This commit is contained in:
@@ -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`
|
||||||
@@ -10,6 +10,7 @@ pub mod http_server;
|
|||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod oauth_server;
|
pub mod oauth_server;
|
||||||
|
pub mod plugin;
|
||||||
pub mod reason;
|
pub mod reason;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod reward;
|
pub mod reward;
|
||||||
@@ -32,6 +33,7 @@ pub use http_server::*;
|
|||||||
pub use log::*;
|
pub use log::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use oauth_server::*;
|
pub use oauth_server::*;
|
||||||
|
pub use plugin::*;
|
||||||
pub use reason::*;
|
pub use reason::*;
|
||||||
pub use response::*;
|
pub use response::*;
|
||||||
pub use reward::*;
|
pub use reward::*;
|
||||||
|
|||||||
@@ -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<RwLock<AppState>>, sender_id: Option<u32>) -> 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<RwLock<AppState>>>) -> Result<IpcResponse<Vec<Plugin>>, 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<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Plugin>, 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<RwLock<AppState>>>) -> Result<IpcResponse<PluginStats>, 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<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<()>, 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<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Plugin>, 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<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<()>, 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<u32>,
|
||||||
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<PluginManifest>, 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<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<String>, 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<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Plugin> 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<RwLock<AppState>>>) -> Result<IpcResponse<Vec<PluginListItem>>, String> {
|
||||||
|
let state_guard = state.read();
|
||||||
|
let plugins = state_guard.plugins.read();
|
||||||
|
let list: Vec<PluginListItem> = (*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<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<Vec<PluginRuntimeModule>>, String> {
|
||||||
|
let state_guard = state.read();
|
||||||
|
let plugins = state_guard.plugins.read();
|
||||||
|
let modules = plugins.get_runtime_modules()?;
|
||||||
|
Ok(IpcResponse::success(modules))
|
||||||
|
}
|
||||||
@@ -106,6 +106,16 @@ pub fn run() {
|
|||||||
log_clear,
|
log_clear,
|
||||||
log_set_level,
|
log_set_level,
|
||||||
log_write,
|
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_export_json,
|
||||||
data_import_json,
|
data_import_json,
|
||||||
window_minimize,
|
window_minimize,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod auto_score;
|
|||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod logger;
|
pub mod logger;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod plugin;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
@@ -17,6 +18,7 @@ pub use auto_score::{
|
|||||||
pub use data::DataService;
|
pub use data::DataService;
|
||||||
pub use logger::LoggerService;
|
pub use logger::LoggerService;
|
||||||
pub use permission::{PermissionLevel, PermissionService};
|
pub use permission::{PermissionLevel, PermissionService};
|
||||||
|
pub use plugin::{Plugin, PluginManifest, PluginRuntimeModule, PluginService, PluginStats};
|
||||||
pub use security::SecurityService;
|
pub use security::SecurityService;
|
||||||
pub use settings::{SettingsKey, SettingsService, SettingsSpec, SettingsValue};
|
pub use settings::{SettingsKey, SettingsService, SettingsSpec, SettingsValue};
|
||||||
pub use theme::{ThemeConfig, ThemeService};
|
pub use theme::{ThemeConfig, ThemeService};
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub main: Option<String>,
|
||||||
|
pub assets: Option<Vec<String>>,
|
||||||
|
pub permissions: Option<Vec<String>>,
|
||||||
|
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<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub main: Option<String>,
|
||||||
|
pub permissions: Option<Vec<String>>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub installed_at: String,
|
||||||
|
pub manifest_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginManifest> 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<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub main: String,
|
||||||
|
pub code: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
plugin_dirs: HashMap<String, PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PluginManifest, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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<Plugin, String> {
|
||||||
|
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<Vec<PluginRuntimeModule>, 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<PluginManifest, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -6,8 +6,8 @@ use tauri::AppHandle;
|
|||||||
|
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
auth::AuthService, auto_score::AutoScoreService, data::DataService, logger::LoggerService,
|
auth::AuthService, auto_score::AutoScoreService, data::DataService, logger::LoggerService,
|
||||||
permission::PermissionService, security::SecurityService, settings::SettingsService,
|
permission::PermissionService, plugin::PluginService, security::SecurityService,
|
||||||
theme::ThemeService, SettingsKey, SettingsValue,
|
settings::SettingsService, theme::ThemeService, SettingsKey, SettingsValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -20,6 +20,7 @@ pub struct AppState {
|
|||||||
pub auto_score: Arc<RwLock<AutoScoreService>>,
|
pub auto_score: Arc<RwLock<AutoScoreService>>,
|
||||||
pub logger: Arc<RwLock<LoggerService>>,
|
pub logger: Arc<RwLock<LoggerService>>,
|
||||||
pub data: Arc<RwLock<DataService>>,
|
pub data: Arc<RwLock<DataService>>,
|
||||||
|
pub plugins: Arc<RwLock<PluginService>>,
|
||||||
pub http_client: Client,
|
pub http_client: Client,
|
||||||
pub app_handle: AppHandle,
|
pub app_handle: AppHandle,
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ impl AppState {
|
|||||||
let auto_score = Arc::new(RwLock::new(AutoScoreService::new()));
|
let auto_score = Arc::new(RwLock::new(AutoScoreService::new()));
|
||||||
let logger = Arc::new(RwLock::new(LoggerService::new()));
|
let logger = Arc::new(RwLock::new(LoggerService::new()));
|
||||||
let data = Arc::new(RwLock::new(DataService::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 db = Arc::new(RwLock::new(None));
|
||||||
|
|
||||||
let http_client = Client::builder()
|
let http_client = Client::builder()
|
||||||
@@ -51,6 +53,7 @@ impl AppState {
|
|||||||
auto_score,
|
auto_score,
|
||||||
logger,
|
logger,
|
||||||
data,
|
data,
|
||||||
|
plugins,
|
||||||
http_client,
|
http_client,
|
||||||
app_handle,
|
app_handle,
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,11 @@ impl AppState {
|
|||||||
auto_score.initialize(&self.app_handle).await?;
|
auto_score.initialize(&self.app_handle).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut plugins = self.plugins.write();
|
||||||
|
plugins.initialize(&self.app_handle)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -22,6 +22,7 @@ import {
|
|||||||
UpOutlined,
|
UpOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
|
CrownOutlined,
|
||||||
} from "@ant-design/icons"
|
} from "@ant-design/icons"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
|
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 { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||||
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
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_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
||||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
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 syncCheckingRef = useRef(false)
|
||||||
const syncApplyLoadingRef = useRef(false)
|
const syncApplyLoadingRef = useRef(false)
|
||||||
const lastLocalMutationAtRef = useRef(0)
|
const lastLocalMutationAtRef = useRef(0)
|
||||||
|
const pluginRuntimeRef = useRef(getPluginRuntime())
|
||||||
|
|
||||||
const activeMenu = useMemo(() => {
|
const activeMenu = useMemo(() => {
|
||||||
const p = location.pathname
|
const p = location.pathname
|
||||||
@@ -141,10 +144,37 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (p.startsWith("/reasons")) return "reasons"
|
if (p.startsWith("/reasons")) return "reasons"
|
||||||
if (p.startsWith("/auto-score")) return "auto-score"
|
if (p.startsWith("/auto-score")) return "auto-score"
|
||||||
if (p.startsWith("/reward-settings")) return "reward-settings"
|
if (p.startsWith("/reward-settings")) return "reward-settings"
|
||||||
|
if (p.startsWith("/plugins")) return "plugins"
|
||||||
if (p.startsWith("/settings")) return "settings"
|
if (p.startsWith("/settings")) return "settings"
|
||||||
return "home"
|
return "home"
|
||||||
}, [location.pathname])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const checkWizard = async () => {
|
const checkWizard = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
@@ -429,12 +459,13 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (key === "home") navigate("/")
|
if (key === "home") navigate("/")
|
||||||
if (key === "students") navigate("/students")
|
if (key === "students") navigate("/students")
|
||||||
if (key === "score") navigate("/score")
|
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 === "boards") navigate("/boards")
|
||||||
if (key === "leaderboard") navigate("/leaderboard")
|
if (key === "leaderboard") navigate("/leaderboard")
|
||||||
if (key === "settlements") navigate("/settlements")
|
if (key === "settlements") navigate("/settlements")
|
||||||
if (key === "reasons") navigate("/reasons")
|
if (key === "reasons") navigate("/reasons")
|
||||||
if (key === "auto-score") navigate("/auto-score")
|
if (key === "plugins") navigate("/plugins")
|
||||||
if (key === "reward-settings") navigate("/reward-settings")
|
|
||||||
if (key === "settings") navigate("/settings")
|
if (key === "settings") navigate("/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +512,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
||||||
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
|
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
|
||||||
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
||||||
|
plugins: <CrownOutlined style={{ fontSize: "18px" }} />,
|
||||||
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
message,
|
message,
|
||||||
} from "antd"
|
} from "antd"
|
||||||
|
import { InfoCircleOutlined } from "@ant-design/icons"
|
||||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||||
import type { ColumnsType } from "antd/es/table"
|
import type { ColumnsType } from "antd/es/table"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
@@ -855,7 +856,18 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
>
|
>
|
||||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t("autoScore.startAt")} extra={intervalElapsedHint}>
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span>{t("autoScore.startAt")}</span>
|
||||||
|
<Tooltip title={intervalElapsedHint} trigger={["hover", "click"]}>
|
||||||
|
<InfoCircleOutlined
|
||||||
|
style={{ color: "var(--ss-text-secondary)", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
showTime
|
showTime
|
||||||
allowClear
|
allowClear
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const loadLeaderboard = () => import("./Leaderboard")
|
|||||||
const loadSettlementHistory = () => import("./SettlementHistory")
|
const loadSettlementHistory = () => import("./SettlementHistory")
|
||||||
const loadRewardSettings = () => import("./RewardSettings")
|
const loadRewardSettings = () => import("./RewardSettings")
|
||||||
const loadBoardManager = () => import("./BoardManager")
|
const loadBoardManager = () => import("./BoardManager")
|
||||||
|
const loadPluginManager = () => import("./PluginManager")
|
||||||
|
|
||||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
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 RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings })))
|
||||||
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
||||||
|
const PluginManager = lazy(() => loadPluginManager().then((m) => ({ default: m.PluginManager })))
|
||||||
|
|
||||||
const warmupRouteChunks = () =>
|
const warmupRouteChunks = () =>
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
@@ -48,6 +50,7 @@ const warmupRouteChunks = () =>
|
|||||||
loadSettlementHistory(),
|
loadSettlementHistory(),
|
||||||
loadRewardSettings(),
|
loadRewardSettings(),
|
||||||
loadBoardManager(),
|
loadBoardManager(),
|
||||||
|
loadPluginManager(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const { Content } = Layout
|
const { Content } = Layout
|
||||||
@@ -110,6 +113,7 @@ export function ContentArea({
|
|||||||
if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements")
|
if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements")
|
||||||
if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons")
|
if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons")
|
||||||
if (normalizedPath.startsWith("/reward-settings")) return t("sidebar.rewardSettings")
|
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")
|
if (normalizedPath.startsWith("/settings")) return t("sidebar.settings")
|
||||||
return "SecScore"
|
return "SecScore"
|
||||||
})()
|
})()
|
||||||
@@ -380,6 +384,7 @@ export function ContentArea({
|
|||||||
path="/reward-settings"
|
path="/reward-settings"
|
||||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/plugins" element={<PluginManager canEdit={permission === "admin"} />} />
|
||||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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<Plugin[]>([])
|
||||||
|
const [stats, setStats] = useState<PluginStats>({ 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<string>("")
|
||||||
|
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<Plugin> = [
|
||||||
|
{
|
||||||
|
title: t("plugin.name"),
|
||||||
|
dataIndex: "name",
|
||||||
|
key: "name",
|
||||||
|
width: 150,
|
||||||
|
render: (name: string, record: Plugin) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>v{record.version}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<Tooltip title={desc}>
|
||||||
|
<span style={{ maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>
|
||||||
|
{desc || "-"}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("plugin.status"),
|
||||||
|
dataIndex: "enabled",
|
||||||
|
key: "enabled",
|
||||||
|
width: 100,
|
||||||
|
render: (enabled: boolean, record: Plugin) => (
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(checked) => handleToggle(record, checked)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("common.operation"),
|
||||||
|
key: "operation",
|
||||||
|
width: 100,
|
||||||
|
render: (_, record: Plugin) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={t("plugin.uninstallConfirm")}
|
||||||
|
description={t("plugin.uninstallDescription")}
|
||||||
|
onConfirm={() => handleUninstall(record.id)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
okText={t("common.yes")}
|
||||||
|
cancelText={t("common.no")}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} disabled={!canEdit}>
|
||||||
|
{t("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px" }}>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions size="small" column={3}>
|
||||||
|
<Descriptions.Item label={t("plugin.totalPlugins")}>
|
||||||
|
<Tag color="blue">{stats.total_plugins}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t("plugin.enabledPlugins")}>
|
||||||
|
<Tag color="success">{stats.enabled_plugins}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={t("plugin.disabledPlugins")}>
|
||||||
|
<Tag color="default">{stats.disabled_plugins}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<FolderOpenOutlined />}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => setInstallModalVisible(true)}
|
||||||
|
>
|
||||||
|
{t("plugin.install")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
locale={{ emptyText: t("plugin.noPlugins") }}
|
||||||
|
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("plugin.install")}
|
||||||
|
open={installModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setInstallModalVisible(false)
|
||||||
|
setSelectedPath("")
|
||||||
|
}}
|
||||||
|
onOk={handleInstall}
|
||||||
|
okText={t("common.confirm")}
|
||||||
|
cancelText={t("common.cancel")}
|
||||||
|
confirmLoading={installLoading}
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label={t("plugin.pluginFolder")}>
|
||||||
|
<Input
|
||||||
|
placeholder={t("plugin.folderPlaceholder")}
|
||||||
|
value={selectedPath}
|
||||||
|
onChange={(e) => setSelectedPath(e.target.value)}
|
||||||
|
addonAfter={
|
||||||
|
<Upload
|
||||||
|
showUploadList={false}
|
||||||
|
directory
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const path = (file as any).path || file.name
|
||||||
|
setSelectedPath(path)
|
||||||
|
return false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" icon={<UploadOutlined />} />
|
||||||
|
</Upload>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||||
|
{t("plugin.installHint")}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
AppstoreAddOutlined,
|
AppstoreAddOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
|
CrownOutlined,
|
||||||
} from "@ant-design/icons"
|
} from "@ant-design/icons"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -196,6 +197,12 @@ export function Sidebar({
|
|||||||
label: t("sidebar.reasons"),
|
label: t("sidebar.reasons"),
|
||||||
disabled: permission !== "admin",
|
disabled: permission !== "admin",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "plugins",
|
||||||
|
icon: <CrownOutlined />,
|
||||||
|
label: t("sidebar.plugins"),
|
||||||
|
disabled: permission !== "admin",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "settings",
|
key: "settings",
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
|
|||||||
@@ -369,7 +369,8 @@
|
|||||||
"syncFailed": "Sync failed",
|
"syncFailed": "Sync failed",
|
||||||
"getDbStatusFailed": "Failed to get database status",
|
"getDbStatusFailed": "Failed to get database status",
|
||||||
"notRemoteMode": "Not in remote database mode, please restart application",
|
"notRemoteMode": "Not in remote database mode, please restart application",
|
||||||
"dbNotConnected": "Database not connected"
|
"dbNotConnected": "Database not connected",
|
||||||
|
"plugins": "Plugins"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"unlock": "Unlock Permission",
|
"unlock": "Unlock Permission",
|
||||||
@@ -947,5 +948,35 @@
|
|||||||
"export": "Export Text File",
|
"export": "Export Text File",
|
||||||
"exportHint": "It is recommended to export and store offline. If lost, it cannot be recovered.",
|
"exportHint": "It is recommended to export and store offline. If lost, it cannot be recovered.",
|
||||||
"hint": "Recommended to save offline after export, cannot be recovered if lost."
|
"hint": "Recommended to save offline after export, cannot be recovered if lost."
|
||||||
|
},
|
||||||
|
"plugin": {
|
||||||
|
"title": "Plugin Manager",
|
||||||
|
"name": "Plugin Name",
|
||||||
|
"author": "Author",
|
||||||
|
"description": "Description",
|
||||||
|
"version": "Version",
|
||||||
|
"status": "Status",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"operation": "Operation",
|
||||||
|
"install": "Install Plugin",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"uninstallConfirm": "Confirm uninstall plugin?",
|
||||||
|
"uninstallDescription": "Uninstall removes the plugin copy installed inside the app plugin directory.",
|
||||||
|
"installSuccess": "Plugin installed successfully",
|
||||||
|
"installFailed": "Plugin installation failed",
|
||||||
|
"uninstallSuccess": "Plugin uninstalled successfully",
|
||||||
|
"uninstallFailed": "Plugin uninstallation failed",
|
||||||
|
"toggleFailed": "Failed to toggle plugin status",
|
||||||
|
"invalidManifest": "Invalid plugin manifest file",
|
||||||
|
"pluginFolder": "Plugin Folder",
|
||||||
|
"folderPlaceholder": "Select folder containing manifest.json",
|
||||||
|
"installHint": "Select a folder containing manifest.json to install the plugin.",
|
||||||
|
"selectFolder": "Please select plugin folder",
|
||||||
|
"totalPlugins": "Total Plugins",
|
||||||
|
"enabledPlugins": "Enabled",
|
||||||
|
"disabledPlugins": "Disabled",
|
||||||
|
"noPlugins": "No plugins installed",
|
||||||
|
"installedAt": "Installed At"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,8 @@
|
|||||||
"syncFailed": "同步失败",
|
"syncFailed": "同步失败",
|
||||||
"getDbStatusFailed": "获取数据库状态失败",
|
"getDbStatusFailed": "获取数据库状态失败",
|
||||||
"notRemoteMode": "当前不是远程数据库模式,请重启应用后重试",
|
"notRemoteMode": "当前不是远程数据库模式,请重启应用后重试",
|
||||||
"dbNotConnected": "数据库未连接"
|
"dbNotConnected": "数据库未连接",
|
||||||
|
"plugins": "插件管理"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"unlock": "权限解锁",
|
"unlock": "权限解锁",
|
||||||
@@ -937,5 +938,35 @@
|
|||||||
"export": "导出文本文件",
|
"export": "导出文本文件",
|
||||||
"exportHint": "建议导出后离线保存,遗失将无法找回。",
|
"exportHint": "建议导出后离线保存,遗失将无法找回。",
|
||||||
"hint": "建议导出后离线保存,遗失将无法找回。"
|
"hint": "建议导出后离线保存,遗失将无法找回。"
|
||||||
|
},
|
||||||
|
"plugin": {
|
||||||
|
"title": "插件管理",
|
||||||
|
"name": "插件名称",
|
||||||
|
"author": "作者",
|
||||||
|
"description": "描述",
|
||||||
|
"version": "版本",
|
||||||
|
"status": "状态",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"operation": "操作",
|
||||||
|
"install": "安装插件",
|
||||||
|
"uninstall": "卸载",
|
||||||
|
"uninstallConfirm": "确认卸载插件?",
|
||||||
|
"uninstallDescription": "卸载后会移除已安装到应用目录中的插件副本。",
|
||||||
|
"installSuccess": "插件安装成功",
|
||||||
|
"installFailed": "插件安装失败",
|
||||||
|
"uninstallSuccess": "插件卸载成功",
|
||||||
|
"uninstallFailed": "插件卸载失败",
|
||||||
|
"toggleFailed": "切换插件状态失败",
|
||||||
|
"invalidManifest": "无效的插件清单文件",
|
||||||
|
"pluginFolder": "插件文件夹",
|
||||||
|
"folderPlaceholder": "选择包含 manifest.json 的文件夹",
|
||||||
|
"installHint": "选择一个包含 manifest.json 的文件夹来安装插件。",
|
||||||
|
"selectFolder": "请选择插件文件夹",
|
||||||
|
"totalPlugins": "插件总数",
|
||||||
|
"enabledPlugins": "已启用",
|
||||||
|
"disabledPlugins": "已禁用",
|
||||||
|
"noPlugins": "暂无已安装的插件",
|
||||||
|
"installedAt": "安装时间"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { pluginRuntimeModule } from "../preload/types"
|
||||||
|
|
||||||
|
type PluginHostEvent = "data-updated" | "route-changed" | "plugins-updated"
|
||||||
|
type PluginCleanup = (() => void | Promise<void>) | void
|
||||||
|
type PluginSetup = (context: PluginContext) => PluginCleanup | Promise<PluginCleanup>
|
||||||
|
|
||||||
|
interface PluginEntryModule {
|
||||||
|
setup?: PluginSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
id: string
|
||||||
|
moduleUrl: string
|
||||||
|
cleanup?: () => void | Promise<void>
|
||||||
|
disposers: Array<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_EVENT_MAP: Record<PluginHostEvent, string> = {
|
||||||
|
"data-updated": "ss:data-updated",
|
||||||
|
"route-changed": "ss:route-changed",
|
||||||
|
"plugins-updated": "ss:plugins-updated",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
permissions: string[]
|
||||||
|
api: any
|
||||||
|
on: (event: PluginHostEvent, handler: (detail: any) => void) => () => void
|
||||||
|
log: (message: string, meta?: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginRuntime {
|
||||||
|
private loadedPlugins: LoadedPlugin[] = []
|
||||||
|
private started = false
|
||||||
|
private loading = false
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
await this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.started = false
|
||||||
|
await this.unloadAllPlugins()
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
if (this.loading) return
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.unloadAllPlugins()
|
||||||
|
if (!this.started) return
|
||||||
|
await this.loadEnabledPlugins()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadEnabledPlugins(): Promise<void> {
|
||||||
|
const api = (window as any).api
|
||||||
|
if (!api?.pluginGetRuntimeModules) return
|
||||||
|
|
||||||
|
let response:
|
||||||
|
| {
|
||||||
|
success: boolean
|
||||||
|
data?: pluginRuntimeModule[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
try {
|
||||||
|
response = await api.pluginGetRuntimeModules()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch runtime plugins:", error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response?.success || !Array.isArray(response.data)) {
|
||||||
|
if (response?.message) {
|
||||||
|
console.warn("Plugin runtime response failed:", response.message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const runtimeModule of response.data) {
|
||||||
|
await this.loadSinglePlugin(runtimeModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSinglePlugin(runtimeModule: pluginRuntimeModule): Promise<void> {
|
||||||
|
const sourceWithTrace = `${runtimeModule.code}\n//# sourceURL=secscore-plugin:${runtimeModule.id}/${runtimeModule.main}\n`
|
||||||
|
const moduleUrl = URL.createObjectURL(
|
||||||
|
new Blob([sourceWithTrace], {
|
||||||
|
type: "text/javascript",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const disposers: Array<() => void> = []
|
||||||
|
let cleanup: (() => void | Promise<void>) | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedModule = await import(/* @vite-ignore */ moduleUrl)
|
||||||
|
const pluginModule = this.normalizeModule(importedModule)
|
||||||
|
if (!pluginModule?.setup) {
|
||||||
|
console.warn(`Plugin ${runtimeModule.id} has no setup() and was skipped`)
|
||||||
|
URL.revokeObjectURL(moduleUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.createContext(runtimeModule, disposers)
|
||||||
|
const setupResult = await pluginModule.setup(context)
|
||||||
|
if (typeof setupResult === "function") {
|
||||||
|
cleanup = setupResult
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadedPlugins.push({
|
||||||
|
id: runtimeModule.id,
|
||||||
|
moduleUrl,
|
||||||
|
cleanup,
|
||||||
|
disposers,
|
||||||
|
})
|
||||||
|
console.info(`Plugin loaded: ${runtimeModule.id}@${runtimeModule.version}`)
|
||||||
|
} catch (error) {
|
||||||
|
for (const dispose of [...disposers].reverse()) {
|
||||||
|
try {
|
||||||
|
dispose()
|
||||||
|
} catch {
|
||||||
|
void 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(moduleUrl)
|
||||||
|
console.error(`Failed to load plugin ${runtimeModule.id}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeModule(moduleValue: unknown): PluginEntryModule | null {
|
||||||
|
if (!moduleValue || typeof moduleValue !== "object") return null
|
||||||
|
const moduleRecord = moduleValue as Record<string, unknown>
|
||||||
|
|
||||||
|
if (typeof moduleRecord.setup === "function") {
|
||||||
|
return { setup: moduleRecord.setup as PluginSetup }
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultExport = moduleRecord.default as unknown
|
||||||
|
if (typeof defaultExport === "function") {
|
||||||
|
return { setup: defaultExport as PluginSetup }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
defaultExport &&
|
||||||
|
typeof defaultExport === "object" &&
|
||||||
|
typeof (defaultExport as Record<string, unknown>).setup === "function"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
setup: ((defaultExport as Record<string, unknown>).setup as PluginSetup).bind(defaultExport),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginExport = moduleRecord.plugin as unknown
|
||||||
|
if (
|
||||||
|
pluginExport &&
|
||||||
|
typeof pluginExport === "object" &&
|
||||||
|
typeof (pluginExport as Record<string, unknown>).setup === "function"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
setup: ((pluginExport as Record<string, unknown>).setup as PluginSetup).bind(pluginExport),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private createContext(
|
||||||
|
runtimeModule: pluginRuntimeModule,
|
||||||
|
disposers: Array<() => void>
|
||||||
|
): PluginContext {
|
||||||
|
const registerEvent = (event: PluginHostEvent, handler: (detail: any) => void): (() => void) => {
|
||||||
|
const eventName = PLUGIN_EVENT_MAP[event]
|
||||||
|
const listener = (nativeEvent: Event) => {
|
||||||
|
handler((nativeEvent as CustomEvent<any>)?.detail)
|
||||||
|
}
|
||||||
|
window.addEventListener(eventName, listener)
|
||||||
|
const dispose = () => {
|
||||||
|
window.removeEventListener(eventName, listener)
|
||||||
|
}
|
||||||
|
disposers.push(dispose)
|
||||||
|
return () => {
|
||||||
|
dispose()
|
||||||
|
const index = disposers.indexOf(dispose)
|
||||||
|
if (index >= 0) {
|
||||||
|
disposers.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: runtimeModule.id,
|
||||||
|
name: runtimeModule.name,
|
||||||
|
version: runtimeModule.version,
|
||||||
|
permissions: runtimeModule.permissions || [],
|
||||||
|
api: (window as any).api,
|
||||||
|
on: registerEvent,
|
||||||
|
log: (message: string, meta?: unknown) => {
|
||||||
|
if (meta === undefined) {
|
||||||
|
console.log(`[Plugin:${runtimeModule.id}] ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[Plugin:${runtimeModule.id}] ${message}`, meta)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unloadAllPlugins(): Promise<void> {
|
||||||
|
for (const plugin of [...this.loadedPlugins].reverse()) {
|
||||||
|
if (plugin.cleanup) {
|
||||||
|
try {
|
||||||
|
await plugin.cleanup()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Plugin cleanup failed: ${plugin.id}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dispose of [...plugin.disposers].reverse()) {
|
||||||
|
try {
|
||||||
|
dispose()
|
||||||
|
} catch {
|
||||||
|
void 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(plugin.moduleUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadedPlugins = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeInstance: PluginRuntime | null = null
|
||||||
|
|
||||||
|
export const getPluginRuntime = (): PluginRuntime => {
|
||||||
|
if (!runtimeInstance) {
|
||||||
|
runtimeInstance = new PluginRuntime()
|
||||||
|
}
|
||||||
|
return runtimeInstance
|
||||||
|
}
|
||||||
@@ -118,6 +118,17 @@ export interface settingsSpec {
|
|||||||
mobile_bottom_nav_items: string[]
|
mobile_bottom_nav_items: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface pluginRuntimeModule {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description?: string | null
|
||||||
|
author?: string | null
|
||||||
|
main: string
|
||||||
|
code: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
// Theme
|
// Theme
|
||||||
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
|
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
|
||||||
@@ -701,6 +712,60 @@ const api = {
|
|||||||
appQuit: (): Promise<void> => invoke("app_quit"),
|
appQuit: (): Promise<void> => invoke("app_quit"),
|
||||||
appRestart: (): Promise<void> => invoke("app_restart"),
|
appRestart: (): Promise<void> => invoke("app_restart"),
|
||||||
|
|
||||||
|
// Plugin
|
||||||
|
pluginGetAll: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: any[]
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get_all"),
|
||||||
|
pluginGet: (pluginId: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: any
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get", { pluginId }),
|
||||||
|
pluginGetStats: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
total_plugins: number
|
||||||
|
enabled_plugins: number
|
||||||
|
disabled_plugins: number
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get_stats"),
|
||||||
|
pluginToggle: (pluginId: string, enabled: boolean): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_toggle", { pluginId, enabled }),
|
||||||
|
pluginInstall: (manifest: any, pluginDir: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: any
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_install", { manifest, pluginDir }),
|
||||||
|
pluginUninstall: (pluginId: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_uninstall", { pluginId }),
|
||||||
|
pluginLoadManifest: (path: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: any
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_load_manifest", { path }),
|
||||||
|
pluginGetDir: (pluginId: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: string
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get_dir", { pluginId }),
|
||||||
|
pluginGetList: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: any[]
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get_list"),
|
||||||
|
pluginGetRuntimeModules: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: pluginRuntimeModule[]
|
||||||
|
message?: string
|
||||||
|
}> => invoke("plugin_get_runtime_modules"),
|
||||||
|
|
||||||
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
||||||
invoke: async (channel: string, ..._args: any[]): Promise<any> => {
|
invoke: async (channel: string, ..._args: any[]): Promise<any> => {
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const MOBILE_NAV_ALL_KEYS = [
|
|||||||
"leaderboard",
|
"leaderboard",
|
||||||
"settlements",
|
"settlements",
|
||||||
"reasons",
|
"reasons",
|
||||||
|
"plugins",
|
||||||
"settings",
|
"settings",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export const MOBILE_NAV_ITEMS: MobileNavItemConfig[] = [
|
|||||||
{ key: "leaderboard", path: "/leaderboard", labelKey: "sidebar.leaderboard" },
|
{ key: "leaderboard", path: "/leaderboard", labelKey: "sidebar.leaderboard" },
|
||||||
{ key: "settlements", path: "/settlements", labelKey: "sidebar.settlements" },
|
{ key: "settlements", path: "/settlements", labelKey: "sidebar.settlements" },
|
||||||
{ key: "reasons", path: "/reasons", labelKey: "sidebar.reasons", adminOnly: true },
|
{ key: "reasons", path: "/reasons", labelKey: "sidebar.reasons", adminOnly: true },
|
||||||
|
{ key: "plugins", path: "/plugins", labelKey: "sidebar.plugins", adminOnly: true },
|
||||||
{ key: "settings", path: "/settings", labelKey: "sidebar.settings" },
|
{ key: "settings", path: "/settings", labelKey: "sidebar.settings" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user